From b124d757b0d10d92ab84f31ab589b7d86e0c01e5 Mon Sep 17 00:00:00 2001 From: wangyunlong <3080433063@qq.com> Date: Fri, 4 Sep 2026 09:49:37 +0800 Subject: [PATCH] feat: expand module workflows, dynamic config, notifications and console API --- .gitignore | 6 + AGENTS.md | 57 +- CLAUDE.md | 73 + README.md | 2 +- config/commands.json | 432 +- config/notification-routing.json | 223 + config/product-monthly-sales.json | 4 - config/runtime-bindings.json | 2191 ++++- config/schedules.json | 99 +- config/secrets.example.env | 16 + .../source-manifests/content_marketing.json | 136 +- config/source-manifests/product_commerce.json | 86 +- .../source-manifests/shop_intelligence.json | 14 +- config/source-manifests/supply_chain.json | 26 +- config/tmall-baibu.json | 62 + config/tmall-video-publication-backfill.json | 36 + config/workflow-dynamic-config-seed.json | 5508 +++++++++++++ config/workflows.json | 2289 +++++- docs/acceptance-report.md | 9 +- docs/architecture.md | 31 +- docs/deployment.md | 59 +- docs/dynamic-workflow-config.md | 82 + docs/note-inventory.md | 89 + docs/runbook.md | 126 +- docs/workflow-acceptance-test-report.md | 5 +- docs/workflow-acceptance-test-requirements.md | 8 +- docs/workflow-console.md | 82 +- pyproject.toml | 8 +- requirements.txt | 172 +- src/gyxx_flow/acceptance.py | 8 +- src/gyxx_flow/accounts.py | 860 ++ src/gyxx_flow/adapters/__init__.py | 45 + src/gyxx_flow/adapters/acceptance_policy.py | 36 +- src/gyxx_flow/adapters/bootstrap.py | 6 + src/gyxx_flow/adapters/browser.py | 62 + src/gyxx_flow/adapters/direct_content_llm.py | 424 + src/gyxx_flow/adapters/erp_state.py | 155 + src/gyxx_flow/adapters/feishu_messages.py | 255 + src/gyxx_flow/adapters/integration.py | 594 +- src/gyxx_flow/adapters/native.py | 37 + src/gyxx_flow/adapters/scrapling.py | 343 + src/gyxx_flow/adapters/workflow_config.py | 1074 +++ src/gyxx_flow/catalog.py | 53 +- src/gyxx_flow/cli.py | 182 +- src/gyxx_flow/console.py | 1787 ++++- src/gyxx_flow/core/exit_codes.py | 10 +- src/gyxx_flow/daily_summary.py | 835 ++ src/gyxx_flow/diagnostics.py | 7 +- .../migration/feishu_style_config_seed.py | 165 + src/gyxx_flow/modules/__init__.py | 5 +- .../modules/content_marketing/__init__.py | 30 +- .../bilibili_comment_scraper.py | 3 +- .../content_marketing/bilibili_scraper.py | 282 +- .../content_marketing/chanmama_scraper.py | 708 +- .../collection_completeness.py | 19 +- .../daily_marketing_report.py | 137 +- .../data/config/analyze.env.example | 11 +- .../data/config/款式_多维表格_对照.json | 117 +- .../data/config/款式_多维表格_对照_自营.json | 28 +- .../data/tools/analyze_comments.py | 46 +- .../data/tools/analyze_note.py | 143 +- .../data/tools/batch_rescrape_bilibili.py | 2 +- .../data/tools/batch_rescrape_douyin.py | 132 +- .../data/tools/batch_rescrape_xiaohongshu.py | 99 +- .../data/tools/collect_note_metrics.py | 2 +- .../data/tools/comment_batch_checkpoint.py | 109 + .../data/tools/daily_dashboard_analytics.py | 10 +- .../data/tools/daily_report_card.py | 396 +- .../content_marketing/data/tools/db.py | 58 +- .../data/tools/feishu_comment_batch.py | 53 +- .../data/tools/friday_relogin_parallel.py | 322 +- .../data/tools/generate_creator_report.py | 283 +- .../tools/migrations/006_note_inventory.sql | 38 + .../tools/migrations/007_notes_master.sql | 274 + .../migrations/008_note_metric_snapshots.sql | 32 + .../data/tools/relogin_bilibili.py | 42 +- .../data/tools/relogin_douyin.py | 40 +- .../data/tools/relogin_pgy.py | 60 +- .../data/tools/relogin_runtime_paths.py | 279 + .../data/tools/relogin_xiaohongshu.py | 42 +- .../data/tools/relogin_xingtu.py | 77 +- .../data/tools/retry_failed.py | 23 +- .../data/tools/schema_gyxx_super_data.sql | 223 +- .../data/tools/sync_cooperations.py | 461 -- .../data/tools/sync_metrics_to_cmt_notes.py | 1260 ++- .../data/tools/sync_notes_master.py | 627 ++ .../douyin_comment_scraper.py | 226 +- .../content_marketing/feishu_doc_import.py | 794 ++ .../content_marketing/feishu_mapping.py | 89 +- .../modules/content_marketing/login_helper.py | 3 +- .../content_marketing/monthly_summary_all.py | 371 +- .../content_marketing/pgy_xhs_scraper.py | 24 +- .../content_marketing/pgy_xhs_scraper_v2.py | 650 +- .../content_marketing/requirements.txt | 3 +- .../modules/content_marketing/run_all.py | 437 +- .../scrapling_browser_facade.py | 285 + .../content_marketing/scrapling_http.py | 61 + .../self_bilibili_scraper.py | 34 +- .../content_marketing/self_douyin_scraper.py | 3 +- .../serialized_page_action.py | 100 + .../content_marketing/weekly_summary_all.py | 470 +- .../xiaohongshu_comment_scraper.py | 215 +- .../content_marketing/xingtu_scraper.py | 23 +- .../content_marketing/xingtu_scraper_v2.py | 2210 +++++- .../modules/product_commerce/.env.example | 27 + .../modules/product_commerce/__init__.py | 137 +- ..._style_with_hermes.py => analyze_style.py} | 592 +- .../backfill_erp_all_shop_daily.py | 726 ++ .../check_nine_day_decline.py | 446 +- .../collect_douyin_qianchuan_ads.py | 424 + .../collect_dy_market_rank.py | 45 +- .../collect_dy_persona_to_bitable.py | 5 +- .../collect_erp_yesterday_metrics.py | 19 +- .../product_commerce/collect_jd_ad_costs.py | 1537 ++++ .../collect_jd_market_rank.py | 266 +- .../collect_jd_persona_to_bitable.py | 155 +- .../collect_persona_to_bitable.py | 10 +- .../collect_sycm_market_rank.py | 27 + .../collect_tmall_wanxiang_ads.py | 1208 +++ .../product_commerce/commands/run_alerts.py | 22 +- .../config/style_config_loader.py | 293 +- .../modules/product_commerce/db/__init__.py | 706 +- .../modules/product_commerce/db/schema.sql | 308 +- .../product_commerce/db/sync_sku_master.py | 80 +- .../modules/product_commerce/direct_llm.py | 377 + .../product_commerce/direct_visual_color.py | 275 + .../dy_audience_profile_collect.py | 28 +- .../product_commerce/dy_product_scraping.py | 44 +- .../erp_login_product_analysis.py | 164 +- .../import_douyin_qianchuan_ads.py | 258 + .../product_commerce/import_jd_ad_costs.py | 875 ++ .../product_commerce/import_product_daily.py | 48 +- .../import_tmall_product_ads.py | 336 + .../jd_main_image_collector.py | 92 +- .../jd_product_data_collector.py | 23 +- .../jd_self_inventory_sales_collector.py | 33 +- .../product_commerce/jd_video_frames.py | 202 + .../product_commerce/jd_video_metadata.py | 1592 ++++ .../jd_video_publication_db.py | 1118 +++ .../market_rank_hermes_notification.py | 157 +- .../orchestrate_daily_collection.py | 257 +- .../orchestrate_market_rank_collection.py | 94 +- .../product_commerce/run_alerts_with_retry.py | 27 +- .../product_commerce/run_daily_persona.py | 64 +- .../run_weekly_jd_main_image.py | 6 +- .../product_commerce/run_weekly_main_image.py | 6 +- .../modules/product_commerce/runtime_paths.py | 21 +- .../sync_monthly_sales_sheet.py | 57 +- ...obao_dmp_item_crowd_insight_screenshots.py | 92 +- .../product_commerce/taobao_sycm_collect.py | 303 +- .../taobao_sycm_collect_backfill.py | 74 +- .../product_commerce/taobao_sycm_products.py | 542 +- .../taobao_wanxiang_ai_creative_report.py | 226 +- .../tmall_baibu_apply_common.py | 1455 ++++ .../tmall_baibu_apply_new_all_bag.py | 6 + .../tmall_baibu_apply_new_chain_bag.py | 6 + .../tmall_baibu_apply_old_all_3c.py | 6 + .../tmall_baibu_apply_old_all_bag.py | 6 + .../tmall_baibu_apply_old_chain_3c.py | 6 + .../tmall_baibu_apply_old_chain_bag.py | 6 + .../upload_video_to_guanghe.py | 1897 ++++- .../product_commerce/upload_video_to_jd.py | 5492 +++++++++++++ .../playwright_selectors.json | 80 + .../vendors/jd-data-flow/jd_data_collector.py | 104 +- .../jd_peer_product_data_collector.py | 47 +- .../jd-data-flow/jd_product_data_collector.py | 50 +- .../product_commerce/visual_color_common.py | 389 + .../dy_store_competitor_store_scraping.py | 53 +- .../collectors/jd_data_collector.py | 98 +- .../jd_peer_store_data_collector.py | 250 +- .../jd_self_operated_brand_daily.py | 50 +- .../jd_self_operated_product_daily.py | 44 +- .../shop_intelligence/dependencies.toml | 3 +- .../shop_intelligence/douyin_price_appeal.py | 157 +- .../modules/supply_chain/__init__.py | 1 + .../modules/supply_chain/config.example.env | 5 + .../modules/supply_chain/dependencies.txt | 4 +- .../orchestrator/arrival_sync_queue.py | 243 + .../supply_chain/orchestrator/config.py | 15 +- .../supply_chain/orchestrator/mcp_workflow.py | 1300 ++- .../supply_chain/orchestrator/pg_writer.py | 167 +- .../supply_chain/orchestrator/runner.py | 10 +- .../scripts/ProductReplenishment.py | 111 +- .../scripts/PurchaseConfirmation.py | 125 +- .../scripts/PurchaseOrderUpdate.py | 265 +- .../scripts/collect_confirmation.ps1 | 2 +- .../scripts/collect_purchase_order_update.ps1 | 2 +- .../scripts/collect_replenishment.ps1 | 2 +- .../collect_replenishment_arrival_sync.ps1 | 129 + .../collect_replenishment_arrival_sync.py | 148 + .../scripts/send_card_notification.py | 296 +- .../scripts/sync_replenishment_arrivals.py | 271 + src/gyxx_flow/notification_routing.py | 657 ++ src/gyxx_flow/ops/effects.py | 17 + src/gyxx_flow/scheduler_service.py | 47 +- src/gyxx_flow/script_catalog.py | 13 + src/gyxx_flow/web/app.css | 4561 ++++++++++- src/gyxx_flow/web/app.js | 2144 ++++- src/gyxx_flow/web/index.html | 354 +- src/gyxx_flow/workflow/engine.py | 16 +- src/gyxx_flow/workflow/graph.py | 105 +- src/gyxx_flow/workflow/model.py | 8 +- src/gyxx_flow/workflow/steps.py | 23 +- .../test_bilibili_play_count_retry.py | 339 + .../test_buyin_metric_snapshots.py | 346 + .../test_chanmama_refresh_before_export.py | 429 +- .../test_collection_completeness.py | 614 +- .../test_comment_batch_recovery.py | 272 + ...st_content_acceptance_feishu_boundaries.py | 53 +- .../test_creator_report_exit_status.py | 596 ++ .../test_daily_marketing_report.py | 62 + .../test_daily_report_card.py | 190 +- .../test_direct_content_llm.py | 99 + .../test_douyin_comment_deadline.py | 162 + .../test_feishu_doc_import.py | 399 + .../test_friday_relogin_parallel.py | 432 + .../test_global_creator_scraping.py | 85 +- .../test_notes_master_sync.py | 194 + .../test_relogin_rollback.py | 137 +- .../test_relogin_runtime_bindings.py | 410 + .../test_remote_database_config.py | 30 +- .../test_retry_failed_completeness.py | 164 + .../test_summary_base_delivery.py | 110 + .../test_sync_metrics_identity.py | 1270 +++ .../test_weekly_tmall_persona.py | 28 +- .../test_xiaohongshu_comment_deadline.py | 108 + .../product_commerce/test_alert_command.py | 55 +- .../product_commerce/test_alert_integrity.py | 274 +- .../test_browser_profile_reuse.py | 230 +- .../test_collect_douyin_qianchuan_ads.py | 92 + .../test_collect_jd_ad_costs.py | 175 + .../test_collect_tmall_wanxiang_ads.py | 180 + .../test_daily_result_integrity.py | 250 + .../product_commerce/test_db_get_conn.py | 133 + .../product_commerce/test_direct_llm.py | 183 + .../test_direct_visual_color.py | 154 + .../test_direct_visual_color_contract.py | 524 ++ .../product_commerce/test_dy_market_rank.py | 22 + .../test_erp_all_shop_daily_backfill.py | 191 + .../product_commerce/test_guanghe_metadata.py | 178 +- .../test_import_douyin_qianchuan_ads.py | 175 + .../test_import_jd_ad_costs.py | 215 + .../test_import_tmall_product_ads.py | 187 + .../product_commerce/test_jd_market_rank.py | 132 +- .../product_commerce/test_jd_video_frames.py | 150 + .../product_commerce/test_jd_video_upload.py | 7072 +++++++++++++++++ .../test_market_rank_hermes_notification.py | 128 +- .../test_market_rank_workflow.py | 110 + .../test_persona_integrity.py | 80 + .../product_commerce/test_persona_launcher.py | 40 + ...st_product_acceptance_feishu_boundaries.py | 62 +- .../test_product_daily_raw_data_protection.py | 46 + .../test_product_nested_child_bindings.py | 35 + .../test_product_workflow_business_dates.py | 51 + .../test_style_analysis_orchestration.py | 366 +- .../test_style_config_loader_erp_source.py | 31 +- .../product_commerce/test_sycm_market_rank.py | 10 + .../test_sync_monthly_sales_sheet.py | 13 + .../test_tm_brand_profiles.py | 298 + .../test_tm_daily_exit_contract.py | 169 +- .../test_tm_persona_recovery.py | 64 +- .../test_tmall_baibu_apply.py | 548 ++ ...test_tmall_video_attachment_idempotency.py | 572 ++ .../test_video_publication_ledger_shared.py | 163 + .../test_collector_exit_semantics.py | 18 +- .../test_douyin_price_appeal.py | 268 + .../test_dy_market_category_selection.py | 31 + ...test_jd_old_datepicker_click_resilience.py | 106 + .../test_jd_peer_category_switch.py | 127 + .../test_jd_self_operated_brand_daily.py | 2 +- .../test_shop_metrics_hardening.py | 76 +- .../test_source_business_logic_parity.py | 6 + tests/test_acceptance_cookie_fail_closed.py | 24 +- tests/test_account_keepalive.py | 608 ++ tests/test_business_modules.py | 16 +- tests/test_catalog.py | 209 +- tests/test_cli_workflows.py | 4 +- tests/test_console.py | 1292 ++- tests/test_content_marketing_module.py | 35 +- tests/test_content_source_snapshot.py | 22 +- tests/test_daily_summary.py | 476 ++ tests/test_effect_ledger.py | 5 + tests/test_engineering_baseline.py | 2 + tests/test_erp_runtime_state.py | 142 + tests/test_feishu_messages.py | 165 + tests/test_langgraph_workflow_engine.py | 115 +- tests/test_module_command_adapter.py | 165 + .../test_nested_workflow_browser_bindings.py | 2 +- tests/test_no_native_browser_automation.py | 79 + tests/test_notification_routing.py | 401 + tests/test_product_commerce_module.py | 170 +- tests/test_product_source_migration.py | 11 +- tests/test_relocated_project.py | 6 +- tests/test_runtime_env_loading.py | 18 + tests/test_runtime_integrations.py | 420 +- tests/test_scheduler_cli.py | 5 +- tests/test_scheduler_service.py | 97 +- tests/test_scrapling_adapter.py | 334 + tests/test_script_catalog.py | 43 +- tests/test_server_scheduler_deployment.py | 2 +- tests/test_source_migration_acceptance.py | 2 +- tests/test_supply_acceptance_isolation.py | 59 +- tests/test_supply_chain_module.py | 2 +- ...est_supply_gateway_acceptance_preflight.py | 1372 +++- .../test_supply_replenishment_arrival_sync.py | 360 + tests/test_workflow_acceptance_policy.py | 18 + tests/test_workflow_dynamic_config.py | 503 ++ tests/test_workflow_engine.py | 129 +- uv.lock | 746 +- 309 files changed, 89358 insertions(+), 6232 deletions(-) create mode 100644 CLAUDE.md create mode 100644 config/notification-routing.json create mode 100644 config/tmall-baibu.json create mode 100644 config/tmall-video-publication-backfill.json create mode 100644 config/workflow-dynamic-config-seed.json create mode 100644 docs/dynamic-workflow-config.md create mode 100644 docs/note-inventory.md create mode 100644 src/gyxx_flow/accounts.py create mode 100644 src/gyxx_flow/adapters/direct_content_llm.py create mode 100644 src/gyxx_flow/adapters/erp_state.py create mode 100644 src/gyxx_flow/adapters/feishu_messages.py create mode 100644 src/gyxx_flow/adapters/scrapling.py create mode 100644 src/gyxx_flow/adapters/workflow_config.py create mode 100644 src/gyxx_flow/daily_summary.py create mode 100644 src/gyxx_flow/migration/feishu_style_config_seed.py create mode 100644 src/gyxx_flow/modules/content_marketing/data/tools/comment_batch_checkpoint.py create mode 100644 src/gyxx_flow/modules/content_marketing/data/tools/migrations/006_note_inventory.sql create mode 100644 src/gyxx_flow/modules/content_marketing/data/tools/migrations/007_notes_master.sql create mode 100644 src/gyxx_flow/modules/content_marketing/data/tools/migrations/008_note_metric_snapshots.sql create mode 100644 src/gyxx_flow/modules/content_marketing/data/tools/relogin_runtime_paths.py delete mode 100644 src/gyxx_flow/modules/content_marketing/data/tools/sync_cooperations.py create mode 100644 src/gyxx_flow/modules/content_marketing/data/tools/sync_notes_master.py create mode 100644 src/gyxx_flow/modules/content_marketing/feishu_doc_import.py create mode 100644 src/gyxx_flow/modules/content_marketing/scrapling_browser_facade.py create mode 100644 src/gyxx_flow/modules/content_marketing/scrapling_http.py create mode 100644 src/gyxx_flow/modules/content_marketing/serialized_page_action.py rename src/gyxx_flow/modules/product_commerce/{analyze_style_with_hermes.py => analyze_style.py} (75%) create mode 100644 src/gyxx_flow/modules/product_commerce/backfill_erp_all_shop_daily.py create mode 100644 src/gyxx_flow/modules/product_commerce/collect_douyin_qianchuan_ads.py create mode 100644 src/gyxx_flow/modules/product_commerce/collect_jd_ad_costs.py create mode 100644 src/gyxx_flow/modules/product_commerce/collect_tmall_wanxiang_ads.py create mode 100644 src/gyxx_flow/modules/product_commerce/direct_llm.py create mode 100644 src/gyxx_flow/modules/product_commerce/direct_visual_color.py create mode 100644 src/gyxx_flow/modules/product_commerce/import_douyin_qianchuan_ads.py create mode 100644 src/gyxx_flow/modules/product_commerce/import_jd_ad_costs.py create mode 100644 src/gyxx_flow/modules/product_commerce/import_tmall_product_ads.py create mode 100644 src/gyxx_flow/modules/product_commerce/jd_video_frames.py create mode 100644 src/gyxx_flow/modules/product_commerce/jd_video_metadata.py create mode 100644 src/gyxx_flow/modules/product_commerce/jd_video_publication_db.py create mode 100644 src/gyxx_flow/modules/product_commerce/tmall_baibu_apply_common.py create mode 100644 src/gyxx_flow/modules/product_commerce/tmall_baibu_apply_new_all_bag.py create mode 100644 src/gyxx_flow/modules/product_commerce/tmall_baibu_apply_new_chain_bag.py create mode 100644 src/gyxx_flow/modules/product_commerce/tmall_baibu_apply_old_all_3c.py create mode 100644 src/gyxx_flow/modules/product_commerce/tmall_baibu_apply_old_all_bag.py create mode 100644 src/gyxx_flow/modules/product_commerce/tmall_baibu_apply_old_chain_3c.py create mode 100644 src/gyxx_flow/modules/product_commerce/tmall_baibu_apply_old_chain_bag.py create mode 100644 src/gyxx_flow/modules/product_commerce/upload_video_to_jd.py create mode 100644 src/gyxx_flow/modules/product_commerce/vendors/dy-data-flow/data/adaptive_selectors/playwright_selectors.json create mode 100644 src/gyxx_flow/modules/product_commerce/visual_color_common.py create mode 100644 src/gyxx_flow/modules/supply_chain/orchestrator/arrival_sync_queue.py create mode 100644 src/gyxx_flow/modules/supply_chain/orchestrator/scripts/collect_replenishment_arrival_sync.ps1 create mode 100644 src/gyxx_flow/modules/supply_chain/orchestrator/scripts/collect_replenishment_arrival_sync.py create mode 100644 src/gyxx_flow/modules/supply_chain/orchestrator/scripts/sync_replenishment_arrivals.py create mode 100644 src/gyxx_flow/notification_routing.py create mode 100644 tests/modules/content_marketing/test_bilibili_play_count_retry.py create mode 100644 tests/modules/content_marketing/test_buyin_metric_snapshots.py create mode 100644 tests/modules/content_marketing/test_comment_batch_recovery.py create mode 100644 tests/modules/content_marketing/test_creator_report_exit_status.py create mode 100644 tests/modules/content_marketing/test_direct_content_llm.py create mode 100644 tests/modules/content_marketing/test_douyin_comment_deadline.py create mode 100644 tests/modules/content_marketing/test_feishu_doc_import.py create mode 100644 tests/modules/content_marketing/test_notes_master_sync.py create mode 100644 tests/modules/content_marketing/test_relogin_runtime_bindings.py create mode 100644 tests/modules/content_marketing/test_summary_base_delivery.py create mode 100644 tests/modules/content_marketing/test_sync_metrics_identity.py create mode 100644 tests/modules/content_marketing/test_xiaohongshu_comment_deadline.py create mode 100644 tests/modules/product_commerce/test_collect_douyin_qianchuan_ads.py create mode 100644 tests/modules/product_commerce/test_collect_jd_ad_costs.py create mode 100644 tests/modules/product_commerce/test_collect_tmall_wanxiang_ads.py create mode 100644 tests/modules/product_commerce/test_db_get_conn.py create mode 100644 tests/modules/product_commerce/test_direct_llm.py create mode 100644 tests/modules/product_commerce/test_direct_visual_color.py create mode 100644 tests/modules/product_commerce/test_direct_visual_color_contract.py create mode 100644 tests/modules/product_commerce/test_erp_all_shop_daily_backfill.py create mode 100644 tests/modules/product_commerce/test_import_douyin_qianchuan_ads.py create mode 100644 tests/modules/product_commerce/test_import_jd_ad_costs.py create mode 100644 tests/modules/product_commerce/test_import_tmall_product_ads.py create mode 100644 tests/modules/product_commerce/test_jd_video_frames.py create mode 100644 tests/modules/product_commerce/test_jd_video_upload.py create mode 100644 tests/modules/product_commerce/test_product_daily_raw_data_protection.py create mode 100644 tests/modules/product_commerce/test_tm_brand_profiles.py create mode 100644 tests/modules/product_commerce/test_tmall_baibu_apply.py create mode 100644 tests/modules/product_commerce/test_tmall_video_attachment_idempotency.py create mode 100644 tests/modules/product_commerce/test_video_publication_ledger_shared.py create mode 100644 tests/modules/shop_intelligence/test_jd_old_datepicker_click_resilience.py create mode 100644 tests/test_account_keepalive.py create mode 100644 tests/test_daily_summary.py create mode 100644 tests/test_erp_runtime_state.py create mode 100644 tests/test_feishu_messages.py create mode 100644 tests/test_no_native_browser_automation.py create mode 100644 tests/test_notification_routing.py create mode 100644 tests/test_scrapling_adapter.py create mode 100644 tests/test_supply_replenishment_arrival_sync.py create mode 100644 tests/test_workflow_dynamic_config.py diff --git a/.gitignore b/.gitignore index c0d00c2..04dd11a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,9 @@ Thumbs.db # Agent-local learning logs .learnings/ + +# Local tool artifacts +/.playwright-cli/ +/output/ +/nul +/new_all_bag.xlsx diff --git a/AGENTS.md b/AGENTS.md index 8ea9986..62db9d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,38 +2,51 @@ ## Project Structure & Module Organization -GYXX Flow is a Python 3.12 `src`-layout package. Framework code lives in `src/gyxx_flow/`; business implementations belong directly in `src/gyxx_flow/modules//`. The former `runtime/` directories are compatibility namespaces only and must not receive new production code. +GYXX Flow is a Python 3.12 `src`-layout modular monolith: LangGraph workflow orchestration, a Python-resident scheduler, and per-run audit (journal, locks, effect ledger). Docs, commit messages, and config descriptions are largely Chinese — follow the existing language of each file. -All project and module tests live in `tests/`. Configuration lives in `config/`, runbooks in `docs/`, and service scripts in `deploy/`. Treat `var/` as generated state, not source; production deployments must set an external `GYXX_DATA_ROOT`. +- `src/gyxx_flow/core/`, `workflow/`, `adapters/` — shared framework: settings/`RunContext`/journal/locks (`core`), validated workflow models + `build_catalog_workflow` + `WorkflowEngine` (`workflow`), and the only sanctioned bridges to PostgreSQL/Feishu/Hermes/browser (`adapters`). +- `src/gyxx_flow/modules//` — business code for `content_marketing`, `product_commerce`, `shop_intelligence`, `supply_chain`. Modules depend only on shared contracts, never on another module's internals. The `runtime/` subpackages are legacy import-compatibility namespaces only; new production code goes directly in the module directory. +- `config/` — the validated configuration quartet: `workflows.json` (scheduled workflows only, exactly one schedule each), `schedules.json` (time rules), `commands.json` (manual-command whitelist), `runtime-bindings.json` (browser bindings + accounts). +- `tests/`, `docs/`, `deploy/` — tests, runbooks, deployment. `var/` is generated state (gitignored); production must set an external `GYXX_DATA_ROOT`. ## Build, Test, and Development Commands -Use `uv` from the repository root: +Everything runs through `uv` from the repository root: ```powershell -uv sync --python 3.12 --group dev # create/update the development environment -uv run pytest # run the project test suite -uv run pytest tests/test_cli.py # run a focused test file -uv run ruff check src tests # lint imports and Python errors -uv build # create distribution artifacts -uv run gyxx doctor --json # validate local configuration -uv run gyxx list # inspect registered workflows +uv sync --python 3.12 --group dev # create/update the dev environment +uv run pytest # full suite +uv run pytest tests/test_cli.py # single file +uv run ruff check src tests # lint (E4, E7, E9, F, I) +uv build # distribution artifacts +uv run gyxx doctor --json # environment/config preflight +uv run gyxx acceptance status --json # acceptance evidence +uv run gyxx sources status --source-root = # upstream drift check ``` -Workflow and command execution defaults to dry-run behavior. Add `--execute` only when real external effects are intended. Public manual commands must be declared in `config/commands.json`; never reintroduce recursive executable-script discovery. +Manual execution is dry-run by default; real external side effects require explicit `--execute`: -Production schedules run only through the Python scheduler service; do not add Windows Task Scheduler registration. Manual commands remain dry-run by default, while `gyxx schedule run` executes enabled jobs unless `--dry-run` is supplied. PostgreSQL uses the cloud service selected through runtime-only credentials, both Hermes roles remain loopback-only, Feishu keeps the existing integration behavior, credentials must not have non-empty source defaults, and every browser entry must retain its unique CDP/Profile/Cookie/storage-state binding. +```powershell +uv run gyxx list # scheduled workflows only +uv run gyxx run product.daily --date 2026-08-01 [--execute] +uv run gyxx backfill content.metrics.daily --from --to [--execute] +uv run gyxx scripts run --date [--execute] +uv run gyxx schedule run [--dry-run] [--once] # executes enabled jobs unless --dry-run +uv run gyxx accounts login|sync [account_id] +``` -Each executable `workflow_id` compiles to a LangGraph `StateGraph`. Preserve the public workflow model, journal, lock, shadow, and effect-ledger contracts when changing graph execution. Check upstream drift with `uv run gyxx sources status`; automated source application is limited to untransformed, conflict-free entries and still requires `--execute`. +## Constraints (preserve these contracts) -## Coding Style & Naming Conventions +- `gyxx schedule run` is the only production scheduling path; never add Windows Task Scheduler entries or duplicate cron rules. +- Manual/backfill/maintenance commands must be declared in `config/commands.json`; never reintroduce recursive executable-script discovery. +- Each executable `workflow_id` compiles to a LangGraph `StateGraph`. Preserve the public contracts of the workflow model, `RunJournal`, `LockManager`, shadow comparison, and `EffectLedger` when changing graph execution. +- External systems: PostgreSQL via runtime-injected cloud DSN (`GYXX_POSTGRES_DSN`; no address/credential defaults in source, loopback rejected in cloud mode); Hermes loopback-only roles on `127.0.0.1:8642/8643` (key `GYXX_HERMES_API_KEY`); Feishu keeps its existing identity. Every browser binding keeps a unique CDP port plus isolated Profile/Cookie/storage-state; scripts sharing an account declare it under `accounts` in `runtime-bindings.json` — the vault `state/accounts//` is the single login authority (`gyxx accounts login/sync`) and its cookies merge read-only into member scripts while profiles stay isolated. +- 启动或重启工作流控制台(`gyxx console`)必须携带 `--env-file D:\product-collector-analyze-flow\.env`(与调度器进程一致),否则 `PG_HOST/PG_PORT/PG_DB/PG_USER/PG_PASSWORD` 等云端 PostgreSQL 凭据和账号配置不会注入,「商品经营日报采集」「聚水潭全店铺款式日报」等正式执行会报“缺少 PostgreSQL 运行时凭据”。参考启动脚本:`var/tmp/start-console.ps1`。 +- Source sync (`gyxx sources status/apply`): manifest-driven three-way hash drift check; only untransformed, conflict-free one-sided changes may be auto-copied, and only with `--execute`. Credentials, cookies, logs, and collected data never enter sync manifests. -Follow existing Python conventions: four-space indentation, type annotations, concise docstrings, and imports ordered as standard library, third party, then local modules. Ruff enforces `E4`, `E7`, `E9`, `F`, and `I` rules. Use `snake_case` for modules, functions, variables, and test files; `PascalCase` for classes; and uppercase names for constants. Keep cross-module contracts in shared framework packages rather than importing another business module’s internals. +## Coding Style & Testing -## Testing Guidelines - -Tests use pytest and follow `test_*.py` / `test_*` naming. Add focused unit tests for new logic and regression tests for fixed defects. Changes to workflows, schedules, manifests, runtime paths, or CLI behavior should include acceptance or boundary coverage. Avoid tests that require live credentials or mutate production services. - -## Commit & Pull Request Guidelines - -Use short Conventional Commit subjects such as `feat: consolidate legacy workflows`. Keep commits scoped. Pull requests should explain behavior, list verification, link issues, and call out configuration or migration effects. Never commit secrets, cookies, tokens, `.env` files, or generated `var/` contents. +- Ruff enforces `E4`, `E7`, `E9`, `F`, `I`. `pyproject.toml` excludes `src/gyxx_flow/modules/*/**` from lint (legacy baseline); CI additionally lints a focused list of critical module scripts — add newly critical module scripts there rather than removing the exclusion. +- Tests are pytest under `tests/` (`test_*.py`); never require live credentials or mutate production services. Changes to workflows, schedules, manifests, runtime paths, or CLI behavior need acceptance or boundary coverage. +- Commits use short Conventional Commit subjects (e.g. `feat: consolidate legacy workflows`); never commit secrets, cookies, `.env` files, or generated `var/` contents. +- Operational detail: `docs/architecture.md`, `docs/deployment.md`, `docs/runbook.md`, `docs/acceptance-report.md`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ca87b06 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,73 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +GYXX Flow is a Python 3.12 modular-monolith business automation platform. It orchestrates 23 scheduled workflows across four business domains (content marketing, product commerce, shop intelligence, supply chain) with LangGraph, runs them through a resident Python scheduler, and audits every run. Documentation, commit messages, and config descriptions are largely in Chinese; follow the existing language of each file. + +## Common Commands + +Everything runs through `uv` from the repo root: + +```powershell +uv sync --python 3.12 --group dev # create/update dev environment +uv run pytest # full test suite +uv run pytest tests/test_cli.py # single test file +uv run pytest tests/path/test_x.py::test_name -k pattern # focused tests +uv run ruff check src tests # lint (E4, E7, E9, F, I) +uv build # distribution artifacts +``` + +CLI (entry point `gyxx_flow.cli:main`). Manual execution is **dry-run by default**; real external side effects require an explicit `--execute` flag: + +```powershell +uv run gyxx list # scheduled workflows only +uv run gyxx run product.daily --date 2026-08-01 # dry-run one workflow +uv run gyxx run product.daily --date 2026-08-01 --execute +uv run gyxx backfill content.metrics.daily --from --to +uv run gyxx scripts run --date # manual backfill/retry/maintenance +uv run gyxx schedule run --dry-run --once # scheduler; omit --dry-run to execute +uv run gyxx doctor --json # environment/config preflight +uv run gyxx acceptance status --json +uv run gyxx sources status --source-root = # upstream source drift check +``` + +Manual/backfill commands must be declared in `config/commands.json`; never reintroduce recursive executable-script discovery. `gyxx schedule run` is the only production scheduling path — do not add Windows Task Scheduler tasks or extra cron entries. + +## Architecture + +**Execution pipeline:** CLI / `scheduler_service.py` → `WorkflowCatalog` (parses `config/workflows.json`) → `WorkflowRegistry` + per-module factories → `build_catalog_workflow` (`workflow/factory.py`) compiles each workflow into a LangGraph `StateGraph` → `WorkflowEngine` (`workflow/engine.py`) executes steps with retries, resource locks, timeouts, and effect-ledger guards. Every run gets a stable `run_id` and records node status, logs, artifacts, locks, and external side effects in the `RunJournal` and `EffectLedger` (`ops/effects.py`). Preserve the public contracts of the workflow model, journal, locks, shadow comparison, and effect ledger when changing graph execution. + +**Layer boundaries** (enforced by tests): + +- `src/gyxx_flow/core/` — settings, `RunContext`, `DataLayout`, `LockManager`, `RunJournal`, artifacts, exit codes. +- `src/gyxx_flow/workflow/` — `StepDefinition`/`WorkflowDefinition` (validated models), `ExecutableStep` protocol, graph compilation, engine. +- `src/gyxx_flow/adapters/` — the only sanctioned bridge to infrastructure: PostgreSQL/Feishu/Hermes outboxes (`external.py`), browser profile/CDP management (`browser.py`), runtime service binding for child scripts (`integration.py`), module command resolution (`native.py`), acceptance/cookie policy (`acceptance_policy.py`). +- `src/gyxx_flow/modules//` — the four business modules (`content_marketing`, `product_commerce`, `shop_intelligence`, `supply_chain`). Business modules may depend only on shared contracts above — **never on another module's internals**. New production code goes directly in the module directory; the `runtime/` subpackages are legacy import-compatibility namespaces only. +- `src/gyxx_flow/ops/`, `source_sync/`, `security/`, `baseline/` — effect ledger/run index, manifest-driven source-drift tooling, secret scanning, engineering baselines. + +**Configuration quartet** in `config/` (all are validated at startup): + +- `workflows.json` — scheduled workflows only: stable id, module, explicit steps (entry script, args, `depends_on`, `run_after_failure`), failure policy. Each entry must have exactly one schedule. +- `schedules.json` — time rules only (all `Asia/Shanghai`; a day plan may have multiple times). Scheduler (`scheduler_service.py`) owns business-date offsets, slot dedup, non-overlap, missed-trigger compensation, graceful stop. +- `commands.json` — manual-execution whitelist (backfill, retry, mapping refresh, protected writes). `--date` renders a command's declared `{business_date}` defaults; these are never new scheduled workflows. +- `runtime-bindings.json` — each browser script gets a unique CDP port plus isolated Profile/Cookie/storage-state; never share writable profiles. Scripts that use the same account may declare an `account` id (see the top-level `accounts` section): the account vault under `state/accounts//` is the single login authority, and its cookies are merged read-only into every member script's cookie files before each run (`gyxx accounts login/sync`). Member profiles stay isolated so parallel runs never contend. + +**External systems** (all accessed through adapters): + +- PostgreSQL: runtime-injected cloud DSN via `GYXX_POSTGRES_DSN` (mapped to `PG_*`/`DB_*`/`AUTOFLOW_PG_*`). No address/credentials defaults in source; loopback DB addresses are rejected in cloud mode. +- Hermes: loopback-only roles — analyzer `http://127.0.0.1:8642/v1`, collector `http://127.0.0.1:8643/v1`; key via `GYXX_HERMES_API_KEY`. Non-loopback Hermes addresses are rejected. Pure-collection workflows run without AI; analysis/notification workflows need the local roles. +- Feishu: keeps the existing lark-cli profile/app identity. +- Steps declare `production_sink`, `official_notification`, and `replay_policy` (`guarded`/`idempotent`); the engine and effect ledger enforce these gates. + +**Data layout:** all runtime paths derive from `GYXX_DATA_ROOT` (dev default `var/`, which is gitignored generated state — never commit it or write source into it; production must use an external root). Layers: `data/{raw,normalized,curated,exports,evidence}/`, `state/` (scheduler, graph, locks, ledger, per-script browser state), `logs/`, `tmp/`. Raw data is append-only. + +## Repo-Specific Rules + +- **Ruff lint baseline:** `src/gyxx_flow/modules/*/**` is excluded via `extend-exclude` in `pyproject.toml` (migrated scripts keep their old baseline). CI additionally lints a focused list of critical module scripts (see `.gitlab-ci.yml`); when adding or substantially changing module scripts that must be linted, add them to that focused CI list. +- **Adding a scheduled workflow:** implement an independently testable entry in `modules//`, declare steps in `workflows.json`, add exactly one schedule rule, register commands and browser bindings as needed, access infrastructure only through shared adapters, and add dry-run/boundary/business-date regression tests. Backfill/maintenance capabilities are registered as commands only — they become workflows only when they become independent recurring jobs. New modules must not require changes to other modules; new steps must not require changes to the scheduler or engine. +- **Source sync** (`gyxx sources status/apply`): manifest-driven three-way hash comparison against upstream module source dirs; only untransformed, conflict-free one-sided changes may be auto-copied, and only with `--execute`. Cookies, profiles, credentials, logs, and collected data never enter sync manifests. +- Tests live in `tests/` (pytest, `test_*.py`); never require live credentials or mutate production services. Changes to workflows, schedules, manifests, runtime paths, or CLI behavior need acceptance or boundary coverage. +- Use Conventional Commit subjects (e.g. `feat: consolidate legacy workflows`). Never commit secrets, cookies, `.env` files, or `var/` contents. +- See `docs/architecture.md`, `docs/deployment.md`, `docs/runbook.md`, and `docs/acceptance-report.md` for operational detail. diff --git a/README.md b/README.md index d622b6f..917a77c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ GYXX Flow 是一个 Python 3.12 业务自动化平台,用 LangGraph 编排内 - 23 条调度工作流(内容 8、商品 8、店铺 4、供应链 3),当前全部启用。 - 工作流目录只保存定时任务;补采、重试、映射刷新和受保护写操作统一由手动命令承载。 - Python 常驻调度支持日、周、月、间隔日、错过触发补偿、防重复和优雅停止。 -- 31 个显式命令覆盖工作流节点和手动补偿入口;136 条内部浏览器绑定继续使用唯一 CDP、Profile、Cookie 和 storage state。 +- 31 个显式命令覆盖工作流节点和手动补偿入口;138 条内部浏览器绑定继续使用唯一 CDP、Profile、Cookie 和 storage state。 - PostgreSQL 使用运行时注入的云端 DSN;地址、数据库、用户和密码均不在源码中提供默认值。 - Hermes 保持本机 `data-collector`、`data-analyzer` 两个角色;飞书保持既有身份和接口。 - JSON、Markdown、CSV、Excel、下载文件和截图统一写入可迁移的数据根。 diff --git a/config/commands.json b/config/commands.json index ffda5f6..a093355 100644 --- a/config/commands.json +++ b/config/commands.json @@ -1,41 +1,401 @@ { "schema_version": 1, "commands": [ - {"id":"content.metrics.collect_collaborators","module":"content_marketing","entry":"run_all.py","kind":"python"}, - {"id":"content.mapping.refresh_self","module":"content_marketing","entry":"data/tools/refresh_self_mapping.py","kind":"python"}, - {"id":"content.metrics.collect_self_bilibili","module":"content_marketing","entry":"self_bilibili_scraper.py","kind":"python"}, - {"id":"content.metrics.collect_self_douyin","module":"content_marketing","entry":"chanmama_scraper.py","kind":"python"}, - {"id":"content.metrics.sync","module":"content_marketing","entry":"data/tools/sync_metrics_to_cmt_notes.py","kind":"python"}, - {"id":"content.marketing_report.generate","module":"content_marketing","entry":"daily_marketing_report.py","kind":"python"}, - {"id":"content.login.refresh","module":"content_marketing","entry":"data/tools/friday_relogin_parallel.py","kind":"python"}, - {"id":"content.creator_report.generate","module":"content_marketing","entry":"data/tools/generate_creator_report.py","kind":"python"}, - {"id":"content.summary.monthly","module":"content_marketing","entry":"monthly_summary_all.py","kind":"python"}, - {"id":"content.cooperations.sync","module":"content_marketing","entry":"data/tools/sync_cooperations.py","kind":"python"}, - {"id":"content.comments.collect_bilibili","module":"content_marketing","entry":"data/tools/batch_rescrape_bilibili.py","kind":"python"}, - {"id":"content.comments.collect_xiaohongshu","module":"content_marketing","entry":"data/tools/batch_rescrape_xiaohongshu.py","kind":"python"}, - {"id":"content.comments.collect_douyin","module":"content_marketing","entry":"data/tools/batch_rescrape_douyin.py","kind":"python"}, - {"id":"content.summary.weekly","module":"content_marketing","entry":"weekly_summary_all.py","kind":"python"}, - {"id":"content.mapping.rebuild","module":"content_marketing","entry":"data/tools/rebuild_mapping.py","kind":"python"}, - {"id":"content.failed.retry","module":"content_marketing","entry":"data/tools/retry_failed.py","kind":"python"}, - - {"id":"shop.metrics.collect","module":"shop_intelligence","entry":"runners/run_shop.py","kind":"python"}, - {"id":"shop.competitor.collect","module":"shop_intelligence","entry":"runners/run_peer_store.py","kind":"python"}, - {"id":"shop.jd_self_operated.collect_brand","module":"shop_intelligence","entry":"collectors/jd_self_operated_brand_daily.py","kind":"python","default_args":["--start-date","{business_date}","--end-date","{business_date}","--headless"]}, - {"id":"shop.jd_self_operated.collect_product","module":"shop_intelligence","entry":"collectors/jd_self_operated_product_daily.py","kind":"python","default_args":["--start-date","{business_date}","--end-date","{business_date}","--headless"]}, - {"id":"shop.douyin_price_appeal","module":"shop_intelligence","entry":"collectors/dy_store_competitor_store_scraping.py","kind":"python","default_args":["--price-appeal","--execute"]}, - - {"id":"product.persona.collect","module":"product_commerce","entry":"run_daily_persona.py","kind":"python"}, - {"id":"product.daily.orchestrate","module":"product_commerce","entry":"orchestrate_daily_collection.py","kind":"python"}, - {"id":"product.alert.run","module":"product_commerce","entry":"commands/run_alerts.py","kind":"python"}, - {"id":"product.import.daily","module":"product_commerce","entry":"commands/import_daily.py","kind":"python"}, - {"id":"product.style_analysis.run","module":"product_commerce","entry":"analyze_style_with_hermes.py","kind":"python"}, - {"id":"product.main_image.collect_jd","module":"product_commerce","entry":"run_weekly_jd_main_image.py","kind":"python"}, - {"id":"product.main_image.collect_tmall","module":"product_commerce","entry":"run_weekly_main_image.py","kind":"python"}, - {"id":"product.sales_sheet.sync","module":"product_commerce","entry":"sync_monthly_sales_sheet.py","kind":"python","default_args":["--month","{business_date}","--execute"]}, - {"id":"product.backfill.run","module":"product_commerce","entry":"backfill_collect.py","kind":"python","default_args":["--from","{business_date}","--to","{business_date}"]}, - {"id":"product.market_rank.orchestrate","module":"product_commerce","entry":"orchestrate_market_rank_collection.py","kind":"python"}, - {"id":"product.review.orchestrate","module":"product_commerce","entry":"orchestrate_review_collection.py","kind":"python","default_args":["--target-date","{business_date}"]}, - - {"id":"supply.workflow.run","module":"supply_chain","entry":"run.py","kind":"python","default_args":["mcp-run","purchase-order-update"]} + { + "id": "content.metrics.collect_collaborators", + "module": "content_marketing", + "entry": "run_all.py", + "kind": "python" + }, + { + "id": "content.mapping.refresh_self", + "module": "content_marketing", + "entry": "data/tools/refresh_self_mapping.py", + "kind": "python" + }, + { + "id": "content.metrics.collect_self_bilibili", + "module": "content_marketing", + "entry": "self_bilibili_scraper.py", + "kind": "python" + }, + { + "id": "content.metrics.collect_self_douyin", + "module": "content_marketing", + "entry": "chanmama_scraper.py", + "kind": "python" + }, + { + "id": "content.metrics.sync", + "module": "content_marketing", + "entry": "data/tools/sync_metrics_to_cmt_notes.py", + "kind": "python" + }, + { + "id": "content.marketing_report.generate", + "module": "content_marketing", + "entry": "daily_marketing_report.py", + "kind": "python", + "notification_workflow_id": "content.marketing_report.daily" + }, + { + "id": "content.login.refresh", + "module": "content_marketing", + "entry": "data/tools/friday_relogin_parallel.py", + "kind": "python", + "notification_workflow_id": "content.relogin.weekly" + }, + { + "id": "content.creator_report.generate", + "module": "content_marketing", + "entry": "data/tools/generate_creator_report.py", + "kind": "python" + }, + { + "id": "content.summary.monthly", + "module": "content_marketing", + "entry": "monthly_summary_all.py", + "kind": "python" + }, + { + "id": "content.notes_master.sync", + "module": "content_marketing", + "entry": "data/tools/sync_notes_master.py", + "kind": "python" + }, + { + "id": "content.comments.collect_bilibili", + "module": "content_marketing", + "entry": "data/tools/batch_rescrape_bilibili.py", + "kind": "python" + }, + { + "id": "content.comments.collect_xiaohongshu", + "module": "content_marketing", + "entry": "data/tools/batch_rescrape_xiaohongshu.py", + "kind": "python" + }, + { + "id": "content.comments.collect_douyin", + "module": "content_marketing", + "entry": "data/tools/batch_rescrape_douyin.py", + "kind": "python" + }, + { + "id": "content.summary.weekly", + "module": "content_marketing", + "entry": "weekly_summary_all.py", + "kind": "python" + }, + { + "id": "content.mapping.rebuild", + "module": "content_marketing", + "entry": "data/tools/rebuild_mapping.py", + "kind": "python" + }, + { + "id": "content.failed.retry", + "module": "content_marketing", + "entry": "data/tools/retry_failed.py", + "kind": "python" + }, + { + "id": "shop.metrics.collect", + "module": "shop_intelligence", + "entry": "runners/run_shop.py", + "kind": "python" + }, + { + "id": "shop.competitor.collect", + "module": "shop_intelligence", + "entry": "runners/run_peer_store.py", + "kind": "python" + }, + { + "id": "shop.jd_self_operated.collect_brand", + "module": "shop_intelligence", + "entry": "collectors/jd_self_operated_brand_daily.py", + "kind": "python", + "default_args": [ + "--start-date", + "{business_date}", + "--end-date", + "{business_date}", + "--headless" + ] + }, + { + "id": "shop.jd_self_operated.collect_product", + "module": "shop_intelligence", + "entry": "collectors/jd_self_operated_product_daily.py", + "kind": "python", + "default_args": [ + "--start-date", + "{business_date}", + "--end-date", + "{business_date}", + "--headless" + ] + }, + { + "id": "shop.douyin_price_appeal", + "module": "shop_intelligence", + "entry": "collectors/dy_store_competitor_store_scraping.py", + "kind": "python", + "default_args": [ + "--price-appeal", + "--execute" + ] + }, + { + "id": "product.persona.collect", + "module": "product_commerce", + "entry": "run_daily_persona.py", + "kind": "python" + }, + { + "id": "product.daily.orchestrate", + "module": "product_commerce", + "entry": "orchestrate_daily_collection.py", + "kind": "python" + }, + { + "id": "product.alert.run", + "module": "product_commerce", + "entry": "commands/run_alerts.py", + "kind": "python", + "notification_workflow_id": "product.alert.daily" + }, + { + "id": "product.import.daily", + "module": "product_commerce", + "entry": "commands/import_daily.py", + "kind": "python" + }, + { + "id": "product.tmall_wanxiang_ads.collect", + "module": "product_commerce", + "entry": "collect_tmall_wanxiang_ads.py", + "kind": "python" + }, + { + "id": "product.import.tmall_ads", + "module": "product_commerce", + "entry": "import_tmall_product_ads.py", + "kind": "python", + "default_args": [ + "--date", + "{business_date}", + "--execute" + ] + }, + { + "id": "product.jd_ad_costs.collect", + "module": "product_commerce", + "entry": "collect_jd_ad_costs.py", + "kind": "python", + "default_args": [ + "--date", + "{business_date}", + "--headless" + ] + }, + { + "id": "product.douyin_qianchuan_ads.collect", + "module": "product_commerce", + "entry": "collect_douyin_qianchuan_ads.py", + "kind": "python", + "default_args": [ + "--date", + "{business_date}", + "--headless" + ] + }, + { + "id": "product.import.douyin_qianchuan_ads", + "module": "product_commerce", + "entry": "import_douyin_qianchuan_ads.py", + "kind": "python", + "default_args": [ + "--date", + "{business_date}", + "--execute" + ] + }, + { + "id": "product.import.jd_ad_costs", + "module": "product_commerce", + "entry": "import_jd_ad_costs.py", + "kind": "python", + "default_args": [ + "--date", + "{business_date}", + "--execute" + ] + }, + { + "id": "product.style_analysis.run", + "module": "product_commerce", + "entry": "analyze_style.py", + "kind": "python" + }, + { + "id": "product.main_image.collect_jd", + "module": "product_commerce", + "entry": "run_weekly_jd_main_image.py", + "kind": "python" + }, + { + "id": "product.main_image.collect_tmall", + "module": "product_commerce", + "entry": "run_weekly_main_image.py", + "kind": "python" + }, + { + "id": "product.video_upload.run", + "module": "product_commerce", + "entry": "upload_video_to_guanghe.py", + "kind": "python", + "default_args": [ + "--store", + "all", + "--execute", + "--keep-browser-open-on-failure" + ] + }, + { + "id": "product.jd_video_upload.run", + "module": "product_commerce", + "entry": "upload_video_to_jd.py", + "kind": "python", + "default_args": [ + "--anchor-record-id", + "recvrXTRWWFXeJ", + "--manual-login", + "--execute" + ] + }, + { + "id": "product.tmall_baibu_apply.old_chain_bag", + "module": "product_commerce", + "entry": "tmall_baibu_apply_old_chain_bag.py", + "kind": "python", + "default_args": [ + "--execute" + ] + }, + { + "id": "product.tmall_baibu_apply.old_all_bag", + "module": "product_commerce", + "entry": "tmall_baibu_apply_old_all_bag.py", + "kind": "python", + "default_args": [ + "--execute" + ] + }, + { + "id": "product.tmall_baibu_apply.old_all_3c", + "module": "product_commerce", + "entry": "tmall_baibu_apply_old_all_3c.py", + "kind": "python", + "default_args": [ + "--execute" + ] + }, + { + "id": "product.tmall_baibu_apply.old_chain_3c", + "module": "product_commerce", + "entry": "tmall_baibu_apply_old_chain_3c.py", + "kind": "python", + "default_args": [ + "--execute" + ] + }, + { + "id": "product.tmall_baibu_apply.new_chain_bag", + "module": "product_commerce", + "entry": "tmall_baibu_apply_new_chain_bag.py", + "kind": "python", + "default_args": [ + "--execute" + ] + }, + { + "id": "product.tmall_baibu_apply.new_all_bag", + "module": "product_commerce", + "entry": "tmall_baibu_apply_new_all_bag.py", + "kind": "python", + "default_args": [ + "--execute" + ] + }, + { + "id": "product.sales_sheet.sync", + "module": "product_commerce", + "entry": "sync_monthly_sales_sheet.py", + "kind": "python", + "default_args": [ + "--month", + "{business_date}", + "--execute" + ] + }, + { + "id": "product.erp_all_shop_daily", + "module": "product_commerce", + "entry": "backfill_erp_all_shop_daily.py", + "kind": "python", + "default_args": [ + "--from", + "{business_date}", + "--to", + "{business_date}" + ] + }, + { + "id": "product.backfill.run", + "module": "product_commerce", + "entry": "backfill_collect.py", + "kind": "python", + "default_args": [ + "--from", + "{business_date}", + "--to", + "{business_date}" + ] + }, + { + "id": "product.market_rank.orchestrate", + "module": "product_commerce", + "entry": "orchestrate_market_rank_collection.py", + "kind": "python", + "notification_workflow_id": "product.market_rank" + }, + { + "id": "product.review.orchestrate", + "module": "product_commerce", + "entry": "orchestrate_review_collection.py", + "kind": "python", + "default_args": [ + "--target-date", + "{business_date}" + ] + }, + { + "id": "supply.replenishment.arrival_sync", + "module": "supply_chain", + "entry": "orchestrator/scripts/collect_replenishment_arrival_sync.py", + "kind": "python" + }, + { + "id": "supply.workflow.run", + "module": "supply_chain", + "entry": "run.py", + "kind": "python", + "default_args": [ + "mcp-run", + "purchase-order-update" + ] + } ] } diff --git a/config/notification-routing.json b/config/notification-routing.json new file mode 100644 index 0000000..3d60f1d --- /dev/null +++ b/config/notification-routing.json @@ -0,0 +1,223 @@ +{ + "schema_version": 1, + "app_profile": "hermes-analyzer", + "app_profiles": { + "collector": { + "label": "采集端飞书应用", + "app_id": "cli_aa8c4fb4c4f81cd3", + "hermes_profile": "data-collector" + }, + "hermes-analyzer": { + "label": "分析端 Hermes 飞书机器人", + "app_id": "cli_aa8c4fc918b85cce", + "hermes_profile": "data-analyzer" + } + }, + "people": { + "he_yingwei": { + "name": "何颖威", + "mobile": null, + "bindings": { + "hermes-analyzer": { + "open_id": "ou_89bcff110ccbb09a23548dc0fb3d880c", + "verified": false, + "source": "provided" + } + } + }, + "wang_yunlong": { + "name": "王云龙", + "mobile": null, + "bindings": { + "collector": { + "open_id": "ou_8ee224968aa26a74c7d30ba27fed5eeb", + "verified": true, + "source": "repository" + }, + "hermes-analyzer": { + "open_id": "ou_7ad5fc8012e2f741afc5346e05ffd447", + "verified": true, + "source": "repository" + } + } + }, + "feng_renyun": { + "name": "冯任运", + "mobile": null, + "bindings": { + "hermes-analyzer": { + "open_id": "ou_c1faf3d3498201d27cd73c388acde067", + "verified": false, + "source": "provided" + } + } + }, + "zhang_yuji": { + "name": "张育基", + "mobile": null, + "bindings": { + "hermes-analyzer": { + "open_id": "ou_339c1d396b397c97b08e1bf377963d40", + "verified": false, + "source": "provided" + } + } + }, + "li_jingxian": { + "name": "李静娴", + "mobile": null, + "bindings": { + "hermes-analyzer": { + "open_id": "ou_2eda5eec112109ae6d19a1f6813eadcb", + "verified": false, + "source": "provided" + } + } + }, + "huang_kunping": { + "name": "黄坤平", + "mobile": null, + "bindings": { + "hermes-analyzer": { + "open_id": "ou_b76e4cbdb24fe28cebd45ad091b60224", + "verified": false, + "source": "provided" + } + } + }, + "ma_zhaoji": { + "name": "马兆基", + "mobile": null, + "bindings": { + "hermes-analyzer": { + "open_id": "ou_54141d75b24d22ddc8c1c4d66e00b6e9", + "verified": false, + "source": "provided" + } + } + }, + "yin_jiangtao": { + "name": "尹江涛", + "mobile": null, + "bindings": { + "hermes-analyzer": { + "open_id": "ou_96fea927e6eef671f019428cc5075e69", + "verified": false, + "source": "provided" + } + } + }, + "yu_penghui": { + "name": "余鹏辉", + "mobile": null, + "bindings": { + "hermes-analyzer": { + "open_id": "ou_531782527ea7164a4abfb23dc1c37b2b", + "verified": false, + "source": "provided" + } + } + }, + "he_jiaqi": { + "name": "何嘉琪", + "mobile": null, + "bindings": { + "hermes-analyzer": { + "open_id": "ou_bfcfa9585a0cc9c0a989816c12de661f", + "verified": false, + "source": "provided" + } + } + }, + "huang_shaoji": { + "name": "黄韶基", + "mobile": null, + "bindings": { + "hermes-analyzer": { + "open_id": "ou_fa8d81a16527ad06352dbecc575285b8", + "verified": false, + "source": "provided" + } + } + }, + "xie_junyu": { + "name": "谢钧宇", + "mobile": null, + "bindings": { + "hermes-analyzer": { + "open_id": "ou_0d407e24fcf61bf6bb3a176bc84006e9", + "verified": false, + "source": "provided" + } + } + }, + "zhao_jing": { + "name": "赵静", + "mobile": null, + "bindings": { + "hermes-analyzer": { + "open_id": "ou_24cc944d6e43c69c59d6560ad4e2ae6e", + "verified": false, + "source": "provided" + } + } + }, + "li_fengyi": { + "name": "李凤仪", + "mobile": null, + "bindings": { + "hermes-analyzer": { + "open_id": "ou_e557867b29d756e57d3a1b656e9d3ce1", + "verified": false, + "source": "provided" + } + } + }, + "chen_yan": { + "name": "陈燕", + "mobile": null, + "bindings": { + "hermes-analyzer": { + "open_id": "ou_63945c9e1b6d4c9ced7cce1c495d8bea", + "verified": false, + "source": "provided" + } + } + }, + "deng_hongmei": { + "name": "邓红梅", + "mobile": null, + "bindings": { + "hermes-analyzer": { + "open_id": "ou_3799e39cfc5f78da1b7c54c2f677f5e3", + "verified": false, + "source": "provided" + } + } + } + }, + "capabilities": { + "content.marketing_report.daily": { + "condition": "正式执行且启用发送时投递最终营销日报。" + }, + "content.relogin.weekly": { + "condition": "刷新期间投递各平台扫码二维码,并在平台失败时投递失败汇总。" + }, + "product.alert.daily": { + "condition": "命中销量下滑规则且尚未重复通知时投递告警。" + }, + "product.market_rank": { + "condition": "存在本次新生成的平台报告链接且尚未重复通知时投递。" + }, + "supply.purchase_confirmation.daily": { + "condition": "采购确认结果生成完成后投递最终业务通知。" + }, + "supply.replenishment.weekly": { + "condition": "补货处理完成后投递最终结果,包括无新增补货项的结果。" + }, + "supply.replenishment_alert.daily": { + "condition": "存在命中库存阈值的 SKU 时投递预警。" + } + }, + "routes": {} +} diff --git a/config/product-monthly-sales.json b/config/product-monthly-sales.json index 5b4a9c2..6d6f0ce 100644 --- a/config/product-monthly-sales.json +++ b/config/product-monthly-sales.json @@ -8,10 +8,6 @@ "target_field": "目标销量", "month_field": "月份", "lark_profile": "cli_aa8c4fb4c4f81cd3", - "style_base_url": "https://bu0zgpibak.feishu.cn/base/TtoCb1NuQaDy3NsZWTpc0GIvnph?table=tblKCjplVAFrRwMC&view=vewvy33xEk", - "style_base_token": "TtoCb1NuQaDy3NsZWTpc0GIvnph", - "style_table_id": "tblKCjplVAFrRwMC", - "style_view_id": "vewvy33xEk", "erp_shop_labels": [ "JD光影行星旗舰店", "ozko抖音店", diff --git a/config/runtime-bindings.json b/config/runtime-bindings.json index 9b321e5..9935b58 100644 --- a/config/runtime-bindings.json +++ b/config/runtime-bindings.json @@ -1,144 +1,2059 @@ { - "schema_version": 2, + "schema_version": 3, "cdp_host": "127.0.0.1", + "accounts": { + "douyin-shop": { + "cdp_port": 22200, + "login_mode": "D", + "required_cookie_domains": [ + "jinritemai.com" + ], + "required_cookie_names": [ + "sessionid", + "sid_tt", + "uid_tt", + "sessionid_ss", + "sid_guard" + ], + "login_identity_markers": [ + "光影行星箱包旗舰店" + ], + "keepalive": { + "enabled": true, + "url": "https://fxg.jinritemai.com/ffa/merchant/campaign-square?list_tab=access&f_tab=0", + "interval_seconds": 600, + "timeout_ms": 60000, + "headless": true, + "real_chrome": true + }, + "members": [ + "product_commerce:collect_dy_market_rank.py", + "product.douyin_qianchuan_ads.collect", + "product_commerce:dy_audience_profile_collect.py", + "product_commerce:dy_product_scraping.py", + "product_commerce:vendors/dy-data-flow/dy_store_competitor_store_scraping.py", + "shop.douyin_price_appeal", + "content_marketing:xingtu_scraper_v2.py" + ] + }, + "jd-shop": { + "cdp_port": 22201, + "login_mode": "D", + "required_cookie_domains": [ + "jd.com" + ], + "required_cookie_names": [ + "pin", + "thor" + ], + "credential_env_names": [ + "JD_SHOP", + "JD_PASSWORD" + ], + "keepalive": { + "enabled": true, + "url": "https://shop.jd.com/jdm/home", + "initial_delay_seconds": 60, + "interval_seconds": 900, + "timeout_ms": 60000, + "headless": true, + "real_chrome": true + }, + "members": [ + "product.jd_video_upload.run", + "product.jd_ad_costs.collect", + "product_commerce:collect_jd_persona_to_bitable.py", + "product_commerce:jd_main_image_collector.py", + "product_commerce:jd_product_data_collector.py", + "product_commerce:vendors/jd-data-flow/jd_data_collector.py", + "product_commerce:vendors/jd-data-flow/jd_product_data_collector.py", + "shop_intelligence:collectors/jd_data_collector.py", + "shop_intelligence:collectors/jd_peer_store_data_collector.py" + ] + }, + "jd-self-operated": { + "cdp_port": 22202, + "login_mode": "D", + "required_cookie_domains": [ + "jd.com" + ], + "required_cookie_names": [ + "pin", + "thor" + ], + "credential_env_names": [ + "JD_SELF_OPERATED_ACCOUNT", + "JD_SELF_OPERATED_PASSWORD" + ], + "keepalive": { + "enabled": true, + "url": "https://shop.jd.com/jdm/home", + "initial_delay_seconds": 120, + "interval_seconds": 900, + "timeout_ms": 60000, + "headless": true, + "real_chrome": true + }, + "members": [ + "shop.jd_self_operated.collect_brand", + "shop.jd_self_operated.collect_product" + ] + }, + "jd-market-rank": { + "cdp_port": 22209, + "login_mode": "D", + "required_cookie_domains": [ + "jd.com" + ], + "required_cookie_names": [ + "pin", + "thor" + ], + "credential_env_names": [ + "JD_MARKET_SHOP", + "JD_MARKET_PASSWORD" + ], + "keepalive": { + "enabled": true, + "url": "https://shop.jd.com/jdm/home", + "initial_delay_seconds": 540, + "interval_seconds": 900, + "timeout_ms": 60000, + "headless": true, + "real_chrome": true + }, + "members": [ + "product_commerce:collect_jd_market_rank.py" + ] + }, + "erp-jushuitan": { + "cdp_port": 22211, + "login_mode": "C", + "login_command_id": "product_commerce:erp_login_product_analysis.py", + "required_cookie_domains": [ + "erp321.com" + ], + "required_cookie_names": [ + "u_id", + "u_co_id", + "ASP.NET_SessionId", + ".ASPXAUTH", + "token" + ], + "credential_env_names": [ + "ERP_USERNAME", + "ERP_PASSWORD", + "GYXX_SUPPLY_ERP_PASSWORD" + ], + "keepalive": { + "enabled": true, + "url": "https://www.erp321.com/epaas", + "initial_delay_seconds": 660, + "interval_seconds": 900, + "timeout_ms": 60000, + "headless": true, + "real_chrome": true + }, + "members": [ + "product_commerce:collect_erp_yesterday_metrics.py", + "product_commerce:erp_login_product_analysis.py", + "product.erp_all_shop_daily", + "product.sales_sheet.sync", + "supply_chain:orchestrator/scripts/ProductReplenishment.py", + "supply_chain:orchestrator/scripts/PurchaseConfirmation.py", + "supply_chain:orchestrator/scripts/PurchaseOrderUpdate.py", + "supply.replenishment.arrival_sync" + ] + }, + "tmall-shop": { + "cdp_port": 22203, + "collection_cdp_port": 22142, + "login_mode": "D", + "required_cookie_domains": [ + "taobao.com", + "alimama.com" + ], + "required_cookie_names": [ + "unb", + "cookie2", + "_tb_token_" + ], + "credential_env_names": [ + "TMALL_SHOP_ACCOUNT", + "TMALL_SHOP_PASSWORD", + "SYCM_ACCOUNT", + "SYCM_PASSWORD", + "WANXIANG_ACCOUNT", + "WANXIANG_PASSWORD" + ], + "login_identity_markers": [ + "光影行星旗舰店" + ], + "keepalive": { + "enabled": true, + "url": "https://sycm.taobao.com/portal/home.htm", + "initial_delay_seconds": 180, + "interval_seconds": 900, + "timeout_ms": 60000, + "headless": true, + "real_chrome": true + }, + "members": [ + "product.tmall_wanxiang_ads.collect", + "product.video_upload.run", + "product_commerce:collect_sycm_market_rank.py", + "product_commerce:taobao_dmp_item_crowd_insight_screenshots.py", + "product_commerce:taobao_sycm_collect.py", + "product_commerce:taobao_sycm_collect_backfill.py", + "product_commerce:taobao_sycm_products.py", + "product_commerce:taobao_wanxiang_ai_creative_report.py", + "shop_intelligence:collectors/taobao_sycm.py", + "product.tmall_baibu_apply.old_chain_bag", + "product.tmall_baibu_apply.old_all_bag", + "product.tmall_baibu_apply.old_all_3c", + "product.tmall_baibu_apply.old_chain_3c" + ] + }, + "tmall-ozko": { + "cdp_port": 22210, + "collection_cdp_port": 22143, + "login_mode": "D", + "required_cookie_domains": [ + "taobao.com", + "alimama.com" + ], + "required_cookie_names": [ + "unb", + "cookie2", + "_tb_token_" + ], + "credential_env_names": [ + "SYCM_OZKO_ACCOUNT", + "SYCM_OZKO_PASSWORD" + ], + "login_identity_markers": [ + "ozko旗舰店", + "OZKO旗舰店" + ], + "keepalive": { + "enabled": true, + "url": "https://sycm.taobao.com/portal/home.htm", + "initial_delay_seconds": 600, + "interval_seconds": 900, + "timeout_ms": 60000, + "headless": true, + "real_chrome": true + }, + "members": [] + }, + "tmall-bag": { + "cdp_port": 22212, + "login_mode": "D", + "required_cookie_domains": [ + "taobao.com", + "alimama.com" + ], + "required_cookie_names": [ + "unb", + "cookie2", + "_tb_token_" + ], + "credential_env_names": [ + "TMALL_BAG_ACCOUNT", + "TMALL_BAG_PASSWORD" + ], + "login_identity_markers": [ + "光影行星鑫华达专卖店" + ], + "keepalive": { + "enabled": true, + "url": "https://qn.taobao.com/home.htm/QnworkbenchHome/", + "initial_delay_seconds": 300, + "interval_seconds": 900, + "timeout_ms": 60000, + "headless": true, + "real_chrome": true + }, + "members": [ + "product.tmall_baibu_apply.new_chain_bag", + "product.tmall_baibu_apply.new_all_bag" + ] + }, + "xiaohongshu-content": { + "cdp_port": 22204, + "login_mode": "D", + "required_cookie_domains": [ + "xiaohongshu.com" + ], + "required_cookie_names": [ + "web_session", + "a1" + ], + "keepalive": { + "enabled": true, + "url": "https://pgy.xiaohongshu.com/solar/pre-trade/home", + "initial_delay_seconds": 240, + "interval_seconds": 900, + "timeout_ms": 60000, + "headless": true, + "real_chrome": true + }, + "members": [ + "content.comments.collect_xiaohongshu", + "content_marketing:data/tools/relogin_pgy.py", + "content_marketing:data/tools/relogin_xiaohongshu.py", + "content_marketing:pgy_xhs_scraper_v2.py" + ] + }, + "douyin-content": { + "cdp_port": 22205, + "login_mode": "D", + "required_cookie_domains": [ + "douyin.com" + ], + "required_cookie_names": [ + "sessionid", + "sessionid_ss", + "sid_tt", + "sid_guard" + ], + "keepalive": { + "enabled": true, + "url": "https://www.douyin.com/", + "initial_delay_seconds": 300, + "interval_seconds": 900, + "timeout_ms": 60000, + "headless": true, + "real_chrome": true + }, + "members": [ + "content.comments.collect_douyin", + "content_marketing:data/tools/relogin_douyin.py", + "content_marketing:self_douyin_scraper.py" + ] + }, + "xingtu": { + "cdp_port": 22206, + "login_mode": "D", + "required_cookie_domains": [ + "douyin.com", + "oceanengine.com", + "xingtu.cn" + ], + "required_cookie_names": [ + "sessionid", + "sessionid_ss", + "sid_tt", + "sid_guard" + ], + "keepalive": { + "enabled": true, + "url": "https://www.xingtu.cn/ad/creator/market", + "initial_delay_seconds": 360, + "interval_seconds": 900, + "timeout_ms": 60000, + "headless": true, + "real_chrome": true + }, + "members": [ + "content_marketing:data/tools/relogin_xingtu.py" + ] + }, + "chanmama": { + "cdp_port": 22207, + "login_mode": "D", + "required_cookie_domains": [ + "chanmama.com" + ], + "required_cookie_names": [], + "keepalive": { + "enabled": true, + "url": "https://www.chanmama.com/", + "initial_delay_seconds": 420, + "interval_seconds": 900, + "timeout_ms": 60000, + "headless": true, + "real_chrome": true + }, + "members": [ + "content.metrics.collect_self_douyin" + ] + }, + "bilibili-content": { + "cdp_port": 22208, + "login_mode": "D", + "required_cookie_domains": [ + "bilibili.com" + ], + "required_cookie_names": [ + "SESSDATA" + ], + "keepalive": { + "enabled": true, + "url": "https://www.bilibili.com/", + "initial_delay_seconds": 480, + "interval_seconds": 900, + "timeout_ms": 60000, + "headless": true, + "real_chrome": true + }, + "members": [ + "content.comments.collect_bilibili", + "content.metrics.collect_self_bilibili", + "content_marketing:data/tools/relogin_bilibili.py" + ] + } + }, "scripts": { - "content_marketing:bilibili_comment_scraper.py": {"script_id":"content_marketing:bilibili_comment_scraper.py","state_key":"content_marketing:bilibili_comment_scraper.py","aliases":["content_marketing:bilibili_comment_scraper.py"],"cdp_port":22000}, - "content_marketing:bilibili_scraper.py": {"script_id":"content_marketing:bilibili_scraper.py","state_key":"content_marketing:bilibili_scraper.py","aliases":["content_marketing:bilibili_scraper.py"],"cdp_port":22001,"login_mode":"A","required_cookie_domains":["bilibili.com"],"required_cookie_names":["SESSDATA"]}, - "content.metrics.collect_self_douyin": {"script_id":"content_marketing:chanmama_scraper.py","state_key":"content_marketing:chanmama_scraper.py","aliases":["content_marketing:chanmama_scraper.py"],"cdp_port":22002,"login_mode":"C","required_cookie_domains":["chanmama.com"],"credential_env_names":["CHANMAMA_ACCOUNT","CHANMAMA_PASSWORD"]}, - "content.marketing_report.generate": {"script_id":"content_marketing:daily_marketing_report.py","state_key":"content_marketing:daily_marketing_report.py","aliases":["content_marketing:daily_marketing_report.py"],"cdp_port":22003}, - "content_marketing:data/tools/analyze_comments.py": {"script_id":"content_marketing:data/tools/analyze_comments.py","state_key":"content_marketing:data/tools/analyze_comments.py","aliases":["content_marketing:data/tools/analyze_comments.py"],"cdp_port":22004}, - "content_marketing:data/tools/analyze_note.py": {"script_id":"content_marketing:data/tools/analyze_note.py","state_key":"content_marketing:data/tools/analyze_note.py","aliases":["content_marketing:data/tools/analyze_note.py"],"cdp_port":22005}, - "content.comments.collect_bilibili": {"script_id":"content_marketing:data/tools/batch_rescrape_bilibili.py","state_key":"content_marketing:data/tools/batch_rescrape_bilibili.py","aliases":["content_marketing:data/tools/batch_rescrape_bilibili.py"],"cdp_port":22006,"login_mode":"A","required_cookie_domains":["bilibili.com"],"required_cookie_names":["SESSDATA"]}, - "content.comments.collect_douyin": {"script_id":"content_marketing:data/tools/batch_rescrape_douyin.py","state_key":"content_marketing:data/tools/batch_rescrape_douyin.py","aliases":["content_marketing:data/tools/batch_rescrape_douyin.py"],"cdp_port":22007,"login_mode":"A","required_cookie_domains":["douyin.com"],"required_cookie_names":["sessionid"]}, - "content.comments.collect_xiaohongshu": {"script_id":"content_marketing:data/tools/batch_rescrape_xiaohongshu.py","state_key":"content_marketing:data/tools/batch_rescrape_xiaohongshu.py","aliases":["content_marketing:data/tools/batch_rescrape_xiaohongshu.py"],"cdp_port":22008,"login_mode":"A","required_cookie_domains":["xiaohongshu.com"],"required_cookie_names":["web_session"]}, - "content_marketing:data/tools/check_status.py": {"script_id":"content_marketing:data/tools/check_status.py","state_key":"content_marketing:data/tools/check_status.py","aliases":["content_marketing:data/tools/check_status.py"],"cdp_port":22009}, - "content_marketing:data/tools/collect_note_metrics.py": {"script_id":"content_marketing:data/tools/collect_note_metrics.py","state_key":"content_marketing:data/tools/collect_note_metrics.py","aliases":["content_marketing:data/tools/collect_note_metrics.py"],"cdp_port":22010}, - "content_marketing:data/tools/daily_marketing_report.bat": {"script_id":"content_marketing:data/tools/daily_marketing_report.bat","state_key":"content_marketing:data/tools/daily_marketing_report.bat","aliases":["content_marketing:data/tools/daily_marketing_report.bat"],"cdp_port":22011}, - "content_marketing:data/tools/daily_run.bat": {"script_id":"content_marketing:data/tools/daily_run.bat","state_key":"content_marketing:data/tools/daily_run.bat","aliases":["content_marketing:data/tools/daily_run.bat"],"cdp_port":22012}, - "content_marketing:data/tools/daily_run_with_backfill.bat": {"script_id":"content_marketing:data/tools/daily_run_with_backfill.bat","state_key":"content_marketing:data/tools/daily_run_with_backfill.bat","aliases":["content_marketing:data/tools/daily_run_with_backfill.bat"],"cdp_port":22013}, - "content_marketing:data/tools/db.py": {"script_id":"content_marketing:data/tools/db.py","state_key":"content_marketing:data/tools/db.py","aliases":["content_marketing:data/tools/db.py"],"cdp_port":22014}, - "content_marketing:data/tools/feishu_comment_batch.py": {"script_id":"content_marketing:data/tools/feishu_comment_batch.py","state_key":"content_marketing:data/tools/feishu_comment_batch.py","aliases":["content_marketing:data/tools/feishu_comment_batch.py"],"cdp_port":22015}, - "content_marketing:data/tools/feishu_doc_writer.py": {"script_id":"content_marketing:data/tools/feishu_doc_writer.py","state_key":"content_marketing:data/tools/feishu_doc_writer.py","aliases":["content_marketing:data/tools/feishu_doc_writer.py"],"cdp_port":22016}, - "content_marketing:data/tools/friday_relogin.bat": {"script_id":"content_marketing:data/tools/friday_relogin.bat","state_key":"content_marketing:data/tools/friday_relogin.bat","aliases":["content_marketing:data/tools/friday_relogin.bat"],"cdp_port":22017}, - "content.login.refresh": {"script_id":"content_marketing:data/tools/friday_relogin_parallel.py","state_key":"content_marketing:data/tools/friday_relogin_parallel.py","aliases":["content_marketing:data/tools/friday_relogin_parallel.py"],"cdp_port":22018,"login_mode":"D"}, - "content.creator_report.generate": {"script_id":"content_marketing:data/tools/generate_creator_report.py","state_key":"content_marketing:data/tools/generate_creator_report.py","aliases":["content_marketing:data/tools/generate_creator_report.py"],"cdp_port":22019}, - "content_marketing:data/tools/init_db.py": {"script_id":"content_marketing:data/tools/init_db.py","state_key":"content_marketing:data/tools/init_db.py","aliases":["content_marketing:data/tools/init_db.py"],"cdp_port":22020}, - "content_marketing:data/tools/kill_project_chrome.ps1": {"script_id":"content_marketing:data/tools/kill_project_chrome.ps1","state_key":"content_marketing:data/tools/kill_project_chrome.ps1","aliases":["content_marketing:data/tools/kill_project_chrome.ps1"],"cdp_port":22021}, - "content_marketing:data/tools/list_project_chrome.ps1": {"script_id":"content_marketing:data/tools/list_project_chrome.ps1","state_key":"content_marketing:data/tools/list_project_chrome.ps1","aliases":["content_marketing:data/tools/list_project_chrome.ps1"],"cdp_port":22022}, - "content_marketing:data/tools/monday_self_run.bat": {"script_id":"content_marketing:data/tools/monday_self_run.bat","state_key":"content_marketing:data/tools/monday_self_run.bat","aliases":["content_marketing:data/tools/monday_self_run.bat"],"cdp_port":22023}, - "content_marketing:data/tools/monthly_creator_report.bat": {"script_id":"content_marketing:data/tools/monthly_creator_report.bat","state_key":"content_marketing:data/tools/monthly_creator_report.bat","aliases":["content_marketing:data/tools/monthly_creator_report.bat"],"cdp_port":22024}, - "content_marketing:data/tools/monthly_summary.bat": {"script_id":"content_marketing:data/tools/monthly_summary.bat","state_key":"content_marketing:data/tools/monthly_summary.bat","aliases":["content_marketing:data/tools/monthly_summary.bat"],"cdp_port":22025}, - "content_marketing:data/tools/pull_all_tables.py": {"script_id":"content_marketing:data/tools/pull_all_tables.py","state_key":"content_marketing:data/tools/pull_all_tables.py","aliases":["content_marketing:data/tools/pull_all_tables.py"],"cdp_port":22026}, - "content.mapping.rebuild": {"script_id":"content_marketing:data/tools/rebuild_mapping.py","state_key":"content_marketing:data/tools/rebuild_mapping.py","aliases":["content_marketing:data/tools/rebuild_mapping.py"],"cdp_port":22027}, - "content.mapping.refresh_self": {"script_id":"content_marketing:data/tools/refresh_self_mapping.py","state_key":"content_marketing:data/tools/refresh_self_mapping.py","aliases":["content_marketing:data/tools/refresh_self_mapping.py"],"cdp_port":22135}, - "content_marketing:data/tools/relogin_bilibili.py": {"script_id":"content_marketing:data/tools/relogin_bilibili.py","state_key":"content_marketing:data/tools/relogin_bilibili.py","aliases":["content_marketing:data/tools/relogin_bilibili.py"],"cdp_port":22028,"login_mode":"D","required_cookie_domains":["bilibili.com"],"required_cookie_names":["SESSDATA"]}, - "content_marketing:data/tools/relogin_douyin.py": {"script_id":"content_marketing:data/tools/relogin_douyin.py","state_key":"content_marketing:data/tools/relogin_douyin.py","aliases":["content_marketing:data/tools/relogin_douyin.py"],"cdp_port":22029,"login_mode":"D","required_cookie_domains":["douyin.com"],"required_cookie_names":["sessionid"]}, - "content_marketing:data/tools/relogin_pgy.py": {"script_id":"content_marketing:data/tools/relogin_pgy.py","state_key":"content_marketing:data/tools/relogin_pgy.py","aliases":["content_marketing:data/tools/relogin_pgy.py"],"cdp_port":22030,"login_mode":"D","required_cookie_domains":["xiaohongshu.com"]}, - "content_marketing:data/tools/relogin_xiaohongshu.py": {"script_id":"content_marketing:data/tools/relogin_xiaohongshu.py","state_key":"content_marketing:data/tools/relogin_xiaohongshu.py","aliases":["content_marketing:data/tools/relogin_xiaohongshu.py"],"cdp_port":22031,"login_mode":"D","required_cookie_domains":["xiaohongshu.com"],"required_cookie_names":["web_session"]}, - "content_marketing:data/tools/relogin_xingtu.py": {"script_id":"content_marketing:data/tools/relogin_xingtu.py","state_key":"content_marketing:data/tools/relogin_xingtu.py","aliases":["content_marketing:data/tools/relogin_xingtu.py"],"cdp_port":22032,"login_mode":"D","required_cookie_domains":["douyin.com","oceanengine.com","xingtu.cn"],"required_cookie_names":["sessionid","sessionid_ss","sid_tt","sid_guard"]}, - "content.failed.retry": {"script_id":"content_marketing:data/tools/retry_failed.py","state_key":"content_marketing:data/tools/retry_failed.py","aliases":["content_marketing:data/tools/retry_failed.py"],"cdp_port":22033}, - "content_marketing:data/tools/sync_cooperations.bat": {"script_id":"content_marketing:data/tools/sync_cooperations.bat","state_key":"content_marketing:data/tools/sync_cooperations.bat","aliases":["content_marketing:data/tools/sync_cooperations.bat"],"cdp_port":22034}, - "content.cooperations.sync": {"script_id":"content_marketing:data/tools/sync_cooperations.py","state_key":"content_marketing:data/tools/sync_cooperations.py","aliases":["content_marketing:data/tools/sync_cooperations.py"],"cdp_port":22035}, - "content.metrics.sync": {"script_id":"content_marketing:data/tools/sync_metrics_to_cmt_notes.py","state_key":"content_marketing:data/tools/sync_metrics_to_cmt_notes.py","aliases":["content_marketing:data/tools/sync_metrics_to_cmt_notes.py"],"cdp_port":22036}, - "content_marketing:data/tools/sync_style_categories.py": {"script_id":"content_marketing:data/tools/sync_style_categories.py","state_key":"content_marketing:data/tools/sync_style_categories.py","aliases":["content_marketing:data/tools/sync_style_categories.py"],"cdp_port":22037}, - "content_marketing:data/tools/validate_mapping.py": {"script_id":"content_marketing:data/tools/validate_mapping.py","state_key":"content_marketing:data/tools/validate_mapping.py","aliases":["content_marketing:data/tools/validate_mapping.py"],"cdp_port":22038}, - "content_marketing:data/tools/weekly_comment_scrape.bat": {"script_id":"content_marketing:data/tools/weekly_comment_scrape.bat","state_key":"content_marketing:data/tools/weekly_comment_scrape.bat","aliases":["content_marketing:data/tools/weekly_comment_scrape.bat"],"cdp_port":22039}, - "content_marketing:data/tools/weekly_summary.bat": {"script_id":"content_marketing:data/tools/weekly_summary.bat","state_key":"content_marketing:data/tools/weekly_summary.bat","aliases":["content_marketing:data/tools/weekly_summary.bat"],"cdp_port":22040}, - "content_marketing:data/tools/write_notes_to_doc.py": {"script_id":"content_marketing:data/tools/write_notes_to_doc.py","state_key":"content_marketing:data/tools/write_notes_to_doc.py","aliases":["content_marketing:data/tools/write_notes_to_doc.py"],"cdp_port":22041}, - "content_marketing:douyin_comment_scraper.py": {"script_id":"content_marketing:douyin_comment_scraper.py","state_key":"content_marketing:douyin_comment_scraper.py","aliases":["content_marketing:douyin_comment_scraper.py"],"cdp_port":22042}, - "content_marketing:feishu_mapping.py": {"script_id":"content_marketing:feishu_mapping.py","state_key":"content_marketing:feishu_mapping.py","aliases":["content_marketing:feishu_mapping.py"],"cdp_port":22043}, - "content_marketing:login_helper.py": {"script_id":"content_marketing:login_helper.py","state_key":"content_marketing:login_helper.py","aliases":["content_marketing:login_helper.py"],"cdp_port":22044}, - "content.summary.monthly": {"script_id":"content_marketing:monthly_summary_all.py","state_key":"content_marketing:monthly_summary_all.py","aliases":["content_marketing:monthly_summary_all.py"],"cdp_port":22045}, - "content_marketing:pgy_xhs_scraper.py": {"script_id":"content_marketing:pgy_xhs_scraper.py","state_key":"content_marketing:pgy_xhs_scraper.py","aliases":["content_marketing:pgy_xhs_scraper.py"],"cdp_port":22046}, - "content_marketing:pgy_xhs_scraper_v2.py": {"script_id":"content_marketing:pgy_xhs_scraper_v2.py","state_key":"content_marketing:pgy_xhs_scraper_v2.py","aliases":["content_marketing:pgy_xhs_scraper_v2.py"],"cdp_port":22047,"login_mode":"A","required_cookie_domains":["xiaohongshu.com"]}, - "content.metrics.collect_collaborators": {"script_id":"content_marketing:run_all.py","state_key":"content_marketing:run_all.py","aliases":["content_marketing:run_all.py"],"cdp_port":22048}, - "content.metrics.collect_self_bilibili": {"script_id":"content_marketing:self_bilibili_scraper.py","state_key":"content_marketing:self_bilibili_scraper.py","aliases":["content_marketing:self_bilibili_scraper.py"],"cdp_port":22049,"login_mode":"A","required_cookie_domains":["bilibili.com"],"required_cookie_names":["SESSDATA"]}, - "content_marketing:self_douyin_scraper.py": {"script_id":"content_marketing:self_douyin_scraper.py","state_key":"content_marketing:self_douyin_scraper.py","aliases":["content_marketing:self_douyin_scraper.py"],"cdp_port":22050,"login_mode":"A","required_cookie_domains":["douyin.com"],"required_cookie_names":["sessionid","sessionid_ss","sid_tt","sid_guard"]}, - "content.summary.weekly": {"script_id":"content_marketing:weekly_summary_all.py","state_key":"content_marketing:weekly_summary_all.py","aliases":["content_marketing:weekly_summary_all.py"],"cdp_port":22051}, - "content_marketing:weekly_summary_xingyun2.py": {"script_id":"content_marketing:weekly_summary_xingyun2.py","state_key":"content_marketing:weekly_summary_xingyun2.py","aliases":["content_marketing:weekly_summary_xingyun2.py"],"cdp_port":22052}, - "content_marketing:xiaohongshu_comment_scraper.py": {"script_id":"content_marketing:xiaohongshu_comment_scraper.py","state_key":"content_marketing:xiaohongshu_comment_scraper.py","aliases":["content_marketing:xiaohongshu_comment_scraper.py"],"cdp_port":22053}, - "content_marketing:xingtu_scraper.py": {"script_id":"content_marketing:xingtu_scraper.py","state_key":"content_marketing:xingtu_scraper.py","aliases":["content_marketing:xingtu_scraper.py"],"cdp_port":22054}, - "content_marketing:xingtu_scraper_v2.py": {"script_id":"content_marketing:xingtu_scraper_v2.py","state_key":"content_marketing:xingtu_scraper_v2.py","aliases":["content_marketing:xingtu_scraper_v2.py"],"cdp_port":22055,"login_mode":"A","required_cookie_domains":["douyin.com","oceanengine.com","xingtu.cn"],"required_cookie_names":["sessionid","sessionid_ss","sid_tt","sid_guard"]}, - "product_commerce:aggregate_daily_final.py": {"script_id":"product_commerce:aggregate_daily_final.py","state_key":"product_commerce:aggregate_daily_final.py","aliases":["product_commerce:aggregate_daily_final.py"],"cdp_port":22056}, - "product.style_analysis.run": {"script_id":"product_commerce:analyze_style_with_hermes.py","state_key":"product_commerce:analyze_style_with_hermes.py","aliases":["product_commerce:analyze_style_with_hermes.py"],"cdp_port":22057}, - "product_commerce:backfill_poseidon_sales.py": {"script_id":"product_commerce:backfill_poseidon_sales.py","state_key":"product_commerce:backfill_poseidon_sales.py","aliases":["product_commerce:backfill_poseidon_sales.py"],"cdp_port":22130}, - "product.backfill.run": {"script_id":"product_commerce:backfill_collect.py","state_key":"product_commerce:backfill_collect.py","aliases":["product_commerce:backfill_collect.py"],"cdp_port":22058}, - "product_commerce:backfill_one_day.py": {"script_id":"product_commerce:backfill_one_day.py","state_key":"product_commerce:backfill_one_day.py","aliases":["product_commerce:backfill_one_day.py"],"cdp_port":22059}, - "product_commerce:check_nine_day_decline.py": {"script_id":"product_commerce:check_nine_day_decline.py","state_key":"product_commerce:check_nine_day_decline.py","aliases":["product_commerce:check_nine_day_decline.py"],"cdp_port":22060}, - "product_commerce:collect_dy_market_rank.py": {"script_id":"product_commerce:collect_dy_market_rank.py","state_key":"product_commerce:collect_dy_market_rank.py","aliases":["product_commerce:collect_dy_market_rank.py"],"cdp_port":22061,"login_mode":"A","required_cookie_domains":["jinritemai.com"]}, - "product_commerce:collect_dy_persona_to_bitable.py": {"script_id":"product_commerce:collect_dy_persona_to_bitable.py","state_key":"product_commerce:collect_dy_persona_to_bitable.py","aliases":["product_commerce:collect_dy_persona_to_bitable.py"],"cdp_port":22062}, - "product_commerce:collect_erp_yesterday_metrics.py": {"script_id":"product_commerce:collect_erp_yesterday_metrics.py","state_key":"product_commerce:collect_erp_yesterday_metrics.py","aliases":["product_commerce:collect_erp_yesterday_metrics.py"],"cdp_port":22063,"login_mode":"C","required_cookie_domains":["erp321.com"]}, - "product_commerce:collect_jd_market_rank.py": {"script_id":"product_commerce:collect_jd_market_rank.py","state_key":"product_commerce:collect_jd_market_rank.py","aliases":["product_commerce:collect_jd_market_rank.py"],"cdp_port":22064,"login_mode":"C","required_cookie_domains":["jd.com"],"credential_env_names":["JD_PASSWORD"]}, - "product_commerce:collect_jd_persona_to_bitable.py": {"script_id":"product_commerce:collect_jd_persona_to_bitable.py","state_key":"product_commerce:collect_jd_persona_to_bitable.py","aliases":["product_commerce:collect_jd_persona_to_bitable.py"],"cdp_port":22065,"login_mode":"C","required_cookie_domains":["jd.com"],"credential_env_names":["JD_PASSWORD"]}, - "product_commerce:collect_persona_to_bitable.py": {"script_id":"product_commerce:collect_persona_to_bitable.py","state_key":"product_commerce:collect_persona_to_bitable.py","aliases":["product_commerce:collect_persona_to_bitable.py"],"cdp_port":22066}, - "product_commerce:collect_sycm_market_rank.py": {"script_id":"product_commerce:collect_sycm_market_rank.py","state_key":"product_commerce:collect_sycm_market_rank.py","aliases":["product_commerce:collect_sycm_market_rank.py"],"cdp_port":22067,"login_mode":"C","required_cookie_domains":["taobao.com"],"credential_env_names":["SYCM_ACCOUNT","SYCM_PASSWORD"]}, - "product.import.daily": {"script_id":"product_commerce:commands/import_daily.py","state_key":"product_commerce:commands/import_daily.py","aliases":["product_commerce:commands/import_daily.py"],"cdp_port":22068}, - "product.alert.run": {"script_id":"product_commerce:commands/run_alerts.py","state_key":"product_commerce:commands/run_alerts.py","aliases":["product_commerce:commands/run_alerts.py"],"cdp_port":22069}, - "product_commerce:db/sync_dim_style.py": {"script_id":"product_commerce:db/sync_dim_style.py","state_key":"product_commerce:db/sync_dim_style.py","aliases":["product_commerce:db/sync_dim_style.py"],"cdp_port":22070}, - "product_commerce:db/sync_sku_master.py": {"script_id":"product_commerce:db/sync_sku_master.py","state_key":"product_commerce:db/sync_sku_master.py","aliases":["product_commerce:db/sync_sku_master.py"],"cdp_port":22071}, - "product_commerce:dy_audience_profile_collect.py": {"script_id":"product_commerce:dy_audience_profile_collect.py","state_key":"product_commerce:dy_audience_profile_collect.py","aliases":["product_commerce:dy_audience_profile_collect.py"],"cdp_port":22072,"login_mode":"A","required_cookie_domains":["jinritemai.com"]}, - "product_commerce:dy_product_scraping.py": {"script_id":"product_commerce:dy_product_scraping.py","state_key":"product_commerce:dy_product_scraping.py","aliases":["product_commerce:dy_product_scraping.py"],"cdp_port":22073,"login_mode":"A","required_cookie_domains":["jinritemai.com"]}, - "product_commerce:erp_login_product_analysis.py": {"script_id":"product_commerce:erp_login_product_analysis.py","state_key":"product_commerce:erp_login_product_analysis.py","aliases":["product_commerce:erp_login_product_analysis.py"],"cdp_port":22074}, - "product_commerce:export_bitable_records.py": {"script_id":"product_commerce:export_bitable_records.py","state_key":"product_commerce:export_bitable_records.py","aliases":["product_commerce:export_bitable_records.py"],"cdp_port":22075}, - "product_commerce:import_product_daily.py": {"script_id":"product_commerce:import_product_daily.py","state_key":"product_commerce:import_product_daily.py","aliases":["product_commerce:import_product_daily.py"],"cdp_port":22076}, - "product_commerce:import_product_reviews.py": {"script_id":"product_commerce:import_product_reviews.py","state_key":"product_commerce:import_product_reviews.py","aliases":["product_commerce:import_product_reviews.py"],"cdp_port":22077}, - "product_commerce:insert_bitable_records.py": {"script_id":"product_commerce:insert_bitable_records.py","state_key":"product_commerce:insert_bitable_records.py","aliases":["product_commerce:insert_bitable_records.py"],"cdp_port":22078}, - "product_commerce:jd_main_image_collector.py": {"script_id":"product_commerce:jd_main_image_collector.py","state_key":"product_commerce:jd_main_image_collector.py","aliases":["product_commerce:jd_main_image_collector.py"],"cdp_port":22079,"login_mode":"C","required_cookie_domains":["jd.com"],"credential_env_names":["JD_PASSWORD"]}, - "product_commerce:jd_product_data_collector.py": {"script_id":"product_commerce:jd_product_data_collector.py","state_key":"product_commerce:jd_product_data_collector.py","aliases":["product_commerce:jd_product_data_collector.py"],"cdp_port":22080,"login_mode":"C","required_cookie_domains":["jd.com"],"credential_env_names":["JD_PASSWORD"]}, - "product_commerce:jd_self_inventory_sales_collector.py": {"script_id":"product_commerce:jd_self_inventory_sales_collector.py","state_key":"product_commerce:jd_self_inventory_sales_collector.py","aliases":["product_commerce:jd_self_inventory_sales_collector.py"],"cdp_port":22081}, - "product_commerce:market_rank_product_import.py": {"script_id":"product_commerce:market_rank_product_import.py","state_key":"product_commerce:market_rank_product_import.py","aliases":["product_commerce:market_rank_product_import.py"],"cdp_port":22131}, - "product.daily.orchestrate": {"script_id":"product_commerce:orchestrate_daily_collection.py","state_key":"product_commerce:orchestrate_daily_collection.py","aliases":["product_commerce:orchestrate_daily_collection.py"],"cdp_port":22082}, - "product.market_rank.orchestrate": {"script_id":"product_commerce:orchestrate_market_rank_collection.py","state_key":"product_commerce:orchestrate_market_rank_collection.py","aliases":["product_commerce:orchestrate_market_rank_collection.py"],"cdp_port":22083}, - "product.review.orchestrate": {"script_id":"product_commerce:orchestrate_review_collection.py","state_key":"product_commerce:orchestrate_review_collection.py","aliases":["product_commerce:orchestrate_review_collection.py"],"cdp_port":22084}, - "product_commerce:reapply_erp_override.py": {"script_id":"product_commerce:reapply_erp_override.py","state_key":"product_commerce:reapply_erp_override.py","aliases":["product_commerce:reapply_erp_override.py"],"cdp_port":22085}, - "product_commerce:rebuild_market_rank_documents.py": {"script_id":"product_commerce:rebuild_market_rank_documents.py","state_key":"product_commerce:rebuild_market_rank_documents.py","aliases":["product_commerce:rebuild_market_rank_documents.py"],"cdp_port":22086}, - "product_commerce:run_alerts_with_retry.py": {"script_id":"product_commerce:run_alerts_with_retry.py","state_key":"product_commerce:run_alerts_with_retry.py","aliases":["product_commerce:run_alerts_with_retry.py"],"cdp_port":22087}, - "product.persona.collect": {"script_id":"product_commerce:run_daily_persona.py","state_key":"product_commerce:run_daily_persona.py","aliases":["product_commerce:run_daily_persona.py"],"cdp_port":22088}, - "product.main_image.collect_jd": {"script_id":"product_commerce:run_weekly_jd_main_image.py","state_key":"product_commerce:run_weekly_jd_main_image.py","aliases":["product_commerce:run_weekly_jd_main_image.py"],"cdp_port":22089}, - "product.main_image.collect_tmall": {"script_id":"product_commerce:run_weekly_main_image.py","state_key":"product_commerce:run_weekly_main_image.py","aliases":["product_commerce:run_weekly_main_image.py"],"cdp_port":22090}, - "product.sales_sheet.sync": {"script_id":"product_commerce:sync_monthly_sales_sheet.py","state_key":"product_commerce:sync_monthly_sales_sheet.py","aliases":["product_commerce:sync_monthly_sales_sheet.py"],"cdp_port":22136,"login_mode":"C","required_cookie_domains":["erp321.com"]}, - "product_commerce:scripts/insert_jd_main_image_records.py": {"script_id":"product_commerce:scripts/insert_jd_main_image_records.py","state_key":"product_commerce:scripts/insert_jd_main_image_records.py","aliases":["product_commerce:scripts/insert_jd_main_image_records.py"],"cdp_port":22091}, - "product_commerce:scripts/insert_main_image_records.py": {"script_id":"product_commerce:scripts/insert_main_image_records.py","state_key":"product_commerce:scripts/insert_main_image_records.py","aliases":["product_commerce:scripts/insert_main_image_records.py"],"cdp_port":22092}, - "product_commerce:taobao_dmp_item_crowd_insight_screenshots.py": {"script_id":"product_commerce:taobao_dmp_item_crowd_insight_screenshots.py","state_key":"product_commerce:taobao_dmp_item_crowd_insight_screenshots.py","aliases":["product_commerce:taobao_dmp_item_crowd_insight_screenshots.py"],"cdp_port":22093,"login_mode":"C","required_cookie_domains":["taobao.com"],"credential_env_names":["SYCM_ACCOUNT","SYCM_PASSWORD"]}, - "product_commerce:taobao_sycm_collect.py": {"script_id":"product_commerce:taobao_sycm_collect.py","state_key":"product_commerce:taobao_sycm_collect.py","aliases":["product_commerce:taobao_sycm_collect.py"],"cdp_port":22094,"login_mode":"C","required_cookie_domains":["taobao.com"],"credential_env_names":["SYCM_ACCOUNT","SYCM_PASSWORD"]}, - "product_commerce:taobao_sycm_collect_backfill.py": {"script_id":"product_commerce:taobao_sycm_collect_backfill.py","state_key":"product_commerce:taobao_sycm_collect_backfill.py","aliases":["product_commerce:taobao_sycm_collect_backfill.py"],"cdp_port":22095,"login_mode":"C","required_cookie_domains":["taobao.com"],"credential_env_names":["SYCM_ACCOUNT","SYCM_PASSWORD"]}, - "product_commerce:taobao_sycm_products.py": {"script_id":"product_commerce:taobao_sycm_products.py","state_key":"product_commerce:taobao_sycm_products.py","aliases":["product_commerce:taobao_sycm_products.py"],"cdp_port":22096,"login_mode":"C","required_cookie_domains":["taobao.com"],"credential_env_names":["SYCM_ACCOUNT","SYCM_PASSWORD"]}, - "product_commerce:taobao_wanxiang_ai_creative_report.py": {"script_id":"product_commerce:taobao_wanxiang_ai_creative_report.py","state_key":"product_commerce:taobao_wanxiang_ai_creative_report.py","aliases":["product_commerce:taobao_wanxiang_ai_creative_report.py"],"cdp_port":22097,"login_mode":"C","required_cookie_domains":["taobao.com","alimama.com"],"credential_env_names":["SYCM_ACCOUNT","SYCM_PASSWORD"]}, - "product_commerce:upload_video_to_guanghe.py": {"script_id":"product_commerce:upload_video_to_guanghe.py","state_key":"product_commerce:upload_video_to_guanghe.py","aliases":["product_commerce:upload_video_to_guanghe.py"],"cdp_port":22098}, - "product_commerce:vendors/dy-data-flow/dy_store_competitor_store_scraping.py": {"script_id":"product_commerce:vendors/dy-data-flow/dy_store_competitor_store_scraping.py","state_key":"product_commerce:vendors/dy-data-flow/dy_store_competitor_store_scraping.py","aliases":["product_commerce:vendors/dy-data-flow/dy_store_competitor_store_scraping.py"],"cdp_port":22099,"login_mode":"A","required_cookie_domains":["jinritemai.com"]}, - "product_commerce:vendors/jd-data-flow/jd_data_collector.py": {"script_id":"product_commerce:vendors/jd-data-flow/jd_data_collector.py","state_key":"product_commerce:vendors/jd-data-flow/jd_data_collector.py","aliases":["product_commerce:vendors/jd-data-flow/jd_data_collector.py"],"cdp_port":22100,"login_mode":"B","required_cookie_domains":["jd.com"],"credential_env_names":["JD_PASSWORD"]}, - "product_commerce:vendors/jd-data-flow/jd_peer_product_data_collector.py": {"script_id":"product_commerce:vendors/jd-data-flow/jd_peer_product_data_collector.py","state_key":"product_commerce:vendors/jd-data-flow/jd_peer_product_data_collector.py","aliases":["product_commerce:vendors/jd-data-flow/jd_peer_product_data_collector.py"],"cdp_port":22101}, - "product_commerce:vendors/jd-data-flow/jd_product_data_collector.py": {"script_id":"product_commerce:vendors/jd-data-flow/jd_product_data_collector.py","state_key":"product_commerce:vendors/jd-data-flow/jd_product_data_collector.py","aliases":["product_commerce:vendors/jd-data-flow/jd_product_data_collector.py"],"cdp_port":22102,"login_mode":"C","required_cookie_domains":["jd.com"],"credential_env_names":["JD_PASSWORD"]}, - "product_commerce:weekly_aggregate.py": {"script_id":"product_commerce:weekly_aggregate.py","state_key":"product_commerce:weekly_aggregate.py","aliases":["product_commerce:weekly_aggregate.py"],"cdp_port":22103}, - "shop.douyin_price_appeal": {"script_id":"shop_intelligence:collectors/dy_store_competitor_store_scraping.py","state_key":"shop_intelligence:collectors/dy_store_competitor_store_scraping.py","aliases":["shop_intelligence:collectors/dy_store_competitor_store_scraping.py"],"cdp_port":22104,"login_mode":"A","required_cookie_domains":["jinritemai.com"]}, - "shop_intelligence:collectors/jd_data_collector.py": {"script_id":"shop_intelligence:collectors/jd_data_collector.py","state_key":"shop_intelligence:collectors/jd_data_collector.py","aliases":["shop_intelligence:collectors/jd_data_collector.py"],"cdp_port":22105,"login_mode":"B","required_cookie_domains":["jd.com"],"credential_env_names":["JD_PASSWORD"]}, - "shop_intelligence:collectors/jd_peer_store_data_collector.py": {"script_id":"shop_intelligence:collectors/jd_peer_store_data_collector.py","state_key":"shop_intelligence:collectors/jd_peer_store_data_collector.py","aliases":["shop_intelligence:collectors/jd_peer_store_data_collector.py"],"cdp_port":22106,"login_mode":"B","required_cookie_domains":["jd.com"],"credential_env_names":["JD_PASSWORD"]}, - "shop.jd_self_operated.collect_brand": {"script_id":"shop_intelligence:collectors/jd_self_operated_brand_daily.py","state_key":"shop_intelligence:collectors/jd_self_operated_brand_daily.py","aliases":["shop_intelligence:collectors/jd_self_operated_brand_daily.py"],"cdp_port":22132,"login_mode":"C","required_cookie_domains":["jd.com"],"credential_env_names":["JD_SELF_OPERATED_ACCOUNT","JD_SELF_OPERATED_PASSWORD"]}, - "shop.jd_self_operated.collect_product": {"script_id":"shop_intelligence:collectors/jd_self_operated_product_daily.py","state_key":"shop_intelligence:collectors/jd_self_operated_product_daily.py","aliases":["shop_intelligence:collectors/jd_self_operated_product_daily.py"],"cdp_port":22133,"login_mode":"C","required_cookie_domains":["jd.com"],"credential_env_names":["JD_SELF_OPERATED_ACCOUNT","JD_SELF_OPERATED_PASSWORD"]}, - "shop_intelligence:collectors/taobao_sycm.py": {"script_id":"shop_intelligence:collectors/taobao_sycm.py","state_key":"shop_intelligence:collectors/taobao_sycm.py","aliases":["shop_intelligence:collectors/taobao_sycm.py"],"cdp_port":22107,"login_mode":"C","required_cookie_domains":["taobao.com"],"credential_env_names":["SYCM_ACCOUNT","SYCM_PASSWORD"]}, - "shop.competitor.collect": {"script_id":"shop_intelligence:runners/run_peer_store.py","state_key":"shop_intelligence:runners/run_peer_store.py","aliases":["shop_intelligence:runners/run_peer_store.py"],"cdp_port":22108}, - "shop_intelligence:runners/run_jd_self_operated_daily.py": {"script_id":"shop_intelligence:runners/run_jd_self_operated_daily.py","state_key":"shop_intelligence:runners/run_jd_self_operated_daily.py","aliases":["shop_intelligence:runners/run_jd_self_operated_daily.py"],"cdp_port":22134}, - "shop.metrics.collect": {"script_id":"shop_intelligence:runners/run_shop.py","state_key":"shop_intelligence:runners/run_shop.py","aliases":["shop_intelligence:runners/run_shop.py"],"cdp_port":22109}, - "shop_intelligence:scripts/remove_scheduler.ps1": {"script_id":"shop_intelligence:scripts/remove_scheduler.ps1","state_key":"shop_intelligence:scripts/remove_scheduler.ps1","aliases":["shop_intelligence:scripts/remove_scheduler.ps1"],"cdp_port":22110}, - "shop_intelligence:scripts/setup_scheduler.ps1": {"script_id":"shop_intelligence:scripts/setup_scheduler.ps1","state_key":"shop_intelligence:scripts/setup_scheduler.ps1","aliases":["shop_intelligence:scripts/setup_scheduler.ps1"],"cdp_port":22111}, - "supply_chain:orchestrator/mcp_workflow.py": {"script_id":"supply_chain:orchestrator/mcp_workflow.py","state_key":"supply_chain:orchestrator/mcp_workflow.py","aliases":["supply_chain:orchestrator/mcp_workflow.py"],"cdp_port":22112}, - "supply_chain:orchestrator/monitor.py": {"script_id":"supply_chain:orchestrator/monitor.py","state_key":"supply_chain:orchestrator/monitor.py","aliases":["supply_chain:orchestrator/monitor.py"],"cdp_port":22113}, - "supply_chain:orchestrator/runner.py": {"script_id":"supply_chain:orchestrator/runner.py","state_key":"supply_chain:orchestrator/runner.py","aliases":["supply_chain:orchestrator/runner.py"],"cdp_port":22114}, - "supply_chain:orchestrator/scripts/ProductReplenishment.py": {"script_id":"supply_chain:orchestrator/scripts/ProductReplenishment.py","state_key":"supply_chain:orchestrator/scripts/ProductReplenishment.py","aliases":["supply_chain:orchestrator/scripts/ProductReplenishment.py"],"cdp_port":22115,"login_mode":"C","required_cookie_domains":["erp321.com"],"required_cookie_names":["u_id","u_co_id","ASP.NET_SessionId",".ASPXAUTH","token"],"credential_env_names":["GYXX_SUPPLY_ERP_PASSWORD"]}, - "supply_chain:orchestrator/scripts/PurchaseConfirmation.py": {"script_id":"supply_chain:orchestrator/scripts/PurchaseConfirmation.py","state_key":"supply_chain:orchestrator/scripts/PurchaseConfirmation.py","aliases":["supply_chain:orchestrator/scripts/PurchaseConfirmation.py"],"cdp_port":22116,"login_mode":"C","required_cookie_domains":["erp321.com"],"required_cookie_names":["ASP.NET_SessionId",".ASPXAUTH","token"],"credential_env_names":["GYXX_SUPPLY_ERP_PASSWORD"]}, - "supply_chain:orchestrator/scripts/PurchaseOrderUpdate.py": {"script_id":"supply_chain:orchestrator/scripts/PurchaseOrderUpdate.py","state_key":"supply_chain:orchestrator/scripts/PurchaseOrderUpdate.py","aliases":["supply_chain:orchestrator/scripts/PurchaseOrderUpdate.py"],"cdp_port":22117,"login_mode":"C","required_cookie_domains":["erp321.com"],"required_cookie_names":["ASP.NET_SessionId",".ASPXAUTH","token"],"credential_env_names":["GYXX_SUPPLY_ERP_PASSWORD"]}, - "supply_chain:orchestrator/scripts/batch_process.py": {"script_id":"supply_chain:orchestrator/scripts/batch_process.py","state_key":"supply_chain:orchestrator/scripts/batch_process.py","aliases":["supply_chain:orchestrator/scripts/batch_process.py"],"cdp_port":22118}, - "supply_chain:orchestrator/scripts/collect_confirmation.ps1": {"script_id":"supply_chain:orchestrator/scripts/collect_confirmation.ps1","state_key":"supply_chain:orchestrator/scripts/collect_confirmation.ps1","aliases":["supply_chain:orchestrator/scripts/collect_confirmation.ps1"],"cdp_port":22119}, - "supply_chain:orchestrator/scripts/collect_purchase_order_update.ps1": {"script_id":"supply_chain:orchestrator/scripts/collect_purchase_order_update.ps1","state_key":"supply_chain:orchestrator/scripts/collect_purchase_order_update.ps1","aliases":["supply_chain:orchestrator/scripts/collect_purchase_order_update.ps1"],"cdp_port":22120}, - "supply_chain:orchestrator/scripts/collect_replenishment.ps1": {"script_id":"supply_chain:orchestrator/scripts/collect_replenishment.ps1","state_key":"supply_chain:orchestrator/scripts/collect_replenishment.ps1","aliases":["supply_chain:orchestrator/scripts/collect_replenishment.ps1"],"cdp_port":22121}, - "supply_chain:orchestrator/scripts/insert_replenishment_bitable.py": {"script_id":"supply_chain:orchestrator/scripts/insert_replenishment_bitable.py","state_key":"supply_chain:orchestrator/scripts/insert_replenishment_bitable.py","aliases":["supply_chain:orchestrator/scripts/insert_replenishment_bitable.py"],"cdp_port":22122}, - "supply_chain:orchestrator/scripts/send_card_notification.py": {"script_id":"supply_chain:orchestrator/scripts/send_card_notification.py","state_key":"supply_chain:orchestrator/scripts/send_card_notification.py","aliases":["supply_chain:orchestrator/scripts/send_card_notification.py"],"cdp_port":22123}, - "supply_chain:orchestrator/scripts/trigger_purchase_order_update.py": {"script_id":"supply_chain:orchestrator/scripts/trigger_purchase_order_update.py","state_key":"supply_chain:orchestrator/scripts/trigger_purchase_order_update.py","aliases":["supply_chain:orchestrator/scripts/trigger_purchase_order_update.py"],"cdp_port":22124}, - "supply_chain:orchestrator/sql/backfill_history.py": {"script_id":"supply_chain:orchestrator/sql/backfill_history.py","state_key":"supply_chain:orchestrator/sql/backfill_history.py","aliases":["supply_chain:orchestrator/sql/backfill_history.py"],"cdp_port":22125}, - "supply.workflow.run": {"script_id":"supply_chain:run.py","state_key":"supply_chain:run.py","aliases":["supply_chain:run.py"],"cdp_port":22126}, - "supply_chain:scripts/purchase-confirmation.bat": {"script_id":"supply_chain:scripts/purchase-confirmation.bat","state_key":"supply_chain:scripts/purchase-confirmation.bat","aliases":["supply_chain:scripts/purchase-confirmation.bat"],"cdp_port":22127}, - "supply_chain:scripts/replenishment-alert.bat": {"script_id":"supply_chain:scripts/replenishment-alert.bat","state_key":"supply_chain:scripts/replenishment-alert.bat","aliases":["supply_chain:scripts/replenishment-alert.bat"],"cdp_port":22128}, - "supply_chain:scripts/replenishment.bat": {"script_id":"supply_chain:scripts/replenishment.bat","state_key":"supply_chain:scripts/replenishment.bat","aliases":["supply_chain:scripts/replenishment.bat"],"cdp_port":22129} + "content_marketing:bilibili_comment_scraper.py": { + "script_id": "content_marketing:bilibili_comment_scraper.py", + "state_key": "content_marketing:bilibili_comment_scraper.py", + "aliases": [ + "content_marketing:bilibili_comment_scraper.py" + ], + "cdp_port": 22000 + }, + "content_marketing:bilibili_scraper.py": { + "script_id": "content_marketing:bilibili_scraper.py", + "state_key": "content_marketing:bilibili_scraper.py", + "aliases": [ + "content_marketing:bilibili_scraper.py" + ], + "cdp_port": 22001 + }, + "content.metrics.collect_self_douyin": { + "script_id": "content_marketing:chanmama_scraper.py", + "state_key": "content_marketing:chanmama_scraper.py", + "aliases": [ + "content_marketing:chanmama_scraper.py" + ], + "cdp_port": 22002, + "login_mode": "C", + "required_cookie_domains": [ + "chanmama.com" + ], + "credential_env_names": [ + "CHANMAMA_ACCOUNT", + "CHANMAMA_PASSWORD" + ] + }, + "content.marketing_report.generate": { + "script_id": "content_marketing:daily_marketing_report.py", + "state_key": "content_marketing:daily_marketing_report.py", + "aliases": [ + "content_marketing:daily_marketing_report.py" + ], + "cdp_port": 22003 + }, + "content_marketing:data/tools/analyze_comments.py": { + "script_id": "content_marketing:data/tools/analyze_comments.py", + "state_key": "content_marketing:data/tools/analyze_comments.py", + "aliases": [ + "content_marketing:data/tools/analyze_comments.py" + ], + "cdp_port": 22004 + }, + "content_marketing:data/tools/analyze_note.py": { + "script_id": "content_marketing:data/tools/analyze_note.py", + "state_key": "content_marketing:data/tools/analyze_note.py", + "aliases": [ + "content_marketing:data/tools/analyze_note.py" + ], + "cdp_port": 22005 + }, + "content.comments.collect_bilibili": { + "script_id": "content_marketing:data/tools/batch_rescrape_bilibili.py", + "state_key": "content_marketing:data/tools/batch_rescrape_bilibili.py", + "aliases": [ + "content_marketing:data/tools/batch_rescrape_bilibili.py" + ], + "cdp_port": 22006, + "login_mode": "A", + "required_cookie_domains": [ + "bilibili.com" + ], + "required_cookie_names": [ + "SESSDATA" + ] + }, + "content.comments.collect_douyin": { + "script_id": "content_marketing:data/tools/batch_rescrape_douyin.py", + "state_key": "content_marketing:data/tools/batch_rescrape_douyin.py", + "aliases": [ + "content_marketing:data/tools/batch_rescrape_douyin.py" + ], + "cdp_port": 22007, + "login_mode": "A", + "required_cookie_domains": [ + "douyin.com" + ], + "required_cookie_names": [ + "sessionid" + ] + }, + "content.comments.collect_xiaohongshu": { + "script_id": "content_marketing:data/tools/batch_rescrape_xiaohongshu.py", + "state_key": "content_marketing:data/tools/batch_rescrape_xiaohongshu.py", + "aliases": [ + "content_marketing:data/tools/batch_rescrape_xiaohongshu.py" + ], + "cdp_port": 22008, + "login_mode": "A", + "required_cookie_domains": [ + "xiaohongshu.com" + ], + "required_cookie_names": [ + "web_session" + ] + }, + "content_marketing:data/tools/check_status.py": { + "script_id": "content_marketing:data/tools/check_status.py", + "state_key": "content_marketing:data/tools/check_status.py", + "aliases": [ + "content_marketing:data/tools/check_status.py" + ], + "cdp_port": 22009 + }, + "content_marketing:data/tools/collect_note_metrics.py": { + "script_id": "content_marketing:data/tools/collect_note_metrics.py", + "state_key": "content_marketing:data/tools/collect_note_metrics.py", + "aliases": [ + "content_marketing:data/tools/collect_note_metrics.py" + ], + "cdp_port": 22010 + }, + "content_marketing:data/tools/daily_marketing_report.bat": { + "script_id": "content_marketing:data/tools/daily_marketing_report.bat", + "state_key": "content_marketing:data/tools/daily_marketing_report.bat", + "aliases": [ + "content_marketing:data/tools/daily_marketing_report.bat" + ], + "cdp_port": 22011 + }, + "content_marketing:data/tools/daily_run.bat": { + "script_id": "content_marketing:data/tools/daily_run.bat", + "state_key": "content_marketing:data/tools/daily_run.bat", + "aliases": [ + "content_marketing:data/tools/daily_run.bat" + ], + "cdp_port": 22012 + }, + "content_marketing:data/tools/daily_run_with_backfill.bat": { + "script_id": "content_marketing:data/tools/daily_run_with_backfill.bat", + "state_key": "content_marketing:data/tools/daily_run_with_backfill.bat", + "aliases": [ + "content_marketing:data/tools/daily_run_with_backfill.bat" + ], + "cdp_port": 22013 + }, + "content_marketing:data/tools/db.py": { + "script_id": "content_marketing:data/tools/db.py", + "state_key": "content_marketing:data/tools/db.py", + "aliases": [ + "content_marketing:data/tools/db.py" + ], + "cdp_port": 22014 + }, + "content_marketing:data/tools/feishu_comment_batch.py": { + "script_id": "content_marketing:data/tools/feishu_comment_batch.py", + "state_key": "content_marketing:data/tools/feishu_comment_batch.py", + "aliases": [ + "content_marketing:data/tools/feishu_comment_batch.py" + ], + "cdp_port": 22015 + }, + "content_marketing:data/tools/feishu_doc_writer.py": { + "script_id": "content_marketing:data/tools/feishu_doc_writer.py", + "state_key": "content_marketing:data/tools/feishu_doc_writer.py", + "aliases": [ + "content_marketing:data/tools/feishu_doc_writer.py" + ], + "cdp_port": 22016 + }, + "content_marketing:data/tools/friday_relogin.bat": { + "script_id": "content_marketing:data/tools/friday_relogin.bat", + "state_key": "content_marketing:data/tools/friday_relogin.bat", + "aliases": [ + "content_marketing:data/tools/friday_relogin.bat" + ], + "cdp_port": 22017 + }, + "content.login.refresh": { + "script_id": "content_marketing:data/tools/friday_relogin_parallel.py", + "state_key": "content_marketing:data/tools/friday_relogin_parallel.py", + "aliases": [ + "content_marketing:data/tools/friday_relogin_parallel.py" + ], + "cdp_port": 22018, + "login_mode": "D" + }, + "content.creator_report.generate": { + "script_id": "content_marketing:data/tools/generate_creator_report.py", + "state_key": "content_marketing:data/tools/generate_creator_report.py", + "aliases": [ + "content_marketing:data/tools/generate_creator_report.py" + ], + "cdp_port": 22019 + }, + "content_marketing:data/tools/init_db.py": { + "script_id": "content_marketing:data/tools/init_db.py", + "state_key": "content_marketing:data/tools/init_db.py", + "aliases": [ + "content_marketing:data/tools/init_db.py" + ], + "cdp_port": 22020 + }, + "content_marketing:data/tools/kill_project_chrome.ps1": { + "script_id": "content_marketing:data/tools/kill_project_chrome.ps1", + "state_key": "content_marketing:data/tools/kill_project_chrome.ps1", + "aliases": [ + "content_marketing:data/tools/kill_project_chrome.ps1" + ], + "cdp_port": 22021 + }, + "content_marketing:data/tools/list_project_chrome.ps1": { + "script_id": "content_marketing:data/tools/list_project_chrome.ps1", + "state_key": "content_marketing:data/tools/list_project_chrome.ps1", + "aliases": [ + "content_marketing:data/tools/list_project_chrome.ps1" + ], + "cdp_port": 22022 + }, + "content_marketing:data/tools/monday_self_run.bat": { + "script_id": "content_marketing:data/tools/monday_self_run.bat", + "state_key": "content_marketing:data/tools/monday_self_run.bat", + "aliases": [ + "content_marketing:data/tools/monday_self_run.bat" + ], + "cdp_port": 22023 + }, + "content_marketing:data/tools/monthly_creator_report.bat": { + "script_id": "content_marketing:data/tools/monthly_creator_report.bat", + "state_key": "content_marketing:data/tools/monthly_creator_report.bat", + "aliases": [ + "content_marketing:data/tools/monthly_creator_report.bat" + ], + "cdp_port": 22024 + }, + "content_marketing:data/tools/monthly_summary.bat": { + "script_id": "content_marketing:data/tools/monthly_summary.bat", + "state_key": "content_marketing:data/tools/monthly_summary.bat", + "aliases": [ + "content_marketing:data/tools/monthly_summary.bat" + ], + "cdp_port": 22025 + }, + "content_marketing:data/tools/pull_all_tables.py": { + "script_id": "content_marketing:data/tools/pull_all_tables.py", + "state_key": "content_marketing:data/tools/pull_all_tables.py", + "aliases": [ + "content_marketing:data/tools/pull_all_tables.py" + ], + "cdp_port": 22026 + }, + "content.mapping.rebuild": { + "script_id": "content_marketing:data/tools/rebuild_mapping.py", + "state_key": "content_marketing:data/tools/rebuild_mapping.py", + "aliases": [ + "content_marketing:data/tools/rebuild_mapping.py" + ], + "cdp_port": 22027 + }, + "content.mapping.refresh_self": { + "script_id": "content_marketing:data/tools/refresh_self_mapping.py", + "state_key": "content_marketing:data/tools/refresh_self_mapping.py", + "aliases": [ + "content_marketing:data/tools/refresh_self_mapping.py" + ], + "cdp_port": 22135 + }, + "content_marketing:data/tools/relogin_bilibili.py": { + "script_id": "content_marketing:data/tools/relogin_bilibili.py", + "state_key": "content_marketing:data/tools/relogin_bilibili.py", + "aliases": [ + "content_marketing:data/tools/relogin_bilibili.py" + ], + "cdp_port": 22028, + "login_mode": "D", + "required_cookie_domains": [ + "bilibili.com" + ], + "required_cookie_names": [ + "SESSDATA" + ] + }, + "content_marketing:data/tools/relogin_douyin.py": { + "script_id": "content_marketing:data/tools/relogin_douyin.py", + "state_key": "content_marketing:data/tools/relogin_douyin.py", + "aliases": [ + "content_marketing:data/tools/relogin_douyin.py" + ], + "cdp_port": 22029, + "login_mode": "D", + "required_cookie_domains": [ + "douyin.com" + ], + "required_cookie_names": [ + "sessionid" + ] + }, + "content_marketing:data/tools/relogin_pgy.py": { + "script_id": "content_marketing:data/tools/relogin_pgy.py", + "state_key": "content_marketing:data/tools/relogin_pgy.py", + "aliases": [ + "content_marketing:data/tools/relogin_pgy.py" + ], + "cdp_port": 22030, + "login_mode": "D", + "required_cookie_domains": [ + "xiaohongshu.com" + ] + }, + "content_marketing:data/tools/relogin_xiaohongshu.py": { + "script_id": "content_marketing:data/tools/relogin_xiaohongshu.py", + "state_key": "content_marketing:data/tools/relogin_xiaohongshu.py", + "aliases": [ + "content_marketing:data/tools/relogin_xiaohongshu.py" + ], + "cdp_port": 22031, + "login_mode": "D", + "required_cookie_domains": [ + "xiaohongshu.com" + ], + "required_cookie_names": [ + "web_session" + ] + }, + "content_marketing:data/tools/relogin_xingtu.py": { + "script_id": "content_marketing:data/tools/relogin_xingtu.py", + "state_key": "content_marketing:data/tools/relogin_xingtu.py", + "aliases": [ + "content_marketing:data/tools/relogin_xingtu.py" + ], + "cdp_port": 22032, + "login_mode": "D", + "required_cookie_domains": [ + "douyin.com", + "oceanengine.com", + "xingtu.cn" + ], + "required_cookie_names": [ + "sessionid", + "sessionid_ss", + "sid_tt", + "sid_guard" + ] + }, + "content.failed.retry": { + "script_id": "content_marketing:data/tools/retry_failed.py", + "state_key": "content_marketing:data/tools/retry_failed.py", + "aliases": [ + "content_marketing:data/tools/retry_failed.py" + ], + "cdp_port": 22033 + }, + "content_marketing:data/tools/sync_cooperations.bat": { + "script_id": "content_marketing:data/tools/sync_cooperations.bat", + "state_key": "content_marketing:data/tools/sync_cooperations.bat", + "aliases": [ + "content_marketing:data/tools/sync_cooperations.bat" + ], + "cdp_port": 22034 + }, + "content.metrics.sync": { + "script_id": "content_marketing:data/tools/sync_metrics_to_cmt_notes.py", + "state_key": "content_marketing:data/tools/sync_metrics_to_cmt_notes.py", + "aliases": [ + "content_marketing:data/tools/sync_metrics_to_cmt_notes.py" + ], + "cdp_port": 22036 + }, + "content_marketing:data/tools/sync_style_categories.py": { + "script_id": "content_marketing:data/tools/sync_style_categories.py", + "state_key": "content_marketing:data/tools/sync_style_categories.py", + "aliases": [ + "content_marketing:data/tools/sync_style_categories.py" + ], + "cdp_port": 22037 + }, + "content_marketing:data/tools/validate_mapping.py": { + "script_id": "content_marketing:data/tools/validate_mapping.py", + "state_key": "content_marketing:data/tools/validate_mapping.py", + "aliases": [ + "content_marketing:data/tools/validate_mapping.py" + ], + "cdp_port": 22038 + }, + "content_marketing:data/tools/weekly_comment_scrape.bat": { + "script_id": "content_marketing:data/tools/weekly_comment_scrape.bat", + "state_key": "content_marketing:data/tools/weekly_comment_scrape.bat", + "aliases": [ + "content_marketing:data/tools/weekly_comment_scrape.bat" + ], + "cdp_port": 22039 + }, + "content_marketing:data/tools/weekly_summary.bat": { + "script_id": "content_marketing:data/tools/weekly_summary.bat", + "state_key": "content_marketing:data/tools/weekly_summary.bat", + "aliases": [ + "content_marketing:data/tools/weekly_summary.bat" + ], + "cdp_port": 22040 + }, + "content_marketing:data/tools/write_notes_to_doc.py": { + "script_id": "content_marketing:data/tools/write_notes_to_doc.py", + "state_key": "content_marketing:data/tools/write_notes_to_doc.py", + "aliases": [ + "content_marketing:data/tools/write_notes_to_doc.py" + ], + "cdp_port": 22041 + }, + "content_marketing:douyin_comment_scraper.py": { + "script_id": "content_marketing:douyin_comment_scraper.py", + "state_key": "content_marketing:douyin_comment_scraper.py", + "aliases": [ + "content_marketing:douyin_comment_scraper.py" + ], + "cdp_port": 22042 + }, + "content_marketing:feishu_mapping.py": { + "script_id": "content_marketing:feishu_mapping.py", + "state_key": "content_marketing:feishu_mapping.py", + "aliases": [ + "content_marketing:feishu_mapping.py" + ], + "cdp_port": 22043 + }, + "content_marketing:login_helper.py": { + "script_id": "content_marketing:login_helper.py", + "state_key": "content_marketing:login_helper.py", + "aliases": [ + "content_marketing:login_helper.py" + ], + "cdp_port": 22044 + }, + "content.summary.monthly": { + "script_id": "content_marketing:monthly_summary_all.py", + "state_key": "content_marketing:monthly_summary_all.py", + "aliases": [ + "content_marketing:monthly_summary_all.py" + ], + "cdp_port": 22045 + }, + "content_marketing:pgy_xhs_scraper.py": { + "script_id": "content_marketing:pgy_xhs_scraper.py", + "state_key": "content_marketing:pgy_xhs_scraper.py", + "aliases": [ + "content_marketing:pgy_xhs_scraper.py" + ], + "cdp_port": 22046 + }, + "content_marketing:pgy_xhs_scraper_v2.py": { + "script_id": "content_marketing:pgy_xhs_scraper_v2.py", + "state_key": "content_marketing:pgy_xhs_scraper_v2.py", + "aliases": [ + "content_marketing:pgy_xhs_scraper_v2.py" + ], + "cdp_port": 22047, + "login_mode": "A", + "required_cookie_domains": [ + "xiaohongshu.com" + ] + }, + "content.metrics.collect_collaborators": { + "script_id": "content_marketing:run_all.py", + "state_key": "content_marketing:run_all.py", + "aliases": [ + "content_marketing:run_all.py" + ], + "cdp_port": 22048 + }, + "content.metrics.collect_self_bilibili": { + "script_id": "content_marketing:self_bilibili_scraper.py", + "state_key": "content_marketing:self_bilibili_scraper.py", + "aliases": [ + "content_marketing:self_bilibili_scraper.py" + ], + "cdp_port": 22049 + }, + "content_marketing:self_douyin_scraper.py": { + "script_id": "content_marketing:self_douyin_scraper.py", + "state_key": "content_marketing:self_douyin_scraper.py", + "aliases": [ + "content_marketing:self_douyin_scraper.py" + ], + "cdp_port": 22050, + "login_mode": "A", + "required_cookie_domains": [ + "douyin.com" + ], + "required_cookie_names": [ + "sessionid", + "sessionid_ss", + "sid_tt", + "sid_guard" + ] + }, + "content.summary.weekly": { + "script_id": "content_marketing:weekly_summary_all.py", + "state_key": "content_marketing:weekly_summary_all.py", + "aliases": [ + "content_marketing:weekly_summary_all.py" + ], + "cdp_port": 22051 + }, + "content_marketing:weekly_summary_xingyun2.py": { + "script_id": "content_marketing:weekly_summary_xingyun2.py", + "state_key": "content_marketing:weekly_summary_xingyun2.py", + "aliases": [ + "content_marketing:weekly_summary_xingyun2.py" + ], + "cdp_port": 22052 + }, + "content_marketing:xiaohongshu_comment_scraper.py": { + "script_id": "content_marketing:xiaohongshu_comment_scraper.py", + "state_key": "content_marketing:xiaohongshu_comment_scraper.py", + "aliases": [ + "content_marketing:xiaohongshu_comment_scraper.py" + ], + "cdp_port": 22053 + }, + "content_marketing:xingtu_scraper.py": { + "script_id": "content_marketing:xingtu_scraper.py", + "state_key": "content_marketing:xingtu_scraper.py", + "aliases": [ + "content_marketing:xingtu_scraper.py" + ], + "cdp_port": 22054 + }, + "content_marketing:xingtu_scraper_v2.py": { + "script_id": "content_marketing:xingtu_scraper_v2.py", + "state_key": "content_marketing:xingtu_scraper_v2.py", + "aliases": [ + "content_marketing:xingtu_scraper_v2.py" + ], + "cdp_port": 22055, + "account": "douyin-shop", + "login_mode": "A", + "required_cookie_domains": [ + "jinritemai.com" + ], + "required_cookie_names": [ + "sessionid", + "sessionid_ss", + "sid_tt", + "sid_guard" + ] + }, + "product_commerce:aggregate_daily_final.py": { + "script_id": "product_commerce:aggregate_daily_final.py", + "state_key": "product_commerce:aggregate_daily_final.py", + "aliases": [ + "product_commerce:aggregate_daily_final.py" + ], + "cdp_port": 22056 + }, + "product.style_analysis.run": { + "script_id": "product_commerce:analyze_style.py", + "state_key": "product_commerce:analyze_style.py", + "aliases": [ + "product_commerce:analyze_style.py" + ], + "cdp_port": 22057 + }, + "product_commerce:backfill_poseidon_sales.py": { + "script_id": "product_commerce:backfill_poseidon_sales.py", + "state_key": "product_commerce:backfill_poseidon_sales.py", + "aliases": [ + "product_commerce:backfill_poseidon_sales.py" + ], + "cdp_port": 22130 + }, + "product.backfill.run": { + "script_id": "product_commerce:backfill_collect.py", + "state_key": "product_commerce:backfill_collect.py", + "aliases": [ + "product_commerce:backfill_collect.py" + ], + "cdp_port": 22058 + }, + "product_commerce:backfill_one_day.py": { + "script_id": "product_commerce:backfill_one_day.py", + "state_key": "product_commerce:backfill_one_day.py", + "aliases": [ + "product_commerce:backfill_one_day.py" + ], + "cdp_port": 22059 + }, + "product_commerce:check_nine_day_decline.py": { + "script_id": "product_commerce:check_nine_day_decline.py", + "state_key": "product_commerce:check_nine_day_decline.py", + "aliases": [ + "product_commerce:check_nine_day_decline.py" + ], + "cdp_port": 22060 + }, + "product_commerce:collect_dy_market_rank.py": { + "script_id": "product_commerce:collect_dy_market_rank.py", + "state_key": "product_commerce:collect_dy_market_rank.py", + "aliases": [ + "product_commerce:collect_dy_market_rank.py" + ], + "cdp_port": 22061, + "login_mode": "A", + "required_cookie_domains": [ + "jinritemai.com" + ], + "account": "douyin-shop" + }, + "product.douyin_qianchuan_ads.collect": { + "script_id": "product_commerce:collect_douyin_qianchuan_ads.py", + "state_key": "product_commerce:collect_douyin_qianchuan_ads.py", + "aliases": [ + "product_commerce:collect_douyin_qianchuan_ads.py" + ], + "cdp_port": 22146, + "login_mode": "A", + "required_cookie_domains": [ + "jinritemai.com" + ], + "account": "douyin-shop" + }, + "product.import.douyin_qianchuan_ads": { + "script_id": "product_commerce:import_douyin_qianchuan_ads.py", + "state_key": "product_commerce:import_douyin_qianchuan_ads.py", + "aliases": [ + "product_commerce:import_douyin_qianchuan_ads.py" + ], + "cdp_port": 22147 + }, + "product_commerce:collect_dy_persona_to_bitable.py": { + "script_id": "product_commerce:collect_dy_persona_to_bitable.py", + "state_key": "product_commerce:collect_dy_persona_to_bitable.py", + "aliases": [ + "product_commerce:collect_dy_persona_to_bitable.py" + ], + "cdp_port": 22062 + }, + "product_commerce:collect_erp_yesterday_metrics.py": { + "script_id": "product_commerce:collect_erp_yesterday_metrics.py", + "state_key": "product_commerce:collect_erp_yesterday_metrics.py", + "aliases": [ + "product_commerce:collect_erp_yesterday_metrics.py" + ], + "cdp_port": 22063, + "login_mode": "C", + "required_cookie_domains": [ + "erp321.com" + ] + }, + "product_commerce:collect_jd_market_rank.py": { + "script_id": "product_commerce:collect_jd_market_rank.py", + "state_key": "product_commerce:collect_jd_market_rank.py", + "aliases": [ + "product_commerce:collect_jd_market_rank.py" + ], + "cdp_port": 22064, + "login_mode": "C", + "required_cookie_domains": [ + "jd.com" + ], + "required_cookie_names": [ + "pin", + "thor" + ], + "credential_env_names": [ + "JD_MARKET_SHOP", + "JD_MARKET_PASSWORD" + ], + "account": "jd-market-rank" + }, + "product_commerce:collect_jd_persona_to_bitable.py": { + "script_id": "product_commerce:collect_jd_persona_to_bitable.py", + "state_key": "product_commerce:collect_jd_persona_to_bitable.py", + "aliases": [ + "product_commerce:collect_jd_persona_to_bitable.py" + ], + "cdp_port": 22065, + "login_mode": "C", + "required_cookie_domains": [ + "jd.com" + ], + "credential_env_names": [ + "JD_PASSWORD" + ] + }, + "product_commerce:collect_persona_to_bitable.py": { + "script_id": "product_commerce:collect_persona_to_bitable.py", + "state_key": "product_commerce:collect_persona_to_bitable.py", + "aliases": [ + "product_commerce:collect_persona_to_bitable.py" + ], + "cdp_port": 22066 + }, + "product_commerce:collect_sycm_market_rank.py": { + "script_id": "product_commerce:collect_sycm_market_rank.py", + "state_key": "product_commerce:collect_sycm_market_rank.py", + "aliases": [ + "product_commerce:collect_sycm_market_rank.py" + ], + "cdp_port": 22067, + "login_mode": "C", + "required_cookie_domains": [ + "taobao.com" + ], + "credential_env_names": [ + "SYCM_ACCOUNT", + "SYCM_PASSWORD" + ] + }, + "product.import.daily": { + "script_id": "product_commerce:commands/import_daily.py", + "state_key": "product_commerce:commands/import_daily.py", + "aliases": [ + "product_commerce:commands/import_daily.py" + ], + "cdp_port": 22068 + }, + "product.tmall_wanxiang_ads.collect": { + "script_id": "product_commerce:collect_tmall_wanxiang_ads.py", + "state_key": "product_commerce:collect_tmall_wanxiang_ads.py", + "aliases": [ + "product_commerce:collect_tmall_wanxiang_ads.py" + ], + "cdp_port": 22141, + "login_mode": "C", + "required_cookie_domains": [ + "taobao.com", + "alimama.com" + ], + "credential_env_names": [ + "WANXIANG_ACCOUNT", + "WANXIANG_PASSWORD" + ] + }, + "product.import.tmall_ads": { + "script_id": "product_commerce:import_tmall_product_ads.py", + "state_key": "product_commerce:import_tmall_product_ads.py", + "aliases": [ + "product_commerce:import_tmall_product_ads.py" + ], + "cdp_port": 22140 + }, + "product.jd_ad_costs.collect": { + "script_id": "product_commerce:collect_jd_ad_costs.py", + "state_key": "product_commerce:collect_jd_ad_costs.py", + "aliases": [ + "product_commerce:collect_jd_ad_costs.py" + ], + "cdp_port": 22144, + "login_mode": "D", + "required_cookie_domains": [ + "jd.com" + ], + "required_cookie_names": [ + "pin", + "thor" + ], + "credential_env_names": [ + "JD_SHOP", + "JD_PASSWORD" + ], + "account": "jd-shop" + }, + "product.import.jd_ad_costs": { + "script_id": "product_commerce:import_jd_ad_costs.py", + "state_key": "product_commerce:import_jd_ad_costs.py", + "aliases": [ + "product_commerce:import_jd_ad_costs.py" + ], + "cdp_port": 22145 + }, + "product.alert.run": { + "script_id": "product_commerce:commands/run_alerts.py", + "state_key": "product_commerce:commands/run_alerts.py", + "aliases": [ + "product_commerce:commands/run_alerts.py" + ], + "cdp_port": 22069 + }, + "product_commerce:db/sync_dim_style.py": { + "script_id": "product_commerce:db/sync_dim_style.py", + "state_key": "product_commerce:db/sync_dim_style.py", + "aliases": [ + "product_commerce:db/sync_dim_style.py" + ], + "cdp_port": 22070 + }, + "product_commerce:db/sync_sku_master.py": { + "script_id": "product_commerce:db/sync_sku_master.py", + "state_key": "product_commerce:db/sync_sku_master.py", + "aliases": [ + "product_commerce:db/sync_sku_master.py" + ], + "cdp_port": 22071 + }, + "product_commerce:dy_audience_profile_collect.py": { + "script_id": "product_commerce:dy_audience_profile_collect.py", + "state_key": "product_commerce:dy_audience_profile_collect.py", + "aliases": [ + "product_commerce:dy_audience_profile_collect.py" + ], + "cdp_port": 22072, + "login_mode": "A", + "required_cookie_domains": [ + "jinritemai.com" + ], + "account": "douyin-shop" + }, + "product_commerce:dy_product_scraping.py": { + "script_id": "product_commerce:dy_product_scraping.py", + "state_key": "product_commerce:dy_product_scraping.py", + "aliases": [ + "product_commerce:dy_product_scraping.py" + ], + "cdp_port": 22073, + "login_mode": "A", + "required_cookie_domains": [ + "jinritemai.com" + ], + "account": "douyin-shop" + }, + "product_commerce:erp_login_product_analysis.py": { + "script_id": "product_commerce:erp_login_product_analysis.py", + "state_key": "product_commerce:erp_login_product_analysis.py", + "aliases": [ + "product_commerce:erp_login_product_analysis.py" + ], + "cdp_port": 22074 + }, + "product_commerce:export_bitable_records.py": { + "script_id": "product_commerce:export_bitable_records.py", + "state_key": "product_commerce:export_bitable_records.py", + "aliases": [ + "product_commerce:export_bitable_records.py" + ], + "cdp_port": 22075 + }, + "product_commerce:import_product_daily.py": { + "script_id": "product_commerce:import_product_daily.py", + "state_key": "product_commerce:import_product_daily.py", + "aliases": [ + "product_commerce:import_product_daily.py" + ], + "cdp_port": 22076 + }, + "product_commerce:import_product_reviews.py": { + "script_id": "product_commerce:import_product_reviews.py", + "state_key": "product_commerce:import_product_reviews.py", + "aliases": [ + "product_commerce:import_product_reviews.py" + ], + "cdp_port": 22077 + }, + "product_commerce:insert_bitable_records.py": { + "script_id": "product_commerce:insert_bitable_records.py", + "state_key": "product_commerce:insert_bitable_records.py", + "aliases": [ + "product_commerce:insert_bitable_records.py" + ], + "cdp_port": 22078 + }, + "product_commerce:jd_main_image_collector.py": { + "script_id": "product_commerce:jd_main_image_collector.py", + "state_key": "product_commerce:jd_main_image_collector.py", + "aliases": [ + "product_commerce:jd_main_image_collector.py" + ], + "cdp_port": 22079, + "login_mode": "C", + "required_cookie_domains": [ + "jd.com" + ], + "credential_env_names": [ + "JD_PASSWORD" + ] + }, + "product_commerce:jd_product_data_collector.py": { + "script_id": "product_commerce:jd_product_data_collector.py", + "state_key": "product_commerce:jd_product_data_collector.py", + "aliases": [ + "product_commerce:jd_product_data_collector.py" + ], + "cdp_port": 22080, + "login_mode": "C", + "required_cookie_domains": [ + "jd.com" + ], + "credential_env_names": [ + "JD_PASSWORD" + ] + }, + "product_commerce:jd_self_inventory_sales_collector.py": { + "script_id": "product_commerce:jd_self_inventory_sales_collector.py", + "state_key": "product_commerce:jd_self_inventory_sales_collector.py", + "aliases": [ + "product_commerce:jd_self_inventory_sales_collector.py" + ], + "cdp_port": 22081 + }, + "product_commerce:market_rank_product_import.py": { + "script_id": "product_commerce:market_rank_product_import.py", + "state_key": "product_commerce:market_rank_product_import.py", + "aliases": [ + "product_commerce:market_rank_product_import.py" + ], + "cdp_port": 22131 + }, + "product.daily.orchestrate": { + "script_id": "product_commerce:orchestrate_daily_collection.py", + "state_key": "product_commerce:orchestrate_daily_collection.py", + "aliases": [ + "product_commerce:orchestrate_daily_collection.py" + ], + "cdp_port": 22082 + }, + "product.market_rank.orchestrate": { + "script_id": "product_commerce:orchestrate_market_rank_collection.py", + "state_key": "product_commerce:orchestrate_market_rank_collection.py", + "aliases": [ + "product_commerce:orchestrate_market_rank_collection.py" + ], + "cdp_port": 22083 + }, + "product.review.orchestrate": { + "script_id": "product_commerce:orchestrate_review_collection.py", + "state_key": "product_commerce:orchestrate_review_collection.py", + "aliases": [ + "product_commerce:orchestrate_review_collection.py" + ], + "cdp_port": 22084 + }, + "product_commerce:reapply_erp_override.py": { + "script_id": "product_commerce:reapply_erp_override.py", + "state_key": "product_commerce:reapply_erp_override.py", + "aliases": [ + "product_commerce:reapply_erp_override.py" + ], + "cdp_port": 22085 + }, + "product_commerce:rebuild_market_rank_documents.py": { + "script_id": "product_commerce:rebuild_market_rank_documents.py", + "state_key": "product_commerce:rebuild_market_rank_documents.py", + "aliases": [ + "product_commerce:rebuild_market_rank_documents.py" + ], + "cdp_port": 22086 + }, + "product_commerce:run_alerts_with_retry.py": { + "script_id": "product_commerce:run_alerts_with_retry.py", + "state_key": "product_commerce:run_alerts_with_retry.py", + "aliases": [ + "product_commerce:run_alerts_with_retry.py" + ], + "cdp_port": 22087 + }, + "product.persona.collect": { + "script_id": "product_commerce:run_daily_persona.py", + "state_key": "product_commerce:run_daily_persona.py", + "aliases": [ + "product_commerce:run_daily_persona.py" + ], + "cdp_port": 22088 + }, + "product.main_image.collect_jd": { + "script_id": "product_commerce:run_weekly_jd_main_image.py", + "state_key": "product_commerce:run_weekly_jd_main_image.py", + "aliases": [ + "product_commerce:run_weekly_jd_main_image.py" + ], + "cdp_port": 22089 + }, + "product.main_image.collect_tmall": { + "script_id": "product_commerce:run_weekly_main_image.py", + "state_key": "product_commerce:run_weekly_main_image.py", + "aliases": [ + "product_commerce:run_weekly_main_image.py" + ], + "cdp_port": 22090 + }, + "product.sales_sheet.sync": { + "script_id": "product_commerce:sync_monthly_sales_sheet.py", + "state_key": "product_commerce:sync_monthly_sales_sheet.py", + "aliases": [ + "product_commerce:sync_monthly_sales_sheet.py" + ], + "cdp_port": 22136, + "login_mode": "C", + "required_cookie_domains": [ + "erp321.com" + ] + }, + "product.erp_all_shop_daily": { + "script_id": "product_commerce:backfill_erp_all_shop_daily.py", + "state_key": "product_commerce:backfill_erp_all_shop_daily.py", + "aliases": [ + "product_commerce:backfill_erp_all_shop_daily.py" + ], + "cdp_port": 22138, + "login_mode": "C", + "required_cookie_domains": [ + "erp321.com" + ] + }, + "product_commerce:scripts/insert_jd_main_image_records.py": { + "script_id": "product_commerce:scripts/insert_jd_main_image_records.py", + "state_key": "product_commerce:scripts/insert_jd_main_image_records.py", + "aliases": [ + "product_commerce:scripts/insert_jd_main_image_records.py" + ], + "cdp_port": 22091 + }, + "product_commerce:scripts/insert_main_image_records.py": { + "script_id": "product_commerce:scripts/insert_main_image_records.py", + "state_key": "product_commerce:scripts/insert_main_image_records.py", + "aliases": [ + "product_commerce:scripts/insert_main_image_records.py" + ], + "cdp_port": 22092 + }, + "product_commerce:taobao_dmp_item_crowd_insight_screenshots.py": { + "script_id": "product_commerce:taobao_dmp_item_crowd_insight_screenshots.py", + "state_key": "product_commerce:taobao_dmp_item_crowd_insight_screenshots.py", + "aliases": [ + "product_commerce:taobao_dmp_item_crowd_insight_screenshots.py" + ], + "cdp_port": 22093, + "login_mode": "C", + "required_cookie_domains": [ + "taobao.com" + ], + "credential_env_names": [ + "SYCM_ACCOUNT", + "SYCM_PASSWORD" + ] + }, + "product_commerce:taobao_sycm_collect.py": { + "script_id": "product_commerce:taobao_sycm_collect.py", + "state_key": "product_commerce:taobao_sycm_collect.py", + "aliases": [ + "product_commerce:taobao_sycm_collect.py" + ], + "cdp_port": 22094, + "login_mode": "C", + "required_cookie_domains": [ + "taobao.com" + ], + "credential_env_names": [ + "SYCM_ACCOUNT", + "SYCM_PASSWORD" + ] + }, + "product_commerce:taobao_sycm_collect_backfill.py": { + "script_id": "product_commerce:taobao_sycm_collect_backfill.py", + "state_key": "product_commerce:taobao_sycm_collect_backfill.py", + "aliases": [ + "product_commerce:taobao_sycm_collect_backfill.py" + ], + "cdp_port": 22095, + "login_mode": "C", + "required_cookie_domains": [ + "taobao.com" + ], + "credential_env_names": [ + "SYCM_ACCOUNT", + "SYCM_PASSWORD" + ] + }, + "product_commerce:taobao_sycm_products.py": { + "script_id": "product_commerce:taobao_sycm_products.py", + "state_key": "product_commerce:taobao_sycm_products.py", + "aliases": [ + "product_commerce:taobao_sycm_products.py" + ], + "cdp_port": 22096, + "login_mode": "C", + "required_cookie_domains": [ + "taobao.com" + ], + "credential_env_names": [ + "SYCM_ACCOUNT", + "SYCM_PASSWORD" + ] + }, + "product_commerce:taobao_wanxiang_ai_creative_report.py": { + "script_id": "product_commerce:taobao_wanxiang_ai_creative_report.py", + "state_key": "product_commerce:taobao_wanxiang_ai_creative_report.py", + "aliases": [ + "product_commerce:taobao_wanxiang_ai_creative_report.py" + ], + "cdp_port": 22097, + "login_mode": "C", + "required_cookie_domains": [ + "taobao.com", + "alimama.com" + ], + "credential_env_names": [ + "SYCM_ACCOUNT", + "SYCM_PASSWORD" + ] + }, + "product.video_upload.run": { + "script_id": "product_commerce:upload_video_to_guanghe.py", + "state_key": "product_commerce:upload_video_to_guanghe.py", + "aliases": [ + "product_commerce:upload_video_to_guanghe.py" + ], + "cdp_port": 22098, + "login_mode": "C", + "required_cookie_domains": [ + "taobao.com" + ], + "credential_env_names": [ + "GUANGHE_USERNAME", + "GUANGHE_PASSWORD", + "GUANGHE_LUGGAGE_USERNAME", + "GUANGHE_LUGGAGE_PASSWORD" + ] + }, + "product.jd_video_upload.run": { + "script_id": "product_commerce:upload_video_to_jd.py", + "state_key": "product_commerce:upload_video_to_jd.py", + "aliases": [ + "product_commerce:upload_video_to_jd.py" + ], + "cdp_port": 22137, + "login_mode": "C", + "required_cookie_domains": [ + "jd.com" + ], + "credential_env_names": [ + "JD_SHOP", + "JD_PASSWORD" + ] + }, + "product_commerce:vendors/dy-data-flow/dy_store_competitor_store_scraping.py": { + "script_id": "product_commerce:vendors/dy-data-flow/dy_store_competitor_store_scraping.py", + "state_key": "product_commerce:vendors/dy-data-flow/dy_store_competitor_store_scraping.py", + "aliases": [ + "product_commerce:vendors/dy-data-flow/dy_store_competitor_store_scraping.py" + ], + "cdp_port": 22099, + "login_mode": "A", + "required_cookie_domains": [ + "jinritemai.com" + ], + "account": "douyin-shop" + }, + "product_commerce:vendors/jd-data-flow/jd_data_collector.py": { + "script_id": "product_commerce:vendors/jd-data-flow/jd_data_collector.py", + "state_key": "product_commerce:vendors/jd-data-flow/jd_data_collector.py", + "aliases": [ + "product_commerce:vendors/jd-data-flow/jd_data_collector.py" + ], + "cdp_port": 22100, + "login_mode": "B", + "required_cookie_domains": [ + "jd.com" + ], + "credential_env_names": [ + "JD_PASSWORD" + ] + }, + "product_commerce:vendors/jd-data-flow/jd_peer_product_data_collector.py": { + "script_id": "product_commerce:vendors/jd-data-flow/jd_peer_product_data_collector.py", + "state_key": "product_commerce:vendors/jd-data-flow/jd_peer_product_data_collector.py", + "aliases": [ + "product_commerce:vendors/jd-data-flow/jd_peer_product_data_collector.py" + ], + "cdp_port": 22101 + }, + "product_commerce:vendors/jd-data-flow/jd_product_data_collector.py": { + "script_id": "product_commerce:vendors/jd-data-flow/jd_product_data_collector.py", + "state_key": "product_commerce:vendors/jd-data-flow/jd_product_data_collector.py", + "aliases": [ + "product_commerce:vendors/jd-data-flow/jd_product_data_collector.py" + ], + "cdp_port": 22102, + "login_mode": "C", + "required_cookie_domains": [ + "jd.com" + ], + "credential_env_names": [ + "JD_PASSWORD" + ] + }, + "product_commerce:weekly_aggregate.py": { + "script_id": "product_commerce:weekly_aggregate.py", + "state_key": "product_commerce:weekly_aggregate.py", + "aliases": [ + "product_commerce:weekly_aggregate.py" + ], + "cdp_port": 22103 + }, + "shop.douyin_price_appeal": { + "script_id": "shop_intelligence:collectors/dy_store_competitor_store_scraping.py", + "state_key": "shop_intelligence:collectors/dy_store_competitor_store_scraping.py", + "aliases": [ + "shop_intelligence:collectors/dy_store_competitor_store_scraping.py" + ], + "cdp_port": 22104, + "login_mode": "A", + "required_cookie_domains": [ + "jinritemai.com" + ], + "account": "douyin-shop" + }, + "shop_intelligence:collectors/jd_data_collector.py": { + "script_id": "shop_intelligence:collectors/jd_data_collector.py", + "state_key": "shop_intelligence:collectors/jd_data_collector.py", + "aliases": [ + "shop_intelligence:collectors/jd_data_collector.py" + ], + "cdp_port": 22105, + "login_mode": "B", + "required_cookie_domains": [ + "jd.com" + ], + "credential_env_names": [ + "JD_PASSWORD" + ] + }, + "shop_intelligence:collectors/jd_peer_store_data_collector.py": { + "script_id": "shop_intelligence:collectors/jd_peer_store_data_collector.py", + "state_key": "shop_intelligence:collectors/jd_peer_store_data_collector.py", + "aliases": [ + "shop_intelligence:collectors/jd_peer_store_data_collector.py" + ], + "cdp_port": 22106, + "login_mode": "B", + "required_cookie_domains": [ + "jd.com" + ], + "credential_env_names": [ + "JD_PASSWORD" + ] + }, + "shop.jd_self_operated.collect_brand": { + "script_id": "shop_intelligence:collectors/jd_self_operated_brand_daily.py", + "state_key": "shop_intelligence:collectors/jd_self_operated_brand_daily.py", + "aliases": [ + "shop_intelligence:collectors/jd_self_operated_brand_daily.py" + ], + "cdp_port": 22132, + "login_mode": "C", + "required_cookie_domains": [ + "jd.com" + ], + "credential_env_names": [ + "JD_SELF_OPERATED_ACCOUNT", + "JD_SELF_OPERATED_PASSWORD" + ] + }, + "shop.jd_self_operated.collect_product": { + "script_id": "shop_intelligence:collectors/jd_self_operated_product_daily.py", + "state_key": "shop_intelligence:collectors/jd_self_operated_product_daily.py", + "aliases": [ + "shop_intelligence:collectors/jd_self_operated_product_daily.py" + ], + "cdp_port": 22133, + "login_mode": "C", + "required_cookie_domains": [ + "jd.com" + ], + "credential_env_names": [ + "JD_SELF_OPERATED_ACCOUNT", + "JD_SELF_OPERATED_PASSWORD" + ] + }, + "shop_intelligence:collectors/taobao_sycm.py": { + "script_id": "shop_intelligence:collectors/taobao_sycm.py", + "state_key": "shop_intelligence:collectors/taobao_sycm.py", + "aliases": [ + "shop_intelligence:collectors/taobao_sycm.py" + ], + "cdp_port": 22107, + "login_mode": "C", + "required_cookie_domains": [ + "taobao.com" + ], + "credential_env_names": [ + "SYCM_ACCOUNT", + "SYCM_PASSWORD" + ] + }, + "shop.competitor.collect": { + "script_id": "shop_intelligence:runners/run_peer_store.py", + "state_key": "shop_intelligence:runners/run_peer_store.py", + "aliases": [ + "shop_intelligence:runners/run_peer_store.py" + ], + "cdp_port": 22108 + }, + "shop_intelligence:runners/run_jd_self_operated_daily.py": { + "script_id": "shop_intelligence:runners/run_jd_self_operated_daily.py", + "state_key": "shop_intelligence:runners/run_jd_self_operated_daily.py", + "aliases": [ + "shop_intelligence:runners/run_jd_self_operated_daily.py" + ], + "cdp_port": 22134 + }, + "shop.metrics.collect": { + "script_id": "shop_intelligence:runners/run_shop.py", + "state_key": "shop_intelligence:runners/run_shop.py", + "aliases": [ + "shop_intelligence:runners/run_shop.py" + ], + "cdp_port": 22109 + }, + "shop_intelligence:scripts/remove_scheduler.ps1": { + "script_id": "shop_intelligence:scripts/remove_scheduler.ps1", + "state_key": "shop_intelligence:scripts/remove_scheduler.ps1", + "aliases": [ + "shop_intelligence:scripts/remove_scheduler.ps1" + ], + "cdp_port": 22110 + }, + "shop_intelligence:scripts/setup_scheduler.ps1": { + "script_id": "shop_intelligence:scripts/setup_scheduler.ps1", + "state_key": "shop_intelligence:scripts/setup_scheduler.ps1", + "aliases": [ + "shop_intelligence:scripts/setup_scheduler.ps1" + ], + "cdp_port": 22111 + }, + "supply_chain:orchestrator/mcp_workflow.py": { + "script_id": "supply_chain:orchestrator/mcp_workflow.py", + "state_key": "supply_chain:orchestrator/mcp_workflow.py", + "aliases": [ + "supply_chain:orchestrator/mcp_workflow.py" + ], + "cdp_port": 22112 + }, + "supply_chain:orchestrator/monitor.py": { + "script_id": "supply_chain:orchestrator/monitor.py", + "state_key": "supply_chain:orchestrator/monitor.py", + "aliases": [ + "supply_chain:orchestrator/monitor.py" + ], + "cdp_port": 22113 + }, + "supply_chain:orchestrator/runner.py": { + "script_id": "supply_chain:orchestrator/runner.py", + "state_key": "supply_chain:orchestrator/runner.py", + "aliases": [ + "supply_chain:orchestrator/runner.py" + ], + "cdp_port": 22114 + }, + "supply_chain:orchestrator/scripts/ProductReplenishment.py": { + "script_id": "supply_chain:orchestrator/scripts/ProductReplenishment.py", + "state_key": "supply_chain:orchestrator/scripts/ProductReplenishment.py", + "aliases": [ + "supply_chain:orchestrator/scripts/ProductReplenishment.py" + ], + "cdp_port": 22115, + "login_mode": "C", + "required_cookie_domains": [ + "erp321.com" + ], + "required_cookie_names": [ + "u_id", + "u_co_id", + "ASP.NET_SessionId", + ".ASPXAUTH", + "token" + ], + "credential_env_names": [ + "GYXX_SUPPLY_ERP_PASSWORD" + ] + }, + "supply_chain:orchestrator/scripts/PurchaseConfirmation.py": { + "script_id": "supply_chain:orchestrator/scripts/PurchaseConfirmation.py", + "state_key": "supply_chain:orchestrator/scripts/PurchaseConfirmation.py", + "aliases": [ + "supply_chain:orchestrator/scripts/PurchaseConfirmation.py" + ], + "cdp_port": 22116, + "login_mode": "C", + "required_cookie_domains": [ + "erp321.com" + ], + "required_cookie_names": [ + "ASP.NET_SessionId", + ".ASPXAUTH", + "token" + ], + "credential_env_names": [ + "GYXX_SUPPLY_ERP_PASSWORD" + ] + }, + "supply_chain:orchestrator/scripts/PurchaseOrderUpdate.py": { + "script_id": "supply_chain:orchestrator/scripts/PurchaseOrderUpdate.py", + "state_key": "supply_chain:orchestrator/scripts/PurchaseOrderUpdate.py", + "aliases": [ + "supply_chain:orchestrator/scripts/PurchaseOrderUpdate.py" + ], + "cdp_port": 22117, + "login_mode": "C", + "required_cookie_domains": [ + "erp321.com" + ], + "required_cookie_names": [ + "ASP.NET_SessionId", + ".ASPXAUTH", + "token" + ], + "credential_env_names": [ + "GYXX_SUPPLY_ERP_PASSWORD" + ] + }, + "supply_chain:orchestrator/scripts/batch_process.py": { + "script_id": "supply_chain:orchestrator/scripts/batch_process.py", + "state_key": "supply_chain:orchestrator/scripts/batch_process.py", + "aliases": [ + "supply_chain:orchestrator/scripts/batch_process.py" + ], + "cdp_port": 22118 + }, + "supply_chain:orchestrator/scripts/collect_confirmation.ps1": { + "script_id": "supply_chain:orchestrator/scripts/collect_confirmation.ps1", + "state_key": "supply_chain:orchestrator/scripts/collect_confirmation.ps1", + "aliases": [ + "supply_chain:orchestrator/scripts/collect_confirmation.ps1" + ], + "cdp_port": 22119 + }, + "supply_chain:orchestrator/scripts/collect_purchase_order_update.ps1": { + "script_id": "supply_chain:orchestrator/scripts/collect_purchase_order_update.ps1", + "state_key": "supply_chain:orchestrator/scripts/collect_purchase_order_update.ps1", + "aliases": [ + "supply_chain:orchestrator/scripts/collect_purchase_order_update.ps1" + ], + "cdp_port": 22120 + }, + "supply_chain:orchestrator/scripts/collect_replenishment.ps1": { + "script_id": "supply_chain:orchestrator/scripts/collect_replenishment.ps1", + "state_key": "supply_chain:orchestrator/scripts/collect_replenishment.ps1", + "aliases": [ + "supply_chain:orchestrator/scripts/collect_replenishment.ps1" + ], + "cdp_port": 22121 + }, + "supply.replenishment.arrival_sync": { + "script_id": "supply_chain:orchestrator/scripts/collect_replenishment_arrival_sync.py", + "state_key": "supply_chain:orchestrator/scripts/collect_replenishment_arrival_sync.py", + "aliases": [ + "supply_chain:orchestrator/scripts/collect_replenishment_arrival_sync.py" + ], + "cdp_port": 22139, + "login_mode": "C", + "required_cookie_domains": [ + "erp321.com" + ], + "required_cookie_names": [ + "ASP.NET_SessionId", + ".ASPXAUTH", + "token" + ], + "credential_env_names": [ + "GYXX_SUPPLY_ERP_PASSWORD" + ] + }, + "supply_chain:orchestrator/scripts/insert_replenishment_bitable.py": { + "script_id": "supply_chain:orchestrator/scripts/insert_replenishment_bitable.py", + "state_key": "supply_chain:orchestrator/scripts/insert_replenishment_bitable.py", + "aliases": [ + "supply_chain:orchestrator/scripts/insert_replenishment_bitable.py" + ], + "cdp_port": 22122 + }, + "supply_chain:orchestrator/scripts/send_card_notification.py": { + "script_id": "supply_chain:orchestrator/scripts/send_card_notification.py", + "state_key": "supply_chain:orchestrator/scripts/send_card_notification.py", + "aliases": [ + "supply_chain:orchestrator/scripts/send_card_notification.py" + ], + "cdp_port": 22123 + }, + "supply_chain:orchestrator/scripts/trigger_purchase_order_update.py": { + "script_id": "supply_chain:orchestrator/scripts/trigger_purchase_order_update.py", + "state_key": "supply_chain:orchestrator/scripts/trigger_purchase_order_update.py", + "aliases": [ + "supply_chain:orchestrator/scripts/trigger_purchase_order_update.py" + ], + "cdp_port": 22124 + }, + "supply_chain:orchestrator/sql/backfill_history.py": { + "script_id": "supply_chain:orchestrator/sql/backfill_history.py", + "state_key": "supply_chain:orchestrator/sql/backfill_history.py", + "aliases": [ + "supply_chain:orchestrator/sql/backfill_history.py" + ], + "cdp_port": 22125 + }, + "supply.workflow.run": { + "script_id": "supply_chain:run.py", + "state_key": "supply_chain:run.py", + "aliases": [ + "supply_chain:run.py" + ], + "cdp_port": 22126 + }, + "supply_chain:scripts/purchase-confirmation.bat": { + "script_id": "supply_chain:scripts/purchase-confirmation.bat", + "state_key": "supply_chain:scripts/purchase-confirmation.bat", + "aliases": [ + "supply_chain:scripts/purchase-confirmation.bat" + ], + "cdp_port": 22127 + }, + "supply_chain:scripts/replenishment-alert.bat": { + "script_id": "supply_chain:scripts/replenishment-alert.bat", + "state_key": "supply_chain:scripts/replenishment-alert.bat", + "aliases": [ + "supply_chain:scripts/replenishment-alert.bat" + ], + "cdp_port": 22128 + }, + "supply_chain:scripts/replenishment.bat": { + "script_id": "supply_chain:scripts/replenishment.bat", + "state_key": "supply_chain:scripts/replenishment.bat", + "aliases": [ + "supply_chain:scripts/replenishment.bat" + ], + "cdp_port": 22129 + }, + "content.notes_master.sync": { + "script_id": "content_marketing:data/tools/sync_notes_master.py", + "state_key": "content_marketing:data/tools/sync_notes_master.py", + "aliases": [ + "content_marketing:data/tools/sync_notes_master.py" + ], + "cdp_port": 22035 + }, + "product.tmall_baibu_apply.old_chain_bag": { + "script_id": "product_commerce:tmall_baibu_apply_old_chain_bag.py", + "state_key": "product_commerce:tmall_baibu_apply_old_chain_bag.py", + "aliases": [ + "product_commerce:tmall_baibu_apply_old_chain_bag.py" + ], + "cdp_port": 22148, + "login_mode": "D", + "required_cookie_domains": [ + "taobao.com", + "alimama.com" + ], + "required_cookie_names": [ + "unb", + "cookie2", + "_tb_token_" + ], + "account": "tmall-shop" + }, + "product.tmall_baibu_apply.old_all_bag": { + "script_id": "product_commerce:tmall_baibu_apply_old_all_bag.py", + "state_key": "product_commerce:tmall_baibu_apply_old_all_bag.py", + "aliases": [ + "product_commerce:tmall_baibu_apply_old_all_bag.py" + ], + "cdp_port": 22149, + "login_mode": "D", + "required_cookie_domains": [ + "taobao.com", + "alimama.com" + ], + "required_cookie_names": [ + "unb", + "cookie2", + "_tb_token_" + ], + "account": "tmall-shop" + }, + "product.tmall_baibu_apply.old_all_3c": { + "script_id": "product_commerce:tmall_baibu_apply_old_all_3c.py", + "state_key": "product_commerce:tmall_baibu_apply_old_all_3c.py", + "aliases": [ + "product_commerce:tmall_baibu_apply_old_all_3c.py" + ], + "cdp_port": 22150, + "login_mode": "D", + "required_cookie_domains": [ + "taobao.com", + "alimama.com" + ], + "required_cookie_names": [ + "unb", + "cookie2", + "_tb_token_" + ], + "account": "tmall-shop" + }, + "product.tmall_baibu_apply.old_chain_3c": { + "script_id": "product_commerce:tmall_baibu_apply_old_chain_3c.py", + "state_key": "product_commerce:tmall_baibu_apply_old_chain_3c.py", + "aliases": [ + "product_commerce:tmall_baibu_apply_old_chain_3c.py" + ], + "cdp_port": 22151, + "login_mode": "D", + "required_cookie_domains": [ + "taobao.com", + "alimama.com" + ], + "required_cookie_names": [ + "unb", + "cookie2", + "_tb_token_" + ], + "account": "tmall-shop" + }, + "product.tmall_baibu_apply.new_chain_bag": { + "script_id": "product_commerce:tmall_baibu_apply_new_chain_bag.py", + "state_key": "product_commerce:tmall_baibu_apply_new_chain_bag.py", + "aliases": [ + "product_commerce:tmall_baibu_apply_new_chain_bag.py" + ], + "cdp_port": 22152, + "login_mode": "C", + "required_cookie_domains": [ + "taobao.com", + "alimama.com" + ], + "required_cookie_names": [ + "unb", + "cookie2", + "_tb_token_" + ], + "credential_env_names": [ + "TMALL_BAG_ACCOUNT", + "TMALL_BAG_PASSWORD" + ], + "account": "tmall-bag" + }, + "product.tmall_baibu_apply.new_all_bag": { + "script_id": "product_commerce:tmall_baibu_apply_new_all_bag.py", + "state_key": "product_commerce:tmall_baibu_apply_new_all_bag.py", + "aliases": [ + "product_commerce:tmall_baibu_apply_new_all_bag.py" + ], + "cdp_port": 22153, + "login_mode": "C", + "required_cookie_domains": [ + "taobao.com", + "alimama.com" + ], + "required_cookie_names": [ + "unb", + "cookie2", + "_tb_token_" + ], + "credential_env_names": [ + "TMALL_BAG_ACCOUNT", + "TMALL_BAG_PASSWORD" + ], + "account": "tmall-bag" + } }, "services": { "feishu": "legacy", diff --git a/config/schedules.json b/config/schedules.json index c336750..2facbb9 100644 --- a/config/schedules.json +++ b/config/schedules.json @@ -1,19 +1,34 @@ { "schedules": [ { - "at": "22:00", + "at": [ + "01:00", + "05:00" + ], "business_date_offset_days": 0, "enabled": true, "kind": "daily", "workflow_id": "content.metrics.daily" }, + { + "at": "14:00", + "business_date_offset_days": 0, + "days": [ + "Monday" + ], + "enabled": false, + "kind": "weekly", + "workflow_id": "content.metrics.backfill" + }, { "at": "09:00", "kind": "daily", - "workflow_id": "content.cooperations.daily" + "workflow_id": "content.notes_master.daily" }, { "at": "10:00", + "business_date_offset_days": 0, + "enabled": false, "kind": "daily", "workflow_id": "content.marketing_report.daily" }, @@ -22,6 +37,7 @@ "days": [ "Friday" ], + "enabled": false, "kind": "weekly", "workflow_id": "content.relogin.weekly" }, @@ -79,8 +95,9 @@ "workflow_id": "shop.douyin_price_appeal" }, { - "at": "16:00", - "business_date_offset_days": -1, + "at": "09:00", + "business_date_offset_days": -2, + "enabled": true, "kind": "daily", "workflow_id": "shop.jd_self_operated.daily" }, @@ -91,14 +108,27 @@ "workflow_id": "product.daily" }, { - "at": "10:00", + "at": "09:00", + "business_date_offset_days": -1, "kind": "daily", + "workflow_id": "product.erp_all_shop_daily" + }, + { + "at": "10:00", + "business_date_offset_days": 0, + "days": [ + "Tuesday" + ], + "enabled": true, + "kind": "weekly", "workflow_id": "product.persona.daily" }, { - "at": "19:00", + "at": "10:00", + "business_date_offset_days": -1, + "enabled": true, "kind": "daily", - "workflow_id": "product.import.daily" + "workflow_id": "product.ecommerce_costs.daily" }, { "at": "23:00", @@ -108,6 +138,7 @@ { "anchor_date": "2026-07-25", "at": "11:00", + "business_date_offset_days": -1, "every_days": 3, "kind": "interval_days", "workflow_id": "product.style_analysis.interval" @@ -121,18 +152,53 @@ "workflow_id": "product.main_image.weekly" }, { - "at": "18:00", - "kind": "daily", + "at": "10:00", + "business_date_offset_days": -1, + "day_of_month": 1, + "enabled": true, + "kind": "monthly", "workflow_id": "product.sales_sheet.daily" }, { "at": "10:00", - "days": [ - "Monday" - ], - "kind": "weekly", + "business_date_offset_days": 0, + "day_of_month": 1, + "enabled": true, + "kind": "monthly", "workflow_id": "product.market_rank" }, + { + "at": [ + "00:00", + "01:00", + "02:00", + "03:00", + "04:00", + "05:00", + "06:00", + "07:00", + "08:00", + "09:00", + "10:00", + "11:00", + "12:00", + "13:00", + "14:00", + "15:00", + "16:00", + "17:00", + "18:00", + "19:00", + "20:00", + "21:00", + "22:00", + "23:00" + ], + "business_date_offset_days": 0, + "enabled": true, + "kind": "daily", + "workflow_id": "product.tmall_baibu_apply" + }, { "at": "07:00", "kind": "daily", @@ -143,11 +209,18 @@ "kind": "daily", "workflow_id": "supply.purchase_confirmation.daily" }, + { + "at": "09:00", + "kind": "daily", + "workflow_id": "supply.replenishment_arrival_sync.daily" + }, { "at": "08:00", + "business_date_offset_days": 0, "days": [ "Monday" ], + "enabled": false, "kind": "weekly", "workflow_id": "supply.replenishment.weekly" } diff --git a/config/secrets.example.env b/config/secrets.example.env index b6ff7cb..9398dd7 100644 --- a/config/secrets.example.env +++ b/config/secrets.example.env @@ -3,6 +3,22 @@ GYXX_POSTGRES_PASSWORD= GYXX_FEISHU_APP_ID= GYXX_FEISHU_APP_SECRET= GYXX_HERMES_API_KEY= +# 款式周期分析、天猫/京东视频标题与京东视觉识别直连 MiniMax; +# 真实密钥只放部署环境运行时 .env +STYLE_ANALYSIS_LLM_BASE_URL=https://api.minimaxi.com/v1 +STYLE_ANALYSIS_LLM_MODEL=MiniMax-M3 +STYLE_ANALYSIS_LLM_API_KEY= +STYLE_ANALYSIS_LLM_FORMAT_RETRIES=2 +STYLE_ANALYSIS_LLM_MAX_TOKENS=8192 +STYLE_ANALYSIS_LLM_THINKING_MODE=disabled +# 内容周度/月度汇总直连 MiniMax;不要与商品/视频链路的 STYLE_ANALYSIS_LLM_* 混用 +CONTENT_ANALYSIS_LLM_BASE_URL=https://api.minimaxi.com/anthropic +CONTENT_ANALYSIS_LLM_MODEL=MiniMax-M3 +CONTENT_ANALYSIS_LLM_API_KEY= +CONTENT_ANALYSIS_LLM_TIMEOUT_SECONDS=1800 +CONTENT_ANALYSIS_LLM_MAX_TOKENS=8192 +CONTENT_ANALYSIS_LLM_TEMPERATURE=1 +CONTENT_ANALYSIS_LLM_THINKING_MODE=disabled GYXX_NOTIFICATION_RECIPIENT_OPEN_ID= JD_SELF_OPERATED_ACCOUNT= JD_SELF_OPERATED_PASSWORD= diff --git a/config/source-manifests/content_marketing.json b/config/source-manifests/content_marketing.json index 516a66e..c581bc1 100644 --- a/config/source-manifests/content_marketing.json +++ b/config/source-manifests/content_marketing.json @@ -7,7 +7,7 @@ "source_relative_path": "bilibili_comment_scraper.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/bilibili_comment_scraper.py", "source_sha256": "c7f116f30889125c668e7f4dd796e7249ba7ea8621d5ab5fb518028f56c7f8dc", - "target_sha256": "95eb40cb6fbe98feca55ea3a051a3f3542b2132a45f476766e6964bed4eef462", + "target_sha256": "109a01a6dc2b79261cff68ff42818a7561f3319b6be3788d9300bb5ce0217a96", "transformed": true, "category": "source" }, @@ -15,7 +15,7 @@ "source_relative_path": "bilibili_scraper.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/bilibili_scraper.py", "source_sha256": "c3df15f317ce073f84a59609f83e2723a21a43e37cfcfc2f17b3cbf3a17df73e", - "target_sha256": "3605db4d3d435e4fca5a3a4f6c85a44367637c91277f9195d80346591f75e6b7", + "target_sha256": "c6c985c860f0b9ab742a66786fb3576a71767bbf897969d9539d7c780303a201", "transformed": true, "category": "source" }, @@ -23,7 +23,7 @@ "source_relative_path": "chanmama_scraper.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/chanmama_scraper.py", "source_sha256": "2edc26879b57d73ec5992c80e5ef74d76a509f09bff7284b1ae17e90df543e07", - "target_sha256": "fbe2506c4176d7fc1f39d5312d031f18b83aada905db2e2fb9354e688b5cab16", + "target_sha256": "72634b4f042f8506bcccadf4512dcbe9f92e9aced3e9b1fc7718483b7c802ebf", "transformed": true, "category": "source" }, @@ -54,8 +54,8 @@ { "source_relative_path": "daily_marketing_report.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/daily_marketing_report.py", - "source_sha256": "22456210632d34cc6e60e4302641fe3ce581353a41970eb9c5613dbeb135ba31", - "target_sha256": "0e07312af6de1f03ab232fd992285b1d61cc752496db76d8f978f5c67713cbe4", + "source_sha256": "b59252594c7d3c0fd1473d14611346e4b64672ef6c263fea8a2da53017ab8675", + "target_sha256": "78fb871c14eb73ec3e0d0986554cad318f8a0ee8a66c2a0981f02a65cded8a26", "transformed": true, "category": "source" }, @@ -78,56 +78,56 @@ { "source_relative_path": "data/config/款式_多维表格_对照.json", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/config/款式_多维表格_对照.json", - "source_sha256": "78985ed95d3363f8843b0ee9e8d6acdd8e44a7238bfded48965229fe615dae19", - "target_sha256": "78985ed95d3363f8843b0ee9e8d6acdd8e44a7238bfded48965229fe615dae19", - "transformed": false, + "source_sha256": "6cf1771833e4cf0e3065ff24aab15389f91439b3d63a4ab8c0a38cb61d569dfd", + "target_sha256": "96e2793cfae6f068ec8fe65f67b4074a414d1d17bc49ebb2c969cd8a69c0cd00", + "transformed": true, "category": "config" }, { "source_relative_path": "data/config/款式_多维表格_对照_自营.json", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/config/款式_多维表格_对照_自营.json", - "source_sha256": "29978931a5c682e9f8a37929f2888ac5781345ee8ab23000c69613383653d315", - "target_sha256": "29978931a5c682e9f8a37929f2888ac5781345ee8ab23000c69613383653d315", - "transformed": false, + "source_sha256": "24752e0628d5888abb8c9afe33e01839aaa40d870d5d78862771db01342ccc22", + "target_sha256": "f844f69e09f5c2248414f54df1d1eaf2e04538c2b33c1f02834b8046bf5f02b7", + "transformed": true, "category": "config" }, { "source_relative_path": "data/tools/analyze_comments.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/analyze_comments.py", "source_sha256": "61677e64d3e864b73573e3e59b7a247bcfddcde93f78f81a20ee27257c326107", - "target_sha256": "1fc30272e9305bb6570beafbef95f5edf3a15954c18dec4a427c30cd6d57bef3", + "target_sha256": "bf47911ae7c47510262618ea8698813013555a5c40c19c89c56225cfb207825b", "transformed": true, "category": "tool" }, { "source_relative_path": "data/tools/analyze_note.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/analyze_note.py", - "source_sha256": "cdf981d21f30d31338b698a31fd44fea97c4c08e04750956ac6f1afb9ed56905", - "target_sha256": "8fd4ab49d45a0084ad15d1a9a595013c9d31867d8f61993d3b971520ed81b949", + "source_sha256": "f9a71dcd34657d3e70af84160bd04afa73325c9ddeee0e669d98d6758ed6b13e", + "target_sha256": "3c62dcac6e5c877c1a5b55f4960244052a124c2e50c61571cc726d2ca916109b", "transformed": true, "category": "tool" }, { "source_relative_path": "data/tools/batch_rescrape_bilibili.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/batch_rescrape_bilibili.py", - "source_sha256": "31d015a53d8b25728e687d4a5db78673bd8dbd25e470e6e8c728a215147d00f1", - "target_sha256": "0a550172b7bed73e254d89826e48d0532e9feea0d5c46676d35eac0f490097e5", + "source_sha256": "5b07fe27f1b76714ca3f3feae34e3d719277ed0cfe124d76cacd8e3c904b1fca", + "target_sha256": "10e5dee90c591201daa7a7b0eb13a40ed1c97d7d51b1b93ef8f1d5d4b8e09580", "transformed": true, "category": "tool" }, { "source_relative_path": "data/tools/batch_rescrape_douyin.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/batch_rescrape_douyin.py", - "source_sha256": "261b3b122708deb40d8587f27fada55cc0cd234a2ddd085af4cf2f2e2871e05a", - "target_sha256": "2253ee01da3b105d65d610e9e8fbabbde29d44ffdd540e724bad39274b0063c8", + "source_sha256": "15b1438513f8216a6c7a103744e8a1ca6bc63b32c111d7c9bce31c18be9e84a6", + "target_sha256": "3233a7840e07a87535a8df065562a7cb681dbc140e002d82970598de3d9768cf", "transformed": true, "category": "tool" }, { "source_relative_path": "data/tools/batch_rescrape_xiaohongshu.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/batch_rescrape_xiaohongshu.py", - "source_sha256": "6c50845f568b5e51038229873aac977c1bc37f31dbe90f7ce10218d0358422ea", - "target_sha256": "411f75888512d68212f221ea59a6af9a52ea425d5b99b95dfe7a2ca34d23cbdb", + "source_sha256": "6d85f7dfd52c7a92b951ac29e7ee3377aebdb743af2a362d1f38366188dcad08", + "target_sha256": "01be5d1ba260ccd46e01b9e72b6f7dd3d08577615455fc9010de4efe8a710aea", "transformed": true, "category": "tool" }, @@ -151,23 +151,23 @@ "source_relative_path": "data/tools/collect_note_metrics.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/collect_note_metrics.py", "source_sha256": "30f40ae47baaf520001a20a0f1ef85472a999fca69127ad06d07bd697d3353bc", - "target_sha256": "c0cf5f2569a50f32ea6f7c89eebb6562dca01ec5a3915906164caed2080c583d", + "target_sha256": "406c4f8f7c5cae02aa9993143d8101651e39d6ba542c2ae4286424e0c29b55e7", "transformed": true, "category": "tool" }, { "source_relative_path": "data/tools/daily_dashboard_analytics.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/daily_dashboard_analytics.py", - "source_sha256": "6f0bd4b918691255290880aacaf82fd1ea16344204b6d80d5a85689448312985", - "target_sha256": "6b5fcf393739f2c4b988ee3cff006577d4c5d851fb446db1d2abf0cf1e85512e", + "source_sha256": "7f50c79d8ffe8d744627d5102305868b49076333ef8dfec5cea14c4f5db5d5a6", + "target_sha256": "5c73287ed0e860c377859a918beb05b4f2a1ecba0c011c417c1a6d6a2243f7f8", "transformed": true, "category": "tool" }, { "source_relative_path": "data/tools/daily_report_card.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/daily_report_card.py", - "source_sha256": "3229a5b4ec321ade3543a5fe172231af95e1874a690122258866f58740067855", - "target_sha256": "9baf37c269e09ae2be1f244b080da0ed9879403aac654962a0fc1d48083c14d1", + "source_sha256": "a34523c07478a61044811c153835a73bbe03f2b69b841499c85d050e6064ac3c", + "target_sha256": "fd13143a85d99fbcc2fccca1793e1a419ee74b33f9d49a00f23f872663890133", "transformed": true, "category": "tool" }, @@ -182,8 +182,8 @@ { "source_relative_path": "data/tools/db.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/db.py", - "source_sha256": "cb2e3c6a8b44d11668e8fdb6fc0acf8b73adf280a4e19b8f17547edc99ef0089", - "target_sha256": "fb652acecc92227554a072a2b1e0be37a81df4c1215f36a5a50de966f7183054", + "source_sha256": "83f158a78841244c1f02255d1500018b5a9dbe1f282cbdb937acc344b08abc25", + "target_sha256": "62ffad854cc2bc3a3ae4f2a28b4ee345bc9eecf72101b647c3aa165c691b456a", "transformed": true, "category": "tool" }, @@ -207,15 +207,15 @@ "source_relative_path": "data/tools/friday_relogin_parallel.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/friday_relogin_parallel.py", "source_sha256": "affed4ab6e5e179c85dc92999502b98e007c71243ed26977b3c5eeba23b9e3dc", - "target_sha256": "6fe2eecd03d0c99fc7004302c7b089994fb7e330fbd49ff246b3353b05ec174e", + "target_sha256": "438bedfb874c7eb566e1998815a22ca72b7d177cee221fd39625a96872bd450b", "transformed": true, "category": "tool" }, { "source_relative_path": "data/tools/generate_creator_report.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/generate_creator_report.py", - "source_sha256": "84f5404d45e05d5d33f61f29c123cb75f764f43ad4b04e83776e98228883dea7", - "target_sha256": "12c3164c9128999449a481882e1d1afb820751cfe620373dc7f26aaf799d63d0", + "source_sha256": "ceb74e953d02a0b4275380117b19e564c0057625d3b5eda626bc3ca780f25542", + "target_sha256": "29f916d0bbb4eb0202663c20299e54b7d3dfadcaa508132ab052a53a0a70212d", "transformed": true, "category": "tool" }, @@ -259,6 +259,14 @@ "transformed": false, "category": "sql" }, + { + "source_relative_path": "data/tools/migrations/006_note_inventory.sql", + "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/migrations/006_note_inventory.sql", + "source_sha256": "d013841571f272e6013893d2bb455578bdba83141c80d97aec42eda8a87bdf3b", + "target_sha256": "d013841571f272e6013893d2bb455578bdba83141c80d97aec42eda8a87bdf3b", + "transformed": false, + "category": "sql" + }, { "source_relative_path": "data/tools/pull_all_tables.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/pull_all_tables.py", @@ -287,7 +295,7 @@ "source_relative_path": "data/tools/relogin_bilibili.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/relogin_bilibili.py", "source_sha256": "b8dc12539bcd6f1a368d50ead1f5d33d6577a75c92f0b7bafa75a67edd7a73fa", - "target_sha256": "ae1a6e7b55e7e713dfa124e5ee90a926a14aff232eae51d916ecfbef4cbc3049", + "target_sha256": "d47f26f62fbe3262128327b916151385e527125a18f27f7455b8382aa5a77ecb", "transformed": true, "category": "tool" }, @@ -295,7 +303,7 @@ "source_relative_path": "data/tools/relogin_douyin.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/relogin_douyin.py", "source_sha256": "3c2017f8a801528ff17e4c4bae24615256b8788c185f9a797fe87470e35b1645", - "target_sha256": "730a9e1091bc040d5c25df8fcd598d2a4ac41f981bb368bee61f74f9212bfb31", + "target_sha256": "82a0ecf4101f2eaded94cc4cd9054cb08e1e27519a58cfc0d3126888cc1ce5d4", "transformed": true, "category": "tool" }, @@ -303,7 +311,7 @@ "source_relative_path": "data/tools/relogin_pgy.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/relogin_pgy.py", "source_sha256": "759c6a68a0851b4c87d8febfc5d94925831a3b2d7cdf1e6a6e74dcd52b83ae3f", - "target_sha256": "dfffec788b2d23e01526be59ae8c36426ead821a13a28c5c6ffc720b2a05a44e", + "target_sha256": "d75697ae2b6ac1f23bb79077a4df0af33113813a46525a6f3980b04391857cac", "transformed": true, "category": "tool" }, @@ -319,7 +327,7 @@ "source_relative_path": "data/tools/relogin_xiaohongshu.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/relogin_xiaohongshu.py", "source_sha256": "fcdea30adecb066c4f3699ded457df0bc763d978248276ddbffa9f18bc9bdf2c", - "target_sha256": "73a0c9cfb2da6780130868f02d678dba923c5a95e77b065dbbcbedafc50d5ac5", + "target_sha256": "e700dd57e5cf87d68529bcd6c062e73d6bd9f1e6ef06ef3122e2b16c288aaf1a", "transformed": true, "category": "tool" }, @@ -327,7 +335,7 @@ "source_relative_path": "data/tools/relogin_xingtu.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/relogin_xingtu.py", "source_sha256": "c9d434cb02a57ebd7b884598563077d733d56e45dad59ed30774eb7e1c668cb0", - "target_sha256": "dd19e522789fc17a12fc0f78fe9a6d2f204323a5288ae75b18948ebe5d62d45b", + "target_sha256": "06ec1088677d69511057008acb2b05cca2da015a8219aee10bf19bbae3c25d79", "transformed": true, "category": "tool" }, @@ -335,15 +343,15 @@ "source_relative_path": "data/tools/retry_failed.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/retry_failed.py", "source_sha256": "90c6dbc47317cc48e28071d452d75c95913138fc885a5963f9eb58986cd99086", - "target_sha256": "813f49cfc6758ff853c2f279b73bca0d8b242353b0b3e6546dc2f886db05ea6a", + "target_sha256": "b4c85e8319bfd0a3071ffdfaff05ea030e426cb3d384a978fdc4d14545ccd679", "transformed": true, "category": "tool" }, { "source_relative_path": "data/tools/schema_gyxx_super_data.sql", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/schema_gyxx_super_data.sql", - "source_sha256": "7d087858c183e2f8dce90842dec663c42d67a37a61215dc14bee48ee053caaec", - "target_sha256": "6555471f333ce738d7944bb2901d09a6c8240ce226b8670476e653ec69de5902", + "source_sha256": "43d202d7d0452b7d0671ee9c40149c21328db4b2863fa4d030af456f89defe3c", + "target_sha256": "43d202d7d0452b7d0671ee9c40149c21328db4b2863fa4d030af456f89defe3c", "transformed": true, "category": "sql" }, @@ -355,19 +363,11 @@ "transformed": true, "category": "tool" }, - { - "source_relative_path": "data/tools/sync_cooperations.py", - "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/sync_cooperations.py", - "source_sha256": "e2a527a456847c88981fa6f4bba622b6e45f7782c94bf44459e08f9715e59105", - "target_sha256": "988851d8f0a6dae276413a0c19364b05daa84c90ccfa0e4207205675334c48fb", - "transformed": true, - "category": "tool" - }, { "source_relative_path": "data/tools/sync_metrics_to_cmt_notes.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/sync_metrics_to_cmt_notes.py", "source_sha256": "3b97cab0740c5c97b47a0fbe763acb5391d7c8e47c95f4675720b4f716f6432f", - "target_sha256": "d2ebaaf0eadc58d437c19346946326db112f72d50066c2b451e2340c3d907e25", + "target_sha256": "4e630a79bd2a9742ad19d1955b88f3f12cc7e741b445196e53c07440c0f3749d", "transformed": true, "category": "tool" }, @@ -407,7 +407,7 @@ "source_relative_path": "douyin_comment_scraper.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/douyin_comment_scraper.py", "source_sha256": "ec785ead9c95c5de65c00b06212b1f03d0159fed475a95a2b593d3b24e486e43", - "target_sha256": "356eb84fb17a2a4526382defffd525aea6a82a592442f1bb6ab3815274a7f9cb", + "target_sha256": "e36ec226b06bae4940f0d2432ae7de0d9a0b56199ab6cd8ee7d34fbfc060ec51", "transformed": true, "category": "source" }, @@ -423,15 +423,15 @@ "source_relative_path": "login_helper.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/login_helper.py", "source_sha256": "15c2563e224b7d8e6eb6ba40eb5da8f91125fb6d99f11e6bd028c0dd546812ac", - "target_sha256": "309deb40e0ecaa498132c14de9a63d5a122b0ec7db4dc315f6aa7126c8379da2", + "target_sha256": "1ab830a6d868bcd786ead95ea93620136b6a5477f025a9f1b3f94e208cd0d85a", "transformed": true, "category": "source" }, { "source_relative_path": "monthly_summary_all.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/monthly_summary_all.py", - "source_sha256": "aa9c50b0a52416f518973d65e707a1428c5bd3c1f7435c674e894ccd4cc33c17", - "target_sha256": "dd5a4be485fe05ad12e038101bf0560776b0bb99456fffbd0f0087211fe3f3c4", + "source_sha256": "50f18d62e8fb1a7c8a04dc746326a4763f64ba4b99b29a6fda5878aa05f290fa", + "target_sha256": "ea2729e1976426d497b69006c63b5ba7d74ce530731efa5a2946a4671593aed7", "transformed": true, "category": "source" }, @@ -439,7 +439,7 @@ "source_relative_path": "pgy_xhs_scraper.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/pgy_xhs_scraper.py", "source_sha256": "7d9cd66b80cf602127541cfdd9589f2d8870ca4a25bac3587478482064aae8f4", - "target_sha256": "d1b3600a08c0ce92b0f9b67fdae1600ffc536172be6d13f07fef5ec3be69c3e6", + "target_sha256": "c1ee83cc68464d5b0b33e010b388abd2adab8a6608846b47d509e9ea0a783f81", "transformed": true, "category": "source" }, @@ -447,7 +447,7 @@ "source_relative_path": "pgy_xhs_scraper_v2.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/pgy_xhs_scraper_v2.py", "source_sha256": "2be78ef76df38e80da2099a76e6e3fd9dcdc2e8875afb44279f54003ccb5f563", - "target_sha256": "a9c36fb308b95ee100d3ff8073cbd1deb1a6f0e0617ef7658cebb294e8b35775", + "target_sha256": "21c3647faa01a335af2c5984f87bc0d8968361cae73352146279bd9027e82c5f", "transformed": true, "category": "source" }, @@ -455,15 +455,15 @@ "source_relative_path": "requirements.txt", "target_relative_path": "src/gyxx_flow/modules/content_marketing/requirements.txt", "source_sha256": "64250b6eb39be48a87fcd79cd962306eb825d8c302f0c3f5f73d79928be602ad", - "target_sha256": "64250b6eb39be48a87fcd79cd962306eb825d8c302f0c3f5f73d79928be602ad", - "transformed": false, + "target_sha256": "703a190b03c28bce765367f8fb92a09cfde068cbbfced13f32dba4444f7fcd7b", + "transformed": true, "category": "dependency" }, { "source_relative_path": "run_all.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/run_all.py", "source_sha256": "7a2529b1d043297171aeb88518fac8753234540992042f922172219b2b233dd4", - "target_sha256": "295487234c50b4f7855a18feaa83e2811ede8c7fd2145413735c32c104217618", + "target_sha256": "0ea60854f1bb1ca293a073c57c9e19a1aa9c5710ff9575dc7c430c4e480525d9", "transformed": true, "category": "source" }, @@ -471,7 +471,7 @@ "source_relative_path": "self_bilibili_scraper.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/self_bilibili_scraper.py", "source_sha256": "2ef650a16dde61697101bb219a8c3adcd682fec1529bb9c5312dc5b384d83a72", - "target_sha256": "477e4137a09c5072922ff8d7401244aa7152054ff4c4506fdd20531af03aa549", + "target_sha256": "42e6b9ac81077b37ff4607b3565d98cac1027eff3ac39a8776e640503d590820", "transformed": true, "category": "source" }, @@ -479,15 +479,15 @@ "source_relative_path": "self_douyin_scraper.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/self_douyin_scraper.py", "source_sha256": "4aba1264dee052521e5712d7219916e534db5705e3cec75caaa1b025a4bc2506", - "target_sha256": "78f18751216f5bb56c062dbdcda334958d607a86a5693b0ad5b3973fb51c84bb", + "target_sha256": "62307df115c8d7336b470baba074163ab3f3468bae8b0af266af370e2e42d9f1", "transformed": true, "category": "source" }, { "source_relative_path": "weekly_summary_all.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/weekly_summary_all.py", - "source_sha256": "f8a2d976378ade00579393a2bb0805281d1b7b500b119b497c69d1b591a9c8aa", - "target_sha256": "77fd5e1580a0e9e4493aac44ee3be509ab10969391e9d57dc51e6ee10c6e9c4b", + "source_sha256": "b93c04c543a8c65418ec122a2713239d0ed857bc169b87ef718cf73348c189b0", + "target_sha256": "a5c866d0720f20fe78bf8f8039a24101fc58e3a81fb107beb7f6499e9782d559", "transformed": true, "category": "source" }, @@ -503,7 +503,7 @@ "source_relative_path": "xiaohongshu_comment_scraper.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/xiaohongshu_comment_scraper.py", "source_sha256": "58bc07bb2fd67897e18a9981f931df8fadf24a6cc5f09d55bd3aaa2e3c6756a4", - "target_sha256": "d3afc963296220e357989f3386f755a4ac2a29b6d2b92b9ce86e8c1efee12639", + "target_sha256": "5e1e306cb1a089c187dc31bc598939304990b3039a5eb91d9da8a71a023b6e69", "transformed": true, "category": "source" }, @@ -511,17 +511,25 @@ "source_relative_path": "xingtu_scraper.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/xingtu_scraper.py", "source_sha256": "a5f101bf7bbd0f3475491eaadb5e42467f3bf3e8c79de343642e6b5b0c5ccfa9", - "target_sha256": "3066bc9a48988d607d0b09b3a6e97f4baedf28064ef549945ae70f739e99d2e6", + "target_sha256": "a52933b0863416b170ef3805cafe678cda8b86e9656c06d009d8b8a4bb551404", "transformed": true, "category": "source" }, { "source_relative_path": "xingtu_scraper_v2.py", "target_relative_path": "src/gyxx_flow/modules/content_marketing/xingtu_scraper_v2.py", - "source_sha256": "f6ee7523f7f8d99790e6c7fd264d8584d48c7aecea93700380d19fcd3aeac467", - "target_sha256": "13c0a2f0cd87849336a781cc027b6d86cb2da8730bc03c1e0597287de61a5794", + "source_sha256": "0d4eb87779064e13b8d78daa6dba13698f1149a26b3a1de2e064c117a29cf49a", + "target_sha256": "e694f3ca9126a99123d0941068dbd102f0fdb4b84160ebdbb51cd56c3f3aa32a", "transformed": true, "category": "source" + }, + { + "source_relative_path": "data/tools/sync_notes_master.py", + "target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/sync_notes_master.py", + "source_sha256": "94edc9b55bfc0106101af30897a68b4f0513d04b8ed4a07a99daf6fce5447f45", + "target_sha256": "2d90abb7e73ec420bdfdb03fba01ec497e6c2a5fd3b7c1fe8848e486da08f2f8", + "transformed": true, + "category": "tool" } ], "intentionally_excluded": [ diff --git a/config/source-manifests/product_commerce.json b/config/source-manifests/product_commerce.json index 6f57672..f058603 100644 --- a/config/source-manifests/product_commerce.json +++ b/config/source-manifests/product_commerce.json @@ -14,10 +14,10 @@ { "category": "production_source", "source_sha256": "cff768f79672cfc7db7f6866caf5ab59936b8c51eb2a7d729b81441940124512", - "target_sha256": "61127a6abf93d0cfa99ab8803580b7c46727c9e031e28d5fb76d4c99292210c4", + "target_sha256": "7b4da4f51cd0945bb9ad144aead5ff96e197fdacc7a3c065e6102e833161992a", "transformed": true, "source_relative_path": "analyze_style_with_hermes.py", - "target_relative_path": "src/gyxx_flow/modules/product_commerce/analyze_style_with_hermes.py" + "target_relative_path": "src/gyxx_flow/modules/product_commerce/analyze_style.py" }, { "category": "production_source", @@ -46,7 +46,7 @@ { "category": "production_source", "source_sha256": "6fb7cd48665c8cb7cf40dd69693408047a85a6abba3cbd410b4e5e9a9a55e9ba", - "target_sha256": "5b875b0be95c2b70c16d964877899eb723662c1d3369cce4195ebf8f7ac3e8bd", + "target_sha256": "72b099e6fa3bc12400f2a635abdeb2e150fc8e69bd2268f5d733cdd27f0dc1c8", "transformed": true, "source_relative_path": "check_nine_day_decline.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/check_nine_day_decline.py" @@ -54,7 +54,7 @@ { "category": "production_source", "source_sha256": "1493dbbfb3d864812df378d04d006e7de0f9ec4b6ab5812e5c6970c304c2dfe6", - "target_sha256": "1503b0631fe23f49855216292d40f88c9c99405c2ddf5f4c28eb2ba10f02877c", + "target_sha256": "2d5a6a7bea5e21a5af5f7cdde287acd5b9e7e375469e6d6f85c15aab1cc31d8e", "transformed": true, "source_relative_path": "collect_dy_market_rank.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/collect_dy_market_rank.py" @@ -62,7 +62,7 @@ { "category": "production_source", "source_sha256": "abd4c82cbd6c41e111e213c174d2fdeba1214568496eaf1dede24bf374b3b052", - "target_sha256": "12254ad0fec0a8a235e18b2b952bc46bc7656c3e6142a8f85ff1d263596b2614", + "target_sha256": "523027ccd4e31bd276bfba3e8944bf43e0e1e67efc0921ef5c72b8bfa4287c28", "transformed": true, "source_relative_path": "collect_dy_persona_to_bitable.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/collect_dy_persona_to_bitable.py" @@ -70,7 +70,7 @@ { "category": "production_source", "source_sha256": "c7b54d941ab7f056e2d0ea4355543ff4a5c34ad986c8ed32145e988ac9501ea2", - "target_sha256": "5ad05625604d27309084c2e47acfa934d8c2f207a185b49e6c4f082982bcf7aa", + "target_sha256": "75cd142aefad9ea54a61e8e11a72e9535ee3a71a0c7b0025a8d030a0a0c0d430", "transformed": true, "source_relative_path": "collect_erp_yesterday_metrics.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/collect_erp_yesterday_metrics.py" @@ -78,7 +78,7 @@ { "category": "production_source", "source_sha256": "8bb1f17f90a7b9c2db5dfb992cf746329892d19c00ae2ae988e2109c049598b2", - "target_sha256": "d484ab2978e9af9a028a1677b55b28138edefd12b9da9bd61417f236ff3ec7b5", + "target_sha256": "5f9a754f2293b8b35ce5214ae930350b67abe4d10b8e702b06b68c9c9a4d2d9a", "transformed": true, "source_relative_path": "collect_jd_market_rank.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/collect_jd_market_rank.py" @@ -86,7 +86,7 @@ { "category": "production_source", "source_sha256": "f9a48c5f89b18472dc3993e340ad858bfecb2fcf59427c7e70db37512c17a6f7", - "target_sha256": "74b366ec592b5bf1416e4d5eff6adccbe0f70932d67793071135d370028c6d6b", + "target_sha256": "17064990b7080969e1907f2e2ae9cf1f49267936746b6db6e6e1a3a689e1b501", "transformed": true, "source_relative_path": "collect_jd_persona_to_bitable.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/collect_jd_persona_to_bitable.py" @@ -94,7 +94,7 @@ { "category": "production_source", "source_sha256": "3c1858a6ab4f7ce480b3a5f2076e6ec79400e9bfaa75377488c3629e707c7963", - "target_sha256": "d08484541820314432a22954b6d076fd3f80db8dd523508c3d3175168bdf205b", + "target_sha256": "0d2981d17c86c1881c86810aaf63bfd0261de03bc94d91eaa1b42a66953d3f3e", "transformed": true, "source_relative_path": "collect_persona_to_bitable.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/collect_persona_to_bitable.py" @@ -110,7 +110,7 @@ { "category": "production_source", "source_sha256": "13b6e68b4872b2e79c1de8ca1617cc4f1249f4ed32f234ad6efb7a04f5948612", - "target_sha256": "2bdde68f197bd3505c843c171ebcb10bd8494c5aa6c9b83ea395c78e2202a8f3", + "target_sha256": "fb120e9501a1220c3746e0f78a9f639e732764e21b122bf532c0fdea03ec6ed7", "transformed": true, "source_relative_path": "collect_sycm_market_rank.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/collect_sycm_market_rank.py" @@ -134,7 +134,7 @@ { "category": "production_source", "source_sha256": "a8900b9f25db6cf2bef3117a1d2cd86283054fea6f5a8590fd91b5cfbbcaf87e", - "target_sha256": "7c2e2ed63a4cfedd1086c17cd66a48f5bdffb8e1947d0b0bb9de2b9474276e1f", + "target_sha256": "7fd65a5b993b5a8b7f681b370e4364411252d6a59b22c7d96d71235daa5fce55", "transformed": true, "source_relative_path": "db/__init__.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/db/__init__.py" @@ -158,7 +158,7 @@ { "category": "production_source", "source_sha256": "78c74c0e52e3651b3b042a2095e596ab250e60309c7ae2de905850822aad4922", - "target_sha256": "9cc8b5b7310bb8646b38e61d225b22f1d20f8679ae09bda7aad0cb51727a1cc2", + "target_sha256": "e7377793895b6176a0bd76df0ce6a3d8927c1f2bb4f3c8b4ca47e7d3dd97dd00", "transformed": true, "source_relative_path": "dy_audience_profile_collect.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/dy_audience_profile_collect.py" @@ -166,7 +166,7 @@ { "category": "production_source", "source_sha256": "492916088fa5e8c170461cc9eba97a88ed1cc1e50c7b99e5f6a5e49167fea157", - "target_sha256": "9d7d673f141828d38a381b1475dae48b3d2edb6e70d048cf72329a1199d31c97", + "target_sha256": "5f621d4003dbb5ed3c8cd58ae813f371bb0c110aa2fbfff0b6ef559b4eb487b1", "transformed": true, "source_relative_path": "dy_product_scraping.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/dy_product_scraping.py" @@ -174,7 +174,7 @@ { "category": "production_source", "source_sha256": "26f3448e57a08020de0a96d1df3387819afc47f9319b81dbab27f1afc81bfb01", - "target_sha256": "e2240d11cfce714662de7ef763ea4f964b8d1c4ddada2b1f022938e80b9ab7df", + "target_sha256": "536097c7558c2edb33639ab7ada22b266a1fe14341f6972dc53d3708f7b61fc0", "transformed": true, "source_relative_path": "erp_login_product_analysis.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/erp_login_product_analysis.py" @@ -230,7 +230,7 @@ { "category": "production_source", "source_sha256": "e0f016bafb3f527f1cae46eaf3ef31510184128aaaa7f52024a7c3f978eec42b", - "target_sha256": "094bbdebe2892c2a5570540cc29db45f585d52d2d4277f52ba978a1a5c86f069", + "target_sha256": "f30caa8e5925ee9d4c5addf3af651c4c79f94d564612922106f8693ecd7addd1", "transformed": true, "source_relative_path": "jd_main_image_collector.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/jd_main_image_collector.py" @@ -238,7 +238,7 @@ { "category": "production_source", "source_sha256": "d99ce71a008128549ac83fc3da52d88540b74538e3fe5c85d47c3b985e414353", - "target_sha256": "180e2bc5053443360f21ee539d2fe080c2e926566c8380c73b005f3de94d740e", + "target_sha256": "3aeec0f04d2d9d7fccf01632503a56066a58716e7dfc953a72241f1857fc8af4", "transformed": true, "source_relative_path": "jd_product_data_collector.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/jd_product_data_collector.py" @@ -246,7 +246,7 @@ { "category": "production_source", "source_sha256": "314790f06f7b4ebf9b0ddd5681db9c60e79a3ef16f5546d6e8489531a003c545", - "target_sha256": "66c691258f2fa017a211bf54a609f28d812ec41c4b78c08aded4c73773f461e8", + "target_sha256": "06cac4bc1635268fec6274891a09344715b6dc38b95307b8d5bcf2e6a250bbf0", "transformed": true, "source_relative_path": "jd_self_inventory_sales_collector.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/jd_self_inventory_sales_collector.py" @@ -278,7 +278,7 @@ { "category": "production_source", "source_sha256": "355f8051935d458703ab1842beefa74ccbb00d0fbc73af4aa6761ee6f3b4b32a", - "target_sha256": "719ef925689ad3f03388186d5efc8f80eefba91a8ef62a6e8a181e7548f3a714", + "target_sha256": "cc0750344d5d64d914e622d3ccbcf888905ca86a991d0b7d8280a2b0714b66da", "transformed": true, "source_relative_path": "market_rank_hermes_notification.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/market_rank_hermes_notification.py" @@ -310,7 +310,7 @@ { "category": "production_source", "source_sha256": "37edbfa24dfb126d52992b08fd6aeccae3ee18c83db56426a0fb02934e4541ae", - "target_sha256": "1bc1784e562b9dd39075446f42c3383f30d537b68ee758afd31d636bbdd4ac98", + "target_sha256": "cade2f855043e05c1e07b86da81c4864a55e90e999e4441804055b2428e1c92f", "transformed": true, "source_relative_path": "orchestrate_daily_collection.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/orchestrate_daily_collection.py" @@ -318,7 +318,7 @@ { "category": "production_source", "source_sha256": "0ab99843d30156465876e27f339f74f3e48b0d17f75d74cf5bfeedd513aaa787", - "target_sha256": "f3d4dac22ba70fe322db74dcb84cf016cae2831327bbc8809383854e8b05677e", + "target_sha256": "f2035df7c2ea0ed15587656adb76662e2ed5f124cc54accb00eafda620bc44ee", "transformed": true, "source_relative_path": "orchestrate_market_rank_collection.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/orchestrate_market_rank_collection.py" @@ -350,7 +350,7 @@ { "category": "production_source", "source_sha256": "47c80d311247f0089a9e30b72679f54875e9c750097c3b71d5aa2ae25ab0a134", - "target_sha256": "d6b9041a1ab0fed97dbb7c257fcd753c95c769c20f508603e70209b532cae421", + "target_sha256": "6d7a2951f75e490d019444cb3019f87be26709747c95ae328f2f49c1113803b2", "transformed": true, "source_relative_path": "run_alerts_with_retry.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/run_alerts_with_retry.py" @@ -358,7 +358,7 @@ { "category": "production_source", "source_sha256": "c9a51f1d1267b398cf260a86e65a8ee49de873d6c95bf9c90db2abb422005cf7", - "target_sha256": "3dd2d8b270bf44a9dff075e315e047486a4d2de10d85d16158179c02080e655a", + "target_sha256": "9c8f0e7e686d7198bb231f87f85b074a00d0ffebf5d5d6b44582f59dddeb8abf", "transformed": true, "source_relative_path": "run_daily_persona.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/run_daily_persona.py" @@ -398,7 +398,7 @@ { "category": "production_source", "source_sha256": "b3e9addd72d8e31292ed948b67018aa9e212d628d7ba39141a81116515ca6721", - "target_sha256": "613c577e1903cf5e1080ff12c7bbb7fd0d3575a51950bf87b48388244a047941", + "target_sha256": "a17c6649f4665a09ae1ba0007936546866b2882b5e296733efd99e37d3120b2e", "transformed": true, "source_relative_path": "taobao_dmp_item_crowd_insight_screenshots.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/taobao_dmp_item_crowd_insight_screenshots.py" @@ -406,7 +406,7 @@ { "category": "production_source", "source_sha256": "4474dd7d68117247af87e20bbeb6b7c072368ef0f606de5d3e186f459d34a0f5", - "target_sha256": "51e2e6f5c1b0c6e4c0d1900c8c6898cbe4efafab77a05849fe31b38b0d2e744e", + "target_sha256": "7e881c9a62227a3ec81b761067034055c5f79d02bec69bc23434a5f7aa13b72c", "transformed": true, "source_relative_path": "taobao_sycm_collect.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/taobao_sycm_collect.py" @@ -414,7 +414,7 @@ { "category": "production_source", "source_sha256": "a881b02c903dc83c7f252d7f53fa9cd721fb562c690271788858ca5782427500", - "target_sha256": "55e9b57bfad8a1c69c77611a7716bbfef61c18ba07ed30bfdf6a4d5d40d06bee", + "target_sha256": "d8957d1f93f807eadd1dd7c5a095485a83ce4eb492ec6218ab18cc71cb3cc7a7", "transformed": true, "source_relative_path": "taobao_sycm_collect_backfill.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/taobao_sycm_collect_backfill.py" @@ -422,7 +422,7 @@ { "category": "production_source", "source_sha256": "9cafa62a03316fd0222f63addd5c209bb0c20fd0b7affa69cd1e256abfa2aa3a", - "target_sha256": "020b0dec86f158b8fa86e8d9ef13f38e79f156bf3e680ed4d5ecae709fc5da54", + "target_sha256": "e20f104a12b1df763b5fde5ef6b8e2536a7801d70049b55cb84143afae21edf5", "transformed": true, "source_relative_path": "taobao_sycm_products.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/taobao_sycm_products.py" @@ -438,7 +438,7 @@ { "category": "production_source", "source_sha256": "e4d99956d9649d108baeb3e3a2d1969c227622d1037062bd277af66375556d82", - "target_sha256": "fdf95dd62d9d19810cc4ac3d2da841b718d1d4183876cee2439fdb9aad23273a", + "target_sha256": "ee73ae4d58ba164422698e1bd0dd2da8510a5c059b8eb2d107be144a844fee4b", "transformed": true, "source_relative_path": "upload_video_to_guanghe.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/upload_video_to_guanghe.py" @@ -488,7 +488,7 @@ { "category": "production_source", "source_sha256": "6daa16c03989fb2231e54545726f5aa488e4ba80b9b8aa6f937cb9a2fa13ff35", - "target_sha256": "14d30692ff01f140bbb38eaf453d69c1b3ba010cb8a35d73e3ee0ca8f85ea8a1", + "target_sha256": "2080856b1c463a6a11e8e969ef7ff7d0816170402864526c60df026e40c9dd1d", "transformed": true, "source_relative_path": "vendors/jd-data-flow/jd_data_collector.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/vendors/jd-data-flow/jd_data_collector.py" @@ -496,7 +496,7 @@ { "category": "production_source", "source_sha256": "a53cc38b0ddeaa0065bc27a1d137ce16c2873ff9bec712483ca87bcedd6baa3a", - "target_sha256": "7f5f9ee0f9845e4b8cd384246842162ed3a17a57a68f39d0d3275fa13d6d4e7f", + "target_sha256": "122f57cbdb76bbc366cfe93f50d7c6fbefa091b53664958acb2d142fbf2fa6f9", "transformed": true, "source_relative_path": "vendors/jd-data-flow/jd_peer_product_data_collector.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/vendors/jd-data-flow/jd_peer_product_data_collector.py" @@ -504,7 +504,7 @@ { "category": "production_source", "source_sha256": "fc65c65939c0324cae1555631bbb0d1f7ce852825195ce14322e7002a2479f61", - "target_sha256": "d69139a41d0941114534d94cd4054be7c8ecedc2c09fd79ba07953e160621d95", + "target_sha256": "0e2cd19895a00a82bd4b82f2d74420e49dd41537b9d3b89a85be3ed22156e572", "transformed": true, "source_relative_path": "vendors/jd-data-flow/jd_product_data_collector.py", "target_relative_path": "src/gyxx_flow/modules/product_commerce/vendors/jd-data-flow/jd_product_data_collector.py" @@ -528,7 +528,7 @@ { "category": "runtime_resource", "source_sha256": "589679874b75643d69ea44522e2fe40914a396506ff8e3232150d7e2a96ae2f9", - "target_sha256": "74561c8ebacb857b08268dde406a99381cef08440181d08b5eaa81eebbcf12a4", + "target_sha256": "dbe5760e9cbcaedadf82a2cfd84733a56a12c21e77d00327e95a8a013a5779ad", "transformed": true, "source_relative_path": ".env.example", "target_relative_path": "src/gyxx_flow/modules/product_commerce/.env.example" @@ -560,7 +560,7 @@ { "category": "runtime_resource", "source_sha256": "9739407b61842c598491d3189a32f5f15aa944aed79a164a5cd5b5a34f20c746", - "target_sha256": "1d354878adc44b32e3555eb42cb56b485683e6b224df18c18f63bae4f5d62f66", + "target_sha256": "bfb13c05e4a0c7a85238200e026332f55d85a8022012cfab1db0c8514746def8", "transformed": true, "source_relative_path": "db/schema.sql", "target_relative_path": "src/gyxx_flow/modules/product_commerce/db/schema.sql" @@ -584,7 +584,7 @@ { "category": "regression_test", "source_sha256": "1398806a524beaa203df9b27eed7652180c7ac427522220e88905d9cd827457f", - "target_sha256": "5f4b5f67b7d8f5f8fbcd9485b237f58ba7d71e7fa8b96a07b2cd27ab797f087b", + "target_sha256": "c87238015ce88743efa88d4977a0c19d9fd03863741ec9cc41b58702702ebdaa", "transformed": true, "source_relative_path": "tests/test_dy_market_rank.py", "target_relative_path": "tests/modules/product_commerce/test_dy_market_rank.py" @@ -632,8 +632,8 @@ { "category": "regression_test", "source_sha256": "3d348a8250a2910f6a1c6504e9d1e53b0493d40bdc6c1fd1c8d0404c78681dc6", - "target_sha256": "3d348a8250a2910f6a1c6504e9d1e53b0493d40bdc6c1fd1c8d0404c78681dc6", - "transformed": false, + "target_sha256": "dca35f4b197d2a7f77eb14e969e91974210b31100c5e874c842fd8036c4dd6e1", + "transformed": true, "source_relative_path": "tests/test_guanghe_metadata.py", "target_relative_path": "tests/modules/product_commerce/test_guanghe_metadata.py" }, @@ -664,7 +664,7 @@ { "category": "regression_test", "source_sha256": "dd82a77378c84cc8921e88ad43a90675bbf1098aaa702e0ffced377a097ce957", - "target_sha256": "ff297246219022359351764eca0ee272363059f41254bcf22f54f0234ceafb74", + "target_sha256": "9729ab00c778db27c7e42f533140661c152ead0140e8114d21d073b404c4aa40", "transformed": true, "source_relative_path": "tests/test_jd_market_rank.py", "target_relative_path": "tests/modules/product_commerce/test_jd_market_rank.py" @@ -680,7 +680,7 @@ { "category": "regression_test", "source_sha256": "0f55af81ae11567c3c5d7943ab74598dd994a7ff193a9e98e7024c7dc4a3a769", - "target_sha256": "9d3221ad3c8519b2fea679847780bb09b38b62c1eacab8b671d8af29a1af2045", + "target_sha256": "cb7e93c09f9ed7531b4c72b8254f4f3cf9259c89f414ac935c26f359b0da0958", "transformed": true, "source_relative_path": "tests/test_market_rank_hermes_notification.py", "target_relative_path": "tests/modules/product_commerce/test_market_rank_hermes_notification.py" @@ -712,7 +712,7 @@ { "category": "regression_test", "source_sha256": "219630aaa213d2a671acf71acb271cb955b860fa56819db4ccbdb4b28a7d3662", - "target_sha256": "3f5ed14d5059ebc16d92e03281e2be584467bdcf3f6742e7862deb75a03ac069", + "target_sha256": "19dc0504e5ec83495cee8864c92851e621cdb31aeaeb2a1403374f2826e69769", "transformed": true, "source_relative_path": "tests/test_market_rank_workflow.py", "target_relative_path": "tests/modules/product_commerce/test_market_rank_workflow.py" @@ -720,7 +720,7 @@ { "category": "regression_test", "source_sha256": "e43712d0eba0213044b685c6162da983be91ee083f7b12ec44a61c5525fe9e36", - "target_sha256": "ee6157dd28249b71f145cb88496494de04a0d633a706b65d4a0ae5f0217f3d69", + "target_sha256": "fc8deeb6f02ee9492c0938e6c43bd3e32065873173883438a8c6b40dbf32b7cc", "transformed": true, "source_relative_path": "tests/test_persona_launcher.py", "target_relative_path": "tests/modules/product_commerce/test_persona_launcher.py" @@ -728,7 +728,7 @@ { "category": "regression_test", "source_sha256": "f9cfbb49fd313444790ac194fbf980dae21912081b9f26ad8e3850912126f09e", - "target_sha256": "ba0768c6291b6a5c8a5dd4191c398b40aba22e6da03cdf75b941811e17b7f272", + "target_sha256": "76a0aeecf17ecea039ab4d81457cb2dc2cc0d0d75f54e500ea0f2a00ccce6f45", "transformed": true, "source_relative_path": "tests/test_style_analysis_orchestration.py", "target_relative_path": "tests/modules/product_commerce/test_style_analysis_orchestration.py" @@ -752,7 +752,7 @@ { "category": "regression_test", "source_sha256": "3267f9a366af11ad40e8b8bc80d2c42e08b5a7e84e2c71f265c404389c2cb2c3", - "target_sha256": "48006d1fb6eb18de19946c3ae8da3ff0a705885b9fb888c9c5a8894729e37b82", + "target_sha256": "d7687cd7ef58e4fe1a0d03b7f542321652eba2a57bfeaf0b1897a0435a1fb539", "transformed": true, "source_relative_path": "tests/test_sycm_market_rank.py", "target_relative_path": "tests/modules/product_commerce/test_sycm_market_rank.py" @@ -760,7 +760,7 @@ { "category": "regression_test", "source_sha256": "f7de702d7d1e641c8778970d5cb1f4e827a2e0ee9b20db2c19f0ed4fd8230e16", - "target_sha256": "9e06ae5b1039d385bed5676a99ab8eecf9bfa571a64d5d7c269dc4356c8b021e", + "target_sha256": "25c72b4256fc4d0485b491fe691d44d78dac926f02316cc4d1b2bb7c3ee536a2", "transformed": true, "source_relative_path": "tests/test_tm_persona_recovery.py", "target_relative_path": "tests/modules/product_commerce/test_tm_persona_recovery.py" @@ -1071,7 +1071,7 @@ { "target_relative_path": "src/gyxx_flow/modules/product_commerce/runtime_paths.py", "purpose": "portable module, data, state, profile, log, temp, vendor, and subprocess path contract", - "target_sha256": "2db1dafccb005aea500bad1cde8268c6d59f5a62eff983b1cb6e0c2c51f743a7" + "target_sha256": "31a658a25cfb32b5081580cd1b7c31b75dbb2580c1805b4d4b3f9a8590185cde" } ] } diff --git a/config/source-manifests/shop_intelligence.json b/config/source-manifests/shop_intelligence.json index 1d5d9a3..1059d69 100644 --- a/config/source-manifests/shop_intelligence.json +++ b/config/source-manifests/shop_intelligence.json @@ -40,7 +40,7 @@ "target_relative_path": "src/gyxx_flow/modules/shop_intelligence/collectors/jd_data_collector.py", "category": "source", "source_sha256": "6f00ae3b2b74e08d3c34c3b911ea819dfa4c6ce55bceef282afb3b2f565aa3ec", - "target_sha256": "caaef9f448c1fd803ee7aac5149d5ddf49890fc793c1d4d6e1ef58f8404e0be9", + "target_sha256": "6dc3a80a9d5869b82e5de4febec1fe920ebe518209fe1d0febafec732c3a97ac", "transformed": true }, { @@ -48,7 +48,7 @@ "target_relative_path": "src/gyxx_flow/modules/shop_intelligence/collectors/jd_peer_store_data_collector.py", "category": "source", "source_sha256": "4ebdb52df7b384d2bfdd2fc840d55d14e18864002c20a36685b63cee1966cf4c", - "target_sha256": "d0a75b08ca50b746dd28b609d15d769b1137a8f6aba575a253a0fc3988bbf34b", + "target_sha256": "6f626e52f52abd2f08441f70f3e9558e9cca41ebfb666438f4cf009cd17dccbe", "transformed": true }, { @@ -56,7 +56,7 @@ "target_relative_path": "src/gyxx_flow/modules/shop_intelligence/collectors/jd_self_operated_brand_daily.py", "category": "source", "source_sha256": "f03e6e6a65faa1cbafae57b641fe2efec9aae8ba685afd1c0cf63680adf7588e", - "target_sha256": "9190b09f739461fa7d231391754ba4373093ec8dede9df77ae06d866306da1a6", + "target_sha256": "efdf6d89da5fe4d16300ec1c2d751b9bdc121afe1259248986e824a0a36e1d01", "transformed": true }, { @@ -64,7 +64,7 @@ "target_relative_path": "src/gyxx_flow/modules/shop_intelligence/collectors/jd_self_operated_product_daily.py", "category": "source", "source_sha256": "92f5c8619eede17c25bad8482b8821ff7a7dac3ff649e4a02d69c21f8dc89de1", - "target_sha256": "8c28a8684bf8812f87e8ca7764fed3d6b026cf107d69dbac6507e0fb4630f54d", + "target_sha256": "9891846f90c1e58743ad088315b9828d709c1bfd44b141ae957eb873a1cad05c", "transformed": true }, { @@ -120,15 +120,15 @@ "target_relative_path": "src/gyxx_flow/modules/shop_intelligence/dependencies.toml", "category": "dependency", "source_sha256": "002035ffb503798d48241f962216c5baa460de4ee8a1d7d696a3d6ba6afb2975", - "target_sha256": "002035ffb503798d48241f962216c5baa460de4ee8a1d7d696a3d6ba6afb2975", - "transformed": false + "target_sha256": "775894542b3b8ca4d4785a05b03ceeb471488e69c8840cdb9f012574bdfd2ed2", + "transformed": true }, { "source_relative_path": "tests/test_jd_self_operated_brand_daily.py", "target_relative_path": "tests/modules/shop_intelligence/test_jd_self_operated_brand_daily.py", "category": "test", "source_sha256": "71186dd318bd582648b43b72641b91c540c1e6908b28f2b9591f70c3590f22e8", - "target_sha256": "8dddfb16c48556b00f59b00477407e7d289e5f893ecff60255b04829b18a4fb5", + "target_sha256": "4d6a465e2670fc743976f61b4587cdd1124b15eb39e1f5284679d68570238a31", "transformed": true }, { diff --git a/config/source-manifests/supply_chain.json b/config/source-manifests/supply_chain.json index 180eaf0..ae83428 100644 --- a/config/source-manifests/supply_chain.json +++ b/config/source-manifests/supply_chain.json @@ -8,7 +8,7 @@ "target_relative_path": "src/gyxx_flow/modules/supply_chain/config.example.env", "category": "config", "source_sha256": "ec2df00f43abd9ba570b3882c5331d9b3c09557cccbc9c79e2326df57b680124", - "target_sha256": "023e15dff12e8e112829a3b65ff0a77a4917577870991198f153b44f32c315af", + "target_sha256": "3fbfd050b522a20802c91d195e305388f8d704fe60d1f24dab41096d74a28286", "transformed": true }, { @@ -24,7 +24,7 @@ "target_relative_path": "src/gyxx_flow/modules/supply_chain/orchestrator/config.py", "category": "source", "source_sha256": "466791caa5439b29e6f37b92ee6afcd4018f2c7e3fbb072efe9c6832955b3400", - "target_sha256": "972ac34462e462fb5cbe0899b881d6294c7d88ad65e46f18fc56b01de9fcec79", + "target_sha256": "835298b3c05983ef215d76150364a1ca89b4b6ce539cced4b9387d3678b508bf", "transformed": true }, { @@ -40,7 +40,7 @@ "target_relative_path": "src/gyxx_flow/modules/supply_chain/orchestrator/mcp_workflow.py", "category": "source", "source_sha256": "286e28c9db042956743d045fe40157d64c58ef70a85301c018a6eaedd8c2984d", - "target_sha256": "c63ab8f4187f8c508781453ad3c7e26ec3130fd357659f485bebe5130bf21a39", + "target_sha256": "2eb41409ef1bb799204b27fabcba243a927ffc30c0e46328c8ccda83d185be41", "transformed": true }, { @@ -56,7 +56,7 @@ "target_relative_path": "src/gyxx_flow/modules/supply_chain/orchestrator/pg_writer.py", "category": "source", "source_sha256": "0991bb67f8f00de04b59bb25d7a9c4b8231c2b507589c201192faa1e4eb5711e", - "target_sha256": "54da879295d476677a7ba7c21f177652ab7d444de5f6580db9dc5ffe9ece1458", + "target_sha256": "9a0f3fb1ff7e89ccfa88a8f202f622f91af3b5683c43a1c7dfee2a374529b012", "transformed": true }, { @@ -80,7 +80,7 @@ "target_relative_path": "src/gyxx_flow/modules/supply_chain/orchestrator/scripts/collect_confirmation.ps1", "category": "launcher", "source_sha256": "90764f8c7fdcaa576f6f8a84a5115cd9fe1487035eb55de3540c8a74011c84be", - "target_sha256": "f87e3d2a3cae9ca1380f492910a0216ff12bc8ff449c19c04b1b09c5180362f8", + "target_sha256": "662aef01b4a387de8c568e2eb551ccfb5e60cb6f19a5910747f9f52495f8b366", "transformed": true }, { @@ -88,7 +88,7 @@ "target_relative_path": "src/gyxx_flow/modules/supply_chain/orchestrator/scripts/collect_purchase_order_update.ps1", "category": "launcher", "source_sha256": "a314db3e3389a56ee62a413056d552f04da546be73ca59e266a998c23fdf5926", - "target_sha256": "df6d89469023501a79293ffbe06172ea0c66791305162fa2e3a134a0b3fd712e", + "target_sha256": "e7ae2567620c30065818478ce751a9f48de13be9ea0f5bd4d17985e74ec3d3bb", "transformed": true }, { @@ -96,7 +96,7 @@ "target_relative_path": "src/gyxx_flow/modules/supply_chain/orchestrator/scripts/collect_replenishment.ps1", "category": "launcher", "source_sha256": "7c59aad10c5c3cb43ccee3d399359fac8c2ba9f444b02f983d3641b194b57c87", - "target_sha256": "0ff2ed2ccc0c133e63afb7fe36405916f2192c7077e96e29867c508b54881033", + "target_sha256": "d6acf99db97bef2bcc4671b16dbb101aa65c3d725ce00b960d64cd5b0c2e60c3", "transformed": true }, { @@ -120,7 +120,7 @@ "target_relative_path": "src/gyxx_flow/modules/supply_chain/orchestrator/scripts/ProductReplenishment.py", "category": "source", "source_sha256": "02f6345142ebd84e25dc2f3b0651bc139ef31dd3994ff739efe66344c77ee49e", - "target_sha256": "44eb5264f787700b7d802a74cb2a1c481def9545af60e5e257af5fc65eaf37e5", + "target_sha256": "32d40a9cbdaba73f88eb5ed181eca0af3bd02df9dc017e65162a4a9acb2f1a1f", "transformed": true }, { @@ -128,7 +128,7 @@ "target_relative_path": "src/gyxx_flow/modules/supply_chain/orchestrator/scripts/PurchaseConfirmation.py", "category": "source", "source_sha256": "d400571da971702302b6e426a0ba12981459a5ee7919c771d6f4ff0038617643", - "target_sha256": "1c51c23b5277678e546596e21d7824fa70b04b9cded49ab409b51a75590a1e49", + "target_sha256": "91cf0ccb4150e9252c5048581a16e77534f251f17a3400ba4e1e4ad03e451941", "transformed": true }, { @@ -136,7 +136,7 @@ "target_relative_path": "src/gyxx_flow/modules/supply_chain/orchestrator/scripts/PurchaseOrderUpdate.py", "category": "source", "source_sha256": "91617a2854fbf1ef38e9f8ef6fefaa34627b477d7a39098c43d6ee5dba296aa7", - "target_sha256": "0b111d2da1f8dd829f5b0c3c273c758cb41d571258080bc49ff35f672cd42556", + "target_sha256": "0ff70f17c748cd9c892e6bb5b20c7083681edddb5097fb18ce0aa182996f29a1", "transformed": true }, { @@ -144,7 +144,7 @@ "target_relative_path": "src/gyxx_flow/modules/supply_chain/orchestrator/scripts/send_card_notification.py", "category": "source", "source_sha256": "1397f5508a15ea7dae3d809aee9b0317b73788f3e7dc3927ff9efba5b7630a7e", - "target_sha256": "bece4e179cc892ca63b63bd83420b78c0aa315540aef271847ccb377a0cfcb5e", + "target_sha256": "783ff97e7137e30ba7bcac72d53559563889e88fda21d0eb2b5217d48bf1ed53", "transformed": true }, { @@ -200,8 +200,8 @@ "target_relative_path": "src/gyxx_flow/modules/supply_chain/dependencies.txt", "category": "dependency", "source_sha256": "1b636fe51fce642e4d17009643013998e5f2b0670f7c11abe4c62fcf5b10e306", - "target_sha256": "1b636fe51fce642e4d17009643013998e5f2b0670f7c11abe4c62fcf5b10e306", - "transformed": false + "target_sha256": "0b649b35c3c5de2d141498ee5e5029b2b561356796afa79264b96a1bddb62d4e", + "transformed": true }, { "source_relative_path": "run.py", diff --git a/config/tmall-baibu.json b/config/tmall-baibu.json new file mode 100644 index 0000000..11b39ce --- /dev/null +++ b/config/tmall-baibu.json @@ -0,0 +1,62 @@ +{ + "schema_version": 1, + "workflow_id": "product.tmall_baibu_apply", + "identity": "user", + "entries": [ + { + "id": "old_chain_bag", + "name": "老天猫·超链·箱包", + "account_id": "tmall-shop", + "account_marker": "光影行星旗舰店", + "template_url": "https://bu0zgpibak.feishu.cn/wiki/MmS9wc6SgiKGDekwkBtcVB4hn9d", + "apply_url": "https://myseller.taobao.com/home.htm/starb/tmc-next/sale/seller/apply.htm?signRecordId=3367786350&puCode=itemApply&processId=0b9eb03de8d94131b3e5f18a43a16d49", + "script": "tmall_baibu_apply_old_chain_bag.py" + }, + { + "id": "old_all_bag", + "name": "老天猫·全渠道·箱包", + "account_id": "tmall-shop", + "account_marker": "光影行星旗舰店", + "template_url": "https://bu0zgpibak.feishu.cn/wiki/RyLzwcRJ7il88Rk7iTccHY4bnMc", + "apply_url": "https://myseller.taobao.com/home.htm/starb/tmc-next/sale/seller/apply.htm?signRecordId=3381614958&puCode=itemApply&processId=1f236b238ff44a87ba6b93ee37e33949", + "script": "tmall_baibu_apply_old_all_bag.py" + }, + { + "id": "old_all_3c", + "name": "老天猫·全渠道·3C", + "account_id": "tmall-shop", + "account_marker": "光影行星旗舰店", + "template_url": "https://bu0zgpibak.feishu.cn/wiki/VCRPw6zamiR98XkGOCxcDsVUntb", + "apply_url": "https://myseller.taobao.com/home.htm/starb/tmc-next/sale/seller/apply.htm?signRecordId=3399437550&puCode=itemApply&processId=98e7de110be54737a2f08a9d7536ef72", + "script": "tmall_baibu_apply_old_all_3c.py", + "complete_drafts_after_import": true + }, + { + "id": "old_chain_3c", + "name": "老天猫·超链·3C", + "account_id": "tmall-shop", + "account_marker": "光影行星旗舰店", + "template_url": "https://bu0zgpibak.feishu.cn/wiki/HO9Hwb1wyijmFhkGEGkcXilbnge", + "apply_url": "https://myseller.taobao.com/home.htm/starb/tmc-next/sale/seller/apply.htm?signRecordId=3400796526&puCode=itemApply&processId=783e0875400d48658ba8241c03416fbd", + "script": "tmall_baibu_apply_old_chain_3c.py" + }, + { + "id": "new_chain_bag", + "name": "新天猫·超链·箱包", + "account_id": "tmall-bag", + "account_marker": "光影行星鑫华达专卖店", + "template_url": "https://bu0zgpibak.feishu.cn/wiki/PjpRwLS3vi86owkGpTZcieJIntc", + "apply_url": "https://qn.taobao.com/home.htm/starb/tmc-next/sale/seller/apply.htm?signRecordId=3507947824&puCode=itemApply&processId=e9818b705acf419b81f2cf9416298907", + "script": "tmall_baibu_apply_new_chain_bag.py" + }, + { + "id": "new_all_bag", + "name": "新天猫·全渠道·箱包", + "account_id": "tmall-bag", + "account_marker": "光影行星鑫华达专卖店", + "template_url": "https://bu0zgpibak.feishu.cn/wiki/JerEwzVZdiUflAkJ7Z8coH6inuc", + "apply_url": "https://qn.taobao.com/home.htm/starb/tmc-next/sale/seller/apply.htm?signRecordId=3546665648&puCode=itemApply&processId=0aafb6d55e5145ada7207eea94c7a604", + "script": "tmall_baibu_apply_new_all_bag.py" + } + ] +} diff --git a/config/tmall-video-publication-backfill.json b/config/tmall-video-publication-backfill.json new file mode 100644 index 0000000..3ef068d --- /dev/null +++ b/config/tmall-video-publication-backfill.json @@ -0,0 +1,36 @@ +{ + "schema_version": 1, + "channel": "tmall", + "target_account_key": "guanghe-luggage", + "evidence": { + "log_path": "var/data/raw/product_commerce/legacy/product_data/logs/guanghe_luggage_full_20260726_final2.log", + "summary": "25 attachments were matched exactly by current Feishu record_id and filename to a legacy luggage run with an explicit publish click and success marker" + }, + "publications": [ + {"store":"luggage","source_record_id":"recuSSKKg5Ft6d","video_file_token":"JFj0bISy6oNgI3xC472cy97fnJh","video_file_name":"7月12日极星双肩.mp4","evidence":{"record_line":22,"publish_click_line":71,"publish_success_line":72}}, + {"store":"luggage","source_record_id":"recuUWG6r7ffQQ","video_file_token":"YKZ5bdgl9orsTVxhMAhcEd0fnqe","video_file_name":"8-16极星双肩~.mp4","evidence":{"record_line":75,"publish_click_line":126,"publish_success_line":127}}, + {"store":"luggage","source_record_id":"recuWViLeDKEmm","video_file_token":"U1MjbMZksoK6iGxy6o1cteLcnle","video_file_name":"e1bcd9daa2dea340e6f7b408d761ea1c_raw.mp4","evidence":{"record_line":130,"publish_click_line":180,"publish_success_line":181}}, + {"store":"luggage","source_record_id":"recv88RThomUGN","video_file_token":"EppKbsONMoWtF2xC0z2cor5WnEb","video_file_name":"微信视频2026-01-12_160141_523.mp4","evidence":{"record_line":184,"publish_click_line":234,"publish_success_line":235}}, + {"store":"luggage","source_record_id":"recvapgjLQ4JES","video_file_token":"XDpUbMMgio9WOZxS729c3J1znhh","video_file_name":"千川.mp4","evidence":{"record_line":238,"publish_click_line":286,"publish_success_line":287}}, + {"store":"luggage","source_record_id":"recvax4L2ApUN7","video_file_token":"JIvsb5HQpolIlDxYMc0cFuN8npf","video_file_name":"极星双肩1.mp4","evidence":{"record_line":290,"publish_click_line":344,"publish_success_line":345}}, + {"store":"luggage","source_record_id":"recvax4L2ApUN7","video_file_token":"QHwGb6X6DoIadDxiN4gcFjkRnzK","video_file_name":"极星双肩3.mp4","evidence":{"record_line":290,"publish_click_line":394,"publish_success_line":395}}, + {"store":"luggage","source_record_id":"recvax4L2ApUN7","video_file_token":"WYqGbwzRUoH6bjxlgdDc2B8Cngf","video_file_name":"极星双肩4.mp4","evidence":{"record_line":290,"publish_click_line":444,"publish_success_line":445}}, + {"store":"luggage","source_record_id":"recvax4L2ApUN7","video_file_token":"D7C7b3wijofP5cx7OXecqCiKn0d","video_file_name":"极星双肩5.mp4","evidence":{"record_line":290,"publish_click_line":494,"publish_success_line":495}}, + {"store":"luggage","source_record_id":"recvax4L2ApUN7","video_file_token":"XkigbBc6cofzLjxPN3JcgmXgnqg","video_file_name":"极星双肩2.mp4","evidence":{"record_line":290,"publish_click_line":544,"publish_success_line":545}}, + {"store":"luggage","source_record_id":"recvaNPX9K2PgW","video_file_token":"SRcFbNPuao1WYrxiPo6cm9ICn9c","video_file_name":"微信视频2026-02-10_092318_345.mp4","evidence":{"record_line":548,"publish_click_line":595,"publish_success_line":596}}, + {"store":"luggage","source_record_id":"recvfpJerrGRRT","video_file_token":"VjgubZytaoGQlbx9NQlcLg5mneg","video_file_name":"黑曜石托特.mp4","evidence":{"record_line":599,"publish_click_line":642,"publish_success_line":643}}, + {"store":"luggage","source_record_id":"recvhzG1QOeAs4","video_file_token":"Ix1DbHXejol6OTxRXACcbrmnnhd","video_file_name":"IMG_0627.MOV","evidence":{"record_line":646,"publish_click_line":687,"publish_success_line":688}}, + {"store":"luggage","source_record_id":"recvi34LVqvw1y","video_file_token":"ZQn4blUX2oNR3IxjkSHc55knnVh","video_file_name":"809950ca8a5f2a2236f4a7831465b724.mp4","evidence":{"record_line":691,"publish_click_line":741,"publish_success_line":742}}, + {"store":"luggage","source_record_id":"recvjsYpsdGqvs","video_file_token":"HdTubwN8yobNj5xSqBMcjPIunTh","video_file_name":"瑞白.mp4","evidence":{"record_line":745,"publish_click_line":786,"publish_success_line":787}}, + {"store":"luggage","source_record_id":"recvnw2g6X0oCQ","video_file_token":"Abocbtngbo4Qk8xqTgacOh7Xn2f","video_file_name":"香港 极星双肩.mp4","evidence":{"record_line":790,"publish_click_line":840,"publish_success_line":841}}, + {"store":"luggage","source_record_id":"recvobc87f5uDf","video_file_token":"DnAWbvxWIoOWshx4Q2gcyyRNnXd","video_file_name":"瑞白 1.mp4","evidence":{"record_line":844,"publish_click_line":885,"publish_success_line":886}}, + {"store":"luggage","source_record_id":"recvomE4AbDk2G","video_file_token":"WiPZbngOrox6BuxC3HlcviqLnTb","video_file_name":"瑞白 2.mp4","evidence":{"record_line":889,"publish_click_line":932,"publish_success_line":933}}, + {"store":"luggage","source_record_id":"recvpqdZMidcqk","video_file_token":"OxYWbA9avooN7QxBpLnczGH3nWe","video_file_name":"05ff3c399855c33ba340c776284be691.mp4","evidence":{"record_line":936,"publish_click_line":977,"publish_success_line":978}}, + {"store":"luggage","source_record_id":"recvpBLn07x9Pd","video_file_token":"VonLbE00ioDqjVxTOE0cPSsZnxe","video_file_name":"IMG_7114.MOV","evidence":{"record_line":981,"publish_click_line":1022,"publish_success_line":1023}}, + {"store":"luggage","source_record_id":"recvpCemZAaKSN","video_file_token":"BadUbsVrho6EjQxD1P4cMXusnzh","video_file_name":"新 极星双肩.mp4","evidence":{"record_line":1026,"publish_click_line":1076,"publish_success_line":1077}}, + {"store":"luggage","source_record_id":"recvpY0BM6v1OW","video_file_token":"KMS8bpMWFohGtSxiChycLAv2nac","video_file_name":"从新剪辑.mp4","evidence":{"record_line":1080,"publish_click_line":1130,"publish_success_line":1131}}, + {"store":"luggage","source_record_id":"recvpZg1gbUK4E","video_file_token":"JNBtbZgrfoO6YmxpUvqcWliYnrh","video_file_name":"瑞柏2.mp4","evidence":{"record_line":1134,"publish_click_line":1175,"publish_success_line":1176}}, + {"store":"luggage","source_record_id":"recvpZg1gbUK4E","video_file_token":"ArQobg8Ero3CK4xdNoOc829Xn9c","video_file_name":"瑞柏3.mp4","evidence":{"record_line":1134,"publish_click_line":1214,"publish_success_line":1215}}, + {"store":"luggage","source_record_id":"recvpZg1gbUK4E","video_file_token":"IntIbMtcUoC3zGxAOe0cZF1anmc","video_file_name":"瑞柏.mp4","evidence":{"record_line":1134,"publish_click_line":1253,"publish_success_line":1254}} + ] +} diff --git a/config/workflow-dynamic-config-seed.json b/config/workflow-dynamic-config-seed.json new file mode 100644 index 0000000..588f2dd --- /dev/null +++ b/config/workflow-dynamic-config-seed.json @@ -0,0 +1,5508 @@ +{ + "schema_version": 1, + "source_url": "https://bu0zgpibak.feishu.cn/base/TtoCb1NuQaDy3NsZWTpc0GIvnph?table=tblKCjplVAFrRwMC&view=vewvy33xEk", + "captured_at": "2026-08-19T06:49:05+00:00", + "record_count": 215, + "records": [ + { + "source_record_id": "recvlso5oU6pf1", + "config": { + "platform": "天猫", + "style_name": "极星pro", + "note": "包含小号、中号", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/OsYvbUe1iaJYeKs5WvLcU4rVnHc?table=tblx8wC2KNOByknq&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/OsYvbUe1iaJYeKs5WvLcU4rVnHc?table=tblFzXSDTKeVHsOY&view=vew7f7EVcH", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/OsYvbUe1iaJYeKs5WvLcU4rVnHc?table=tblASZDx24q5Bjwy&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/OsYvbUe1iaJYeKs5WvLcU4rVnHc?table=tblCge8vnc0VQvj8&view=vewFUV790L", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/OsYvbUe1iaJYeKs5WvLcU4rVnHc?table=tblLrqX4cMXwgouH&view=vewsBI4qK9", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/OsYvbUe1iaJYeKs5WvLcU4rVnHc?table=tblUQxEiRyUI8Nzm&view=vewsBI4qK9", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/OsYvbUe1iaJYeKs5WvLcU4rVnHc?table=tblAq1xAfiRGixne&view=vewt3gxYA9", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/OsYvbUe1iaJYeKs5WvLcU4rVnHc?table=tbl5xxVjXBdTXqBQ&view=vewt3gxYA9", + "style_content": "", + "erp_codes": [ + "10398" + ], + "item_ids": [ + "832214449817", + "785061026078", + "785013594474" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlsojKFFPT2", + "config": { + "platform": "天猫", + "style_name": "盖亚微单", + "note": "包含微单、微单pro", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/Qs4mbzyAaaQ8SasnDpkc9lA5npe?table=tbltcJbLyiEcxQZJ&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/Qs4mbzyAaaQ8SasnDpkc9lA5npe?table=tbl3czAfAuey9eTc&view=vewQHErmIa", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/Qs4mbzyAaaQ8SasnDpkc9lA5npe?table=tblMok3p5E6QydPq&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/Qs4mbzyAaaQ8SasnDpkc9lA5npe?table=tblQZ7EewZGZHRPG&view=vewEGtOprr", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/Qs4mbzyAaaQ8SasnDpkc9lA5npe?table=tbl37dKqdVn0GrWL&view=vewEGtOprr", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/Qs4mbzyAaaQ8SasnDpkc9lA5npe?table=tblnLFOWNQrBZ2FT&view=vewEGtOprr", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/Qs4mbzyAaaQ8SasnDpkc9lA5npe?table=tbl4g1d7a6xYnFw2&view=vewqPxP5zY", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/Qs4mbzyAaaQ8SasnDpkc9lA5npe?table=tblV6J7K86t97332&view=vewqPxP5zY", + "style_content": "", + "erp_codes": [ + "10393" + ], + "item_ids": [ + "765518426414", + "710312339945" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdT0U1AZ", + "config": { + "platform": "天猫", + "style_name": "盖世m1", + "note": "包含m1、扩展版", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/KnBRbH9iQaH3PAsAUk5cHLxNnMg?table=tblfQBL0TNhZkfgk&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/KnBRbH9iQaH3PAsAUk5cHLxNnMg?table=tbl1cXPCZZg4xmGW&view=vewlc7JnDj", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/KnBRbH9iQaH3PAsAUk5cHLxNnMg?table=tblcZ8y5126gTHe7&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/KnBRbH9iQaH3PAsAUk5cHLxNnMg?table=tbl5xa8UQqQ92dLX&view=vew3d3jlgC", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/KnBRbH9iQaH3PAsAUk5cHLxNnMg?table=tblBszpHEryOWQ0U&view=vewskiUFTR", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/KnBRbH9iQaH3PAsAUk5cHLxNnMg?table=tbln1Bc2indteEpx&view=vewskiUFTR", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/KnBRbH9iQaH3PAsAUk5cHLxNnMg?table=tbl4KkQV6goQBawV&view=vewStRNML4", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/KnBRbH9iQaH3PAsAUk5cHLxNnMg?table=tbleWXpVM5W3Lex8&view=vewDlhAYhl", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/KnBRbH9iQaH3PAsAUk5cHLxNnMg?table=tblqFDSD63voDLMI&view=vewDlhAYhl", + "style_content": "", + "erp_codes": [ + "10416", + "10455" + ], + "item_ids": [ + "822904000398", + "929578566382" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdT0Ll9t", + "config": { + "platform": "天猫", + "style_name": "星迹2", + "note": "包含星迹、星迹2", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/Ssp8bXmrwahRBbsko2BcuiLanad?table=tblWz56o40TF8Rqj&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/Ssp8bXmrwahRBbsko2BcuiLanad?table=tblI4VItN9zJ2NWm&view=vewGr5tM7V", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/Ssp8bXmrwahRBbsko2BcuiLanad?table=tblqNsVvKj4EPyzA&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/Ssp8bXmrwahRBbsko2BcuiLanad?table=tblMPlE7z9WqNOsz&view=vewUUvHUNO", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/Ssp8bXmrwahRBbsko2BcuiLanad?table=tblMJVGItaTZSYer&view=vewpOL5EMy", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/Ssp8bXmrwahRBbsko2BcuiLanad?table=tbl5qecfy2CDB1Gh&view=vewFd9nUkm", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/Ssp8bXmrwahRBbsko2BcuiLanad?table=tblMK0bFX6KbBssl&view=vewStRNML4", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/Ssp8bXmrwahRBbsko2BcuiLanad?table=tblqI9qpESpn0Cbz&view=vewaauNaAd", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/Ssp8bXmrwahRBbsko2BcuiLanad?table=tblaI0n2v3TxDW5y&view=vewaauNaAd", + "style_content": "", + "erp_codes": [ + "10504", + "10394" + ], + "item_ids": [ + "770900272208", + "765693071830", + "1018640025955", + "1019020504788" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdT0XuC5", + "config": { + "platform": "天猫", + "style_name": "星云2", + "note": "包含星云、星云2", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/BJaWbLEOKaHONMshOidcxvsKngh?table=tbla91Pfbb5T78AN&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/BJaWbLEOKaHONMshOidcxvsKngh?table=tbl4TSY8KSR3xJGL&view=vewWvUtlaT", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/BJaWbLEOKaHONMshOidcxvsKngh?table=tblR0tCfPNk34ULj&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/BJaWbLEOKaHONMshOidcxvsKngh?table=tblhrzHUt9iFrbf1&view=vewoG3AP6V", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/BJaWbLEOKaHONMshOidcxvsKngh?table=tbl7CKdsXN9vRAhN&view=vewgLq6AzX", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/BJaWbLEOKaHONMshOidcxvsKngh?table=tblCCsbii1E5A9D2&view=vewgLq6AzX", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/BJaWbLEOKaHONMshOidcxvsKngh?table=tbl1sID3mOpVPr9Q&view=vewStRNML4", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/BJaWbLEOKaHONMshOidcxvsKngh?table=tbltzBjbdiW4GrUd&view=vewk9XrdEE", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/BJaWbLEOKaHONMshOidcxvsKngh?table=tblbtmOzjc8rTsOC&view=vewk9XrdEE", + "style_content": "", + "erp_codes": [ + "10439" + ], + "item_ids": [ + "900176117674", + "865283623976", + "669949528463" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdT0FLzF", + "config": { + "platform": "天猫", + "style_name": "宙斯", + "note": "包含所有宙斯", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/N43abi7IlaS8XosnUXec6gH2n7g?table=tblwYxTG3gAqXaKx&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/N43abi7IlaS8XosnUXec6gH2n7g?table=tblsNUawGSzmB06P&view=vewa4JsdEu", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/N43abi7IlaS8XosnUXec6gH2n7g?table=tblasQwwbXwpGbpL&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/N43abi7IlaS8XosnUXec6gH2n7g?table=tblhO3XHmAxv2Wwd&view=vewtwIgtg4", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/N43abi7IlaS8XosnUXec6gH2n7g?table=tblS2NugDF8Mgz7b&view=vewjqTdeRK", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/N43abi7IlaS8XosnUXec6gH2n7g?table=tblXgO3fFK9skUbN&view=vewlbDYd51", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/N43abi7IlaS8XosnUXec6gH2n7g?table=tblxvEHpG2D1nHzS&view=vewStRNML4", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/N43abi7IlaS8XosnUXec6gH2n7g?table=tblpjldjjyM3BNo4&view=vewi6Tx2rX", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/N43abi7IlaS8XosnUXec6gH2n7g?table=tbliV6jvlob0Iexu&view=vewi6Tx2rX", + "style_content": "", + "erp_codes": [ + "10489", + "10484", + "10483" + ], + "item_ids": [ + "986141740958", + "655191144133", + "675338689161", + "743062995650", + "775995806796", + "877579274516", + "764357861270" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdT0dVCJ", + "config": { + "platform": "天猫", + "style_name": "阿波罗2", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/W80vbtPDoaaHTOs5Tipc6CTLn6c?table=tblz8Adhwr71hMv2&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/W80vbtPDoaaHTOs5Tipc6CTLn6c?table=tblg7YhED1mxPJ6W&view=vewS5SPd7e", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/W80vbtPDoaaHTOs5Tipc6CTLn6c?table=tblUaRl444G5IhVY&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/W80vbtPDoaaHTOs5Tipc6CTLn6c?table=tblv4cDwTQcuKYjU&view=vewuCrqdqk", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/W80vbtPDoaaHTOs5Tipc6CTLn6c?table=tblfRLj0qMxymdWY&view=vewewA4FG3", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/W80vbtPDoaaHTOs5Tipc6CTLn6c?table=tbl1vi1RQUDuDBhY&view=vewewA4FG3", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/W80vbtPDoaaHTOs5Tipc6CTLn6c?table=tbl6PsOTcxPnVo52&view=vewStRNML4", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/W80vbtPDoaaHTOs5Tipc6CTLn6c?table=tblXfbUEHHewfqdn&view=vew1wuOFW8", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/W80vbtPDoaaHTOs5Tipc6CTLn6c?table=tblUVBUzBJUKIwSh&view=vew1wuOFW8", + "style_content": "", + "erp_codes": [ + "10442" + ], + "item_ids": [ + "900428335849", + "956633501942" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdT0Bh8B", + "config": { + "platform": "天猫", + "style_name": "阿波罗x1", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/CtW5bUcc9a5rqRsZkwucUWDVnHb?table=tblLq5ROaFQodu25&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/CtW5bUcc9a5rqRsZkwucUWDVnHb?table=tblZB7JHoR3Wkyol&view=vewYT5gGTl", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/CtW5bUcc9a5rqRsZkwucUWDVnHb?table=tbl8WgoFrOjkqOD5&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/CtW5bUcc9a5rqRsZkwucUWDVnHb?table=tblkcgDPoML0KxCJ&view=vewRJNcCm2", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/CtW5bUcc9a5rqRsZkwucUWDVnHb?table=tblCRpRWw4IVLBkQ&view=vewMlYdYpN", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/CtW5bUcc9a5rqRsZkwucUWDVnHb?table=tblr2ulqov1DvQxq&view=vewMlYdYpN", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/CtW5bUcc9a5rqRsZkwucUWDVnHb?table=tblmukfaxYELuTgj&view=vewtJSyN8j", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/CtW5bUcc9a5rqRsZkwucUWDVnHb?table=tbl1jhQfH0ES6e9H&view=vewtJSyN8j", + "style_content": "", + "erp_codes": [ + "10388" + ], + "item_ids": [ + "727733319542" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdT08lEm", + "config": { + "platform": "天猫", + "style_name": "波塞冬edge", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/JsVLbso8Ba6RjbslOl1cJX8lnyb?table=tblzcYqrBg7VznO0&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/JsVLbso8Ba6RjbslOl1cJX8lnyb?table=tblYBP0lORSKty7r&view=vewXi77KZH", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/JsVLbso8Ba6RjbslOl1cJX8lnyb?table=tblwqyhbymn9Aour&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/JsVLbso8Ba6RjbslOl1cJX8lnyb?table=tblZT0olp85rkxA4&view=vewa9LWxmS", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/JsVLbso8Ba6RjbslOl1cJX8lnyb?table=tbl6JwV2H0OJj30R&view=vewN6zJwHi", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/JsVLbso8Ba6RjbslOl1cJX8lnyb?table=tblSevRm6OAIxX6i&view=vewN6zJwHi", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/JsVLbso8Ba6RjbslOl1cJX8lnyb?table=tblCHB1QmEsVs8j3&view=vewStRNML4", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/JsVLbso8Ba6RjbslOl1cJX8lnyb?table=tblX1VqISE4ZNNRZ&view=vewyKIUxAX", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/JsVLbso8Ba6RjbslOl1cJX8lnyb?table=tbl6j4gEw7nifGYx&view=vewyKIUxAX", + "style_content": "", + "erp_codes": [ + "10472" + ], + "item_ids": [ + "981750801552", + "980988758018" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdT0tGO2", + "config": { + "platform": "天猫", + "style_name": "逐星", + "note": "不含逐星GT", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/IQrbbxAHpa9oiWs631GcPkHznob?table=tblfXykMGS52Cymo&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/IQrbbxAHpa9oiWs631GcPkHznob?table=tblNCawlW3MRlsvE&view=vewibnFe7G", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/IQrbbxAHpa9oiWs631GcPkHznob?table=tblmWRjVp0wQiVeG&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/IQrbbxAHpa9oiWs631GcPkHznob?table=tbllvG9p9wJMIgva&view=vewKANFWkc", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/IQrbbxAHpa9oiWs631GcPkHznob?table=tbl4vo1PdNpC8Qh4&view=vewtgjZbMe", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/IQrbbxAHpa9oiWs631GcPkHznob?table=tblQjgOGvhiTu5VU&view=vewtgjZbMe", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/IQrbbxAHpa9oiWs631GcPkHznob?table=tblNVimu1eFsKpdh&view=vewStRNML4", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/IQrbbxAHpa9oiWs631GcPkHznob?table=tblCCXSRJwjs1cbk&view=vewQsThaYy", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/IQrbbxAHpa9oiWs631GcPkHznob?table=tblWtZJmmWRWMozj&view=vewQsThaYy", + "style_content": "", + "erp_codes": [ + "10411", + "10474" + ], + "item_ids": [ + "809320285523", + "814599474998" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdT09vvq", + "config": { + "platform": "天猫", + "style_name": "逐星GT", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/MAEZbKrNaabJbDs5ZY7caq1ynrd?table=tblVb4a0OUk26tSR&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/MAEZbKrNaabJbDs5ZY7caq1ynrd?table=tbl7oARAa66atiJA&view=vewRWPTJDu", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/MAEZbKrNaabJbDs5ZY7caq1ynrd?table=tblcT5gqVRsPBuOE&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/MAEZbKrNaabJbDs5ZY7caq1ynrd?table=tblIGRvfzyMjm36C&view=vewiztG3PM", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/MAEZbKrNaabJbDs5ZY7caq1ynrd?table=tblzeVeAUc2I94bW&view=vewmQZzG4m", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/MAEZbKrNaabJbDs5ZY7caq1ynrd?table=tbl2cigovQoPEZkl&view=vewmQZzG4m", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/MAEZbKrNaabJbDs5ZY7caq1ynrd?table=tblxjmfLPDDuG2Lj&view=vewOCA5eWJ", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/MAEZbKrNaabJbDs5ZY7caq1ynrd?table=tblBjMZnHwBKjqq3&view=vewOCA5eWJ", + "style_content": "", + "erp_codes": [ + "10494" + ], + "item_ids": [ + "1001281519943" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdT0FjEG", + "config": { + "platform": "天猫", + "style_name": "星云mini", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/RKfkb5JkIa9FttsgHvNc2nJYnbf?table=tbl9JZhmKzioqhLy&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/RKfkb5JkIa9FttsgHvNc2nJYnbf?table=tbl0g7G9gsFr7xnt&view=vewlRaaxOo", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/RKfkb5JkIa9FttsgHvNc2nJYnbf?table=tblu1yXKkQqwje0d&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/RKfkb5JkIa9FttsgHvNc2nJYnbf?table=tblCJb9KlKoQ7z5I&view=vewIXUpTJQ", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/RKfkb5JkIa9FttsgHvNc2nJYnbf?table=tblAB5gRMsKKlZjZ&view=vewsIde3jV", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/RKfkb5JkIa9FttsgHvNc2nJYnbf?table=tblK3cMKzEatgoPJ&view=vewsIde3jV", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/RKfkb5JkIa9FttsgHvNc2nJYnbf?table=tbleLq1bq2r7ZbD2&view=vewStRNML4", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/RKfkb5JkIa9FttsgHvNc2nJYnbf?table=tblkAdG9nQzr7GXm&view=vew88ySl7A", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/RKfkb5JkIa9FttsgHvNc2nJYnbf?table=tbl48ki0favkRTvy&view=vew88ySl7A", + "style_content": "", + "erp_codes": [ + "10475" + ], + "item_ids": [ + "981022406378" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdT03JgC", + "config": { + "platform": "天猫", + "style_name": "晨星2", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/VrLtbkldsaggOms3F4Bcu3DinCg?table=tblsBVuBxAQRv2DR&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/VrLtbkldsaggOms3F4Bcu3DinCg?table=tblV6eSZik3sGRjV&view=vewF4mmrC6", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/VrLtbkldsaggOms3F4Bcu3DinCg?table=tblO5hzLek6Nk43J&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/VrLtbkldsaggOms3F4Bcu3DinCg?table=tbljjsSWRwv3mUFX&view=vew47xRxuD", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/VrLtbkldsaggOms3F4Bcu3DinCg?table=tblSZbCzrVHwswPX&view=vewRPla64L", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/VrLtbkldsaggOms3F4Bcu3DinCg?table=tblRIaVfnrCtHSEM&view=vewRPla64L", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/VrLtbkldsaggOms3F4Bcu3DinCg?table=tblDbO3PlRfXh2Zo&view=vewf026aCB", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/VrLtbkldsaggOms3F4Bcu3DinCg?table=tblGS41ZbynabNjI&view=vewf026aCB", + "style_content": "", + "erp_codes": [ + "10469" + ], + "item_ids": [ + "969316700235" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdT03hiY", + "config": { + "platform": "天猫", + "style_name": "极星双肩", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/GubZbTrMSahLg5sdonIc8kR8nZe?table=tblp7yTxlgnsvddO&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/GubZbTrMSahLg5sdonIc8kR8nZe?table=tblDSwCPStXoPj4a&view=vew4DqI9SY", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/GubZbTrMSahLg5sdonIc8kR8nZe?table=tbl1tl5CI0LhKeuk&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/GubZbTrMSahLg5sdonIc8kR8nZe?table=tbliOAom3h5JF2mC&view=vew2vYFpI0", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/GubZbTrMSahLg5sdonIc8kR8nZe?table=tblcNtjaCv2uwBE4&view=vewGZJYFmb", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/GubZbTrMSahLg5sdonIc8kR8nZe?table=tbl3cRylFweahC1z&view=vewGZJYFmb", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/GubZbTrMSahLg5sdonIc8kR8nZe?table=tblI11Gdn9ASUdzy&view=vewStRNML4", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/GubZbTrMSahLg5sdonIc8kR8nZe?table=tblzczViPjwFXSxe&view=vewsi0OIAG", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/GubZbTrMSahLg5sdonIc8kR8nZe?table=tblQfoHZylq4SGRG&view=vewsi0OIAG", + "style_content": "", + "erp_codes": [ + "10465" + ], + "item_ids": [ + "972285625931", + "966753757238", + "938160082795", + "1021224008872", + "938138060543" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdT0ruoc", + "config": { + "platform": "天猫", + "style_name": "凌云air", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/VwpibmlakaqHuLsGChccMb3BnNd?table=tblMpxWIz3amDyD6&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/VwpibmlakaqHuLsGChccMb3BnNd?table=tbl0sEhjIdRX2Ifg&view=vew8PYjRfl", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/VwpibmlakaqHuLsGChccMb3BnNd?table=tbl7fPd2dMFKQdih&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/VwpibmlakaqHuLsGChccMb3BnNd?table=tblUsAOviXbz4ntr&view=vewhUMlLZG", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/VwpibmlakaqHuLsGChccMb3BnNd?table=tbldXeFzu4EFUvLK&view=vewYJfPRG4", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/VwpibmlakaqHuLsGChccMb3BnNd?table=tblU4crxF7cJZqhO&view=vewYJfPRG4", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/VwpibmlakaqHuLsGChccMb3BnNd?table=tblLheCfo2KirXfD&view=vewStRNML4", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/VwpibmlakaqHuLsGChccMb3BnNd?table=tblCwDuo3VgsjafD&view=vew1NjbXQN", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/VwpibmlakaqHuLsGChccMb3BnNd?table=tblFMnrdmOjipFGM&view=vew1NjbXQN", + "style_content": "", + "erp_codes": [ + "10454" + ], + "item_ids": [ + "921333419403", + "941604987083" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdT0845K", + "config": { + "platform": "天猫", + "style_name": "觅光", + "note": "包含觅光、觅光mix", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/CQcibpRjgaUCg7sUTMwcnrhunMh?table=tbl1TMMsYYyr9ivY&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/CQcibpRjgaUCg7sUTMwcnrhunMh?table=tblyZV8DGIedjSc0&view=vewkg4Zf94", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/CQcibpRjgaUCg7sUTMwcnrhunMh?table=tblFHe5uONCWxbEh&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/CQcibpRjgaUCg7sUTMwcnrhunMh?table=tbloHd4mz2Vmv0ph&view=vewH94HpqB", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/CQcibpRjgaUCg7sUTMwcnrhunMh?table=tblhOjIw5UkC0yhS&view=vewqeWcvBE", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/CQcibpRjgaUCg7sUTMwcnrhunMh?table=tblkI1PUtKJojVI2&view=vewqeWcvBE", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/CQcibpRjgaUCg7sUTMwcnrhunMh?table=tblHp2DcSN3N6GSK&view=vew4k9nQIc", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/CQcibpRjgaUCg7sUTMwcnrhunMh?table=tbl4oJoaWmkO473a&view=vew4k9nQIc", + "style_content": "", + "erp_codes": [ + "10497" + ], + "item_ids": [ + "999776082964", + "1015049280303", + "1014849366869", + "1016460984468" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdT0F6cB", + "config": { + "platform": "天猫", + "style_name": "凌云3", + "note": "不含凌云2", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/XtHjbqpwcab3J4sWP6Fcfgh5nYt?table=tblk37EfdDrVCu1r&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/XtHjbqpwcab3J4sWP6Fcfgh5nYt?table=tbl5EqD1AbU8hjLE&view=vewsT4SRkP", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/XtHjbqpwcab3J4sWP6Fcfgh5nYt?table=tblqRgK6MI5C1H4K&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/XtHjbqpwcab3J4sWP6Fcfgh5nYt?table=tblsW0YCo0Y1Omhm&view=vewjKCLKUo", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/XtHjbqpwcab3J4sWP6Fcfgh5nYt?table=tblAkTAVCRAL2GTl&view=vewcHE910Q", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/XtHjbqpwcab3J4sWP6Fcfgh5nYt?table=tblOrpyNTtDr36GF&view=vewcHE910Q", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/XtHjbqpwcab3J4sWP6Fcfgh5nYt?table=tblMCT9eDe9QGTP2&view=vew5UBuT16", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/XtHjbqpwcab3J4sWP6Fcfgh5nYt?table=tblMCT9eDe9QGTP2&view=vew5UBuT16", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/XtHjbqpwcab3J4sWP6Fcfgh5nYt?table=tblujYjOT9AqQtXr&view=vew5UBuT16", + "style_content": "", + "erp_codes": [ + "10510" + ], + "item_ids": [ + "1051305611567", + "1051293159491" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdT0Es6m", + "config": { + "platform": "天猫", + "style_name": "瑞白", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/GNOkbKNpna8pDJsLMfzcuZt3ncf?table=tbl0tXeoW4ZlYDGu&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/GNOkbKNpna8pDJsLMfzcuZt3ncf?table=tblbHkUuK95t9B3T&view=vewEpzCvC2", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/GNOkbKNpna8pDJsLMfzcuZt3ncf?table=tbljwMiRxb4SlpGr&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/GNOkbKNpna8pDJsLMfzcuZt3ncf?table=tblqfNpXZpe5slUs&view=vewQI6R09L", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/GNOkbKNpna8pDJsLMfzcuZt3ncf?table=tblaPsxl3hWG74Xq&view=vewQ7zzCyX", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/GNOkbKNpna8pDJsLMfzcuZt3ncf?table=tblKUPoTwlAWgEDh&view=vewQ7zzCyX", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/GNOkbKNpna8pDJsLMfzcuZt3ncf?table=tblCU7lv5efZulqn&view=vewStRNML4", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/GNOkbKNpna8pDJsLMfzcuZt3ncf?table=tblHor5ILcsOOdM6&view=vewYjTMKuJ", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/GNOkbKNpna8pDJsLMfzcuZt3ncf?table=tblXCVoMccqmsipu&view=vewYjTMKuJ", + "style_content": "", + "erp_codes": [ + "10491" + ], + "item_ids": [ + "1040706443911", + "1001264424551" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdT0OVRW", + "config": { + "platform": "天猫", + "style_name": "黑曜石吐司包", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/U2JGbbWcIal6WvsxURRcTMwEnhf?table=tblaRCgW5jniY3pv&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/U2JGbbWcIal6WvsxURRcTMwEnhf?table=tblSOUXQL4nhz2Y5&view=vew9USsO3K", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/U2JGbbWcIal6WvsxURRcTMwEnhf?table=tblVEdURLsfRZykJ&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/U2JGbbWcIal6WvsxURRcTMwEnhf?table=tblGfpH4dL8zD5xi&view=vewXaKOnMC", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/U2JGbbWcIal6WvsxURRcTMwEnhf?table=tblUINCNlx1dCAB1&view=vewe9vfpPS", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/U2JGbbWcIal6WvsxURRcTMwEnhf?table=tblUaNexNe97cWDW&view=vewe9vfpPS", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/U2JGbbWcIal6WvsxURRcTMwEnhf?table=tblYRSeWV8YhiCyK&view=vewrD7YrdR", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/U2JGbbWcIal6WvsxURRcTMwEnhf?table=tblXaA6q5QgEs961&view=vewrD7YrdR", + "style_content": "", + "erp_codes": [ + "10502" + ], + "item_ids": [ + "1022486093557", + "1026596944943" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdT0k10K", + "config": { + "platform": "天猫", + "style_name": "黑曜石托特包", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/U9S8bLYsNa8Ul8sPffAcrJ02nHI?table=tblg0vofQilmXUiu&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/U9S8bLYsNa8Ul8sPffAcrJ02nHI?table=tblae0t1lfcPrtK6&view=vewyl2eOzN", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/U9S8bLYsNa8Ul8sPffAcrJ02nHI?table=tblhpEmlXGyqCGuh&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/U9S8bLYsNa8Ul8sPffAcrJ02nHI?table=tbl8R1dgIi6kGo4f&view=vewQqBu62v", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/U9S8bLYsNa8Ul8sPffAcrJ02nHI?table=tbl6OCDZ6oG09Grv&view=vewIK2Ccsd", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/U9S8bLYsNa8Ul8sPffAcrJ02nHI?table=tbljN1uV9vlNceiX&view=vewIK2Ccsd", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/U9S8bLYsNa8Ul8sPffAcrJ02nHI?table=tblwlrW5ZujYgVhF&view=vewStRNML4", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/U9S8bLYsNa8Ul8sPffAcrJ02nHI?table=tblgY6MEIw11iwzN&view=vewbAowRMh", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/U9S8bLYsNa8Ul8sPffAcrJ02nHI?table=tblQFfSXTdyYQFwM&view=vewbAowRMh", + "style_content": "", + "erp_codes": [ + "10503" + ], + "item_ids": [ + "1022490201756", + "1026467516876" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdT04fd2", + "config": { + "platform": "天猫", + "style_name": "曜影", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/UPk3bR9MOaTkgrsG1qLc7IUvnQe?table=tblFE6x5g2A2Eyd0&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/UPk3bR9MOaTkgrsG1qLc7IUvnQe?table=tblYgdR3IgTvTspB&view=vewdgTtWoO", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/UPk3bR9MOaTkgrsG1qLc7IUvnQe?table=tbllXFWRI59dLCM2&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/UPk3bR9MOaTkgrsG1qLc7IUvnQe?table=tblTO1MPpnQqHVaL&view=vewgHnpi7u", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/UPk3bR9MOaTkgrsG1qLc7IUvnQe?table=tblXWECQmhVVOcbg&view=vew2xYluJG", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/UPk3bR9MOaTkgrsG1qLc7IUvnQe?table=tbly5ZfXF2NRIM5Z&view=vew2xYluJG", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/UPk3bR9MOaTkgrsG1qLc7IUvnQe?table=tblULX16sKFlRFft&view=vewStRNML4", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/UPk3bR9MOaTkgrsG1qLc7IUvnQe?table=tblPHeROFR6UeE99&view=vew0AV6kv9", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/UPk3bR9MOaTkgrsG1qLc7IUvnQe?table=tblXxFk61dJfaGIk&view=vew0AV6kv9", + "style_content": "", + "erp_codes": [ + "10507" + ], + "item_ids": [ + "1045840128509", + "1048058265963" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdT0LBzO", + "config": { + "platform": "天猫", + "style_name": "黑曜石电脑包", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/PwrGbXcpRaxJnOsd9cacFou8nAf?table=tbl0yBLBVo8HMGx9&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/PwrGbXcpRaxJnOsd9cacFou8nAf?table=tblPIHM3SORWYGzM&view=vew2X87RUb", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/PwrGbXcpRaxJnOsd9cacFou8nAf?table=tbludhnt5lkmdvSF&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/PwrGbXcpRaxJnOsd9cacFou8nAf?table=tblrXTrfW6Bg9dgf&view=vewrUbG6qb", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/PwrGbXcpRaxJnOsd9cacFou8nAf?table=tblWNFbeVCbPZyV0&view=vewyKtGPjm", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/PwrGbXcpRaxJnOsd9cacFou8nAf?table=tbleakIYG11PnMW0&view=vewyKtGPjm", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/PwrGbXcpRaxJnOsd9cacFou8nAf?table=tblpG3xbGr1qVFN4&view=vewZUcy7xc", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/PwrGbXcpRaxJnOsd9cacFou8nAf?table=tblNjfD1mJGhxQZZ&view=vewZUcy7xc", + "style_content": "", + "erp_codes": [ + "10508" + ], + "item_ids": [ + "1046854786628", + "1046497090037" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlsykEyTWp0", + "config": { + "platform": "天猫", + "style_name": "拓界2", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/CNgPbjuXiaPoxPsShROcYbRDnbb?table=tblbj4E6PAtej4jG&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/CNgPbjuXiaPoxPsShROcYbRDnbb?table=tblofU5j1BlqmXd1&view=vewkwocbDP", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/CNgPbjuXiaPoxPsShROcYbRDnbb?table=tblEymEPk7lVaQN1&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/CNgPbjuXiaPoxPsShROcYbRDnbb?table=tblv2MsmwXAmCRFv&view=vewZowHQLu", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/CNgPbjuXiaPoxPsShROcYbRDnbb?table=tblNwm01WB2BWXCw&view=vewsgolEWa", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/CNgPbjuXiaPoxPsShROcYbRDnbb?table=tblACK0DlfAGHGaM&view=vewEcML3KC", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/CNgPbjuXiaPoxPsShROcYbRDnbb?table=tblJuNYndFIuB7qU&view=vewT4qJrpW", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/CNgPbjuXiaPoxPsShROcYbRDnbb?table=tblpuyzyqoGKDS2A&view=vewT4qJrpW", + "style_content": "", + "erp_codes": [ + "10511" + ], + "item_ids": [ + "1051287111533", + "1053051469809" + ], + "enabled": true + } + }, + { + "source_record_id": "recvnkmSD6AIf8", + "config": { + "platform": "天猫", + "style_name": "布谷", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/KU3Vbw5qDay8kUsSWibcl0cinAc?table=tblMjsKLKa6iPRR3&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/KU3Vbw5qDay8kUsSWibcl0cinAc?table=tblMjsKLKa6iPRR3&view=vewGbFVRhD", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/KU3Vbw5qDay8kUsSWibcl0cinAc?table=tblkrKh0LHn4tzq5&view=vewStRNML4", + "brand": "ozko", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/KU3Vbw5qDay8kUsSWibcl0cinAc?table=tbltNPDMMnDP8Due&view=vewh4acTvy", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/KU3Vbw5qDay8kUsSWibcl0cinAc?table=tbl9o3AO8RE8W3MQ&view=vewh4acTvy", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/KU3Vbw5qDay8kUsSWibcl0cinAc?table=tblvwiCIbp0Tm47x&view=vewh4acTvy", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/KU3Vbw5qDay8kUsSWibcl0cinAc?table=tblydBoK3k3tNWna&view=vewLSH3vil", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/KU3Vbw5qDay8kUsSWibcl0cinAc?table=tblTa7CAwqdsw32U&view=vewNBaGX22", + "style_content": "", + "erp_codes": [ + "10352", + "10496" + ], + "item_ids": [ + "733457293894" + ], + "enabled": true + } + }, + { + "source_record_id": "recvnkmT58ief5", + "config": { + "platform": "天猫", + "style_name": "白鹭", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/UhuObUyaFaU4Djs55FIcA5RYnVc?table=tbl8fMI03EpEEEur&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/UhuObUyaFaU4Djs55FIcA5RYnVc?table=tbl8fMI03EpEEEur&view=vewGbFVRhD", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/UhuObUyaFaU4Djs55FIcA5RYnVc?table=tbl4YMl9ZOUHF6pA&view=vewStRNML4", + "brand": "ozko", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/UhuObUyaFaU4Djs55FIcA5RYnVc?table=tbl0YZznhfXNJT6I&view=vewFJL5qqf", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/UhuObUyaFaU4Djs55FIcA5RYnVc?table=tbluRQqdWYeqLLDu&view=vewFJL5qqf", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/UhuObUyaFaU4Djs55FIcA5RYnVc?table=tblyl7pDMeazEENg&view=vewFJL5qqf", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/UhuObUyaFaU4Djs55FIcA5RYnVc?table=tblekT8vmUdmVCAy&view=vewzXa4fNM", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/UhuObUyaFaU4Djs55FIcA5RYnVc?table=tbl48FgGqvw61woi&view=vewzXa4fNM", + "style_content": "", + "erp_codes": [ + "10310" + ], + "item_ids": [ + "705102437453", + "720245282650" + ], + "enabled": true + } + }, + { + "source_record_id": "recvnkmTmeukrc", + "config": { + "platform": "天猫", + "style_name": "蓝鹊", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/NoM1bh0zZap7wwsQ69ScydGmnNd?table=tbleOD0u4WS6igEg&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/NoM1bh0zZap7wwsQ69ScydGmnNd?table=tbleOD0u4WS6igEg&view=vewGbFVRhD", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/NoM1bh0zZap7wwsQ69ScydGmnNd?table=tblFZc9UfFynYYFf&view=vewStRNML4", + "brand": "ozko", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/NoM1bh0zZap7wwsQ69ScydGmnNd?table=tblQeJWu9F2P3j6I&view=vewOF5DSSm", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/NoM1bh0zZap7wwsQ69ScydGmnNd?table=tblPQDKglBN9P0eJ&view=vewOF5DSSm", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/NoM1bh0zZap7wwsQ69ScydGmnNd?table=tblVsXlwKuuWRdCt&view=vewOF5DSSm", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/NoM1bh0zZap7wwsQ69ScydGmnNd?table=tblCsGMRuIEe3G3t&view=vewOiOdjPU", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/NoM1bh0zZap7wwsQ69ScydGmnNd?table=tblCsGMRuIEe3G3t&view=vewOiOdjPU", + "style_content": "", + "erp_codes": [ + "10279" + ], + "item_ids": [ + "678524598235", + "676803100764" + ], + "enabled": true + } + }, + { + "source_record_id": "recvnkxGFgIBl0", + "config": { + "platform": "天猫", + "style_name": "盖亚双肩3", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/XsbmbDyp9adqawsHInacBID1nTc?table=tblWLfx1mja0JyIU&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/XsbmbDyp9adqawsHInacBID1nTc?table=tblXZu1tN5neNrZk&view=vewdPe0N2x", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/XsbmbDyp9adqawsHInacBID1nTc?table=tblkAakL8aj3CBrC&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/XsbmbDyp9adqawsHInacBID1nTc?table=tblcO66p5SD12Es3&view=vewH2ueZ30", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/XsbmbDyp9adqawsHInacBID1nTc?table=tbl64yK3pexG76Ei&view=vewRKihBVS", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/XsbmbDyp9adqawsHInacBID1nTc?table=tblRu5G85qDFGdx5&view=vewRKihBVS", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/XsbmbDyp9adqawsHInacBID1nTc?table=tbl1yTxdrfN5JDv1&view=vewStRNML4", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/XsbmbDyp9adqawsHInacBID1nTc?table=tblEbqXS8BDO4mBp&view=vewBArlxJv", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/XsbmbDyp9adqawsHInacBID1nTc?table=tblkoy0269A1abgx&view=vewBArlxJv", + "style_content": "", + "erp_codes": [ + "10512" + ], + "item_ids": [ + "1058784974545" + ], + "enabled": true + } + }, + { + "source_record_id": "recvnkFDqwjSkW", + "config": { + "platform": "天猫", + "style_name": "极星2", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/LvLBblI5KaekeUsaPRpcDeSBnxb?table=tblDHCPURC5P2VX0&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/LvLBblI5KaekeUsaPRpcDeSBnxb?table=tblYk804YGQJ5rZQ&view=vewNxZLIvy", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/LvLBblI5KaekeUsaPRpcDeSBnxb?table=tblNYpJkLJYWaV82&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/LvLBblI5KaekeUsaPRpcDeSBnxb?table=tbl1O2bXetc9wav5&view=vewvcrG41K", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/LvLBblI5KaekeUsaPRpcDeSBnxb?table=tblSUrZyWUEhEJrD&view=vewvcrG41K", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/LvLBblI5KaekeUsaPRpcDeSBnxb?table=tblcvS2bs8rMacZo&view=vewvcrG41K", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/LvLBblI5KaekeUsaPRpcDeSBnxb?table=tbl4Fi989YAVpx8n&view=vewXPg4JT4", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/LvLBblI5KaekeUsaPRpcDeSBnxb?table=tbl4Fi989YAVpx8n&view=vewXPg4JT4", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/LvLBblI5KaekeUsaPRpcDeSBnxb?table=tblJ9yClvrbZyO40&view=vewXPg4JT4", + "style_content": "", + "erp_codes": [ + "10520" + ], + "item_ids": [ + "1065685510640" + ], + "enabled": true + } + }, + { + "source_record_id": "recvnkGLMwkbaJ", + "config": { + "platform": "天猫", + "style_name": "拾影斜挎相机包", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/PjElbfNpsadXipsgt6ecNa9wnZb?table=tbl2SBqWoQIErusy&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/PjElbfNpsadXipsgt6ecNa9wnZb?table=tblk7FpHVa7Mww2R&view=vewdPe0N2x", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/WGVebBy7waEcffsnKFXcuIrwnNf?table=tbloHTSnakmokRkf&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/WGVebBy7waEcffsnKFXcuIrwnNf?table=tbl44cL2ZXfN85Xk&view=vewlmIcjqI", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/WGVebBy7waEcffsnKFXcuIrwnNf?table=tblg9uDJCa7FyPS9&view=vewpmNwPeX", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/WGVebBy7waEcffsnKFXcuIrwnNf?table=tblXgVj1O9UbaJf7&view=vewgovlfBH", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/WGVebBy7waEcffsnKFXcuIrwnNf?table=tblpXpslL2rcUJAK&view=vewStRNML4", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/WGVebBy7waEcffsnKFXcuIrwnNf?table=tbl1PcudZ1kjq5Z5&view=vewJwbpDtU", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/WGVebBy7waEcffsnKFXcuIrwnNf?table=tblwWSAt77EZbuBd&view=vewJwbpDtU", + "style_content": "", + "erp_codes": [ + "10517" + ], + "item_ids": [ + "1063050471117" + ], + "enabled": true + } + }, + { + "source_record_id": "recvnkGSgYD98Y", + "config": { + "platform": "天猫", + "style_name": "拾影双肩", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/JAGEbWz1aarBf7sX96zc8S0VnXg?table=tbl9l9ZfBi47wOfQ&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/JAGEbWz1aarBf7sX96zc8S0VnXg?table=tbl7qTX2R9dvUkBy&view=vewdPe0N2x", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/JAGEbWz1aarBf7sX96zc8S0VnXg?table=tblddNHGumvADu2S&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/JAGEbWz1aarBf7sX96zc8S0VnXg?table=tbldh9wItJmX4dMQ&view=vewJ9rdlXK", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/JAGEbWz1aarBf7sX96zc8S0VnXg?table=tblYPZTTtte4ABnq&view=vewOowVCt4", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/JAGEbWz1aarBf7sX96zc8S0VnXg?table=tblQN3WZ8MUbTNqF&view=vewWRdRfmF", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10518" + ], + "item_ids": [ + "1062199479826", + "1066036452695" + ], + "enabled": true + } + }, + { + "source_record_id": "recvnkHg0t4enB", + "config": { + "platform": "天猫", + "style_name": "云栖", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/Ln7qbbemaaRdZZsNFdTc5R8knbu?table=tblAPSFhzDpjLjND&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/Ln7qbbemaaRdZZsNFdTc5R8knbu?table=tbl7BG0JiJERnt0O&view=vewa4JsdEu", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/Ln7qbbemaaRdZZsNFdTc5R8knbu?table=tbl7pqiDt3I7wWQx&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/IYqDbYyX3a6a9MsWDtTcRu1ynNc?table=tblP7CfbarUF5tIu&view=vewpidtv3W", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/Ln7qbbemaaRdZZsNFdTc5R8knbu?table=tbl0jzSWiv4yXzML&view=vewjqTdeRK", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/IYqDbYyX3a6a9MsWDtTcRu1ynNc?table=tbl4cbgoCFK8bJXG&view=vewbywyonT", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/Ln7qbbemaaRdZZsNFdTc5R8knbu?table=tbl1QIlAKkAMuaaT&view=vewi6Tx2rX", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/Ln7qbbemaaRdZZsNFdTc5R8knbu?table=tbliunq6TGJepaKI&view=vewi6Tx2rX", + "style_content": "", + "erp_codes": [ + "10521" + ], + "item_ids": [ + "1074059145982", + "1072189687125" + ], + "enabled": true + } + }, + { + "source_record_id": "recvnlrenIJRMM", + "config": { + "platform": "天猫", + "style_name": "波塞冬手提电脑包", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/Aiazbsa80auaVGsktHEcembEnzc?table=tblgdlUCQg5wQqkG&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/Aiazbsa80auaVGsktHEcembEnzc?table=tblLF4jpc7EsaBsO&view=vewdPe0N2x", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/Aiazbsa80auaVGsktHEcembEnzc?table=tblaRStV5YO5Mqkw&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/Aiazbsa80auaVGsktHEcembEnzc?table=tbljRRXBT8hIFtpI&view=vewNnFCmcC", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/Aiazbsa80auaVGsktHEcembEnzc?table=tbl46wiFMwOUvfUy&view=vewtE8M4Yx", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/Aiazbsa80auaVGsktHEcembEnzc?table=tbllbuWfjuJtmZCh&view=vewtE8M4Yx", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/Aiazbsa80auaVGsktHEcembEnzc?table=tblHgeqnVMWayXiL&view=vewStRNML4", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/Aiazbsa80auaVGsktHEcembEnzc?table=tblFph4oYic3bG8k&view=vew4GSCwsg", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/Aiazbsa80auaVGsktHEcembEnzc?table=tblhBrv9vVHMf9uW&view=vew4GSCwsg", + "style_content": "", + "erp_codes": [ + "10515" + ], + "item_ids": [ + "1060420778652", + "1061408417997", + "1063521443835" + ], + "enabled": true + } + }, + { + "source_record_id": "recvnlrmk0kXIN", + "config": { + "platform": "天猫", + "style_name": "极星双肩2", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/UBM9bg9WnaU0Jvs3MYDcC8s6nYe?table=tbl5j0lrF8spF2jG&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/UBM9bg9WnaU0Jvs3MYDcC8s6nYe?table=tbl7Zf0uBujbYyCx&view=vewdPe0N2x", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/UBM9bg9WnaU0Jvs3MYDcC8s6nYe?table=tbl78I4vUt7o4IDS&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/UBM9bg9WnaU0Jvs3MYDcC8s6nYe?table=tblyxGJyG5RmDAHn&view=vewuHTZ4cf", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/UBM9bg9WnaU0Jvs3MYDcC8s6nYe?table=tblomER5RALmn1Pr&view=vewuHTZ4cf", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/UBM9bg9WnaU0Jvs3MYDcC8s6nYe?table=tblIoFSti7abM4kr&view=vewuHTZ4cf", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/UBM9bg9WnaU0Jvs3MYDcC8s6nYe?table=tblzVYspLYZHrKMD&view=vewKdUhQH3", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/UBM9bg9WnaU0Jvs3MYDcC8s6nYe?table=tblzVYspLYZHrKMD&view=vewKdUhQH3", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/UBM9bg9WnaU0Jvs3MYDcC8s6nYe?table=tblk5E39pYpkUo61&view=vewKdUhQH3", + "style_content": "", + "erp_codes": [ + "10516" + ], + "item_ids": [ + "1060769508522", + "1058044011542" + ], + "enabled": true + } + }, + { + "source_record_id": "recvnltMk21XkC", + "config": { + "platform": "天猫", + "style_name": "极星单肩", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/MVxPb5RFEa9YEPsDmEMc6ujinoh?table=tbl7sJP5eUSZPuEo&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/MVxPb5RFEa9YEPsDmEMc6ujinoh?table=tblMFNazbzmS8mQ5&view=vewdPe0N2x", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/MVxPb5RFEa9YEPsDmEMc6ujinoh?table=tblzb89dVJpVYq3V&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/MVxPb5RFEa9YEPsDmEMc6ujinoh?table=tblOPZf16yTqDaiW&view=vewethewWA", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/MVxPb5RFEa9YEPsDmEMc6ujinoh?table=tblquQN1aVM6Dxil&view=vewethewWA", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/MVxPb5RFEa9YEPsDmEMc6ujinoh?table=tblHa2zqb3QmDKy4&view=vewethewWA", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/MVxPb5RFEa9YEPsDmEMc6ujinoh?table=tblC6HezRHW6UDn2&view=vewE0ar3sW", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/MVxPb5RFEa9YEPsDmEMc6ujinoh?table=tblNQjwSf0jeRyH8&view=vewE0ar3sW", + "style_content": "", + "erp_codes": [ + "10519" + ], + "item_ids": [ + "1067186407464", + "1070109956883" + ], + "enabled": true + } + }, + { + "source_record_id": "recvoVO9RcqvTx", + "config": { + "platform": "天猫", + "style_name": "极星托特", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/YUhNbNBedaFshCsvhPdcpzEWnPg?table=tbli3VcKVuVdvCK4&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/YUhNbNBedaFshCsvhPdcpzEWnPg?table=tbl9SSMW5wj16hUJ&view=vewdPe0N2x", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/YUhNbNBedaFshCsvhPdcpzEWnPg?table=tbl6L5fNUNsDGGcN&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/YUhNbNBedaFshCsvhPdcpzEWnPg?table=tblRlT6A6u1TEjfy&view=vew9hLJb2m", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10513" + ], + "item_ids": [ + "1061672805190", + "1060552550413" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqUASXmvPvg", + "config": { + "platform": "天猫", + "style_name": "盖亚斜挎", + "note": "包含盖亚斜挎1、盖亚斜挎2、盖亚斜挎3", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/UkbabpqRYanmD7sk7ksceVuhnwf?table=tblrKElXywgE18ta&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/UkbabpqRYanmD7sk7ksceVuhnwf?table=tblJoRIPTq96oCtC&view=vewdPe0N2x", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/UkbabpqRYanmD7sk7ksceVuhnwf?table=tbl2BdhIaohZNkOD&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/UkbabpqRYanmD7sk7ksceVuhnwf?table=tblPp5gjxxEVibHh&view=vewLb05uq3", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/UkbabpqRYanmD7sk7ksceVuhnwf?table=tblxFVgzOCeW1bGL&view=vewLb05uq3", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/UkbabpqRYanmD7sk7ksceVuhnwf?table=tbl7N6rQKVKVcRuv&view=vewLb05uq3", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/UkbabpqRYanmD7sk7ksceVuhnwf?table=tblABweR9amHihkJ&view=vewStRNML4", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/UkbabpqRYanmD7sk7ksceVuhnwf?table=tbliD5NUUcmpDIvy&view=vewpOPkJiW", + "style_analysis_bitable_url": "https://bu0zgpibak.feishu.cn/base/UkbabpqRYanmD7sk7ksceVuhnwf?table=tbldC5NjOvQMbG1B&view=vewpOPkJiW", + "style_content": "", + "erp_codes": [ + "10384", + "10452", + "10514" + ], + "item_ids": [ + "921336775006", + "674099008611", + "673333504257", + "685132225920", + "1059764111499" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqUC8tWxUXZ", + "config": { + "platform": "天猫", + "style_name": "", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [], + "enabled": true + } + }, + { + "source_record_id": "recvrYFxgH3cjD", + "config": { + "platform": "天猫", + "style_name": "瑞白mix", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/CJgAbncgfaBboOsYTR1czd4an1c?table=tblKhgkYfjS3qHwC&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/CJgAbncgfaBboOsYTR1czd4an1c?table=tblJ5lrGeIKo8rfA&view=vewdPe0N2x", + "creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/CJgAbncgfaBboOsYTR1czd4an1c?table=tblfaZQNwXEjd5Vd&view=vewStRNML4", + "brand": "光影行星", + "tm_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/CJgAbncgfaBboOsYTR1czd4an1c?table=tbl4e7ImXByp809t&view=vewuHTZ4cf", + "jd_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/CJgAbncgfaBboOsYTR1czd4an1c?table=tbllXDFhn3KTTtux&view=vewuHTZ4cf", + "dy_persona_bitable_url": "https://bu0zgpibak.feishu.cn/base/CJgAbncgfaBboOsYTR1czd4an1c?table=tbluwNcsP5cUW3cV&view=vewuHTZ4cf", + "self_creator_bitable_url": "https://bu0zgpibak.feishu.cn/base/CJgAbncgfaBboOsYTR1czd4an1c?table=tblFkE5PVrD9tVX6&view=vewStRNML4", + "weekly_note_analysis_url": "https://bu0zgpibak.feishu.cn/base/CJgAbncgfaBboOsYTR1czd4an1c?table=tblksdziIqYey4S1&view=vewKdUhQH3", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10522" + ], + "item_ids": [ + "1074032406675", + "1074080082354" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlsmrFQnvEB", + "config": { + "platform": "京东旗舰店", + "style_name": "盖亚斜挎", + "note": "包含盖亚斜挎1、盖亚斜挎2、盖亚斜挎3", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/UkbabpqRYanmD7sk7ksceVuhnwf?table=tblrKElXywgE18ta&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/UkbabpqRYanmD7sk7ksceVuhnwf?table=tblJoRIPTq96oCtC&view=vewdPe0N2x", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10384", + "10452", + "10514" + ], + "item_ids": [ + "10022621048112", + "10023155725944", + "10030097050220", + "10035283963107" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlsokbQnRNo", + "config": { + "platform": "京东旗舰店", + "style_name": "极星pro", + "note": "包含小号、中号", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/OsYvbUe1iaJYeKs5WvLcU4rVnHc?table=tblx8wC2KNOByknq&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/OsYvbUe1iaJYeKs5WvLcU4rVnHc?table=tblFzXSDTKeVHsOY&view=vew7f7EVcH", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10398" + ], + "item_ids": [ + "10025628785945", + "10026601031834", + "10026600460605" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJAVKNG", + "config": { + "platform": "京东旗舰店", + "style_name": "盖亚微单", + "note": "包含微单、微单pro", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/Qs4mbzyAaaQ8SasnDpkc9lA5npe?table=tbltcJbLyiEcxQZJ&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/Qs4mbzyAaaQ8SasnDpkc9lA5npe?table=tbl3czAfAuey9eTc&view=vewQHErmIa", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10393" + ], + "item_ids": [ + "10025310117864", + "10023909449487" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJAgruz", + "config": { + "platform": "京东旗舰店", + "style_name": "盖世m1", + "note": "包含m1、扩展版", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/KnBRbH9iQaH3PAsAUk5cHLxNnMg?table=tblfQBL0TNhZkfgk&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/KnBRbH9iQaH3PAsAUk5cHLxNnMg?table=tbl1cXPCZZg4xmGW&view=vewlc7JnDj", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10416", + "10455" + ], + "item_ids": [ + "10026326257225", + "10029721108947" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJAJl1m", + "config": { + "platform": "京东旗舰店", + "style_name": "星迹2", + "note": "包含星迹、星迹2", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/Ssp8bXmrwahRBbsko2BcuiLanad?table=tblWz56o40TF8Rqj&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/Ssp8bXmrwahRBbsko2BcuiLanad?table=tblI4VItN9zJ2NWm&view=vewGr5tM7V", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10504", + "10394" + ], + "item_ids": [ + "10033913254016", + "10025329514925", + "10025329599430" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJAJNLw", + "config": { + "platform": "京东旗舰店", + "style_name": "星云2", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/BJaWbLEOKaHONMshOidcxvsKngh?table=tbla91Pfbb5T78AN&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/BJaWbLEOKaHONMshOidcxvsKngh?table=tbl4TSY8KSR3xJGL&view=vewWvUtlaT", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10439" + ], + "item_ids": [ + "10027762032411" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJAH9hv", + "config": { + "platform": "京东旗舰店", + "style_name": "宙斯", + "note": "包含所有宙斯", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/N43abi7IlaS8XosnUXec6gH2n7g?table=tblwYxTG3gAqXaKx&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/N43abi7IlaS8XosnUXec6gH2n7g?table=tblsNUawGSzmB06P&view=vewa4JsdEu", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10489", + "10484", + "10483" + ], + "item_ids": [ + "10033333485894", + "10025292822415", + "10028005218343", + "10025020862662", + "10032720707692", + "10028005207681" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJAJNFL", + "config": { + "platform": "京东旗舰店", + "style_name": "阿波罗2", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/W80vbtPDoaaHTOs5Tipc6CTLn6c?table=tblz8Adhwr71hMv2&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/W80vbtPDoaaHTOs5Tipc6CTLn6c?table=tblg7YhED1mxPJ6W&view=vewS5SPd7e", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10442" + ], + "item_ids": [ + "10028594796004", + "10028595026975" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJAQtfE", + "config": { + "platform": "京东旗舰店", + "style_name": "阿波罗x1", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/CtW5bUcc9a5rqRsZkwucUWDVnHb?table=tblLq5ROaFQodu25&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/CtW5bUcc9a5rqRsZkwucUWDVnHb?table=tblZB7JHoR3Wkyol&view=vewYT5gGTl", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10388" + ], + "item_ids": [ + "10024429591100", + "10024753441908" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJAxCSw", + "config": { + "platform": "京东旗舰店", + "style_name": "波塞冬edge", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/JsVLbso8Ba6RjbslOl1cJX8lnyb?table=tblzcYqrBg7VznO0&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/JsVLbso8Ba6RjbslOl1cJX8lnyb?table=tblYBP0lORSKty7r&view=vewXi77KZH", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10472" + ], + "item_ids": [ + "10032366483452" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJAdUf3", + "config": { + "platform": "京东旗舰店", + "style_name": "逐星", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/IQrbbxAHpa9oiWs631GcPkHznob?table=tblfXykMGS52Cymo&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/IQrbbxAHpa9oiWs631GcPkHznob?table=tblNCawlW3MRlsvE&view=vewibnFe7G", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10411", + "10474" + ], + "item_ids": [ + "10026025497958", + "10031326808929" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJAFH3z", + "config": { + "platform": "京东旗舰店", + "style_name": "逐星GT", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/MAEZbKrNaabJbDs5ZY7caq1ynrd?table=tblVb4a0OUk26tSR&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/MAEZbKrNaabJbDs5ZY7caq1ynrd?table=tbl7oARAa66atiJA&view=vewRWPTJDu", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10494" + ], + "item_ids": [ + "10033470328729" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJAXa5Q", + "config": { + "platform": "京东旗舰店", + "style_name": "星云mini", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/RKfkb5JkIa9FttsgHvNc2nJYnbf?table=tbl9JZhmKzioqhLy&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/RKfkb5JkIa9FttsgHvNc2nJYnbf?table=tbl0g7G9gsFr7xnt&view=vewlRaaxOo", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10475" + ], + "item_ids": [ + "10032211469888" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJAVE4W", + "config": { + "platform": "京东旗舰店", + "style_name": "晨星2", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/VrLtbkldsaggOms3F4Bcu3DinCg?table=tblsBVuBxAQRv2DR&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/VrLtbkldsaggOms3F4Bcu3DinCg?table=tblV6eSZik3sGRjV&view=vewF4mmrC6", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10469" + ], + "item_ids": [ + "10031295279033" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJAvJgS", + "config": { + "platform": "京东旗舰店", + "style_name": "极星双肩", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/GubZbTrMSahLg5sdonIc8kR8nZe?table=tblp7yTxlgnsvddO&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/GubZbTrMSahLg5sdonIc8kR8nZe?table=tblDSwCPStXoPj4a&view=vew4DqI9SY", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10465" + ], + "item_ids": [ + "10029986282219", + "10029920288281", + "10033941202259" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJAZq7f", + "config": { + "platform": "京东旗舰店", + "style_name": "凌云air", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/VwpibmlakaqHuLsGChccMb3BnNd?table=tblMpxWIz3amDyD6&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/VwpibmlakaqHuLsGChccMb3BnNd?table=tbl0sEhjIdRX2Ifg&view=vew8PYjRfl", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10454" + ], + "item_ids": [ + "10029593796467" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJA8WqN", + "config": { + "platform": "京东旗舰店", + "style_name": "觅光", + "note": "包含觅光、觅光mix", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/CQcibpRjgaUCg7sUTMwcnrhunMh?table=tbl1TMMsYYyr9ivY&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/CQcibpRjgaUCg7sUTMwcnrhunMh?table=tblyZV8DGIedjSc0&view=vewkg4Zf94", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10497" + ], + "item_ids": [ + "10033787463147", + "10033860225085" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJAdm9r", + "config": { + "platform": "京东旗舰店", + "style_name": "瑞白", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/GNOkbKNpna8pDJsLMfzcuZt3ncf?table=tbl0tXeoW4ZlYDGu&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/GNOkbKNpna8pDJsLMfzcuZt3ncf?table=tblbHkUuK95t9B3T&view=vewEpzCvC2", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10491" + ], + "item_ids": [ + "10033366316587" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJAaGg9", + "config": { + "platform": "京东旗舰店", + "style_name": "黑曜石吐司包", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/U2JGbbWcIal6WvsxURRcTMwEnhf?table=tblaRCgW5jniY3pv&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/U2JGbbWcIal6WvsxURRcTMwEnhf?table=tblSOUXQL4nhz2Y5&view=vew9USsO3K", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10502" + ], + "item_ids": [ + "10033995817475" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJAdZGi", + "config": { + "platform": "京东旗舰店", + "style_name": "黑曜石托特包", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/U9S8bLYsNa8Ul8sPffAcrJ02nHI?table=tblg0vofQilmXUiu&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/U9S8bLYsNa8Ul8sPffAcrJ02nHI?table=tblae0t1lfcPrtK6&view=vewyl2eOzN", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10503" + ], + "item_ids": [ + "10033995834580" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJAtsJV", + "config": { + "platform": "京东旗舰店", + "style_name": "曜影", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/UPk3bR9MOaTkgrsG1qLc7IUvnQe?table=tblFE6x5g2A2Eyd0&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/UPk3bR9MOaTkgrsG1qLc7IUvnQe?table=tblYgdR3IgTvTspB&view=vewdgTtWoO", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10507" + ], + "item_ids": [ + "10034534903226" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJAYP15", + "config": { + "platform": "京东旗舰店", + "style_name": "黑曜石电脑包", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/PwrGbXcpRaxJnOsd9cacFou8nAf?table=tbl0yBLBVo8HMGx9&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/PwrGbXcpRaxJnOsd9cacFou8nAf?table=tblPIHM3SORWYGzM&view=vew2X87RUb", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10508" + ], + "item_ids": [ + "10034651877353" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdJAjEjw", + "config": { + "platform": "京东旗舰店", + "style_name": "凌云3", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/XtHjbqpwcab3J4sWP6Fcfgh5nYt?table=tblk37EfdDrVCu1r&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/XtHjbqpwcab3J4sWP6Fcfgh5nYt?table=tbl5EqD1AbU8hjLE&view=vewsT4SRkP", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10510" + ], + "item_ids": [ + "10034906180847" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlsFS27gL2U", + "config": { + "platform": "京东旗舰店", + "style_name": "拓界2", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/CNgPbjuXiaPoxPsShROcYbRDnbb?table=tblbj4E6PAtej4jG&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/CNgPbjuXiaPoxPsShROcYbRDnbb?table=tblofU5j1BlqmXd1&view=vewkwocbDP", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10511" + ], + "item_ids": [ + "10034905606265" + ], + "enabled": true + } + }, + { + "source_record_id": "recvnkCcR7Snmx", + "config": { + "platform": "京东旗舰店", + "style_name": "盖亚双肩3", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/XsbmbDyp9adqawsHInacBID1nTc?table=tblWLfx1mja0JyIU&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/XsbmbDyp9adqawsHInacBID1nTc?table=tblXZu1tN5neNrZk&view=vewdPe0N2x", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10512" + ], + "item_ids": [ + "10035189766894" + ], + "enabled": true + } + }, + { + "source_record_id": "recvnkLMQMbIQL", + "config": { + "platform": "京东旗舰店", + "style_name": "极星2", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/PjElbfNpsadXipsgt6ecNa9wnZb?table=tbl2SBqWoQIErusy&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/PjElbfNpsadXipsgt6ecNa9wnZb?table=tblk7FpHVa7Mww2R&view=vewdPe0N2x", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10520" + ], + "item_ids": [ + "10035475842381" + ], + "enabled": true + } + }, + { + "source_record_id": "recvoRICXmvmC7", + "config": { + "platform": "京东旗舰店", + "style_name": "拾影斜挎相机包", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/JAGEbWz1aarBf7sX96zc8S0VnXg?table=tbl9l9ZfBi47wOfQ&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/JAGEbWz1aarBf7sX96zc8S0VnXg?table=tbl7qTX2R9dvUkBy&view=vewdPe0N2x", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10517" + ], + "item_ids": [ + "10035423416854" + ], + "enabled": true + } + }, + { + "source_record_id": "recvoRIDfo6pD7", + "config": { + "platform": "京东旗舰店", + "style_name": "拾影双肩", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10518" + ], + "item_ids": [ + "10035388815380" + ], + "enabled": true + } + }, + { + "source_record_id": "recvoRIDAzFIV9", + "config": { + "platform": "京东旗舰店", + "style_name": "云栖", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/HwrSbl4BJaCxHGsja07cxvWlnGd?table=tblQhnv04ONow7O8&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/HwrSbl4BJaCxHGsja07cxvWlnGd?table=tbl5YAcyNSd50bB2&view=vewiftNcAB", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10521" + ], + "item_ids": [ + "10035749561420" + ], + "enabled": true + } + }, + { + "source_record_id": "recvoRIJWyBgsk", + "config": { + "platform": "京东旗舰店", + "style_name": "波塞冬手提电脑包", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/Aiazbsa80auaVGsktHEcembEnzc?table=tblgdlUCQg5wQqkG&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/Aiazbsa80auaVGsktHEcembEnzc?table=tblLF4jpc7EsaBsO&view=vewdPe0N2x", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10515" + ], + "item_ids": [ + "10035289085755" + ], + "enabled": true + } + }, + { + "source_record_id": "recvoRIKgAxXdO", + "config": { + "platform": "京东旗舰店", + "style_name": "极星双肩2", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/UBM9bg9WnaU0Jvs3MYDcC8s6nYe?table=tbl5j0lrF8spF2jG&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/UBM9bg9WnaU0Jvs3MYDcC8s6nYe?table=tbl7Zf0uBujbYyCx&view=vewdPe0N2x", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10516" + ], + "item_ids": [ + "10035288767154" + ], + "enabled": true + } + }, + { + "source_record_id": "recvoRIKgABJW5", + "config": { + "platform": "京东旗舰店", + "style_name": "极星托特", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/YUhNbNBedaFshCsvhPdcpzEWnPg?table=tbli3VcKVuVdvCK4&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/YUhNbNBedaFshCsvhPdcpzEWnPg?table=tbl9SSMW5wj16hUJ&view=vewdPe0N2x", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10513" + ], + "item_ids": [ + "10035316148496" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqDz9Hiwdpm", + "config": { + "platform": "京东旗舰店", + "style_name": "极星单肩", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/MVxPb5RFEa9YEPsDmEMc6ujinoh?table=tbl7sJP5eUSZPuEo&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/MVxPb5RFEa9YEPsDmEMc6ujinoh?table=tblMFNazbzmS8mQ5&view=vewdPe0N2x", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10519" + ], + "item_ids": [ + "10035605500485" + ], + "enabled": true + } + }, + { + "source_record_id": "recvs2R6eJq3Wq", + "config": { + "platform": "京东旗舰店", + "style_name": "瑞白mix", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/CJgAbncgfaBboOsYTR1czd4an1c?table=tblKhgkYfjS3qHwC&view=vewGbFVRhD", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10522" + ], + "item_ids": [ + "10035794708094" + ], + "enabled": true + } + }, + { + "source_record_id": "recvs2R6qwOkXJ", + "config": { + "platform": "京东旗舰店", + "style_name": "", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [], + "enabled": true + } + }, + { + "source_record_id": "recvs2R6CZxsT8", + "config": { + "platform": "京东旗舰店", + "style_name": "", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [], + "enabled": true + } + }, + { + "source_record_id": "recvlsmrFQSx4C", + "config": { + "platform": "抖音", + "style_name": "盖亚斜挎", + "note": "包含盖亚斜挎1、盖亚斜挎2、盖亚斜挎3", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/UkbabpqRYanmD7sk7ksceVuhnwf?table=tblrKElXywgE18ta&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/UkbabpqRYanmD7sk7ksceVuhnwf?table=tblJoRIPTq96oCtC&view=vewdPe0N2x", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10384", + "10452", + "10514" + ], + "item_ids": [ + "3758423390084530598", + "3546704497957054088", + "3723294781737140535", + "3605892222299556299", + "3614412506279319659" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlsol1Bbnj5", + "config": { + "platform": "抖音", + "style_name": "极星pro", + "note": "包含小号、中号", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/OsYvbUe1iaJYeKs5WvLcU4rVnHc?table=tblx8wC2KNOByknq&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/OsYvbUe1iaJYeKs5WvLcU4rVnHc?table=tblFzXSDTKeVHsOY&view=vew7f7EVcH", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10398" + ], + "item_ids": [ + "3723301894270091349", + "3705732014197047560", + "3774368299438178482", + "3723330591068914054", + "3679360949275460130" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoESUYr", + "config": { + "platform": "抖音", + "style_name": "盖亚微单", + "note": "包含微单、微单pro", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/Qs4mbzyAaaQ8SasnDpkc9lA5npe?table=tbltcJbLyiEcxQZJ&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/Qs4mbzyAaaQ8SasnDpkc9lA5npe?table=tbl3czAfAuey9eTc&view=vewQHErmIa", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10393" + ], + "item_ids": [ + "3723306401804714428", + "3664497398165669984", + "3610734864606824434", + "3774359264898974008", + "3608647206321664032" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoExrip", + "config": { + "platform": "抖音", + "style_name": "盖世m1", + "note": "包含m1、扩展版", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/KnBRbH9iQaH3PAsAUk5cHLxNnMg?table=tblfQBL0TNhZkfgk&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/KnBRbH9iQaH3PAsAUk5cHLxNnMg?table=tbl1cXPCZZg4xmGW&view=vewlc7JnDj", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10416", + "10455" + ], + "item_ids": [ + "3701026559747031390", + "3723329152103874604", + "3754316016256155917" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoEdAdz", + "config": { + "platform": "抖音", + "style_name": "星迹2", + "note": "包含星迹、星迹2", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/Ssp8bXmrwahRBbsko2BcuiLanad?table=tblWz56o40TF8Rqj&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/Ssp8bXmrwahRBbsko2BcuiLanad?table=tblI4VItN9zJ2NWm&view=vewGr5tM7V", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10504", + "10394" + ], + "item_ids": [ + "3723195804173860873", + "3801422650698498468", + "3800356691338461484", + "3665624563645022287", + "3774356172614796203" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoE7c90", + "config": { + "platform": "抖音", + "style_name": "星云2", + "note": "包含星云、星云2", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/BJaWbLEOKaHONMshOidcxvsKngh?table=tbla91Pfbb5T78AN&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/BJaWbLEOKaHONMshOidcxvsKngh?table=tbl4TSY8KSR3xJGL&view=vewWvUtlaT", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10439" + ], + "item_ids": [ + "3725242433383039375", + "3774363265803616739", + "3731617571452092515" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoEC1YK", + "config": { + "platform": "抖音", + "style_name": "宙斯", + "note": "包含所有宙斯", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/N43abi7IlaS8XosnUXec6gH2n7g?table=tblwYxTG3gAqXaKx&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/N43abi7IlaS8XosnUXec6gH2n7g?table=tblsNUawGSzmB06P&view=vewa4JsdEu", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10489", + "10484", + "10483" + ], + "item_ids": [ + "3730219261105406099", + "3792873247570264438", + "3731145017650184656", + "3592716653063984937", + "3562996449333647313", + "3774172500418756988", + "3663380642202287652", + "3728487369196830898", + "3723297457493377197", + "3586918760285641845", + "3774174388157546868", + "3789772833136181558" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoEKTQ3", + "config": { + "platform": "抖音", + "style_name": "阿波罗2", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/W80vbtPDoaaHTOs5Tipc6CTLn6c?table=tblz8Adhwr71hMv2&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/W80vbtPDoaaHTOs5Tipc6CTLn6c?table=tblg7YhED1mxPJ6W&view=vewS5SPd7e", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10442" + ], + "item_ids": [ + "3764111277610369503", + "3774182389698396336", + "3742100377680609285" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoEZPcE", + "config": { + "platform": "抖音", + "style_name": "阿波罗x1", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/CtW5bUcc9a5rqRsZkwucUWDVnHb?table=tblLq5ROaFQodu25&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/CtW5bUcc9a5rqRsZkwucUWDVnHb?table=tblZB7JHoR3Wkyol&view=vewYT5gGTl", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10388" + ], + "item_ids": [ + "3728485299140034862", + "3626260611501006526", + "3626260839109106918", + "3730190905890701314", + "3723144876716589485" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoEVnMk", + "config": { + "platform": "抖音", + "style_name": "波塞冬edge", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/JsVLbso8Ba6RjbslOl1cJX8lnyb?table=tblzcYqrBg7VznO0&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/JsVLbso8Ba6RjbslOl1cJX8lnyb?table=tblYBP0lORSKty7r&view=vewXi77KZH", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10472" + ], + "item_ids": [ + "3779730985017540981" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoEMIqP", + "config": { + "platform": "抖音", + "style_name": "逐星", + "note": "不含逐星GT", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/IQrbbxAHpa9oiWs631GcPkHznob?table=tblfXykMGS52Cymo&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/IQrbbxAHpa9oiWs631GcPkHznob?table=tblNCawlW3MRlsvE&view=vewibnFe7G", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10411", + "10474" + ], + "item_ids": [ + "3692499943458734547", + "3723297966522500082", + "3770677535256477997" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoEBesU", + "config": { + "platform": "抖音", + "style_name": "逐星GT", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/MAEZbKrNaabJbDs5ZY7caq1ynrd?table=tblVb4a0OUk26tSR&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/MAEZbKrNaabJbDs5ZY7caq1ynrd?table=tbl7oARAa66atiJA&view=vewRWPTJDu", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10494" + ], + "item_ids": [ + "3791442727141507531" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoEdiyN", + "config": { + "platform": "抖音", + "style_name": "星云mini", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/RKfkb5JkIa9FttsgHvNc2nJYnbf?table=tbl9JZhmKzioqhLy&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/RKfkb5JkIa9FttsgHvNc2nJYnbf?table=tbl0g7G9gsFr7xnt&view=vewlRaaxOo", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10475" + ], + "item_ids": [ + "3778067305603399962" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoEsaGR", + "config": { + "platform": "抖音", + "style_name": "晨星2", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/VrLtbkldsaggOms3F4Bcu3DinCg?table=tblsBVuBxAQRv2DR&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/VrLtbkldsaggOms3F4Bcu3DinCg?table=tblV6eSZik3sGRjV&view=vewF4mmrC6", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10469" + ], + "item_ids": [ + "3770491999128519634" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoE7TTw", + "config": { + "platform": "抖音", + "style_name": "极星双肩", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/GubZbTrMSahLg5sdonIc8kR8nZe?table=tblp7yTxlgnsvddO&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/GubZbTrMSahLg5sdonIc8kR8nZe?table=tblDSwCPStXoPj4a&view=vew4DqI9SY", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10465" + ], + "item_ids": [ + "3757136031745441873", + "3773588464549888244", + "3773580514364096762" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoEWhLR", + "config": { + "platform": "抖音", + "style_name": "凌云air", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/VwpibmlakaqHuLsGChccMb3BnNd?table=tblMpxWIz3amDyD6&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/VwpibmlakaqHuLsGChccMb3BnNd?table=tbl0sEhjIdRX2Ifg&view=vew8PYjRfl", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10454" + ], + "item_ids": [ + "3753046423898292279", + "3774190345974318296" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoE5X5G", + "config": { + "platform": "抖音", + "style_name": "觅光", + "note": "包含觅光、觅光mix", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/CQcibpRjgaUCg7sUTMwcnrhunMh?table=tbl1TMMsYYyr9ivY&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/CQcibpRjgaUCg7sUTMwcnrhunMh?table=tblyZV8DGIedjSc0&view=vewkg4Zf94", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10497" + ], + "item_ids": [ + "3799053750765748658", + "3798126273943961843" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoEWvdg", + "config": { + "platform": "抖音", + "style_name": "凌云3", + "note": "不含凌云2", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/XtHjbqpwcab3J4sWP6Fcfgh5nYt?table=tblk37EfdDrVCu1r&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/XtHjbqpwcab3J4sWP6Fcfgh5nYt?table=tbl5EqD1AbU8hjLE&view=vewsT4SRkP", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10510" + ], + "item_ids": [ + "3821081325813760272" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoErp8R", + "config": { + "platform": "抖音", + "style_name": "瑞白", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/GNOkbKNpna8pDJsLMfzcuZt3ncf?table=tbl0tXeoW4ZlYDGu&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/GNOkbKNpna8pDJsLMfzcuZt3ncf?table=tblbHkUuK95t9B3T&view=vewEpzCvC2", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10491" + ], + "item_ids": [ + "3790157724633923739" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoET8iR", + "config": { + "platform": "抖音", + "style_name": "黑曜石吐司包", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/U2JGbbWcIal6WvsxURRcTMwEnhf?table=tblaRCgW5jniY3pv&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/U2JGbbWcIal6WvsxURRcTMwEnhf?table=tblSOUXQL4nhz2Y5&view=vew9USsO3K", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10502" + ], + "item_ids": [ + "3803281856141590866" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoEqby9", + "config": { + "platform": "抖音", + "style_name": "黑曜石托特包", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/U9S8bLYsNa8Ul8sPffAcrJ02nHI?table=tblg0vofQilmXUiu&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/U9S8bLYsNa8Ul8sPffAcrJ02nHI?table=tblae0t1lfcPrtK6&view=vewyl2eOzN", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10503" + ], + "item_ids": [ + "3804002356081852742" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoEUS7a", + "config": { + "platform": "抖音", + "style_name": "曜影", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/UPk3bR9MOaTkgrsG1qLc7IUvnQe?table=tblFE6x5g2A2Eyd0&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/UPk3bR9MOaTkgrsG1qLc7IUvnQe?table=tblYgdR3IgTvTspB&view=vewdgTtWoO", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10507" + ], + "item_ids": [ + "3814987314862620724" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlstdoEh0XO", + "config": { + "platform": "抖音", + "style_name": "黑曜石电脑包", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/PwrGbXcpRaxJnOsd9cacFou8nAf?table=tbl0yBLBVo8HMGx9&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/PwrGbXcpRaxJnOsd9cacFou8nAf?table=tblPIHM3SORWYGzM&view=vew2X87RUb", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10508" + ], + "item_ids": [ + "3816632074752884911" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlsyp0i4Uc4", + "config": { + "platform": "抖音", + "style_name": "拓界2", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/CNgPbjuXiaPoxPsShROcYbRDnbb?table=tblbj4E6PAtej4jG&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/CNgPbjuXiaPoxPsShROcYbRDnbb?table=tblofU5j1BlqmXd1&view=vewkwocbDP", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10511" + ], + "item_ids": [ + "3823529057790853229" + ], + "enabled": true + } + }, + { + "source_record_id": "recvnkFFpkOHKF", + "config": { + "platform": "抖音", + "style_name": "盖亚双肩3", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/XsbmbDyp9adqawsHInacBID1nTc?table=tblWLfx1mja0JyIU&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/XsbmbDyp9adqawsHInacBID1nTc?table=tblXZu1tN5neNrZk&view=vewdPe0N2x", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10512" + ], + "item_ids": [ + "3826142143701975727" + ], + "enabled": true + } + }, + { + "source_record_id": "recvnkJT6FvLMj", + "config": { + "platform": "抖音", + "style_name": "极星2", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/LvLBblI5KaekeUsaPRpcDeSBnxb?table=tblDHCPURC5P2VX0&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/LvLBblI5KaekeUsaPRpcDeSBnxb?table=tblYk804YGQJ5rZQ&view=vewNxZLIvy", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10520" + ], + "item_ids": [ + "3831137255175618993" + ], + "enabled": true + } + }, + { + "source_record_id": "recvnkJTu2Dk6y", + "config": { + "platform": "抖音", + "style_name": "拾影斜挎相机包", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/PjElbfNpsadXipsgt6ecNa9wnZb?table=tbl2SBqWoQIErusy&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/PjElbfNpsadXipsgt6ecNa9wnZb?table=tblk7FpHVa7Mww2R&view=vewdPe0N2x", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10517" + ], + "item_ids": [ + "3829862105213960657" + ], + "enabled": true + } + }, + { + "source_record_id": "recvnkLKMCJ29q", + "config": { + "platform": "抖音", + "style_name": "拾影双肩", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/JAGEbWz1aarBf7sX96zc8S0VnXg?table=tbl9l9ZfBi47wOfQ&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/JAGEbWz1aarBf7sX96zc8S0VnXg?table=tbl7qTX2R9dvUkBy&view=vewdPe0N2x", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10518" + ], + "item_ids": [ + "3833696860279800009" + ], + "enabled": true + } + }, + { + "source_record_id": "recvnkLKMCGUXa", + "config": { + "platform": "抖音", + "style_name": "云栖", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/HwrSbl4BJaCxHGsja07cxvWlnGd?table=tblQhnv04ONow7O8&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/HwrSbl4BJaCxHGsja07cxvWlnGd?table=tbl5YAcyNSd50bB2&view=vewiftNcAB", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10521" + ], + "item_ids": [ + "3836521930811376128" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqyMMnIMHWu", + "config": { + "platform": "抖音", + "style_name": "波塞冬手提电脑包", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/Aiazbsa80auaVGsktHEcembEnzc?table=tblgdlUCQg5wQqkG&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/Aiazbsa80auaVGsktHEcembEnzc?table=tblLF4jpc7EsaBsO&view=vewdPe0N2x", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10515" + ], + "item_ids": [ + "3830202762700915010" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqyMQXvnzhI", + "config": { + "platform": "抖音", + "style_name": "极星托特", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/YUhNbNBedaFshCsvhPdcpzEWnPg?table=tbli3VcKVuVdvCK4&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/YUhNbNBedaFshCsvhPdcpzEWnPg?table=tbl9SSMW5wj16hUJ&view=vewdPe0N2x", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10513" + ], + "item_ids": [ + "3828504002086174918" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqDru7QewzK", + "config": { + "platform": "抖音", + "style_name": "极星双肩2", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/UBM9bg9WnaU0Jvs3MYDcC8s6nYe?table=tbl5j0lrF8spF2jG&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/UBM9bg9WnaU0Jvs3MYDcC8s6nYe?table=tbl7Zf0uBujbYyCx&view=vewdPe0N2x", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10516" + ], + "item_ids": [ + "3828496080069329122" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqDryb3vojK", + "config": { + "platform": "抖音", + "style_name": "极星单肩", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/MVxPb5RFEa9YEPsDmEMc6ujinoh?table=tbl7sJP5eUSZPuEo&view=vewGbFVRhD", + "main_image_bitable_url": "https://bu0zgpibak.feishu.cn/base/MVxPb5RFEa9YEPsDmEMc6ujinoh?table=tblMFNazbzmS8mQ5&view=vewdPe0N2x", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10519" + ], + "item_ids": [ + "3832824717652394232" + ], + "enabled": true + } + }, + { + "source_record_id": "recvsIPfXluZOa", + "config": { + "platform": "抖音", + "style_name": "瑞白mix", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/CJgAbncgfaBboOsYTR1czd4an1c?table=tblKhgkYfjS3qHwC&view=vewGbFVRhD", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10522" + ], + "item_ids": [ + "3837044873177989189" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlElveTOYMP", + "config": { + "platform": "京东自营", + "style_name": "盖亚斜挎", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/UkbabpqRYanmD7sk7ksceVuhnwf?from=from_copylink", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "100044949353", + "100087299891" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlElNpdSjIx", + "config": { + "platform": "京东自营", + "style_name": "极星pro", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/OsYvbUe1iaJYeKs5WvLcU4rVnHc?from=from_copylink", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "100117005088", + "100100271235" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlElNpdR7PH", + "config": { + "platform": "京东自营", + "style_name": "盖亚微单", + "note": "包含微单、微单pro", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/Qs4mbzyAaaQ8SasnDpkc9lA5npe?from=from_copylink", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "100108144308", + "100108144324" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlElNpdGiik", + "config": { + "platform": "京东自营", + "style_name": "盖世m1", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/KnBRbH9iQaH3PAsAUk5cHLxNnMg?from=from_copylink", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "100191634938" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlElNpd1Zyi", + "config": { + "platform": "京东自营", + "style_name": "星迹2", + "note": "包含星迹、星迹2", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/Ssp8bXmrwahRBbsko2BcuiLanad?from=from_copylink", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "100108144306", + "100150785348", + "100108144260", + "100100271371", + "100241562067", + "100241562047" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlElNpd3m8L", + "config": { + "platform": "京东自营", + "style_name": "星云2", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/BJaWbLEOKaHONMshOidcxvsKngh?from=from_copylink", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "100160619963", + "100160619965", + "100248422862" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlElNpdVyVz", + "config": { + "platform": "京东自营", + "style_name": "宙斯", + "note": "宙斯Z AIR、宙斯3AIR", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/N43abi7IlaS8XosnUXec6gH2n7g?from=from_copylink", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "100221530889", + "100306128160", + "100306128166", + "100255125975", + "100239269869" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlElNpd5jFt", + "config": { + "platform": "京东自营", + "style_name": "阿波罗x1", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/CtW5bUcc9a5rqRsZkwucUWDVnHb?from=from_copylink", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "100058323820", + "100058323830", + "100058916832", + "100058916818" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlElNpdY5w2", + "config": { + "platform": "京东自营", + "style_name": "逐星", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/IQrbbxAHpa9oiWs631GcPkHznob?from=from_copylink", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "100165451136", + "100165451142" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlElNpd9Nwj", + "config": { + "platform": "京东自营", + "style_name": "星云mini", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/RKfkb5JkIa9FttsgHvNc2nJYnbf?from=from_copylink", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "100215176791", + "100215176793" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlElNpdFS8P", + "config": { + "platform": "京东自营", + "style_name": "晨星2", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/VrLtbkldsaggOms3F4Bcu3DinCg?from=from_copylink", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "100287829888", + "100287829866" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlElNpd1qRI", + "config": { + "platform": "京东自营", + "style_name": "极星双肩", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/GubZbTrMSahLg5sdonIc8kR8nZe?table=tblp7yTxlgnsvddO&view=vewGbFVRhD", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "100247532920", + "100247532928" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlElNpdjz8l", + "config": { + "platform": "京东自营", + "style_name": "凌云air", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/VwpibmlakaqHuLsGChccMb3BnNd?from=from_copylink", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "100291530148", + "100218450063" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlElNpdcDso", + "config": { + "platform": "京东自营", + "style_name": "觅光", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/CQcibpRjgaUCg7sUTMwcnrhunMh?from=from_copylink", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "100241563697" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlElNpdkt1R", + "config": { + "platform": "京东自营", + "style_name": "拓界mode", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "100251533359", + "100251533369" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlV90wImdVa", + "config": { + "platform": "京东自营", + "style_name": "波塞冬手提电脑包", + "note": "", + "sales_bitable_url": "https://bu0zgpibak.feishu.cn/base/Aiazbsa80auaVGsktHEcembEnzc?table=tblgdlUCQg5wQqkG&view=vewGbFVRhD", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [ + "10515" + ], + "item_ids": [ + "100384878692" + ], + "enabled": true + } + }, + { + "source_record_id": "recvlV90wIXUkn", + "config": { + "platform": "京东自营", + "style_name": "极星单肩", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "100306462655" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlMN3HsaG6", + "config": { + "platform": "京东自营", + "style_name": "", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [], + "enabled": true + } + }, + { + "source_record_id": "recvqlOMevIBHc", + "config": { + "platform": "京东自营", + "style_name": "", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [], + "enabled": true + } + }, + { + "source_record_id": "recvqlSCJNKR5i", + "config": { + "platform": "京东自营", + "style_name": "", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SU0UxX", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "盖世m1", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "796806671474" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SUECcR", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "星云2", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "795816538796" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SUTkE4", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "宙斯3", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "866208768871" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SU5PWI", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "阿波罗2", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "800001967781" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SUIcJa", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "阿波罗x1", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SUt9rH", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "波塞冬edge", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "833629964492" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SUMcq9", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "逐星evo", + "note": "不含逐星GT", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "804404874914" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SUJYpR", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "逐星GT", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "873151381195" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SUWVF7", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "星云mini", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "833519003326" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SUGZXB", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "晨星2", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "803854562579" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SUSSww", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "极星双肩", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "800073077101" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SUYgQT", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "凌云air", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "800053125401" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SU5cxS", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "觅光", + "note": "包含觅光、觅光mix", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "916394593769" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SUJx9T", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "凌云3", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "957206503538" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SUj9LZ", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "瑞白", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "873170073048" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SUCh0S", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "黑曜石吐司包", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "916389172709" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SUZHQf", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "黑曜石托特包", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "916392176225" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SUvR7k", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "曜影", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "941051124742" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SU6Mwy", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "黑曜石电脑包", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "946827629662" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SUz09W", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "拓界2", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "967694976113" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SUzwIa", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "盖亚双肩3", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "967417798621" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SUl4VV", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "极星2", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "977435835374" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SUEOnE", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "波塞冬手提电脑包", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "975553735945" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlT9SUj3yE", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "极星单肩", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "981117810245" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmHoXADBbQ", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "拾影双肩", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "981478279171" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmI3s1E2Jv", + "config": { + "platform": "拼多多光影行星GYXX箱包专卖店", + "style_name": "", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gaZEPx", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "盖世m1", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "674047266950", + "645712066897" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gaimpo", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "星云2", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "693613142137" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gaKNhE", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "宙斯", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "697826658578", + "453321954557", + "866205862784" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gaw2c6", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "阿波罗2", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "720829344391" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gapphl", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "波塞冬edge", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "833624893603" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gaitpA", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "逐星", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "627358816584" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gaAEhq", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "逐星GT", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "873127423019" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gam0yg", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "星云mini", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "833515676462" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gatOaU", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "晨星2", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "801532281093" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gaB756", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "极星双肩", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "808750165771", + "778163096333" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gaTpON", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "凌云air", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "778102474091" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gasrDF", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "觅光", + "note": "包含觅光、觅光mix", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "895798214789", + "916381815082" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gaWV2C", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "凌云3", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "956806395076" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gavLLE", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "瑞白", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "873154838682" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gaQC7G", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "黑曜石吐司包", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "916304846983" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gaun8j", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "黑曜石托特包", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "916378838998" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gaRHZd", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "曜影", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "941026390403" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gaCWdU", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "黑曜石电脑包", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "946816426219" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gaOaaZ", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "拓界2", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "967661019929" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gatTUz", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "拾影双肩", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "975118673004" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0garLxU", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "波塞冬手提电脑包", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "975556792236" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gaaegL", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "极星双肩2", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gaQpVg", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "极星单肩", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "981115501941" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gaxkgr", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "极星托特", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [], + "enabled": true + } + }, + { + "source_record_id": "recvqlZiokPkGl", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "逐星evo", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "801540403739" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqlY0gaRzaC", + "config": { + "platform": "拼多多光影行星箱包官方旗舰店", + "style_name": "极星2", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "977434994768" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmwG7EyRIl", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "极星pro", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZIp4J", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "盖亚微单", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "586211539884" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZxhlL", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "盖世m1", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "645725773147" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZ6ezy", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "星迹2", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZGgp4", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "星云2", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "693591537247" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZnKLR", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "宙斯3", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "865856231622" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZFbCu", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "阿波罗2", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "720427969622", + "777595249177" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZxRzA", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "阿波罗x1", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZAoNW", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "波塞冬edge", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "833610802202" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZBj1L", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "逐星", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "627361716572" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZBd6u", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "逐星GT", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "873146514760" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZ5tWV", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "星云mini", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "833506670674" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZH9P6", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "晨星2", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "800539543563" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZTQ1W", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "极星双肩", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "808725764238", + "778053029763" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZqadW", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "凌云air", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "777625381821" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZpkrK", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "觅光", + "note": "包含觅光、觅光mix", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "916292673961", + "895285657180" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZrGsr", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "凌云3", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "956798437196" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZ2Y59", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "瑞白", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "873167698431" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZ3Y0Q", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "黑曜石吐司包", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZYuYQ", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "黑曜石托特包", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "916289343735" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZh8BC", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "曜影", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "941020937703" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZPgi7", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "黑曜石电脑包", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZx60x", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "拓界2", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "967659019225" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZugEw", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "盖亚双肩3", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "967415950891" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZDmCO", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "极星2", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "977435195102" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZn4C9", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "拾影斜挎相机包", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZQgqF", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "拾影双肩", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "981477739266" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZmBwi", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "云栖", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZxZbg", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "波塞冬手提电脑包", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "975640058450" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZC0t7", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "极星双肩2", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "971705445463" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZxJvo", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "极星单肩", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "981117858092" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmy3EZlK6H", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "极星托特", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "971651899906" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmzTyVGhiM", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "宙斯Z Air", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "309415939460", + "697751167824" + ], + "enabled": true + } + }, + { + "source_record_id": "recvqmBpOrCXJn", + "config": { + "platform": "拼多多光影行星官方旗舰店", + "style_name": "逐星evo", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "光影行星", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [ + "801504243477" + ], + "enabled": true + } + }, + { + "source_record_id": "recvsIaIGbe0Me", + "config": { + "platform": "", + "style_name": "", + "note": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "brand": "", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "erp_codes": [], + "item_ids": [], + "enabled": true + } + } + ] +} diff --git a/config/workflows.json b/config/workflows.json index 9bfdad2..21dac7a 100644 --- a/config/workflows.json +++ b/config/workflows.json @@ -1,31 +1,2268 @@ { "schema_version": 3, "workflows": [ - {"id":"content.metrics.daily","module":"content_marketing","trigger":"scheduled","execution":{"steps":[{"id":"collect_collaborators","name":"采集合作达人指标","description":"按每日范围并行抓取合作达人在 B站、小红书蒲公英和抖音星图的曝光等指标,生成各平台采集结果。","data_flow":{"sources":[{"system":"飞书","label":"合作达人及内容清单"},{"system":"内容平台","label":"B站、小红书蒲公英与抖音星图指标"}],"processing":["按业务日期拆分平台采集任务","并行抓取合作内容曝光与互动指标"],"destinations":[{"system":"本地采集产物","label":"合作达人多平台指标结果"}]},"entry":"run_all.py","args":["--daily-scope"]},{"id":"refresh_self_mapping","name":"刷新自营款式映射","description":"从飞书索引刷新自营账号、款式与多维表映射,供后续自营采集准确定位目标记录。","data_flow":{"sources":[{"system":"飞书","label":"内容索引、账号与款式配置"}],"processing":["刷新自营账号、款式和多维表对应关系"],"destinations":[{"system":"本地映射","label":"自营账号、款式与目标表索引"}]},"entry":"data/tools/refresh_self_mapping.py","depends_on":["collect_collaborators"],"run_after_failure":true},{"id":"collect_self_bilibili","name":"采集自营 B站指标","description":"读取自营映射中的 B站笔记链接,通过公开 API 获取播放量和发布时间并回填对应飞书表。","data_flow":{"sources":[{"system":"本地映射","label":"自营 B站笔记链接"},{"system":"B站公开 API","label":"播放量与发布时间"}],"processing":["按映射定位自营笔记并读取公开指标"],"destinations":[{"system":"飞书","label":"自营 B站内容指标表"},{"system":"本地采集产物","label":"B站自营指标结果"}]},"entry":"self_bilibili_scraper.py","depends_on":["refresh_self_mapping"],"run_after_failure":true},{"id":"collect_self_douyin","name":"采集自营抖音指标","description":"使用蝉妈妈登录态导出自营抖音视频数据,解析曝光指标并回填飞书自营表。","data_flow":{"sources":[{"system":"本地映射","label":"自营抖音账号与款式"},{"system":"蝉妈妈","label":"已登录的抖音视频导出数据"}],"processing":["导出自营视频数据并解析曝光指标"],"destinations":[{"system":"飞书","label":"自营抖音内容指标表"},{"system":"本地采集产物","label":"抖音自营指标结果"}]},"entry":"chanmama_scraper.py","depends_on":["collect_self_bilibili"],"run_after_failure":true},{"id":"sync","name":"同步曝光指标入库","description":"汇总本轮合作与自营曝光结果,按记录映射更新或补建 PostgreSQL 内容笔记数据。","data_flow":{"sources":[{"system":"本轮采集产物","label":"合作达人及自营内容指标"},{"system":"本地映射","label":"内容记录对应关系"}],"processing":["合并各平台指标并匹配内容记录","更新已有记录或补建缺失记录"],"destinations":[{"system":"PostgreSQL","label":"内容笔记与曝光指标"}]},"entry":"data/tools/sync_metrics_to_cmt_notes.py","depends_on":["collect_self_douyin"],"run_after_failure":true}]},"provenance":{"source_project":"content","task_name":"YingxiaoYunying_DailyRun"},"note":"每日先采集合作达人,再通过专有自营映射、B站和蝉妈妈脚本采集自营笔记;各阶段保留批处理继续执行语义,最终任一步失败则工作流失败。"}, - {"id":"content.marketing_report.daily","module":"content_marketing","trigger":"scheduled","execution":{"steps":[{"id":"generate_and_send","name":"生成并发送营销日报","description":"聚合各款式内容与评论分析,生成一页全域种草日报并发送给飞书负责人。","data_flow":{"sources":[{"system":"PostgreSQL","label":"内容、经营、评论与达人数据","detail":"款式指标、笔记、评论、合作和画像等"},{"system":"飞书","label":"款式、负责人及素材配置"}],"processing":["聚合业务事实与款式表现","调用 Hermes 生成营销分析","生成图表和 Markdown 日报"],"destinations":[{"system":"本地报告","label":"Markdown 日报与 PNG 数据看板"},{"system":"飞书","label":"负责人日报卡片","condition":"正式执行且启用 --send"}]},"entry":"daily_marketing_report.py","args":["--send"]}]},"provenance":{"source_project":"content","task_name":"YingxiaoYunying_DailyMarketingReport"}}, - {"id":"content.relogin.weekly","module":"content_marketing","trigger":"scheduled","execution":{"steps":[{"id":"relogin","name":"刷新内容平台登录态","description":"并行启动五个平台的二维码重新登录流程,按配置轮次重试,持久化登录状态并通知结果。","data_flow":{"sources":[{"system":"内容平台","label":"五个平台登录页与当前会话状态"}],"processing":["并行发起二维码登录并按轮次重试","校验并持久化新的登录会话"],"destinations":[{"system":"浏览器状态","label":"各平台 Cookie 与 storage state"},{"system":"飞书","label":"登录刷新结果通知"}]},"entry":"data/tools/friday_relogin_parallel.py","args":["--max-attempts","3","--round-timeout","300","--screenshot-delay","60"]}]},"provenance":{"source_project":"content","task_name":"YingxiaoYunying_FridayRelogin"}}, - {"id":"content.creator_report.monthly","module":"content_marketing","trigger":"scheduled","execution":{"steps":[{"id":"generate","name":"生成达人合作月报","description":"从 PostgreSQL 读取达人、款式和合作数据,生成完整的报价与合作分析报告。","data_flow":{"sources":[{"system":"PostgreSQL","label":"达人、款式、合作与内容表现数据"}],"processing":["统计达人报价、合作和内容效果","生成月度筛选与报价分析"],"destinations":[{"system":"本地报告","label":"达人合作月报 Markdown"},{"system":"飞书文档","label":"达人合作分析报告"},{"system":"PostgreSQL","label":"月报记录与报告链接"}]},"entry":"data/tools/generate_creator_report.py"}]},"provenance":{"source_project":"content","task_name":"YingxiaoYunying_MonthlyCreatorReport"}}, - {"id":"content.summary.monthly","module":"content_marketing","trigger":"scheduled","execution":{"steps":[{"id":"summarize","name":"生成内容月度汇总","description":"分析上月各款全部笔记并做跨平台横向对比,生成飞书文档、回写生命进程表并入库。","data_flow":{"sources":[{"system":"PostgreSQL","label":"上月各款内容、互动与评论数据"},{"system":"飞书","label":"款式与生命进程配置"}],"processing":["按款式分析全部笔记","完成跨平台对比与月度总结"],"destinations":[{"system":"飞书文档","label":"内容月度报告"},{"system":"飞书","label":"款式生命进程表"},{"system":"PostgreSQL","label":"月度汇总结果"}]},"entry":"monthly_summary_all.py","args":["--max-workers","4"]}]},"provenance":{"source_project":"content","task_name":"YingxiaoYunying_MonthlySummary"}}, - {"id":"content.cooperations.daily","module":"content_marketing","trigger":"scheduled","execution":{"steps":[{"id":"sync","name":"同步达人合作记录","description":"遍历飞书款式合作表,幂等写入或更新 PostgreSQL 中的达人属性和合作记录。","data_flow":{"sources":[{"system":"飞书","label":"各款式达人合作表"}],"processing":["刷新款式映射并规范化达人合作字段","按业务主键幂等新增或更新"],"destinations":[{"system":"PostgreSQL","label":"达人档案与合作记录"}]},"entry":"data/tools/sync_cooperations.py","args":["--refresh-mapping"]}]},"provenance":{"source_project":"content","task_name":"YingxiaoYunying_SyncCooperations"}}, - {"id":"content.comments.weekly","module":"content_marketing","trigger":"scheduled","execution":{"steps":[{"id":"bilibili","name":"重采 B站评论","description":"从数据库读取 B站视频,重新采集互动指标和评论,替换评论表数据并保存本地产物。","data_flow":{"sources":[{"system":"PostgreSQL","label":"B站视频清单"},{"system":"B站","label":"视频互动指标与评论"}],"processing":["逐条重新采集视频互动和评论"],"destinations":[{"system":"PostgreSQL","label":"B站评论与互动指标"},{"system":"本地采集产物","label":"B站评论结果"}]},"entry":"data/tools/batch_rescrape_bilibili.py"},{"id":"xiaohongshu","name":"重采小红书评论","description":"从数据库读取小红书笔记,重新采集互动指标和评论,替换评论表数据并保存本地产物。","data_flow":{"sources":[{"system":"PostgreSQL","label":"小红书笔记清单"},{"system":"小红书","label":"笔记互动指标与评论"}],"processing":["逐条重新采集笔记互动和评论"],"destinations":[{"system":"PostgreSQL","label":"小红书评论与互动指标"},{"system":"本地采集产物","label":"小红书评论结果"}]},"entry":"data/tools/batch_rescrape_xiaohongshu.py"},{"id":"douyin","name":"重采抖音评论","description":"从数据库读取抖音视频,重新采集互动指标和评论,替换评论表数据并保存本地产物。","data_flow":{"sources":[{"system":"PostgreSQL","label":"抖音视频清单"},{"system":"抖音","label":"视频互动指标与评论"}],"processing":["逐条重新采集视频互动和评论"],"destinations":[{"system":"PostgreSQL","label":"抖音评论与互动指标"},{"system":"本地采集产物","label":"抖音评论结果"}]},"entry":"data/tools/batch_rescrape_douyin.py"}]},"provenance":{"source_project":"content","task_name":"YingxiaoYunying_WeeklyCommentScrape"},"note":"保留来源批处理语义:三平台并行启动、全部等待,任一平台失败则工作流最终失败。"}, - {"id":"content.summary.weekly","module":"content_marketing","trigger":"scheduled","execution":{"steps":[{"id":"summarize","name":"生成内容周度汇总","description":"分析上周各款新增笔记,调用模型生成综合评级,创建飞书周报并回写生命进程表。","data_flow":{"sources":[{"system":"PostgreSQL","label":"上周各款新增笔记与互动数据"},{"system":"飞书","label":"款式与生命进程配置"}],"processing":["按款式汇总新增内容","调用模型生成评级与周度分析"],"destinations":[{"system":"飞书文档","label":"内容周报"},{"system":"飞书","label":"款式生命进程表"},{"system":"PostgreSQL","label":"周度汇总结果"}]},"entry":"weekly_summary_all.py","args":["--max-workers","4"]}]},"provenance":{"source_project":"content","task_name":"YingxiaoYunying_WeeklySummary"}}, - - {"id":"shop.metrics.weekly","module":"shop_intelligence","trigger":"scheduled","execution":{"steps":[{"id":"jd","name":"采集京东店铺经营指标","description":"独立采集京东店铺经营数据,生成本次产物并写入 PostgreSQL;生产模式写入飞书 Base。","data_flow":{"sources":[{"system":"京东商智","label":"店铺经营指标"}],"processing":["按业务日期采集并规范化京东店铺数据"],"destinations":[{"system":"本地采集产物","label":"京东店铺指标"},{"system":"PostgreSQL","label":"店铺经营指标"},{"system":"飞书 Base","label":"京东店铺周数据","condition":"正式执行"}]},"entry":"runners/run_shop.py","args":["--platform","jd"]},{"id":"dy","name":"采集抖音店铺经营指标","description":"独立采集抖音店铺经营数据,生成本次产物并写入 PostgreSQL;生产模式写入飞书 Base。","data_flow":{"sources":[{"system":"抖音电商罗盘","label":"店铺经营指标"}],"processing":["按业务日期采集并规范化抖音店铺数据"],"destinations":[{"system":"本地采集产物","label":"抖音店铺指标"},{"system":"PostgreSQL","label":"店铺经营指标"},{"system":"飞书 Base","label":"抖音店铺周数据","condition":"正式执行"}]},"entry":"runners/run_shop.py","args":["--platform","dy"]},{"id":"tm","name":"采集天猫店铺经营指标","description":"独立采集天猫店铺经营数据,生成本次产物并写入 PostgreSQL;按原逻辑不写飞书店铺表。","data_flow":{"sources":[{"system":"天猫生意参谋","label":"店铺经营指标"}],"processing":["按业务日期采集并规范化天猫店铺数据"],"destinations":[{"system":"本地采集产物","label":"天猫店铺指标"},{"system":"PostgreSQL","label":"店铺经营指标"}]},"entry":"runners/run_shop.py","args":["--platform","tm"]}]},"provenance":{"source_project":"shop","task_name":"shop-data-collection"},"note":"三个平台作为独立 LangGraph 节点并行执行,分别入库和收敛错误,单个平台失败不阻断其他平台。"}, - {"id":"shop.competitor.weekly","module":"shop_intelligence","trigger":"scheduled","execution":{"steps":[{"id":"jd","name":"采集京东竞店经营指标","description":"独立采集京东竞店排行,生成本次产物并写入 PostgreSQL;生产模式写入飞书 Base。","data_flow":{"sources":[{"system":"京东商智","label":"竞店排行与经营指标"}],"processing":["采集目标竞店并规范化排行数据"],"destinations":[{"system":"本地采集产物","label":"京东竞店指标"},{"system":"PostgreSQL","label":"竞店经营指标"},{"system":"飞书 Base","label":"京东竞店周数据","condition":"正式执行"}]},"entry":"runners/run_peer_store.py","args":["--platform","jd"]},{"id":"dy","name":"采集抖音竞店经营指标","description":"独立采集抖音竞店排行,生成本次产物并写入 PostgreSQL;生产模式写入飞书 Base。","data_flow":{"sources":[{"system":"抖音电商罗盘","label":"竞店排行与经营指标"}],"processing":["采集目标竞店并规范化排行数据"],"destinations":[{"system":"本地采集产物","label":"抖音竞店指标"},{"system":"PostgreSQL","label":"竞店经营指标"},{"system":"飞书 Base","label":"抖音竞店周数据","condition":"正式执行"}]},"entry":"runners/run_peer_store.py","args":["--platform","dy"]}]},"provenance":{"source_project":"shop","task_name":"peer-store-data-collection"},"note":"京东和抖音作为独立 LangGraph 节点并行执行,分别入库和收敛错误,单个平台失败不阻断另一个平台;当前无天猫竞店采集任务。"}, - {"id":"shop.jd_self_operated.daily","module":"shop_intelligence","trigger":"scheduled","execution":{"steps":[{"id":"brand","name":"采集京东自营品牌业绩","description":"按业务日期采集京东自营品牌维度业绩,保存原始产物并写入 PostgreSQL。","data_flow":{"sources":[{"system":"京东自营后台","label":"品牌维度经营数据"}],"processing":["按业务日期采集并整理品牌业绩"],"destinations":[{"system":"本地原始产物","label":"品牌业绩数据"},{"system":"PostgreSQL","label":"京东自营品牌日指标"}]},"entry":"collectors/jd_self_operated_brand_daily.py","args":["--start-date","{business_date}","--end-date","{business_date}","--headless"],"timeout_seconds":600},{"id":"product","name":"采集京东自营商品业绩","description":"按业务日期逐页采集京东自营商品维度业绩,保存原始产物并写入 PostgreSQL。","data_flow":{"sources":[{"system":"京东自营后台","label":"商品维度经营数据"}],"processing":["逐页采集商品明细并整理日业绩"],"destinations":[{"system":"本地原始产物","label":"商品业绩数据"},{"system":"PostgreSQL","label":"京东自营商品日指标"}]},"entry":"collectors/jd_self_operated_product_daily.py","args":["--start-date","{business_date}","--end-date","{business_date}","--headless"],"depends_on":["brand"],"run_after_failure":true,"timeout_seconds":1800}]},"provenance":{"source_project":"shop","task_name":"京东自营昨日业绩每日采集"},"note":"每天采集计划日期前一天;品牌失败仍继续商品,任一步失败则工作流失败。"}, - {"id":"shop.douyin_price_appeal","module":"shop_intelligence","trigger":"scheduled","execution":{"steps":[{"id":"dy","name":"提交抖音待改价申诉","description":"复用店铺周采集的抖音商家账号 Cookie 与 Profile,打开活动广场;待改价不为 0 时逐条进入立即改价,兼容“申诉”和“发起申诉”入口,全选 SKU,并以固定原因提交申诉。","data_flow":{"sources":[{"system":"抖音电商","label":"活动广场待改价商品"},{"system":"浏览器状态","label":"店铺周采集账号 Cookie 与 Profile"}],"processing":["检查待改价数量并打开待处理列表","逐条进入立即改价并兼容两种申诉入口","全选 SKU,选择“对议价价格存疑 / 认为识别有误,该规格没有这么低的价格”并提交"],"destinations":[{"system":"抖音电商","label":"待改价议价申诉","condition":"正式执行(控制台或定时调度)"}]},"entry":"collectors/dy_store_competitor_store_scraping.py","args":["--price-appeal","--execute"],"timeout_seconds":1800,"replay_policy":"idempotent"}]},"provenance":{"source_project":"shop","task_name":"douyin-price-appeal-daily"},"note":"每天 08:00、16:00、22:00 自动执行;仍可在控制台手动执行。每个时点都会重新扫描,已申诉商品不会再次出现可申诉入口;安全预演不访问抖音也不提交申诉。"}, - {"id":"product.persona.daily","module":"product_commerce","trigger":"scheduled","execution":{"steps":[{"id":"collect","name":"采集三平台人群画像","description":"并行运行天猫、抖音和京东人群画像采集器,等待全部完成并写入对应飞书多维表。","data_flow":{"sources":[{"system":"天猫、抖音与京东","label":"商品人群画像后台数据"},{"system":"本地配置","label":"款式与目标表映射"}],"processing":["并行采集三平台画像","统一画像维度并匹配款式"],"destinations":[{"system":"飞书多维表","label":"各平台商品人群画像"}]},"entry":"run_daily_persona.py","args":["--date","{business_date}"]}]},"provenance":{"source_project":"product","task_name":"PersonaDailyCollect"}}, - {"id":"product.daily","module":"product_commerce","trigger":"scheduled","execution":{"steps":[{"id":"collect_analyze_publish","name":"采集、汇总并发布商品日报","description":"按业务日期采集 ERP 与三平台数据,完成汇总分析、导出并写入飞书日报记录。","data_flow":{"sources":[{"system":"ERP","label":"商品库存与销售数据"},{"system":"天猫、京东与抖音","label":"商品经营指标"}],"processing":["按业务日期采集各数据源","汇总并分析商品经营表现","导出日报记录"],"destinations":[{"system":"本地日报产物","label":"商品经营汇总与导出文件"},{"system":"飞书","label":"商品日报记录"}]},"entry":"orchestrate_daily_collection.py","args":["--target-date","{business_date}","--stages","collect,analyze,export,insert"]}]},"provenance":{"source_project":"product","task_name":"ProductCollectorDailyCollect"}}, - {"id":"product.alert.daily","module":"product_commerce","trigger":"scheduled","execution":{"steps":[{"id":"detect_and_notify","name":"检测并通知销量下滑","description":"检测连续销量下滑,幂等记录告警事件,并通过 Hermes 与飞书通知配置对象。","data_flow":{"sources":[{"system":"PostgreSQL","label":"商品日销量与历史趋势"},{"system":"告警配置","label":"检测规则与通知对象"}],"processing":["检测连续销量下滑","调用 Hermes 生成告警说明","幂等判定是否需要通知"],"destinations":[{"system":"PostgreSQL","label":"销量告警事件"},{"system":"飞书","label":"配置对象告警通知","condition":"命中规则且未重复发送"}]},"entry":"commands/run_alerts.py"}]},"provenance":{"source_project":"product","task_name":"ProductCollectorSalesAlert"}}, - {"id":"product.import.daily","module":"product_commerce","trigger":"scheduled","execution":{"steps":[{"id":"import","name":"导入商品日报原始数据","description":"扫描业务日期前一天的三平台原始报表,统一字段后幂等写入 PostgreSQL 商品日指标表。","replay_policy":"idempotent","data_flow":{"sources":[{"system":"本地原始报表","label":"天猫、京东与抖音商品日报"}],"processing":["按业务日期定位报表","统一三平台字段并校验记录","按业务主键幂等导入"],"destinations":[{"system":"PostgreSQL","label":"商品日经营指标"}]},"entry":"commands/import_daily.py"}]},"provenance":{"source_project":"product","task_name":"ProductDailyImport"}}, - {"id":"product.style_analysis.interval","module":"product_commerce","trigger":"scheduled","execution":{"steps":[{"id":"analyze","name":"生成款式周期分析","description":"聚合每个款式最近 3 天经营指标,调用 Hermes 分析,创建飞书文档并保存报告链接。","data_flow":{"sources":[{"system":"PostgreSQL","label":"最近 3 天款式经营指标"},{"system":"款式配置","label":"待分析款式与最小分析间隔"}],"processing":["聚合款式周期指标","调用 Hermes 生成经营分析","跳过分析间隔未满足的款式"],"destinations":[{"system":"飞书文档","label":"款式周期分析报告"},{"system":"PostgreSQL","label":"报告链接与分析状态"}]},"entry":"analyze_style_with_hermes.py","args":["--all-styles","--days","3","--min-interval-days","3","--skip-existing","--end-date","{business_date}"]}]},"provenance":{"source_project":"product","task_name":"StyleAnalysisEvery3Days"}}, - {"id":"product.main_image.weekly","module":"product_commerce","trigger":"scheduled","execution":{"steps":[{"id":"jd","name":"采集京东主图表现","description":"采集京东款式主图及曝光、点击指标,保存 JSON,并写入每款飞书主图表和 PostgreSQL。","data_flow":{"sources":[{"system":"京东商智","label":"款式主图、曝光与点击指标"},{"system":"本地配置","label":"款式与飞书目标表映射"}],"processing":["采集并按款式整理京东主图表现"],"destinations":[{"system":"本地 JSON","label":"京东主图采集结果"},{"system":"飞书","label":"各款式京东主图表"},{"system":"PostgreSQL","label":"京东主图表现指标"}]},"entry":"run_weekly_jd_main_image.py","args":["--date","{business_date}","--headless"]},{"id":"tmall","name":"采集天猫主图表现","description":"采集天猫万相台款式主图聚合指标,保存 JSON,并写入每款飞书主图表和 PostgreSQL。","data_flow":{"sources":[{"system":"天猫万相台","label":"款式主图聚合表现"},{"system":"本地配置","label":"款式与飞书目标表映射"}],"processing":["采集并按款式整理天猫主图表现"],"destinations":[{"system":"本地 JSON","label":"天猫主图采集结果"},{"system":"飞书","label":"各款式天猫主图表"},{"system":"PostgreSQL","label":"天猫主图表现指标"}]},"entry":"run_weekly_main_image.py","args":["--date","{business_date}","--headless"]}]},"provenance":{"source_project":"product","task_name":"WeeklyJdAndTmallMainImageCollect"},"note":"每周日 08:30 并行执行京东与天猫万相主图采集;两个平台分别完成 PG 入库和飞书插入,资源、状态及失败互不影响。任一平台失败时另一个仍继续,工作流最终报告具体的平台错误。"}, - {"id":"product.sales_sheet.daily","module":"product_commerce","trigger":"scheduled","execution":{"steps":[{"id":"sync","name":"刷新商品月度销量表","description":"全量读取预算产品与款式主表,不按预算月份过滤款式;所选月份只控制聚水潭查询区间和飞书目标月份页。","replay_policy":"idempotent","data_flow":{"sources":[{"system":"飞书多维表格","label":"全部预算产品、目标销量、款式名与 ERP 款式编码"},{"system":"聚水潭商品主体分析","label":"所选自然月全渠道自定义销量与自定义退货量"}],"processing":["合并预算 Base 与款式主表的全量款式并匹配 ERP 编码","按所选月月初到月末逐 ERP 编码生成一次全渠道报表","累加全部商品行和同款全部 ERP 编码并计算退货率、实销和目标达成率"],"destinations":[{"system":"飞书电子表格","label":"当月产品销量页","condition":"正式执行"}]},"entry":"sync_monthly_sales_sheet.py","args":["--month","{business_date}","--execute","--allow-missing-erp-as-zero"]}]},"provenance":{"source_project":"product","task_name":"ProductMonthlySalesSheetDailySync"},"note":"同名月份页幂等覆盖;月份只控制聚水潭自然月区间与目标页,产品全集不按预算月份筛选;抓取和校验全部 ERP 报表成功后才清空写入;无 ERP 编码产品按 0 写入;缺页时复制最近历史月份页继承格式。"}, - - {"id":"supply.purchase_confirmation.daily","module":"supply_chain","trigger":"scheduled","execution":{"steps":[{"id":"collect_analyze_notify","name":"生成采购确认通知","description":"从 ERP 识别库存缺口商品并检查协议到货时间,写入 PostgreSQL 后发送采购确认通知。","replay_policy":"idempotent","data_flow":{"sources":[{"system":"ERP","label":"商品库存、在途与采购数据"},{"system":"采购配置","label":"协议到货时间与确认规则"}],"processing":["识别库存缺口商品","核对协议到货时间并生成确认项"],"destinations":[{"system":"PostgreSQL","label":"采购确认处理记录"},{"system":"飞书","label":"采购确认通知"}]},"entry":"run.py","args":["mcp-run","purchase-confirmation"]}]},"provenance":{"source_project":"supply","task_name":"auto-flow-purchase-confirmation"}}, - {"id":"supply.replenishment.weekly","module":"supply_chain","trigger":"scheduled","execution":{"steps":[{"id":"collect_analyze_notify","name":"生成并发送补货建议","description":"导出 ERP 库存并计算补货量,写入 PostgreSQL 与飞书多维表,最后发送补货结果通知。","replay_policy":"idempotent","data_flow":{"sources":[{"system":"ERP","label":"库存、销售与商品数据"},{"system":"补货配置","label":"SKU 范围与补货规则"}],"processing":["导出并规范化库存数据","按规则计算建议补货量","生成补货批次结果"],"destinations":[{"system":"PostgreSQL","label":"补货批次与建议明细"},{"system":"飞书多维表","label":"补货建议记录"},{"system":"飞书","label":"补货结果通知"}]},"entry":"run.py","args":["mcp-run","replenishment"]}]},"provenance":{"source_project":"supply","task_name":"auto-flow-replenishment"}}, - {"id":"supply.replenishment_alert.daily","module":"supply_chain","trigger":"scheduled","execution":{"steps":[{"id":"collect_analyze_notify","name":"生成库存阈值预警","description":"依据配置 SKU 和库存阈值生成预警并写入 PostgreSQL,仅在存在预警时发送业务通知。","replay_policy":"idempotent","data_flow":{"sources":[{"system":"ERP","label":"SKU 当前库存"},{"system":"预警配置","label":"监控 SKU 与库存阈值"}],"processing":["比较当前库存与配置阈值","生成需要处理的库存预警"],"destinations":[{"system":"PostgreSQL","label":"库存阈值预警记录"},{"system":"飞书","label":"库存预警通知","condition":"存在命中阈值的 SKU"}]},"entry":"run.py","args":["mcp-run","replenishment-alert"]}]},"provenance":{"source_project":"supply","task_name":"auto-flow-replenishment-alert"}}, - - {"id":"product.market_rank","module":"product_commerce","trigger":"scheduled","execution":{"steps":[{"id":"collect_analyze_publish","name":"采集并发布三平台市场排行","description":"并行采集天猫、京东和抖音市场排行,汇总平台结果并发送飞书报告链接。","data_flow":{"sources":[{"system":"天猫、京东与抖音","label":"市场商品排行与表现数据"},{"system":"本地配置","label":"类目、关键词与采集范围"}],"processing":["并行采集三平台排行","汇总平台结果并生成市场分析","归档报告并准备通知"],"destinations":[{"system":"本地报告产物","label":"三平台市场排行结果"},{"system":"飞书","label":"市场排行报告链接与通知"}]},"entry":"orchestrate_market_rank_collection.py","args":["--report-date","{business_date}"]}]},"provenance":{"source_project":"product","task_name":"三平台市场排行周采集"},"note":"每周一 10:00 由项目内 Python 调度器触发。"} + { + "id": "content.metrics.daily", + "module": "content_marketing", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "collect_collaborators", + "name": "采集合作达人指标", + "description": "按每日范围并行抓取合作达人在 B站、小红书蒲公英和星图的曝光等指标,星图未命中的任务再用抖店达人广场补采;05:00 仅补 01:00 未完成的任务。", + "data_flow": { + "sources": [ + { + "system": "飞书", + "label": "合作达人及内容清单" + }, + { + "system": "内容平台", + "label": "B站、小红书蒲公英与星图/抖店补采指标" + } + ], + "processing": [ + "按业务日期拆分平台采集任务", + "并行抓取合作内容曝光与互动指标" + ], + "destinations": [ + { + "system": "本地采集产物", + "label": "合作达人多平台指标结果" + } + ] + }, + "entry": "run_all.py", + "args": [ + "--daily-scope" + ], + "replay_policy": "repeatable" + }, + { + "id": "refresh_self_mapping", + "name": "刷新自营款式映射", + "description": "从飞书索引刷新自营账号、款式与多维表映射,供后续自营采集准确定位目标记录;账号暂时失效时仅暂停自营采集,不移除工作流步骤。", + "data_flow": { + "sources": [ + { + "system": "飞书", + "label": "自营内容索引、账号与款式配置" + } + ], + "processing": [ + "刷新自营账号、款式和多维表对应关系" + ], + "destinations": [ + { + "system": "本地映射", + "label": "自营账号、款式与目标表索引" + } + ] + }, + "entry": "data/tools/refresh_self_mapping.py", + "depends_on": [ + "collect_collaborators" + ], + "run_after_failure": true, + "replay_policy": "repeatable" + }, + { + "id": "collect_self_bilibili", + "name": "采集自营 B站指标", + "description": "读取自营映射中的 B站笔记链接,通过公开 API 获取播放量和发布时间并回填对应飞书表。", + "data_flow": { + "sources": [ + { + "system": "本地映射", + "label": "自营 B站笔记链接" + }, + { + "system": "B站公开 API", + "label": "播放量与发布时间" + } + ], + "processing": [ + "按映射定位自营笔记并读取公开指标" + ], + "destinations": [ + { + "system": "飞书", + "label": "自营 B站内容指标表" + }, + { + "system": "本地采集产物", + "label": "B站自营指标结果" + } + ] + }, + "entry": "self_bilibili_scraper.py", + "depends_on": [ + "refresh_self_mapping" + ], + "run_after_failure": true, + "replay_policy": "repeatable" + }, + { + "id": "collect_self_douyin", + "name": "采集自营抖音指标", + "description": "使用蝉妈妈新账号登录态导出自营抖音视频数据,解析曝光指标并回填飞书自营表。", + "data_flow": { + "sources": [ + { + "system": "本地映射", + "label": "自营抖音账号与款式" + }, + { + "system": "蝉妈妈", + "label": "已登录的抖音视频导出数据" + } + ], + "processing": [ + "导出自营视频数据并解析曝光指标" + ], + "destinations": [ + { + "system": "飞书", + "label": "自营抖音内容指标表" + }, + { + "system": "本地采集产物", + "label": "抖音自营指标结果" + } + ] + }, + "entry": "chanmama_scraper.py", + "depends_on": [ + "collect_self_bilibili" + ], + "run_after_failure": true, + "replay_policy": "repeatable" + }, + { + "id": "sync", + "name": "同步合作与自营曝光指标入库", + "description": "汇总本轮合作达人及自营曝光结果,按记录映射更新或补建 PostgreSQL 内容笔记数据。", + "data_flow": { + "sources": [ + { + "system": "本轮采集产物", + "label": "合作达人及自营内容指标" + }, + { + "system": "本地映射", + "label": "内容记录对应关系" + } + ], + "processing": [ + "合并各平台指标并匹配内容记录", + "更新已有记录或补建缺失记录" + ], + "destinations": [ + { + "system": "PostgreSQL", + "label": "内容笔记与曝光指标" + } + ] + }, + "entry": "data/tools/sync_metrics_to_cmt_notes.py", + "depends_on": [ + "collect_self_douyin" + ], + "replay_policy": "repeatable", + "run_after_failure": true + } + ] + }, + "provenance": { + "source_project": "content", + "task_name": "YingxiaoYunying_DailyRun" + }, + "note": "每日 01:00 执行合作达人及自营 B站、蝉妈妈采集,05:00 按 01:00 产生的未完成清单补采;账号切换期间仅暂停自营采集步骤,不移除自营链;两次均完成后同步曝光指标入库。" + }, + { + "id": "content.metrics.backfill", + "module": "content_marketing", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "collect_collaborators", + "name": "补采合作达人指标", + "description": "按每日范围并行补采合作达人在 B站、小红书蒲公英和星图的曝光等指标,星图未命中的任务再用抖店达人广场补采。", + "data_flow": { + "sources": [ + { + "system": "飞书", + "label": "合作达人及内容清单" + }, + { + "system": "内容平台", + "label": "B站、小红书蒲公英与星图/抖店补采指标" + } + ], + "processing": [ + "按每日范围补采合作达人多平台指标" + ], + "destinations": [ + { + "system": "本地采集产物", + "label": "合作达人多平台补采结果" + } + ] + }, + "entry": "run_all.py", + "args": [ + "--daily-scope" + ] + }, + { + "id": "sync", + "name": "同步补采曝光指标入库", + "description": "将合作达人补采产物同步到 PostgreSQL 内容笔记表;即使采集步骤失败也执行同步,保留来源批处理的收敛语义。", + "data_flow": { + "sources": [ + { + "system": "本轮采集产物", + "label": "合作达人补采指标" + }, + { + "system": "本地映射", + "label": "内容记录对应关系" + } + ], + "processing": [ + "匹配合作达人内容记录并同步曝光指标" + ], + "destinations": [ + { + "system": "PostgreSQL", + "label": "内容笔记与曝光指标" + } + ] + }, + "entry": "data/tools/sync_metrics_to_cmt_notes.py", + "depends_on": [ + "collect_collaborators" + ], + "run_after_failure": true + } + ] + }, + "provenance": { + "source_project": "content", + "task_name": "YingxiaoYunying_MondayBackfill" + }, + "note": "严格保留来源周一补采语义:只运行合作达人 run_all.py --daily-scope,随后无论采集是否失败都执行 PostgreSQL 同步;不包含自营映射和自营平台采集。" + }, + { + "id": "content.marketing_report.daily", + "module": "content_marketing", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "generate_and_send", + "name": "生成并发送营销日报", + "description": "聚合各款式内容与评论分析,生成一页全域种草日报并发送给飞书负责人。", + "data_flow": { + "sources": [ + { + "system": "PostgreSQL", + "label": "内容、经营、评论与达人数据", + "detail": "款式指标、笔记、评论、合作和画像等" + }, + { + "system": "飞书", + "label": "款式、负责人及素材配置" + } + ], + "processing": [ + "聚合业务事实与款式表现", + "调用 Hermes 生成营销分析", + "生成图表和 Markdown 日报" + ], + "destinations": [ + { + "system": "本地报告", + "label": "Markdown 日报与 PNG 数据看板" + }, + { + "system": "飞书", + "label": "负责人日报卡片", + "condition": "正式执行且启用 --send" + } + ] + }, + "entry": "daily_marketing_report.py", + "args": [ + "--send" + ] + } + ] + }, + "provenance": { + "source_project": "content", + "task_name": "YingxiaoYunying_DailyMarketingReport" + } + }, + { + "id": "content.relogin.weekly", + "module": "content_marketing", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "relogin", + "name": "刷新内容平台登录态", + "description": "并行启动五个平台的二维码重新登录流程,按配置轮次重试,持久化登录状态并通知结果。", + "data_flow": { + "sources": [ + { + "system": "内容平台", + "label": "五个平台登录页与当前会话状态" + } + ], + "processing": [ + "并行发起二维码登录并按轮次重试", + "校验并持久化新的登录会话" + ], + "destinations": [ + { + "system": "浏览器状态", + "label": "各平台 Cookie 与 storage state" + }, + { + "system": "飞书", + "label": "登录刷新结果通知" + } + ] + }, + "entry": "data/tools/friday_relogin_parallel.py", + "args": [ + "--max-attempts", + "3", + "--round-timeout", + "300", + "--screenshot-delay", + "60" + ] + } + ] + }, + "provenance": { + "source_project": "content", + "task_name": "YingxiaoYunying_FridayRelogin" + } + }, + { + "id": "content.creator_report.monthly", + "module": "content_marketing", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "generate", + "name": "生成达人合作月报", + "description": "从 PostgreSQL 读取达人、款式和合作数据,生成完整的报价与合作分析报告。", + "data_flow": { + "sources": [ + { + "system": "PostgreSQL", + "label": "达人、款式、合作与内容表现数据" + } + ], + "processing": [ + "统计达人报价、合作和内容效果", + "生成月度筛选与报价分析" + ], + "destinations": [ + { + "system": "本地报告", + "label": "达人合作月报 Markdown" + }, + { + "system": "飞书文档", + "label": "达人合作分析报告" + }, + { + "system": "PostgreSQL", + "label": "月报记录与报告链接" + } + ] + }, + "entry": "data/tools/generate_creator_report.py" + } + ] + }, + "provenance": { + "source_project": "content", + "task_name": "YingxiaoYunying_MonthlyCreatorReport" + } + }, + { + "id": "content.summary.monthly", + "module": "content_marketing", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "summarize", + "name": "生成内容月度汇总", + "description": "分析上月各款全部笔记并做跨平台横向对比,生成飞书文档、回写生命进程表并入库。", + "data_flow": { + "sources": [ + { + "system": "PostgreSQL", + "label": "上月各款内容、互动与评论数据" + }, + { + "system": "飞书", + "label": "款式与生命进程配置" + } + ], + "processing": [ + "按款式分析全部笔记", + "完成跨平台对比与月度总结" + ], + "destinations": [ + { + "system": "飞书文档", + "label": "内容月度报告" + }, + { + "system": "飞书", + "label": "款式生命进程表" + }, + { + "system": "PostgreSQL", + "label": "月度汇总结果" + } + ] + }, + "entry": "monthly_summary_all.py", + "args": [ + "--max-workers", + "4" + ] + } + ] + }, + "provenance": { + "source_project": "content", + "task_name": "YingxiaoYunying_MonthlySummary" + } + }, + { + "id": "content.notes_master.daily", + "module": "content_marketing", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "sync", + "name": "同步笔记清单与合作记录", + "description": "遍历各款式合作达人和自营多维表格,一次拉取后幂等写入 PostgreSQL 笔记主表(笔记身份+合作财务同行),同步达人档案,并标记来源已删除记录。", + "data_flow": { + "sources": [ + { + "system": "飞书", + "label": "各款式合作达人及自营笔记表" + } + ], + "processing": [ + "刷新款式与来源表映射", + "全量读取笔记身份与合作字段并规范化", + "按飞书来源记录幂等同步并标记已删除记录" + ], + "destinations": [ + { + "system": "PostgreSQL", + "label": "笔记主表 cmt_notes_master 与达人档案" + } + ] + }, + "entry": "data/tools/sync_notes_master.py", + "args": [ + "--execute" + ] + } + ] + }, + "provenance": { + "source_project": "content", + "task_name": "GYXX_DailyNotesMasterSync" + }, + "note": "合并原合作记录同步与完整笔记清单同步:一次飞书拉取同时落笔记身份与合作字段;笔记数量以主表 source_active AND is_countable 的去重链接为准,曝光指标由内容指标采集写回同一行。" + }, + { + "id": "content.comments.weekly", + "module": "content_marketing", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "bilibili", + "name": "重采 B站评论", + "description": "从数据库读取 B站视频,重新采集互动指标和评论,替换评论表数据并保存本地产物。", + "data_flow": { + "sources": [ + { + "system": "PostgreSQL", + "label": "B站视频清单" + }, + { + "system": "B站", + "label": "视频互动指标与评论" + } + ], + "processing": [ + "逐条重新采集视频互动和评论" + ], + "destinations": [ + { + "system": "PostgreSQL", + "label": "B站评论与互动指标" + }, + { + "system": "本地采集产物", + "label": "B站评论结果" + } + ] + }, + "entry": "data/tools/batch_rescrape_bilibili.py" + }, + { + "id": "xiaohongshu", + "name": "重采小红书评论", + "description": "从数据库读取小红书笔记,重新采集互动指标和评论,替换评论表数据并保存本地产物。", + "data_flow": { + "sources": [ + { + "system": "PostgreSQL", + "label": "小红书笔记清单" + }, + { + "system": "小红书", + "label": "笔记互动指标与评论" + } + ], + "processing": [ + "逐条重新采集笔记互动和评论" + ], + "destinations": [ + { + "system": "PostgreSQL", + "label": "小红书评论与互动指标" + }, + { + "system": "本地采集产物", + "label": "小红书评论结果" + } + ] + }, + "entry": "data/tools/batch_rescrape_xiaohongshu.py" + }, + { + "id": "douyin", + "name": "重采抖音评论", + "description": "从数据库读取抖音视频,重新采集互动指标和评论,替换评论表数据并保存本地产物。", + "data_flow": { + "sources": [ + { + "system": "PostgreSQL", + "label": "抖音视频清单" + }, + { + "system": "抖音", + "label": "视频互动指标与评论" + } + ], + "processing": [ + "逐条重新采集视频互动和评论" + ], + "destinations": [ + { + "system": "PostgreSQL", + "label": "抖音评论与互动指标" + }, + { + "system": "本地采集产物", + "label": "抖音评论结果" + } + ] + }, + "entry": "data/tools/batch_rescrape_douyin.py" + } + ] + }, + "provenance": { + "source_project": "content", + "task_name": "YingxiaoYunying_WeeklyCommentScrape" + }, + "note": "保留来源批处理语义:三平台并行启动、全部等待,任一平台失败则工作流最终失败。" + }, + { + "id": "content.summary.weekly", + "module": "content_marketing", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "summarize", + "name": "生成内容周度汇总", + "description": "分析上周各款新增笔记,调用模型生成综合评级,创建飞书周报并回写生命进程表。", + "data_flow": { + "sources": [ + { + "system": "PostgreSQL", + "label": "上周各款新增笔记与互动数据" + }, + { + "system": "飞书", + "label": "款式与生命进程配置" + } + ], + "processing": [ + "按款式汇总新增内容", + "调用模型生成评级与周度分析" + ], + "destinations": [ + { + "system": "飞书文档", + "label": "内容周报" + }, + { + "system": "飞书", + "label": "款式生命进程表" + }, + { + "system": "PostgreSQL", + "label": "周度汇总结果" + } + ] + }, + "entry": "weekly_summary_all.py", + "args": [ + "--max-workers", + "4" + ] + } + ] + }, + "provenance": { + "source_project": "content", + "task_name": "YingxiaoYunying_WeeklySummary" + } + }, + { + "id": "shop.metrics.weekly", + "module": "shop_intelligence", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "jd", + "name": "采集京东店铺经营指标", + "description": "独立采集京东店铺经营数据,生成本次产物并写入 PostgreSQL;不写入飞书 Base。", + "data_flow": { + "sources": [ + { + "system": "京东商智", + "label": "店铺经营指标" + } + ], + "processing": [ + "按业务日期采集并规范化京东店铺数据" + ], + "destinations": [ + { + "system": "本地采集产物", + "label": "京东店铺指标" + }, + { + "system": "PostgreSQL", + "label": "店铺经营指标" + } + ] + }, + "entry": "runners/run_shop.py", + "args": [ + "--platform", + "jd", + "--skip-feishu" + ] + }, + { + "id": "dy", + "name": "采集抖音店铺经营指标", + "description": "独立采集抖音店铺经营数据,生成本次产物并写入 PostgreSQL;不写入飞书 Base。", + "data_flow": { + "sources": [ + { + "system": "抖音电商罗盘", + "label": "店铺经营指标" + } + ], + "processing": [ + "按业务日期采集并规范化抖音店铺数据" + ], + "destinations": [ + { + "system": "本地采集产物", + "label": "抖音店铺指标" + }, + { + "system": "PostgreSQL", + "label": "店铺经营指标" + } + ] + }, + "entry": "runners/run_shop.py", + "args": [ + "--platform", + "dy", + "--skip-feishu" + ] + }, + { + "id": "tm", + "name": "采集天猫店铺经营指标", + "description": "独立采集天猫店铺经营数据,生成本次产物并写入 PostgreSQL;按原逻辑不写飞书店铺表。", + "data_flow": { + "sources": [ + { + "system": "天猫生意参谋", + "label": "店铺经营指标" + } + ], + "processing": [ + "按业务日期采集并规范化天猫店铺数据" + ], + "destinations": [ + { + "system": "本地采集产物", + "label": "天猫店铺指标" + }, + { + "system": "PostgreSQL", + "label": "店铺经营指标" + } + ] + }, + "entry": "runners/run_shop.py", + "args": [ + "--platform", + "tm" + ] + } + ] + }, + "provenance": { + "source_project": "shop", + "task_name": "shop-data-collection" + }, + "note": "三个平台作为独立 LangGraph 节点并行执行,分别采集并写入 PostgreSQL;店铺经营指标周采集不写飞书 Base,单个平台失败不阻断其他平台。" + }, + { + "id": "shop.competitor.weekly", + "module": "shop_intelligence", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "jd", + "name": "采集京东竞店经营指标", + "description": "独立采集京东竞店排行,生成本次产物并写入 PostgreSQL;生产模式写入飞书 Base。", + "data_flow": { + "sources": [ + { + "system": "京东商智", + "label": "竞店排行与经营指标" + } + ], + "processing": [ + "采集目标竞店并规范化排行数据" + ], + "destinations": [ + { + "system": "本地采集产物", + "label": "京东竞店指标" + }, + { + "system": "PostgreSQL", + "label": "竞店经营指标" + }, + { + "system": "飞书 Base", + "label": "京东竞店周数据", + "condition": "正式执行" + } + ] + }, + "entry": "runners/run_peer_store.py", + "args": [ + "--platform", + "jd" + ] + }, + { + "id": "dy", + "name": "采集抖音竞店经营指标", + "description": "独立采集抖音竞店排行,生成本次产物并写入 PostgreSQL;生产模式写入飞书 Base。", + "data_flow": { + "sources": [ + { + "system": "抖音电商罗盘", + "label": "竞店排行与经营指标" + } + ], + "processing": [ + "采集目标竞店并规范化排行数据" + ], + "destinations": [ + { + "system": "本地采集产物", + "label": "抖音竞店指标" + }, + { + "system": "PostgreSQL", + "label": "竞店经营指标" + }, + { + "system": "飞书 Base", + "label": "抖音竞店周数据", + "condition": "正式执行" + } + ] + }, + "entry": "runners/run_peer_store.py", + "args": [ + "--platform", + "dy" + ] + } + ] + }, + "provenance": { + "source_project": "shop", + "task_name": "peer-store-data-collection" + }, + "note": "京东和抖音作为独立 LangGraph 节点并行执行,分别入库和收敛错误,单个平台失败不阻断另一个平台;当前无天猫竞店采集任务。" + }, + { + "id": "shop.jd_self_operated.daily", + "module": "shop_intelligence", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "brand", + "name": "采集京东自营品牌业绩", + "description": "按业务日期采集京东自营品牌维度业绩,保存原始产物并写入 PostgreSQL。", + "data_flow": { + "sources": [ + { + "system": "京东自营后台", + "label": "品牌维度经营数据" + } + ], + "processing": [ + "按业务日期采集并整理品牌业绩" + ], + "destinations": [ + { + "system": "本地原始产物", + "label": "品牌业绩数据" + }, + { + "system": "PostgreSQL", + "label": "京东自营品牌日指标" + } + ] + }, + "entry": "collectors/jd_self_operated_brand_daily.py", + "args": [ + "--start-date", + "{business_date}", + "--end-date", + "{business_date}", + "--headless" + ], + "timeout_seconds": 600 + }, + { + "id": "product", + "name": "采集京东自营商品业绩", + "description": "按业务日期逐页采集京东自营商品维度业绩,保存原始产物并写入 PostgreSQL。", + "data_flow": { + "sources": [ + { + "system": "京东自营后台", + "label": "商品维度经营数据" + } + ], + "processing": [ + "逐页采集商品明细并整理日业绩" + ], + "destinations": [ + { + "system": "本地原始产物", + "label": "商品业绩数据" + }, + { + "system": "PostgreSQL", + "label": "京东自营商品日指标" + } + ] + }, + "entry": "collectors/jd_self_operated_product_daily.py", + "args": [ + "--start-date", + "{business_date}", + "--end-date", + "{business_date}", + "--headless" + ], + "depends_on": [ + "brand" + ], + "run_after_failure": true, + "timeout_seconds": 1800 + } + ] + }, + "provenance": { + "source_project": "shop", + "task_name": "京东自营昨日业绩每日采集" + }, + "note": "每天采集计划日期前两天;品牌失败仍继续商品,任一步失败则工作流失败。" + }, + { + "id": "shop.douyin_price_appeal", + "module": "shop_intelligence", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "dy", + "name": "提交抖音待改价申诉", + "description": "复用店铺周采集的抖音商家账号 Cookie 与 Profile,打开活动广场;待改价不为 0 时逐条进入立即改价,兼容“申诉”和“发起申诉”入口,全选 SKU,并以固定原因提交申诉。", + "data_flow": { + "sources": [ + { + "system": "抖音电商", + "label": "活动广场待改价商品" + }, + { + "system": "浏览器状态", + "label": "店铺周采集账号 Cookie 与 Profile" + } + ], + "processing": [ + "检查待改价数量并打开待处理列表", + "逐条进入立即改价并兼容两种申诉入口", + "全选 SKU,选择“对议价价格存疑 / 认为识别有误,该规格没有这么低的价格”并提交" + ], + "destinations": [ + { + "system": "抖音电商", + "label": "待改价议价申诉", + "condition": "正式执行(控制台或定时调度)" + } + ] + }, + "entry": "collectors/dy_store_competitor_store_scraping.py", + "args": [ + "--price-appeal", + "--execute" + ], + "timeout_seconds": 1800, + "replay_policy": "idempotent" + } + ] + }, + "provenance": { + "source_project": "shop", + "task_name": "douyin-price-appeal-daily" + }, + "note": "每天 08:00、16:00、22:00 自动执行;仍可在控制台手动执行。每个时点都会重新扫描,已申诉商品不会再次出现可申诉入口;安全预演不访问抖音也不提交申诉。" + }, + { + "id": "product.persona.daily", + "module": "product_commerce", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "collect", + "name": "采集三平台人群画像", + "description": "并行运行天猫、抖音和京东人群画像采集器,等待全部完成并写入对应飞书多维表。", + "data_flow": { + "sources": [ + { + "system": "天猫、抖音与京东", + "label": "商品人群画像后台数据" + }, + { + "system": "本地配置", + "label": "款式与目标表映射" + } + ], + "processing": [ + "并行采集三平台画像", + "统一画像维度并匹配款式" + ], + "destinations": [ + { + "system": "飞书多维表", + "label": "各平台商品人群画像" + } + ] + }, + "entry": "run_daily_persona.py", + "args": [ + "--date", + "{business_date}" + ], + "replay_policy": "idempotent" + } + ] + }, + "provenance": { + "source_project": "product", + "task_name": "PersonaDailyCollect" + } + }, + { + "id": "product.daily", + "module": "product_commerce", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "collect_import_analyze_publish", + "name": "采集、入库、汇总并发布商品日报", + "description": "按业务日期采集 ERP 与三平台数据,将原始商品日报幂等写入 PostgreSQL,完成汇总分析、导出并写入飞书日报记录。", + "data_flow": { + "sources": [ + { + "system": "ERP", + "label": "商品库存与销售数据" + }, + { + "system": "天猫、京东与抖音", + "label": "商品经营指标" + } + ], + "processing": [ + "按业务日期采集各数据源", + "将三平台原始商品日报幂等写入 PostgreSQL 商品日指标表", + "汇总并分析商品经营表现", + "导出日报记录" + ], + "destinations": [ + { + "system": "本地日报产物", + "label": "商品经营汇总与导出文件" + }, + { + "system": "PostgreSQL", + "label": "商品日经营指标" + }, + { + "system": "飞书", + "label": "商品日报记录" + } + ] + }, + "entry": "orchestrate_daily_collection.py", + "args": [ + "--target-date", + "{business_date}", + "--stages", + "collect,import,analyze,export,insert" + ] + } + ] + }, + "provenance": { + "source_project": "product", + "task_name": "ProductCollectorDailyCollect" + } + }, + { + "id": "product.alert.daily", + "module": "product_commerce", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "detect_and_notify", + "name": "检测并通知销量下滑", + "description": "检测连续销量下滑,幂等记录告警事件,并通过分析端 Hermes 机器人通知配置对象。", + "data_flow": { + "sources": [ + { + "system": "PostgreSQL", + "label": "商品日销量与历史趋势" + }, + { + "system": "告警配置", + "label": "检测规则与通知对象" + } + ], + "processing": [ + "检测连续销量下滑", + "生成确定性告警说明", + "幂等判定是否需要通知" + ], + "destinations": [ + { + "system": "PostgreSQL", + "label": "销量告警事件" + }, + { + "system": "飞书", + "label": "配置对象告警通知", + "condition": "命中规则且未重复发送" + } + ] + }, + "entry": "commands/run_alerts.py" + } + ] + }, + "provenance": { + "source_project": "product", + "task_name": "ProductCollectorSalesAlert" + } + }, + { + "id": "product.ecommerce_costs.daily", + "module": "product_commerce", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "tmall_download", + "name": "下载万相台商品广告报表", + "description": "登录天猫万相台商品报表,点击下载报表,按业务日期设置日期范围并点击确定(定时默认昨天,手动可选任意日期);从下载任务管理页下载 ZIP 并安全解压商品级 CSV。", + "entry": "collect_tmall_wanxiang_ads.py", + "args": [ + "--date", + "{business_date}", + "--headless" + ], + "timeout_seconds": 21600, + "replay_policy": "idempotent", + "data_flow": { + "sources": [ + { + "system": "天猫万相台", + "label": "商品报表与下载任务管理", + "detail": "商品报表 item_promotion;点击下载报表后按业务日期设置日期范围" + } + ], + "processing": [ + "复用已登录 Profile 或用运行时凭据登录", + "优先复用同日已有报表任务", + "仅在没有同日任务时进入商品报表点击下载报表", + "在日期范围选择昨日并点击确定", + "轮询生成成功任务", + "安全解压并写入原始 CSV 与 manifest" + ], + "destinations": [ + { + "system": "本地原始产物", + "label": "万相台商品广告 ZIP/CSV", + "detail": "raw/product_commerce/tmall_wanxiang_ads//" + } + ] + } + }, + { + "id": "tmall_import", + "name": "导入万相台商品广告指标", + "description": "解析解压后的商品 CSV,将花费、广告归因成交金额和投产比写入 product_daily_metrics.raw_data.tm_ad_metrics;款式按商品 ID 优先、商品名称精确包含兜底,并记录匹配方式,不覆盖经营日报字段。", + "entry": "import_tmall_product_ads.py", + "depends_on": [ + "tmall_download" + ], + "args": [ + "--date", + "{business_date}", + "--execute" + ], + "timeout_seconds": 21600, + "replay_policy": "idempotent", + "data_flow": { + "sources": [ + { + "system": "本地原始产物", + "label": "万相台商品广告 CSV" + } + ], + "processing": [ + "校验 CSV 编码、日期、商品主体和广告指标", + "合并同商品同日多计划行", + "按平台/日期/商品幂等更新广告 JSON 节点" + ], + "destinations": [ + { + "system": "PostgreSQL", + "label": "product_daily_metrics.raw_data.tm_ad_metrics", + "detail": "由 data-hub 产品生命进程读取并计算广告成交、投产比、电商费比;节点同时保存 style_name/style_match_method" + } + ] + } + }, + { + "id": "jd_download", + "name": "下载京东营销概况全站与非全站报表", + "description": "登录京准通 custom-report,按业务日期先提交并下载非全站营销的 SKU 报表,再在全站营销的 SPU 维度提交并下载;下载中心接近 100 条上限时自动批量删除旧报表,定时默认昨天,控制台可选择任意历史日期。", + "entry": "collect_jd_ad_costs.py", + "args": [ + "--date", + "{business_date}", + "--headless" + ], + "timeout_seconds": 21600, + "replay_policy": "idempotent", + "data_flow": { + "sources": [ + { + "system": "京准通", + "label": "custom-report 营销概况与下载中心", + "detail": "overview 页的非全站营销 SKU、全站营销 SPU 以及 download 页生成任务" + } + ], + "processing": [ + "复用运行时账号 Profile、Cookie 和已生成的同日下载任务", + "选择光影行星旗舰店霖并设置目标业务日期", + "先提交非全站 SKU 报表,再提交全站 SPU 报表且不重新选择店铺", + "从下载中心下载两个原始 CSV 并写入日期隔离目录" + ], + "destinations": [ + { + "system": "本地原始产物", + "label": "京东营销概况全站/非全站 CSV", + "detail": "raw/product_commerce/jd_ad_costs//,含 manifest.json" + } + ] + } + }, + { + "id": "jd_import", + "name": "导入京东电商费用指标", + "description": "仅解析报表中的当前时间区块,跳过对比时间与汇总行;全站 SPU 直接入库,非全站 SKU 按 SKU 名称匹配 dim_style.jd_spus 与最新京东 SPU 商品名,歧义和未匹配行保留审计状态。", + "entry": "import_jd_ad_costs.py", + "depends_on": [ + "jd_download" + ], + "args": [ + "--date", + "{business_date}", + "--execute" + ], + "timeout_seconds": 21600, + "replay_policy": "idempotent", + "data_flow": { + "sources": [ + { + "system": "本地原始产物", + "label": "京东营销概况全站/非全站 CSV" + }, + { + "system": "PostgreSQL", + "label": "dim_style.jd_spus", + "detail": "款式名到京东 SPU 的业务维度映射" + }, + { + "system": "PostgreSQL", + "label": "product_daily_metrics", + "detail": "最新京东 SPU 商品名,用于补充当天全站报表未返回的 SPU" + } + ], + "processing": [ + "按当前时间标记过滤并校验目标业务日期覆盖", + "保留全站 SPU 与非全站 SKU 的完整原始列和来源行号", + "以 stat_date、广告主、报表类型、行键幂等覆盖" + ], + "destinations": [ + { + "system": "PostgreSQL", + "label": "jd_product_ad_daily_metrics", + "detail": "京东全站 SPU 和非全站 SKU 电商费用日报;SKU 的 spu_id/style_name 记录匹配结果" + } + ] + } + }, + { + "id": "douyin_download", + "name": "下载巨量千川全域推广商品日报", + "description": "复用周店铺采集使用的 douyin-shop 账号保险库,打开全域投放数据的推商品/商品维度页面,按业务日期下载包含商品日明细的 Excel。", + "entry": "collect_douyin_qianchuan_ads.py", + "args": [ + "--date", + "{business_date}", + "--headless" + ], + "timeout_seconds": 21600, + "replay_policy": "idempotent", + "data_flow": { + "sources": [ + { + "system": "巨量千川", + "label": "全域投放数据 > 推商品 > 商品维度", + "detail": "广告主 1813687011565580;登录态来自 douyin-shop 账号保险库" + } + ], + "processing": [ + "校验广告主 ID、店铺标识和商品数据明细", + "按业务日期下载原始 Excel", + "重置千川错误的 worksheet A1 dimension 并校验日期覆盖" + ], + "destinations": [ + { + "system": "本地原始产物", + "label": "巨量千川商品推广 Excel", + "detail": "raw/product_commerce/douyin_qianchuan_ads//,含 manifest.json" + } + ] + } + }, + { + "id": "douyin_import", + "name": "导入抖音电商费用商品日报", + "description": "跳过每个商品的全部汇总行,保留按日期展开的商品明细;商品 ID 按文本保存,以日期、广告主和商品幂等写入云端 PostgreSQL。", + "entry": "import_douyin_qianchuan_ads.py", + "depends_on": [ + "douyin_download" + ], + "args": [ + "--date", + "{business_date}", + "--execute" + ], + "timeout_seconds": 21600, + "replay_policy": "idempotent", + "data_flow": { + "sources": [ + { + "system": "本地原始产物", + "label": "巨量千川商品推广 Excel" + } + ], + "processing": [ + "解析展示、点击、消耗、成交、ROI、结算率和退款率", + "跳过商品汇总行并校验目标日期完整性", + "以 stat_date、advertiser_id、product_id 幂等覆盖" + ], + "destinations": [ + { + "system": "PostgreSQL", + "label": "dy_product_ad_daily_metrics", + "detail": "抖音商品级电商费用与推广成交日报" + } + ] + } + } + ] + }, + "provenance": { + "source_project": "product", + "task_name": "EcommerceAdvertisingCostDaily" + }, + "note": "每天 10:00 并行采集天猫、京东、抖音业务日期前一天的电商费用;每个平台保持下载后再导入的依赖,单个平台失败不阻断其他平台;控制台手动运行可选择任意业务日期,正式调度沿用 gyxx schedule run。" + }, + { + "id": "product.style_analysis.interval", + "module": "product_commerce", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "analyze", + "name": "生成款式周期分析", + "description": "聚合每个款式最近 3 天经营指标,直连 MiniMax 分析,创建飞书文档并保存报告链接。", + "data_flow": { + "sources": [ + { + "system": "PostgreSQL", + "label": "最近 3 天款式经营指标" + }, + { + "system": "款式配置", + "label": "待分析款式与最小分析间隔" + } + ], + "processing": [ + "聚合款式周期指标", + "直连 MiniMax 生成经营分析", + "跳过分析间隔未满足的款式" + ], + "destinations": [ + { + "system": "飞书文档", + "label": "款式周期分析报告" + }, + { + "system": "PostgreSQL", + "label": "报告链接与分析状态" + } + ] + }, + "entry": "analyze_style.py", + "args": [ + "--all-styles", + "--days", + "3", + "--min-interval-days", + "3", + "--skip-existing", + "--end-date", + "{business_date}" + ] + } + ] + }, + "provenance": { + "source_project": "product", + "task_name": "StyleAnalysisEvery3Days" + } + }, + { + "id": "product.main_image.weekly", + "module": "product_commerce", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "jd", + "name": "采集京东主图表现", + "description": "采集京东款式主图及曝光、点击指标,保存 JSON,并写入每款飞书主图表和 PostgreSQL。", + "data_flow": { + "sources": [ + { + "system": "京东商智", + "label": "款式主图、曝光与点击指标" + }, + { + "system": "本地配置", + "label": "款式与飞书目标表映射" + } + ], + "processing": [ + "采集并按款式整理京东主图表现" + ], + "destinations": [ + { + "system": "本地 JSON", + "label": "京东主图采集结果" + }, + { + "system": "飞书", + "label": "各款式京东主图表" + }, + { + "system": "PostgreSQL", + "label": "京东主图表现指标" + } + ] + }, + "entry": "run_weekly_jd_main_image.py", + "args": [ + "--date", + "{business_date}", + "--headless" + ] + }, + { + "id": "tmall", + "name": "采集天猫主图表现", + "description": "采集天猫万相台款式主图聚合指标,保存 JSON,并写入每款飞书主图表和 PostgreSQL。", + "data_flow": { + "sources": [ + { + "system": "天猫万相台", + "label": "款式主图聚合表现" + }, + { + "system": "本地配置", + "label": "款式与飞书目标表映射" + } + ], + "processing": [ + "采集并按款式整理天猫主图表现" + ], + "destinations": [ + { + "system": "本地 JSON", + "label": "天猫主图采集结果" + }, + { + "system": "飞书", + "label": "各款式天猫主图表" + }, + { + "system": "PostgreSQL", + "label": "天猫主图表现指标" + } + ] + }, + "entry": "run_weekly_main_image.py", + "args": [ + "--date", + "{business_date}", + "--headless" + ] + } + ] + }, + "provenance": { + "source_project": "product", + "task_name": "WeeklyJdAndTmallMainImageCollect" + }, + "note": "每周日 08:30 并行执行京东与天猫万相主图采集;两个平台分别完成 PG 入库和飞书插入,资源、状态及失败互不影响。任一平台失败时另一个仍继续,工作流最终报告具体的平台错误。" + }, + { + "id": "product.sales_sheet.daily", + "module": "product_commerce", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "sync", + "name": "刷新商品月度销量表", + "description": "全量读取预算产品与款式主表,不按预算月份过滤款式;所选月份只控制聚水潭查询区间和飞书目标月份页。", + "replay_policy": "idempotent", + "data_flow": { + "sources": [ + { + "system": "飞书多维表格", + "label": "全部预算产品、目标销量、款式名与 ERP 款式编码" + }, + { + "system": "聚水潭商品主体分析", + "label": "所选自然月全渠道自定义销量与自定义退货量" + } + ], + "processing": [ + "合并预算 Base 与款式主表的全量款式并匹配 ERP 编码", + "按所选月月初到月末逐 ERP 编码生成一次全渠道报表", + "累加全部商品行和同款全部 ERP 编码并计算退货率、实销和目标达成率" + ], + "destinations": [ + { + "system": "飞书电子表格", + "label": "当月产品销量页", + "condition": "正式执行" + } + ] + }, + "entry": "sync_monthly_sales_sheet.py", + "args": [ + "--month", + "{business_date}", + "--execute", + "--allow-missing-erp-as-zero" + ] + } + ] + }, + "provenance": { + "source_project": "product", + "task_name": "ProductMonthlySalesSheetDailySync" + }, + "note": "同名月份页幂等覆盖;月份只控制聚水潭自然月区间与目标页,产品全集不按预算月份筛选;抓取和校验全部 ERP 报表成功后才清空写入;无 ERP 编码产品按 0 写入;缺页时复制最近历史月份页继承格式。" + }, + { + "id": "product.erp_all_shop_daily", + "module": "product_commerce", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "collect", + "name": "采集聚水潭全店铺款式日报", + "description": "在聚水潭商品主题分析中全选全部店铺,查询业务日期当天所有 ERP 款式编码的自定义销量和自定义退货量,并幂等写入 PostgreSQL。", + "replay_policy": "idempotent", + "data_flow": { + "sources": [ + { + "system": "款式主配置", + "label": "全部款式及 ERP 款式编码" + }, + { + "system": "聚水潭商品主题分析", + "label": "业务日期当天全店铺自定义销量与自定义退货量" + } + ], + "processing": [ + "全选全部店铺并一次提交全部 ERP 款式编码", + "按款式编码汇总商品行销量和退货量", + "保存当日原始证据并幂等覆盖数据库记录" + ], + "destinations": [ + { + "system": "本地原始证据", + "label": "聚水潭全店铺款式日报 JSON" + }, + { + "system": "PostgreSQL", + "label": "全店铺款式日销量与退货量", + "condition": "正式执行" + } + ] + }, + "entry": "backfill_erp_all_shop_daily.py", + "args": [ + "--from", + "{business_date}", + "--to", + "{business_date}", + "--execute", + "--force" + ], + "timeout_seconds": 21600 + } + ] + }, + "provenance": { + "source_project": "product", + "task_name": "JushuitanAllShopStyleDailyCollect" + }, + "note": "每天自动采集昨天数据;控制台可手动选择任意补采日期并幂等覆盖该日。全选聚水潭全部店铺,不拆京东、抖音、天猫;每次只采集一个业务日期。" + }, + { + "id": "product.video_upload", + "module": "product_commerce", + "trigger": "manual", + "execution": { + "steps": [ + { + "id": "upload", + "name": "上传所选天猫店铺短视频", + "description": "先按前端所选店铺扫描飞书并筛选符合路由的视频附件,再按视频 file_token、目标账号和飞书记录写入 PostgreSQL 附件级发布账本;仅在存在可领取附件时登录对应光合账号上传,已发布附件自动跳过。", + "replay_policy": "idempotent", + "data_flow": { + "sources": [ + { + "system": "飞书多维表格", + "label": "所选店铺待上传短视频、款式、品牌和性别信息" + }, + { + "system": "PostgreSQL", + "label": "天猫商品 ID、商品标题与附件级发布账本" + }, + { + "system": "淘宝光合浏览器状态", + "label": "旗舰店与箱包店独立账号和 Profile" + } + ], + "processing": [ + "按前端所选旗舰店或箱包店规则筛选飞书记录并拆分视频附件", + "按视频 file_token、目标账号和飞书记录原子领取未上传附件", + "确认存在可上传附件后登录所选店铺账号", + "生成标题、关联天猫商品并发布;明确成功后写 published,提交后结果不明写 ambiguous" + ], + "destinations": [ + { + "system": "淘宝光合", + "label": "前端所选天猫店铺短视频", + "condition": "正式执行且存在可上传附件" + }, + { + "system": "PostgreSQL", + "label": "附件发布状态、尝试记录与确认凭据", + "condition": "正式执行" + }, + { + "system": "飞书多维表格", + "label": "天猫上传状态", + "condition": "旗舰店记录全部附件发布成功" + } + ] + }, + "entry": "upload_video_to_guanghe.py", + "args": [ + "--execute", + "--keep-browser-open-on-failure" + ], + "timeout_seconds": 21600 + } + ] + }, + "provenance": { + "source_project": "product", + "task_name": "TmallGuangheVideoUpload" + }, + "topic_config": { + "default_keyword": "我的夏日焕新清单", + "label": "天猫话题关键词", + "hint": "输入完整话题或关键词;例如输入“秋上新”也会匹配并选中“秋上新开拍了”。" + }, + "note": "前端手动选择旗舰店或箱包店后执行;脚本先扫描飞书并按所选店铺规则筛选,只有数据库存在可领取附件时才登录对应账号上传。允许同一业务日期重复扫描新增附件,PostgreSQL 附件级账本阻止已发布或结果不明的附件自动重发;框架安全预演不会启动上传脚本。" + }, + { + "id": "product.jd_video_upload", + "module": "product_commerce", + "trigger": "manual", + "execution": { + "steps": [ + { + "id": "upload", + "name": "扫描新增视频并发布到京东", + "description": "每次点击执行都会从飞书视图中的凌云air基线记录起扫描到视图末尾,不依赖日期字段;以 record_id + 视频 file_token 为幂等键,只发布新增或安全可重试的单款视频。无飞书封面时保留京东从视频生成的默认封面。", + "replay_policy": "idempotent", + "data_flow": { + "sources": [ + { + "system": "飞书多维表格", + "label": "凌云air基线之后的代拍产品、视频与可选封面附件;日期可为空" + }, + { + "system": "PostgreSQL", + "label": "京东商品标题与附件级发布账本" + }, + { + "system": "京麦浏览器状态", + "label": "独立 JD 视频发布 Profile、Cookie 与 Storage State" + } + ], + "processing": [ + "每次执行按飞书视图顺序从固定 record_id 锚点扫描到末尾,识别新增视频 file_token", + "组合款在下载素材前直接跳过;单款有封面时上传飞书封面,无封面时保留京东默认视频封面", + "按款式标题模糊匹配并用直连多模态大模型判断包体颜色,多选最多10个同款同色商品", + "选择相关话题和标签后逐条发布;已入账附件由 PostgreSQL 唯一键自动防重" + ], + "destinations": [ + { + "system": "京麦内容中心", + "label": "京东旗舰店短视频", + "condition": "正式执行且款式与包体颜色匹配" + }, + { + "system": "PostgreSQL", + "label": "视频发布状态、尝试记录与确认凭据", + "condition": "正式执行" + } + ] + }, + "entry": "upload_video_to_jd.py", + "args": [ + "--anchor-record-id", + "recvrXTRWWFXeJ", + "--manual-login", + "--execute" + ], + "timeout_seconds": 21600 + } + ] + }, + "provenance": { + "source_project": "product", + "task_name": "JdVideoUpload" + }, + "note": "前端手动幂等工作流;每次点击从凌云air基线扫描到视图末尾,新增行或旧行新增的视频 file_token 会入账,已发布 token 不会重复发布。安全验证出现时在有头浏览器中人工完成后脚本自动继续。" + }, + { + "id": "product.tmall_baibu_apply", + "module": "product_commerce", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "old_chain_bag", + "name": "导入老天猫超链箱包模板", + "description": "用 Feishu user 身份下载老天猫超链箱包模板,打开文档指定报名入口,校验光影行星旗舰店身份后提交商品批量导入。", + "has_hyperlink": true, + "args": [ + "--execute" + ], + "replay_policy": "repeatable", + "data_flow": { + "sources": [ + { + "system": "飞书电子表格", + "label": "老天猫·超链·箱包商品导入模板" + }, + { + "system": "淘宝浏览器状态", + "label": "光影行星旗舰店独立账号登录态" + } + ], + "processing": [ + "以 user 身份下载文档指定模板", + "打开文档指定报名链接并校验店铺身份", + "提交商品批量导入并等待页面结果" + ], + "destinations": [ + { + "system": "天猫百亿补贴", + "label": "老天猫·超链·箱包商品导入" + } + ] + }, + "entry": "tmall_baibu_apply_old_chain_bag.py", + "timeout_seconds": 21600 + }, + { + "id": "old_all_bag", + "name": "导入老天猫全渠道箱包模板", + "description": "用 Feishu user 身份下载老天猫全渠道箱包模板,打开文档指定报名入口,校验光影行星旗舰店身份后提交商品批量导入。", + "depends_on": [ + "old_chain_bag" + ], + "run_after_failure": true, + "args": [ + "--execute" + ], + "replay_policy": "repeatable", + "data_flow": { + "sources": [ + { + "system": "飞书电子表格", + "label": "老天猫·全渠道·箱包商品导入模板" + }, + { + "system": "淘宝浏览器状态", + "label": "光影行星旗舰店独立账号登录态" + } + ], + "processing": [ + "以 user 身份下载文档指定模板", + "打开文档指定报名链接并校验店铺身份", + "提交商品批量导入并等待页面结果" + ], + "destinations": [ + { + "system": "天猫百亿补贴", + "label": "老天猫·全渠道·箱包商品导入" + } + ] + }, + "entry": "tmall_baibu_apply_old_all_bag.py", + "timeout_seconds": 21600 + }, + { + "id": "old_all_3c", + "name": "导入老天猫全渠道 3C 模板", + "description": "用 Feishu user 身份下载老天猫全渠道 3C 模板,打开文档指定报名入口,校验光影行星旗舰店身份后提交商品批量导入;导入完成后进入草稿,逐个点击完善商品、选择否并下一步,直到草稿清空。", + "depends_on": [ + "old_all_bag" + ], + "run_after_failure": true, + "args": [ + "--execute" + ], + "replay_policy": "repeatable", + "data_flow": { + "sources": [ + { + "system": "飞书电子表格", + "label": "老天猫·全渠道·3C 商品导入模板" + }, + { + "system": "淘宝浏览器状态", + "label": "光影行星旗舰店独立账号登录态" + } + ], + "processing": [ + "以 user 身份下载文档指定模板", + "打开文档指定报名链接并校验店铺身份", + "提交商品批量导入并等待页面结果", + "进入草稿逐个完善商品,选择否并点击下一步,直到草稿清空" + ], + "destinations": [ + { + "system": "天猫百亿补贴", + "label": "老天猫·全渠道·3C 商品导入" + } + ] + }, + "entry": "tmall_baibu_apply_old_all_3c.py", + "timeout_seconds": 21600 + }, + { + "id": "old_chain_3c", + "name": "导入老天猫超链 3C 模板", + "description": "用 Feishu user 身份下载老天猫超链 3C 模板,打开文档指定报名入口,校验光影行星旗舰店身份后提交商品批量导入。", + "has_hyperlink": true, + "depends_on": [ + "old_all_3c" + ], + "run_after_failure": true, + "args": [ + "--execute" + ], + "replay_policy": "repeatable", + "data_flow": { + "sources": [ + { + "system": "飞书电子表格", + "label": "老天猫·超链·3C 商品导入模板" + }, + { + "system": "淘宝浏览器状态", + "label": "光影行星旗舰店独立账号登录态" + } + ], + "processing": [ + "以 user 身份下载文档指定模板", + "打开文档指定报名链接并校验店铺身份", + "提交商品批量导入并等待页面结果" + ], + "destinations": [ + { + "system": "天猫百亿补贴", + "label": "老天猫·超链·3C 商品导入" + } + ] + }, + "entry": "tmall_baibu_apply_old_chain_3c.py", + "timeout_seconds": 21600 + }, + { + "id": "new_chain_bag", + "name": "导入新天猫超链箱包模板", + "description": "用 Feishu user 身份下载新天猫超链箱包模板,打开文档指定报名入口,校验光影行星鑫华达专卖店身份后提交商品批量导入。", + "has_hyperlink": true, + "depends_on": [ + "old_chain_3c" + ], + "run_after_failure": true, + "args": [ + "--execute" + ], + "replay_policy": "repeatable", + "data_flow": { + "sources": [ + { + "system": "飞书电子表格", + "label": "新天猫·超链·箱包商品导入模板" + }, + { + "system": "淘宝浏览器状态", + "label": "光影行星鑫华达专卖店独立账号登录态" + } + ], + "processing": [ + "以 user 身份下载文档指定模板", + "打开文档指定报名链接并校验店铺身份", + "提交商品批量导入并等待页面结果" + ], + "destinations": [ + { + "system": "天猫百亿补贴", + "label": "新天猫·超链·箱包商品导入" + } + ] + }, + "entry": "tmall_baibu_apply_new_chain_bag.py", + "timeout_seconds": 21600 + }, + { + "id": "new_all_bag", + "name": "导入新天猫全渠道箱包模板", + "description": "用 Feishu user 身份下载新天猫全渠道箱包模板,打开文档指定报名入口,校验光影行星鑫华达专卖店身份后提交商品批量导入。", + "depends_on": [ + "new_chain_bag" + ], + "run_after_failure": true, + "args": [ + "--execute" + ], + "replay_policy": "repeatable", + "data_flow": { + "sources": [ + { + "system": "飞书电子表格", + "label": "新天猫·全渠道·箱包商品导入模板" + }, + { + "system": "淘宝浏览器状态", + "label": "光影行星鑫华达专卖店独立账号登录态" + } + ], + "processing": [ + "以 user 身份下载文档指定模板", + "打开文档指定报名链接并校验店铺身份", + "提交商品批量导入并等待页面结果" + ], + "destinations": [ + { + "system": "天猫百亿补贴", + "label": "新天猫·全渠道·箱包商品导入" + } + ] + }, + "entry": "tmall_baibu_apply_new_all_bag.py", + "timeout_seconds": 21600 + } + ] + }, + "provenance": { + "source_project": "product", + "task_name": "TmallBaibuBatchApplyHourly" + }, + "note": "默认按每小时整点调度,仅导入三个不带超链的普通入口;其中老天猫全渠道 3C 入口在导入完成后会逐个完善草稿商品并清空草稿;前端仍可切换为导入全部六个入口并手动执行,且同样经过该后置流程。每次运行都会重新从飞书下载模板并提交,不按业务日期分组或防重。四个老天猫入口固定使用光影行星旗舰店,两个新天猫入口固定使用光影行星鑫华达专卖店;选中的入口串行执行且后续步骤允许在前一步失败后继续,模板与报名链接均来自飞书清单;每个入口使用独立 Profile、Cookie/Storage State 和 CDP 端口,脚本 finally 必须关闭自己的浏览器。" + }, + { + "id": "supply.purchase_confirmation.daily", + "module": "supply_chain", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "collect_analyze_notify", + "name": "生成采购确认通知", + "description": "从 ERP 识别库存缺口商品并检查协议到货时间,写入 PostgreSQL 后发送采购确认通知。", + "replay_policy": "idempotent", + "data_flow": { + "sources": [ + { + "system": "ERP", + "label": "商品库存、在途与采购数据" + }, + { + "system": "采购配置", + "label": "协议到货时间与确认规则" + } + ], + "processing": [ + "识别库存缺口商品", + "核对协议到货时间并生成确认项" + ], + "destinations": [ + { + "system": "PostgreSQL", + "label": "采购确认处理记录" + }, + { + "system": "飞书", + "label": "采购确认通知" + } + ] + }, + "entry": "run.py", + "args": [ + "mcp-run", + "purchase-confirmation" + ] + } + ] + }, + "provenance": { + "source_project": "supply", + "task_name": "auto-flow-purchase-confirmation" + } + }, + { + "id": "supply.replenishment.weekly", + "module": "supply_chain", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "collect_analyze_notify", + "name": "生成并发送补货建议", + "description": "导出 ERP 库存并计算补货量,写入 PostgreSQL 与飞书多维表,最后发送补货结果通知。", + "replay_policy": "idempotent", + "data_flow": { + "sources": [ + { + "system": "ERP", + "label": "库存、销售与商品数据" + }, + { + "system": "补货配置", + "label": "SKU 范围与补货规则" + } + ], + "processing": [ + "导出并规范化库存数据", + "按规则计算建议补货量", + "生成补货批次结果" + ], + "destinations": [ + { + "system": "PostgreSQL", + "label": "补货批次与建议明细" + }, + { + "system": "飞书多维表", + "label": "补货建议记录" + }, + { + "system": "飞书", + "label": "补货结果通知" + } + ] + }, + "entry": "run.py", + "args": [ + "mcp-run", + "replenishment" + ] + } + ] + }, + "provenance": { + "source_project": "supply", + "task_name": "auto-flow-replenishment" + } + }, + { + "id": "supply.replenishment_alert.daily", + "module": "supply_chain", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "collect_analyze_notify", + "name": "生成库存阈值预警", + "description": "依据配置 SKU 和库存阈值生成预警并写入 PostgreSQL,仅在存在预警时发送业务通知。", + "replay_policy": "idempotent", + "data_flow": { + "sources": [ + { + "system": "ERP", + "label": "SKU 当前库存" + }, + { + "system": "预警配置", + "label": "监控 SKU 与库存阈值" + } + ], + "processing": [ + "比较当前库存与配置阈值", + "生成需要处理的库存预警" + ], + "destinations": [ + { + "system": "PostgreSQL", + "label": "库存阈值预警记录" + }, + { + "system": "飞书", + "label": "库存预警通知", + "condition": "存在命中阈值的 SKU" + } + ] + }, + "entry": "run.py", + "args": [ + "mcp-run", + "replenishment-alert" + ] + } + ] + }, + "provenance": { + "source_project": "supply", + "task_name": "auto-flow-replenishment-alert" + } + }, + { + "id": "supply.replenishment_arrival_sync.daily", + "module": "supply_chain", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "sync_queue", + "name": "同步补货建议预计到货时间", + "description": "读取 erp_replenishment_arrival_sync 待处理队列,使用聚水潭采购单页面按商品编码更新预计到货时间,并逐条回写同步状态。", + "replay_policy": "idempotent", + "data_flow": { + "sources": [ + { + "system": "PostgreSQL", + "label": "erp_replenishment_arrival_sync 到货时间同步队列" + }, + { + "system": "聚水潭", + "label": "采购单管理页面与登录态" + } + ], + "processing": [ + "按租户和 SKU 原子认领 pending/failed 队列行", + "在聚水潭采购单明细中更新协议到货日期", + "按目标时间版本回写 success 或 failed 状态" + ], + "destinations": [ + { + "system": "聚水潭", + "label": "采购单明细协议到货日期" + }, + { + "system": "PostgreSQL", + "label": "同步状态、更新时间、尝试次数和失败原因" + } + ] + }, + "entry": "orchestrator/scripts/collect_replenishment_arrival_sync.py" + } + ] + }, + "provenance": { + "source_project": "supply", + "task_name": "auto-flow-replenishment-arrival-sync" + }, + "note": "队列表为空时成功结束;失败行保留 failed 状态,下一次调度自动重试。默认租户为 1,可用 GYXX_ERP_TENANT_ID 覆盖。" + }, + { + "id": "product.market_rank", + "module": "product_commerce", + "trigger": "scheduled", + "execution": { + "steps": [ + { + "id": "collect_analyze_publish", + "name": "采集并发布三平台市场排行", + "description": "并行采集天猫、京东和抖音市场排行,汇总平台结果并发送飞书报告链接。", + "data_flow": { + "sources": [ + { + "system": "天猫、京东与抖音", + "label": "市场商品排行与表现数据" + }, + { + "system": "本地配置", + "label": "类目、关键词与采集范围" + } + ], + "processing": [ + "并行采集三平台排行", + "汇总平台结果并生成市场分析", + "归档报告并准备通知" + ], + "destinations": [ + { + "system": "本地报告产物", + "label": "三平台市场排行结果" + }, + { + "system": "飞书", + "label": "市场排行报告链接与通知" + } + ] + }, + "entry": "orchestrate_market_rank_collection.py", + "args": [ + "--report-date", + "{business_date}" + ] + } + ] + }, + "provenance": { + "source_project": "product", + "task_name": "三平台市场排行月采集" + }, + "note": "每月 1 日 10:00 由项目内 Python 调度器触发。" + } ] } diff --git a/docs/acceptance-report.md b/docs/acceptance-report.md index cc02131..0ddb0d5 100644 --- a/docs/acceptance-report.md +++ b/docs/acceptance-report.md @@ -46,15 +46,14 @@ | `shop.douyin_price_appeal` | 每日 08:00、16:00、22:00 | 复用店铺周采集登录态,扫描全部待改价列表并对可申诉商品提交固定原因申诉。 | | `shop.jd_self_operated.daily` | 每日 16:00,业务日 -1 | 京东自营数据从下午开始推送;预留刷新窗口后先采集品牌,再采集商品,品牌失败后商品仍继续。 | -### 商品经营(7) +### 商品经营(6) | 工作流 | 时间 | 用途 | |---|---|---| -| `product.daily` | 每日 08:40,业务日 -1 | 编排 ERP 与三平台采集、分析、导出和入库。 | +| `product.daily` | 每日 08:40,业务日 -1 | 编排 ERP 与三平台采集、原始商品日报幂等入 PostgreSQL、分析、导出和飞书写入。 | | `product.persona.daily` | 每日 10:00 | 并行采集天猫、抖音和京东商品画像。 | -| `product.import.daily` | 每日 19:00 | 幂等导入三平台商品日报。 | | `product.alert.daily` | 每日 23:00 | 独立检测商品异常并按条件通知。 | -| `product.style_analysis.interval` | 每 3 天 11:00 | 使用本地 Hermes 分析到期款式。 | +| `product.style_analysis.interval` | 每 3 天 11:00 | 直连 MiniMax 分析到期款式;不依赖本地 Hermes 分析端。 | | `product.main_image.weekly` | 周日 08:30 | 并行启动 JD、TM 两个独立主图分支,各自写入本地 PG 和飞书;一个平台失败不阻断另一平台,全部结束后汇总整体状态和平台级具体错误。 | | `product.market_rank` | 周一 10:00 | 并行采集天猫、京东、抖音市场排行并汇总归档。 | @@ -136,4 +135,4 @@ flowchart TD - [x] 不可执行历史项不再参与目录、注册和数量统计。 - [x] 最终全量测试、Ruff、构建、doctor、调度 dry-run 与敏感信息扫描通过。 -真实浏览器登录、飞书写入、Hermes 分析和 ERP 修改不由本次结构合并自动触发。各业务链路最近一次实跑结果与未通过原因见 [工作流验收测试报告](workflow-acceptance-test-report.md)。 +真实浏览器登录、仍依赖 Hermes 的分析和 ERP 修改不由本次结构合并自动触发;款式周期分析的 MiniMax 直连及其飞书/Base/PostgreSQL 写入已单独完成实跑验证。各业务链路最近一次实跑结果与未通过原因见 [工作流验收测试报告](workflow-acceptance-test-report.md)。 diff --git a/docs/architecture.md b/docs/architecture.md index 0627f3e..b2359d7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -67,9 +67,34 @@ flowchart TD ## 外部系统 - PostgreSQL:云端模式不提供地址、数据库、用户或密码的源码默认值;由 `GYXX_POSTGRES_DSN` 在运行时注入,并拒绝回环数据库地址。 -- Hermes:保留本机 `data-analyzer` 和 `data-collector` 两个角色,以及各自 API 和 gateway。 -- 飞书:复用现有 lark-cli profile、应用身份、表格和消息调用方式。 -- 浏览器:每个脚本从 `config/runtime-bindings.json` 获得唯一 CDP 端口及独立 Profile、Cookie、storage state 路径,不共享可写 Profile。 +- Hermes:保留本机 `data-analyzer` 和 `data-collector` 两个角色,以及各自 API 和 gateway;Hermes 只承担需要大模型的分析,不作为飞书消息投递身份。 +- 飞书:表格写入和业务消息统一使用 `lark-cli --profile hermes-analyzer --as user`。消息发送必须校验真实 `message_id` 回执;卡片图片预上传因上游接口仅支持 tenant token,保留同一 profile 的 bot 媒体上传例外,但最终卡片仍由 user 身份投递。 +- 浏览器:每个脚本从 `config/runtime-bindings.json` 获得唯一 CDP 端口及独立 Profile、Cookie、storage state 路径,不共享可写 Profile;多个脚本若属于同一登录身份,则通过 `state/accounts//` 的账号 vault 合并 Cookie,仍保持 Profile 隔离。像商品经营日报这类按品牌动态路由的包装脚本,会在运行时选择对应账号 vault,但仍为每个品牌保留独立 Profile。 +- 账号保活:`accounts..keepalive` 由唯一的 `gyxx schedule run` 常驻进程错峰执行;只有安全页面确认未跳转登录页且必需 Cookie 仍有效时,才原子发布新 vault 并同步成员脚本。京东 `jd-shop`、`jd-self-operated`、`jd-market-rank` 以及天猫 `tmall-shop`、`tmall-ozko` 都使用独立 vault,避免同域不同店铺相互覆盖。 + +### Scrapling 采集边界 + +浏览器和公开 HTTP 采集统一使用 `scrapling[fetchers]==0.4.13`。生产代码不得直接 +导入或启动 Playwright、Patchright、Selenium;静态接口使用 `Fetcher`,普通动态 +页面使用 `DynamicSession`,需要隐身能力的页面使用 `StealthySession`。Scrapling 的 +动态抓取器内部仍分别使用 Playwright/Patchright,因此它们会作为传递依赖出现,但 +不是项目业务 API。官方选型说明见 +。 + +`gyxx_flow.adapters.scrapling.ScraplingBrowser` 是统一浏览器边界,负责: + +- 默认只执行一次,避免上传、申诉等有副作用动作被框架静默重放; +- 从绑定专属 Cookie/storage-state 文件恢复状态,并在关闭前原子保存; +- 用持久 Profile 启动自有浏览器,或借用外部 CDP 中唯一已有 context; +- 借用 CDP 时只关闭本次页面和连接,不关闭远端 browser/context; +- 将 Scrapling 回调中被记录后吞掉的异常重新抛给工作流; +- 在业务代码不导入底层引擎的前提下统一识别浏览器超时。 + +Scrapling 0.4.13 当前要求 `curl-cffi==0.16.1b1`,项目显式锁定该版本。部署安装锁定 +依赖后运行 `uv run scrapling install` 准备浏览器运行时;定时采集任务本身不得执行 +安装。`tests/test_no_native_browser_automation.py` 负责阻止原生浏览器调用回流,唯一 +排除项是不可执行的上游 Scrapling 源码快照 +`vendors/dy-data-flow/dynamic_session_src.py`。 ## 数据布局 diff --git a/docs/deployment.md b/docs/deployment.md index 99608b9..d5b7fb4 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -16,7 +16,7 @@ - Python 3.12 和 `uv` - 可访问的 PostgreSQL 13+ 云端实例及运行时注入的 `GYXX_POSTGRES_DSN` - 本机 Hermes `data-analyzer` 与 `data-collector` -- Chrome/Playwright,以及个别业务入口仍需要的 PowerShell 运行条件 +- Chrome 与 Scrapling 浏览器运行时,以及个别业务入口仍需要的 PowerShell 运行条件 - 可访问现有飞书身份的专用系统用户 创建生产目录和服务账户: @@ -33,14 +33,67 @@ sudo install -d -o root -g gyxx-flow -m 0750 /etc/gyxx-flow ```bash cd /opt/gyxx-flow sudo -u gyxx-flow uv sync --python 3.12 --no-group dev --frozen +sudo -u gyxx-flow uv run scrapling install ``` +业务采集代码只使用 Scrapling;其动态与隐身抓取器所需的底层浏览器由 +Scrapling 安装和管理。具体边界与验证命令见 +[`architecture.md`](architecture.md#scrapling-采集边界)。 + 凭据放入 `/etc/gyxx-flow/gyxx-flow.env`,权限设为 `0640`。该文件不提交到 Git,至少按实际环境注入数据库密码、飞书身份和可选 Hermes 密钥。 控制台和调度器都支持可重复的 `--env-file`,只加载显式列出的文件。systemd 的 `EnvironmentFile=` 或当前进程环境优先于文件中的同名值,避免本地文件意外覆盖密钥系统 注入值。 +## 三平台电商费用日报部署边界 + +`product.ecommerce_costs.daily` 是包含天猫万相台、京东京准通和抖音千川分支的电商费用工作流;三个 +平台下载节点并行,导入节点分别等待本平台下载完成。当前项目对其中天猫万相台这条 +浏览器链路按 Windows Server + NSSM 验收;Linux 上的通用调度器说明不等于万相台浏览器流程 +已经完成 Linux/无头浏览器验收。部署到 Windows Server 时使用项目内的 NSSM 服务包装器,业务 +时间仍只由 `config/schedules.json` 管理,不创建 Windows Task Scheduler 条目。 + +该工作流需要由服务账户注入以下环境变量,真实值不要写入 Git、命令参数或文档: + +```text +GYXX_DATA_ROOT=D:\gyxx-flow-data +GYXX_POSTGRES_DSN=<云端 PostgreSQL DSN> +WANXIANG_ACCOUNT=<万相台账号> +WANXIANG_PASSWORD=<万相台密码> +``` + +未显式设置 `WANXIANG_USER_DATA_DIR` 时,登录态保存在 +`\state\browser-profiles\wanxiang-ads`。这个 Profile 是该脚本的独立登录态, +不要与其他淘宝/万相台脚本共用。使用 NSSM 的 `AppEnvironmentExtra` 或服务器密码管理器注入 +凭据;不要把真实密码写进 PowerShell 脚本或 `nssm` 命令历史。 + +安装 Windows 常驻服务: + +```powershell +cd D:\gyxx-flow +uv sync --python 3.12 --group dev +.\deploy\windows-service\install.ps1 -ProjectRoot D:\gyxx-flow -DataRoot D:\gyxx-flow-data +# 按服务器密码管理器的方式为 gyxx-flow-scheduler 注入上述环境变量 +nssm start gyxx-flow-scheduler +``` + +首次上线先在有头浏览器中建立登录态并导入一日数据;验证码或滑块必须在同一 Profile 中人工 +完成: + +```powershell +$env:GYXX_DATA_ROOT = 'D:\gyxx-flow-data' +$env:WANXIANG_ACCOUNT = '<从密码管理器读取>' +$env:WANXIANG_PASSWORD = '<从密码管理器读取>' +uv run gyxx doctor --json +uv run gyxx scripts run product.tmall_wanxiang_ads.collect --date 2026-08-17 --execute +uv run gyxx scripts run product.import.tmall_ads --date 2026-08-17 --execute +``` + +确认手工链路成功后,再让唯一的 `gyxx schedule run` 常驻服务接管;不要为 19:30 另建系统定时 +任务。完整的服务重启和 dry-run 流程见本文件的“systemd 调度服务”章节以及 +[`runbook.md`](runbook.md) 的万相台小节。 + ## PostgreSQL 从受限环境文件加载云端数据库连接: @@ -66,7 +119,9 @@ data-collector: API base http://127.0.0.1:8643/v1 `28790/28791` 不作为工作流业务端点。 -运行时配置必须保持回环地址。Hermes 不可用时,纯采集、文件处理和数据库同步仍可运行;依赖 Hermes 分析或通知的工作流应保持停用或手工执行,不得静默改用远程 AI。 +运行时配置必须保持回环地址。Hermes 不可用时,纯采集、文件处理、数据库同步和不依赖大模型的确定性通知仍可运行;仍依赖 Hermes 的工作流应保持停用或手工执行,不得静默改用远程 AI。飞书消息投递本身统一依赖运行服务账户的 `hermes-analyzer` lark-cli user 授权。 + +例外:`content.summary.weekly` / `content.summary.monthly` 的内容报告、`product.style_analysis.interval` 的款式周期分析,以及 `product.video_upload` / `product.jd_video_upload` 的视频标题与视觉颜色识别,均配置为显式直连 MiniMax。内容报告使用 `CONTENT_ANALYSIS_LLM_BASE_URL`、`CONTENT_ANALYSIS_LLM_MODEL` 和 `CONTENT_ANALYSIS_LLM_API_KEY`;商品/视频链路使用 `STYLE_ANALYSIS_LLM_*`(视频链路也支持 `GYXX_DIRECT_LLM_*` 覆盖)。这些链路不读取 Hermes 分析端口,但仍保留本地结果校验、断点、EffectLedger、飞书和 PostgreSQL 写入链路。 ## 上线前验证 diff --git a/docs/dynamic-workflow-config.md b/docs/dynamic-workflow-config.md new file mode 100644 index 0000000..3db85fe --- /dev/null +++ b/docs/dynamic-workflow-config.md @@ -0,0 +1,82 @@ +# 工作流动态配置 + +## 配置源 + +商品 ID、ERP 款式编码和各业务飞书目标表现在统一保存在云端 PostgreSQL。 +控制台“配置中心 → 款式平台配置”提供查询、新增、编辑、 +停用和删除。工作流运行时只读取这些项目表,不再读取旧配置主表 +`TtoCb1NuQaDy3NsZWTpc0GIvnph/tblKCjplVAFrRwMC`。 + +目标飞书表仍是工作流的业务输入或输出,例如销量表、主图表、人群画像表和合作达人表; +本次下线的是集中维护这些地址和商品 ID 的旧飞书索引表,不是业务目标表本身。 + +## 受影响工作流 + +| 工作流 | 动态读取内容 | 生效入口 | +| --- | --- | --- | +| `content.summary.monthly` | 款式与每周笔记分析/生命进程目标表 | `monthly_summary_all.py`(复用周汇总配置加载器) | +| `content.summary.weekly` | 款式与每周笔记分析/生命进程目标表 | `weekly_summary_all.py` | +| `content.metrics.daily` | 合作达人目标表(自营采集暂缓) | `run_all.py`、`sync_metrics_to_cmt_notes.py` | +| `content.metrics.backfill` | 合作达人目标表 | `run_all.py` | +| `content.notes_master.daily` | 各款式合作达人及自营笔记表 | `sync_notes_master.py` | +| `product.persona.daily` | 三平台商品 ID 与人群画像目标表 | 三个平台画像采集脚本 | +| `product.daily` | 天猫/京东/抖音商品 ID、ERP 编码、销量目标表 | `orchestrate_daily_collection.py` 及平台采集脚本 | +| `product.style_analysis.interval` | 款式与平台单品分析目标表 | `analyze_style.py` | +| `product.main_image.weekly` | 京东 SPU、天猫款式和主图目标表 | 两个平台主图采集入口 | +| `product.sales_sheet.daily` | 款式与 ERP 编码 | `sync_monthly_sales_sheet.py` | +| `product.erp_all_shop_daily` | 全部款式与 ERP 编码 | `backfill_erp_all_shop_daily.py` | + +共 11 个定时工作流依赖这套动态配置。维护脚本 `feishu_comment_batch.py` 和 +`db/sync_sku_master.py` 也已改为读取同一项目数据库,避免从非定时入口绕回旧主表。 + +`product.alert.daily`、商品导入、万相台广告、视频发布和市场排行使用各自数据库或专用配置, +不依赖旧配置主表,因此不在本次切换范围内。 + +## 字段归属与数据模型 + +新模型不再把共享字段重复放在每个平台行: + +| 层级 | 字段 | 使用目的 | +| --- | --- | --- | +| 款式 | 款式名、品牌、ERP 款式编码 | 统一业务身份;ERP 日采、月度销量表和款式主数据使用 | +| 款式 | 销量表、主图表 | 商品日报和天猫/京东主图工作流的款式目标表 | +| 款式 | 合作达人表、自营合作达人表 | 内容采集、合作同步和笔记清单使用 | +| 款式 | 每周/每月笔记分析、平台单品分析 | 内容周月报和款式周期分析使用 | +| 平台 | 平台名、商品 ID、启用状态 | 天猫商品 ID、京东 SPU、京东自营 SKU、抖音/PDD 商品 ID | +| 平台 | 本平台人群画像表 | 天猫、京东、抖音画像采集分别写入自己的目标表 | + +数据库使用父子表: + +- `workflow_dynamic_styles`:一个款式一行,保存 ERP 和共享飞书目标。 +- `workflow_dynamic_style_platforms`:一个款式可有多个平台子行,只保存平台商品 ID、平台画像表和启停状态。 +- 旧 `workflow_dynamic_configs` 保留为原始迁移审计,不再参与运行时读取。 +- `erp_codes`、`item_ids` 使用 PostgreSQL 数组,页面接受中英文逗号或换行并自动去重。 +- `destinations` 使用通用 JSONB 目标列表,每项包含 `key`、`label`、`url`、`description`、`enabled`;六个旧目标字段仍同步保留,保证已有工作流兼容。后续新增分析逻辑只需增加一个稳定的 `key` 和对应飞书表地址。 +- 款式带递增 `revision`;整页保存和删除必须提交 `If-Match`,避免并发覆盖。 + +## 首次迁移 + +`config/workflow-dynamic-config-seed.json` 是 2026-08-19 从旧 Base 分页导出的只读迁移快照, +共 215 条源记录。项目第一次连接一个空数据库时会: + +1. 幂等创建旧迁移审计表、款式父表和平台子表; +2. 在事务和 PostgreSQL advisory lock 内导入 215 条记录; +3. 写入迁移标记 `feishu-style-config-20260819-v1`; +4. 运行 v2 归一化迁移:按款式合并共享字段,按平台合并商品 ID;旧表中集中在天猫行的三平台画像地址分别迁入对应平台子行; +5. 写入迁移标记 `workflow-style-platform-config-20260819-v2`,此后不会重复迁移。 + +服务器必须注入 `GYXX_POSTGRES_DSN`,或完整的 `PG_HOST/PG_PORT/PG_DB/PG_USER/PG_PASSWORD`。 +没有数据库且没有数据库生成的运行缓存时,相关工作流会明确失败,不会静默回读旧飞书主表。 + +## 运行时路径 + +```text +控制台分组 CRUD + ↓ +workflow_dynamic_styles + workflow_dynamic_style_platforms (PostgreSQL) + ├─ 兼容聚合输出 → StyleConfigLoader → 商品经营工作流 + └─ 兼容聚合输出 → feishu_mapping → 内容营销工作流 → 各业务飞书目标表 +``` + +`StyleConfigLoader` 只在数据库短暂不可用时使用最近一次数据库成功读取后生成的本地缓存; +`feishu_mapping` 的目标表字段缓存仍保留,用来减少对各业务飞书表的字段查询。 diff --git a/docs/note-inventory.md b/docs/note-inventory.md new file mode 100644 index 0000000..ccbfc96 --- /dev/null +++ b/docs/note-inventory.md @@ -0,0 +1,89 @@ +# 笔记主表(cmt_notes_master)设计文档 + +> 原 `cmt_notes` / `cmt_note_inventory` / `cmt_cooperations` 三张表已合并为 +> `cmt_notes_master`(migration 007)。本文替代原“完整笔记清单同步”文档。 + +## 口径 + +`cmt_notes_master` 是合作达人和自营笔记的唯一主表,一行 = 一条飞书来源记录 +(合作达人表 / 自营笔记表),或一条采集补建笔记(`source_*` 列为 NULL)。 +飞书来源行同时具备“发布笔记标题”“发布时间”“发布链接”时, +`is_countable = TRUE`,才计入笔记数。 + +三张表合一后: + +- 笔记数:统计 `cmt_notes_master` 中 `source_active AND is_countable` 的去重链接; +- 曝光/互动指标:同一行的 `view_count` / `like_count` 等列,由 + `sync_metrics_to_cmt_notes.py`(曝光)与评论采集链路(互动、评论)按 + URL / 记录写回; +- 合作财务:同一行的 `cooperation_cost` / `ad_spend` / `cpm` 等列,由 + 飞书同步与合作字段一并写入(仅合作表记录有值,自营为 NULL); +- 没采到曝光的笔记仍计入笔记数,曝光为空; +- 覆盖率:有 `view_count` 的有效笔记数 / 完整笔记数。 + +同一 URL 在飞书可能被多条记录重复登记,主表按记录粒度保留全部行, +`url` 不再唯一;按 URL 读取时以最早 `id` 为规范行。 + +来源记录被删除时只将 `source_active` 置为 `FALSE`,保留审计历史,不物理删除。 + +## 调度 + +项目常驻 Python 调度器每天执行: + +- `09:00`:`content.notes_master.daily`(合并原 `content.cooperations.daily` + 与 `content.note_inventory.daily`,一次飞书拉取落全量字段) +- `10:00`:`content.marketing_report.daily` + +时间表位于 `config/schedules.json`,不创建 Windows Task Scheduler 任务。 + +也可以在工作流控制台搜索“笔记清单与合作同步”,点击“手动同步”。弹窗支持 +“同步预演”和“正式同步”;正式同步会先检查云端 PostgreSQL 运行时凭据, +再异步扫描全部来源表,进度、错误和运行历史均在控制台展示。 + +手动预演(只读飞书,不写数据库): + +```powershell +uv run gyxx scripts run content.notes_master.sync +``` + +正式执行: + +```powershell +uv run gyxx scripts run content.notes_master.sync --execute --arg=--execute +``` + +正式执行前,服务器运行环境必须提供 `PG_HOST`、`PG_PORT`、`PG_DB`、`PG_USER`、 +`PG_PASSWORD`。凭据只放在服务运行环境或外部 `GYXX_DATA_ROOT` 状态配置中,不写入源码。 + +## 覆盖率查询 + +```sql +SELECT + s.name AS style_name, + COUNT(DISTINCT n.url) AS note_count, + COUNT(DISTINCT n.url) FILTER (WHERE n.view_count IS NOT NULL) AS metric_note_count, + ROUND( + COUNT(DISTINCT n.url) FILTER (WHERE n.view_count IS NOT NULL)::numeric + / NULLIF(COUNT(DISTINCT n.url), 0), + 4 + ) AS metric_coverage_rate, + SUM(n.view_count) AS collected_exposure +FROM cmt_notes_master n +JOIN cmt_styles s ON s.id = n.style_id +WHERE n.source_active = TRUE + AND n.is_countable = TRUE +GROUP BY s.name +ORDER BY s.name; +``` + +## 数据迁移(007) + +`migrations/007_notes_master.sql` 执行内容: + +1. 创建 `cmt_notes_master`(笔记身份 + 合作字段 + 采集指标 + 审计列); +2. `cmt_notes` 行保留原 `id` 迁入(`cmt_comments.note_id` 零改值); +3. `cmt_note_inventory` 按 `(style_id, feishu_record_id)` 归并来源身份, + 未匹配行直接成为新行; +4. `cmt_cooperations` 按 `(style_id, feishu_record_id)` 回填合作字段, + 未匹配记录保留为 `source_active=FALSE` 的历史行; +5. `cmt_comments.note_id` 外键重指向主表,删除三张旧表。 diff --git a/docs/runbook.md b/docs/runbook.md index 44e2d64..67a0605 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -6,8 +6,14 @@ uv run gyxx doctor --json uv run gyxx schedule status uv run gyxx list +lark-cli --profile hermes-analyzer auth status --json --verify +lark-cli --profile hermes-analyzer auth check --scope "im:message.send_as_user im:message" --json ``` +两条 lark-cli 检查必须在实际运行调度服务的系统账户下执行。业务消息统一由该 profile 的 +user 身份投递;只有日报卡片的图片预上传因接口限制使用同 profile 的 bot 身份。不要用 +交互登录账户的授权结果代替 NSSM/systemd 服务账户验证。 + Linux 服务同时检查: ```bash @@ -27,7 +33,7 @@ uv run gyxx scripts run --date 2026-08-01 uv run gyxx scripts run --date 2026-08-01 --execute ``` -`gyxx run` 只接受 22 个调度工作流;补采和维护操作使用 `gyxx scripts run`。两类入口都默认 dry-run,只有业务日期、凭据、浏览器登录态、PostgreSQL 和对应 Hermes 角色全部确认后才使用 `--execute`。 +`gyxx run` 接受 23 个调度工作流和已注册的手动工作流;补采和维护操作使用 `gyxx scripts run`。两类入口都默认 dry-run,只有业务日期、凭据、浏览器登录态、PostgreSQL、lark-cli user 身份,以及工作流确实需要大模型时对应的 Hermes 角色全部确认后才使用 `--execute`。 | 手动场景 | 命令 ID | 日期行为 | |---|---|---| @@ -37,16 +43,106 @@ uv run gyxx scripts run --date 2026-08-01 --execute | 京东自营品牌单日回采 | `shop.jd_self_operated.collect_brand` | `--date` 自动成为起止日期 | | 商品历史补采 | `product.backfill.run` | `--date` 自动成为 `--from/--to` | | 商品评价补采 | `product.review.orchestrate` | `--date` 自动成为目标日期 | +| 天猫旗舰店和箱包店视频上传 | `product.video_upload.run` | 业务日期用于运行审计;默认顺序扫描两店,由 PostgreSQL 附件级账本防重 | +| 京东旗舰店视频上传 | `product.jd_video_upload.run` | 业务日期只用于运行审计;从固定凌云air记录锚点按飞书视图顺序扫描,日期可为空 | | 采购单更新 | `supply.workflow.run` | 自动选择 `mcp-run purchase-order-update` | 命令的额外脚本参数使用可重复的 `--arg=<值>` 传入。`supply.workflow.run --execute` 会进入真实 ERP 修改链路,必须在任务文件、目标范围和回滚条件全部复核后执行。 +### 天猫视频上传工作流 + +天猫视频上传注册为手动幂等工作流 `product.video_upload`,不会被常驻调度器自动触发。前端“立即运行”窗口会显示“光影行星旗舰店 / 光影行星鑫华达专卖店:龙虾仔”单选下拉框,并允许填写天猫话题完整名称或关键词;正式执行只处理所选店铺,话题搜索结果按包含关系选中。默认 dry-run 只生成运行记录并跳过浏览器节点;确认飞书待传记录、对应淘宝光合店铺的登录态和 PostgreSQL 后,再显式正式执行: + +```bash +uv run gyxx run product.video_upload --date 2026-08-07 +uv run gyxx run product.video_upload --date 2026-08-07 --execute +``` + +前端把店铺选择作为受限运行参数传给脚本,只允许 `flagship` 或 `luggage`。脚本先读取飞书并应用所选店铺路由,再写入/读取附件级账本;只有存在可领取的未上传附件时才启动有头 Chrome、登录对应账号并上传。旗舰店只选择“天猫上传”未勾选且有附件的光影行星男/女记录;箱包店只选择女性、全部款式命中箱包店白名单且有附件的记录。两店分别使用 `GUANGHE_USERNAME` / `GUANGHE_PASSWORD` 和 `GUANGHE_LUGGAGE_USERNAME` / `GUANGHE_LUGGAGE_PASSWORD`,箱包店当前账号为 `光影行星鑫华达专卖店:龙虾仔`,密码配置保持不变,并保留独立浏览器 Profile。发生登录或页面错误时保留窗口供人工定位,排查完成后手动关闭。 + +每次正式扫描都会把视频附件写入 PostgreSQL 附件级发布账本,防重身份由视频 `file_token + 目标账号 + 飞书记录` 构成。账本状态为 `published` 的附件在下次执行时自动跳过,因此同一业务日期可以安全重跑并发现新增附件。旗舰店仍只在一条飞书记录的全部附件发布成功后回写“天猫上传”;箱包店不借用该勾选状态,是否已上传完全以自己的目标账号账本为准。 + +首次正式执行会读取 `config/tmall-video-publication-backfill.json`,将 2026-07-26 箱包店旧日志中可按 `record_id + file_token + 文件名` 再次核验的 25 个明确成功附件回填为 `published`。配置与当前飞书任一身份不一致都会停止,不会静默猜测。该回填不依赖部署机保留旧 `var/` 日志文件;证据路径与成功行号已固化在配置中供审计。 + +只有淘宝光合页面出现明确成功回执后才把附件记为 `published`。点击发布后超时、浏览器中断或回执无法确认时,必须记为 `ambiguous` 并停止自动重发;先在对应店铺的作品管理中按账号、附件和飞书记录人工核对,再做审计式对账,不能通过更换业务日期或重复运行绕过。 + +单条续跑或指定附件使用公开命令,并通过可重复的 `--arg` 传参。下面的命令会停在正式发布前,核对无误后才能去掉 `--stop-before-publish`: + +```bash +uv run gyxx scripts run product.video_upload.run --date 2026-08-07 --arg=--record-id --arg=RECORD_ID --arg=--stop-before-publish --execute +``` + +### 京东视频上传工作流 + +京东视频上传注册为手动幂等工作流 `product.jd_video_upload`。它不会被常驻调度器自动触发;同一业务日期可以重复执行。每次都从飞书视图中的固定凌云air记录 `recvrXTRWWFXeJ`(含)扫描到视图末尾,不依赖“日期”字段。真正的防重键是 PostgreSQL 中的 `视频 file_token + 目标账号 + 飞书记录`: + +```bash +uv run gyxx run product.jd_video_upload --date 2026-08-07 +uv run gyxx run product.jd_video_upload --date 2026-08-07 --execute +``` + +框架 dry-run 不启动子进程,不下载附件、不写数据库也不打开京东。前端控制台点击“京东视频上传”会重新扫描锚点之后的视图后缀:新建的记录即使日期为空也能被发现;既有后缀记录后来新增第二个视频附件时,新 `file_token` 也会形成独立任务。已入账的同一 `record_id + file_token` 不会重复发布。锚点丢失或重复时脚本拒绝退化为全表扫描,避免误发历史内容。组合款在下载素材前直接跳过。正式执行按视图顺序逐条处理:有图片时设置飞书封面;没有图片时保留京东从视频生成的默认封面;生成 5-27 字标题、关联同款同色商品、选择相关话题和标签。只有页面出现明确成功回执后才把账本写为 `published`;点击发布后超时或浏览器中断会写为 `ambiguous` 并停止,必须先在京东内容管理中人工核对,不能自动重发。 + +商品关联只按页面商品标题中的款式名做受控模糊匹配,再由直连多模态大模型判断包体颜色;价格和 SPU 不参与查询或勾选。同款同色商品可多选,整个表单最多 10 个。SPU 仅作为可选审计信息;组合款视频当前主动跳过。 + +首次登录或遇到验证码时,使用公开命令限定一条记录并停在发布按钮前: + +```bash +uv run gyxx scripts run product.jd_video_upload.run --date 2026-08-07 --arg=--record-id --arg=RECORD_ID --arg=--manual-login --arg=--stop-before-publish --execute +``` + +首次部署或多京东账号部署必须显式配置稳定且不可变的 `JD_VIDEO_ACCOUNT_KEY`;已有单账号账本时,前端运行会从 JD 渠道唯一账号 cohort 自动解析,数据库不保存登录账号明文。浏览器由 Scrapling 隐身会话启动真实 Chrome,默认使用该命令独立的 Profile、Cookie 和 Storage State,不能改成其他京东采集任务的共享目录。需要复用一个已启动且已验证的京麦 Chrome 会话时,使用运行时绑定注入的 `GYXX_BROWSER_CDP_URL`;脚本只借用唯一的现有 context,并在退出时保留原浏览器及其页面。 + ### 主图周工作流 主图调度的唯一工作流 ID 是 `product.main_image.weekly`,每周日 08:30 启动。图同时运行 JD、TM 两个无依赖分支;一个平台失败不会取消、跳过或回滚另一个平台的采集、PG upsert 和飞书插入。两个分支全部结束后再汇总工作流状态:任一平台失败时整体状态为失败,并在错误摘要中标明具体平台和错误,另一平台已经成功的结果继续保留。 两个平台默认都在采集后执行飞书主图表插入和本地 PostgreSQL `main_image_creatives` upsert。执行前必须同时确认两套浏览器登录态、飞书身份和本地数据库;排障时分别检查 `jd`、`tmall` 节点以及对应插入脚本日志,不能只以采集文件存在判断双 sink 已完成。 +### 三平台电商费用日报 + +唯一工作流 ID 是 `product.ecommerce_costs.daily`,每天 10:00 由项目内 Python 调度器运行, +业务日期为前一天;天猫、京东、抖音三个下载分支并行启动,各自完成下载后再导入。天猫流程先进入万相台商品报表选择昨日,提交下载任务;任务在下载列表显示“生成成功” +后下载 ZIP、做路径安全校验并解压 CSV,随后将商品级广告指标幂等写入 +`product_daily_metrics.raw_data.tm_ad_metrics`。商品款式先按 `dim_style.tm_spus` 的商品 ID 匹配, +匹配不到再按商品名称做精确包含匹配,并在节点内保留 `style_name` 与 +`style_match_method`(`product_id`/`product_name`/`ambiguous`/`unmatched`)。它不会覆盖同一商品日期 +已有的访客、成交等经营日报字段;data-hub 产品生命进程从该节点读取广告成交金额和花费并计算投产比、电商费比。 + +正式服务必须注入 `WANXIANG_ACCOUNT`、`WANXIANG_PASSWORD`、`GYXX_DATA_ROOT` 和 +`GYXX_POSTGRES_DSN`。默认浏览器 Profile 是 +`\state\browser-profiles\wanxiang-ads`,登录失效时不能在另一个 Profile 中登录后 +期待服务自动获得状态。 + +手工验证或补采某一天时,先 dry-run,再显式执行完整工作流: + +```powershell +uv run gyxx run product.ecommerce_costs.daily --date 2026-08-17 +uv run gyxx run product.ecommerce_costs.daily --date 2026-08-17 --execute +``` + +如果登录态失效,使用有头的采集命令(不要给它加 `--headless`)完成验证码/滑块,再单独导入: + +```powershell +uv run gyxx scripts run product.tmall_wanxiang_ads.collect --date 2026-08-17 --execute +uv run gyxx scripts run product.import.tmall_ads --date 2026-08-17 --execute +``` + +原始文件位于 `\data\raw\product_commerce\tmall_wanxiang_ads\\`,其中 +`manifest.json` 记录 ZIP 和解压出的 CSV。定时工作流每次都会进入商品报表点击“下载报表”,在日期范围 +选择“昨日”并点击“确定”,再到下载列表等待“生成成功”。若只需重新解析已下载文件,可以跳过采集步骤 +重跑导入;默认导入以 manifest 列出的本次 CSV 为准,避免同一日期目录中历史 CSV 被重复合并;重复导入同一日期和商品不会产生重复行。采集命令手工重试时若不加 `--force-request`,仍可 +复用下载列表中已有的“生成成功”任务。 + +排障顺序: + +1. 检查 `gyxx schedule status`、服务日志和对应 `run_id`,确认失败发生在申请、下载、解压还是入库。 +2. 检查同一 Profile 是否仍能打开万相台商品报表;登录失效时在有头命令中人工完成验证。 +3. 检查日期目录中的 `manifest.json`、CSV 表头和 `日期/主体ID/主体名称`;ZIP 存在不代表数据库已导入。 +4. 检查 PostgreSQL 的 `product_daily_metrics` 是否存在 `platform='tm'`、目标日期和商品 ID,且 + `raw_data->'tm_ad_metrics'` 已更新;确认经营日报其他 JSON 节点仍保留。 +5. 只有在外部状态核对清楚后才按同一业务日期重跑,不能用换日期或重复申请掩盖下载结果不明。 + ## 调度操作 ```bash @@ -73,9 +169,37 @@ uv run gyxx schedule run --env-file /etc/gyxx-flow/gyxx-flow.env - `data/normalized`、`data/curated`:清洗、标准化和聚合数据。 - `data/exports`:Markdown、Excel 等交付文件。 - `state/browser// + diff --git a/src/gyxx_flow/workflow/engine.py b/src/gyxx_flow/workflow/engine.py index d93d54f..04eccf8 100644 --- a/src/gyxx_flow/workflow/engine.py +++ b/src/gyxx_flow/workflow/engine.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from typing import Callable, Literal from gyxx_flow.core.context import RunContext -from gyxx_flow.core.locks import LockManager +from gyxx_flow.core.locks import LockManager, ResourceBusyError from gyxx_flow.core.records import RunJournal from gyxx_flow.core.text import bounded_head_tail from gyxx_flow.ops.effects import ( @@ -203,6 +203,8 @@ class WorkflowEngine: self._effect_ledger.mark_applied(claim) elif result.status == "skipped": self._effect_ledger.cancel(claim) + elif result.reason == "resource-busy-before-execution": + self._effect_ledger.cancel(claim) else: self._effect_ledger.mark_ambiguous( claim, reason="step-did-not-succeed" @@ -221,12 +223,14 @@ class WorkflowEngine: for attempt in range(1, step.max_attempts + 1): journal_id = _attempt_journal_id(step.step_id, attempt) journal.start_step(journal_id, attempt=attempt) + action_started = False try: with ExitStack() as stack: for resource in sorted(step.resources): stack.enter_context( self._lock_manager.acquire(resource, owner=context.run_id) ) + action_started = True outcome = step.action.execute( context=context, timeout_seconds=step.timeout_seconds, @@ -234,6 +238,16 @@ class WorkflowEngine: ) if not isinstance(outcome, StepExecution): raise TypeError("step action must return StepExecution") + except ResourceBusyError as exc: + outcome = StepExecution( + exit_code=1, + error=f"ResourceBusyError: {exc}", + reason=( + None + if action_started + else "resource-busy-before-execution" + ), + ) except Exception as exc: # action and resource failures are retryable outcome = StepExecution( exit_code=1, diff --git a/src/gyxx_flow/workflow/graph.py b/src/gyxx_flow/workflow/graph.py index 31081b4..6a419d7 100644 --- a/src/gyxx_flow/workflow/graph.py +++ b/src/gyxx_flow/workflow/graph.py @@ -79,17 +79,39 @@ def compile_workflow_graph( *, run_step: GraphStepRunner, ) -> CompiledStateGraph: - """Compile dependencies into a real LangGraph DAG.""" + """Compile dependencies into a real LangGraph DAG. + + LangGraph executes all nodes in one Pregel superstep before starting the + next one. That global barrier is correct for a join, but it is too coarse + for independent linear pipelines: a downstream step in one pipeline + would otherwise wait for unrelated roots in other pipelines. Collapse + each linear segment into its first node's runner so its steps can advance + immediately, while retaining the step nodes and dependency edges in the + public graph for inspection and for joins. + """ builder = StateGraph(WorkflowGraphState) ordered_steps = workflow.ordered_steps() + dependents = {step.step_id: [] for step in ordered_steps} + for step in ordered_steps: + for dependency in step.depends_on: + dependents[dependency].append(step.step_id) + + chains = _linear_chains(ordered_steps, dependents) + chain_by_start = {chain[0].step_id: chain for chain in chains} dependent_ids = { dependency for step in ordered_steps for dependency in step.depends_on } for step in ordered_steps: - builder.add_node(step.step_id, _step_node(step, run_step)) + chain = chain_by_start.get(step.step_id) + builder.add_node( + step.step_id, + _chain_node(chain, run_step) + if chain is not None + else _step_node(step, run_step), + ) if step.depends_on: builder.add_edge(list(step.depends_on), step.step_id) else: @@ -99,21 +121,98 @@ def compile_workflow_graph( return builder.compile() +def _linear_chains( + ordered_steps: tuple[StepDefinition, ...], + dependents: dict[str, list[str]], +) -> tuple[tuple[StepDefinition, ...], ...]: + """Find maximal dependency segments safe to advance without a join.""" + + steps_by_id = {step.step_id: step for step in ordered_steps} + starts = [ + step + for step in ordered_steps + if _is_linear_chain_start(step, dependents) + ] + chains: list[tuple[StepDefinition, ...]] = [] + assigned: set[str] = set() + for start in starts: + if start.step_id in assigned: + continue + chain: list[StepDefinition] = [] + current = start + while current.step_id not in assigned: + chain.append(current) + assigned.add(current.step_id) + children = dependents[current.step_id] + if len(children) != 1: + break + child = steps_by_id[children[0]] + if len(child.depends_on) != 1: + break + current = child + chains.append(tuple(chain)) + + # WorkflowDefinition already rejects cycles, so this is only a defensive + # guard for future changes to the graph construction above. + if len(assigned) != len(ordered_steps): + missing = [step.step_id for step in ordered_steps if step.step_id not in assigned] + raise ValueError(f"unable to partition workflow steps into chains: {missing}") + return tuple(chains) + + +def _is_linear_chain_start( + step: StepDefinition, + dependents: dict[str, list[str]], +) -> bool: + if len(step.depends_on) != 1: + return True + parent_id = step.depends_on[0] + return len(dependents[parent_id]) != 1 + + def _step_node( step: StepDefinition, run_step: GraphStepRunner, ) -> Callable[[WorkflowGraphState], dict[str, object]]: def execute(state: WorkflowGraphState) -> dict[str, object]: + if step.step_id in state["step_results"]: + return {"step_results": {}} result = run_step(step, state) return {"step_results": {step.step_id: result}} return execute +def _chain_node( + chain: tuple[StepDefinition, ...] | None, + run_step: GraphStepRunner, +) -> Callable[[WorkflowGraphState], dict[str, object]]: + if not chain: + raise ValueError("linear chain must contain at least one step") + + def execute(state: WorkflowGraphState) -> dict[str, object]: + local_state = dict(state) + local_results = dict(state["step_results"]) + for step in chain: + if step.step_id in local_results: + continue + result = run_step( + step, + { + **local_state, + "step_results": local_results, + }, + ) + local_results[step.step_id] = result + return {"step_results": local_results} + + return execute + + def _merge_step_results( current: dict[str, GraphStepResult], update: dict[str, GraphStepResult], ) -> dict[str, GraphStepResult]: - """Merge branch-local results emitted in the same LangGraph superstep.""" + """Merge branch-local and eagerly advanced chain results.""" return {**current, **update} diff --git a/src/gyxx_flow/workflow/model.py b/src/gyxx_flow/workflow/model.py index 685f314..298c2b5 100644 --- a/src/gyxx_flow/workflow/model.py +++ b/src/gyxx_flow/workflow/model.py @@ -16,7 +16,11 @@ if TYPE_CHECKING: _ID = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$") _RESOURCE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:._-]*$") -ReplayPolicy = Literal["guarded", "idempotent"] +# ``repeatable`` is reserved for production actions that intentionally submit +# on every invocation. Unlike ``guarded``, it does not use the business-date +# keyed effect ledger; unlike ``idempotent``, it does not claim that the +# external action itself is idempotent. +ReplayPolicy = Literal["guarded", "idempotent", "repeatable"] class WorkflowValidationError(ValueError): @@ -100,7 +104,7 @@ class StepDefinition: raise WorkflowValidationError( f"run_after_failure must be boolean for step {self.step_id!r}" ) - if self.replay_policy not in {"guarded", "idempotent"}: + if self.replay_policy not in {"guarded", "idempotent", "repeatable"}: raise WorkflowValidationError( f"invalid replay_policy for step {self.step_id!r}: " f"{self.replay_policy!r}" diff --git a/src/gyxx_flow/workflow/steps.py b/src/gyxx_flow/workflow/steps.py index 8b8b9c1..5c18858 100644 --- a/src/gyxx_flow/workflow/steps.py +++ b/src/gyxx_flow/workflow/steps.py @@ -14,7 +14,10 @@ from typing import IO, Mapping import psutil from gyxx_flow.core.context import RunContext -from gyxx_flow.core.exit_codes import COOKIE_SKIP_EXIT_CODE +from gyxx_flow.core.exit_codes import ( + COOKIE_SKIP_EXIT_CODE, + IDEMPOTENT_REPLAY_EXIT_CODE, +) from gyxx_flow.core.text import bounded_head_tail _CREATE_SUSPENDED = 0x00000004 @@ -226,6 +229,15 @@ class CommandStep: reason="cookie-preflight", ) + if return_code == IDEMPOTENT_REPLAY_EXIT_CODE: + return StepExecution( + exit_code=return_code, + stdout=stdout, + stderr=stderr, + skipped=True, + reason="idempotent-replay", + ) + error = None if return_code != 0: error = _command_failure_detail( @@ -249,9 +261,16 @@ def _start_process( stdout: IO[str], stderr: IO[str], ) -> tuple[subprocess.Popen[str], _WindowsJob | None]: + effective_env = dict(env) + if os.name == "nt": + # Python launched with redirected stdio can otherwise inherit the + # Windows ANSI code page (typically GBK), while the parent streams + # are opened as UTF-8. Keep child diagnostics readable in journals + # and the console, especially for Chinese business errors. + effective_env.setdefault("PYTHONIOENCODING", "utf-8") common = { "cwd": cwd, - "env": env, + "env": effective_env, "shell": False, "stdout": stdout, "stderr": stderr, diff --git a/tests/modules/content_marketing/test_bilibili_play_count_retry.py b/tests/modules/content_marketing/test_bilibili_play_count_retry.py new file mode 100644 index 0000000..2c9cbce --- /dev/null +++ b/tests/modules/content_marketing/test_bilibili_play_count_retry.py @@ -0,0 +1,339 @@ +import json +from types import SimpleNamespace + +import pytest + +from gyxx_flow.modules.content_marketing import bilibili_scraper as bili +from gyxx_flow.modules.content_marketing import scrapling_http +from gyxx_flow.modules.content_marketing import self_bilibili_scraper as self_bili + + +class FakeResponse: + def __init__( + self, + *, + status: int = 200, + payload: dict | None = None, + text: str | None = None, + content_type: str = "application/json; charset=utf-8", + json_error: ValueError | None = None, + ) -> None: + self.status_code = status + self._payload = payload + self.text = json.dumps(payload) if text is None and payload is not None else (text or "") + self.content = self.text.encode("utf-8") + self.headers = {"content-type": content_type} + self._json_error = json_error + + def json(self): + if self._json_error is not None: + raise self._json_error + return self._payload + + +class SequenceSession: + def __init__(self, outcomes): + self.outcomes = list(outcomes) + self.calls = [] + + def get(self, url, **kwargs): + self.calls.append((url, kwargs)) + outcome = self.outcomes.pop(0) + if isinstance(outcome, BaseException): + raise outcome + return outcome + + +def test_scrapling_http_session_maps_fetcher_response(monkeypatch): + calls = [] + + def get(url, **kwargs): + calls.append((url, kwargs)) + return SimpleNamespace( + status=200, + headers={"content-type": "application/json"}, + url="https://www.bilibili.com/video/BV17NNw6jEPx", + body=b'{"code": 0}', + encoding="utf-8", + ) + + monkeypatch.setattr(scrapling_http.Fetcher, "get", get) + session = scrapling_http.ScraplingHttpSession(headers={"User-Agent": "gyxx"}) + + response = session.get( + "https://b23.tv/demo", + allow_redirects=True, + timeout=10, + ) + + assert calls == [( + "https://b23.tv/demo", + { + "headers": {"User-Agent": "gyxx"}, + "follow_redirects": "all", + "retries": 1, + "timeout": 10, + }, + )] + assert response.status_code == 200 + assert response.json() == {"code": 0} + assert response.url.endswith("BV17NNw6jEPx") + + +def test_self_bilibili_uses_scrapling_user_agent_instead_of_stale_override(): + session = SequenceSession([ + FakeResponse( + payload={ + "code": 0, + "data": {"stat": {"view": 456}, "pubdate": 1_700_000_000}, + } + ) + ]) + + result = self_bili.fetch_bili_stat( + "https://www.bilibili.com/video/BV17NNw6jEPx", + session, + ) + + assert result == (456, 1_700_000_000) + assert session.calls == [( + "https://api.bilibili.com/x/web-interface/view", + { + "params": {"bvid": "BV17NNw6jEPx"}, + "timeout": 10, + "headers": {"Referer": "https://www.bilibili.com/"}, + }, + )] + + +def success_response(view: int = 321) -> FakeResponse: + return FakeResponse(payload={"code": 0, "message": "OK", "data": {"stat": {"view": view}}}) + + +def detail_success_response(view: int = 321) -> FakeResponse: + return FakeResponse( + payload={"code": 0, "message": "OK", "data": {"View": {"stat": {"view": view}}}} + ) + + +def test_empty_body_retries_then_returns_play_count(monkeypatch, capsys): + session = SequenceSession([FakeResponse(text=""), success_response(456)]) + sleeps = [] + monkeypatch.setattr(bili.time, "sleep", sleeps.append) + + result = bili.fetch_play_count( + "https://www.bilibili.com/video/BV17NNw6jEPx", + session, + ) + + assert result == 456 + assert len(session.calls) == 2 + assert sleeps == [1.5] + assert "[empty_body]" in capsys.readouterr().out + + +@pytest.mark.parametrize( + ("first_response", "category"), + [ + (FakeResponse(status=412, text="request blocked", content_type="text/plain"), "http_412"), + (FakeResponse(payload={"code": -509, "message": "request limit"}), "api_code_-509"), + ( + FakeResponse( + text="temporary gateway page", + content_type="text/html", + json_error=ValueError("not json"), + ), + "invalid_json", + ), + ], +) +def test_transient_response_classes_are_retried( + monkeypatch, capsys, first_response, category +): + session = SequenceSession([first_response, success_response()]) + monkeypatch.setattr(bili.time, "sleep", lambda _seconds: None) + + result = bili.fetch_play_count( + "https://www.bilibili.com/video/BV17NNw6jEPx", + session, + ) + + assert result == 321 + assert len(session.calls) == 2 + assert f"[{category}]" in capsys.readouterr().out + + +def test_non_retryable_api_code_stops_after_one_attempt(monkeypatch, capsys): + session = SequenceSession( + [FakeResponse(payload={"code": -404, "message": "video not found"})] + ) + monkeypatch.setattr( + bili.time, + "sleep", + lambda _seconds: pytest.fail("terminal API result must not back off"), + ) + + result = bili.fetch_play_count( + "https://www.bilibili.com/video/BV17NNw6jEPx", + session, + ) + + assert result is None + assert len(session.calls) == 1 + assert "[api_code_-404]" in capsys.readouterr().out + + +def test_transient_failures_exhaust_three_attempts_honestly(monkeypatch, capsys): + session = SequenceSession( + [ + *[FakeResponse(text="") for _ in range(3)], + FakeResponse(status=412, text="fallback blocked", content_type="text/plain"), + ] + ) + sleeps = [] + monkeypatch.setattr(bili.time, "sleep", sleeps.append) + + result = bili.fetch_play_count( + "https://www.bilibili.com/video/BV17NNw6jEPx", + session, + ) + + assert result is None + assert len(session.calls) == 4 + assert sleeps == [1.5, 3.0] + output = capsys.readouterr().out + assert "attempt=3/3" in output + assert "[view_detail_http_412]" in output + + +def test_view_detail_is_used_once_after_primary_transient_exhaustion( + monkeypatch, capsys +): + session = SequenceSession( + [*[FakeResponse(text="") for _ in range(3)], detail_success_response(789)] + ) + monkeypatch.setattr(bili.time, "sleep", lambda _seconds: None) + + result = bili.fetch_play_count( + "https://www.bilibili.com/video/BV17NNw6jEPx", + session, + ) + + assert result == 789 + assert len(session.calls) == 4 + assert session.calls[-1][0] == bili._VIEW_DETAIL_API_URL + assert "[FALLBACK]" in capsys.readouterr().out + + +def test_play_count_failure_remains_an_incomplete_summary(monkeypatch): + style = { + "style": "款式A", + "name": "款式A", + "index": 1, + "base_token": "base", + "table_id": "table", + "field_map": { + "platform": {"field_id": "platform"}, + "note_url": {"field_id": "url"}, + "creator_name": {"field_id": "creator"}, + "publish_time": {"field_id": "pub"}, + "read_count_7d": {"field_id": "s7"}, + "read_count_14d": {"field_id": "s14"}, + "read_count_21d": {"field_id": "s21"}, + "read_count_28d": {"field_id": "s28"}, + "month_end": {"field_id": "sm"}, + }, + } + monkeypatch.setattr( + bili, + "list_all_records", + lambda *_args: [{ + "record_id": "failed", + "platform": "B站", + "creator": "达人", + "url": "https://www.bilibili.com/video/BV17NNw6jEPx", + "pub": "2026-08-08", + }], + ) + monkeypatch.setattr(bili, "fetch_play_count", lambda *_args: None) + monkeypatch.setattr( + bili, + "write_record", + lambda *_args, **_kwargs: pytest.fail("failed fetch must not write"), + ) + + summary = bili.process_style( + style, + only_record_ids=None, + dry_run=True, + delay=0, + session=object(), + state={}, + force_today=bili.date(2026, 8, 9), + first_run=False, + ) + + assert summary["complete"] is False + assert summary["retryable_failures"] == 1 + assert summary["details"][0]["reason"] == "play_count_fetch_failed" + + +def test_terminal_unavailable_detail_is_a_complete_blocked_input(monkeypatch): + style = { + "style": "款式A", + "name": "款式A", + "index": 1, + "base_token": "base", + "table_id": "table", + "field_map": { + "platform": {"field_id": "platform"}, + "note_url": {"field_id": "url"}, + "creator_name": {"field_id": "creator"}, + "publish_time": {"field_id": "pub"}, + "read_count_7d": {"field_id": "s7"}, + "read_count_14d": {"field_id": "s14"}, + "read_count_21d": {"field_id": "s21"}, + "read_count_28d": {"field_id": "s28"}, + "month_end": {"field_id": "sm"}, + }, + } + monkeypatch.setattr( + bili, + "list_all_records", + lambda *_args: [{ + "record_id": "unavailable", + "platform": "B站", + "creator": "达人", + "url": "https://www.bilibili.com/video/BV17NNw6jEPx", + "pub": "2026-08-08", + }], + ) + monkeypatch.setattr(bili.time, "sleep", lambda _seconds: None) + monkeypatch.setattr( + bili, + "write_record", + lambda *_args, **_kwargs: pytest.fail("unavailable video must not write"), + ) + session = SequenceSession([ + *[FakeResponse(status=412, text="request blocked") for _ in range(3)], + FakeResponse(payload={"code": 62002, "message": "稿件不可见", "data": None}), + ]) + + summary = bili.process_style( + style, + only_record_ids=None, + dry_run=True, + delay=0, + session=session, + state={}, + force_today=bili.date(2026, 8, 9), + first_run=False, + ) + + assert len(session.calls) == 4 + assert summary["complete"] is True + assert summary["blocked_input"] == 1 + assert summary["retryable_failures"] == 0 + assert summary["unresolved"] == 0 + assert summary["details"][0]["status"] == "blocked_input" + assert summary["details"][0]["reason"] == "video_unavailable" diff --git a/tests/modules/content_marketing/test_buyin_metric_snapshots.py b/tests/modules/content_marketing/test_buyin_metric_snapshots.py new file mode 100644 index 0000000..3645686 --- /dev/null +++ b/tests/modules/content_marketing/test_buyin_metric_snapshots.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import datetime as dt +from argparse import Namespace +from pathlib import Path + +import pytest + +from gyxx_flow.modules.content_marketing import run_all +from gyxx_flow.modules.content_marketing import xingtu_scraper_v2 as xingtu +from gyxx_flow.modules.content_marketing.data.tools import retry_failed +from gyxx_flow.modules.content_marketing.data.tools import ( + sync_metrics_to_cmt_notes as sync_metrics, +) + + +@pytest.fixture(autouse=True) +def reset_content_source(): + xingtu.configure_source("xingtu") + yield + xingtu.configure_source("xingtu") + + +def test_buyin_source_uses_doudian_market_and_cookie_scope(): + xingtu.configure_source("buyin") + + assert xingtu.CONTENT_SOURCE == "buyin" + assert xingtu.MARKET_URL.startswith( + "https://buyin.jinritemai.com/dashboard/servicehall/daren-square" + ) + assert xingtu.DETAIL_URL_MARK == "/dashboard/servicehall/daren-profile" + assert xingtu.BUSINESS_COOKIE_DOMAINS == ("jinritemai.com",) + + +def test_save_state_creates_cookie_parent_directory(tmp_path, monkeypatch): + cookie_file = tmp_path / "nested" / "cookies.json" + monkeypatch.setattr(xingtu, "DATA_DIR", tmp_path / "raw") + monkeypatch.setattr(xingtu, "COOKIE_FILE", cookie_file) + + class Context: + def cookies(self): + return [{"name": "sessionid", "value": "redacted"}] + + class Page: + context = Context() + + xingtu.save_state(Page()) + + assert cookie_file.exists() + assert "sessionid" in cookie_file.read_text(encoding="utf-8") + + +def test_run_all_keeps_cooperation_xt_on_xingtu_for_buyin_fallback(): + args = Namespace( + dry_run=False, + style=None, + daily_scope=False, + no_retry=False, + ) + + command = run_all.build_process_command("xt", args) + + assert "--buyin" not in command + assert "--skip-field-prepare" in command + + +def test_retry_failed_keeps_cooperation_xt_on_xingtu_for_buyin_fallback(): + assert retry_failed.SCRIPTS["xt"]["extra_args"] == [] + + +def test_buyin_video_parser_is_dom_html_based_and_maps_viewers_to_exposure(): + xingtu.configure_source("buyin") + + class Page: + def __init__(self): + self.script = "" + + def evaluate(self, script): + self.script = script + return [{ + "title": "#达人笔记标题", + "play_count": "1,155", + "view_count": "1,155", + "exposure_count": "1,155", + }] + + page = Page() + cards = xingtu.parse_videos_on_page(page) + + assert cards[0]["play_count"] == "1,155" + assert cards[0]["exposure_count"] == "1,155" + assert "观看人数" in page.script + assert "innerText" in page.script + + +def test_buyin_history_tab_and_trade_video_filter_are_selected(): + xingtu.configure_source("buyin") + events = [] + filter_states = {"只看带货视频": True, "只看带货图文": True} + + class Locator: + first = None + + def __init__(self, name): + self.name = name + self.first = self + + def count(self): + return 1 + + def filter(self, **kwargs): + return Locator(kwargs.get("has_text", self.name)) + + def click(self, **_kwargs): + events.append(f"click-{self.name}") + if self.name in filter_states: + filter_states[self.name] = False + + class Page: + def get_by_text(self, text, **_kwargs): + return Locator(text) + + def get_by_role(self, _role, name, **_kwargs): + return Locator(name) + + def locator(self, _selector): + return Locator("selector") + + def evaluate(self, script, *_args): + events.append(script) + if _args and _args[0] in filter_states: + return filter_states[_args[0]] + return False + + def wait_for_timeout(self, milliseconds): + events.append(milliseconds) + + assert xingtu.open_creation_ability(Page()) is True + assert "click-历史内容" in events + assert "click-近30天" in events + assert "click-视频" in events + assert "click-只看带货视频" in events + assert "click-只看带货图文" in events + assert any("scrollIntoView" in event for event in events if isinstance(event, str)) + + +def test_buyin_creator_search_clicks_real_button_and_waits_for_changed_results( + monkeypatch, +): + xingtu.configure_source("buyin") + events = [] + + class Locator: + def __init__(self, kind): + self.kind = kind + self.first = self + + def count(self): + return 1 + + def wait_for(self, **_kwargs): + return None + + def click(self, **_kwargs): + events.append(f"click-{self.kind}") + + def fill(self, value): + events.append(f"fill-{value}") + + def press(self, key): + events.append(f"press-{key}") + + class Page: + def locator(self, selector): + if selector.startswith("button.auxo-input-search-button"): + return Locator("button") + return Locator("input") + + def evaluate(self, _script): + return "idle" + + def get_by_role(self, *_args, **_kwargs): + raise AssertionError("Buyin should use the real search button first") + + def wait_for_timeout(self, _milliseconds): + return None + + monkeypatch.setattr(xingtu, "dismiss_popups", lambda _page: None) + monkeypatch.setattr(xingtu, "select_nickname_search", lambda _page: None) + monkeypatch.setattr(xingtu, "_buyin_result_page_signature", lambda _page: "before") + observed = {} + + def wait_for_results(_page, _name, timeout_ms, previous_signature=None): + observed.update(timeout_ms=timeout_ms, previous_signature=previous_signature) + + monkeypatch.setattr(xingtu, "wait_for_visible_creator_result", wait_for_results) + + xingtu.search_creator(Page(), "linyl燕", "LinFX2327") + + assert events == ["click-input", "fill-LinFX2327", "click-button"] + assert observed == {"timeout_ms": 30000, "previous_signature": "before"} + + +def test_buyin_search_button_waits_until_loading_finishes(): + xingtu.configure_source("buyin") + states = iter(["busy", "busy", "idle"]) + waits = [] + + class Page: + def evaluate(self, _script): + return next(states) + + def wait_for_timeout(self, milliseconds): + waits.append(milliseconds) + + xingtu._wait_for_buyin_search_button_idle(Page(), timeout_ms=1000) + + assert waits == [250, 250] + + +def test_buyin_wait_distinguishes_temporary_search_throttling(): + xingtu.configure_source("buyin") + + class Page: + def evaluate(self, *_args): + return "throttled" + + def wait_for_timeout(self, _milliseconds): + pytest.fail("throttling must return immediately") + + with pytest.raises(xingtu.SearchTemporarilyThrottled, match="过于频繁"): + xingtu.wait_for_visible_creator_result(Page(), "LinFX2327", timeout_ms=1000) + + +def test_buyin_creator_scan_supports_single_row_without_profile_href(): + script = xingtu._buyin_scan_creator_script() + + assert "tr[data-row-key], [role=\"row\"][data-row-key]" in script + assert "rowKey: row.getAttribute('data-row-key')" in script + assert "via: 'single-search-result'" in script + assert "first-id-search-result" not in script + + +def test_buyin_next_page_waits_for_a_changed_html_signature(): + xingtu.configure_source("buyin") + signatures = iter(["1/4|first-card", "2/4|second-card"]) + + class Page: + def evaluate(self, script): + if "const selectors" in script: + return {"clicked": True, "picked": "semi-page-next"} + return next(signatures) + + def locator(self, _selector): + class Locator: + first = None + + def __init__(self): + self.first = self + + def click(self, **_kwargs): + return None + + return Locator() + + def wait_for_timeout(self, _milliseconds): + return None + + assert xingtu.go_next_page(Page()) is True + + +def test_metric_snapshot_date_prefers_orchestrator_business_date(monkeypatch): + monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-08-31") + + assert sync_metrics._resolve_metric_date() == dt.date(2026, 8, 31) + assert sync_metrics._resolve_metric_date("2026-08-30") == dt.date(2026, 8, 30) + with pytest.raises(ValueError, match="YYYY-MM-DD"): + sync_metrics._resolve_metric_date("not-a-date") + + +def test_metric_snapshot_upsert_is_idempotent_by_note_and_business_date(): + class Cursor: + def __init__(self): + self.calls = [] + + def execute(self, sql, params): + self.calls.append((" ".join(sql.split()), params)) + + cursor = Cursor() + sync_metrics._upsert_metric_snapshot( + cursor, + 42, + { + "view_count": 1155, + "dst_platform": "douyin", + "self_operated": False, + "record_id": "rec-1", + "source_url": "https://www.douyin.com/video/42", + }, + dt.date(2026, 8, 31), + ) + + sql, params = cursor.calls[0] + assert "INSERT INTO cmt_note_metric_snapshots" in sql + assert "ON CONFLICT (note_id, metric_date)" in sql + assert params[0:2] == (42, dt.date(2026, 8, 31)) + assert params[6] == 1155 + + +def test_snapshot_schema_and_report_query_use_daily_history(): + root = Path(__file__).resolve().parents[3] + schema = ( + root + / "src" + / "gyxx_flow" + / "modules" + / "content_marketing" + / "data" + / "tools" + / "schema_gyxx_super_data.sql" + ).read_text(encoding="utf-8") + migration = ( + root + / "src" + / "gyxx_flow" + / "modules" + / "content_marketing" + / "data" + / "tools" + / "migrations" + / "008_note_metric_snapshots.sql" + ).read_text(encoding="utf-8") + report = ( + root + / "src" + / "gyxx_flow" + / "modules" + / "content_marketing" + / "daily_marketing_report.py" + ).read_text(encoding="utf-8") + + for source in (schema, migration): + assert "CREATE TABLE IF NOT EXISTS cmt_note_metric_snapshots" in source + assert "UNIQUE (note_id, metric_date)" in source + assert "COALESCE(ms.view_count, n.view_count)" in report + assert "ms.metric_date = %s" in report diff --git a/tests/modules/content_marketing/test_chanmama_refresh_before_export.py b/tests/modules/content_marketing/test_chanmama_refresh_before_export.py index dd8bcf2..759b891 100644 --- a/tests/modules/content_marketing/test_chanmama_refresh_before_export.py +++ b/tests/modules/content_marketing/test_chanmama_refresh_before_export.py @@ -1,7 +1,14 @@ +from datetime import datetime +from types import SimpleNamespace + import pytest from gyxx_flow.core.exit_codes import COOKIE_SKIP_EXIT_CODE from gyxx_flow.modules.content_marketing import chanmama_scraper as chanmama +from gyxx_flow.modules.content_marketing import feishu_mapping +from gyxx_flow.modules.content_marketing import ( + scrapling_browser_facade as browser_facade, +) def test_exposure_state_clicks_text_and_zero_but_keeps_positive_values(): @@ -152,6 +159,277 @@ def test_missing_get_data_button_does_not_trigger_refresh_wait(monkeypatch): assert sleeps == [] +def test_create_driver_uses_runtime_cdp_profile_and_download_bindings(monkeypatch, tmp_path): + calls = [] + expected = object() + monkeypatch.setattr(chanmama, "DOWNLOAD_DIR", str(tmp_path / "downloads")) + monkeypatch.setattr( + chanmama, + "PATHS", + SimpleNamespace(browser_profile_dir=tmp_path / "profile"), + ) + monkeypatch.setenv("GYXX_BROWSER_CDP_URL", "http://127.0.0.1:22002") + monkeypatch.setenv("GYXX_BROWSER_CDP_PORT", "22002") + + def start(**kwargs): + calls.append(kwargs) + return expected + + monkeypatch.setattr(chanmama, "ScraplingDriver", SimpleNamespace(start=start)) + + assert chanmama.create_driver() is expected + assert calls == [{ + "profile_dir": str(tmp_path / "profile"), + "download_dir": str(tmp_path / "downloads"), + "cdp_url": "http://127.0.0.1:22002", + "stealthy": True, + }] + + +def test_create_driver_starts_scrapling_without_cdp(monkeypatch, tmp_path): + calls = [] + expected = object() + monkeypatch.setattr(chanmama, "DOWNLOAD_DIR", str(tmp_path / "downloads")) + monkeypatch.setattr( + chanmama, + "PATHS", + SimpleNamespace(browser_profile_dir=tmp_path / "profile"), + ) + monkeypatch.delenv("GYXX_BROWSER_CDP_URL", raising=False) + + def start(**kwargs): + calls.append(kwargs) + return expected + + monkeypatch.setattr(chanmama, "ScraplingDriver", SimpleNamespace(start=start)) + + assert chanmama.create_driver() is expected + assert calls[0]["cdp_url"] is None + assert calls[0]["stealthy"] is True + assert calls[0]["real_chrome"] is False + + +def test_create_driver_falls_back_when_bound_cdp_is_not_listening( + monkeypatch, + tmp_path, +): + calls = [] + expected = object() + monkeypatch.setattr(chanmama, "DOWNLOAD_DIR", str(tmp_path / "downloads")) + monkeypatch.setattr( + chanmama, + "PATHS", + SimpleNamespace(browser_profile_dir=tmp_path / "profile"), + ) + monkeypatch.setenv("GYXX_BROWSER_CDP_URL", "http://127.0.0.1:22002") + monkeypatch.setenv("GYXX_BROWSER_CDP_PORT", "22002") + + def start(**kwargs): + calls.append(kwargs) + if kwargs["cdp_url"]: + raise RuntimeError( + "BrowserType.connect_over_cdp: connect ECONNREFUSED " + "127.0.0.1:22002; retrieving websocket url" + ) + return expected + + monkeypatch.setattr(chanmama, "ScraplingDriver", SimpleNamespace(start=start)) + + assert chanmama.create_driver() is expected + assert [call["cdp_url"] for call in calls] == [ + "http://127.0.0.1:22002", + None, + ] + assert all(call["stealthy"] is True for call in calls) + assert calls[1]["real_chrome"] is False + + +def test_create_driver_keeps_explicit_remote_cdp_fail_closed( + monkeypatch, + tmp_path, +): + monkeypatch.setattr(chanmama, "DOWNLOAD_DIR", str(tmp_path / "downloads")) + monkeypatch.setattr( + chanmama, + "PATHS", + SimpleNamespace(browser_profile_dir=tmp_path / "profile"), + ) + monkeypatch.setenv("GYXX_BROWSER_CDP_URL", "http://browser.internal:22002") + monkeypatch.setenv("GYXX_BROWSER_CDP_PORT", "22002") + monkeypatch.setattr( + chanmama, + "ScraplingDriver", + SimpleNamespace( + start=lambda **_kwargs: (_ for _ in ()).throw( + RuntimeError("connect ECONNREFUSED browser.internal:22002") + ) + ), + ) + + with pytest.raises(RuntimeError, match="ECONNREFUSED"): + chanmama.create_driver() + + +def test_create_driver_does_not_hide_non_connection_cdp_failures( + monkeypatch, + tmp_path, +): + monkeypatch.setattr(chanmama, "DOWNLOAD_DIR", str(tmp_path / "downloads")) + monkeypatch.setattr( + chanmama, + "PATHS", + SimpleNamespace(browser_profile_dir=tmp_path / "profile"), + ) + monkeypatch.setenv("GYXX_BROWSER_CDP_URL", "http://127.0.0.1:22002") + monkeypatch.setattr( + chanmama, + "ScraplingDriver", + SimpleNamespace( + start=lambda **_kwargs: (_ for _ in ()).throw( + RuntimeError("browser profile is corrupt") + ) + ), + ) + + with pytest.raises(RuntimeError, match="profile is corrupt"): + chanmama.create_driver() + + +def test_scrapling_facade_reuses_runtime_cdp_context(monkeypatch, tmp_path): + created = [] + + class Page: + def on(self, *_args): + return None + + def add_init_script(self, script): + assert "navigator" in script + + class Context: + pages = [Page()] + + class Browser: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.context = Context() + self.page = None + self.closed = False + created.append(self) + + def start(self): + return self + + def close(self): + self.closed = True + + monkeypatch.setattr(browser_facade, "ScraplingBrowser", Browser) + + driver = browser_facade.ScraplingDriver.start( + profile_dir=tmp_path / "profile", + download_dir=tmp_path / "downloads", + cdp_url="http://127.0.0.1:22002", + ) + + assert created[0].kwargs["reuse_existing_cdp_context"] is True + assert created[0].kwargs["cdp_url"] == "http://127.0.0.1:22002" + assert "user_data_dir" not in created[0].kwargs + assert "--no-sandbox" not in created[0].kwargs["extra_flags"] + assert created[0].page is created[0].context.pages[0] + driver.quit() + assert created[0].closed is True + + +def _one_backfill_style(): + return { + "tables": [ + { + "index": 1, + "name": "宙斯", + "base_token": "base", + "table_id": "table", + "field_map": { + "platform": {"field_id": "platform"}, + "note_title": {"field_id": "title"}, + "publish_time": {"field_id": "published"}, + "read_count_7d": { + "field_id": "read-7d", + "field_name": "7日阅读量", + }, + }, + } + ] + } + + +def test_backfill_mapping_or_record_list_failure_returns_false(monkeypatch, tmp_path): + monkeypatch.setattr( + feishu_mapping, + "load_mapping", + lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("mapping down")), + ) + assert chanmama.backfill_self_tables([]) is False + + monkeypatch.setattr( + feishu_mapping, "load_mapping", lambda **_kwargs: {"tables": []} + ) + assert chanmama.backfill_self_tables([]) is False + + monkeypatch.setattr( + feishu_mapping, "load_mapping", lambda **_kwargs: _one_backfill_style() + ) + assert chanmama.backfill_self_tables([], only_style=999) is False + monkeypatch.setattr( + chanmama, + "PATHS", + SimpleNamespace(normalized_root=tmp_path / "normalized"), + ) + monkeypatch.setattr( + chanmama, + "_list_records_by_table", + lambda *_args: (_ for _ in ()).throw(RuntimeError("base down")), + ) + + assert chanmama.backfill_self_tables([]) is False + + +def test_backfill_write_failure_is_false_but_natural_no_match_is_success( + monkeypatch, tmp_path +): + monkeypatch.setattr( + feishu_mapping, "load_mapping", lambda **_kwargs: _one_backfill_style() + ) + monkeypatch.setattr( + chanmama, + "PATHS", + SimpleNamespace(normalized_root=tmp_path / "normalized"), + ) + record = { + "record_id": "record-1", + "platform": "抖音", + "title": "相同标题", + "published": datetime.now(), + } + monkeypatch.setattr( + chanmama, "_list_records_by_table", lambda *_args: [record] + ) + monkeypatch.setattr(chanmama, "_write_back", lambda *_args: False) + + assert chanmama.backfill_self_tables( + [{"title": "相同标题", "exposure": 100}] + ) is False + + monkeypatch.setattr( + chanmama, + "_write_back", + lambda *_args: (_ for _ in ()).throw( + AssertionError("natural no-match must not write") + ), + ) + assert chanmama.backfill_self_tables( + [{"title": "完全不同", "exposure": 100}] + ) is True + + def test_main_reuses_profile_before_cookie_file_or_credentials(monkeypatch): events = [] @@ -159,7 +437,6 @@ def test_main_reuses_profile_before_cookie_file_or_credentials(monkeypatch): def quit(self): events.append("quit") - monkeypatch.setattr(chanmama, "HAS_SELENIUM", True) monkeypatch.setattr(chanmama.sys, "argv", ["chanmama_scraper.py"]) monkeypatch.setattr(chanmama, "create_driver", lambda: Driver()) monkeypatch.setattr(chanmama, "verify_login", lambda _driver: True) @@ -180,10 +457,75 @@ def test_main_reuses_profile_before_cookie_file_or_credentials(monkeypatch): lambda *_args, **_kwargs: ([], []), ) - assert chanmama.main() == 0 + assert chanmama.main() == 1 assert events == ["saved", "quit"] +def test_main_full_mode_exception_and_keyboard_interrupt_are_nonzero(monkeypatch): + monkeypatch.setattr(chanmama.sys, "argv", ["chanmama_scraper.py"]) + monkeypatch.setattr( + chanmama, + "create_driver", + lambda: (_ for _ in ()).throw(RuntimeError("driver failed")), + ) + assert chanmama.main() == 1 + + monkeypatch.setattr( + chanmama, + "create_driver", + lambda: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + assert chanmama.main() == 130 + + +def test_main_propagates_backfill_failure_after_nonempty_export(monkeypatch): + events = [] + + class Driver: + def quit(self): + events.append("quit") + + monkeypatch.setattr(chanmama.sys, "argv", ["chanmama_scraper.py"]) + monkeypatch.setattr(chanmama, "create_driver", lambda: Driver()) + monkeypatch.setattr(chanmama, "verify_login", lambda _driver: True) + monkeypatch.setattr(chanmama, "save_cookies", lambda _driver: True) + monkeypatch.setattr( + chanmama, + "refresh_then_export_accounts", + lambda *_args, **_kwargs: ([{"title": "video"}], []), + ) + monkeypatch.setattr( + chanmama, + "backfill_self_tables", + lambda *_args, **_kwargs: False, + ) + + assert chanmama.main() == 1 + assert events == ["quit"] + + +def test_backfill_only_propagates_backfill_failure(monkeypatch, tmp_path): + workbook = tmp_path / "existing.xlsx" + workbook.write_bytes(b"placeholder") + monkeypatch.setattr( + chanmama.sys, + "argv", + ["chanmama_scraper.py", "--backfill-only", "--excel", str(workbook)], + ) + monkeypatch.setattr( + chanmama, + "parse_chanmama_excel", + lambda _path: ([{"title": "video"}], []), + ) + monkeypatch.setattr( + chanmama, + "backfill_self_tables", + lambda *_args, **_kwargs: False, + ) + + assert chanmama.main() == 1 + + def test_acceptance_skips_when_profile_cookie_and_credentials_fail( monkeypatch, tmp_path, @@ -196,7 +538,6 @@ def test_acceptance_skips_when_profile_cookie_and_credentials_fail( monkeypatch.setenv("GYXX_WORKFLOW_ACCEPTANCE", "1") monkeypatch.setenv("GYXX_ACCEPTANCE_EVIDENCE_FILE", str(evidence)) - monkeypatch.setattr(chanmama, "HAS_SELENIUM", True) monkeypatch.setattr(chanmama.sys, "argv", ["chanmama_scraper.py"]) monkeypatch.setattr(chanmama, "ACCOUNT", "") monkeypatch.setattr(chanmama, "PASSWORD", "") @@ -251,3 +592,85 @@ def test_cli_preserves_cookie_skip_exit_code(monkeypatch): chanmama.cli() assert raised.value.code == COOKIE_SKIP_EXIT_CODE + + +def test_wait_for_exported_excel_polls_until_file_appears(monkeypatch): + now = [1000.0] + sleeps: list[float] = [] + results = iter([None, None, "/downloads/博主A_视频记录_周期内发布视频.xlsx"]) + + monkeypatch.setattr(chanmama.time, "time", lambda: now[0]) + monkeypatch.setattr( + chanmama.time, + "sleep", + lambda seconds: (sleeps.append(seconds), now.__setitem__(0, now[0] + seconds)), + ) + monkeypatch.setattr(chanmama, "_find_new_excel_after", lambda _pre_mtime: next(results)) + + assert chanmama._wait_for_exported_excel(0.0) == ( + "/downloads/博主A_视频记录_周期内发布视频.xlsx" + ) + assert sleeps == [5.0, 5.0] + + +def test_wait_for_exported_excel_returns_none_after_timeout(monkeypatch): + now = [1000.0] + sleeps: list[float] = [] + + monkeypatch.setattr(chanmama.time, "time", lambda: now[0]) + monkeypatch.setattr( + chanmama.time, + "sleep", + lambda seconds: (sleeps.append(seconds), now.__setitem__(0, now[0] + seconds)), + ) + monkeypatch.setattr(chanmama, "_find_new_excel_after", lambda _pre_mtime: None) + + assert ( + chanmama._wait_for_exported_excel( + 0.0, + timeout_seconds=12, + grace_seconds=0, + ) + is None + ) + assert len(sleeps) == 3 # 1000 -> 1005 -> 1010 -> 1015(>= 1012 超时) + + +def test_wait_for_exported_excel_allows_boundary_grace(monkeypatch): + now = [1000.0] + sleeps: list[float] = [] + results = iter([None, None, "/downloads/边界后落盘.xlsx"]) + + monkeypatch.setattr(chanmama.time, "time", lambda: now[0]) + monkeypatch.setattr( + chanmama.time, + "sleep", + lambda seconds: (sleeps.append(seconds), now.__setitem__(0, now[0] + seconds)), + ) + monkeypatch.setattr(chanmama, "_find_new_excel_after", lambda _pre_mtime: next(results)) + + assert chanmama._wait_for_exported_excel( + 0.0, + timeout_seconds=5, + grace_seconds=5, + poll_interval_seconds=5, + ) == "/downloads/边界后落盘.xlsx" + assert sleeps == [5.0, 5.0] + + +def test_wait_for_exported_excel_returns_immediately_when_present(monkeypatch): + now = [1000.0] + + monkeypatch.setattr(chanmama.time, "time", lambda: now[0]) + monkeypatch.setattr( + chanmama.time, + "sleep", + lambda _seconds: pytest.fail("不应等待已存在的文件"), + ) + monkeypatch.setattr( + chanmama, + "_find_new_excel_after", + lambda _pre_mtime: "/downloads/已有.xlsx", + ) + + assert chanmama._wait_for_exported_excel(0.0) == "/downloads/已有.xlsx" diff --git a/tests/modules/content_marketing/test_collection_completeness.py b/tests/modules/content_marketing/test_collection_completeness.py index 0790d26..5cea190 100644 --- a/tests/modules/content_marketing/test_collection_completeness.py +++ b/tests/modules/content_marketing/test_collection_completeness.py @@ -9,6 +9,10 @@ from gyxx_flow.modules.content_marketing import pgy_xhs_scraper_v2 as pgy from gyxx_flow.modules.content_marketing import run_all from gyxx_flow.modules.content_marketing import self_douyin_scraper as self_dy from gyxx_flow.modules.content_marketing import xingtu_scraper_v2 as xingtu +from gyxx_flow.modules.content_marketing.serialized_page_action import ( + is_transient_browser_error, + run_serialized_page_action, +) def test_normalize_title_handles_nfkc_case_and_zero_width(): @@ -131,6 +135,28 @@ def test_known_mismatched_content_ids_cannot_fall_back_to_same_title(): assert cc.match_tasks_to_cards([task], [card], platform="douyin") == {} +def test_xingtu_can_use_strong_title_when_share_id_is_canonicalized(): + task = { + "record_id": "r1", + "target_title": "这家包颜值和实用性真挺高", + "note_id": "7665297310758210854", + } + card = { + "title": "这家包颜值和实用性真挺高 #潮流运动 #光影行星", + "note_id": "canonical- Xingtu-item-id", + } + + matched = cc.match_tasks_to_cards( + [task], + [card], + platform="douyin", + allow_mismatched_content_id_title=True, + ) + + assert matched["r1"]["match_method"] == "title" + assert matched["r1"]["match_score"] >= 0.9 + + def test_xingtu_api_and_dom_duplicate_are_merged_before_matching(): cards = [ { @@ -354,8 +380,11 @@ class _FakeSearchPage: def test_pgy_search_retry_keeps_creator_id_query(): page = _FakeSearchPage() - pgy._ensure_search_results(page, query="xhs-id-123", result_name="达人昵称", max_retries=2) + found = pgy._ensure_search_results( + page, query="xhs-id-123", result_name="达人昵称", max_retries=2 + ) + assert found is True assert page.input.filled == ["xhs-id-123"] @@ -376,11 +405,19 @@ def test_apply_matched_task_reports_write_failure(monkeypatch): assert outcome["write_ok"] is False -def test_default_creator_batch_has_no_forced_hour_pause(): - assert pgy.DEFAULT_BATCH_SIZE == 0 +def test_pgy_default_creator_batch_rotates_browser_before_memory_accumulates(): + assert pgy.DEFAULT_BATCH_SIZE == 4 assert xingtu.DEFAULT_BATCH_SIZE == 0 +@pytest.mark.parametrize("message", [ + "Page crashed: Out of Memory", + "Target page, context or browser has been closed: not enough memory", +]) +def test_pgy_treats_chromium_memory_exhaustion_as_a_recoverable_session_loss(message): + assert pgy.is_browser_session_lost(RuntimeError(message)) is True + + def test_repeatable_style_argument_and_selection_keep_every_requested_style(): parser = argparse.ArgumentParser() cc.add_repeatable_style_argument(parser, "styles") @@ -408,6 +445,223 @@ def test_pgy_stable_cards_retry_an_initial_empty_render(monkeypatch): assert page.waits == 2 +def test_pgy_stable_cards_waits_through_a_slow_initial_render(monkeypatch): + card = {"title": "标题", "read_count": "1"} + sequence = [[], [], [], [], [], [card], [card]] + monkeypatch.setattr(pgy, "parse_cards_on_page", lambda _page: sequence.pop(0)) + + class Page: + waits = 0 + + def wait_for_timeout(self, _ms): + self.waits += 1 + + page = Page() + assert pgy.parse_cards_stable(page) == [card] + assert page.waits == 6 + + +def test_pgy_card_parser_recovers_note_id_from_internal_card_link(): + note_id = "0123456789abcdef01234567" + + class Page: + def evaluate(self, _script): + return [{ + "title": "标题", + "read_count": "1", + "href": "", + "note_id": "", + "_href_candidates": [ + f"https://pgy.xiaohongshu.com/internal?source_note_id={note_id}" + ], + "_data_note_ids": [], + }] + + cards = pgy.parse_cards_on_page(Page()) + + assert cards == [{ + "title": "标题", + "read_count": "1", + "href": "", + "note_id": note_id, + }] + + +def test_pgy_card_parser_prefers_strict_note_detail_id_over_generic_segment(): + note_id = "0123456789abcdef01234567" + internal_href = f"https://example.invalid/note/detail/{note_id}" + + class Page: + def evaluate(self, _script): + return [{ + "title": "标题", + "read_count": "1", + "href": internal_href, + # This is what the old generic /note/ parser produced. + "note_id": "detail", + "_href_candidates": [internal_href], + "_data_note_ids": [], + }] + + cards = pgy.parse_cards_on_page(Page()) + + assert cards[0]["note_id"] == note_id + + +def test_pgy_dom_parser_reads_root_data_id_and_rejects_arbitrary_href_fallback(): + captured = {} + + class Page: + def evaluate(self, script): + captured["script"] = script + return [] + + assert pgy.parse_cards_on_page(Page()) == [] + assert "const dataNoteNodes = [card, ...card.querySelectorAll" in captured["script"] + assert "const href = link?.href || '';" in captured["script"] + assert "hrefCandidates[0]" not in captured["script"] + + +def test_pgy_arbitrary_shared_anchor_does_not_collapse_distinct_cards(): + shared_anchor = "https://example.invalid/creator/profile" + + class Page: + def evaluate(self, _script): + return [ + { + "title": "第一篇笔记", + "read_count": "1", + "like_count": "1", + "collect_count": "1", + "publish_time": "2026-08-01", + "href": "", + "note_id": "", + "_href_candidates": [shared_anchor], + "_data_note_ids": [], + }, + { + "title": "第二篇笔记", + "read_count": "2", + "like_count": "2", + "collect_count": "2", + "publish_time": "2026-08-02", + "href": "", + "note_id": "", + "_href_candidates": [shared_anchor], + "_data_note_ids": [], + }, + ] + + cards = pgy.parse_cards_on_page(Page()) + + assert [card["href"] for card in cards] == ["", ""] + assert pgy._card_observation_key(cards[0]) != pgy._card_observation_key(cards[1]) + + +def test_pgy_deduplicates_one_card_repeated_across_pages_before_matching(monkeypatch): + card = { + "title": "同一篇笔记", + "read_count": "100", + "like_count": "10", + "collect_count": "5", + "publish_time": "2026-08-01", + "href": "", + "note_id": "", + } + pages = [[dict(card)], [dict(card)], [dict(card)]] + monkeypatch.setattr(pgy, "parse_cards_stable", lambda _page: pages.pop(0)) + monkeypatch.setattr(pgy, "go_next_page", lambda _page: bool(pages)) + + found, candidates, page_limit_hit = pgy.find_notes_for_tasks( + object(), + [{"record_id": "r1", "target_title": "同一篇笔记"}], + max_pages=4, + ) + + assert found["r1"]["read_count"] == "100" + assert candidates == ["同一篇笔记"] + assert page_limit_hit is False + + +def test_pgy_missing_creator_is_blocked_but_unmatched_note_stays_retryable(): + no_creator = pgy._missing_detail_outcome(saw_search_result=False) + no_unique_note = pgy._unmatched_note_outcome( + candidates=["平台上的其他笔记"], + page_limit_hit=False, + ) + completed = cc.finalize_summary({ + "total": 2, + "results": [ + {"record_id": "creator", **no_creator}, + {"record_id": "note", **no_unique_note}, + ], + }) + + assert completed["blocked_input"] == 1 + assert completed["retryable_failures"] == 1 + assert completed["unresolved"] == 1 + assert completed["complete"] is False + assert pgy._missing_detail_outcome(saw_search_result=True)["status"] == "retryable_failure" + assert pgy._unmatched_note_outcome( + candidates=[], page_limit_hit=False + )["reason"] == "empty_detail_page" + assert pgy._unmatched_note_outcome( + candidates=["候选"], page_limit_hit=True + )["status"] == "retryable_failure" + + +@pytest.mark.parametrize( + "state", + [ + { + "nextFound": True, + "nextDisabled": True, + "activePage": None, + "lastPage": None, + "numericPageCount": 0, + }, + { + "nextFound": True, + "nextDisabled": False, + "activePage": 7, + "lastPage": 7, + "numericPageCount": 7, + }, + ], +) +def test_pgy_known_last_page_stops_without_clicking(state): + class Page: + def evaluate(self, script): + if "pgy-pagination-state" in script: + return state + if "pgy-pagination-click" in script: + pytest.fail("terminal paginator must not be clicked") + return "last-page-card-signature" + + assert pgy.go_next_page(Page()) is False + + +def test_pgy_clicked_pagination_without_card_change_uses_source_end_semantics(): + class Page: + def evaluate(self, script): + if "pgy-pagination-state" in script: + return { + "nextFound": True, + "nextDisabled": False, + "activePage": 2, + "lastPage": 7, + "numericPageCount": 7, + } + if "pgy-pagination-click" in script: + return {"clicked": True, "text": "next"} + return "same-card-signature" + + def wait_for_timeout(self, _ms): + return None + + assert pgy.go_next_page(Page()) is False + + def test_xingtu_paginates_before_falling_back_to_search(monkeypatch): pages = [ [{"title": "无关视频", "play_count": "10"}], @@ -432,6 +686,125 @@ def test_xingtu_paginates_before_falling_back_to_search(monkeypatch): assert calls["next"] == 1 +def test_xingtu_video_search_uses_visible_input_and_source_keyboard_events(): + events = [] + + class Locator: + first = None + + def __init__(self): + self.first = self + + def count(self): + return 1 + + def wait_for(self, **kwargs): + events.append(("wait", kwargs)) + + def click(self): + events.append(("click",)) + + def press(self, key): + events.append(("locator_press", key)) + + def fill(self, _value): + pytest.fail("Xingtu search must preserve source key-by-key input") + + class Keyboard: + def type(self, value, **kwargs): + events.append(("type", value, kwargs)) + + def press(self, key): + events.append(("keyboard_press", key)) + + class Page: + keyboard = Keyboard() + + def __init__(self): + self.selector = None + self.input = Locator() + + def locator(self, selector): + self.selector = selector + return self.input + + page = Page() + + assert xingtu.submit_video_search(page, "目标标题") is True + assert ":visible" in page.selector + assert events == [ + ("wait", {"state": "visible", "timeout": 5000}), + ("click",), + ("locator_press", "Control+A"), + ("locator_press", "Backspace"), + ("type", "目标标题", {"delay": 20}), + ("keyboard_press", "Enter"), + ] + + +def test_xingtu_only_confirmed_creator_miss_is_blocked(): + no_creator = xingtu._missing_detail_outcome(saw_search_result=False) + no_unique_video = xingtu._unmatched_video_outcome( + candidates=["平台上的其他视频"], + page_limit_hit=False, + ) + completed = cc.finalize_summary({ + "total": 2, + "results": [ + {"record_id": "creator", **no_creator}, + {"record_id": "video", **no_unique_video}, + ], + }) + + assert completed["blocked_input"] == 1 + assert completed["retryable_failures"] == 1 + assert completed["unresolved"] == 1 + assert completed["complete"] is False + assert no_unique_video["status"] == "retryable_failure" + assert xingtu._missing_detail_outcome( + saw_search_result=True + )["status"] == "retryable_failure" + assert xingtu._unmatched_video_outcome( + candidates=[], page_limit_hit=False + )["reason"] == "empty_detail_page" + assert xingtu._unmatched_video_outcome( + candidates=["候选"], page_limit_hit=True + )["status"] == "retryable_failure" + + +def test_xingtu_missing_creator_result_has_distinct_business_exception(): + class Context: + pages = [] + + class Page: + context = Context() + + def evaluate(self, _script, argument=None): + if argument is not None: + return {"found": False} + return False + + def wait_for_timeout(self, _milliseconds): + return None + + with pytest.raises(xingtu.CreatorNotFound, match="未找到精确达人"): + xingtu.open_creator_detail(Page(), "不存在的达人", "missing-id") + + +def test_xingtu_clicked_pagination_without_card_change_is_retryable_timeout(): + class Page: + def evaluate(self, script): + if "const all" in script: + return {"clicked": True, "picked": "pagination"} + return "same-card-signature" + + def wait_for_timeout(self, _milliseconds): + return None + + with pytest.raises(xingtu.BrowserTimeoutError, match="did not change"): + xingtu.go_next_page(Page()) + + def test_xingtu_normalizes_show_items_api_video(): card = xingtu.normalize_xingtu_api_item({ "item_id": 7654321, @@ -456,6 +829,9 @@ def test_xingtu_normalizes_show_items_api_video(): def test_xingtu_show_items_capture_collects_and_deduplicates_cards(): + import gc + import weakref + class Response: url = "https://www.xingtu.cn/gw/api/author/get_author_show_items_v2" @@ -477,9 +853,14 @@ def test_xingtu_show_items_capture_collects_and_deduplicates_cards(): page = Page() cards = xingtu.setup_show_items_capture(page) - page.callback(Response()) + response = Response() + response_ref = weakref.ref(response) + page.callback(response) + del response + gc.collect() assert [card["note_id"] for card in cards] == ["1", "2"] + assert response_ref() is None def test_xingtu_matches_api_cards_without_dom_pagination(monkeypatch): @@ -542,9 +923,221 @@ def test_browser_session_loss_detection_is_specific(): closed = RuntimeError("Target page, context or browser has been closed") assert pgy.is_browser_session_lost(closed) assert xingtu.is_browser_session_lost(closed) + assert pgy.is_browser_session_lost(RuntimeError("Page crashed")) + assert xingtu.is_browser_session_lost(RuntimeError("Page crashed")) + assert pgy.is_browser_session_lost( + RuntimeError("Execution context was destroyed, most likely because of a navigation.") + ) assert not pgy.is_browser_session_lost(RuntimeError("ordinary parse failure")) +def test_transient_browser_error_excludes_business_timeout(): + assert is_transient_browser_error( + pgy.BrowserTimeoutError("Page.goto: Timeout 60000ms exceeded") + ) + assert is_transient_browser_error( + RuntimeError("Page.goto: net::ERR_CONNECTION_RESET") + ) + assert not is_transient_browser_error( + TimeoutError("Login timed out after 300 seconds.") + ) + + +def _stub_creator_session_failure(monkeypatch, tmp_path, module, error_factory): + style = {"index": 1, "name": "style"} + task = { + "record_id": "record-1", + "creator_name": "creator", + "creator_id": None, + "target_title": "target", + "style_context": {"index": 1}, + } + summary = { + "index": 1, + "name": "style", + "total": 1, + "filled": 0, + "results": [], + } + group = { + "creator_name": "creator", + "creator_id": None, + "tasks": [task], + } + sessions = [] + + class Session: + def __init__(self, **_kwargs): + sessions.append(self) + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def fail_page_action(*_args, **_kwargs): + raise error_factory() + + monkeypatch.setattr( + module, + "collect_tasks_across_styles", + lambda *_args, **_kwargs: ([task], {1: summary}), + ) + monkeypatch.setattr(module, "group_tasks_by_creator", lambda _tasks: [group]) + monkeypatch.setattr(module, "chunk_creator_groups", lambda groups, _size: [groups]) + monkeypatch.setattr(module, "DynamicSession", Session) + monkeypatch.setattr(module, "run_serialized_page_action", fail_page_action) + monkeypatch.setattr(module, "_force_cleanup_session", lambda _session: None) + monkeypatch.setattr(module, "PROFILE_DIR", tmp_path / module.__name__.split(".")[-1]) + return style, sessions + + +@pytest.mark.parametrize("module", [pgy, xingtu]) +def test_creator_collectors_retry_browser_timeout_in_three_fresh_sessions( + monkeypatch, tmp_path, module +): + style, sessions = _stub_creator_session_failure( + monkeypatch, + tmp_path, + module, + lambda: module.BrowserTimeoutError("Page.goto: Timeout 60000ms exceeded"), + ) + + summaries = module.scrape_styles([style], 1, True, None, True) + + assert len(sessions) == 3 + assert summaries[0]["complete"] is False + assert summaries[0]["retryable_failures"] == 1 + + +@pytest.mark.parametrize("module", [pgy, xingtu]) +def test_creator_collectors_do_not_retry_acceptance_terminal( + monkeypatch, tmp_path, module +): + style, sessions = _stub_creator_session_failure( + monkeypatch, + tmp_path, + module, + lambda: module.AcceptanceCookieSkip("cookie unavailable in acceptance"), + ) + + with pytest.raises(module.AcceptanceCookieSkip): + module.scrape_styles([style], 1, True, None, True) + + assert len(sessions) == 1 + + +@pytest.mark.parametrize("module", [pgy, xingtu]) +def test_creator_collectors_do_not_retry_login_timeout_terminal( + monkeypatch, tmp_path, module +): + style, sessions = _stub_creator_session_failure( + monkeypatch, + tmp_path, + module, + lambda: TimeoutError("Login timed out after 300 seconds."), + ) + + with pytest.raises(TimeoutError, match="Login timed out"): + module.scrape_styles([style], 1, True, None, True) + + assert len(sessions) == 1 + + +@pytest.mark.parametrize("error_type", [xingtu.SearchQuotaExhausted, xingtu.CaptchaBlocked]) +def test_xingtu_does_not_retry_explicit_business_terminal( + monkeypatch, tmp_path, error_type +): + style, sessions = _stub_creator_session_failure( + monkeypatch, + tmp_path, + xingtu, + lambda: error_type("explicit platform terminal"), + ) + + with pytest.raises(error_type): + xingtu.scrape_styles([style], 1, True, None, True) + + assert len(sessions) == 1 + + +@pytest.mark.parametrize("module", [pgy, xingtu]) +def test_creator_collectors_with_no_tasks_do_not_open_browser( + monkeypatch, module +): + style = {"index": 1, "name": "style"} + summary = {"index": 1, "name": "style", "total": 0, "results": []} + monkeypatch.setattr( + module, + "collect_tasks_across_styles", + lambda *_args, **_kwargs: ([], {1: summary}), + ) + monkeypatch.setattr( + module, + "DynamicSession", + lambda **_kwargs: pytest.fail("no browser session expected"), + ) + + assert module.scrape_styles([style], 1, True, None, True) == [summary] + + +def test_long_page_action_does_not_retain_navigation_response(): + import gc + import weakref + + response_ref = None + + class Response: + url = "https://example.test/final" + status = 200 + headers = {"content-type": "text/html"} + + class Page: + closed = False + + def set_default_timeout(self, _timeout): + pass + + def set_default_navigation_timeout(self, _timeout): + pass + + def goto(self, *_args, **_kwargs): + nonlocal response_ref + response = Response() + response_ref = weakref.ref(response) + return response + + def is_closed(self): + return self.closed + + def close(self): + self.closed = True + + page = Page() + + class Context: + def new_page(self): + return page + + class Session: + context = Context() + + def action(_page): + gc.collect() + assert response_ref is not None + assert response_ref() is None + + navigation = run_serialized_page_action(Session(), "https://example.test", action) + + assert navigation == { + "url": "https://example.test/final", + "status": 200, + "headers": {"content-type": "text/html"}, + } + assert page.closed is True + + def test_xingtu_nested_session_loss_is_reraised(): closed = RuntimeError("Target page, context or browser has been closed") with pytest.raises(RuntimeError, match="has been closed"): @@ -694,6 +1287,19 @@ def test_bilibili_missing_url_does_not_trigger_collection(monkeypatch): assert [row["record_id"] for row in summary["details"]] == ["published"] +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("2026-07-20T00:00:00.000+08:00", bili.date(2026, 7, 20)), + ("2026-07-20T00:00:00+08:00", bili.date(2026, 7, 20)), + ("2026-07-19T16:00:00.000Z", bili.date(2026, 7, 19)), + ("2026-07-20", bili.date(2026, 7, 20)), + ], +) +def test_bilibili_parse_pub_date_accepts_feishu_iso_values(raw, expected): + assert bili.parse_pub_date(raw) == expected + + def test_bilibili_partial_retry_preserves_old_success(): existing = { "style": "款式A", "index": 1, "total_b_records": 2, diff --git a/tests/modules/content_marketing/test_comment_batch_recovery.py b/tests/modules/content_marketing/test_comment_batch_recovery.py new file mode 100644 index 0000000..3c18eb0 --- /dev/null +++ b/tests/modules/content_marketing/test_comment_batch_recovery.py @@ -0,0 +1,272 @@ +import pytest + +from gyxx_flow.adapters.scrapling import BrowserTimeoutError +from gyxx_flow.modules.content_marketing.data.tools import ( + batch_rescrape_douyin as douyin_batch, +) +from gyxx_flow.modules.content_marketing.data.tools import ( + batch_rescrape_xiaohongshu as xhs_batch, +) +from gyxx_flow.modules.content_marketing.data.tools.comment_batch_checkpoint import ( + CommentBatchCheckpoint, + is_browser_process_lost, +) + + +def test_page_crash_requires_a_fresh_browser(): + assert is_browser_process_lost( + RuntimeError("Page.wait_for_timeout: Page crashed") + ) + assert not is_browser_process_lost(RuntimeError("ordinary parse failure")) + + +@pytest.mark.parametrize("batch", [xhs_batch, douyin_batch]) +def test_non_acceptance_relogin_runs_platform_script(monkeypatch, batch): + class Policy: + enabled = False + + calls = [] + monkeypatch.setattr(batch, "current_acceptance_policy", lambda: Policy()) + monkeypatch.setattr( + batch.subprocess, + "call", + lambda command, **kwargs: calls.append((command, kwargs)) or 0, + ) + + assert batch.do_relogin() is True + assert calls[0][0][1] == str(batch.RELOGIN_SCRIPT) + + +def test_comment_checkpoint_matches_both_note_id_and_url(tmp_path): + path = tmp_path / "checkpoint.json" + checkpoint = CommentBatchCheckpoint( + "douyin", business_date="2026-08-09", path=path + ) + checkpoint.mark_completed(7, "https://www.douyin.com/video/7") + + restored = CommentBatchCheckpoint( + "douyin", business_date="2026-08-09", path=path + ) + assert restored.contains(7, "https://www.douyin.com/video/7") is True + assert restored.contains(7, "https://www.douyin.com/video/changed") is False + + restored.clear() + assert not path.exists() + + +def test_xhs_page_crash_retries_same_note_with_new_scraper_call(monkeypatch): + calls = [] + + def scrape(url, *_args): + calls.append(url) + if len(calls) == 1: + raise RuntimeError("Page.wait_for_timeout: Page crashed") + return [], {"stats": {}} + + monkeypatch.setattr(xhs_batch.scraper, "scrape_comments", scrape) + monkeypatch.setattr(xhs_batch.time, "sleep", lambda _seconds: None) + + comments, result, error = xhs_batch.scrape_with_fresh_browser("xhs-url") + + assert comments == [] + assert result == {"stats": {}} + assert error is None + assert calls == ["xhs-url", "xhs-url"] + + +def test_douyin_page_crash_retries_same_note_with_new_scraper_call(monkeypatch): + calls = [] + + def scrape(url, *_args): + calls.append(url) + if len(calls) == 1: + raise RuntimeError("Page.wait_for_timeout: Page crashed") + return [], {"stats": {}} + + monkeypatch.setattr(douyin_batch.scraper, "scrape_comments", scrape) + monkeypatch.setattr(douyin_batch.time, "sleep", lambda _seconds: None) + + comments, result, error = douyin_batch.scrape_with_fresh_browser("dy-url") + + assert comments == [] + assert result == {"stats": {}} + assert error is None + assert calls == ["dy-url", "dy-url"] + + +@pytest.mark.parametrize( + ("batch", "url"), + [(xhs_batch, "xhs-url"), (douyin_batch, "dy-url")], +) +def test_transient_timeout_gets_three_fresh_browser_attempts(monkeypatch, batch, url): + calls = [] + + def scrape(scrape_url, *_args): + calls.append(scrape_url) + if len(calls) < 3: + raise BrowserTimeoutError("Page.goto: Timeout 90000ms exceeded") + return [], {"stats": {}} + + monkeypatch.setattr(batch.scraper, "scrape_comments", scrape) + monkeypatch.setattr(batch.time, "sleep", lambda _seconds: None) + + comments, result, error = batch.scrape_with_fresh_browser(url) + + assert comments == [] + assert result == {"stats": {}} + assert error is None + assert calls == [url, url, url] + + +@pytest.mark.parametrize( + ("batch", "url"), + [(xhs_batch, "xhs-url"), (douyin_batch, "dy-url")], +) +def test_transient_network_failure_exhausts_three_attempts_honestly( + monkeypatch, batch, url +): + calls = [] + + def scrape(scrape_url, *_args): + calls.append(scrape_url) + raise RuntimeError("Page.goto: net::ERR_CONNECTION_RESET") + + monkeypatch.setattr(batch.scraper, "scrape_comments", scrape) + monkeypatch.setattr(batch.time, "sleep", lambda _seconds: None) + + comments, result, error = batch.scrape_with_fresh_browser(url) + + assert comments is None + assert result is None + assert error is not None + assert "ERR_CONNECTION_RESET" in str(error) + assert calls == [url, url, url] + + +@pytest.mark.parametrize( + ("batch", "url"), + [(xhs_batch, "xhs-url"), (douyin_batch, "dy-url")], +) +def test_terminal_parse_failure_is_not_retried(monkeypatch, batch, url): + calls = [] + + def scrape(scrape_url, *_args): + calls.append(scrape_url) + raise RuntimeError("ordinary terminal parse failure") + + monkeypatch.setattr(batch.scraper, "scrape_comments", scrape) + + comments, result, error = batch.scrape_with_fresh_browser(url) + + assert comments is None + assert result is None + assert error is not None + assert calls == [url] + + +def test_xhs_failed_note_keeps_success_checkpoint_and_fails_batch(monkeypatch): + class Checkpoint: + completed = [] + cleared = False + + def __init__(self, _platform): + pass + + def contains(self, _note_id, _url): + return False + + def mark_completed(self, note_id, url): + self.completed.append((note_id, url)) + + def clear(self): + self.cleared = True + + rows = [ + {"id": 1, "url": "xhs-1", "title": "one"}, + {"id": 2, "url": "xhs-2", "title": "two"}, + ] + + def scrape(url, *_args): + if url == "xhs-2": + raise RuntimeError("ordinary terminal failure") + return ([{"level": "comment"}], { + "title": "one", + "stats": {"logged_in": True, "note_metrics": {}}, + }) + + monkeypatch.setattr(xhs_batch, "CommentBatchCheckpoint", Checkpoint) + monkeypatch.setattr(xhs_batch, "collect_urls", lambda: rows) + monkeypatch.setattr(xhs_batch.scraper, "scrape_comments", scrape) + monkeypatch.setattr(xhs_batch.scraper, "write_outputs", lambda *_args: (None, None)) + monkeypatch.setattr(xhs_batch, "upsert_metrics", lambda *_args: None) + monkeypatch.setattr(xhs_batch.db, "replace_comments", lambda *_args, **_kwargs: None) + + assert xhs_batch.main() == 1 + assert Checkpoint.completed == [(1, "xhs-1")] + assert Checkpoint.cleared is False + + +@pytest.mark.parametrize( + ("batch", "platform"), + [(xhs_batch, "xhs"), (douyin_batch, "dy")], +) +def test_incomplete_comments_preserve_db_but_remain_resume_eligible( + monkeypatch, batch, platform +): + class Checkpoint: + def __init__(self): + self.completed = [] + self.cleared = False + + def contains(self, _note_id, _url): + return False + + def mark_completed(self, note_id, url): + self.completed.append((note_id, url)) + + def clear(self): + self.cleared = True + + checkpoint = Checkpoint() + rows = [ + { + "id": 1, + "url": f"{platform}-partial", + "title": "partial", + "comment_count": 5, + }, + { + "id": 2, + "url": f"{platform}-failed", + "title": "failed", + "comment_count": 5, + }, + ] + + def scrape(url, *_args): + if url.endswith("-failed"): + raise RuntimeError("ordinary terminal failure") + comments = [{"level": "comment"}] + stats = { + "logged_in": False, + "api_total": 5, + "note_metrics": {"comment_count": 5}, + } + if batch is xhs_batch: + return comments, {"title": "partial", "stats": stats} + return comments, {"stats": stats} + + monkeypatch.setattr(batch, "CommentBatchCheckpoint", lambda _platform: checkpoint) + monkeypatch.setattr(batch, "collect_urls", lambda: rows) + monkeypatch.setattr(batch.scraper, "scrape_comments", scrape) + monkeypatch.setattr(batch.scraper, "write_outputs", lambda *_args: (None, None)) + monkeypatch.setattr(batch, "upsert_metrics", lambda *_args: None) + monkeypatch.setattr( + batch.db, + "replace_comments", + lambda *_args, **_kwargs: pytest.fail("incomplete comments must not replace DB"), + ) + + assert batch.main() == 1 + assert checkpoint.completed == [] + assert checkpoint.cleared is False diff --git a/tests/modules/content_marketing/test_content_acceptance_feishu_boundaries.py b/tests/modules/content_marketing/test_content_acceptance_feishu_boundaries.py index ba348f0..2cee847 100644 --- a/tests/modules/content_marketing/test_content_acceptance_feishu_boundaries.py +++ b/tests/modules/content_marketing/test_content_acceptance_feishu_boundaries.py @@ -4,7 +4,7 @@ import json from pathlib import Path from types import SimpleNamespace -from gyxx_flow.adapters import WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID +from gyxx_flow.adapters import ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID from gyxx_flow.modules.content_marketing import chanmama_scraper, weekly_summary_all from gyxx_flow.modules.content_marketing.data.tools import ( analyze_comments, @@ -71,17 +71,13 @@ def test_daily_card_sends_only_to_wang_yunlong( tmp_path: Path, ) -> None: _enable_acceptance(monkeypatch, tmp_path) - commands = [] + calls = [] - def fake_run(command, **kwargs): - commands.append(command) - return SimpleNamespace( - returncode=0, - stdout=json.dumps({"message_id": "om_test"}), - stderr="", - ) + def fake_send(**kwargs): + calls.append(kwargs) + return {"message_id": "om_test"} - monkeypatch.setattr(daily_report_card.subprocess, "run", fake_run) + monkeypatch.setattr(daily_report_card, "send_lark_bot_message", fake_send) results = daily_report_card.send_card_to_recipients( {"schema": "2.0", "body": {"elements": []}}, ("ou_other_a", "ou_other_b"), @@ -89,9 +85,9 @@ def test_daily_card_sends_only_to_wang_yunlong( ) assert len(results) == 1 - assert len(commands) == 1 - command = commands[0] - assert command[command.index("--user-id") + 1] == WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID + assert len(calls) == 1 + assert calls[0]["user_id"] == ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID + assert calls[0]["profile"] == "hermes-analyzer" def test_comment_summary_sends_only_to_wang_yunlong( @@ -99,17 +95,17 @@ def test_comment_summary_sends_only_to_wang_yunlong( tmp_path: Path, ) -> None: _enable_acceptance(monkeypatch, tmp_path) - commands = [] + calls = [] - def fake_run(command, **kwargs): - commands.append(command) - return SimpleNamespace(returncode=0, stdout="{}", stderr="") + def fake_send(**kwargs): + calls.append(kwargs) + return {"message_id": "om_test"} - monkeypatch.setattr(analyze_comments.subprocess, "run", fake_run) + monkeypatch.setattr(analyze_comments, "send_lark_bot_message", fake_send) analyze_comments.send_feishu_summary("summary", "ou_other") - command = commands[0] - assert command[command.index("--user-id") + 1] == WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID + assert calls[0]["user_id"] == ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID + assert calls[0]["profile"] == "hermes-analyzer" def test_note_report_sends_only_to_wang_yunlong( @@ -117,19 +113,18 @@ def test_note_report_sends_only_to_wang_yunlong( tmp_path: Path, ) -> None: _enable_acceptance(monkeypatch, tmp_path) - commands = [] - monkeypatch.setattr(analyze_note.sys, "platform", "linux") + calls = [] monkeypatch.setattr( analyze_note, "build_card_payload", lambda *args, **kwargs: {"schema": "2.0"}, ) - def fake_run(command, **kwargs): - commands.append(command) - return SimpleNamespace(returncode=0, stdout="{}", stderr="") + def fake_send(**kwargs): + calls.append(kwargs) + return {"message_id": "om_test"} - monkeypatch.setattr(analyze_note.subprocess, "run", fake_run) + monkeypatch.setattr(analyze_note, "send_lark_bot_message", fake_send) analyze_note.send_feishu_report( {}, {}, @@ -141,8 +136,8 @@ def test_note_report_sends_only_to_wang_yunlong( tmp_path / "report.md", ) - command = commands[0] - assert command[command.index("--user-id") + 1] == WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID + assert calls[0]["user_id"] == ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID + assert calls[0]["profile"] == "hermes-analyzer" def test_relogin_notifications_resolve_only_to_wang_yunlong( @@ -153,5 +148,5 @@ def test_relogin_notifications_resolve_only_to_wang_yunlong( assert ( friday_relogin_parallel.recipient_for_platform("pgy", "ou_other") - == WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID + == ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID ) diff --git a/tests/modules/content_marketing/test_creator_report_exit_status.py b/tests/modules/content_marketing/test_creator_report_exit_status.py new file mode 100644 index 0000000..540a262 --- /dev/null +++ b/tests/modules/content_marketing/test_creator_report_exit_status.py @@ -0,0 +1,596 @@ +import sys +from datetime import date +from types import SimpleNamespace + +import pytest + +from gyxx_flow.modules.content_marketing import monthly_summary_all, weekly_summary_all +from gyxx_flow.modules.content_marketing.data.tools import db +from gyxx_flow.modules.content_marketing.data.tools import ( + generate_creator_report as report, +) +from gyxx_flow.modules.content_marketing.feishu_doc_import import ( + FeishuDocImportTimeout, +) + + +def _prepare_main(monkeypatch, tmp_path, *, doc_url, saved): + monkeypatch.setattr(report, "DATA_DIR", tmp_path) + monkeypatch.setattr( + report, + "_compute_last_month", + lambda: (date(2026, 7, 1), date(2026, 7, 31)), + ) + monkeypatch.setattr( + report, + "generate_report", + lambda style_filter=None: ("report", [], {}, {}), + ) + monkeypatch.setattr(report, "load_existing_creator_report", lambda *_args: None) + monkeypatch.setattr( + report, "create_feishu_doc", lambda *args, **_kwargs: doc_url + ) + monkeypatch.setattr(report, "save_to_db", lambda *args: saved) + monkeypatch.setattr(sys, "argv", ["generate_creator_report.py"]) + + +def test_main_requires_both_doc_and_database_effects(monkeypatch, tmp_path): + _prepare_main(monkeypatch, tmp_path, doc_url="https://example/doc", saved=True) + assert report.main() == 0 + + _prepare_main(monkeypatch, tmp_path, doc_url=None, saved=True) + assert report.main() == 1 + + _prepare_main(monkeypatch, tmp_path, doc_url="https://example/doc", saved=False) + assert report.main() == 1 + + +def test_main_dry_run_does_not_require_external_effects(monkeypatch, tmp_path): + _prepare_main(monkeypatch, tmp_path, doc_url=None, saved=False) + monkeypatch.setattr(sys, "argv", ["generate_creator_report.py", "--dry-run"]) + assert report.main() == 0 + + +def _import_timeout(tmp_path): + return FeishuDocImportTimeout( + "known import ticket did not become ready", + audit_path=tmp_path / "ticket-audit.json", + ) + + +def test_creator_report_timeout_is_persisted_as_ambiguous(monkeypatch, tmp_path): + captured = {} + _prepare_main(monkeypatch, tmp_path, doc_url=None, saved=True) + monkeypatch.setattr( + report, + "load_existing_creator_report", + lambda *_args: {"doc_url": "", "doc_title": "", "status": "doc_ambiguous"}, + ) + + def raise_timeout(*_args, **kwargs): + captured["resume_only"] = kwargs.get("resume_only") + raise _import_timeout(tmp_path) + + def capture_save(*args): + captured["doc_url"] = args[-2] + captured["status"] = args[-1] + return True + + monkeypatch.setattr(report, "create_feishu_doc", raise_timeout) + monkeypatch.setattr(report, "save_to_db", capture_save) + + assert report.main() == 1 + assert captured == { + "doc_url": None, + "resume_only": True, + "status": "doc_ambiguous", + } + + +@pytest.mark.parametrize( + ("module", "processor_name", "creator_name"), + ( + (weekly_summary_all, "process_one_style", "create_feishu_doc"), + ( + monthly_summary_all, + "process_one_style_monthly", + "create_feishu_doc_monthly", + ), + ), +) +def test_summary_timeout_is_persisted_as_ambiguous( + monkeypatch, + tmp_path, + module, + processor_name, + creator_name, +): + saved = {} + creator_call = {} + notes = [{"id": 1, "view_count": 10, "comment_count": 1}] + monkeypatch.setattr(module, "dry_run_global", False) + monkeypatch.setattr(module, "REPORTS_DIR", tmp_path) + monkeypatch.setattr(module, "find_target_fields", lambda *_args: ("time", "summary")) + if module is weekly_summary_all: + monkeypatch.setattr( + module, + "load_existing_summary_delivery", + lambda _style: { + "doc_url": "", + "doc_title": "", + "status": "doc_ambiguous", + }, + ) + monkeypatch.setattr(module, "get_last_week_notes", lambda _style: notes) + monkeypatch.setattr(module, "generate_summary", lambda *_args: "summary") + else: + monkeypatch.setattr( + module, + "load_existing_monthly_summary_delivery", + lambda _style: { + "doc_url": "", + "doc_title": "", + "status": "doc_ambiguous", + }, + ) + monkeypatch.setattr(module, "get_last_month_notes", lambda _style: notes) + monkeypatch.setattr(module, "generate_monthly_summary", lambda *_args: "summary") + monkeypatch.setattr(module, "run_single_note_analysis", lambda _note_id: "report") + + def raise_timeout(*_args, **kwargs): + creator_call["resume_only"] = kwargs.get("resume_only") + raise _import_timeout(tmp_path) + + def capture_save(*args): + saved["doc_url"] = args[-3] + saved["doc_title"] = args[-2] + saved["status"] = args[-1] + return True + + monkeypatch.setattr(module, creator_name, raise_timeout) + save_name = "save_to_db" if module is weekly_summary_all else "save_to_db_monthly" + monkeypatch.setattr(module, save_name, capture_save) + + processor = getattr(module, processor_name) + arguments = ("宙斯", ("base", "table", "url")) + if module is weekly_summary_all: + arguments += (False,) + result = processor(*arguments) + + assert result["status"] == "doc_ambiguous" + assert saved == { + "doc_url": None, + "doc_title": None, + "status": "doc_ambiguous", + } + assert creator_call == {"resume_only": True} + + +@pytest.mark.parametrize( + ("module", "processor_name", "creator_name"), + ( + (weekly_summary_all, "process_one_style", "create_feishu_doc"), + ( + monthly_summary_all, + "process_one_style_monthly", + "create_feishu_doc_monthly", + ), + ), +) +def test_summary_pg_failure_keeps_real_document_receipt_and_fails_result( + monkeypatch, + tmp_path, + module, + processor_name, + creator_name, +): + doc_url = "https://example.feishu.cn/docx/real-document" + notes = [{"id": 1, "view_count": 10, "comment_count": 1}] + monkeypatch.setattr(module, "dry_run_global", False) + monkeypatch.setattr(module, "REPORTS_DIR", tmp_path) + monkeypatch.setattr(module, "find_target_fields", lambda *_args: ("time", "summary")) + if module is weekly_summary_all: + monkeypatch.setattr(module, "load_existing_summary_delivery", lambda _style: None) + monkeypatch.setattr(module, "get_last_week_notes", lambda _style: notes) + monkeypatch.setattr(module, "generate_summary", lambda *_args: "summary") + else: + monkeypatch.setattr( + module, "load_existing_monthly_summary_delivery", lambda _style: None + ) + monkeypatch.setattr(module, "get_last_month_notes", lambda _style: notes) + monkeypatch.setattr(module, "generate_monthly_summary", lambda *_args: "summary") + monkeypatch.setattr(module, "run_single_note_analysis", lambda _note_id: "report") + monkeypatch.setattr( + module, creator_name, lambda *_args, **_kwargs: (doc_url, None) + ) + monkeypatch.setattr( + module, "write_to_target_table", lambda *_args, **_kwargs: True + ) + save_name = "save_to_db" if module is weekly_summary_all else "save_to_db_monthly" + monkeypatch.setattr(module, save_name, lambda *_args: False) + + processor = getattr(module, processor_name) + arguments = ("宙斯", ("base", "table", "url")) + if module is weekly_summary_all: + arguments += (False,) + result = processor(*arguments) + + assert result["status"] == "db_failed" + assert result["doc_url"] == doc_url + assert result["target_table_written"] is True + assert not weekly_summary_all.summary_results_succeeded([result]) + + +def test_creator_existing_period_document_is_never_imported_again( + monkeypatch, tmp_path +): + _prepare_main(monkeypatch, tmp_path, doc_url=None, saved=False) + existing_url = "https://example.feishu.cn/docx/existing-creator-report" + monkeypatch.setattr( + report, + "load_existing_creator_report", + lambda *_args: { + "doc_url": existing_url, + "doc_title": "existing", + "status": "doc_ambiguous", + }, + ) + + def fail_import(*_args, **_kwargs): + raise AssertionError("an existing period URL must not be imported again") + + monkeypatch.setattr(report, "create_feishu_doc", fail_import) + monkeypatch.setattr( + report, + "save_to_db", + lambda *_args: (_ for _ in ()).throw( + AssertionError("completed period must not be overwritten") + ), + ) + + assert report.main() == 0 + + +@pytest.mark.parametrize( + ("module", "processor_name", "loader_name", "creator_name"), + ( + ( + weekly_summary_all, + "process_one_style", + "load_existing_summary_delivery", + "create_feishu_doc", + ), + ( + monthly_summary_all, + "process_one_style_monthly", + "load_existing_monthly_summary_delivery", + "create_feishu_doc_monthly", + ), + ), +) +def test_completed_summary_period_skips_all_external_work( + monkeypatch, + module, + processor_name, + loader_name, + creator_name, +): + existing_url = "https://example.feishu.cn/docx/existing-summary" + monkeypatch.setattr(module, "dry_run_global", False) + monkeypatch.setattr( + module, + loader_name, + lambda _style: { + "doc_url": existing_url, + "doc_title": "existing", + "status": "ok", + }, + ) + + def fail_work(*_args, **_kwargs): + raise AssertionError("completed summary period must be skipped") + + monkeypatch.setattr(module, "find_target_fields", fail_work) + monkeypatch.setattr(module, creator_name, fail_work) + processor = getattr(module, processor_name) + arguments = ("宙斯", ("base", "table", "url")) + if module is weekly_summary_all: + arguments += (False,) + + result = processor(*arguments) + + assert result["status"] == "ok" + assert result["doc_url"] == existing_url + assert result["already_complete"] is True + + +@pytest.mark.parametrize( + ("module", "processor_name", "loader_name", "creator_name", "updater_name"), + ( + ( + weekly_summary_all, + "process_one_style", + "load_existing_summary_delivery", + "create_feishu_doc", + "update_existing_summary_delivery_status", + ), + ( + monthly_summary_all, + "process_one_style_monthly", + "load_existing_monthly_summary_delivery", + "create_feishu_doc_monthly", + "update_existing_monthly_summary_delivery_status", + ), + ), +) +def test_failed_base_delivery_reuses_existing_document_without_import( + monkeypatch, + module, + processor_name, + loader_name, + creator_name, + updater_name, +): + existing_url = "https://example.feishu.cn/docx/existing-summary" + write_call = {} + status_call = {} + monkeypatch.setattr(module, "dry_run_global", False) + monkeypatch.setattr( + module, + loader_name, + lambda _style: { + "doc_url": existing_url, + "doc_title": "existing", + "status": "feishu_write_failed", + }, + ) + monkeypatch.setattr(module, "find_target_fields", lambda *_args: ("time", "summary")) + + def capture_write(*args, **kwargs): + write_call["args"] = args + write_call["kwargs"] = kwargs + return True + + def capture_status(style, status): + status_call.update({"style": style, "status": status}) + return True + + def fail_import(*_args, **_kwargs): + raise AssertionError("an existing document must not be imported again") + + monkeypatch.setattr(module, "write_to_target_table", capture_write) + monkeypatch.setattr(module, updater_name, capture_status) + monkeypatch.setattr(module, creator_name, fail_import) + processor = getattr(module, processor_name) + arguments = ("宙斯", ("base", "table", "url")) + if module is weekly_summary_all: + arguments += (False,) + + result = processor(*arguments) + + assert result["status"] == "ok" + assert result["doc_url"] == existing_url + assert result["reused_document"] is True + assert write_call["args"][5] == existing_url + assert status_call == {"style": "宙斯", "status": "ok"} + if module is monthly_summary_all: + assert write_call["kwargs"] == { + "time_label": monthly_summary_all.TIME_LABEL, + "report_label": "月度汇总", + } + + +def test_business_date_drives_default_report_periods(monkeypatch): + for name in ("WEEK_SINCE", "WEEK_UNTIL", "MONTH_SINCE", "MONTH_UNTIL"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-08-09") + + assert report._compute_last_month() == (date(2026, 7, 1), date(2026, 7, 31)) + assert weekly_summary_all._resolve_week_period() == ( + date(2026, 7, 27), + date(2026, 8, 2), + ) + assert monthly_summary_all._resolve_month_period() == ( + date(2026, 7, 1), + date(2026, 7, 31), + ) + + +def test_explicit_period_overrides_take_priority_over_business_date(monkeypatch): + monkeypatch.setenv("GYXX_BUSINESS_DATE", "not-a-date") + monkeypatch.setenv("WEEK_SINCE", "2026-06-01") + monkeypatch.setenv("WEEK_UNTIL", "2026-06-07") + monkeypatch.setenv("MONTH_SINCE", "2026-05-01") + monkeypatch.setenv("MONTH_UNTIL", "2026-05-31") + + assert weekly_summary_all._resolve_week_period() == ( + date(2026, 6, 1), + date(2026, 6, 7), + ) + assert monthly_summary_all._resolve_month_period() == ( + date(2026, 5, 1), + date(2026, 5, 31), + ) + assert report._compute_last_month() == (date(2026, 5, 1), date(2026, 5, 31)) + + +def test_invalid_business_date_and_unpaired_overrides_fail_closed(monkeypatch): + for name in ("WEEK_SINCE", "WEEK_UNTIL", "MONTH_SINCE", "MONTH_UNTIL"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026/08/09") + + with pytest.raises(ValueError, match="GYXX_BUSINESS_DATE"): + report._compute_last_month() + with pytest.raises(ValueError, match="GYXX_BUSINESS_DATE"): + weekly_summary_all._resolve_week_period() + with pytest.raises(ValueError, match="GYXX_BUSINESS_DATE"): + monthly_summary_all._resolve_month_period() + + monkeypatch.delenv("GYXX_BUSINESS_DATE") + monkeypatch.setenv("WEEK_SINCE", "2026-06-01") + with pytest.raises(ValueError, match="configured together"): + weekly_summary_all._resolve_week_period() + monkeypatch.delenv("WEEK_SINCE") + monkeypatch.setenv("MONTH_SINCE", "2026-05-01") + with pytest.raises(ValueError, match="configured together"): + report._compute_last_month() + with pytest.raises(ValueError, match="configured together"): + monthly_summary_all._resolve_month_period() + + +class _RecordingCursor: + def __init__(self): + self.statements = [] + + def execute(self, statement, params): + self.statements.append((statement, params)) + + def fetchone(self): + return (7,) + + +class _RecordingConnection: + def __init__(self): + self.cursor_instance = _RecordingCursor() + + def cursor(self): + return self.cursor_instance + + def commit(self): + return None + + def close(self): + return None + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + +@pytest.mark.parametrize( + ("module", "save_name", "table_name"), + ( + (report, "save_to_db", "cmt_creator_report"), + (weekly_summary_all, "save_to_db", "cmt_weekly_summary"), + (monthly_summary_all, "save_to_db_monthly", "cmt_monthly_summary"), + ), +) +def test_empty_upsert_values_preserve_existing_document_identity( + monkeypatch, + module, + save_name, + table_name, +): + connection = _RecordingConnection() + if module is report: + monkeypatch.setattr(report, "get_db_config", lambda: {}) + monkeypatch.setattr(report.psycopg, "connect", lambda **_kwargs: connection) + saved = report.save_to_db( + "summary", + [], + {}, + {}, + "2026-07-01", + "2026-07-31", + None, + "doc_ambiguous", + ) + else: + monkeypatch.setattr(module, "dry_run_global", False) + monkeypatch.setattr(db, "get_conn", lambda: connection) + saved = getattr(module, save_name)( + "宙斯", + [], + "summary", + None, + None, + "doc_ambiguous", + ) + + assert saved is True + upsert = connection.cursor_instance.statements[-1][0] + assert "COALESCE" in upsert + assert "NULLIF(EXCLUDED.doc_url, '')" in upsert + assert f"{table_name}.doc_url" in upsert + assert "NULLIF(EXCLUDED.doc_title, '')" in upsert + assert f"{table_name}.doc_title" in upsert + + +@pytest.mark.parametrize( + ( + "module", + "function_name", + "args", + "expected_title", + "source_filename", + "operation_prefix", + ), + ( + ( + report, + "create_feishu_doc", + ("report", "2026-07-01", "2026-07-31"), + "达人合作数据筛选与报价分析报告(2026-07-01~07-31)", + "creator_report_doc.md", + "content-creator-report-", + ), + ( + weekly_summary_all, + "create_feishu_doc", + ("宙斯", "summary"), + "宙斯 周笔记汇总(2026-07-27~08-02)", + "weekly_summary_doc.md", + "content-weekly-summary-", + ), + ( + monthly_summary_all, + "create_feishu_doc_monthly", + ("宙斯", "summary"), + "宙斯 月度汇总(2026-07-01~07-31)", + "monthly_summary_doc.md", + "content-monthly-summary-", + ), + ), +) +def test_feishu_doc_import_preserves_title_body_and_root_target( + monkeypatch, + tmp_path, + module, + function_name, + args, + expected_title, + source_filename, + operation_prefix, +): + captured = {} + + def fake_import(markdown, **kwargs): + captured["markdown"] = markdown + captured.update(kwargs) + return SimpleNamespace(url="https://example/doc") + + monkeypatch.setattr( + module, + "PATHS", + SimpleNamespace(tmp_root=tmp_path, evidence_root=tmp_path / "evidence"), + ) + monkeypatch.setattr(module, "import_markdown_document", fake_import) + if module is weekly_summary_all: + monkeypatch.setattr(module, "TIME_LABEL", "2026-07-27~08-02") + elif module is monthly_summary_all: + monkeypatch.setattr(module, "TIME_LABEL", "2026-07-01~07-31") + + getattr(module, function_name)(*args) + + assert captured["markdown"] == args[0 if module is report else 1] + assert captured["title"] == expected_title + assert captured["source_filename"] == source_filename + assert captured["working_directory"] == tmp_path + assert captured["audit_directory"] == tmp_path / "evidence" / "feishu_doc_import" + assert captured["identity"] == "user" + assert captured.get("folder_token") is None + assert captured["operation_id"].startswith(operation_prefix) + assert len(captured["operation_id"].rsplit("-", 1)[1]) == 40 + assert captured["resume_only"] is False diff --git a/tests/modules/content_marketing/test_daily_marketing_report.py b/tests/modules/content_marketing/test_daily_marketing_report.py index d19cebe..c4e677e 100644 --- a/tests/modules/content_marketing/test_daily_marketing_report.py +++ b/tests/modules/content_marketing/test_daily_marketing_report.py @@ -1,5 +1,11 @@ +import io +import sys +import tempfile import unittest +from contextlib import redirect_stdout from datetime import date +from pathlib import Path +from types import SimpleNamespace from unittest.mock import patch from gyxx_flow.modules.content_marketing import daily_marketing_report as daily @@ -47,6 +53,41 @@ class DailyMarketingReportTests(unittest.TestCase): ), ) + def test_disabled_dynamic_route_keeps_report_but_skips_feishu_send(self): + facts = ([], [], [], [], {}) + with tempfile.TemporaryDirectory() as directory, \ + patch.object(daily, "PATHS", SimpleNamespace(exports_root=Path(directory))), \ + patch.object(daily, "_latest_report_date", return_value=date(2026, 8, 7)), \ + patch.object(daily, "load_report_facts", return_value=facts), \ + patch.object(daily, "build_daily_prompt", return_value="prompt"), \ + patch.object(daily, "call_hermes_analyzer", return_value="report"), \ + patch.object(daily, "require_valid_hermes_report", return_value="report"), \ + patch.object(daily, "normalize_report", return_value="report"), \ + patch.object(daily, "generate_dashboard"), \ + patch.object( + daily, + "resolve_notification_route", + return_value=SimpleNamespace( + enabled=False, + open_ids=(), + app_profile="hermes-analyzer", + ), + ), \ + patch.object(daily, "send_daily_report_cards") as send, \ + patch.object(sys, "argv", ["daily_marketing_report.py", "--send"]): + output = io.StringIO() + with redirect_stdout(output): + self.assertEqual(daily.main(), 0) + + report_path = ( + Path(directory) + / "summary" + / "daily_marketing_report_2026-08-07.md" + ) + self.assertTrue(report_path.is_file()) + self.assertIn("动态配置中禁用", output.getvalue()) + send.assert_not_called() + def test_hermes_transport_error_is_not_accepted_as_report(self): with self.assertRaisesRegex(RuntimeError, "Hermes 分析失败"): daily.require_valid_hermes_report("API call failed after 3 retries: Connection error.") @@ -142,6 +183,25 @@ class DailyMarketingReportTests(unittest.TestCase): self.assertEqual(valid["engagement_count_num"], 2143) self.assertEqual(valid["publish_status"], "已记录发布时间") + def test_style_note_signal_keeps_complete_count_and_reports_metric_coverage(self): + signals = daily._build_style_signals( + ["宙斯"], + [], + [ + {"style_name": "宙斯", "exposure_count": 1000}, + {"style_name": "宙斯", "exposure_count": None}, + ], + [], + [], + [], + ) + + self.assertEqual(signals["宙斯"]["note_count"], 2) + self.assertEqual(signals["宙斯"]["recent_note_exposure"], 1000) + self.assertEqual(signals["宙斯"]["metric_note_count"], 1) + self.assertEqual(signals["宙斯"]["metric_missing_count"], 1) + self.assertEqual(signals["宙斯"]["metric_coverage_rate"], 0.5) + def test_load_report_facts_tracks_all_styles_for_report_date(self): metrics = { "宙斯": {"light": "🟢"}, @@ -156,6 +216,7 @@ class DailyMarketingReportTests(unittest.TestCase): patch.object(daily, "load_style_images", return_value={}), \ patch.object(daily, "load_style_categories", return_value={}), \ patch.object(daily, "_cooperation_context", return_value=[]), \ + patch.object(daily, "_note_inventory_context", return_value=[]), \ patch.object(daily, "_style_comment_context", return_value=[]), \ patch.object(daily, "_review_context", return_value=[]), \ patch.object(daily, "_creative_context", return_value=[]), \ @@ -185,6 +246,7 @@ class DailyMarketingReportTests(unittest.TestCase): patch.object(daily, "load_style_images", return_value={}), \ patch.object(daily, "load_style_categories", return_value={}), \ patch.object(daily, "_cooperation_context", return_value=[]), \ + patch.object(daily, "_note_inventory_context", return_value=[]), \ patch.object(daily, "_style_comment_context", return_value=[]), \ patch.object(daily, "_review_context", return_value=[]), \ patch.object(daily, "_creative_context", return_value=[]), \ diff --git a/tests/modules/content_marketing/test_daily_report_card.py b/tests/modules/content_marketing/test_daily_report_card.py index d9894c6..1603e7e 100644 --- a/tests/modules/content_marketing/test_daily_report_card.py +++ b/tests/modules/content_marketing/test_daily_report_card.py @@ -49,6 +49,83 @@ class DailyReportCardTests(unittest.TestCase): self.assertNotIn("![全域种草数据看板]", analysis_text) self.assertIn("波塞冬:继续观察", analysis_text) self.assertIn("退款订单比上升", analysis_text) + self.assertEqual(analysis["schema"], "2.0") + self.assertEqual(analysis["config"]["width_mode"], "fill") + self.assertEqual( + [item["tag"] for item in analysis["body"]["elements"]], + ["column_set", "collapsible_panel", "column_set"], + ) + self.assertNotEqual( + analysis["body"]["elements"], + [{"tag": "markdown", "content": report}], + ) + + def test_analysis_card_structures_full_five_section_report(self): + report = """品牌SKU营销运营日报 +日期:2026年8月1日 + +一、今日经营概览 +销量:整体下降。 +今日核心结论:流量收缩,转化承压。 + +二、SKU表现 +|SKU|今日销量|综合判断|建议动作| +|---|---|---|---| +|星云2|128|继续放大|维持资源| +|星迹2|33|先修承接|优化详情页| + +注:曝光为累计值。 + +三、重点SKU分析 +星云2:经营表现健康,继续放大。 + +四、达人营销分析 +最佳渠道:抖音。 + +五、明日运营建议 +1. 优化星迹2详情页。 +2. 补充逐星内容。 +""" + card = build_analysis_card("2026-08-01", report) + serialized = json.dumps(card, ensure_ascii=False) + + self.assertEqual( + [item["tag"] for item in card["body"]["elements"]], + [ + "column_set", + "table", + "collapsible_panel", + "collapsible_panel", + "column_set", + ], + ) + self.assertEqual(len(card["body"]["elements"][1]["rows"]), 2) + sku_panel, marketing_panel = card["body"]["elements"][2:4] + self.assertTrue(sku_panel["expanded"]) + self.assertTrue(marketing_panel["expanded"]) + self.assertEqual(sku_panel["background_color"], "blue-50") + self.assertEqual(marketing_panel["background_color"], "violet-50") + self.assertNotEqual( + sku_panel["background_color"], marketing_panel["background_color"] + ) + self.assertEqual(sku_panel["header"]["title"]["tag"], "markdown") + self.assertIn( + "**重点SKU分析**", + sku_panel["header"]["title"]["content"], + ) + self.assertIn( + "**达人与内容营销分析**", + marketing_panel["header"]["title"]["content"], + ) + for phrase in ( + "流量收缩,转化承压", + "曝光为累计值", + "继续放大", + "最佳渠道:抖音", + "补充逐星内容", + ): + self.assertIn(phrase, serialized) + self.assertNotIn("button", serialized) def test_build_card_uses_card_2_schema_and_dashboard(self): report = """一、核心经营结论 @@ -139,25 +216,99 @@ class DailyReportCardTests(unittest.TestCase): @patch( "gyxx_flow.modules.content_marketing.data.tools." - "daily_report_card.subprocess.run" + "daily_report_card.send_lark_bot_message" ) - def test_send_card_to_all_recipients(self, run): - run.side_effect = [ - type("Result", (), {"returncode": 0, "stdout": json.dumps({"message_id": f"om_{i}"}), "stderr": ""})() + def test_send_card_to_all_recipients(self, send): + send.side_effect = [ + {"message_id": f"om_{i}"} for i in range(3) ] recipients = ["ou_one", "ou_two", "ou_three"] results = send_card_to_recipients({"schema": "2.0"}, recipients, "2026-07-15") self.assertEqual([item["message_id"] for item in results], ["om_0", "om_1", "om_2"]) - self.assertEqual(run.call_count, 3) - commands = [call.args[0] for call in run.call_args_list] - self.assertEqual([cmd[cmd.index("--user-id") + 1] for cmd in commands], recipients) - self.assertTrue(all("--msg-type" in cmd and "interactive" in cmd for cmd in commands)) - keys = [cmd[cmd.index("--idempotency-key") + 1] for cmd in commands] + self.assertEqual(send.call_count, 3) + calls = send.call_args_list + self.assertEqual([call.kwargs["user_id"] for call in calls], recipients) + self.assertTrue(all(call.kwargs["msg_type"] == "interactive" for call in calls)) + self.assertTrue(all(call.kwargs["profile"] == "hermes-analyzer" for call in calls)) + keys = [call.kwargs["idempotency_key"] for call in calls] self.assertTrue(all(key.startswith("daily-2026-07-15-") for key in keys)) self.assertTrue(all(len(key) <= 50 for key in keys)) + @patch( + "gyxx_flow.modules.content_marketing.data.tools." + "daily_report_card.send_lark_bot_message" + ) + def test_send_card_rejects_success_exit_without_message_id(self, send): + send.return_value = {"ok": True} + + with self.assertRaisesRegex(RuntimeError, "message_id"): + send_card_to_recipients( + {"schema": "2.0"}, + ["ou_owner"], + "2026-08-09", + ) + + @patch( + "gyxx_flow.modules.content_marketing.data.tools." + "daily_report_card.send_lark_bot_message" + ) + def test_sender_uses_fixed_user_profile_and_hashes_full_recipient_identity(self, send): + send.side_effect = [ + {"message_id": f"om_{index}"} + for index in range(2) + ] + recipients = ["ou_first_sharedsuffix", "ou_second_sharedsuffix"] + + send_card_to_recipients( + {"schema": "2.0"}, + recipients, + "2026-08-07", + ) + + calls = send.call_args_list + self.assertTrue( + all(call.kwargs["profile"] == "hermes-analyzer" for call in calls) + ) + keys = [ + call.kwargs["idempotency_key"] + for call in calls + ] + self.assertEqual(len(set(keys)), 2) + with self.assertRaisesRegex(ValueError, "hermes-analyzer"): + send_card_to_recipients( + {"schema": "2.0"}, + ["ou_owner"], + "2026-08-07", + app_profile="dynamic-analyzer", + ) + + @patch( + "gyxx_flow.modules.content_marketing.data.tools." + "daily_report_card.send_lark_bot_message" + ) + def test_stable_fingerprint_ignores_rotating_uploaded_image_key(self, send): + send.side_effect = [ + {"message_id": f"om_{index}"} + for index in range(2) + ] + + for image_key in ("img_first_upload", "img_second_upload"): + send_card_to_recipients( + build_dashboard_card("2026-08-07", [], image_key), + ["ou_owner"], + "2026-08-07", + message_kind="dashboard", + idempotency_fingerprint="same-dashboard-bytes-and-metrics", + ) + + keys = [ + call.kwargs["idempotency_key"] + for call in send.call_args_list + ] + self.assertEqual(keys[0], keys[1]) + @patch( "gyxx_flow.modules.content_marketing.data.tools.daily_report_card.send_card_to_recipients" ) @@ -177,13 +328,16 @@ class DailyReportCardTests(unittest.TestCase): ] recipients = ["ou_one", "ou_two"] - results = send_daily_report_cards( - "2026-07-17", - "第一部分 整体经营分析\n完整分析内容", - [{"light": "🟢"}], - Path("charts/dashboard.png"), - recipients, - ) + with tempfile.TemporaryDirectory() as tmp: + chart_path = Path(tmp) / "dashboard.png" + Image.new("RGB", (4, 4), "white").save(chart_path) + results = send_daily_report_cards( + "2026-07-17", + "第一部分 整体经营分析\n完整分析内容", + [{"light": "🟢"}], + chart_path, + recipients, + ) self.assertEqual( [item["message_id"] for item in results], @@ -195,6 +349,10 @@ class DailyReportCardTests(unittest.TestCase): self.assertIn("img_v3_test", json.dumps(first_card, ensure_ascii=False)) self.assertIn("完整分析内容", json.dumps(second_card, ensure_ascii=False)) self.assertEqual(send.call_args_list[0].kwargs["message_kind"], "dashboard") + self.assertIn( + "idempotency_fingerprint", + send.call_args_list[0].kwargs, + ) self.assertEqual(send.call_args_list[1].kwargs["message_kind"], "analysis") diff --git a/tests/modules/content_marketing/test_direct_content_llm.py b/tests/modules/content_marketing/test_direct_content_llm.py new file mode 100644 index 0000000..5385373 --- /dev/null +++ b/tests/modules/content_marketing/test_direct_content_llm.py @@ -0,0 +1,99 @@ +import json + +import pytest + +from gyxx_flow.adapters.direct_content_llm import ( + ContentLLMConfig, + ContentLLMConfigurationError, + ContentLLMResponseError, + call_content_analyzer, + load_content_llm_config, +) + + +def _config() -> ContentLLMConfig: + return ContentLLMConfig( + endpoint="https://api.minimaxi.com/anthropic/v1/messages", + credential="test-only-secret", + model="MiniMax-M3", + thinking_mode="disabled", + ) + + +def _response(*blocks: dict[str, object]) -> bytes: + return json.dumps( + { + "type": "message", + "content": list(blocks), + "usage": {"input_tokens": 3, "output_tokens": 4}, + } + ).encode() + + +def test_config_uses_anthropic_endpoint_and_redacts_key(monkeypatch) -> None: + monkeypatch.setenv( + "CONTENT_ANALYSIS_LLM_BASE_URL", "https://api.minimaxi.com/anthropic" + ) + monkeypatch.setenv("CONTENT_ANALYSIS_LLM_API_KEY", "test-only-secret") + monkeypatch.setenv("CONTENT_ANALYSIS_LLM_MODEL", "MiniMax-M3") + + config = load_content_llm_config() + + assert config.endpoint == "https://api.minimaxi.com/anthropic/v1/messages" + assert config.model == "MiniMax-M3" + assert "test-only-secret" not in repr(config) + + +def test_call_sends_anthropic_message_and_ignores_thinking_block() -> None: + observed: dict[str, object] = {} + + def transport(url, headers, body, timeout): + payload = json.loads(body) + observed.update( + url=url, + headers=headers, + payload=payload, + timeout=timeout, + ) + return _response( + {"type": "thinking", "thinking": "hidden reasoning"}, + {"type": "text", "text": "最终报告"}, + ) + + result = call_content_analyzer( + "system prompt", + "user prompt", + transport=transport, + config=_config(), + ) + + assert result == "最终报告" + assert observed["url"] == "https://api.minimaxi.com/anthropic/v1/messages" + assert observed["headers"]["X-Api-Key"] == "test-only-secret" + assert observed["headers"]["anthropic-version"] == "2023-06-01" + assert observed["payload"]["model"] == "MiniMax-M3" + assert observed["payload"]["system"] == "system prompt" + assert observed["payload"]["messages"] == [ + {"role": "user", "content": "user prompt"} + ] + assert observed["payload"]["thinking"] == {"type": "disabled"} + + +def test_call_rejects_openai_response_shape() -> None: + with pytest.raises(ContentLLMResponseError, match="response shape"): + call_content_analyzer( + "system prompt", + "user prompt", + transport=lambda *_args: b'{"choices": []}', + config=_config(), + ) + + +def test_config_rejects_local_gateway(monkeypatch) -> None: + monkeypatch.setenv( + "CONTENT_ANALYSIS_LLM_BASE_URL", "http://127.0.0.1:8642/v1" + ) + monkeypatch.setenv("CONTENT_ANALYSIS_LLM_API_KEY", "test-only-secret") + + with pytest.raises(ContentLLMConfigurationError, match="local gateway"): + load_content_llm_config() diff --git a/tests/modules/content_marketing/test_douyin_comment_deadline.py b/tests/modules/content_marketing/test_douyin_comment_deadline.py new file mode 100644 index 0000000..c63b525 --- /dev/null +++ b/tests/modules/content_marketing/test_douyin_comment_deadline.py @@ -0,0 +1,162 @@ +import pytest + +from gyxx_flow.modules.content_marketing import douyin_comment_scraper as scraper +from gyxx_flow.modules.content_marketing.data.tools import ( + batch_rescrape_douyin as batch, +) + + +class _EvaluatePage: + def __init__(self, result): + self.result = result + self.calls = [] + + def evaluate(self, script, argument): + self.calls.append((script, argument)) + return self.result + + +def test_bounded_fetch_uses_abort_controller_and_clears_timer(monkeypatch): + page = _EvaluatePage( + {"__gyxx_status": "ok", "payload": {"comments": []}} + ) + monkeypatch.setattr(scraper.time, "monotonic", lambda: 100.0) + + payload = scraper._fetch_json_with_timeout( + page, + "https://example.invalid/comments", + deadline=1000.0, + stage="top-level pagination", + ) + + assert payload == {"comments": []} + script, argument = page.calls[0] + assert "AbortController" in script + assert "clearTimeout(timer)" in script + assert argument["timeoutMs"] == 30_000 + + +def test_bounded_fetch_timeout_is_an_explicit_transient_failure(monkeypatch): + page = _EvaluatePage({"__gyxx_status": "timeout"}) + monkeypatch.setattr(scraper.time, "monotonic", lambda: 100.0) + + with pytest.raises(scraper.TransientDouyinCommentTimeout) as caught: + scraper._fetch_json_with_timeout( + page, + "https://example.invalid/comments", + deadline=1000.0, + stage="reply pagination", + ) + + assert isinstance(caught.value, ConnectionError) + assert "reply pagination" in str(caught.value) + + +def test_scroll_checks_the_note_deadline_before_browser_work(monkeypatch): + page = _EvaluatePage(None) + monkeypatch.setattr(scraper.time, "monotonic", lambda: 901.0) + + with pytest.raises(scraper.TransientDouyinCommentTimeout): + scraper.scroll_comments( + page, + max_scrolls=60, + idle_rounds=10, + comments_by_id={}, + deadline=900.0, + ) + + assert page.calls == [] + + +def test_top_and_reply_pagination_share_the_bounded_fetch(monkeypatch): + stages = [] + monkeypatch.setattr(scraper.time, "monotonic", lambda: 0.0) + + def fetch(_page, _url, *, deadline, stage): + assert deadline == 900.0 + stages.append(stage) + return {"comments": [], "has_more": 0, "cursor": 0} + + monkeypatch.setattr(scraper, "_fetch_json_with_timeout", fetch) + monkeypatch.setattr(scraper, "collect_from_payload", lambda *_args: None) + page = _EvaluatePage(None) + + scraper.fetch_missing_top_comments( + page, + "source-url", + "aweme-id", + {"existing": {}}, + { + "comment_url_template": "https://example.invalid/top", + "api_total": 2, + "last_cursor": 1, + }, + deadline=900.0, + ) + scraper.fetch_missing_replies( + page, + "source-url", + { + "parent": { + "level": "comment", + "reply_count": 1, + "comment_id": "parent-id", + } + }, + { + "reply_url_template": "https://example.invalid/reply", + "aweme_id": "aweme-id", + }, + deadline=900.0, + ) + + assert stages == ["top-level pagination", "reply pagination"] + + +def test_deadline_failure_retries_and_never_replaces_existing_comments( + monkeypatch, +): + attempts = [] + + class Checkpoint: + completed = [] + cleared = False + + def __init__(self, _platform): + pass + + def contains(self, _note_id, _url): + return False + + def mark_completed(self, note_id, url): + self.completed.append((note_id, url)) + + def clear(self): + self.cleared = True + + def scrape(url, *_args): + attempts.append(url) + raise scraper.TransientDouyinCommentTimeout( + "Douyin comment scrape exceeded 900 seconds" + ) + + monkeypatch.setattr(batch, "CommentBatchCheckpoint", Checkpoint) + monkeypatch.setattr( + batch, + "collect_urls", + lambda: [{"id": 1, "url": "dy-url", "comment_count": 10}], + ) + monkeypatch.setattr(batch.scraper, "scrape_comments", scrape) + monkeypatch.setattr(batch.time, "sleep", lambda _seconds: None) + monkeypatch.setattr( + batch.db, + "replace_comments", + lambda *_args, **_kwargs: pytest.fail( + "a timed-out scrape must not replace existing comments" + ), + ) + + assert batch.main() == 1 + assert attempts == ["dy-url", "dy-url", "dy-url"] + assert Checkpoint.completed == [] + assert Checkpoint.cleared is False diff --git a/tests/modules/content_marketing/test_feishu_doc_import.py b/tests/modules/content_marketing/test_feishu_doc_import.py new file mode 100644 index 0000000..830eea2 --- /dev/null +++ b/tests/modules/content_marketing/test_feishu_doc_import.py @@ -0,0 +1,399 @@ +import json +import subprocess +from types import SimpleNamespace + +import pytest + +from gyxx_flow.modules.content_marketing.feishu_doc_import import ( + FeishuDocImportAmbiguous, + FeishuDocImportError, + FeishuDocImportTimeout, + _parse_cli_json, + deterministic_import_operation_id, + import_markdown_document, +) + + +def _completed(payload, *, returncode=0, prefix="", suffix=""): + return SimpleNamespace( + returncode=returncode, + stdout=f"{prefix}{json.dumps(payload)}{suffix}", + stderr="", + ) + + +class _FakeClock: + def __init__(self): + self.now = 100.0 + + def monotonic(self): + return self.now + + def sleep(self, seconds): + self.now += seconds + + +def test_import_ready_result_uses_relative_file_exact_title_and_audits_without_token( + tmp_path, +): + commands = [] + title = "宙斯 周笔记汇总(2026-07-27~08-02)" + markdown = "# 标题\n\n完整正文" + document_token = "VERY_SECRET_DOCUMENT_TOKEN_123456" + + def fake_run(command, **kwargs): + commands.append(command) + source = command[command.index("--file") + 1] + assert source.startswith("./") + assert (kwargs["cwd"] / source.removeprefix("./")).read_text( + encoding="utf-8" + ) == markdown + return _completed( + { + "scenario": "import", + "ticket": "ticket-ready", + "ready": True, + "failed": False, + "job_status": 0, + "job_status_label": "success", + "token": document_token, + "url": f"https://example.feishu.cn/docx/{document_token}", + }, + prefix="lark-cli notice\n", + suffix="\ntrailing notifier", + ) + + result = import_markdown_document( + markdown, + title=title, + working_directory=tmp_path / "work", + audit_directory=tmp_path / "audit", + source_filename="weekly_summary_doc.md", + runner=fake_run, + operation_id="ready-op", + ) + + assert result.url == f"https://example.feishu.cn/docx/{document_token}" + assert result.document_token == document_token + assert commands[0][1:3] == ["drive", "+import"] + assert commands[0][commands[0].index("--name") + 1] == title + assert commands[0][commands[0].index("--type") + 1] == "docx" + assert commands[0][commands[0].index("--as") + 1] == "user" + assert "--folder-token" not in commands[0] + + audit_text = result.audit_path.read_text(encoding="utf-8") + audit = json.loads(audit_text) + assert audit["status"] == "ready" + assert audit["ticket"] == "ticket-ready" + assert audit["url"] == "https://example.feishu.cn/docx/" + assert audit["last_result"]["url"] == audit["url"] + assert audit["document_title"] == title + assert audit["folder_target"] == "root" + assert "token" not in audit + assert "document_token" not in audit_text + assert document_token not in audit_text + assert not list((tmp_path / "work").glob("*.md")) + + +def test_import_persists_ticket_and_polls_task_result_until_ready(tmp_path): + responses = iter( + [ + _completed( + { + "scenario": "import", + "ticket": "ticket-async", + "ready": False, + "failed": False, + "timed_out": True, + } + ), + _completed( + { + "scenario": "import", + "ticket": "ticket-async", + "ready": False, + "failed": False, + "job_status": 1, + "job_status_label": "processing", + } + ), + _completed( + { + "scenario": "import", + "ticket": "ticket-async", + "ready": True, + "failed": False, + "job_status": 0, + "job_status_label": "success", + "token": "doxcnAsyncDocument", + "url": "https://example.feishu.cn/docx/doxcnAsyncDocument", + } + ), + ] + ) + commands = [] + + def fake_run(command, **_kwargs): + commands.append(command) + if len(commands) == 2: + in_progress = json.loads( + (tmp_path / "audit" / "async-op.json").read_text(encoding="utf-8") + ) + assert in_progress["status"] == "processing" + assert in_progress["ticket"] == "ticket-async" + assert in_progress["last_result"]["ready"] is False + return next(responses) + + result = import_markdown_document( + "report", + title="精确标题", + working_directory=tmp_path / "work", + audit_directory=tmp_path / "audit", + source_filename="report.md", + runner=fake_run, + sleeper=lambda _seconds: None, + operation_id="async-op", + ) + + assert result.ticket == "ticket-async" + assert len(commands) == 3 + for command in commands[1:]: + assert command[1:3] == ["drive", "+task_result"] + assert command[command.index("--scenario") + 1] == "import" + assert command[command.index("--ticket") + 1] == "ticket-async" + assert command[command.index("--as") + 1] == "user" + audit = json.loads(result.audit_path.read_text(encoding="utf-8")) + assert audit["poll_attempts"] == 2 + assert audit["ticket"] == "ticket-async" + assert audit["last_result"]["ready"] is True + + +def test_import_reports_failed_task_without_retrying_mutation(tmp_path): + leaked_document_token = "VERY_SECRET_FAILED_DOCUMENT_TOKEN" + leaked_open_id = "ou_SHOULD_NOT_BE_IN_AUDIT" + responses = iter( + [ + _completed( + { + "ticket": "ticket-failed", + "ready": False, + "failed": False, + } + ), + _completed( + { + "ticket": "ticket-failed", + "ready": False, + "failed": True, + "job_status": -1, + "job_status_label": "failed", + "job_error_msg": ( + "conversion failed at " + f"https://example.feishu.cn/docx/{leaked_document_token} " + f'\"token\":\"{leaked_document_token}\" ' + f'\"open_id\":\"{leaked_open_id}\"' + ), + } + ), + ] + ) + commands = [] + + def fake_run(command, **_kwargs): + commands.append(command) + return next(responses) + + with pytest.raises(FeishuDocImportError, match="conversion failed") as raised: + import_markdown_document( + "report", + title="失败报告", + working_directory=tmp_path / "work", + audit_directory=tmp_path / "audit", + source_filename="report.md", + runner=fake_run, + sleeper=lambda _seconds: None, + operation_id="failed-op", + ) + + assert sum(command[1:3] == ["drive", "+import"] for command in commands) == 1 + audit_text = raised.value.audit_path.read_text(encoding="utf-8") + audit = json.loads(audit_text) + assert audit["status"] == "failed" + assert audit["ticket"] == "ticket-failed" + assert audit["last_result"]["failed"] is True + assert leaked_document_token not in audit_text + assert leaked_open_id not in audit_text + + +def test_import_timeout_keeps_ticket_for_auditable_follow_up(tmp_path): + clock = _FakeClock() + responses = iter( + [ + _completed( + {"ticket": "ticket-timeout", "ready": False, "failed": False} + ), + _completed( + { + "ticket": "ticket-timeout", + "ready": False, + "failed": False, + "job_status_label": "processing", + } + ), + ] + ) + + with pytest.raises(FeishuDocImportTimeout) as raised: + import_markdown_document( + "report", + title="超时报告", + working_directory=tmp_path / "work", + audit_directory=tmp_path / "audit", + source_filename="report.md", + runner=lambda *_args, **_kwargs: next(responses), + clock=clock.monotonic, + sleeper=clock.sleep, + wait_timeout_seconds=1, + poll_interval_seconds=1, + operation_id="timeout-op", + ) + + audit = json.loads(raised.value.audit_path.read_text(encoding="utf-8")) + assert audit["status"] == "timed_out" + assert audit["ticket"] == "ticket-timeout" + assert audit["poll_attempts"] == 1 + + +def test_mutating_import_process_timeout_is_ambiguous_and_never_retried(tmp_path): + commands = [] + + def time_out(command, **kwargs): + commands.append(command) + assert kwargs["timeout"] == 2 + raise subprocess.TimeoutExpired(command, kwargs["timeout"]) + + with pytest.raises(FeishuDocImportTimeout) as raised: + import_markdown_document( + "report", + title="有限超时报告", + working_directory=tmp_path / "work", + audit_directory=tmp_path / "audit", + source_filename="report.md", + runner=time_out, + import_command_timeout_seconds=2, + operation_id="command-timeout-op", + ) + + assert len(commands) == 1 + assert commands[0][1:3] == ["drive", "+import"] + audit = json.loads(raised.value.audit_path.read_text(encoding="utf-8")) + assert audit["status"] == "ambiguous" + assert audit["ticket"] is None + assert not list((tmp_path / "work").glob("*.md")) + + +def test_deterministic_audit_resumes_only_known_ticket_without_second_import(tmp_path): + audit_root = tmp_path / "audit" + audit_root.mkdir() + operation_id = "stable-report-op" + audit_path = audit_root / f"{operation_id}.json" + audit_path.write_text( + json.dumps( + { + "audit_schema": "gyxx.feishu-doc-import.v1", + "document_title": "固定周期报告", + "folder_target": "root", + "identity": "user", + "markdown_sha256": "previous-input-digest", + "operation_id": operation_id, + "poll_attempts": 1, + "status": "timed_out", + "target_type": "docx", + "ticket": "known-ticket", + } + ), + encoding="utf-8", + ) + commands = [] + + def fake_run(command, **_kwargs): + commands.append(command) + return _completed( + { + "ticket": "known-ticket", + "ready": True, + "failed": False, + "token": "doxcnRecoveredDocument", + "url": "https://example.feishu.cn/docx/doxcnRecoveredDocument", + } + ) + + result = import_markdown_document( + "possibly regenerated report", + title="固定周期报告", + working_directory=tmp_path / "work", + audit_directory=audit_root, + source_filename="report.md", + runner=fake_run, + operation_id=operation_id, + ) + + assert result.url.endswith("/doxcnRecoveredDocument") + assert len(commands) == 1 + assert commands[0][1:3] == ["drive", "+task_result"] + assert all(command[1:3] != ["drive", "+import"] for command in commands) + audit_text = audit_path.read_text(encoding="utf-8") + audit = json.loads(audit_text) + assert audit["status"] == "ready" + assert audit["resume_count"] == 1 + assert audit["resume_input_changed"] is True + assert "doxcnRecoveredDocument" not in audit_text + + +def test_resume_only_without_ticket_audit_refuses_mutating_import(tmp_path): + commands = [] + + with pytest.raises(FeishuDocImportAmbiguous, match="缺少可对账") as raised: + import_markdown_document( + "report", + title="待对账报告", + working_directory=tmp_path / "work", + audit_directory=tmp_path / "audit", + source_filename="report.md", + runner=lambda command, **_kwargs: commands.append(command), + operation_id="missing-ticket-op", + resume_only=True, + ) + + assert commands == [] + audit = json.loads(raised.value.audit_path.read_text(encoding="utf-8")) + assert audit["status"] == "ambiguous" + assert audit["ticket"] is None + + +def test_deterministic_operation_id_is_stable_and_period_specific(): + first = deterministic_import_operation_id( + "content-weekly-summary", "宙斯", "2026-07-27", "2026-08-02" + ) + same = deterministic_import_operation_id( + "content-weekly-summary", "宙斯", "2026-07-27", "2026-08-02" + ) + other_style = deterministic_import_operation_id( + "content-weekly-summary", "赫拉", "2026-07-27", "2026-08-02" + ) + + assert first == same + assert first != other_style + assert first.startswith("content-weekly-summary-") + assert len(first.rsplit("-", 1)[1]) == 40 + + +def test_cli_json_parser_accepts_noise_and_rejects_invalid_output(): + assert _parse_cli_json( + 'notice {"notice":"update"}\n{"ready":false,"ticket":"abc"}\ntrailing' + ) == { + "ready": False, + "ticket": "abc", + } + with pytest.raises(ValueError, match="no JSON"): + _parse_cli_json("plain text", "also plain text") diff --git a/tests/modules/content_marketing/test_friday_relogin_parallel.py b/tests/modules/content_marketing/test_friday_relogin_parallel.py index 5641149..ac565b9 100644 --- a/tests/modules/content_marketing/test_friday_relogin_parallel.py +++ b/tests/modules/content_marketing/test_friday_relogin_parallel.py @@ -1,3 +1,7 @@ +from types import SimpleNamespace + +import pytest + from gyxx_flow.modules.content_marketing.data.tools import ( friday_relogin_parallel as relogin, ) @@ -43,6 +47,434 @@ def test_failed_platforms_are_grouped_by_their_responsible_recipient(): } +def test_legacy_route_keeps_the_existing_per_platform_recipients(monkeypatch): + observed = {} + + def fake_resolver(workflow_id, defaults): + observed["workflow_id"] = workflow_id + observed["defaults"] = defaults + return relogin.ResolvedNotificationRoute( + configured=False, + enabled=True, + app_profile=None, + open_ids=tuple(defaults), + ) + + monkeypatch.setattr(relogin, "resolve_notification_route", fake_resolver) + + route = relogin.resolve_weekly_notification_route() + + assert observed == { + "workflow_id": "content.relogin.weekly", + "defaults": ( + "ou_24cc944d6e43c69c59d6560ad4e2ae6e", + "ou_7ad5fc8012e2f741afc5346e05ffd447", + ), + } + assert relogin.recipients_for_platform("pgy", route) == ( + "ou_24cc944d6e43c69c59d6560ad4e2ae6e", + ) + assert relogin.recipients_for_platform("douyin", route) == ( + "ou_7ad5fc8012e2f741afc5346e05ffd447", + ) + + +def test_disabled_route_still_runs_relogin_without_sending(monkeypatch): + route = relogin.ResolvedNotificationRoute( + configured=True, + enabled=False, + app_profile="hermes-analyzer", + open_ids=(), + ) + rounds = [] + + monkeypatch.setattr( + relogin, + "current_acceptance_policy", + lambda: SimpleNamespace(enabled=False), + ) + monkeypatch.setattr( + relogin, + "resolve_weekly_notification_route", + lambda _override=None: route, + ) + + def fake_round_factory(_args, resolved_route, notification_failures): + assert resolved_route is route + assert notification_failures == [] + + def run_round(names, attempt): + rounds.append((tuple(names), attempt)) + return {name: True for name in names} + + return run_round + + monkeypatch.setattr(relogin, "_run_round_factory", fake_round_factory) + monkeypatch.setattr( + relogin, + "_send_lark", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("disabled route must not send Lark messages") + ), + ) + monkeypatch.setattr( + relogin.sys, + "argv", + ["friday_relogin_parallel.py", "--platform", "pgy"], + ) + + assert relogin.main() == 0 + assert rounds == [(("pgy",), 1)] + + +def test_enabled_route_sends_qr_text_and_image_to_every_recipient( + monkeypatch, + tmp_path, +): + route = relogin.ResolvedNotificationRoute( + configured=True, + enabled=True, + app_profile="hermes-analyzer", + open_ids=("ou_first123", "ou_second456"), + ) + sent = [] + image = tmp_path / "qrcode.png" + + monkeypatch.setattr( + relogin, + "_send_lark", + lambda recipient, text=None, image=None, app_profile=None: sent.append( + (recipient, text, image, app_profile) + ), + ) + + failures = relogin._send_qr_notification( + route, + "pgy", + override=None, + text="请扫码", + image=image, + ) + + assert failures == () + assert sent == [ + ("ou_first123", "请扫码", None, "hermes-analyzer"), + ("ou_first123", None, image, "hermes-analyzer"), + ("ou_second456", "请扫码", None, "hermes-analyzer"), + ("ou_second456", None, image, "hermes-analyzer"), + ] + assert relogin.group_platforms_by_notification_recipient( + ["pgy", "douyin"], route + ) == { + "ou_first123": ["pgy", "douyin"], + "ou_second456": ["pgy", "douyin"], + } + + +def test_qr_delivery_aggregates_errors_and_attempts_every_recipient( + monkeypatch, + tmp_path, +): + route = relogin.ResolvedNotificationRoute( + configured=True, + enabled=True, + app_profile="hermes-analyzer", + open_ids=("ou_first123", "ou_second456"), + ) + attempts = [] + image = tmp_path / "qrcode.png" + + def fake_send(recipient, text=None, image=None, app_profile=None): + stage = "qr_text" if text is not None else "qr_image" + attempts.append((recipient, stage, app_profile)) + if (recipient, stage) == ("ou_first123", "qr_text"): + raise RuntimeError("first text rejected") + if (recipient, stage) == ("ou_second456", "qr_image"): + raise RuntimeError("second image rejected") + + monkeypatch.setattr(relogin, "_send_lark", fake_send) + + failures = relogin._send_qr_notification( + route, + "pgy", + override=None, + text="请扫码", + image=image, + ) + + assert attempts == [ + ("ou_first123", "qr_text", "hermes-analyzer"), + ("ou_first123", "qr_image", "hermes-analyzer"), + ("ou_second456", "qr_text", "hermes-analyzer"), + ("ou_second456", "qr_image", "hermes-analyzer"), + ] + assert failures == ( + relogin.NotificationDeliveryFailure( + stage="qr_text", + platform="pgy", + recipient="ou_first123", + detail="first text rejected", + ), + relogin.NotificationDeliveryFailure( + stage="qr_image", + platform="pgy", + recipient="ou_second456", + detail="second image rejected", + ), + ) + + +def test_failure_summary_aggregates_every_recipient_error(monkeypatch): + route = relogin.ResolvedNotificationRoute( + configured=True, + enabled=True, + app_profile="hermes-analyzer", + open_ids=("ou_first123", "ou_second456"), + ) + attempts = [] + + def fail_send(recipient, **_kwargs): + attempts.append(recipient) + raise RuntimeError(f"rejected {recipient}") + + monkeypatch.setattr(relogin, "_send_lark", fail_send) + + failures = relogin._send_failure_summaries( + route, + ["pgy", "douyin"], + override=None, + max_attempts=3, + ) + + assert attempts == ["ou_first123", "ou_second456"] + assert [failure.stage for failure in failures] == [ + "failure_summary", + "failure_summary", + ] + assert [failure.recipient for failure in failures] == attempts + assert [failure.platform for failure in failures] == [ + "pgy,douyin", + "pgy,douyin", + ] + + +def test_main_returns_failure_and_reports_all_notification_errors( + monkeypatch, + capsys, +): + route = relogin.ResolvedNotificationRoute( + configured=True, + enabled=True, + app_profile="hermes-analyzer", + open_ids=("ou_first123", "ou_second456"), + ) + monkeypatch.setattr( + relogin, + "current_acceptance_policy", + lambda: SimpleNamespace(enabled=False), + ) + monkeypatch.setattr( + relogin, + "resolve_weekly_notification_route", + lambda _override=None: route, + ) + + def fake_round_factory(_args, _route, notification_failures): + notification_failures.extend( + ( + relogin.NotificationDeliveryFailure( + "qr_text", "pgy", "ou_first123", "text rejected" + ), + relogin.NotificationDeliveryFailure( + "qr_image", "pgy", "ou_second456", "image rejected" + ), + ) + ) + return lambda names, _attempt: {name: True for name in names} + + monkeypatch.setattr(relogin, "_run_round_factory", fake_round_factory) + monkeypatch.setattr( + relogin.sys, + "argv", + ["friday_relogin_parallel.py", "--platform", "pgy"], + ) + + assert relogin.main() == 2 + output = capsys.readouterr().out + assert "Final relogin status: {'pgy': True}" in output + assert "Final notification status: failed (2 deliveries)" in output + assert "recipient=ou_first123 error=text rejected" in output + assert "recipient=ou_second456 error=image rejected" in output + + +def test_round_completes_relogin_after_notification_failure(monkeypatch, tmp_path): + route = relogin.ResolvedNotificationRoute( + configured=True, + enabled=True, + app_profile="hermes-analyzer", + open_ids=("ou_first123", "ou_second456"), + ) + args = SimpleNamespace( + round_timeout=10, + screenshot_delay=0, + no_send=False, + recipient=None, + max_attempts=1, + ) + failures = [] + attempts = [] + waits = [] + + class Process: + pid = 1234 + + def wait(self, timeout): + waits.append(timeout) + return 0 + + monkeypatch.setattr(relogin.subprocess, "Popen", lambda *_args, **_kwargs: Process()) + monkeypatch.setattr(relogin, "environment_for_child_script", lambda *_args: {}) + monkeypatch.setattr( + relogin, + "binding_from_environment", + lambda _environment: SimpleNamespace(cookie_file=tmp_path / "cookies.json"), + ) + monkeypatch.setattr(relogin.time, "sleep", lambda _seconds: None) + monkeypatch.setattr(relogin, "_login_windows", lambda: {"pgy": 101}) + monkeypatch.setattr(relogin, "_tile_windows", lambda _handles: None) + monkeypatch.setattr(relogin, "_capture_window", lambda _hwnd, _output: None) + monkeypatch.setattr(relogin, "_valid_cookie", lambda *_args: True) + + def fake_send(recipient, text=None, image=None, **_kwargs): + stage = "qr_text" if text is not None else "qr_image" + attempts.append((recipient, stage)) + if (recipient, stage) == ("ou_first123", "qr_text"): + raise RuntimeError("temporary send failure") + + monkeypatch.setattr(relogin, "_send_lark", fake_send) + + run_round = relogin._run_round_factory(args, route, failures) + + assert run_round(["pgy"], 1) == {"pgy": True} + assert waits == [100] + assert attempts == [ + ("ou_first123", "qr_text"), + ("ou_first123", "qr_image"), + ("ou_second456", "qr_text"), + ("ou_second456", "qr_image"), + ] + assert failures == [ + relogin.NotificationDeliveryFailure( + stage="qr_text", + platform="pgy", + recipient="ou_first123", + detail="temporary send failure", + ) + ] + + +@pytest.mark.parametrize( + ("returncode", "cookie_valid", "expected_status", "expected_failure_count"), + ( + (0, True, True, 0), + (1, False, False, 4), + ), +) +def test_missing_qr_window_is_classified_after_relogin_result( + monkeypatch, + tmp_path, + returncode, + cookie_valid, + expected_status, + expected_failure_count, +): + route = relogin.ResolvedNotificationRoute( + configured=True, + enabled=True, + app_profile="hermes-analyzer", + open_ids=("ou_first123", "ou_second456"), + ) + args = SimpleNamespace( + round_timeout=10, + screenshot_delay=0, + no_send=False, + recipient=None, + max_attempts=1, + ) + failures = [] + + class Process: + pid = 1234 + + def wait(self, timeout): + assert timeout == 100 + return returncode + + monkeypatch.setattr(relogin.subprocess, "Popen", lambda *_args, **_kwargs: Process()) + monkeypatch.setattr(relogin, "environment_for_child_script", lambda *_args: {}) + monkeypatch.setattr( + relogin, + "binding_from_environment", + lambda _environment: SimpleNamespace(cookie_file=tmp_path / "cookies.json"), + ) + monkeypatch.setattr(relogin.time, "sleep", lambda _seconds: None) + monkeypatch.setattr(relogin, "_login_windows", lambda: {}) + monkeypatch.setattr(relogin, "_window_for_process", lambda _pid: None) + monkeypatch.setattr(relogin, "_tile_windows", lambda _handles: None) + monkeypatch.setattr(relogin, "_valid_cookie", lambda *_args: cookie_valid) + monkeypatch.setattr( + relogin, + "_send_lark", + lambda *_args, **_kwargs: pytest.fail( + "no QR window must not start a send before child classification" + ), + ) + + run_round = relogin._run_round_factory(args, route, failures) + + assert run_round(["pgy"], 1) == {"pgy": expected_status} + assert len(failures) == expected_failure_count + assert {failure.stage for failure in failures} <= {"qr_text", "qr_image"} + assert all( + failure.detail == "no visible QR window and relogin did not succeed" + for failure in failures + ) + + +def test_lark_send_uses_shared_analyzer_user_sender_for_text_and_image( + monkeypatch, + tmp_path, +): + calls = [] + image = tmp_path / "qrcode.png" + image.write_bytes(b"png") + + monkeypatch.setattr(relogin, "ANALYZER_DIR", tmp_path) + monkeypatch.setenv("HERMES_ANALYZER_TOKEN", "must-not-leak") + + def fake_send(**kwargs): + calls.append(kwargs) + return {"message_id": f"om_{len(calls)}"} + + monkeypatch.setattr(relogin, "send_lark_bot_message", fake_send) + + receipts = relogin._send_lark( + "ou_recipient123", + text="登录提醒", + image=image, + ) + + assert [receipt["message_id"] for receipt in receipts] == ["om_1", "om_2"] + assert calls[0]["user_id"] == "ou_recipient123" + assert calls[0]["text"] == "登录提醒" + assert "image" not in calls[0] + assert calls[1]["user_id"] == "ou_recipient123" + assert calls[1]["image"] == image + assert "text" not in calls[1] + assert all(call["profile"] == "hermes-analyzer" for call in calls) + assert all("HERMES_ANALYZER_TOKEN" not in call["env"] for call in calls) + + def test_login_windows_are_classified_by_title(): assert relogin._platform_from_window_title("账号登录 - Google Chrome") == "bilibili" assert relogin._platform_from_window_title("小红书蒲公英 - Google Chrome") == "pgy" diff --git a/tests/modules/content_marketing/test_global_creator_scraping.py b/tests/modules/content_marketing/test_global_creator_scraping.py index cb56b98..02f836c 100644 --- a/tests/modules/content_marketing/test_global_creator_scraping.py +++ b/tests/modules/content_marketing/test_global_creator_scraping.py @@ -169,6 +169,73 @@ def test_xingtu_search_and_open_creator_submits_only_one_search(monkeypatch): assert calls == [("同一达人", "creator-1")] +def test_creator_search_attempts_id_then_nickname_and_skips_blank_id(): + assert xingtu._creator_search_attempts("Rm.", "Ruan97") == [ + ("ID", "Ruan97", "Ruan97"), + ("昵称", "Rm.", None), + ] + assert xingtu._creator_search_attempts("Rm.", " ") == [ + ("昵称", "Rm.", None), + ] + + +def test_xingtu_primary_retries_only_unmatched_tasks_through_buyin(monkeypatch): + xingtu.configure_source("xingtu") + styles = [{"index": 1, "name": "款式A"}] + context = {"index": 1} + tasks = [ + {"record_id": "hit", "creator_name": "达人A", "creator_id": "id-a", "style_context": context}, + {"record_id": "miss", "creator_name": "达人B", "creator_id": "id-b", "style_context": context}, + ] + summaries = { + 1: { + "style": "款式A", + "index": 1, + "total": 2, + "matched": 0, + "filled": 0, + "skipped_no_pubtime": 0, + "results": [], + } + } + calls = [] + + def fake_collect(*_args, **_kwargs): + return tasks, summaries + + def fake_scrape(*_args, **kwargs): + source = xingtu.CONTENT_SOURCE + selected = kwargs["task_override"] + calls.append((source, [task["record_id"] for task in selected], kwargs.get("replace_existing", False))) + for task in selected: + matched = source == "buyin" or task["record_id"] == "hit" + xingtu.upsert_result(summaries[1], { + "record_id": task["record_id"], + "matched": matched, + "status": xingtu.SUCCESS if matched else xingtu.RETRYABLE_FAILURE, + "reason": None if matched else "title_unmatched", + }) + return [summaries[1]] + + monkeypatch.setattr(xingtu, "collect_tasks_across_styles", fake_collect) + monkeypatch.setattr(xingtu, "scrape_styles", fake_scrape) + try: + result = xingtu.scrape_styles_with_fallback( + styles, 1, False, None, True, + ) + finally: + xingtu.configure_source("xingtu") + + assert calls == [ + ("xingtu", ["hit", "miss"], False), + ("buyin", ["miss"], True), + ] + rows = {row["record_id"]: row for row in result[0]["results"]} + assert rows["hit"]["collection_source"] == "xingtu" + assert rows["miss"]["collection_source"] == "buyin_fallback" + assert rows["miss"]["fallback_from"] == "xingtu" + + def test_xingtu_search_budget_blocks_duplicate_without_failure_circuit(): budget = xingtu.CreatorSearchBudget(max_consecutive_failures=3) @@ -291,20 +358,22 @@ def test_scheduled_collection_has_no_automatic_retry_and_syncs_once(): ) -def test_daily_collection_uses_dedicated_self_operated_pipeline(): +def test_daily_collection_includes_self_operated_pipeline(): steps = _workflow("content.metrics.daily").steps entries = [step.entry for step in steps] run_step = next(step for step in steps if step.entry == "run_all.py") assert "--daily-scope" in run_step.args assert "--include-self-operated" not in run_step.args - assert "data/tools/refresh_self_mapping.py" in entries - assert "self_bilibili_scraper.py" in entries - assert "chanmama_scraper.py" in entries - refresh_index = entries.index("data/tools/refresh_self_mapping.py") - bili_index = entries.index("self_bilibili_scraper.py") - douyin_index = entries.index("chanmama_scraper.py") - assert refresh_index < bili_index < douyin_index + assert entries.index("run_all.py") < entries.index( + "data/tools/refresh_self_mapping.py" + ) + assert entries.index("data/tools/refresh_self_mapping.py") < entries.index( + "self_bilibili_scraper.py" + ) + assert entries.index("self_bilibili_scraper.py") < entries.index( + "chanmama_scraper.py" + ) @pytest.mark.parametrize( diff --git a/tests/modules/content_marketing/test_notes_master_sync.py b/tests/modules/content_marketing/test_notes_master_sync.py new file mode 100644 index 0000000..cf47be9 --- /dev/null +++ b/tests/modules/content_marketing/test_notes_master_sync.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from gyxx_flow.modules.content_marketing.data.tools import sync_notes_master as sync + + +def _table() -> dict: + return { + "name": "宙斯", + "base_token": "base-token", + "table_id": "tbl-source", + "field_map": { + "note_title": {"field_id": "title"}, + "publish_time": {"field_id": "published"}, + "note_url": {"field_id": "url"}, + "platform": {"field_id": "platform"}, + "creator_name": {"field_id": "creator"}, + }, + } + + +def test_parse_records_counts_only_complete_note_rows() -> None: + rows = sync.parse_records( + _table(), + [ + { + "record_id": "complete", + "title": "通勤双肩包实测", + "published": "2026-08-11 09:20:00", + "url": "[笔记](https://www.xiaohongshu.com/explore/abc)", + "platform": "小红书", + "creator": "达人甲", + }, + { + "record_id": "missing-publish-time", + "title": "只有标题和链接", + "published": None, + "url": "https://www.douyin.com/video/123", + "platform": "抖音", + "creator": "达人乙", + }, + ], + self_operated=False, + ) + + assert rows[0].is_countable is True + assert rows[0].platform == "xiaohongshu" + assert rows[0].publish_time == "2026-08-11 09:20:00+08:00" + assert rows[0].url == "https://www.xiaohongshu.com/explore/abc" + assert rows[1].is_countable is False + + +def test_platform_can_be_inferred_from_url_when_source_cell_is_empty() -> None: + rows = sync.parse_records( + _table(), + [{ + "record_id": "bili", + "title": "B站开箱", + "published": "2026-08-10", + "url": "https://www.bilibili.com/video/BV1test", + "platform": "", + "creator": "自营号", + }], + self_operated=True, + ) + + assert rows[0].platform == "bilibili" + assert rows[0].is_countable is True + + +def test_unrecognized_source_text_is_bounded_to_database_columns() -> None: + rows = sync.parse_records( + _table(), + [{ + "record_id": "unexpected-long-values", + "title": "题" * 600, + "published": "2026-08-10", + "url": "not-a-platform-url", + "platform": "unexpected-platform-value-" * 4, + "creator": "创" * 300, + }], + self_operated=False, + ) + + assert len(rows[0].platform) == 32 + assert len(rows[0].creator_name) == 255 + assert len(rows[0].title) == 512 + + +def test_collaboration_fields_parsed_only_for_collaborator_tables() -> None: + table = _table() + table["field_map"].update( + { + "cost": {"field_id": "cost"}, + "tracking_no": {"field_id": "tracking"}, + "is_paid": {"field_id": "paid"}, + "follower_count": {"field_id": "followers"}, + } + ) + records = [{ + "record_id": "coop-1", + "title": "合作笔记", + "published": "2026-08-11", + "url": "https://www.douyin.com/video/9", + "platform": "抖音", + "creator": "达人丙", + "cost": 1200, + "tracking": "SF123", + "paid": True, + "followers": "3.7万", + }] + + coop_row = sync.parse_records(table, records, self_operated=False)[0] + assert coop_row.coop_attrs["cooperation_cost"] == 1200.0 + assert coop_row.coop_attrs["tracking_number"] == "SF123" + assert coop_row.coop_attrs["is_paid"] is True + assert coop_row.creator_attrs["follower_count_num"] == 37000 + + self_row = sync.parse_records(table, records, self_operated=True)[0] + assert self_row.coop_attrs == {} + assert self_row.creator_attrs == {} + + +def test_dry_run_fetches_both_scopes_without_opening_database(monkeypatch) -> None: + collaborator = {**_table(), "name": "合作款"} + self_operated = {**_table(), "name": "自营款", "table_id": "tbl-self"} + monkeypatch.setattr( + sync, + "load_sources", + lambda **_kwargs: [(False, collaborator), (True, self_operated)], + ) + monkeypatch.setattr( + sync.feishu_mapping, + "_record_list_all", + lambda *_args: [{ + "record_id": "one", + "title": "完整笔记", + "published": "2026-08-11", + "url": "https://www.douyin.com/video/1", + "platform": "抖音", + "creator": "达人", + }], + ) + monkeypatch.setattr( + sync.psycopg, + "connect", + lambda **_kwargs: (_ for _ in ()).throw( + AssertionError("dry-run must not open PostgreSQL") + ), + ) + + summary = sync.synchronize() + + assert summary == { + "tables": 2, + "rows": 2, + "creators": 0, + "countable": 2, + "incomplete": 0, + "deactivated": 0, + } + + +def test_sync_table_deactivates_only_rows_missing_from_same_source() -> None: + class Cursor: + rowcount = 3 + + def __init__(self) -> None: + self.calls = [] + + def execute(self, sql, params=None): + self.calls.append((" ".join(sql.split()), params)) + + def fetchone(self): + return (42,) + + cur = Cursor() + rows = [ + sync.MasterRow( + record_id="rec-1", + platform="xiaohongshu", + creator_name="达人", + title="标题", + publish_time="2026-08-11 00:00:00+08:00", + url="https://www.xiaohongshu.com/explore/1", + ) + ] + + result = sync.sync_table(cur, _table(), self_operated=False, rows=rows) + + assert result["countable"] == 1 + deactivate_sql, params = cur.calls[-1] + assert "source_base_token = %s AND source_table_id = %s" in deactivate_sql + assert "NOT (feishu_record_id = ANY(%s))" in deactivate_sql + assert params == ("base-token", "tbl-source", ["rec-1"]) diff --git a/tests/modules/content_marketing/test_relogin_rollback.py b/tests/modules/content_marketing/test_relogin_rollback.py index 7454e6e..35df412 100644 --- a/tests/modules/content_marketing/test_relogin_rollback.py +++ b/tests/modules/content_marketing/test_relogin_rollback.py @@ -15,25 +15,31 @@ from gyxx_flow.modules.content_marketing.data.tools import ( @pytest.mark.parametrize("module", [relogin_pgy, relogin_xingtu]) def test_failed_relogin_restores_cookie_and_profile(tmp_path, monkeypatch, module): cookie = tmp_path / "cookies.json" + storage = tmp_path / "storage_state.json" profile = tmp_path / "profile" cookie.write_text("old-cookie", encoding="utf-8") + storage.write_text("old-storage", encoding="utf-8") profile.mkdir() (profile / "state.txt").write_text("old-profile", encoding="utf-8") monkeypatch.setattr(module, "COOKIE_FILE", cookie) + monkeypatch.setattr(module, "STATE_FILE", storage) monkeypatch.setattr(module, "PROFILE_DIR", profile) - cookie_backup, profile_backup = module.reset_state() + cookie_backup, profile_backup, state_backup = module.reset_state() assert not cookie.exists() + assert not storage.exists() assert not profile.exists() cookie.write_text("partial-new-cookie", encoding="utf-8") + storage.write_text("partial-new-storage", encoding="utf-8") profile.mkdir() (profile / "partial.txt").write_text("partial", encoding="utf-8") - module.restore_previous_state(cookie_backup, profile_backup) + module.restore_previous_state(cookie_backup, profile_backup, state_backup) assert cookie.read_text(encoding="utf-8") == "old-cookie" + assert storage.read_text(encoding="utf-8") == "old-storage" assert (profile / "state.txt").read_text(encoding="utf-8") == "old-profile" assert not (profile / "partial.txt").exists() @@ -41,21 +47,27 @@ def test_failed_relogin_restores_cookie_and_profile(tmp_path, monkeypatch, modul @pytest.mark.parametrize("module", [relogin_pgy, relogin_xingtu]) def test_successful_relogin_keeps_new_cookie(tmp_path, monkeypatch, module): cookie = tmp_path / "cookies.json" + storage = tmp_path / "storage_state.json" profile = tmp_path / "profile" cookie.write_text("old-cookie", encoding="utf-8") + storage.write_text("old-storage", encoding="utf-8") profile.mkdir() monkeypatch.setattr(module, "COOKIE_FILE", cookie) + monkeypatch.setattr(module, "STATE_FILE", storage) monkeypatch.setattr(module, "PROFILE_DIR", profile) - cookie_backup, profile_backup = module.reset_state() + cookie_backup, profile_backup, state_backup = module.reset_state() cookie.write_text("new-cookie", encoding="utf-8") + storage.write_text("new-storage", encoding="utf-8") profile.mkdir() module.finish_successful_relogin(profile_backup) assert cookie.read_text(encoding="utf-8") == "new-cookie" + assert storage.read_text(encoding="utf-8") == "new-storage" assert cookie_backup.exists() + assert state_backup.exists() assert not profile_backup.exists() @@ -127,6 +139,82 @@ def test_xingtu_index_page_is_recognized_as_logged_in(): assert xingtu_scraper_v2.is_logged_in(Page()) is True +def test_xingtu_index_page_with_xingtu_session_is_logged_in_without_market_ui(): + class Locator: + @property + def first(self): + return self + + def count(self): + return 0 + + def wait_for(self, timeout): + raise xingtu_scraper_v2.BrowserTimeoutError("not on market page") + + def is_visible(self): + return False + + class Context: + def cookies(self): + return [ + { + "name": "sessionid", + "value": "fresh", + "domain": ".xingtu.cn", + } + ] + + class Page: + url = "https://www.xingtu.cn/ad/creator/index" + context = Context() + + def get_by_text(self, *_args, **_kwargs): + return Locator() + + def locator(self, *_args, **_kwargs): + return Locator() + + assert xingtu_scraper_v2.is_logged_in(Page()) is True + + +def test_xingtu_index_page_without_xingtu_session_is_not_logged_in(): + class Locator: + @property + def first(self): + return self + + def count(self): + return 0 + + def wait_for(self, timeout): + raise xingtu_scraper_v2.BrowserTimeoutError("missing") + + def is_visible(self): + return False + + class Context: + def cookies(self): + return [ + { + "name": "sessionid", + "value": "douyin-only", + "domain": ".douyin.com", + } + ] + + class Page: + url = "https://www.xingtu.cn/ad/creator/index" + context = Context() + + def get_by_text(self, *_args, **_kwargs): + return Locator() + + def locator(self, *_args, **_kwargs): + return Locator() + + assert xingtu_scraper_v2.is_logged_in(Page()) is False + + def test_xingtu_public_homepage_is_not_logged_in_even_with_stale_cookie(): class Context: def cookies(self): @@ -149,7 +237,7 @@ def test_xingtu_market_page_without_business_ui_rejects_stale_cookie(): return self def wait_for(self, **_kwargs): - raise xingtu_scraper_v2.PlaywrightTimeoutError("missing") + raise xingtu_scraper_v2.BrowserTimeoutError("missing") def count(self): return 0 @@ -282,25 +370,64 @@ def test_xingtu_scan_login_follows_replacement_page_after_oauth_tab_closes(monke assert saved == [market_page] +def test_xingtu_scan_login_detects_business_tab_while_qr_tab_stays_open(monkeypatch): + class Context: + def __init__(self): + self.pages = [] + + class Page: + def __init__(self, context, url): + self.context = context + self.url = url + + def is_closed(self): + return False + + def wait_for_timeout(self, _milliseconds): + raise AssertionError("authenticated tab should be detected immediately") + + context = Context() + qr_page = Page(context, "https://open.douyin.com/platform/oauth/pc/auth") + index_page = Page(context, "https://www.xingtu.cn/ad/creator/index") + context.pages = [qr_page, index_page] + saved = [] + + monkeypatch.setattr(xingtu_scraper_v2, "open_scan_login", lambda _page: None) + monkeypatch.setattr( + xingtu_scraper_v2, "is_logged_in", lambda page: page is index_page + ) + monkeypatch.setattr(xingtu_scraper_v2, "save_state", lambda page: saved.append(page)) + + result = xingtu_scraper_v2.wait_for_scan_login(qr_page, 2) + + assert result is index_page + assert saved == [index_page] + + def test_xingtu_interrupted_transaction_is_recovered(tmp_path, monkeypatch): cookie = tmp_path / "xingtu_cookies.json" + storage = tmp_path / "xingtu_storage_state.json" profile = tmp_path / "profile" cookie.write_text("old-cookie", encoding="utf-8") + storage.write_text("old-storage", encoding="utf-8") profile.mkdir() (profile / "old.txt").write_text("old-profile", encoding="utf-8") monkeypatch.setattr(relogin_xingtu, "COOKIE_FILE", cookie) + monkeypatch.setattr(relogin_xingtu, "STATE_FILE", storage) monkeypatch.setattr(relogin_xingtu, "PROFILE_DIR", profile) - cookie_backup, profile_backup = relogin_xingtu.reset_state() + cookie_backup, profile_backup, state_backup = relogin_xingtu.reset_state() profile.mkdir() (profile / "partial.txt").write_text("partial", encoding="utf-8") assert relogin_xingtu.recover_interrupted_state() is True assert cookie.read_text(encoding="utf-8") == "old-cookie" + assert storage.read_text(encoding="utf-8") == "old-storage" assert (profile / "old.txt").read_text(encoding="utf-8") == "old-profile" assert not (profile / "partial.txt").exists() assert cookie_backup is not None + assert state_backup is not None assert profile_backup is not None diff --git a/tests/modules/content_marketing/test_relogin_runtime_bindings.py b/tests/modules/content_marketing/test_relogin_runtime_bindings.py new file mode 100644 index 0000000..dfa2df2 --- /dev/null +++ b/tests/modules/content_marketing/test_relogin_runtime_bindings.py @@ -0,0 +1,410 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import ModuleType + +import pytest + +from gyxx_flow.adapters.integration import RuntimeIntegrationCatalog +from gyxx_flow.modules.content_marketing.data.tools import ( + relogin_bilibili, + relogin_douyin, + relogin_pgy, + relogin_runtime_paths, + relogin_xiaohongshu, + relogin_xingtu, +) +from gyxx_flow.modules.content_marketing.data.tools.relogin_runtime_paths import ( + CONSUMER_BINDING_IDS, + child_browser_environment, + resolve_relogin_runtime_paths, + sync_relogin_state_to_consumers, +) +from gyxx_flow.modules.content_marketing.runtime_paths import PATHS + +PROJECT_ROOT = Path(__file__).resolve().parents[3] + + +def test_relogin_paths_prefer_injected_runtime_binding(tmp_path: Path) -> None: + cookie = tmp_path / "binding" / "cookies.json" + storage = tmp_path / "binding" / "storage_state.json" + profile = tmp_path / "binding" / "profile" + + resolved = resolve_relogin_runtime_paths( + "pgy", + { + "GYXX_BROWSER_COOKIE_FILE": str(cookie), + "GYXX_BROWSER_STORAGE_STATE_FILE": str(storage), + "GYXX_BROWSER_PROFILE_DIR": str(profile), + }, + ) + + assert resolved.cookie_file == cookie.resolve() + assert resolved.storage_state_file == storage.resolve() + assert resolved.profile_dir == profile.resolve() + + +def test_relogin_paths_keep_legacy_standalone_layout() -> None: + resolved = resolve_relogin_runtime_paths("xiaohongshu", {}) + + assert resolved.cookie_file == ( + PATHS.state_root / "cookies/xiaohongshu_cookies.json" + ).resolve() + assert resolved.storage_state_file == ( + PATHS.state_root / "cookies/xiaohongshu_storage_state.json" + ).resolve() + assert resolved.profile_dir == ( + PATHS.state_root / "browser-profiles/xiaohongshu" + ).resolve() + + +def test_child_login_environment_preserves_wrapper_binding(tmp_path: Path) -> None: + cookie = tmp_path / "cookies.json" + storage = tmp_path / "storage_state.json" + profile = tmp_path / "profile" + + environment = child_browser_environment( + cookie_file=cookie, + storage_state_file=storage, + profile_dir=profile, + environment={"GYXX_MODULE_ID": "content_marketing", "KEEP": "1"}, + ) + + assert environment["GYXX_BROWSER_COOKIE_FILE"] == str(cookie.resolve()) + assert environment["GYXX_BROWSER_STORAGE_STATE_FILE"] == str( + storage.resolve() + ) + assert environment["GYXX_BROWSER_PROFILE_DIR"] == str(profile.resolve()) + assert "GYXX_MODULE_ID" not in environment + assert environment["KEEP"] == "1" + + +@pytest.mark.parametrize( + ("module", "platform", "login_cookie", "argv"), + [ + ( + relogin_bilibili, + "bilibili", + "SESSDATA", + ["relogin_bilibili.py"], + ), + (relogin_douyin, "douyin", "sessionid", ["relogin_douyin.py"]), + (relogin_pgy, "pgy", "web_session", ["relogin_pgy.py"]), + ( + relogin_xiaohongshu, + "xiaohongshu", + "web_session", + ["relogin_xiaohongshu.py"], + ), + ( + relogin_xingtu, + "xingtu", + "sessionid", + ["relogin_xingtu.py", "--force"], + ), + ], +) +def test_relogin_wrapper_validates_the_same_binding_written_by_child( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + module: ModuleType, + platform: str, + login_cookie: str, + argv: list[str], +) -> None: + cookie = tmp_path / "cookies.json" + storage = tmp_path / "storage_state.json" + profile = tmp_path / "profile" + monkeypatch.setattr(module, "COOKIE_FILE", cookie) + monkeypatch.setattr(module, "STATE_FILE", storage) + monkeypatch.setattr(module, "PROFILE_DIR", profile) + monkeypatch.setattr(module.sys, "argv", argv) + monkeypatch.setenv("GYXX_MODULE_ID", "content_marketing") + if module is relogin_xingtu: + monkeypatch.setattr(module, "terminate_profile_processes", lambda: None) + monkeypatch.setattr(module, "sync_to_douyin_scraper", lambda: None) + synced: list[tuple[str, Path, Path]] = [] + + def fake_sync( + synced_platform: str, + *, + cookie_file: Path, + storage_state_file: Path, + ) -> tuple[Path, ...]: + synced.append( + (synced_platform, cookie_file.resolve(), storage_state_file.resolve()) + ) + return () + + monkeypatch.setattr(module, "sync_relogin_state_to_consumers", fake_sync) + + def fake_call( + _command: list[str], + *, + cwd: str, + env: dict[str, str], + ) -> int: + assert cwd == str(module.PROJECT_DIR) + assert env["GYXX_BROWSER_COOKIE_FILE"] == str(cookie.resolve()) + assert env["GYXX_BROWSER_STORAGE_STATE_FILE"] == str(storage.resolve()) + assert env["GYXX_BROWSER_PROFILE_DIR"] == str(profile.resolve()) + assert "GYXX_MODULE_ID" not in env + cookie.write_text( + json.dumps([{"name": login_cookie, "value": "fresh"}]), + encoding="utf-8", + ) + storage.write_text("{}", encoding="utf-8") + profile.mkdir() + return 0 + + monkeypatch.setattr(module.subprocess, "call", fake_call) + + assert module.main() == 0 + assert cookie.is_file() + assert storage.is_file() + assert profile.is_dir() + assert synced == [(platform, cookie.resolve(), storage.resolve())] + + +@pytest.mark.parametrize( + ("module", "platform", "login_cookie", "cookie_domain", "argv"), + [ + ( + relogin_bilibili, + "bilibili", + "SESSDATA", + ".bilibili.com", + ["relogin_bilibili.py"], + ), + ( + relogin_douyin, + "douyin", + "sessionid", + ".douyin.com", + ["relogin_douyin.py"], + ), + ( + relogin_pgy, + "pgy", + "web_session", + ".xiaohongshu.com", + ["relogin_pgy.py"], + ), + ( + relogin_xiaohongshu, + "xiaohongshu", + "web_session", + ".xiaohongshu.com", + ["relogin_xiaohongshu.py"], + ), + ( + relogin_xingtu, + "xingtu", + "sessionid", + ".xingtu.cn", + ["relogin_xingtu.py", "--force"], + ), + ], +) +def test_relogin_wrapper_restores_previous_state_when_publish_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + module: ModuleType, + platform: str, + login_cookie: str, + cookie_domain: str, + argv: list[str], +) -> None: + cookie = tmp_path / "cookies.json" + storage = tmp_path / "storage_state.json" + profile = tmp_path / "profile" + old_cookie = '[{"name":"old","value":"old-session"}]' + old_storage = '{"cookies":[{"name":"old"}]}' + cookie.write_text(old_cookie, encoding="utf-8") + storage.write_text(old_storage, encoding="utf-8") + profile.mkdir() + (profile / "old-state.txt").write_text("old-profile", encoding="utf-8") + monkeypatch.setattr(module, "COOKIE_FILE", cookie) + monkeypatch.setattr(module, "STATE_FILE", storage) + monkeypatch.setattr(module, "PROFILE_DIR", profile) + monkeypatch.setattr(module.sys, "argv", argv) + if module is relogin_xingtu: + monkeypatch.setattr(module, "terminate_profile_processes", lambda: None) + monkeypatch.setattr(module, "sync_to_douyin_scraper", lambda: None) + + def fake_call( + _command: list[str], + *, + cwd: str, + env: dict[str, str], + ) -> int: + assert cwd == str(module.PROJECT_DIR) + assert env["GYXX_BROWSER_COOKIE_FILE"] == str(cookie.resolve()) + cookie.write_text( + json.dumps( + [ + { + "name": login_cookie, + "value": "fresh-session", + "domain": cookie_domain, + } + ] + ), + encoding="utf-8", + ) + storage.write_text('{"cookies":[],"origins":[]}', encoding="utf-8") + profile.mkdir() + return 0 + + def fail_publish( + synced_platform: str, + *, + cookie_file: Path, + storage_state_file: Path, + ) -> tuple[Path, ...]: + assert synced_platform == platform + assert cookie_file == cookie + assert storage_state_file == storage + raise OSError("simulated consumer publish failure") + + monkeypatch.setattr(module.subprocess, "call", fake_call) + monkeypatch.setattr(module, "sync_relogin_state_to_consumers", fail_publish) + + assert module.main() == 1 + assert cookie.read_text(encoding="utf-8") == old_cookie + assert storage.read_text(encoding="utf-8") == old_storage + assert (profile / "old-state.txt").read_text(encoding="utf-8") == ( + "old-profile" + ) + + +@pytest.mark.parametrize( + ("platform", "cookie_name", "cookie_domain"), + [ + ("bilibili", "SESSDATA", ".bilibili.com"), + ("douyin", "sessionid", ".douyin.com"), + ("pgy", "web_session", ".xiaohongshu.com"), + ("xiaohongshu", "web_session", ".xiaohongshu.com"), + ("xingtu", "sessionid", ".xingtu.cn"), + ], +) +def test_successful_relogin_atomically_updates_unique_consumer_state( + tmp_path: Path, + platform: str, + cookie_name: str, + cookie_domain: str, +) -> None: + data_root = tmp_path / "data" + source_cookie = tmp_path / "relogin" / "cookies.json" + source_storage = tmp_path / "relogin" / "storage_state.json" + source_cookie.parent.mkdir(parents=True) + fresh_cookie = [ + { + "name": cookie_name, + "value": "fresh-session", + "domain": cookie_domain, + } + ] + fresh_storage = {"cookies": fresh_cookie, "origins": []} + source_cookie.write_text(json.dumps(fresh_cookie), encoding="utf-8") + source_storage.write_text(json.dumps(fresh_storage), encoding="utf-8") + + catalog = RuntimeIntegrationCatalog.load_default( + project_root=PROJECT_ROOT, + data_root=data_root, + ) + consumer_id = CONSUMER_BINDING_IDS[platform][0] + consumer = catalog.binding_for(consumer_id) + consumer.cookie_file.parent.mkdir(parents=True, exist_ok=True) + consumer.cookie_file.write_text('[{"name":"old"}]', encoding="utf-8") + consumer.storage_state_file.write_text( + '{"cookies":[{"name":"old"}]}', + encoding="utf-8", + ) + + destinations = sync_relogin_state_to_consumers( + platform, + cookie_file=source_cookie, + storage_state_file=source_storage, + environment={ + "GYXX_PROJECT_ROOT": str(PROJECT_ROOT), + "GYXX_DATA_ROOT": str(data_root), + }, + ) + + assert source_cookie.resolve() != consumer.cookie_file.resolve() + assert source_storage.resolve() != consumer.storage_state_file.resolve() + assert destinations == (consumer.cookie_file.resolve(),) + assert json.loads(consumer.cookie_file.read_text(encoding="utf-8")) == ( + fresh_cookie + ) + assert json.loads( + consumer.storage_state_file.read_text(encoding="utf-8") + ) == fresh_storage + + +def test_consumer_state_is_rolled_back_if_atomic_publish_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + data_root = tmp_path / "data" + source_cookie = tmp_path / "relogin" / "cookies.json" + source_storage = tmp_path / "relogin" / "storage_state.json" + source_cookie.parent.mkdir(parents=True) + source_cookie.write_text( + json.dumps( + [ + { + "name": "SESSDATA", + "value": "fresh-session", + "domain": ".bilibili.com", + } + ] + ), + encoding="utf-8", + ) + source_storage.write_text('{"cookies":[],"origins":[]}', encoding="utf-8") + + catalog = RuntimeIntegrationCatalog.load_default( + project_root=PROJECT_ROOT, + data_root=data_root, + ) + consumer = catalog.binding_for(CONSUMER_BINDING_IDS["bilibili"][0]) + consumer.cookie_file.parent.mkdir(parents=True, exist_ok=True) + old_cookie = '[{"name":"SESSDATA","value":"old"}]' + old_storage = '{"cookies":[{"name":"old"}]}' + consumer.cookie_file.write_text(old_cookie, encoding="utf-8") + consumer.storage_state_file.write_text(old_storage, encoding="utf-8") + + original_replace = relogin_runtime_paths.os.replace + failed = False + + def fail_second_replace(source: Path | str, target: Path | str) -> None: + nonlocal failed + source_path = Path(source) + target_path = Path(target) + if ( + not failed + and source_path.suffix == ".tmp" + and target_path == consumer.storage_state_file + ): + failed = True + raise OSError("simulated storage publish failure") + original_replace(source, target) + + monkeypatch.setattr(relogin_runtime_paths.os, "replace", fail_second_replace) + + with pytest.raises(OSError, match="simulated storage publish failure"): + sync_relogin_state_to_consumers( + "bilibili", + cookie_file=source_cookie, + storage_state_file=source_storage, + environment={ + "GYXX_PROJECT_ROOT": str(PROJECT_ROOT), + "GYXX_DATA_ROOT": str(data_root), + }, + ) + + assert consumer.cookie_file.read_text(encoding="utf-8") == old_cookie + assert consumer.storage_state_file.read_text(encoding="utf-8") == old_storage diff --git a/tests/modules/content_marketing/test_remote_database_config.py b/tests/modules/content_marketing/test_remote_database_config.py index 6892331..d155af5 100644 --- a/tests/modules/content_marketing/test_remote_database_config.py +++ b/tests/modules/content_marketing/test_remote_database_config.py @@ -1,6 +1,7 @@ import io import unittest from pathlib import Path +from unittest.mock import patch from gyxx_flow.adapters import RuntimeServicePolicy from gyxx_flow.modules.content_marketing.data.tools import db @@ -60,7 +61,7 @@ class LocalDatabaseConfigTests(unittest.TestCase): paths = ( ROOT / "data" / "tools" / "db.py", ROOT / "data" / "tools" / "sync_metrics_to_cmt_notes.py", - ROOT / "data" / "tools" / "sync_cooperations.py", + ROOT / "data" / "tools" / "sync_notes_master.py", ROOT / "data" / "tools" / "generate_creator_report.py", ) for path in paths: @@ -79,6 +80,33 @@ class LocalDatabaseConfigTests(unittest.TestCase): self.assertTrue(raw.getvalue()) + def test_explicit_insert_missing_preserves_default_sync_contract(self): + with ( + patch("sys.argv", ["sync_metrics_to_cmt_notes.py", "--insert-missing"]), + patch.object( + sync_metrics, + "sync", + return_value={"identity_ambiguous": 0}, + ) as sync_call, + ): + sync_metrics.main() + + sync_call.assert_called_once_with(dry_run=False, insert_missing=True) + + def test_default_sync_exits_nonzero_when_identity_is_ambiguous(self): + with ( + patch("sys.argv", ["sync_metrics_to_cmt_notes.py"]), + patch.object( + sync_metrics, + "sync", + return_value={"identity_ambiguous": 2}, + ), + self.assertRaises(SystemExit) as exc_info, + ): + sync_metrics.main() + + self.assertEqual(exc_info.exception.code, 1) + if __name__ == "__main__": unittest.main() diff --git a/tests/modules/content_marketing/test_retry_failed_completeness.py b/tests/modules/content_marketing/test_retry_failed_completeness.py index c7f4e80..f8a67a4 100644 --- a/tests/modules/content_marketing/test_retry_failed_completeness.py +++ b/tests/modules/content_marketing/test_retry_failed_completeness.py @@ -1,6 +1,7 @@ import json import os import time +from datetime import date from pathlib import Path from types import SimpleNamespace @@ -39,6 +40,54 @@ def test_success_status_cannot_hide_write_failure_and_blocked_input_is_terminal( }], } assert retry.extract_failed_records(blocked, "bili") == [] + assert retry.requested_records_succeeded(blocked, ["b"]) == (True, []) + + +def test_requested_records_require_exactly_one_resolved_blocked_terminal(): + duplicate_blocked = { + "results": [ + { + "record_id": "b", + "status": "blocked_input", + "matched": False, + "reason": "url_missing", + }, + { + "record_id": "b", + "status": "blocked_input", + "matched": False, + "reason": "url_missing", + }, + ], + } + + assert retry.requested_records_succeeded(duplicate_blocked, ["b"]) == ( + False, + ["b"], + ) + + +def test_blocked_input_cannot_hide_explicit_execution_or_write_failure(): + for explicit_failure in ( + {"write_ok": False}, + {"write_error": "database rejected update"}, + {"error": "browser session crashed"}, + ): + blocked = { + "results": [{ + "record_id": "b", + "status": "blocked_input", + "matched": False, + "reason": "url_missing", + **explicit_failure, + }], + } + + assert retry.requested_records_succeeded(blocked, ["b"]) == ( + False, + ["b"], + ) + assert retry.extract_failed_records(blocked, "bili") == ["b"] def test_canonical_filename_requires_current_style_name(): @@ -305,6 +354,87 @@ def test_validate_summary_rejects_expected_style_left_over_from_old_run(tmp_path assert "current run" in reason +def test_build_retry_targets_narrows_retryable_rows_and_expands_structural_gaps(): + targets = run_all.build_retry_targets( + { + "bili": [{ + "index": 1, + "total_b_records": 2, + "details": [ + {"record_id": "ok", "status": "success", "ok": True}, + {"record_id": "bad", "status": "retryable_failure", "ok": False}, + ], + }], + "pgy": [{ + "index": 1, + "total": 2, + "results": [{"record_id": "only-one", "status": "success"}], + }], + "xt": [{ + "index": 1, + "total": 1, + "results": [{ + "record_id": "terminal", + "status": "blocked_input", + "matched": False, + }], + }], + }, + {1}, + ) + + assert targets == { + "bili": {1: {"bad"}}, + "pgy": {1: None}, + } + + +def test_retry_manifest_round_trips_record_and_full_style_scopes(monkeypatch, tmp_path): + monkeypatch.setattr(run_all, "RETRY_MANIFEST_DIR", tmp_path) + targets = {"bili": {1: {"r2", "r1"}}, "pgy": {3: None}} + + run_all._write_retry_manifest( + date(2026, 9, 2), targets, source_slot="01:00" + ) + + assert run_all._load_retry_manifest(date(2026, 9, 2)) == targets + + +def test_retry_process_command_passes_only_target_records(): + args = SimpleNamespace( + dry_run=False, + style=[1, 2], + daily_scope=True, + no_retry=False, + ) + + command = run_all.build_process_command( + "pgy", args, retry_targets={2: {"record-b", "record-a"}} + ) + + assert command[command.index("--style") + 1] == "2" + assert command.count("--style") == 1 + assert command[command.index("--record") + 1] == "record-a" + assert command.count("--record") == 2 + assert "--no-retry" in command + + +def test_retry_process_command_uses_full_style_when_scope_is_structurally_incomplete(): + args = SimpleNamespace( + dry_run=False, + style=[1, 2], + daily_scope=True, + no_retry=False, + ) + + command = run_all.build_process_command( + "xt", args, retry_targets={1: None} + ) + + assert command[command.index("--style") + 1] == "1" + assert "--record" not in command + + def test_aggregate_counts_all_unresolved_rows_as_errors(): aggregate = run_all.aggregate({ "pgy": [{ @@ -363,6 +493,40 @@ def test_aggregate_keeps_partial_counts_when_validation_marks_payload_invalid(): assert platform["validation_error"] == "invalid summary: unresolved" +def test_aggregate_fails_closed_for_stale_payload(): + aggregate = run_all.aggregate({ + "pgy": { + "error": "invalid summary: summary file is stale", + "payload": [{ + "index": 1, + "total": 141, + "matched": 101, + "results": [], + }], + }, + }) + + platform = aggregate["platforms"]["pgy"] + assert platform["loaded"] is False + assert platform["error"] == "invalid summary: summary file is stale" + assert aggregate["totals"]["tasks"] == 0 + + +def test_aggregate_filters_merged_history_to_current_styles(): + aggregate = run_all.aggregate( + { + "xt": [ + {"index": 1, "total": 2, "matched": 1, "results": []}, + {"index": 2, "total": 99, "matched": 99, "results": []}, + ], + }, + expected_indices={1}, + ) + + assert aggregate["platforms"]["xt"]["styles"] == 1 + assert aggregate["platforms"]["xt"]["tasks"] == 2 + + def test_run_round_marks_exit_zero_platform_failed_when_summary_is_invalid(monkeypatch, tmp_path): args = SimpleNamespace(dry_run=True, style=[1]) stdout = tmp_path / "stdout.log" diff --git a/tests/modules/content_marketing/test_summary_base_delivery.py b/tests/modules/content_marketing/test_summary_base_delivery.py new file mode 100644 index 0000000..c2467f5 --- /dev/null +++ b/tests/modules/content_marketing/test_summary_base_delivery.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +from gyxx_flow.modules.content_marketing import weekly_summary_all + + +def test_target_table_payload_uses_temp_cwd_and_relative_reference( + monkeypatch, + tmp_path: Path, +) -> None: + payload_root = tmp_path / "payloads" + payload_root.mkdir() + working_directory = tmp_path / "working-directory" + working_directory.mkdir() + monkeypatch.chdir(working_directory) + monkeypatch.setattr( + weekly_summary_all, + "PATHS", + SimpleNamespace(tmp_root=payload_root), + ) + monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False) + + calls: list[tuple[list[str], Path | None]] = [] + + def fake_call_lark_json( + args: list[str], *, cwd: Path | None = None + ) -> dict[str, object]: + calls.append((args, cwd)) + if args[1] == "+record-list": + assert cwd is None + return { + "ok": True, + "data": { + "record_id_list": [], + "data": [], + "field_id_list": [], + }, + } + assert cwd == payload_root + assert cwd != Path.cwd() + payload_reference = args[args.index("--json") + 1] + assert payload_reference.startswith("@./") + payload_path = cwd / payload_reference.removeprefix("@./") + assert not Path(payload_reference[1:]).is_absolute() + assert json.loads(payload_path.read_text(encoding="utf-8")) == { + "fld_time": "2026-07-01~07-31", + "fld_summary": ( + "[凌云air 月度汇总(2026-07-01~07-31)]" + "(https://example.test/doc)" + ), + } + return {"ok": True} + + monkeypatch.setattr(weekly_summary_all, "call_lark_json", fake_call_lark_json) + + assert weekly_summary_all.write_to_target_table( + "base-test", + "table-test", + "fld_time", + "fld_summary", + "凌云air", + "https://example.test/doc", + False, + time_label="2026-07-01~07-31", + report_label="月度汇总", + ) + assert [command[1] for command, _cwd in calls] == [ + "+record-list", + "+record-upsert", + ] + + +def test_call_lark_json_logs_only_redacted_failure_detail( + monkeypatch, + capsys, + tmp_path: Path, +) -> None: + raw_secret = "sensitive-token-value" + raw_open_id = "sensitive-open-id" + captured_kwargs: dict[str, object] = {} + + def fake_run(*_args, **kwargs): + captured_kwargs.update(kwargs) + return SimpleNamespace( + returncode=1, + stdout="", + stderr=( + f"request failed; base_token={raw_secret}; " + f"open_id={raw_open_id}; https://example.test/private" + ), + ) + + monkeypatch.setattr(weekly_summary_all.subprocess, "run", fake_run) + + response = weekly_summary_all.call_lark_json( + ["base", "+record-upsert"], cwd=tmp_path + ) + + captured = capsys.readouterr().out + assert captured_kwargs["cwd"] == tmp_path + assert response["ok"] is False + assert raw_secret not in response["error"] + assert raw_open_id not in response["error"] + assert raw_secret not in captured + assert raw_open_id not in captured + assert "[REDACTED]" in captured + assert "[URL]" in captured diff --git a/tests/modules/content_marketing/test_sync_metrics_identity.py b/tests/modules/content_marketing/test_sync_metrics_identity.py new file mode 100644 index 0000000..8087ec3 --- /dev/null +++ b/tests/modules/content_marketing/test_sync_metrics_identity.py @@ -0,0 +1,1270 @@ +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import sys + +import pytest + +from gyxx_flow.modules.content_marketing.data.tools import ( + sync_metrics_to_cmt_notes as sync_metrics, +) + + +class CursorStub: + def __init__(self, *, url_note=None, record_notes=()): + self.url_note = url_note + self.record_notes = list(record_notes) + self.calls: list[tuple[str, tuple]] = [] + self._mode = "" + + def execute(self, sql: str, params: tuple) -> None: + self.calls.append((" ".join(sql.split()), params)) + self._mode = "url" if "WHERE url = %s" in sql else "record" + + def fetchone(self): + assert self._mode == "url" + return self.url_note + + def fetchall(self): + assert self._mode == "record" + return self.record_notes + + +def _metric(**overrides): + row = { + "record_id": "table-local-record", + "dst_platform": "xiaohongshu", + "self_operated": False, + "source_url": "https://example.invalid/note/owner", + } + row.update(overrides) + return row + + +def test_existing_note_lookup_prefers_url_inside_complete_owner_scope(): + cursor = CursorStub(url_note=(101, 900)) + + note, resolution = sync_metrics._lookup_existing_note(cursor, _metric(), 7) + + assert note == (101, 900) + assert resolution == "url" + assert len(cursor.calls) == 1 + sql, params = cursor.calls[0] + assert "url = %s" in sql + assert "style_id = %s" in sql + assert "platform = %s" in sql + assert "self_operated IS NOT DISTINCT FROM %s" in sql + assert params == ( + "https://example.invalid/note/owner", + 7, + "xiaohongshu", + False, + ) + + +def test_record_fallback_is_scoped_to_style_platform_and_feishu_table_kind(): + cursor = CursorStub(url_note=None, record_notes=[(202, 800)]) + + note, resolution = sync_metrics._lookup_existing_note(cursor, _metric(), 7) + + assert note == (202, 800) + assert resolution == "record" + assert len(cursor.calls) == 2 + sql, params = cursor.calls[1] + assert "feishu_record_id = %s" in sql + assert "style_id = %s" in sql + assert "platform = %s" in sql + assert "self_operated IS NOT DISTINCT FROM %s" in sql + assert params == ("table-local-record", 7, "xiaohongshu", False) + + +def test_duplicate_inside_complete_owner_scope_is_reported_ambiguous(): + cursor = CursorStub(url_note=None, record_notes=[(202, 800), (303, 700)]) + + note, resolution = sync_metrics._lookup_existing_note(cursor, _metric(), 7) + + assert note is None + assert resolution == "ambiguous" + + +def test_metric_collection_carries_source_url_for_canonical_identity( + tmp_path, monkeypatch +): + payload = { + "results": [ + { + "record_id": "table-local-record", + "note_url": "https://example.invalid/note/owner", + "read_count": 1234, + "matched": True, + "status": "success", + } + ] + } + (tmp_path / "03-盖亚微单_v2.json").write_text( + json.dumps(payload, ensure_ascii=False), + encoding="utf-8", + ) + monkeypatch.setattr(sync_metrics, "V2_DIR", tmp_path) + + rows = sync_metrics.collect_metrics_from_v2() + + assert len(rows) == 1 + assert rows[0]["style_name"] == "盖亚微单" + assert rows[0]["source_url"] == "https://example.invalid/note/owner" + + +def test_url_normalization_rejects_non_urls_and_allows_verified_xhs_cn_variant(): + assert sync_metrics._extract_url("4.17") is None + assert sync_metrics._extract_url("ttps://broken.invalid/path") is None + assert sync_metrics._url_host_matches_platform( + "https://xhslink.cn/fixture", + "xiaohongshu", + ) + assert not sync_metrics._url_host_matches_platform( + "https://not-xhslink.cn/fixture", + "xiaohongshu", + ) + + +def test_exact_owner_url_is_strict_evidence_after_scope_and_platform_validation(): + exact_redirect = "https://redirect.invalid/opaque-owner-link" + + assert sync_metrics._same_physical_content( + exact_redirect, + exact_redirect, + "xiaohongshu", + ) + assert not sync_metrics._same_physical_content( + exact_redirect, + "https://redirect.invalid/different-link", + "xiaohongshu", + ) + + +def test_metric_collection_skips_artifacts_outside_bound_business_date( + tmp_path, + monkeypatch, +): + payload = { + "details": [ + { + "record_id": "table-local-record", + "play_count": 1234, + "matched": True, + } + ] + } + current = tmp_path / "01-Current_bilibili_v2.json" + stale = tmp_path / "02-Stale_bilibili_v2.json" + for path in (current, stale): + path.write_text(json.dumps(payload), encoding="utf-8") + os.utime( + current, + ( + dt.datetime.fromisoformat("2026-08-09T12:00:00+08:00").timestamp(), + ) + * 2, + ) + os.utime( + stale, + ( + dt.datetime.fromisoformat("2026-08-01T12:00:00+08:00").timestamp(), + ) + * 2, + ) + monkeypatch.setattr(sync_metrics, "V2_DIR", tmp_path) + monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-08-09") + + rows = sync_metrics.collect_metrics_from_v2() + + assert [row["style_idx"] for row in rows] == [1] + + +def _repair_candidate(**overrides): + candidate = { + "id": 101, + "record_id": "table-local-record", + "platform": "xiaohongshu", + "style_id": 7, + "style_name": "Fixture Style", + "self_operated": False, + "url": "https://www.xiaohongshu.com/explore/content-123?source=db", + "view_count": 900, + "updated_at": dt.datetime.fromisoformat("2026-08-09T18:36:40+08:00"), + } + candidate.update(overrides) + return candidate + + +def _repair_style(target_date: dt.date): + return { + "name": "Fixture Style", + "base_token": "fixture-base", + "table_id": "fixture-table", + "field_map": { + "platform": {"field_id": "platform-field"}, + "note_url": {"field_id": "url-field"}, + "daily_exposure": { + "field_id": "daily-field", + "field_name": f"{target_date.isoformat()}曝光量", + }, + }, + } + + +def test_collision_evidence_uses_exact_owner_table_and_stable_content_id(monkeypatch): + target_date = dt.date(2026, 8, 9) + window_end = dt.datetime.fromisoformat("2026-08-09T18:36:50+08:00") + style = _repair_style(target_date) + monkeypatch.setattr( + sync_metrics, + "_record_index_for_repair", + lambda selected: { + "table-local-record": [ + { + "record_id": "table-local-record", + "platform-field": ["小红书"], + "url-field": ( + "https://www.xiaohongshu.com/explore/content-123?source=base" + ), + "daily-field": "1.2万", + } + ] + }, + ) + monkeypatch.setattr( + sync_metrics, + "_field_definitions_for_repair", + lambda selected: [ + {"id": "platform-field", "name": "投放平台"}, + {"id": "url-field", "name": "发布笔记链接"}, + {"id": "daily-field", "name": "2026-08-09曝光量"}, + ], + ) + + status, exposure = sync_metrics._collision_repair_evidence( + _repair_candidate(), + {False: {"Fixture Style": [style]}, True: {}}, + window_end=window_end, + ) + + assert status == "verified" + assert exposure == 12000 + + +def test_collision_evidence_rejects_wrong_platform_url_and_stale_daily_field( + monkeypatch, +): + target_date = dt.date(2026, 8, 9) + window_end = dt.datetime.fromisoformat("2026-08-09T18:36:50+08:00") + style = _repair_style(target_date) + record = { + "record_id": "table-local-record", + "platform-field": "抖音", + "url-field": "https://www.xiaohongshu.com/explore/content-123", + "daily-field": 1234, + } + monkeypatch.setattr( + sync_metrics, + "_record_index_for_repair", + lambda selected: {"table-local-record": [record]}, + ) + fields = [ + {"id": "platform-field", "name": "投放平台"}, + {"id": "url-field", "name": "发布笔记链接"}, + {"id": "daily-field", "name": "2026-08-09曝光量"}, + ] + monkeypatch.setattr( + sync_metrics, + "_field_definitions_for_repair", + lambda selected: fields, + ) + indexes = {False: {"Fixture Style": [style]}, True: {}} + + status, _ = sync_metrics._collision_repair_evidence( + _repair_candidate(), indexes, window_end=window_end + ) + assert status == "platform_mismatch" + + record["platform-field"] = "小红书" + record["url-field"] = "https://www.xiaohongshu.com/explore/another-content" + status, _ = sync_metrics._collision_repair_evidence( + _repair_candidate(), indexes, window_end=window_end + ) + assert status == "url_mismatch" + + record["url-field"] = "https://www.xiaohongshu.com/explore/content-123" + fields[-1]["name"] = "2026-08-10曝光量" + status, _ = sync_metrics._collision_repair_evidence( + _repair_candidate(), indexes, window_end=window_end + ) + assert status == "field_missing" + + +class RepairCursorStub: + def __init__(self, *, rows=(), update_result=(101,)): + self.rows = list(rows) + self.update_result = update_result + self.calls: list[tuple[str, tuple | None]] = [] + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def execute(self, sql: str, params: tuple | None = None): + self.calls.append((" ".join(sql.split()), params)) + + def fetchall(self): + return self.rows + + def fetchone(self): + return self.update_result + + +class RepairConnectionStub: + def __init__(self, cursor: RepairCursorStub): + self._cursor = cursor + self.rollbacks = 0 + self.commits = 0 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def cursor(self): + return self._cursor + + def rollback(self): + self.rollbacks += 1 + + def commit(self): + self.commits += 1 + + +def test_legacy_victim_filter_uses_old_lookup_only_for_candidate_scope(): + cursor = RepairCursorStub(update_result=(101, 900)) + candidates = [_repair_candidate()] + metrics = [ + { + "record_id": "table-local-record", + "dst_platform": "xiaohongshu", + "style_name": "Expected Style", + "self_operated": False, + "view_count": 999999, + } + ] + + victims = sync_metrics._legacy_wrong_candidate_ids( + cursor, + candidates, + {"Expected Style": 8}, + metrics, + ) + + assert victims == {101} + sql, params = cursor.calls[0] + assert "SELECT id, view_count FROM cmt_notes_master" in sql + assert "WHERE feishu_record_id = %s AND platform = %s" in sql + assert "style_id" not in sql + assert params == ("table-local-record", "xiaohongshu") + + +def test_collision_candidate_read_is_windowed_and_explicitly_rolled_back( + monkeypatch, +): + row = ( + 101, + "table-local-record", + "xiaohongshu", + 7, + "Fixture Style", + False, + "https://www.xiaohongshu.com/explore/content-123", + 900, + dt.datetime.fromisoformat("2026-08-09T18:36:40+08:00"), + ) + cursor = RepairCursorStub(rows=[row]) + connection = RepairConnectionStub(cursor) + monkeypatch.setattr(sync_metrics, "get_conn", lambda: connection) + monkeypatch.setattr( + sync_metrics, + "_style_id_lookup", + lambda _cursor: {"Fixture Style": 7}, + ) + monkeypatch.setattr(sync_metrics, "collect_metrics_from_v2", lambda: []) + monkeypatch.setattr( + sync_metrics, + "_legacy_wrong_candidate_ids", + lambda *_args: {101}, + ) + monkeypatch.setattr( + sync_metrics, + "_legacy_url_merge_victim_ids", + lambda *_args: set(), + ) + + windows = [ + ( + dt.datetime.fromisoformat("2026-08-09T18:36:30+08:00"), + dt.datetime.fromisoformat("2026-08-09T18:36:50+08:00"), + ) + ] + candidates = sync_metrics._collision_candidates(windows) + + assert candidates == [ + _repair_candidate(url=row[6], window_end=windows[0][1]) + ] + assert connection.rollbacks == 1 + sql, params = cursor.calls[0] + assert params == windows[0] + assert "note.updated_at >= %s AND note.updated_at <= %s" in sql + assert "note.feishu_record_id IS NOT NULL" in sql + + +class LegacyUrlCursorStub: + def __init__(self, url_targets: dict[str, int]): + self.url_targets = url_targets + self.selected = None + + def execute(self, sql: str, params: tuple): + assert "WHERE url = %s LIMIT 1" in " ".join(sql.split()) + self.selected = self.url_targets.get(params[0]) + + def fetchone(self): + return (self.selected,) if self.selected is not None else None + + +def test_legacy_global_url_filter_keeps_cross_scope_scan_candidates(monkeypatch): + candidates = [ + _repair_candidate( + id=101, + style_id=1, + platform="bilibili", + url="https://www.bilibili.com/video/BVfixtureA", + ), + _repair_candidate( + id=102, + style_id=13, + platform="xiaohongshu", + url="https://v.douyin.com/fixtureB", + ), + _repair_candidate( + id=103, + style_id=5, + platform="bilibili", + url="https://www.bilibili.com/video/BVcorrect", + view_count=700, + ), + ] + metrics = [ + _metric( + record_id="missing-bili", + style_name="Other Bili Style", + dst_platform="bilibili", + source_url=None, + view_count=900, + ), + _metric( + record_id="missing-douyin", + style_name="Other Douyin Style", + dst_platform="douyin", + source_url=None, + view_count=900, + ), + _metric( + record_id="same-window-correct", + style_name="Another Bili Style", + dst_platform="bilibili", + source_url=None, + view_count=701, + ), + ] + backfills = { + "missing-bili": {"url": candidates[0]["url"]}, + "missing-douyin": {"url": candidates[1]["url"]}, + "same-window-correct": {"url": candidates[2]["url"]}, + } + monkeypatch.setattr( + sync_metrics, + "_load_repair_mapping", + lambda **_kwargs: {"tables": []}, + ) + monkeypatch.setattr( + sync_metrics, + "_lookup_existing_note", + lambda *_args: (None, "missing"), + ) + monkeypatch.setattr( + sync_metrics, + "_backfill_record", + lambda _mapping, _style, record_id: backfills[record_id], + ) + cursor = LegacyUrlCursorStub( + {candidate["url"]: candidate["id"] for candidate in candidates} + ) + + victims = sync_metrics._legacy_url_merge_victim_ids( + cursor, + candidates, + { + "Other Bili Style": 2, + "Other Douyin Style": 13, + "Another Bili Style": 6, + }, + metrics, + ) + + assert victims == {101, 102, 103} + + +def test_new_global_url_incident_window_uses_merge_victim_filter(monkeypatch): + incident_time = dt.datetime.fromisoformat("2026-08-09T19:21:07+08:00") + rows = [ + ( + 101, + "owner-a", + "bilibili", + 1, + "Owner A", + False, + "https://www.bilibili.com/video/BVfixtureA", + 900, + incident_time, + ), + ( + 102, + "owner-b", + "xiaohongshu", + 13, + "Owner B", + False, + "https://v.douyin.com/fixtureB", + 800, + incident_time, + ), + ] + cursor = RepairCursorStub(rows=rows) + connection = RepairConnectionStub(cursor) + monkeypatch.setattr(sync_metrics, "get_conn", lambda: connection) + monkeypatch.setattr( + sync_metrics, + "_style_id_lookup", + lambda _cursor: {"Owner A": 1, "Owner B": 13}, + ) + monkeypatch.setattr(sync_metrics, "collect_metrics_from_v2", lambda: []) + monkeypatch.setattr( + sync_metrics, + "_legacy_wrong_candidate_ids", + lambda *_args: set(), + ) + monkeypatch.setattr( + sync_metrics, + "_legacy_url_merge_victim_ids", + lambda *_args: {101, 102}, + ) + windows = [ + ( + dt.datetime.fromisoformat("2026-08-09T19:21:00+08:00"), + dt.datetime.fromisoformat("2026-08-09T19:21:15+08:00"), + ) + ] + + candidates = sync_metrics._collision_candidates( + windows, + candidate_source="global-url", + ) + + assert [candidate["id"] for candidate in candidates] == [101, 102] + assert connection.rollbacks == 1 + + +class MissingUrlCursorStub: + def __init__(self, *, scoped_rows=(), global_row=None): + self.scoped_rows = list(scoped_rows) + self.global_row = global_row + self.calls: list[tuple[str, tuple]] = [] + self.mode = "" + + def execute(self, sql: str, params: tuple): + normalized = " ".join(sql.split()) + self.calls.append((normalized, params)) + self.mode = "scoped" if "style_id = %s" in normalized else "global" + + def fetchall(self): + assert self.mode == "scoped" + return self.scoped_rows + + def fetchone(self): + assert self.mode == "global" + return self.global_row + + +def test_missing_url_merge_requires_owner_platform_and_complete_scope(): + row = _metric( + dst_platform="xiaohongshu", + self_operated=False, + ) + backfill = { + "url": "https://xhslink.cn/fixture", + "platform": "xiaohongshu", + } + cursor = MissingUrlCursorStub(scoped_rows=[(101, 900)]) + + note, status = sync_metrics._resolve_missing_url_target( + cursor, + row, + 7, + backfill, + ) + + assert note == (101, 900) + assert status == "scoped" + sql, params = cursor.calls[0] + assert "style_id = %s" in sql + assert "platform = %s" in sql + assert "self_operated IS NOT DISTINCT FROM %s" in sql + assert params == ( + "https://xhslink.cn/fixture", + 7, + "xiaohongshu", + False, + ) + + mismatch_cursor = MissingUrlCursorStub() + note, status = sync_metrics._resolve_missing_url_target( + mismatch_cursor, + row, + 7, + {**backfill, "platform": "douyin"}, + ) + assert note is None + assert status == "owner_platform_mismatch" + assert mismatch_cursor.calls == [] + + +def test_missing_url_merge_reports_global_cross_scope_collision(): + cursor = MissingUrlCursorStub(scoped_rows=[], global_row=(999,)) + + note, status = sync_metrics._resolve_missing_url_target( + cursor, + _metric(dst_platform="douyin", self_operated=False), + 13, + { + "url": "https://v.douyin.com/fixture", + "platform": "douyin", + }, + ) + + assert note is None + assert status == "global_scope_collision" + assert len(cursor.calls) == 2 + + +class SyncSafetyCursor: + def __init__(self): + self.calls: list[tuple[str, tuple | None]] = [] + self.mode = "" + self.global_targets = { + "https://www.bilibili.com/video/BVfixtureA": 101, + "https://v.douyin.com/fixtureB": 102, + } + self.params: tuple | None = None + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def execute(self, sql: str, params: tuple | None = None): + normalized = " ".join(sql.split()) + self.calls.append((normalized, params)) + self.params = params + if "SELECT publish_time FROM cmt_notes_master" in normalized: + self.mode = "publish_time" + elif "INSERT INTO cmt_note_metric_snapshots" in normalized: + self.mode = "snapshot" + elif "WHERE url = %s AND style_id = %s" in normalized: + self.mode = "scoped_url" + elif "SELECT id FROM cmt_notes_master WHERE url = %s LIMIT 1" in normalized: + self.mode = "global_url" + else: + raise AssertionError(f"unexpected SQL: {normalized}") + + def fetchall(self): + assert self.mode == "scoped_url" + return [] + + def fetchone(self): + if self.mode == "publish_time": + return (dt.datetime(2026, 8, 9, 12, 0),) + assert self.mode == "global_url" + target = self.global_targets.get(self.params[0]) + return (target,) if target is not None else None + + +def test_repair_then_sync_keeps_zero_net_mismatch_on_cross_scope_urls( + monkeypatch, +): + metrics = [ + _metric( + record_id="owner-bili", + style_name="Owner Bili", + dst_platform="bilibili", + source_url=None, + view_count=100, + ), + _metric( + record_id="owner-xhs", + style_name="Owner XHS", + dst_platform="xiaohongshu", + source_url=None, + view_count=200, + ), + _metric( + record_id="missing-other-style", + style_name="Other Bili", + dst_platform="bilibili", + source_url=None, + view_count=999, + ), + _metric( + record_id="missing-other-platform", + style_name="Owner XHS", + dst_platform="douyin", + source_url=None, + view_count=888, + ), + ] + repaired_notes = { + "owner-bili": (101, 100), + "owner-xhs": (102, 200), + } + backfills = { + "missing-other-style": { + "url": "https://www.bilibili.com/video/BVfixtureA", + "platform": "bilibili", + "creator_name": "fixture", + }, + "missing-other-platform": { + "url": "https://v.douyin.com/fixtureB", + "platform": "douyin", + "creator_name": "fixture", + }, + } + cursor = SyncSafetyCursor() + connection = RepairConnectionStub(cursor) + monkeypatch.setattr(sync_metrics, "get_conn", lambda: connection) + monkeypatch.setattr(sync_metrics, "collect_metrics_from_v2", lambda: metrics) + monkeypatch.setattr( + sync_metrics, + "_style_id_lookup", + lambda _cursor: { + "Owner Bili": 1, + "Owner XHS": 13, + "Other Bili": 2, + }, + ) + monkeypatch.setattr( + sync_metrics, + "_lookup_existing_note", + lambda _cursor, row, _style_id: ( + (repaired_notes[row["record_id"]], "record") + if row["record_id"] in repaired_notes + else (None, "missing") + ), + ) + monkeypatch.setattr( + sync_metrics.feishu_mapping, + "load_mapping", + lambda **_kwargs: {"tables": []}, + ) + monkeypatch.setattr( + sync_metrics, + "_backfill_record", + lambda _mapping, _style, record_id: backfills[record_id], + ) + monkeypatch.setattr( + sync_metrics, + "_ensure_creator", + lambda *_args: (_ for _ in ()).throw( + AssertionError("cross-scope collisions must stop before creator writes") + ), + ) + + summary = sync_metrics.sync(dry_run=False, insert_missing=True) + + assert summary["matched"] == 2 + assert summary["updated"] == 0 + assert summary["identity_ambiguous"] == 2 + assert summary["skipped_insert"] == 2 + assert connection.commits == 1 + assert not any(sql.startswith("UPDATE cmt_notes_master") for sql, _ in cursor.calls) + + +def test_collision_repair_dry_run_never_opens_write_connection_or_leaks_rows( + monkeypatch, + capsys, +): + candidate = _repair_candidate() + windows = [ + ( + dt.datetime.fromisoformat("2026-08-09T18:36:30+08:00"), + dt.datetime.fromisoformat("2026-08-09T18:36:50+08:00"), + ) + ] + monkeypatch.setattr(sync_metrics, "_collision_candidates", lambda _: [candidate]) + monkeypatch.setattr( + sync_metrics, + "_load_repair_mapping", + lambda **_kwargs: {"tables": []}, + ) + monkeypatch.setattr( + sync_metrics, + "_collision_repair_evidence", + lambda *_args, **_kwargs: ("verified", 1200), + ) + monkeypatch.setattr( + sync_metrics, + "get_conn", + lambda: (_ for _ in ()).throw(AssertionError("write connection opened")), + ) + + summary = sync_metrics.repair_record_collisions( + windows=windows, + dry_run=True, + ) + + assert summary["would_update"] == 1 + assert summary["updated"] == 0 + output = capsys.readouterr().out + for private_value in ( + candidate["record_id"], + candidate["style_name"], + candidate["url"], + ): + assert private_value not in output + + +def test_collision_repair_update_has_full_scope_and_old_view_cas( + monkeypatch, +): + candidate = _repair_candidate() + windows = [ + ( + dt.datetime.fromisoformat("2026-08-09T18:36:30+08:00"), + dt.datetime.fromisoformat("2026-08-09T18:36:50+08:00"), + ) + ] + monkeypatch.setattr(sync_metrics, "_collision_candidates", lambda _: [candidate]) + monkeypatch.setattr( + sync_metrics, + "_load_repair_mapping", + lambda **_kwargs: {"tables": []}, + ) + monkeypatch.setattr( + sync_metrics, + "_collision_repair_evidence", + lambda *_args, **_kwargs: ("verified", 1200), + ) + cursor = RepairCursorStub(update_result=(candidate["id"],)) + connection = RepairConnectionStub(cursor) + monkeypatch.setattr(sync_metrics, "get_conn", lambda: connection) + + summary = sync_metrics.repair_record_collisions( + windows=windows, + ) + + assert summary["updated"] == 1 + assert summary["cas_conflict"] == 0 + assert connection.commits == 1 + sql, params = cursor.calls[0] + assert "id = %s" in sql + assert "style_id = %s" in sql + assert "platform = %s" in sql + assert "self_operated IS NOT DISTINCT FROM %s" in sql + assert "view_count IS NOT DISTINCT FROM %s" in sql + assert "updated_at IS NOT DISTINCT FROM %s" in sql + assert params == ( + 1200, + candidate["id"], + candidate["style_id"], + candidate["platform"], + candidate["self_operated"], + candidate["view_count"], + candidate["record_id"], + candidate["url"], + candidate["updated_at"], + ) + + +def test_collision_repair_expected_update_mismatch_never_opens_write_connection( + monkeypatch, + capsys, +): + incident_time = dt.datetime.fromisoformat("2026-08-09T19:21:07+08:00") + candidates = [ + _repair_candidate(id=101, view_count=900, updated_at=incident_time), + _repair_candidate(id=102, view_count=800, updated_at=incident_time), + ] + windows = [ + ( + dt.datetime.fromisoformat("2026-08-09T19:21:00+08:00"), + dt.datetime.fromisoformat("2026-08-09T19:21:15+08:00"), + ) + ] + monkeypatch.setattr(sync_metrics, "_collision_candidates", lambda _: candidates) + monkeypatch.setattr( + sync_metrics, + "_load_repair_mapping", + lambda **_kwargs: {"tables": []}, + ) + monkeypatch.setattr( + sync_metrics, + "_collision_repair_evidence", + lambda candidate, *_args, **_kwargs: ( + ("verified", 1200) + if candidate["id"] == 101 + else ("unchanged", candidate["view_count"]) + ), + ) + monkeypatch.setattr( + sync_metrics, + "get_conn", + lambda: (_ for _ in ()).throw(AssertionError("write connection opened")), + ) + + with pytest.raises(RuntimeError, match="does not match expectation"): + sync_metrics.repair_record_collisions( + windows=windows, + expected_updates=2, + ) + + output = capsys.readouterr().out + assert "candidates=2" in output + assert "verified=1" in output + assert "unchanged=1" in output + + +def test_collision_repair_expected_update_match_writes_exactly_two(monkeypatch): + incident_time = dt.datetime.fromisoformat("2026-08-09T19:21:07+08:00") + candidates = [ + _repair_candidate(id=101, view_count=900, updated_at=incident_time), + _repair_candidate(id=102, view_count=800, updated_at=incident_time), + ] + windows = [ + ( + dt.datetime.fromisoformat("2026-08-09T19:21:00+08:00"), + dt.datetime.fromisoformat("2026-08-09T19:21:15+08:00"), + ) + ] + monkeypatch.setattr(sync_metrics, "_collision_candidates", lambda _: candidates) + monkeypatch.setattr( + sync_metrics, + "_load_repair_mapping", + lambda **_kwargs: {"tables": []}, + ) + monkeypatch.setattr( + sync_metrics, + "_collision_repair_evidence", + lambda candidate, *_args, **_kwargs: ( + "verified", + candidate["view_count"] + 100, + ), + ) + cursor = RepairCursorStub(update_result=(101,)) + connection = RepairConnectionStub(cursor) + monkeypatch.setattr(sync_metrics, "get_conn", lambda: connection) + + summary = sync_metrics.repair_record_collisions( + windows=windows, + expected_updates=2, + ) + + assert summary["would_update"] == 2 + assert summary["updated"] == 2 + assert len(cursor.calls) == 2 + assert connection.commits == 1 + + +def test_repair_window_parser_requires_timezone_and_order(): + start, end = sync_metrics._parse_repair_window( + "2026-08-09T17:36:21+08:00,2026-08-09T17:36:45+08:00" + ) + + assert start.tzinfo is not None + assert end > start + with pytest.raises(argparse.ArgumentTypeError): + sync_metrics._parse_repair_window( + "2026-08-09T17:36:21,2026-08-09T17:36:45" + ) + with pytest.raises(argparse.ArgumentTypeError): + sync_metrics._parse_repair_window( + "2026-08-09T17:36:45+08:00,2026-08-09T17:36:21+08:00" + ) + + +def test_historical_exposure_uses_latest_nonempty_parseable_field_before_window(): + fields = [ + {"id": "future", "name": "2026-08-10曝光量"}, + {"id": "today-empty", "name": "2026-08-09曝光量"}, + {"id": "recent-invalid", "name": "2026-08-08曝光量"}, + {"id": "older-valid", "name": "2026-08-07曝光量"}, + {"id": "logical", "name": "daily_exposure"}, + ] + record = { + "future": 9999, + "today-empty": "", + "recent-invalid": "not-a-number", + "older-valid": "1.5万", + "logical": 8888, + } + + status, exposure = sync_metrics._historical_daily_exposure( + record, + fields, + latest_date=dt.date(2026, 8, 9), + ) + + assert status == "verified" + assert exposure == 15000 + + +def test_collision_repair_never_touches_candidate_outside_explicit_windows( + monkeypatch, +): + windows = [ + ( + dt.datetime.fromisoformat("2026-08-09T18:36:30+08:00"), + dt.datetime.fromisoformat("2026-08-09T18:36:50+08:00"), + ) + ] + candidate = _repair_candidate( + updated_at=dt.datetime.fromisoformat("2026-08-09T18:37:00+08:00") + ) + monkeypatch.setattr(sync_metrics, "_collision_candidates", lambda _: [candidate]) + monkeypatch.setattr( + sync_metrics, + "_load_repair_mapping", + lambda **_kwargs: {"tables": []}, + ) + monkeypatch.setattr( + sync_metrics, + "_collision_repair_evidence", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("outside-window candidate evaluated") + ), + ) + monkeypatch.setattr( + sync_metrics, + "get_conn", + lambda: (_ for _ in ()).throw(AssertionError("write connection opened")), + ) + + summary = sync_metrics.repair_record_collisions( + windows=windows, + dry_run=False, + ) + + assert summary["outside_window"] == 1 + assert summary["updated"] == 0 + + +def test_repair_cli_rejects_missing_window(monkeypatch): + monkeypatch.setattr( + sys, + "argv", + ["sync_metrics_to_cmt_notes.py", "--repair-record-collisions"], + ) + monkeypatch.setattr( + sync_metrics, + "repair_record_collisions", + lambda **_kwargs: (_ for _ in ()).throw( + AssertionError("repair should not start") + ), + ) + monkeypatch.setattr( + sync_metrics, + "sync", + lambda **_kwargs: (_ for _ in ()).throw( + AssertionError("sync should not start") + ), + ) + + with pytest.raises(SystemExit) as exc_info: + sync_metrics.main() + + assert exc_info.value.code == 2 + + +def test_repair_cli_runs_repair_before_sync_and_hides_row_logs(monkeypatch, capsys): + calls = [] + + def repair(*, windows, dry_run): + calls.append(("repair", windows, dry_run)) + return {"updated": 1} + + def sync(*, dry_run, insert_missing): + calls.append(("sync", dry_run, insert_missing)) + print("private-row-id private-url private-name") + return { + "total": 3, + "matched": 2, + "updated": 1, + "inserted": 0, + "skipped_not_found": 1, + "skipped_insert": 0, + "identity_ambiguous": 0, + } + + monkeypatch.setattr(sync_metrics, "repair_record_collisions", repair) + monkeypatch.setattr(sync_metrics, "sync", sync) + monkeypatch.setattr( + sys, + "argv", + [ + "sync_metrics_to_cmt_notes.py", + "--repair-record-collisions", + "--repair-window=2026-08-09T17:36:21+08:00,2026-08-09T17:36:45+08:00", + "--repair-window=2026-08-09T18:36:30+08:00,2026-08-09T18:36:50+08:00", + "--insert-missing", + ], + ) + + sync_metrics.main() + + assert calls[0][0] == "repair" + assert calls[0][1] == [ + ( + dt.datetime.fromisoformat("2026-08-09T17:36:21+08:00"), + dt.datetime.fromisoformat("2026-08-09T17:36:45+08:00"), + ), + ( + dt.datetime.fromisoformat("2026-08-09T18:36:30+08:00"), + dt.datetime.fromisoformat("2026-08-09T18:36:50+08:00"), + ) + ] + assert calls[0][2] is False + assert calls[1] == ("sync", False, True) + output = capsys.readouterr().out + assert "private-row-id" not in output + assert "private-url" not in output + assert "private-name" not in output + assert "total=3" in output + + +def test_repair_cli_exits_nonzero_when_scoped_identity_remains_ambiguous( + monkeypatch, +): + monkeypatch.setattr( + sync_metrics, + "repair_record_collisions", + lambda **_kwargs: {"updated": 0}, + ) + monkeypatch.setattr( + sync_metrics, + "sync", + lambda **_kwargs: {"identity_ambiguous": 1}, + ) + monkeypatch.setattr( + sys, + "argv", + [ + "sync_metrics_to_cmt_notes.py", + "--repair-record-collisions", + "--repair-window=2026-08-09T18:36:30+08:00,2026-08-09T18:36:50+08:00", + ], + ) + + with pytest.raises(SystemExit) as exc_info: + sync_metrics.main() + + assert exc_info.value.code == 1 + + +@pytest.mark.parametrize( + "argv", + [ + ["sync_metrics_to_cmt_notes.py", "--repair-only"], + [ + "sync_metrics_to_cmt_notes.py", + "--repair-expected-updates=2", + ], + [ + "sync_metrics_to_cmt_notes.py", + "--repair-record-collisions", + "--repair-window=2026-08-09T19:21:00+08:00,2026-08-09T19:21:15+08:00", + "--repair-only", + "--insert-missing", + ], + ], +) +def test_repair_only_rejects_unsafe_argument_combinations(monkeypatch, argv): + monkeypatch.setattr(sys, "argv", argv) + monkeypatch.setattr( + sync_metrics, + "repair_record_collisions", + lambda **_kwargs: (_ for _ in ()).throw( + AssertionError("invalid repair-only invocation must not run") + ), + ) + + with pytest.raises(SystemExit) as exc_info: + sync_metrics.main() + + assert exc_info.value.code == 2 + + +def test_repair_only_runs_registered_repair_without_normal_sync(monkeypatch): + calls = [] + monkeypatch.setattr( + sync_metrics, + "repair_record_collisions", + lambda **kwargs: calls.append(("repair", kwargs)) or {"updated": 2}, + ) + monkeypatch.setattr( + sync_metrics, + "sync", + lambda **_kwargs: (_ for _ in ()).throw( + AssertionError("repair-only must not run the normal V2 sync") + ), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "sync_metrics_to_cmt_notes.py", + "--repair-record-collisions", + "--repair-window=2026-08-09T19:21:00+08:00,2026-08-09T19:21:15+08:00", + "--repair-only", + "--repair-candidate-source=global-url", + "--repair-expected-updates=2", + ], + ) + + sync_metrics.main() + + assert calls == [ + ( + "repair", + { + "windows": [ + ( + dt.datetime.fromisoformat("2026-08-09T19:21:00+08:00"), + dt.datetime.fromisoformat("2026-08-09T19:21:15+08:00"), + ) + ], + "dry_run": False, + "candidate_source": "global-url", + "expected_updates": 2, + }, + ) + ] diff --git a/tests/modules/content_marketing/test_weekly_tmall_persona.py b/tests/modules/content_marketing/test_weekly_tmall_persona.py index 732bba0..6acc2d5 100644 --- a/tests/modules/content_marketing/test_weekly_tmall_persona.py +++ b/tests/modules/content_marketing/test_weekly_tmall_persona.py @@ -5,6 +5,32 @@ from gyxx_flow.modules.content_marketing import weekly_summary_all as weekly class WeeklyTmallPersonaTests(unittest.TestCase): + def test_summary_result_requires_all_real_external_effects(self): + self.assertTrue( + weekly.summary_results_succeeded( + [{"status": "ok"}, {"status": "no_notes"}] + ) + ) + for status in ( + "no_fields", + "no_reports", + "llm_failed", + "doc_failed", + "doc_ambiguous", + "db_failed", + "write_failed", + "exception", + ): + with self.subTest(status=status): + self.assertFalse( + weekly.summary_results_succeeded([{"status": status}]) + ) + + def test_dry_run_status_is_only_successful_in_dry_run_mode(self): + results = [{"status": "dry_run"}] + self.assertFalse(weekly.summary_results_succeeded(results)) + self.assertTrue(weekly.summary_results_succeeded(results, dry_run=True)) + def test_missing_persona_forbids_invented_percentages(self): text = weekly.format_tmall_persona(None) self.assertIn("不得虚构具体比例", text) @@ -56,7 +82,7 @@ class WeeklyTmallPersonaTests(unittest.TestCase): }] with patch.object(weekly, "get_latest_tmall_persona", return_value=persona) as get_persona, \ - patch.object(weekly, "call_hermes_analyzer", side_effect=["单篇分析", "## 三、周度总结"]) as call: + patch.object(weekly, "call_content_analyzer", side_effect=["单篇分析", "## 三、周度总结"]) as call: weekly.generate_summary("星云2", notes) get_persona.assert_called_once_with("星云2") diff --git a/tests/modules/content_marketing/test_xiaohongshu_comment_deadline.py b/tests/modules/content_marketing/test_xiaohongshu_comment_deadline.py new file mode 100644 index 0000000..a0d9369 --- /dev/null +++ b/tests/modules/content_marketing/test_xiaohongshu_comment_deadline.py @@ -0,0 +1,108 @@ +import pytest + +from gyxx_flow.modules.content_marketing import xiaohongshu_comment_scraper as scraper + + +class _EvaluatePage: + def __init__(self, result): + self.result = result + self.calls = [] + + def evaluate(self, script, argument): + self.calls.append((script, argument)) + return self.result + + +def test_bounded_fetch_uses_abort_controller_and_clears_timer(monkeypatch): + page = _EvaluatePage( + {"__gyxx_status": "ok", "payload": {"comments": []}} + ) + monkeypatch.setattr(scraper.time, "monotonic", lambda: 100.0) + + payload = scraper._fetch_json_with_timeout( + page, + "https://example.invalid/comments", + deadline=1000.0, + stage="top-level pagination", + ) + + assert payload == {"comments": []} + script, argument = page.calls[0] + assert "AbortController" in script + assert "clearTimeout(timer)" in script + assert argument["timeoutMs"] == 30_000 + + +def test_bounded_fetch_timeout_is_an_explicit_transient_failure(monkeypatch): + page = _EvaluatePage({"__gyxx_status": "timeout"}) + monkeypatch.setattr(scraper.time, "monotonic", lambda: 100.0) + + with pytest.raises(scraper.TransientXiaohongshuCommentTimeout) as caught: + scraper._fetch_json_with_timeout( + page, + "https://example.invalid/comments", + deadline=1000.0, + stage="reply pagination", + ) + + assert isinstance(caught.value, ConnectionError) + assert "reply pagination" in str(caught.value) + + +def test_scroll_checks_the_note_deadline_before_browser_work(monkeypatch): + page = _EvaluatePage(None) + monkeypatch.setattr(scraper.time, "monotonic", lambda: 901.0) + + with pytest.raises(scraper.TransientXiaohongshuCommentTimeout): + scraper.scroll_comments( + page, + max_scrolls=30, + idle_rounds=4, + comments_by_id={}, + deadline=900.0, + ) + + assert page.calls == [] + + +def test_top_and_reply_pagination_share_the_bounded_fetch(monkeypatch): + stages = [] + monkeypatch.setattr(scraper.time, "monotonic", lambda: 0.0) + + def fetch(_page, _url, *, deadline, stage): + assert deadline == 900.0 + stages.append(stage) + return {"comments": [], "has_more": 0, "cursor": 0} + + monkeypatch.setattr(scraper, "_fetch_json_with_timeout", fetch) + monkeypatch.setattr(scraper, "collect_from_payload", lambda *_args: None) + page = _EvaluatePage(None) + + scraper.fetch_missing_top_comments( + page, + "source-url", + {}, + { + "comment_url_template": "https://example.invalid/top", + "api_total": 2, + "last_cursor": 1, + }, + deadline=900.0, + ) + scraper.fetch_missing_replies( + page, + "source-url", + { + "parent": { + "level": "comment", + "reply_count": 1, + "comment_id": "parent-id", + } + }, + { + "sub_url_template": "https://example.invalid/reply", + }, + deadline=900.0, + ) + + assert stages == ["top-level pagination", "reply pagination"] diff --git a/tests/modules/product_commerce/test_alert_command.py b/tests/modules/product_commerce/test_alert_command.py index 7393ffc..507ef61 100644 --- a/tests/modules/product_commerce/test_alert_command.py +++ b/tests/modules/product_commerce/test_alert_command.py @@ -3,11 +3,11 @@ from __future__ import annotations import importlib.util import sys from pathlib import Path -from types import ModuleType +from types import ModuleType, SimpleNamespace import pytest -from gyxx_flow.adapters import WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID +from gyxx_flow.adapters import ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID PROJECT_ROOT = Path(__file__).resolve().parents[3] COMMAND_PATH = ( @@ -56,7 +56,7 @@ def test_alert_command_uses_canonical_acceptance_recipient( "--end-date", "2026-08-01", "--openid", - WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID, + ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID, ) @@ -72,3 +72,52 @@ def test_alert_command_accepts_canonical_production_recipient( assert module.main() == 0 assert observed["argv"][-1] == "ou_configured" + + +def test_alert_command_passes_all_dynamic_recipients( + monkeypatch: pytest.MonkeyPatch, +) -> None: + observed: dict[str, object] = {} + monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-08-01") + monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False) + module = _load_command(monkeypatch, observed) + monkeypatch.setattr( + module, + "resolve_notification_route", + lambda *_args, **_kwargs: SimpleNamespace( + enabled=True, + open_ids=("ou_first", "ou_second"), + ), + ) + + assert module.main() == 0 + assert observed["argv"] == ( + sys.argv[0], + "--end-date", + "2026-08-01", + "--openid", + "ou_first", + "--openid", + "ou_second", + ) + + +def test_alert_command_uses_safe_no_notify_when_route_is_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + observed: dict[str, object] = {} + monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-08-01") + module = _load_command(monkeypatch, observed) + monkeypatch.setattr( + module, + "resolve_notification_route", + lambda *_args, **_kwargs: SimpleNamespace(enabled=False, open_ids=()), + ) + + assert module.main() == 0 + assert observed["argv"] == ( + sys.argv[0], + "--end-date", + "2026-08-01", + "--no-notify", + ) diff --git a/tests/modules/product_commerce/test_alert_integrity.py b/tests/modules/product_commerce/test_alert_integrity.py index f51e2a8..8861dfa 100644 --- a/tests/modules/product_commerce/test_alert_integrity.py +++ b/tests/modules/product_commerce/test_alert_integrity.py @@ -53,6 +53,11 @@ def _prepare_main(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: ) monkeypatch.setattr(decline, "managed_data_path", lambda value: Path(value)) monkeypatch.setattr(decline, "append_log", lambda line: None) + monkeypatch.setattr( + decline, + "_persist_detected_events", + lambda events, *_args: len(events), + ) monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False) @@ -142,27 +147,73 @@ def test_segment_loader_uses_postgres_range_sum_on_one_connection( ] -@pytest.mark.parametrize( - ("response", "expected"), - [ - ({"id": "chatcmpl-not-a-receipt"}, None), - ({"message_id": "om_direct123"}, "om_direct123"), - ({"data": {"message_id": "om_nested123"}}, "om_nested123"), - ( - {"choices": [{"message": {"content": "飞书回执 om_content123"}}]}, - "om_content123", - ), - ( - {"choices": [{"message": {"content": "发送成功,但没有回执"}}]}, - None, - ), - ], -) -def test_extract_feishu_message_id_requires_real_receipt_shape( - response: dict, - expected: str | None, +def test_build_decline_notification_message_is_deterministic() -> None: + message = decline.build_decline_notification_message( + [_event()], + end_date="2026-08-01", + window_days=9, + ) + + assert "销量连续下滑告警(截至 2026-08-01,3 段 × 3 天)" in message + assert "[tm] 测试款" in message + assert "300 → 210 → 90" in message + assert "累计 -70.0%" in message + assert "ERP" in message + + +def test_alert_delivery_uuid_is_stable_and_recipient_scoped() -> None: + first = decline._notification_delivery_uuid( + [_event()], + end_date="2026-08-01", + window_days=9, + openid="ou_first", + ) + same = decline._notification_delivery_uuid( + [_event()], + end_date="2026-08-01", + window_days=9, + openid="ou_first", + ) + second = decline._notification_delivery_uuid( + [_event()], + end_date="2026-08-01", + window_days=9, + openid="ou_second", + ) + + assert first == same + assert first != second + + +def test_decline_notification_uses_shared_lark_user_sender( + monkeypatch: pytest.MonkeyPatch, ) -> None: - assert decline.extract_feishu_message_id(response) == expected + captured: dict[str, object] = {} + monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False) + + def fake_send(**kwargs): + captured.update(kwargs) + return {"message_id": "om_real123"} + + monkeypatch.setattr(decline, "send_lark_bot_message", fake_send) + + response = decline.send_decline_notification( + [_event()], + "2026-08-01", + 9, + openid="ou_owner", + ) + + assert response["message_id"] == "om_real123" + assert captured["user_id"] == "ou_owner" + assert captured["profile"] == "hermes-analyzer" + assert captured["idempotency_key"] == decline._notification_delivery_uuid( + [_event()], + end_date="2026-08-01", + window_days=9, + openid="ou_owner", + ) + assert "测试款" in str(captured["text"]) def test_main_fails_when_postgres_detection_read_fails( @@ -205,12 +256,12 @@ def test_main_dedup_query_failure_is_fail_closed( lambda *args: (_ for _ in ()).throw(RuntimeError("db timeout")), ) notify = pytest.fail - monkeypatch.setattr(decline, "notify_hermes", notify) + monkeypatch.setattr(decline, "send_decline_notification", notify) assert decline.main() == 5 -def test_main_does_not_accept_arbitrary_non_error_hermes_text( +def test_main_does_not_accept_lark_response_without_real_message_id( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -219,11 +270,8 @@ def test_main_does_not_accept_arbitrary_non_error_hermes_text( monkeypatch.setattr(decline, "_already_notified", lambda *args: False) monkeypatch.setattr( decline, - "notify_hermes", - lambda *args, **kwargs: { - "id": "chatcmpl-123", - "choices": [{"message": {"content": "发送成功"}}], - }, + "send_decline_notification", + lambda *args, **kwargs: {"ok": True}, ) persist = pytest.fail monkeypatch.setattr(decline, "_persist_events", persist) @@ -240,7 +288,7 @@ def test_main_returns_nonzero_when_receipt_persistence_fails( monkeypatch.setattr(decline, "_already_notified", lambda *args: False) monkeypatch.setattr( decline, - "notify_hermes", + "send_decline_notification", lambda *args, **kwargs: {"message_id": "om_real123"}, ) monkeypatch.setattr( @@ -263,7 +311,7 @@ def test_main_success_requires_and_persists_feishu_receipt( monkeypatch.setattr(decline, "_already_notified", lambda *args: False) monkeypatch.setattr( decline, - "notify_hermes", + "send_decline_notification", lambda *args, **kwargs: {"message_id": "om_real123"}, ) @@ -281,6 +329,174 @@ def test_main_success_requires_and_persists_feishu_receipt( assert "persistence=committed history_rows=1" in output +def test_main_no_notify_still_detects_hits_and_succeeds( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _prepare_main(monkeypatch, tmp_path) + monkeypatch.setattr( + sys, + "argv", + [ + "check_nine_day_decline.py", + "--end-date", + "2026-08-01", + "--no-notify", + "--data-root", + str(tmp_path), + ], + ) + monkeypatch.setattr(decline, "detect_decline", lambda *args: [_event()]) + persisted: list[tuple[list[dict], str, int]] = [] + monkeypatch.setattr( + decline, + "_persist_detected_events", + lambda events, end_date, window_days: ( + persisted.append((events, end_date, window_days)) or len(events) + ), + ) + monkeypatch.setattr( + decline, + "_already_notified", + lambda *args: pytest.fail("disabled notification must not query receipts"), + ) + monkeypatch.setattr( + decline, + "send_decline_notification", + lambda *args, **kwargs: pytest.fail("disabled notification must not send"), + ) + + assert decline.main() == 0 + assert persisted == [([_event()], "2026-08-01", 9)] + output = capsys.readouterr().out + assert "events=1 notification=disabled persistence=committed" in output + assert "detection_rows=1" in output + + +def test_main_blocks_notification_when_detection_output_cannot_be_persisted( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _prepare_main(monkeypatch, tmp_path) + monkeypatch.setattr(decline, "detect_decline", lambda *args: [_event()]) + monkeypatch.setattr( + decline, + "_persist_detected_events", + lambda *_args: (_ for _ in ()).throw(RuntimeError("write failed")), + ) + monkeypatch.setattr( + decline, + "send_decline_notification", + lambda *args, **kwargs: pytest.fail("must not send without durable output"), + ) + + assert decline.main() == 5 + + +def test_main_sends_and_persists_each_recipient_independently( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _prepare_main(monkeypatch, tmp_path) + monkeypatch.setattr( + sys, + "argv", + [ + "check_nine_day_decline.py", + "--end-date", + "2026-08-01", + "--openid", + "ou_first", + "--openid", + "ou_second", + "--data-root", + str(tmp_path), + ], + ) + monkeypatch.setattr(decline, "detect_decline", lambda *args: [_event()]) + dedup_queries: list[str] = [] + persisted: list[tuple[str, str]] = [] + + def fake_already_notified(_style, _date, recipient): + dedup_queries.append(recipient) + return False + + def fake_notify(*_args, openid, **_kwargs): + return {"message_id": f"om_{openid.removeprefix('ou_')}"} + + def fake_persist(*_args, openid, message_id, **_kwargs): + persisted.append((openid, message_id)) + return 1 + + monkeypatch.setattr(decline, "_already_notified", fake_already_notified) + monkeypatch.setattr(decline, "send_decline_notification", fake_notify) + monkeypatch.setattr(decline, "_persist_events", fake_persist) + + assert decline.main() == 0 + assert dedup_queries == ["ou_first", "ou_second"] + assert persisted == [ + ("ou_first", "om_first"), + ("ou_second", "om_second"), + ] + + +def test_alert_schema_and_upsert_are_recipient_scoped() -> None: + repository_root = Path(__file__).resolve().parents[3] + schema = ( + repository_root + / "src" + / "gyxx_flow" + / "modules" + / "product_commerce" + / "db" + / "schema.sql" + ).read_text(encoding="utf-8") + db_module = ( + repository_root + / "src" + / "gyxx_flow" + / "modules" + / "product_commerce" + / "db" + / "__init__.py" + ).read_text(encoding="utf-8") + + assert "UNIQUE (style_name, trigger_date, recipient)" in schema + assert "DROP CONSTRAINT IF EXISTS fact_alert_history_style_name_trigger_date_key" in schema + assert "ON CONFLICT (style_name, trigger_date, recipient)" in db_module + assert "def ensure_alert_history_recipient_scope" in db_module + assert "fact_alert_history_style_name_trigger_date_key" in db_module + + +def test_detected_output_migrates_recipient_scope_before_upsert( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_db = ModuleType("db") + connection = object() + calls: list[tuple[str, object]] = [] + + class ConnectionContext: + def __enter__(self): + return connection + + def __exit__(self, *args): + return False + + fake_db.get_conn = ConnectionContext # type: ignore[attr-defined] + fake_db.ensure_alert_history_recipient_scope = ( # type: ignore[attr-defined] + lambda conn: calls.append(("migrate", conn)) + ) + fake_db.upsert_decline_event = ( # type: ignore[attr-defined] + lambda conn, event: calls.append(("upsert", (conn, event))) + ) + monkeypatch.setitem(sys.modules, "db", fake_db) + + assert decline._persist_detected_events([_event()], "2026-08-01", 9) == 1 + assert calls[0] == ("migrate", connection) + assert calls[1][0] == "upsert" + + class _FailedCollector: def __init__(self, *args): self.pages: list[tuple] = [] diff --git a/tests/modules/product_commerce/test_browser_profile_reuse.py b/tests/modules/product_commerce/test_browser_profile_reuse.py index 046fff8..bbd6c10 100644 --- a/tests/modules/product_commerce/test_browser_profile_reuse.py +++ b/tests/modules/product_commerce/test_browser_profile_reuse.py @@ -7,16 +7,10 @@ from types import SimpleNamespace import collect_erp_yesterday_metrics as erp import collect_jd_persona_to_bitable as jd_persona -from runtime_paths import playwright_launch_options +import erp_login_product_analysis as erp_login +from runtime_paths import scrapling_session_options - -class _Chromium: - def __init__(self) -> None: - self.kwargs = None - - def launch_persistent_context(self, **kwargs): - self.kwargs = kwargs - return object() +from gyxx_flow.core.exit_codes import COOKIE_SKIP_EXIT_CODE def test_jd_persona_launches_the_managed_persistent_profile( @@ -25,13 +19,20 @@ def test_jd_persona_launches_the_managed_persistent_profile( ) -> None: profile = tmp_path / "jd-profile" monkeypatch.setenv("GYXX_BROWSER_PROFILE_DIR", str(profile)) - chromium = _Chromium() + captured: dict[str, object] = {} - context = jd_persona._launch_jd_context(chromium, headless=True) + def browser_factory(**options): + captured.update(options) + return object() - assert context is not None - assert chromium.kwargs["user_data_dir"] == str(profile.resolve()) - assert chromium.kwargs["headless"] is True + monkeypatch.setattr(jd_persona, "ScraplingBrowser", browser_factory) + + browser = jd_persona._jd_browser(headless=True) + + assert browser is not None + assert captured["user_data_dir"] == str(profile.resolve()) + assert captured["headless"] is True + assert captured["retries"] == 1 def test_jd_persona_reuses_logged_in_page_without_credentials() -> None: @@ -61,6 +62,36 @@ def test_jd_persona_reuses_logged_in_page_without_credentials() -> None: assert calls == [] +def test_jd_persona_waits_for_slow_shop_shell_before_login_fallback() -> None: + calls: list[tuple[str, str]] = [] + bodies = iter(("", "", "", "", "京东商家后台 商品明细")) + waits: list[int] = [] + + class Page: + url = "https://shop.jd.com/jdm/home" + + def goto(self, *_args, **_kwargs): + return None + + def wait_for_timeout(self, milliseconds): + waits.append(milliseconds) + + def locator(self, _selector): + return SimpleNamespace( + inner_text=lambda **_kwargs: next(bodies), + ) + + jd_persona._ensure_jd_session( + Page(), + shop="", + password="", + step_login=lambda _page, shop, password: calls.append((shop, password)), + ) + + assert calls == [] + assert waits == [2000, 1000, 1000, 1000, 1000] + + def test_erp_uses_managed_profile_when_bound_cdp_is_not_running( monkeypatch, tmp_path: Path, @@ -95,6 +126,62 @@ def test_erp_attaches_only_to_the_live_managed_cdp(monkeypatch) -> None: assert user_data_dir is None +def test_erp_login_navigates_before_probing_initial_blank_page( + monkeypatch, +) -> None: + events: list[tuple[str, str]] = [] + + class Page: + url = "about:blank" + context = object() + + def goto(self, url, **_kwargs): + events.append(("goto", url)) + self.url = url + + def wait_for_timeout(self, _milliseconds): + return None + + monkeypatch.setattr(erp_login, "restore_erp_runtime_state", lambda _context: None) + monkeypatch.setattr(erp_login, "has_valid_session", lambda _page: False) + monkeypatch.setattr(erp_login, "is_authenticated_session", lambda _page: False) + monkeypatch.setattr(erp_login, "first_visible", lambda *_args: object()) + monkeypatch.setattr( + erp_login, + "fill_first_visible", + lambda _page, _selectors, _value, label: events.append(("fill", label)) or True, + ) + monkeypatch.setattr(erp_login, "check_visible_checkboxes", lambda _page: None) + monkeypatch.setattr(erp_login, "click_login_button", lambda _page: events.append(("click", "login"))) + monkeypatch.setattr(erp_login, "wait_for_manual_captcha", lambda *_args, **_kwargs: None) + monkeypatch.setattr(erp_login, "wait_until_logged_in", lambda *_args, **_kwargs: True) + monkeypatch.setattr(erp_login, "persist_erp_runtime_state", lambda _context: 1) + + state: dict[str, object] = {} + action = erp_login.make_page_action( + login_url="https://erp.test/login.aspx", + username="user", + password="pw", + home_url="https://erp.test/epaas", + timeout_ms=1000, + captcha_checks=0, + captcha_interval_seconds=1, + hold_seconds=0, + login_only=True, + state=state, + ) + + action(Page()) + + assert events[:4] == [ + ("goto", "https://erp.test/login.aspx"), + ("fill", "username"), + ("fill", "password"), + ("click", "login"), + ] + assert state == {"ok": True, "final_url": "https://erp.test/login.aspx"} + + def test_erp_main_fails_before_browser_when_no_style_is_collectable(monkeypatch) -> None: args = SimpleNamespace( config="unused.json", @@ -192,6 +279,110 @@ def test_erp_main_fails_when_collector_summary_contains_failures( assert erp.main() == 1 +def test_erp_main_maps_login_recovery_to_cookie_skip( + monkeypatch, + tmp_path: Path, +) -> None: + args = SimpleNamespace( + config="unused.json", + target_date=date(2026, 8, 12), + mode="daily", + refresh_config=False, + input="unused-styles.json", + data_dir=str(tmp_path), + platform="all", + no_cdp=False, + timeout_ms=1000, + limit=0, + report_timeout=1, + report_attempts=1, + include_slow_codes=False, + hold_seconds=0, + headless=True, + ) + styles = [ + { + "style_name": "style-a", + "erp_style_codes": ["erp-a"], + "brand": erp.DEFAULT_BRAND, + } + ] + monkeypatch.setattr(erp, "parse_args", lambda: args) + monkeypatch.setattr( + erp, + "load_config", + lambda _path: { + "login": { + "url": "https://erp.test/login", + "username": "placeholder", + "password": "placeholder", + } + }, + ) + monkeypatch.setattr( + erp, + "load_erp_styles", + lambda *_args: ( + styles, + {"source": "lark", "collectable": ["style-a"], "skipped": []}, + ), + ) + monkeypatch.setattr(erp, "print_skip_summary", lambda _report: None) + monkeypatch.setattr(erp, "write_erp_skip_report", lambda *_args: None) + monkeypatch.setattr(erp, "managed_data_path", lambda _path: tmp_path) + monkeypatch.setattr(erp, "resolve_browser_session", lambda _config: (None, None)) + + def fake_login_action(**kwargs): + state = kwargs["state"] + + def action(_page): + state["ok"] = False + state["error"] = "Login did not complete; captcha required" + + return action + + monkeypatch.setattr(erp, "make_page_action", fake_login_action) + monkeypatch.setattr( + erp.DynamicFetcher, + "fetch", + lambda _url, **kwargs: kwargs["page_action"](object()), + ) + + assert erp.main() == COOKIE_SKIP_EXIT_CODE + + +def test_erp_login_waits_for_a_delayed_captcha(monkeypatch) -> None: + captcha_states = iter([False, False, True, True, False]) + waits: list[int] = [] + + class Page: + url = "https://erp.test/login" + + def wait_for_timeout(self, milliseconds): + waits.append(milliseconds) + + monkeypatch.setattr( + erp_login, + "captcha_exists", + lambda _page: next(captcha_states), + ) + monotonic_values = iter([0.0, 0.1, 0.2]) + monkeypatch.setattr( + erp_login.time, + "monotonic", + lambda: next(monotonic_values), + ) + + erp_login.wait_for_manual_captcha( + Page(), + checks=3, + interval_seconds=5, + login_url="https://erp.test/login", + ) + + assert waits == [500, 500, 5000, 5000] + + def test_erp_date_inputs_are_required_and_read_back() -> None: source = inspect.getsource(erp.set_platform_and_code) @@ -200,8 +391,11 @@ def test_erp_date_inputs_are_required_and_read_back() -> None: assert "date range did not persist" in source -def test_playwright_channel_is_optional_and_environment_driven() -> None: - assert playwright_launch_options({}) == {} - assert playwright_launch_options({"GYXX_PLAYWRIGHT_CHANNEL": "chrome"}) == { - "channel": "chrome" +def test_scrapling_real_chrome_is_optional_and_environment_driven() -> None: + assert scrapling_session_options({}) == {} + assert scrapling_session_options({"GYXX_PLAYWRIGHT_CHANNEL": "chrome"}) == { + "real_chrome": True + } + assert scrapling_session_options({"GYXX_SCRAPLING_REAL_CHROME": "false"}) == { + "real_chrome": False } diff --git a/tests/modules/product_commerce/test_collect_douyin_qianchuan_ads.py b/tests/modules/product_commerce/test_collect_douyin_qianchuan_ads.py new file mode 100644 index 0000000..3267730 --- /dev/null +++ b/tests/modules/product_commerce/test_collect_douyin_qianchuan_ads.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from pathlib import Path + +import collect_douyin_qianchuan_ads as collector + + +def test_qianchuan_browser_uses_scrapling_stealth_and_persistent_state( + monkeypatch, tmp_path: Path +) -> None: + captured: dict[str, object] = {} + + class _FakeBrowser: + def __init__(self, **options: object) -> None: + captured.update(options) + + cookie_file = tmp_path / "cookies.json" + storage_state_file = tmp_path / "storage_state.json" + monkeypatch.setenv("GYXX_BROWSER_COOKIE_FILE", str(cookie_file)) + monkeypatch.setenv("GYXX_BROWSER_STORAGE_STATE_FILE", str(storage_state_file)) + monkeypatch.setattr(collector, "ScraplingBrowser", _FakeBrowser) + + browser = collector._start_browser(True, str(tmp_path / "profile")) + + assert isinstance(browser, _FakeBrowser) + assert captured["stealthy"] is True + assert captured["real_chrome"] is False + assert captured["headless"] is True + assert captured["user_data_dir"] == str((tmp_path / "profile").resolve()) + assert captured["cookie_file"] == str(cookie_file) + assert captured["storage_state_file"] == str(storage_state_file) + assert captured["extra_flags"] == ["--no-proxy-server"] + assert captured["additional_args"] == { + "viewport": {"width": 1920, "height": 1080}, + "accept_downloads": True, + } + + +def test_calendar_cell_prefers_actual_month_when_adjacent_month_duplicates() -> None: + class _Locator: + def __init__(self, count: int) -> None: + self._count = count + + def count(self) -> int: + return self._count + + class _Page: + def locator(self, selector: str) -> _Locator: + if ".ovui-date__cell--inview" in selector: + return _Locator(1) + return _Locator(2) + + cell = collector._calendar_cell(_Page(), "2026-08-30") + + assert cell.count() == 1 + + +def test_calendar_cell_falls_back_when_picker_has_no_inview_class() -> None: + class _Locator: + def __init__(self, count: int) -> None: + self._count = count + + def count(self) -> int: + return self._count + + class _Page: + def locator(self, selector: str) -> _Locator: + if ".ovui-date__cell--inview" in selector: + return _Locator(0) + return _Locator(1) + + cell = collector._calendar_cell(_Page(), "2026-08-30") + + assert cell.count() == 1 + + +def test_report_ready_wait_guards_missing_body() -> None: + captured: dict[str, object] = {} + + class _Page: + def wait_for_function(self, expression: str, **options: object) -> None: + captured["expression"] = expression + captured.update(options) + + page = _Page() + collector._wait_for_report_ready(page) + + assert captured == { + "expression": collector.REPORT_READY_EXPRESSION, + "arg": list(collector._required_page_markers()), + "timeout": 90_000, + } diff --git a/tests/modules/product_commerce/test_collect_jd_ad_costs.py b/tests/modules/product_commerce/test_collect_jd_ad_costs.py new file mode 100644 index 0000000..dc89cdd --- /dev/null +++ b/tests/modules/product_commerce/test_collect_jd_ad_costs.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import zipfile +from datetime import date +from pathlib import Path + +import collect_jd_ad_costs as collector +import pytest + + +def test_login_starts_from_jd_merchant_login_page() -> None: + assert collector.JD_LOGIN_URL == ( + "https://passport.shop.jd.com/login/index.action/jdm?" + "ReturnUrl=https%3A%2F%2Fshop.jd.com%2Fjdm%2Fhome" + ) + + +def test_ensure_logged_in_navigates_to_login_entry_first(monkeypatch) -> None: + visited: list[str] = [] + monkeypatch.setattr( + collector, + "safe_goto", + lambda _page, url: visited.append(url), + ) + monkeypatch.setattr(collector, "_login_complete", lambda _page: True) + + collector.ensure_logged_in(object(), "光影行星研发", "secret") + + assert visited == [collector.JD_LOGIN_URL] + + +def test_ensure_logged_in_reuses_valid_cookie_session(monkeypatch) -> None: + visited: list[str] = [] + + class Page: + def wait_for_timeout(self, _milliseconds: int) -> None: + return None + + states = iter((False, True)) + monkeypatch.setattr( + collector, + "safe_goto", + lambda _page, url: visited.append(url), + ) + monkeypatch.setattr(collector, "_login_complete", lambda _page: next(states)) + + collector.ensure_logged_in(Page(), "光影行星研发", "secret") + + assert visited == [collector.JD_LOGIN_URL, collector.JD_HOME_URL] + + +def test_login_failure_reason_exposes_jd_credential_error() -> None: + class Page: + def locator(self, _selector): + return self + + def inner_text(self, timeout): + del timeout + return "账号名与密码不匹配" + + assert collector._login_failure_reason(Page()) == "账号名与密码不匹配" + + +def test_safe_goto_stops_when_browser_context_is_closed() -> None: + class Page: + def goto(self, *_args, **_kwargs) -> None: + raise RuntimeError("Target page, context or browser has been closed") + + def is_closed(self) -> bool: + return True + + with pytest.raises(collector.JDAdCostCollectionError, match="上下文意外关闭"): + collector.safe_goto(Page(), "https://jzt.jd.com/custom-report/#/download") + + +def test_jd_ad_costs_uses_bundled_chromium_despite_global_chrome_override( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + def build_browser(**options): + captured.update(options) + return object() + + monkeypatch.setattr(collector, "ScraplingBrowser", build_browser) + monkeypatch.setenv("GYXX_SCRAPLING_REAL_CHROME", "true") + + collector._start_browser(False, "D:/gyxx-flow/var/state/browser-profiles/jd-ad-costs") + + assert captured["real_chrome"] is False + + +@pytest.mark.parametrize( + ("text", "report_type", "expected"), + [ + ("营销概况_矩阵分析_SKU维度_2026-08-23 报告已生成", "non_full", "ready"), + ("营销概况_下钻趋势图_2026-08-23 报表已生成", "full", "ready"), + ("营销概况_矩阵分析_SKU维度_2026-08-23 生成中", "non_full", "pending"), + ("营销概况_下钻趋势图_2026-08-22 报告已生成", "full", "absent"), + ( + "光影行星研发_营销概况_矩阵分析_SKU维度_2026-08-17至2026-08-17" + " 报表已生成 创建日期 2026-08-23", + "non_full", + "absent", + ), + ( + "光影行星研发_营销概况_下钻趋势图_2026-06-01至2026-08-23" + " 报表已生成 创建日期 2026-08-23", + "full", + "absent", + ), + ], +) +def test_download_center_state_is_report_type_and_date_scoped( + text: str, report_type: collector.ReportType, expected: collector.ReportState +) -> None: + assert ( + collector._report_state_from_text(text, report_type, date(2026, 8, 23)) + == expected + ) + + +def test_download_center_total_treats_empty_table_as_zero() -> None: + class Body: + def inner_text(self, timeout): + del timeout + return "下载报表 暂无数据" + + class Page: + def locator(self, selector): + assert selector == "body" + return Body() + + assert collector._download_center_total(Page()) == 0 + + +def test_clear_download_center_deletes_each_page_until_empty(monkeypatch) -> None: + totals = iter((61, 51, 41, 31, 21, 11, 1, 0)) + deleted_batches: list[int] = [] + + monkeypatch.setattr(collector, "_wait_for_download_center_table", lambda _page: None) + monkeypatch.setattr(collector, "_download_center_total", lambda _page: next(totals)) + monkeypatch.setattr( + collector, + "_select_all_download_center_rows", + lambda _page: deleted_batches.append(1) or 10, + ) + monkeypatch.setattr( + collector, "_delete_selected_download_center_rows", lambda _page: None + ) + monkeypatch.setattr(collector, "_wait_for", lambda *args, **kwargs: None) + monkeypatch.setattr(collector, "_reload_download_center", lambda _page: None) + + collector._clear_download_center(object()) + + assert len(deleted_batches) == 7 + + +def test_safe_extract_zip_rejects_path_traversal(tmp_path: Path) -> None: + archive = tmp_path / "report.zip" + with zipfile.ZipFile(archive, "w") as handle: + handle.writestr("../outside.csv", "bad") + + with pytest.raises(collector.JDAdCostCollectionError, match="路径越界"): + collector._safe_extract_zip(archive, tmp_path / "extracted") + + +def test_safe_extract_zip_keeps_csv_inside_output(tmp_path: Path) -> None: + archive = tmp_path / "report.zip" + with zipfile.ZipFile(archive, "w") as handle: + handle.writestr("nested/full.csv", "日期,'当前时间',SPU ID\n") + + files = collector._safe_extract_zip(archive, tmp_path / "extracted") + + assert files == [(tmp_path / "extracted" / "nested" / "full.csv").resolve()] diff --git a/tests/modules/product_commerce/test_collect_tmall_wanxiang_ads.py b/tests/modules/product_commerce/test_collect_tmall_wanxiang_ads.py new file mode 100644 index 0000000..6da86d5 --- /dev/null +++ b/tests/modules/product_commerce/test_collect_tmall_wanxiang_ads.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import json +import zipfile +from datetime import date +from pathlib import Path + +import collect_tmall_wanxiang_ads as collector +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[3] + + +def test_item_promotion_url_contains_explicit_target_date() -> None: + url = collector.item_promotion_url(date(2026, 8, 17)) + assert "#!/report/item_promotion" in url + assert "startTime=2026-08-17" in url + assert "endTime=2026-08-17" in url + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("商品报表 2026-08-18 生成成功", "ready"), + ("商品报表 2026-08-18 生成中", "pending"), + ("商品报表 2026-08-18 生成失败", "failed"), + ("商品报表 2026-08-17 生成成功", "absent"), + # 数据日期范围不匹配,仅创建日期含目标日:不能误判为同日报表 + ( + "生成成功 商品报表_20260818_120200 15天累计数据 2026-08-11至2026-08-11 2026-08-18", + "absent", + ), + # 数据日期范围覆盖目标日:可复用 + ( + "生成成功 商品报表_20260819_113811 15天累计数据 2026-08-18至2026-08-18 2026-08-19", + "ready", + ), + ],) +def test_report_task_state_is_date_scoped_and_conservative( + text: str, expected: collector.ReportTaskState +) -> None: + assert collector._report_task_state_from_text(text, date(2026, 8, 18)) == expected + + +def test_scheduled_wanxiang_workflow_does_not_force_duplicate_request() -> None: + payload = json.loads( + (PROJECT_ROOT / "config" / "workflows.json").read_text(encoding="utf-8") + ) + workflow = next( + item for item in payload["workflows"] if item["id"] == "product.ecommerce_costs.daily" + ) + download_step = next( + step for step in workflow["execution"]["steps"] if step["id"] == "tmall_download" + ) + assert "--force-request" not in download_step["args"] + + +def test_request_report_submits_outer_dialog_only_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + page = object() + modal = {"open": True} + confirm_calls = 0 + selected_dates: list[date] = [] + + monkeypatch.setattr(collector, "_wait_for_text", lambda *args, **kwargs: None) + monkeypatch.setattr(collector, "_click_visible_text", lambda *args, **kwargs: True) + monkeypatch.setattr( + collector, + "_select_date_range", + lambda _page, target_date: selected_dates.append(target_date), + ) + monkeypatch.setattr(collector, "_modal_visible", lambda _page: modal["open"]) + + def confirm(_page: object) -> bool: + nonlocal confirm_calls + confirm_calls += 1 + modal["open"] = False + return True + + monkeypatch.setattr(collector, "_click_download_dialog_confirm", confirm) + + collector.request_report(page, date(2026, 8, 18)) + + assert confirm_calls == 1 + assert selected_dates == [date(2026, 8, 18)] + + +def test_run_waits_for_existing_pending_task_instead_of_requesting_another( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + class _Session: + def close(self) -> None: + pass + + session = _Session() + page = object() + calls: list[bool] = [] + report_zip = tmp_path / "report.zip" + extracted_csv = tmp_path / "report.csv" + + monkeypatch.setattr( + collector, + "_start_session", + lambda _headless, _user_data_dir: (session, object(), page), + ) + monkeypatch.setattr(collector, "ensure_logged_in", lambda *args, **kwargs: None) + monkeypatch.setattr( + collector, + "download_ready_report", + lambda _page, _date, _output, *, timeout_seconds, wait_for_generation: ( + calls.append(wait_for_generation) + or (None if len(calls) == 1 else report_zip) + ), + ) + monkeypatch.setattr(collector, "_report_task_state", lambda *_args: "pending") + monkeypatch.setattr( + collector, + "extract_report_zip", + lambda _zip_path, _target_date: [extracted_csv], + ) + monkeypatch.setattr( + collector, + "request_report", + lambda *_args, **_kwargs: pytest.fail("must not submit a second task"), + ) + + args = collector.build_parser().parse_args( + ["--date", "2026-08-18", "--output", str(tmp_path)] + ) + # Content validation only re-requests when the reused report does not + # cover the target date; a matching CSV must keep the reuse path. + extracted_csv.write_text("日期,主体ID\n2026-08-18,1\n", encoding="utf-8") + assert collector.run(args) == [extracted_csv] + assert calls == [False, True] + + +class _Page: + def __init__(self, url: str, body: str) -> None: + self.url = url + self._body = body + + def locator(self, selector: str): + assert selector == "body" + return self + + def inner_text(self, timeout: int = 0) -> str: + return self._body + + +def test_login_success_accepts_taobao_seller_center_before_redirect() -> None: + assert collector._is_taobao_login_complete( + _Page("https://myseller.taobao.com/home", "物流监控 违规预警") + ) + assert not collector._is_taobao_login_complete( + _Page("https://loginmyseller.taobao.com/", "请输入登录密码") + ) + + +def test_safe_extract_zip_returns_csv_and_writes_manifest(tmp_path: Path) -> None: + archive_path = tmp_path / "商品报表.zip" + with zipfile.ZipFile(archive_path, "w") as archive: + archive.writestr("商品报表_20260817.csv", "日期,主体ID\n") + archive.writestr("说明.txt", "万相台商品报表") + + csv_files = collector.extract_report_zip(archive_path, date(2026, 8, 17)) + + assert [path.name for path in csv_files] == ["商品报表_20260817.csv"] + manifest = (tmp_path / "manifest.json").read_text(encoding="utf-8") + assert '"target_date": "2026-08-17"' in manifest + assert (tmp_path / "extracted" / "商品报表_20260817.csv").is_file() + + +def test_safe_extract_zip_rejects_path_traversal(tmp_path: Path) -> None: + archive_path = tmp_path / "unsafe.zip" + with zipfile.ZipFile(archive_path, "w") as archive: + archive.writestr("../escape.csv", "bad") + + with pytest.raises(collector.WanxiangDownloadError, match="路径越界"): + collector._safe_extract_zip(archive_path, tmp_path / "out") diff --git a/tests/modules/product_commerce/test_daily_result_integrity.py b/tests/modules/product_commerce/test_daily_result_integrity.py index 662bcdc..cbba330 100644 --- a/tests/modules/product_commerce/test_daily_result_integrity.py +++ b/tests/modules/product_commerce/test_daily_result_integrity.py @@ -13,6 +13,11 @@ import orchestrate_daily_collection as daily import pytest from commands import import_daily as import_daily_command +from gyxx_flow.core.exit_codes import ( + COOKIE_SKIP_EXIT_CODE, + NON_RETRYABLE_EXIT_CODE, +) + class _Context: def __init__(self, value): @@ -93,6 +98,153 @@ def test_selected_platform_collector_failure_stops_daily_aggregation(monkeypatch target_date="2026-08-03", ) + +def test_cookie_skip_is_not_retried_by_daily_orchestrator(monkeypatch) -> None: + calls = 0 + + def fake_run(*_args, **_kwargs): + nonlocal calls + calls += 1 + return {"name": "erp", "code": COOKIE_SKIP_EXIT_CODE} + + monkeypatch.setattr(daily, "run_step", fake_run) + monkeypatch.setattr( + daily.time, + "sleep", + lambda *_args: pytest.fail("cookie recovery must not enter retry cooldown"), + ) + + with pytest.raises(daily.CookiePreflightSkipped, match="erp requires"): + daily.run_step_with_retry("erp", ["python", "collector.py"]) + + assert calls == 1 + + +def test_non_retryable_collector_result_is_not_retried(monkeypatch) -> None: + calls = 0 + + def fake_run(*_args, **_kwargs): + nonlocal calls + calls += 1 + return {"name": "tm", "code": NON_RETRYABLE_EXIT_CODE} + + monkeypatch.setattr(daily, "run_step", fake_run) + monkeypatch.setattr( + daily.time, + "sleep", + lambda *_args: pytest.fail("deterministic validation must not retry"), + ) + + result = daily.run_step_with_retry( + "tm", + ["python", "collector.py"], + critical=False, + ) + + assert result["code"] == NON_RETRYABLE_EXIT_CODE + assert calls == 1 + + +def test_daily_main_propagates_cookie_skip_exit_code(monkeypatch) -> None: + monkeypatch.setattr( + daily, + "run_collect", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + daily.CookiePreflightSkipped("erp requires browser login") + ), + ) + monkeypatch.setattr( + daily.argparse.ArgumentParser, + "parse_args", + lambda _parser: SimpleNamespace( + date=None, + target_date="2026-08-12", + stages=["collect"], + skip_erp=False, + skip_platforms=False, + platforms="jd,dy,tm", + serial_platforms=False, + skip_insert=False, + skip_reviews_import=False, + dry_run=False, + erp_mode="daily", + max_run_retries=2, + ), + ) + + assert daily.main() == COOKIE_SKIP_EXIT_CODE + + +def test_erp_mode_auto_uses_daily_only_for_yesterday() -> None: + today = datetime(2026, 8, 13).date() + + assert daily.resolve_erp_mode("2026-08-12", today=today) == "daily" + assert daily.resolve_erp_mode("2026-08-11", today=today) == "backfill" + assert daily.resolve_erp_mode("2026-08-13", today=today) == "backfill" + assert daily.is_historical_target("2026-08-12", today=today) is False + assert daily.is_historical_target("2026-08-11", today=today) is True + + +@pytest.mark.parametrize("mode", ["daily", "weekly", "backfill"]) +def test_explicit_erp_mode_overrides_automatic_selection(mode) -> None: + assert ( + daily.resolve_erp_mode( + "2026-08-11", + requested_mode=mode, + today=datetime(2026, 8, 13).date(), + ) + == mode + ) + + +def test_historical_daily_collection_passes_backfill_mode_to_erp( + monkeypatch, +) -> None: + args = SimpleNamespace( + skip_erp=False, + skip_platforms=True, + skip_reviews_import=True, + dry_run=False, + erp_mode="auto", + ) + calls = [] + monkeypatch.setattr( + daily, + "resolve_erp_mode", + lambda *_args, **_kwargs: "backfill", + ) + monkeypatch.setattr( + daily, + "run_step_with_retry", + lambda *args, **kwargs: calls.append((args, kwargs)) + or {"name": "erp", "code": 0}, + ) + + daily.run_collect(args, "2026-08-11", ["jd", "dy", "tm"], failed=None) + + command = calls[0][0][1] + assert command[-4:] == ["--target-date", "2026-08-11", "--mode", "backfill"] + + +def test_historical_tm_collection_uses_backfill_child(monkeypatch) -> None: + calls: list[tuple[str, list[str]]] = [] + + def fake_run(name, command, **_kwargs): + calls.append((name, command)) + return {"name": name, "code": 0} + + monkeypatch.setattr(daily, "run_step_with_retry", fake_run) + monkeypatch.setattr(daily, "verify_platform_artifacts", lambda *_args: 1) + + daily.run_platforms( + parallel=False, + platforms=["tm"], + target_date="2026-08-11", + historical=True, + ) + + assert calls[0][1][-3:] == ["--backfill", "--target-date", "2026-08-11"] + def test_platform_selection_keeps_omitted_collectors_optional(monkeypatch) -> None: calls: list[str] = [] @@ -217,6 +369,63 @@ def test_platform_artifact_verifier_accepts_fresh_valid_product( assert daily.verify_platform_artifacts("jd", "2026-08-03", marker_ns) == 1 +def test_dy_verifier_accepts_source_date_format_and_legitimate_zero_style( + monkeypatch, + tmp_path, +) -> None: + class Loader: + def get_daily_styles(self): + return { + "dy": { + "collectable": ["matched-style", "zero-style"], + "skipped": [], + } + } + + monkeypatch.setattr(daily, "StyleConfigLoader", Loader) + monkeypatch.setattr(daily, "DATA_ROOT", tmp_path) + marker_ns = time.time_ns() + for style, matched in (("matched-style", 2), ("zero-style", 0)): + path = daily._platform_artifact_path("dy", style, "2026-08-03") + path.parent.mkdir(parents=True) + path.write_text( + json.dumps({ + "date_range": "2026/08/03 - 2026/08/03", + "matched_row_count": matched, + "download_file": "_downloads/2026-08-03/report.xlsx", + }), + encoding="utf-8", + ) + + assert daily.verify_platform_artifacts("dy", "2026-08-03", marker_ns) == 2 + + +def test_platform_artifact_verifier_rejects_an_export_with_no_product_matches( + monkeypatch, + tmp_path, +) -> None: + class Loader: + def get_daily_styles(self): + return {"dy": {"collectable": ["zero-style"], "skipped": []}} + + monkeypatch.setattr(daily, "StyleConfigLoader", Loader) + monkeypatch.setattr(daily, "DATA_ROOT", tmp_path) + marker_ns = time.time_ns() + path = daily._platform_artifact_path("dy", "zero-style", "2026-08-03") + path.parent.mkdir(parents=True) + path.write_text( + json.dumps({ + "date_range": "2026/08/03 - 2026/08/03", + "matched_row_count": 0, + "download_file": "_downloads/2026-08-03/report.xlsx", + }), + encoding="utf-8", + ) + + with pytest.raises(RuntimeError, match="matched no configured products"): + daily.verify_platform_artifacts("dy", "2026-08-03", marker_ns) + + def test_review_import_remains_explicitly_noncritical(monkeypatch) -> None: args = SimpleNamespace( skip_erp=True, @@ -240,6 +449,27 @@ def test_review_import_remains_explicitly_noncritical(monkeypatch) -> None: assert calls[0][1]["critical"] is False +def test_daily_import_stage_uses_the_collected_business_date(monkeypatch) -> None: + args = SimpleNamespace(dry_run=True, platforms="jd,tm") + calls = [] + monkeypatch.setattr( + daily, + "run_step_with_retry", + lambda *args, **kwargs: calls.append((args, kwargs)) or {"name": args[0], "code": 0}, + ) + + daily.run_import(args, "2026-08-03", failed=None) + + assert calls[0][0][0] == "import_product_daily" + assert calls[0][0][1][-4:] == [ + "--date", + "2026-08-03", + "--platforms", + "jd,tm", + ] + assert calls[0][1]["dry_run"] is True + + def test_formal_aggregation_fails_when_postgres_write_is_not_fresh(monkeypatch) -> None: marker = datetime(2026, 8, 4, 3, 0, tzinfo=timezone.utc) args = SimpleNamespace(dry_run=False) @@ -374,6 +604,26 @@ def test_import_fails_when_selected_platform_has_no_target_directory(monkeypatch assert "dy: 没有匹配所选日期范围的报表目录" in capsys.readouterr().err +def test_import_deduplicates_rerun_rows_before_one_upsert_batch(capsys) -> None: + first = { + **_record(), + "source_file": "old-report.xlsx", + "visitors": 10, + } + latest = { + **_record(), + "source_file": "latest-report.xlsx", + "visitors": 20, + } + + result = product_import._deduplicate_records_for_upsert([first, latest]) + + assert len(result) == 1 + assert result[0]["source_file"] == "latest-report.xlsx" + assert result[0]["visitors"] == 20 + assert "丢弃重复原始行: 1 行" in capsys.readouterr().out + + def test_import_fails_when_target_directory_has_no_input_file(monkeypatch, capsys) -> None: target_dir = Path("2026-08-03") monkeypatch.setattr(product_import, "list_date_dirs", lambda _platform: [target_dir]) diff --git a/tests/modules/product_commerce/test_db_get_conn.py b/tests/modules/product_commerce/test_db_get_conn.py new file mode 100644 index 0000000..2dc5575 --- /dev/null +++ b/tests/modules/product_commerce/test_db_get_conn.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import db +import pytest +from db import InterfaceError, get_conn + + +class FakeCursor: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def execute(self, _sql): + return None + + def fetchone(self): + return (1,) + + +class FakeConnection: + def __init__(self, alive: bool) -> None: + self.alive = alive + self.closed = False + self.committed = False + self.rolled_back = False + + def rollback(self) -> None: + if not self.alive: + raise RuntimeError("connection already closed") + self.rolled_back = True + + def cursor(self): + if not self.alive: + raise RuntimeError("connection already closed") + return FakeCursor() + + def commit(self) -> None: + self.committed = True + + def close(self) -> None: + self.closed = True + + +class FakePool: + def __init__(self, connections): + self.connections = list(connections) + self.returned: list[tuple[object, bool]] = [] + + def getconn(self): + if not self.connections: + raise RuntimeError("pool exhausted") + return self.connections.pop(0) + + def putconn(self, conn, close: bool = False) -> None: + self.returned.append((conn, close)) + + +def test_get_conn_reuses_healthy_pooled_connection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + connection = FakeConnection(alive=True) + fake_pool = FakePool([connection]) + monkeypatch.setattr(db, "_get_pool", lambda: fake_pool) + + with get_conn() as acquired: + assert acquired is connection + + assert connection.committed is True + assert fake_pool.returned == [(connection, False)] + + +def test_get_conn_discards_stale_connection_and_borrows_fresh( + monkeypatch: pytest.MonkeyPatch, +) -> None: + stale = FakeConnection(alive=False) + fresh = FakeConnection(alive=True) + fake_pool = FakePool([stale, fresh]) + monkeypatch.setattr(db, "_get_pool", lambda: fake_pool) + + with get_conn() as acquired: + assert acquired is fresh + + assert stale.closed is True + assert fresh.committed is True + assert fake_pool.returned == [(stale, True), (fresh, False)] + + +def test_get_conn_fails_closed_when_all_pooled_connections_are_dead( + monkeypatch: pytest.MonkeyPatch, +) -> None: + first = FakeConnection(alive=False) + second = FakeConnection(alive=False) + fake_pool = FakePool([first, second]) + monkeypatch.setattr(db, "_get_pool", lambda: fake_pool) + + with pytest.raises(InterfaceError): + with get_conn(): + pass + + assert first.closed is True + assert second.closed is True + assert fake_pool.returned == [(first, True), (second, True)] + + +def test_get_pool_retries_transient_initial_connection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + attempts: list[str] = [] + + def fake_pool(**_kwargs): + attempts.append("connect") + if len(attempts) == 1: + raise db.OperationalError("transient connection failure") + return object() + + for name, value in { + "PG_HOST": "db.example.test", + "PG_PORT": "5432", + "PG_DB": "warehouse", + "PG_USER": "collector", + "PG_PASSWORD": "unit-test-only", + }.items(): + monkeypatch.setenv(name, value) + monkeypatch.setattr(db, "_pool", None) + monkeypatch.setattr(db.pool, "SimpleConnectionPool", fake_pool) + monkeypatch.setattr(db.time, "sleep", lambda _seconds: attempts.append("sleep")) + + result = db._get_pool(connect_attempts=2, retry_delay_seconds=0) + + assert result is not None + assert attempts == ["connect", "sleep", "connect"] diff --git a/tests/modules/product_commerce/test_direct_llm.py b/tests/modules/product_commerce/test_direct_llm.py new file mode 100644 index 0000000..8ae0d63 --- /dev/null +++ b/tests/modules/product_commerce/test_direct_llm.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import json + +import pytest + +from gyxx_flow.modules.product_commerce.direct_llm import ( + DirectLLMConfig, + DirectLLMConfigurationError, + DirectLLMResponseError, + DirectLLMTransientError, + chat_completion, + load_direct_llm_config, +) + + +def _config() -> DirectLLMConfig: + return DirectLLMConfig( + endpoint="https://api.minimaxi.com/v1/chat/completions", + credential="test-only-secret", + title_model="MiniMax-M3", + vision_model="MiniMax-M3", + ) + + +def _response(content: str) -> bytes: + return json.dumps( + { + "choices": [{"message": {"content": content}}], + "usage": {"prompt_tokens": 3, "completion_tokens": 4}, + } + ).encode() + + +def test_direct_chat_uses_provider_endpoint_and_model_without_echoing_secret() -> None: + observed: dict[str, object] = {} + + def transport(url, headers, body, timeout): + payload = json.loads(body) + observed.update( + url=url, + model=payload["model"], + messages=payload["messages"], + reasoning_split=payload["reasoning_split"], + timeout=timeout, + authorization=headers["Authorization"], + ) + return _response('{"ok":true}') + + content, usage = chat_completion( + [{"role": "user", "content": "hello"}], + model="MiniMax-M3", + timeout=17, + transport=transport, + config=_config(), + ) + + assert content == '{"ok":true}' + assert usage == {"prompt_tokens": 3, "completion_tokens": 4} + assert observed["url"] == "https://api.minimaxi.com/v1/chat/completions" + assert observed["model"] == "MiniMax-M3" + assert observed["reasoning_split"] is True + assert observed["timeout"] == 17.0 + assert observed["authorization"] == "Bearer test-only-secret" + + +def test_direct_chat_can_disable_minimax_thinking_for_structured_video_tasks() -> None: + observed: dict[str, object] = {} + + def transport(_url, _headers, body, _timeout): + observed.update(json.loads(body)) + return _response("ok") + + chat_completion( + [{"role": "user", "content": "return one token"}], + thinking_mode="disabled", + transport=transport, + config=_config(), + ) + + assert observed["thinking"] == {"type": "disabled"} + + +def test_direct_chat_rejects_unknown_thinking_mode() -> None: + with pytest.raises(DirectLLMConfigurationError, match="thinking mode"): + chat_completion( + [{"role": "user", "content": "hello"}], + thinking_mode="sometimes", + transport=lambda *_args: _response("unused"), + config=_config(), + ) + + +def test_config_reads_shared_minimax_environment(monkeypatch) -> None: + monkeypatch.delenv("GYXX_DIRECT_LLM_BASE_URL", raising=False) + monkeypatch.delenv("GYXX_DIRECT_LLM_API_KEY", raising=False) + monkeypatch.delenv("GYXX_DIRECT_LLM_MODEL", raising=False) + monkeypatch.setenv("STYLE_ANALYSIS_LLM_BASE_URL", "https://api.minimaxi.com/v1") + monkeypatch.setenv("STYLE_ANALYSIS_LLM_API_KEY", "test-only-secret") + monkeypatch.setenv("STYLE_ANALYSIS_LLM_MODEL", "MiniMax-M3") + + config = load_direct_llm_config() + + assert config.endpoint == "https://api.minimaxi.com/v1/chat/completions" + assert config.title_model == "MiniMax-M3" + assert config.vision_model == "MiniMax-M3" + assert "test-only-secret" not in repr(config) + + +def test_config_rejects_legacy_local_gateway(monkeypatch) -> None: + monkeypatch.setenv( + "GYXX_DIRECT_LLM_BASE_URL", + "http://127.0.0.1:8642/v1", + ) + monkeypatch.setenv("GYXX_DIRECT_LLM_API_KEY", "test-only-secret") + + with pytest.raises(DirectLLMConfigurationError, match="local gateway"): + load_direct_llm_config() + + +def test_invalid_response_is_fail_closed() -> None: + with pytest.raises(DirectLLMResponseError, match="invalid JSON"): + chat_completion( + [{"role": "user", "content": "hello"}], + transport=lambda *_args: b"not-json", + config=_config(), + ) + + +def test_direct_chat_retries_transient_provider_overload(monkeypatch) -> None: + calls = 0 + + def transport(_url, _headers, _body, _timeout): + nonlocal calls + calls += 1 + if calls < 3: + raise DirectLLMTransientError( + "direct model request failed with HTTP 529" + ) + return _response("recovered") + + delays: list[float] = [] + monkeypatch.setattr( + "gyxx_flow.modules.product_commerce.direct_llm.time.sleep", + delays.append, + ) + + content, _usage = chat_completion( + [{"role": "user", "content": "hello"}], + transport=transport, + config=_config(), + ) + + assert content == "recovered" + assert calls == 3 + assert delays == [1.0, 3.0] + + +def test_direct_chat_preserves_transient_error_after_bounded_retries( + monkeypatch, +) -> None: + calls = 0 + + def transport(_url, _headers, _body, _timeout): + nonlocal calls + calls += 1 + raise DirectLLMTransientError( + "direct model request failed with HTTP 529" + ) + + monkeypatch.setattr( + "gyxx_flow.modules.product_commerce.direct_llm.time.sleep", + lambda _seconds: None, + ) + + with pytest.raises(DirectLLMTransientError, match="HTTP 529"): + chat_completion( + [{"role": "user", "content": "hello"}], + transport=transport, + config=_config(), + ) + + assert calls == 3 diff --git a/tests/modules/product_commerce/test_direct_visual_color.py b/tests/modules/product_commerce/test_direct_visual_color.py new file mode 100644 index 0000000..7fe9e48 --- /dev/null +++ b/tests/modules/product_commerce/test_direct_visual_color.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +from PIL import Image + +from gyxx_flow.modules.product_commerce.direct_llm import DirectLLMConfig +from gyxx_flow.modules.product_commerce.direct_visual_color import ( + DirectVisualColorClassifier, + VisualCandidate, +) + + +def _image(path: Path, color: str) -> Path: + Image.new("RGB", (12, 12), color).save(path, format="PNG") + return path + + +def _response(classification: dict[str, object]) -> bytes: + return json.dumps( + { + "choices": [ + { + "message": { + "content": json.dumps(classification), + } + } + ] + } + ).encode() + + +def test_direct_visual_classifier_sends_images_to_minimax_m3(tmp_path) -> None: + source = _image(tmp_path / "source.png", "black") + candidate = _image(tmp_path / "candidate.png", "black") + observed: dict[str, object] = {} + + def transport(url, headers, body, timeout): + payload = json.loads(body) + image_parts = [ + part + for part in payload["messages"][1]["content"] + if part["type"] == "image_url" + ] + observed.update( + url=url, + model=payload["model"], + thinking=payload["thinking"], + timeout=timeout, + image_count=len(image_parts), + data_url_prefix=image_parts[0]["image_url"]["url"][:22], + has_auth=headers["Authorization"].startswith("Bearer "), + ) + return _response( + { + "source_color": "black", + "candidate_colors": {"candidate": "black"}, + "visual_match": {"unique": False, "key": None}, + } + ) + + result = DirectVisualColorClassifier( + transport=transport, + config_resolver=lambda: DirectLLMConfig( + endpoint="https://api.minimaxi.com/v1/chat/completions", + credential="test-only-secret", + title_model="MiniMax-M3", + vision_model="MiniMax-M3", + ), + ).classify([source], [VisualCandidate("candidate", candidate)]) + + assert result.source_color == "black" + assert result.selected_key == "candidate" + assert observed == { + "url": "https://api.minimaxi.com/v1/chat/completions", + "model": "MiniMax-M3", + "thinking": {"type": "disabled"}, + "timeout": 120.0, + "image_count": 2, + "data_url_prefix": "data:image/png;base64,", + "has_auth": True, + } + + +def test_direct_visual_payload_uses_external_provider_endpoint(tmp_path) -> None: + source = _image(tmp_path / "source.png", "black") + candidate = _image(tmp_path / "candidate.png", "black") + captured: dict[str, object] = {} + + def transport(url, _headers, body, _timeout): + captured["url"] = url + payload = json.loads(body) + image_url = next( + part["image_url"]["url"] + for part in payload["messages"][1]["content"] + if part["type"] == "image_url" + ) + assert base64.b64decode(image_url.split(",", 1)[1]) + return _response( + { + "source_color": "unknown", + "candidate_colors": {"candidate": "black"}, + "visual_match": {"unique": False, "key": None}, + } + ) + + DirectVisualColorClassifier( + transport=transport, + config_resolver=lambda: DirectLLMConfig( + endpoint="https://api.minimaxi.com/v1/chat/completions", + credential="test-only-secret", + title_model="MiniMax-M3", + vision_model="MiniMax-M3", + ), + ).classify([source], [VisualCandidate("candidate", candidate)]) + + assert captured["url"] == "https://api.minimaxi.com/v1/chat/completions" + assert "8642" not in str(captured["url"]) + + +def test_direct_visual_classifier_accepts_model_reasoning_wrapper(tmp_path) -> None: + source = _image(tmp_path / "source.png", "black") + candidate = _image(tmp_path / "candidate.png", "black") + classification = { + "source_color": "black", + "candidate_colors": {"candidate": "black"}, + "visual_match": {"unique": False, "key": None}, + } + + def transport(_url, _headers, _body, _timeout): + content = ( + "分析图片后再给出严格结果。\n" + "```json\n" + + json.dumps(classification) + + "\n```" + ) + return json.dumps( + {"choices": [{"message": {"content": content}}]} + ).encode() + + result = DirectVisualColorClassifier( + transport=transport, + config_resolver=lambda: DirectLLMConfig( + endpoint="https://api.minimaxi.com/v1/chat/completions", + credential="test-only-secret", + title_model="MiniMax-M3", + vision_model="MiniMax-M3", + ), + ).classify([source], [VisualCandidate("candidate", candidate)]) + + assert result.source_color == "black" + assert result.selected_key == "candidate" diff --git a/tests/modules/product_commerce/test_direct_visual_color_contract.py b/tests/modules/product_commerce/test_direct_visual_color_contract.py new file mode 100644 index 0000000..8465243 --- /dev/null +++ b/tests/modules/product_commerce/test_direct_visual_color_contract.py @@ -0,0 +1,524 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from PIL import Image + +from gyxx_flow.modules.product_commerce.direct_llm import DirectLLMConfig +from gyxx_flow.modules.product_commerce.direct_visual_color import ( + COARSE_COLORS, + MAX_CANDIDATES, + DirectVisualColorClassifier, + DirectVisualColorError, + VisualCandidate, + normalize_coarse_color, +) + + +def _image(path: Path, color: str) -> Path: + Image.new("RGB", (12, 12), color).save(path, format="PNG") + return path + + +def _response(classification: dict[str, Any]) -> bytes: + return json.dumps( + { + "choices": [ + { + "message": { + "role": "assistant", + "content": json.dumps(classification), + } + } + ] + } + ).encode() + + +def _classifier(response: bytes, observed: dict[str, Any] | None = None): + def transport(url, headers, body, timeout): + if observed is not None: + payload = json.loads(body) + image_parts = [ + part + for part in payload["messages"][1]["content"] + if part["type"] == "image_url" + ] + observed.update( + { + "url": url, + "timeout": timeout, + "model": payload["model"], + "image_count": len(image_parts), + "all_data_urls": all( + part["image_url"]["url"].startswith( + "data:image/png;base64," + ) + for part in image_parts + ), + "has_authorization": headers.get("Authorization", "").startswith( + "Bearer " + ), + } + ) + return response + + return DirectVisualColorClassifier( + transport=transport, + config_resolver=lambda: DirectLLMConfig( + endpoint="https://api.minimaxi.com/v1/chat/completions", + credential="test-only-credential", + title_model="MiniMax-M3", + vision_model="MiniMax-M3", + ), + ) + + +def test_unique_coarse_color_match_uses_local_images_as_data_urls(tmp_path) -> None: + source = _image(tmp_path / "source.png", "saddlebrown") + black = _image(tmp_path / "black.png", "black") + brown = _image(tmp_path / "brown.png", "peru") + observed: dict[str, Any] = {} + classifier = _classifier( + _response( + { + "source_color": "brown", + "candidate_colors": {"sku-black": "black", "sku-brown": "tan"}, + "visual_match": {"unique": False, "key": None}, + } + ), + observed, + ) + + result = classifier.classify( + [source], + [ + VisualCandidate("sku-black", black), + VisualCandidate("sku-brown", brown), + ], + ) + + assert result.source_color == "brown" + assert result.candidate_colors == {"sku-black": "black", "sku-brown": "brown"} + assert result.selected_key == "sku-brown" + assert result.selection_basis == "coarse_color" + assert observed == { + "url": "https://api.minimaxi.com/v1/chat/completions", + "timeout": 120.0, + "model": "MiniMax-M3", + "image_count": 3, + "all_data_urls": True, + "has_authorization": True, + } + + +def test_same_color_tie_allows_one_visual_similarity_match(tmp_path) -> None: + source_a = _image(tmp_path / "source-a.png", "navy") + source_b = _image(tmp_path / "source-b.png", "blue") + first = _image(tmp_path / "first.png", "navy") + second = _image(tmp_path / "second.png", "blue") + classifier = _classifier( + _response( + { + "source_color": "navy", + "candidate_colors": {"first": "blue", "second": "blue"}, + "visual_match": {"unique": True, "key": "second"}, + } + ) + ) + + result = classifier.classify( + [source_a, source_b], + [VisualCandidate("first", first), VisualCandidate("second", second)], + ) + + assert result.source_color == "blue" + assert result.selected_key == "second" + assert result.selection_basis == "visual_similarity" + + +@pytest.mark.parametrize( + "classification", + [ + { + "source_color": "blue", + "candidate_colors": {"first": "blue", "second": "blue"}, + "visual_match": {"unique": True, "key": "invented"}, + }, + { + "source_color": "blue", + "candidate_colors": {"first": "blue", "invented": "blue"}, + "visual_match": {"unique": False, "key": None}, + }, + { + "source_color": "blue", + "candidate_colors": {"first": "blue"}, + "visual_match": {"unique": False, "key": None}, + }, + ], +) +def test_unknown_or_missing_candidate_key_fails_closed( + tmp_path, + classification, +) -> None: + source = _image(tmp_path / "source.png", "blue") + first = _image(tmp_path / "first.png", "blue") + second = _image(tmp_path / "second.png", "blue") + + with pytest.raises(DirectVisualColorError, match="candidate key"): + _classifier(_response(classification)).classify( + [source], + [VisualCandidate("first", first), VisualCandidate("second", second)], + ) + + +def test_one_character_candidate_key_typo_fails_closed(tmp_path) -> None: + source = _image(tmp_path / "source.png", "black") + candidate = _image(tmp_path / "candidate.png", "black") + + with pytest.raises(DirectVisualColorError, match="unknown candidate key"): + _classifier( + _response( + { + "source_color": "black", + "candidate_colors": {"card-eb51a74da3a4": "black"}, + "visual_match": {"unique": False, "key": None}, + } + ) + ).classify( + [source], + [VisualCandidate("card-eb51a74da3a3", candidate)], + ) + + +def test_visual_match_cannot_bypass_coarse_color_filter(tmp_path) -> None: + source = _image(tmp_path / "source.png", "red") + red = _image(tmp_path / "red.png", "red") + blue = _image(tmp_path / "blue.png", "blue") + + with pytest.raises(DirectVisualColorError, match="inconsistent"): + _classifier( + _response( + { + "source_color": "red", + "candidate_colors": {"red": "red", "blue": "blue"}, + "visual_match": {"unique": True, "key": "blue"}, + } + ) + ).classify( + [source], + [VisualCandidate("red", red), VisualCandidate("blue", blue)], + ) + + +@pytest.mark.parametrize( + "assistant_content", + [ + "not-json", + '```json\n{"source_color":"black"}\n```', + '{"source_color": NaN}', + '{"source_color":"black","candidate_colors":{},"visual_match":{},"extra":1}', + ], +) +def test_non_strict_model_json_fails_closed(tmp_path, assistant_content) -> None: + source = _image(tmp_path / "source.png", "black") + candidate = _image(tmp_path / "candidate.png", "black") + response = json.dumps( + {"choices": [{"message": {"content": assistant_content}}]} + ).encode() + + with pytest.raises(DirectVisualColorError): + _classifier(response).classify( + [source], + [VisualCandidate("candidate", candidate)], + ) + + +def test_non_string_source_color_fails_closed(tmp_path) -> None: + source = _image(tmp_path / "source.png", "black") + candidate = _image(tmp_path / "candidate.png", "black") + + with pytest.raises(DirectVisualColorError, match="source color"): + _classifier( + _response( + { + "source_color": None, + "candidate_colors": {"candidate": "black"}, + "visual_match": {"unique": False, "key": None}, + } + ) + ).classify( + [source], + [VisualCandidate("candidate", candidate)], + ) + + +def test_image_count_limits_fail_before_transport(tmp_path) -> None: + image = _image(tmp_path / "image.png", "black") + called = False + + def transport(*_args): + nonlocal called + called = True + raise AssertionError("must not call transport") + + classifier = DirectVisualColorClassifier( + transport=transport, + config_resolver=lambda: DirectLLMConfig( + endpoint="https://api.minimaxi.com/v1/chat/completions", + credential="test-only-credential", + title_model="MiniMax-M3", + vision_model="MiniMax-M3", + ), + ) + with pytest.raises(DirectVisualColorError, match="between 1 and 2"): + classifier.classify([], [VisualCandidate("one", image)]) + with pytest.raises(DirectVisualColorError, match="between 1 and 2"): + classifier.classify([image, image, image, image], [VisualCandidate("one", image)]) + with pytest.raises(DirectVisualColorError, match="safe limit"): + classifier.classify( + [image], + [VisualCandidate(f"candidate-{index}", image) for index in range(MAX_CANDIDATES + 1)], + ) + assert called is False + + +def test_http_failure_never_echoes_secret_or_image_payload(tmp_path) -> None: + source = _image(tmp_path / "source.png", "black") + candidate = _image(tmp_path / "candidate.png", "black") + leaked_marker = "data:image/png;base64,SHOULD-NOT-LEAK" + + def transport(*_args): + raise RuntimeError(f"secret-value {leaked_marker}") + + classifier = DirectVisualColorClassifier( + transport=transport, + config_resolver=lambda: DirectLLMConfig( + endpoint="https://api.minimaxi.com/v1/chat/completions", + credential="secret-value", + title_model="MiniMax-M3", + vision_model="MiniMax-M3", + ), + ) + with pytest.raises(DirectVisualColorError) as captured: + classifier.classify( + [source], + [VisualCandidate("candidate", candidate)], + ) + + message = str(captured.value) + assert "secret-value" not in message + assert "base64" not in message + assert captured.value.__cause__ is None + + +def test_missing_or_invalid_image_fails_before_transport(tmp_path) -> None: + invalid = tmp_path / "invalid.png" + invalid.write_text("not an image", encoding="utf-8") + candidate = _image(tmp_path / "candidate.png", "black") + + with pytest.raises(DirectVisualColorError, match="invalid"): + _classifier(b"unused").classify( + [invalid], + [VisualCandidate("candidate", candidate)], + ) + + +def test_color_normalization_is_closed() -> None: + assert normalize_coarse_color("Grey") == "unknown" + assert normalize_coarse_color("light gray") == "white" + assert normalize_coarse_color("\u6d45\u7070") == "white" + assert normalize_coarse_color("silver-white") == "white" + assert normalize_coarse_color("dark gray") == "black" + assert normalize_coarse_color("\u70ad\u7070") == "black" + assert normalize_coarse_color("\u6df1\u7070") == "black" + assert normalize_coarse_color("\u5361\u5176") == "brown" + assert normalize_coarse_color("navy") == "blue" + assert normalize_coarse_color("transparent") == "unknown" + assert normalize_coarse_color(None) == "unknown" + + +def test_gray_model_output_is_unknown_and_never_matches(tmp_path) -> None: + source = _image(tmp_path / "source.png", "gray") + candidate = _image(tmp_path / "candidate.png", "white") + + result = _classifier( + _response( + { + "source_color": "gray", + "candidate_colors": {"candidate": "white"}, + "visual_match": {"unique": False, "key": None}, + } + ) + ).classify([source], [VisualCandidate("candidate", candidate)]) + + assert result.source_color == "unknown" + assert result.candidate_colors == {"candidate": "white"} + assert result.selected_key is None + assert result.selection_basis == "unresolved" + + +def test_light_neutral_matches_white_but_remains_distinct_from_black(tmp_path) -> None: + source = _image(tmp_path / "source.png", "#dddddd") + candidate = _image(tmp_path / "candidate.png", "white") + + light_result = _classifier( + _response( + { + "source_color": "light gray", + "candidate_colors": {"candidate": "white"}, + "visual_match": {"unique": False, "key": None}, + } + ) + ).classify([source], [VisualCandidate("candidate", candidate)]) + dark_result = _classifier( + _response( + { + "source_color": "black", + "candidate_colors": {"candidate": "light gray"}, + "visual_match": {"unique": False, "key": None}, + } + ) + ).classify([source], [VisualCandidate("candidate", candidate)]) + + assert light_result.source_color == "white" + assert light_result.selected_key == "candidate" + assert dark_result.source_color == "black" + assert dark_result.candidate_colors == {"candidate": "white"} + assert dark_result.selected_key is None + + +def test_prompt_and_schema_split_neutrals_by_brightness(tmp_path) -> None: + source = _image(tmp_path / "source.png", "#dddddd") + candidate = _image(tmp_path / "candidate.png", "white") + observed: dict[str, Any] = {} + + def transport(_url, _headers, body, _timeout): + payload = json.loads(body) + observed["instruction"] = payload["messages"][1]["content"][0]["text"] + return _response( + { + "source_color": "white", + "candidate_colors": {"candidate": "white"}, + "visual_match": {"unique": False, "key": None}, + } + ) + + result = DirectVisualColorClassifier( + transport=transport, + config_resolver=lambda: DirectLLMConfig( + endpoint="https://api.minimaxi.com/v1/chat/completions", + credential="test-only-credential", + title_model="MiniMax-M3", + vision_model="MiniMax-M3", + ), + ).classify([source], [VisualCandidate("candidate", candidate)]) + + instruction = str(observed["instruction"]) + schema = json.loads(instruction.rsplit("shape: ", maxsplit=1)[1]) + allowed_value = "one of [" + ", ".join(COARSE_COLORS) + "]" + assert "gray" not in COARSE_COLORS + assert schema["source_color"] == allowed_value + assert schema["candidate_colors"] == {"candidate": allowed_value} + assert "silver-white, and light gray" in instruction + assert "black includes black, charcoal, and dark gray" in instruction + assert "Never output gray as a standalone color" in instruction + assert result.source_color == "white" + assert result.selected_key == "candidate" + + +def test_prompt_classifies_only_bag_body_and_ignores_clothing_and_overlays( + tmp_path, +) -> None: + source = _image(tmp_path / "source.png", "black") + candidate = _image(tmp_path / "candidate.png", "black") + observed: dict[str, str] = {} + + def transport(_url, _headers, body, _timeout): + payload = json.loads(body) + observed["system"] = payload["messages"][0]["content"] + observed["instruction"] = payload["messages"][1]["content"][0]["text"] + return _response( + { + "source_color": "black", + "candidate_colors": {"candidate": "black"}, + "visual_match": {"unique": False, "key": None}, + } + ) + + result = DirectVisualColorClassifier( + transport=transport, + config_resolver=lambda: DirectLLMConfig( + endpoint="https://api.minimaxi.com/v1/chat/completions", + credential="test-only-credential", + title_model="MiniMax-M3", + vision_model="MiniMax-M3", + ), + ).classify([source], [VisualCandidate("candidate", candidate)]) + + instruction = observed["instruction"] + assert "Classify the BAG itself, not the whole image" in instruction + assert "only the dominant color of the bag's main body panels" in instruction + assert "product color is never the color of a person or their clothing" in instruction + assert "jackets, shirts, pants" in instruction + assert "text, prices, badges" in instruction + assert "black bag on a light-gray jacket is black" in instruction + assert "black or navy bag on green clothing" in instruction + assert "Never classify the overall image palette" in instruction + assert "return unknown" in instruction + assert "When that count is 0 or 1" in instruction + assert 'visual_match MUST be exactly {"unique":false,"key":null}' in instruction + assert "never put the sole same-color candidate" in instruction + assert "Only when the count is 2 or more" in instruction + assert "fail-closed product image classifier" in observed["system"] + assert result.source_color == "black" + assert result.selected_key == "candidate" + + +def test_prompt_repeats_exact_opaque_candidate_key_allowlist(tmp_path) -> None: + source = _image(tmp_path / "source.png", "black") + first = _image(tmp_path / "first.png", "black") + second = _image(tmp_path / "second.png", "white") + exact_keys = ("card-eb51a74da3a3", "card-eb51a74da3af") + observed: dict[str, str] = {} + + def transport(_url, _headers, body, _timeout): + payload = json.loads(body) + observed["instruction"] = payload["messages"][1]["content"][0]["text"] + return _response( + { + "source_color": "black", + "candidate_colors": { + exact_keys[0]: "black", + exact_keys[1]: "white", + }, + "visual_match": {"unique": False, "key": None}, + } + ) + + result = DirectVisualColorClassifier( + transport=transport, + config_resolver=lambda: DirectLLMConfig( + endpoint="https://api.minimaxi.com/v1/chat/completions", + credential="test-only-credential", + title_model="MiniMax-M3", + vision_model="MiniMax-M3", + ), + ).classify( + [source], + [VisualCandidate(exact_keys[0], first), VisualCandidate(exact_keys[1], second)], + ) + + instruction = observed["instruction"] + allowlist = json.dumps(list(exact_keys), separators=(",", ":")) + assert instruction.count(allowlist) == 2 + assert "Candidate keys are opaque, immutable identifiers" in instruction + assert "byte-for-byte exactly as provided" in instruction + assert "do not edit, invent, autocomplete, normalize, truncate, or repair" in instruction + assert "each provided candidate key exactly once and no other keys" in instruction + assert result.selected_key == exact_keys[0] + + diff --git a/tests/modules/product_commerce/test_dy_market_rank.py b/tests/modules/product_commerce/test_dy_market_rank.py index 8fe5508..5337594 100644 --- a/tests/modules/product_commerce/test_dy_market_rank.py +++ b/tests/modules/product_commerce/test_dy_market_rank.py @@ -1,4 +1,6 @@ import unittest +from pathlib import Path +from tempfile import TemporaryDirectory from unittest.mock import MagicMock, patch from collect_dy_market_rank import ( @@ -7,12 +9,14 @@ from collect_dy_market_rank import ( build_dy_category_table_xml, build_dy_feishu_batch_xml, build_dy_feishu_intro_xml, + install_saved_cookies, is_excluded_product_title, navigate_to_product_rank, parse_market_product_rank_html, parse_money_range_upper, payment_metric_header_index, require_payment_metric_header, + snapshot_rank_page, ) from collect_sycm_market_rank import build_feishu_batch_xml @@ -54,6 +58,24 @@ SAMPLE_HTML = """ class DyMarketRankTests(unittest.TestCase): + def test_saved_cookies_refresh_existing_context(self): + context = MagicMock() + cookies = [{"name": "session", "value": "secret"}] + + self.assertEqual(install_saved_cookies(context, cookies), 1) + context.add_cookies.assert_called_once_with(cookies) + + def test_rank_timeout_snapshot_persists_dom(self): + page = MagicMock() + page.content.return_value = "新版榜单" + + with TemporaryDirectory() as temp_dir: + with patch("collect_dy_market_rank.DEBUG_DIR", Path(temp_dir)): + path = snapshot_rank_page(page, "header timeout") + + self.assertTrue(path.is_file()) + self.assertIn("新版榜单", path.read_text(encoding="utf-8")) + def test_title_blacklist_filters_children_primary_and_middle_school(self): self.assertTrue(is_excluded_product_title("儿童轻便双肩包")) self.assertTrue(is_excluded_product_title("小学生护脊书包")) diff --git a/tests/modules/product_commerce/test_erp_all_shop_daily_backfill.py b/tests/modules/product_commerce/test_erp_all_shop_daily_backfill.py new file mode 100644 index 0000000..17f1cea --- /dev/null +++ b/tests/modules/product_commerce/test_erp_all_shop_daily_backfill.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +from datetime import date +from pathlib import Path + +import pytest + +from gyxx_flow.modules.product_commerce import backfill_erp_all_shop_daily as backfill +from gyxx_flow.modules.product_commerce.db import ( + upsert_erp_all_shop_style_daily_metrics, +) + + +def test_default_range_is_yesterday_only() -> None: + assert backfill.DEFAULT_FROM_DATE == backfill.DEFAULT_TO_DATE + assert backfill.DEFAULT_FROM_DATE == date.today() - backfill.timedelta(days=1) + + +def test_historical_range_is_inclusive_and_covers_103_days() -> None: + dates = backfill.inclusive_dates(date(2026, 5, 1), date(2026, 8, 11)) + + assert dates[0] == date(2026, 5, 1) + assert dates[-1] == date(2026, 8, 11) + assert len(dates) == 103 + + +def test_invalid_or_oversized_range_is_rejected() -> None: + with pytest.raises(ValueError, match="--to"): + backfill.inclusive_dates(date(2026, 8, 12), date(2026, 8, 11)) + with pytest.raises(ValueError, match="366"): + backfill.inclusive_dates(date(2025, 1, 1), date(2026, 8, 11)) + + +def test_style_plan_rejects_one_erp_code_owned_by_two_styles() -> None: + with pytest.raises(ValueError, match="belongs to both"): + backfill.build_style_plans( + { + "styles": { + "款式甲": {"erp_codes": ["10416"]}, + "款式乙": {"erp_codes": ["10416"]}, + } + } + ) + + +def test_all_shop_filter_selects_every_shop_and_batches_all_codes() -> None: + class Frame: + def __init__(self) -> None: + self.script = "" + self.args = {} + + def evaluate(self, script, args): + self.script = script + self.args = args + return { + "selected_shop_count": 38, + "selection_mode": "all", + } + + frame = Frame() + result = backfill.set_all_shop_daily_filters( + frame, + report_date=date(2026, 5, 1), + erp_codes=("10416", "10455"), + ) + + assert frame.args == { + "reportDate": "2026-05-01", + "erpCodes": ["10416", "10455"], + } + assert 'input[name="shop_id"]' in frame.script + assert "=== '全选'" in frame.script + assert "erpCodes.join(',')" in frame.script + assert "shopLabels" not in frame.script + assert result == {"selected_shop_count": 38, "selection_mode": "all"} + + +def test_style_records_sum_product_rows_and_keep_zero_styles() -> None: + plans = ( + backfill.StylePlan("款式甲", ("10416", "10455")), + backfill.StylePlan("款式乙", ("10504",)), + ) + rows = [ + {"erp_style_code": "10416", "sales": 12, "return_qty": 2}, + {"erp_style_code": "10416", "sales": 8, "return_qty": 1}, + {"erp_style_code": "10455", "sales": 5, "return_qty": 3}, + ] + + records = backfill.aggregate_style_records( + date(2026, 5, 1), + plans, + rows, + selected_shop_count=38, + source="raw/product_commerce/evidence.json", + ) + + assert records[0] == { + "metric_date": date(2026, 5, 1), + "style_name": "款式甲", + "sales": 25, + "return_qty": 6, + "erp_style_codes": ["10416", "10455"], + "selected_shop_count": 38, + "matched_product_rows": 3, + "source": "raw/product_commerce/evidence.json", + } + assert records[1]["sales"] == 0 + assert records[1]["return_qty"] == 0 + assert records[1]["matched_product_rows"] == 0 + + +def test_range_collector_queries_once_per_date_not_once_per_style(monkeypatch) -> None: + filter_frame = object() + monkeypatch.setattr( + backfill.erp, "find_report_frames", lambda _page: (filter_frame, object()) + ) + calls: list[date] = [] + + def collect_one(_page, _frame, _plans, report_date, **_kwargs): + calls.append(report_date) + return ( + {"selected_shop_count": 38}, + {"rows": [], "frame_url": f"https://example/{report_date}", "signature": str(report_date)}, + ) + + monkeypatch.setattr(backfill, "collect_one_date", collect_one) + persisted: list[date] = [] + backfill.collect_range_in_page( + object(), + ( + backfill.StylePlan("款式甲", ("10416", "10455")), + backfill.StylePlan("款式乙", ("10504",)), + ), + (date(2026, 5, 1), date(2026, 5, 2)), + persist=lambda report_date, _selection, _report: persisted.append(report_date), + ) + + assert calls == [date(2026, 5, 1), date(2026, 5, 2)] + assert persisted == calls + + +def test_database_upsert_uses_date_and_style_conflict_key(monkeypatch) -> None: + captured = {} + + class Cursor: + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + class Connection: + def cursor(self): + return Cursor() + + def fake_execute_values(cursor, sql, rows): + captured.update(cursor=cursor, sql=sql, rows=rows) + + monkeypatch.setattr( + "gyxx_flow.modules.product_commerce.db.execute_values", fake_execute_values + ) + written = upsert_erp_all_shop_style_daily_metrics( + Connection(), + [ + { + "metric_date": date(2026, 5, 1), + "style_name": "款式甲", + "sales": 25, + "return_qty": 6, + "erp_style_codes": ["10455", "10416"], + "selected_shop_count": 38, + "matched_product_rows": 3, + "source": "raw/evidence.json", + } + ], + ) + + assert written == 1 + assert "ON CONFLICT (metric_date, style_name)" in captured["sql"] + assert captured["rows"][0][2:4] == (25, 6) + assert captured["rows"][0][4] == ["10416", "10455"] + + +def test_schema_keeps_all_shop_metrics_separate_from_platform_metrics() -> None: + schema = ( + Path(backfill.__file__).parent / "db" / "schema.sql" + ).read_text(encoding="utf-8") + + assert "CREATE TABLE IF NOT EXISTS erp_all_shop_style_daily_metrics" in schema + assert "PRIMARY KEY (metric_date, style_name)" in schema + assert "return_qty" in schema diff --git a/tests/modules/product_commerce/test_guanghe_metadata.py b/tests/modules/product_commerce/test_guanghe_metadata.py index 3ae4983..5f0a74a 100644 --- a/tests/modules/product_commerce/test_guanghe_metadata.py +++ b/tests/modules/product_commerce/test_guanghe_metadata.py @@ -5,22 +5,97 @@ from unittest.mock import Mock, patch from upload_video_to_guanghe import ( GuangHeUploader, + TmallTitleGenerationError, _match_tm_item_ids_from_product_titles, + _select_video_product_names, + _tm_product_title_search_pattern, download_videos, ) +def test_tm_product_title_search_pattern_uses_model_token() -> None: + assert _tm_product_title_search_pattern("拾影单肩包") == "%拾影%" + assert _tm_product_title_search_pattern("星云mini") == "%星云mini%" + + +def test_multi_style_record_prefers_style_named_by_attachment() -> None: + assert _select_video_product_names( + ["阿波罗2", "宙斯3"], "宙斯3 7.30.mp4" + ) == ["宙斯3"] + assert _select_video_product_names( + ["阿波罗2", "宙斯3"], "7月30日.mp4" + ) == ["阿波罗2", "宙斯3"] + + +def test_attachment_style_matching_handles_combos_and_overlapping_aliases() -> None: + assert _select_video_product_names( + ["阿波罗2", "宙斯3", "星云2", "逐星"], + "宙斯3+阿波罗2+星云2+逐星.m4v", + ) == ["宙斯3", "阿波罗2", "星云2", "逐星"] + assert _select_video_product_names( + ["极星双肩", "极星双肩2"], "极星双肩2 新新新.mp4" + ) == ["极星双肩2"] + + class GuangHeMetadataTests(unittest.TestCase): + def test_formal_title_rejection_uses_non_browser_failure_type(self): + GuangHeUploader.configure_catalog_product_titles( + {"极星双肩2": ["极星双肩2双肩包轻便电脑包"]} + ) + uploader = GuangHeUploader("", "", require_llm_titles=True) + try: + with patch( + "upload_video_to_guanghe.JdVideoTitleGenerator.generate", + side_effect=RuntimeError("候选不合格"), + ): + with self.assertRaisesRegex( + TmallTitleGenerationError, "候选不合格" + ): + uploader._generate_title(["极星双肩2"], video_key="token") + finally: + GuangHeUploader.configure_catalog_product_titles({}) + + def test_formal_collection_title_forbids_specific_style_names(self): + GuangHeUploader.configure_catalog_product_titles( + { + "宙斯3": ["宙斯3双肩包男士大容量"], + "阿波罗2": ["阿波罗2电脑包手提轻便"], + } + ) + uploader = GuangHeUploader("", "", require_llm_titles=True) + try: + with patch( + "upload_video_to_guanghe.JdVideoTitleGenerator" + ) as generator_class: + generator = generator_class.return_value + generator.generate.return_value = ( + "多款通勤包袋,大容量轻便按场景切换" + ) + title = uploader._generate_title( + ["宙斯3", "阿波罗2"], video_key="combo-token" + ) + self.assertEqual(title, "多款通勤包袋,大容量轻便按场景切换") + self.assertTrue(generator_class.call_args.kwargs["generic_collection"]) + self.assertFalse(generator_class.call_args.kwargs["require_product_name"]) + self.assertEqual(generator_class.call_args.kwargs["llm_timeout"], 300) + generator.generate.assert_called_once() + self.assertEqual( + generator.generate.call_args.args[0], ["宙斯3", "阿波罗2"] + ) + finally: + GuangHeUploader.configure_catalog_product_titles({}) + def test_downloads_with_same_attachment_name_are_isolated_by_record(self): attachment = {"file_token": "file-token", "name": "same-name.mp4"} with TemporaryDirectory() as tmp_dir, patch( "upload_video_to_guanghe.DOWNLOAD_DIR", Path(tmp_dir) ), patch("upload_video_to_guanghe.run_lark") as run_lark_mock: - def fake_download(args): - output_dir = Path(args[args.index("--output") + 1]) + def fake_download(args, *, cwd=None, timeout=None): + self.assertEqual(timeout, 600) + output_dir = Path(cwd) output_dir.mkdir(parents=True, exist_ok=True) - (output_dir / attachment["name"]).write_bytes(b"video") + (output_dir / args[args.index("--output") + 1]).write_bytes(b"video") return 0, "", "" run_lark_mock.side_effect = fake_download @@ -28,8 +103,9 @@ class GuangHeMetadataTests(unittest.TestCase): first = download_videos("record-one", [attachment]) second = download_videos("record-two", [attachment]) - self.assertEqual(first[0].parent.name, "record-one") - self.assertEqual(second[0].parent.name, "record-two") + self.assertEqual(first[0].parent.parent.name, "record-one") + self.assertEqual(second[0].parent.parent.name, "record-two") + self.assertEqual(first[0].parent.name, "file-token") self.assertNotEqual(first[0], second[0]) def test_unresolved_style_uses_exact_database_product_title_match(self): @@ -81,14 +157,20 @@ class GuangHeMetadataTests(unittest.TestCase): def test_topic_result_card_requires_topic_and_activity_statistics(self): self.assertTrue( GuangHeUploader._topic_result_card_text_matches( - "# 在淘宝种草一夏 小二推荐 作品数469.5万 参与数16.2万", - "在淘宝种草一夏", + "# 我的夏日焕新清单 小二推荐 作品数469.5万 参与数16.2万", + "我的夏日焕新清单", + ) + ) + self.assertTrue( + GuangHeUploader._topic_result_card_text_matches( + "# 秋上新开拍了 时尚 作品数198.3万 参与数11.3万", + "秋上新", ) ) self.assertFalse( GuangHeUploader._topic_result_card_text_matches( - "在淘宝种草一夏", - "在淘宝种草一夏", + "我的夏日焕新清单", + "我的夏日焕新清单", ) ) self.assertTrue(GuangHeUploader._topic_card_box_is_clickable(620, 120)) @@ -160,13 +242,12 @@ class GuangHeMetadataTests(unittest.TestCase): self.assertGreaterEqual(len(title), 26) self.assertLessEqual(len(title), 30) - def test_same_style_multiple_videos_get_distinct_titles_when_hermes_repeats(self): + def test_same_style_multiple_videos_get_distinct_titles_when_model_repeats(self): uploader = object.__new__(GuangHeUploader) repeated = "光影行星宙斯双肩包科学分区收纳轻量通勤出行" with ( patch.object(uploader, "_search_product_features", return_value=""), - patch("upload_video_to_guanghe.HAS_HERMES", True), - patch("upload_video_to_guanghe._call_hermes", return_value=(repeated, {})), + patch("upload_video_to_guanghe.call_direct_llm", return_value=(repeated, {})), ): first = uploader._generate_title(["宙斯"], video_index=1, video_total=2) second = uploader._generate_title(["宙斯"], video_index=2, video_total=2) @@ -175,13 +256,12 @@ class GuangHeMetadataTests(unittest.TestCase): self.assertTrue(first.startswith("宙斯双肩包,")) self.assertTrue(second.startswith("宙斯双肩包,")) - def test_same_style_repeated_hermes_copy_gets_low_similarity_bodies(self): + def test_same_style_repeated_model_copy_gets_low_similarity_bodies(self): uploader = object.__new__(GuangHeUploader) repeated = "光影行星宙斯双肩包,科学分区收纳,轻量通勤出行!" with ( patch.object(uploader, "_search_product_features", return_value=""), - patch("upload_video_to_guanghe.HAS_HERMES", True), - patch("upload_video_to_guanghe._call_hermes", return_value=(repeated, {})), + patch("upload_video_to_guanghe.call_direct_llm", return_value=(repeated, {})), ): titles = [ uploader._generate_title(["宙斯"], video_index=index, video_total=4) @@ -202,8 +282,7 @@ class GuangHeMetadataTests(unittest.TestCase): repeated = "科学分区收纳,轻装通勤出行" with ( patch.object(uploader, "_search_product_features", return_value=""), - patch("upload_video_to_guanghe.HAS_HERMES", True), - patch("upload_video_to_guanghe._call_hermes", return_value=(repeated, {})), + patch("upload_video_to_guanghe.call_direct_llm", return_value=(repeated, {})), ): first = uploader._generate_title(["宙斯"], video_index=1) second = uploader._generate_title(["星云2"], video_index=1) @@ -339,9 +418,8 @@ class GuangHeMetadataTests(unittest.TestCase): uploader = object.__new__(GuangHeUploader) with ( patch.object(uploader, "_search_product_features", return_value=""), - patch("upload_video_to_guanghe.HAS_HERMES", True), patch( - "upload_video_to_guanghe._call_hermes", + "upload_video_to_guanghe.call_direct_llm", return_value=("光影行星宙斯双肩包大容量笔记本电脑轻量通勤", {}), ), ): @@ -350,7 +428,7 @@ class GuangHeMetadataTests(unittest.TestCase): self.assertTrue(title.startswith("宙斯双肩包,")) self.assertNotIn("包袋双肩包", title) - def test_generated_poseidon_title_cannot_be_changed_to_backpack_by_hermes(self): + def test_generated_poseidon_title_cannot_be_changed_to_backpack_by_model(self): uploader = object.__new__(GuangHeUploader) with ( patch.object( @@ -358,9 +436,8 @@ class GuangHeMetadataTests(unittest.TestCase): "_search_product_features", return_value="双肩包、电脑包、托特包、大容量", ), - patch("upload_video_to_guanghe.HAS_HERMES", True), patch( - "upload_video_to_guanghe._call_hermes", + "upload_video_to_guanghe.call_direct_llm", return_value=( "光影行星波塞冬edge双肩包暴雨通勤防泼水电脑仓从容出行", {}, @@ -439,8 +516,8 @@ class GuangHeMetadataTests(unittest.TestCase): with self.subTest(product_names=product_names): uploader = object.__new__(GuangHeUploader) calls = [] - uploader._pick_content_labels = Mock( - side_effect=lambda labels: calls.append(("labels", list(labels))) + uploader._pick_content_label = Mock( + side_effect=lambda label: calls.append(("label", label)) ) uploader._pick_topic = Mock( side_effect=lambda topic: calls.append(("topic", topic)) @@ -452,8 +529,8 @@ class GuangHeMetadataTests(unittest.TestCase): self.assertEqual( calls, [ - ("labels", expected_labels), - ("topic", "在淘宝种草一夏"), + *[("label", label) for label in expected_labels], + ("topic", "我的夏日焕新清单"), ], ) @@ -461,8 +538,55 @@ class GuangHeMetadataTests(unittest.TestCase): self.assertEqual(GuangHeUploader.FIXED_CONTENT_LABELS, ("商品展示",)) self.assertNotIn("户外", GuangHeUploader.FIXED_CONTENT_LABELS) + def test_missing_user_selected_topic_stops_metadata_preparation(self): + uploader = object.__new__(GuangHeUploader) + uploader._pick_content_label = Mock() + uploader._pick_topic = Mock( + side_effect=RuntimeError("没有找到固定话题: #我的夏日焕新清单") + ) + uploader._snapshot = Mock() + + with self.assertRaisesRegex(RuntimeError, "没有找到固定话题"): + uploader._prepare_publish_metadata(["极星双肩"]) + + self.assertEqual( + uploader._pick_content_label.call_args_list, + [unittest.mock.call("双肩包"), unittest.mock.call("商品展示")], + ) + + def test_optional_content_label_failures_do_not_skip_required_topic(self): + uploader = object.__new__(GuangHeUploader) + uploader.page = Mock() + uploader._pick_content_label = Mock( + side_effect=[ + RuntimeError("内容标签没有精确匹配项: #双肩包"), + RuntimeError("内容标签没有精确匹配项: #商品展示"), + ] + ) + uploader._pick_topic = Mock() + uploader._snapshot = Mock() + + uploader._prepare_publish_metadata(["极星双肩"]) + + self.assertEqual( + uploader._pick_content_label.call_args_list, + [unittest.mock.call("双肩包"), unittest.mock.call("商品展示")], + ) + uploader._pick_topic.assert_called_once_with("我的夏日焕新清单") + + def test_metadata_uses_operator_topic_keyword(self): + uploader = object.__new__(GuangHeUploader) + calls = [] + uploader._pick_content_label = Mock() + uploader._pick_topic = Mock(side_effect=lambda topic: calls.append(topic)) + uploader._snapshot = Mock() + + uploader._prepare_publish_metadata(["极星双肩"], topic_keyword="秋上新") + + self.assertEqual(calls, ["秋上新"]) + def test_fixed_topic_is_not_a_recommended_first_result(self): - self.assertEqual(GuangHeUploader.FIXED_TOPIC, "在淘宝种草一夏") + self.assertEqual(GuangHeUploader.FIXED_TOPIC, "我的夏日焕新清单") if __name__ == "__main__": diff --git a/tests/modules/product_commerce/test_import_douyin_qianchuan_ads.py b/tests/modules/product_commerce/test_import_douyin_qianchuan_ads.py new file mode 100644 index 0000000..4fc2916 --- /dev/null +++ b/tests/modules/product_commerce/test_import_douyin_qianchuan_ads.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +import db as product_db +import import_douyin_qianchuan_ads as importer +from openpyxl import Workbook + +HEADERS = [ + "商品ID", + "商品名称", + "日期", + "整体展示次数", + "整体点击次数", + "整体点击率", + "整体转化率", + "整体消耗(元)", + "整体成交金额(元)", + "整体支付ROI", + "整体成交订单成本(元)", + "用户实际支付金额(元)", + "电商平台补贴金额(元)", + "净成交ROI", + "净成交金额(元)", + "净成交订单成本(元)", + "净成交金额结算率", + "1小时内退款率", +] + + +def _workbook(path) -> None: + workbook = Workbook() + sheet = workbook.active + sheet.append(HEADERS) + sheet.append( + [ + "3725242433383039375", + "测试商品", + "全部", + "12,345", + "678", + "5.49%", + "1.00%", + "100.00", + "900.00", + "9.00", + "25.00", + "850.00", + "50.00", + "8.00", + "800.00", + "30.00", + "88.89%", + "11.11%", + ] + ) + sheet.append( + [ + "3725242433383039375", + "测试商品", + "2026-08-23", + "12,345", + "678", + "5.49%", + "1.00%", + "100.00", + "900.00", + "9.00", + "25.00", + "850.00", + "50.00", + "8.00", + "800.00", + "30.00", + "88.89%", + "11.11%", + ] + ) + workbook.save(path) + + +def test_parse_workbook_keeps_long_product_id_and_skips_total(tmp_path) -> None: + path = tmp_path / "qianchuan.xlsx" + _workbook(path) + + records = importer.parse_workbook(path) + + assert len(records) == 1 + assert records[0]["stat_date"] == date(2026, 8, 23) + assert records[0]["product_id"] == "3725242433383039375" + assert records[0]["overall_impressions"] == 12345 + assert records[0]["spend"] == Decimal("100.00") + assert records[0]["refund_rate_1h"] == Decimal("11.11") + + +def test_upsert_uses_advertiser_date_product_business_key(monkeypatch) -> None: + executed = {} + + class Cursor: + def execute(self, sql, params=None): + executed["lookup_sql"] = sql + executed["lookup_params"] = params + + def fetchall(self): + return [("测试款式", ["3725242433383039375"])] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return None + + class Connection: + def cursor(self): + return Cursor() + + def fake_execute_values(cursor, sql, rows): + executed.update(sql=sql, rows=rows) + + monkeypatch.setattr(product_db, "execute_values", fake_execute_values) + record = { + "stat_date": date(2026, 8, 23), + "advertiser_id": importer.ADVERTISER_ID, + "product_id": "3725242433383039375", + "product_name": "测试商品", + "spend": Decimal("100.00"), + } + + assert product_db.upsert_douyin_product_ad_daily_metrics( + Connection(), [record, record] + ) == 1 + assert "ON CONFLICT (stat_date, advertiser_id, product_id)" in executed["sql"] + assert len(executed["rows"]) == 1 + assert "style_name" in executed["sql"] + assert executed["rows"][0][4] == "测试款式" + assert executed["rows"][0][5] == "product_id" + + +def test_upsert_falls_back_to_exact_product_name_style_match(monkeypatch) -> None: + executed = {} + + class Cursor: + def execute(self, sql, params=None): + executed["lookup_sql"] = sql + executed["lookup_params"] = params + + def fetchall(self): + return [("测试款式", [])] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return None + + class Connection: + def cursor(self): + return Cursor() + + def fake_execute_values(cursor, sql, rows): + executed.update(sql=sql, rows=rows) + + monkeypatch.setattr(product_db, "execute_values", fake_execute_values) + record = { + "stat_date": date(2026, 8, 23), + "advertiser_id": importer.ADVERTISER_ID, + "product_id": "unmapped-product-id", + "product_name": "GYXX/测试款式3双肩包2026新款", + "spend": Decimal("100.00"), + } + + assert product_db.upsert_douyin_product_ad_daily_metrics(Connection(), [record]) == 1 + assert executed["rows"][0][4] == "测试款式" + assert executed["rows"][0][5] == "product_name" diff --git a/tests/modules/product_commerce/test_import_jd_ad_costs.py b/tests/modules/product_commerce/test_import_jd_ad_costs.py new file mode 100644 index 0000000..a88f0d0 --- /dev/null +++ b/tests/modules/product_commerce/test_import_jd_ad_costs.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +from datetime import date +from pathlib import Path + +import db as product_db +import import_jd_ad_costs as importer + + +def test_jd_exports_only_import_current_block_and_ignore_comparison_rows( + tmp_path: Path, +) -> None: + full = tmp_path / "full.csv" + full.write_text( + "查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-08-23\n" + "日期,'当前时间',SPU ID,商品计划名称,SpuImgUrl,花费,全站投产比\n" + "20260823~20260823,当前时间,spu-1,盖亚款式,img,12.30,4.50\n" + "汇总,,,汇总,,12.30,4.50\n" + "20260822~20260822,对比时间,spu-old,旧款,img,99.00,9.00\n", + encoding="utf-8-sig", + ) + sku = tmp_path / "sku.csv" + sku.write_text( + "查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-08-23\n" + "SKU ID,SKU 名称,点击时间,'当前时间',花费,展现数,点击数,投产比\n" + "sku-1,光影行星 盖亚款式 黑色,20260823~20260823,当前时间,3.20,100,5,2.50\n" + "sku-old,光影行星 盖亚款式 黑色,20260822~20260822,对比时间,99.00,100,5,9.00\n", + encoding="utf-8-sig", + ) + + full_records = importer.parse_full_csv(full, date(2026, 8, 23)) + sku_records = importer.parse_non_full_csv(sku, date(2026, 8, 23)) + + assert [record["spu_id"] for record in full_records] == ["spu-1"] + assert [record["sku_id"] for record in sku_records] == ["sku-1"] + assert full_records[0]["metrics"]["source"]["current_marker"] == "当前时间" + assert full_records[0]["spend"] == importer.Decimal("12.30") + assert sku_records[0]["metrics"]["clicks"] == 5 + + +def test_sku_name_matches_dim_style_spu_and_keeps_audit_fields(tmp_path: Path) -> None: + report = tmp_path / "sku.csv" + report.write_text( + "查询条件,广告主PIN: 光影行星旗舰店霖\n" + "SKU ID,SKU 名称,点击时间,'当前时间',花费,投产比\n" + "sku-1,光影行星(GYXX)盖亚C1斜挎包 曜夜黑,20260823~20260823,当前时间,3.20,2.50\n", + encoding="utf-8-sig", + ) + sku_records = importer.parse_non_full_csv(report, date(2026, 8, 23)) + matched = importer.apply_sku_matches( + sku_records, + style_catalog=[{"style_name": "盖亚斜挎", "jd_spus": ["spu-1"]}], + full_records=[{"spu_id": "spu-1", "spu_name": "盖亚C1斜挎包"}], + ) + + assert matched[0]["match_status"] == "matched" + assert matched[0]["spu_id"] == "spu-1" + assert matched[0]["style_name"] == "盖亚斜挎" + assert matched[0]["metrics"]["match"]["reason"] + + +def test_full_spu_matches_dim_style_exactly() -> None: + full_records = [ + { + "spu_id": "spu-1", + "style_name": None, + "match_status": "direct", + "match_reason": "full-site SPU report", + "metrics": {}, + } + ] + + matched = importer.apply_full_style_matches( + full_records, + style_catalog=[ + {"style_name": "盖亚斜挎", "jd_spus": ["spu-1"]}, + ], + ) + + assert matched[0]["style_name"] == "盖亚斜挎" + assert matched[0]["match_status"] == "direct" + assert matched[0]["metrics"]["match"]["style_name"] == "盖亚斜挎" + + +def test_full_spu_name_matches_base_style_when_spu_mapping_is_missing() -> None: + full_records = [ + { + "spu_id": "spu-2", + "spu_name": "光影行星(GYXX)盖亚斜挎包3单反相机包", + "style_name": None, + "match_status": "direct", + "match_reason": "full-site SPU report", + "metrics": {}, + } + ] + + matched = importer.apply_full_style_matches( + full_records, + style_catalog=[ + {"style_name": "盖亚斜挎", "jd_spus": []}, + {"style_name": "盖亚双肩3", "jd_spus": []}, + ], + spu_catalog=[ + {"spu_id": "spu-2", "product_no": "盖亚斜挎3"}, + ], + ) + + assert matched[0]["style_name"] == "盖亚斜挎" + assert matched[0]["metrics"]["match"]["reason"] == ( + "full-site product_no matched style name" + ) + + +def test_sku_name_uses_latest_jd_spu_catalog_when_full_report_lacks_spu() -> None: + sku_records = [ + { + "sku_name": "光影行星(GYXX)阿波罗2斜挎包男士通勤便携单肩背包 曜夜黑", + "metrics": {}, + } + ] + matched = importer.apply_sku_matches( + sku_records, + style_catalog=[ + {"style_name": "阿波罗2", "jd_spus": ["spu-1", "spu-2"]} + ], + full_records=[], + spu_catalog=[ + { + "spu_id": "spu-1", + "spu_name": "光影行星(GYXX)阿波罗2斜挎包男士通勤便携单肩背包", + }, + {"spu_id": "spu-2", "spu_name": "另一款商品"}, + ], + ) + + assert matched[0]["match_status"] == "matched" + assert matched[0]["spu_id"] == "spu-1" + + +class _Cursor: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + +class _Connection: + def cursor(self): + return _Cursor() + + +def test_jd_upsert_deduplicates_same_logical_row(monkeypatch) -> None: + captured: dict[str, object] = {} + + def fake_execute_values(_cursor, sql, rows, *, template=None): + captured["sql"] = sql + captured["rows"] = rows + captured["template"] = template + + monkeypatch.setattr(product_db, "execute_values", fake_execute_values) + record = { + "stat_date": date(2026, 8, 23), + "advertiser_pin": "光影行星旗舰店霖", + "shop_account": "光影行星研发", + "report_type": "full", + "row_key": "spu:spu-1", + "spu_id": "spu-1", + "spu_name": "盖亚", + "match_status": "direct", + "spend": importer.Decimal("12.30"), + "reported_roi": importer.Decimal("4.50"), + "metrics": {"spend": "12.30"}, + "source_file": "full.csv", + "source_row_number": 3, + } + + assert product_db.upsert_jd_product_ad_daily_metrics( + _Connection(), [record, {**record, "spend": importer.Decimal("13.30")}] + ) == 1 + assert len(captured["rows"]) == 1 + assert captured["rows"][0][11] == importer.Decimal("13.30") + assert "jd_product_ad_daily_metrics" in str(captured["sql"]) + + +def test_jd_dry_run_accepts_explicit_desktop_style_files(tmp_path: Path, capsys) -> None: + full = tmp_path / "full.csv" + full.write_text( + "条件,广告主PIN: 光影行星旗舰店霖\n" + "日期,'当前时间',SPU ID,商品计划名称,花费\n" + "20260823~20260823,当前时间,spu-1,盖亚,1.00\n", + encoding="utf-8-sig", + ) + sku = tmp_path / "sku.csv" + sku.write_text( + "条件,广告主PIN: 光影行星旗舰店霖\n" + "SKU ID,SKU 名称,点击时间,'当前时间',花费\n" + "sku-1,盖亚黑色,20260823~20260823,当前时间,1.00\n", + encoding="utf-8-sig", + ) + + assert importer.main( + [ + "--date", + "2026-08-23", + "--full-file", + str(full), + "--non-full-file", + str(sku), + ] + ) == 0 + output = capsys.readouterr().out + assert "全站 SPU 1" in output + assert "非全站 SKU 1" in output + assert "DRY-RUN" in output diff --git a/tests/modules/product_commerce/test_import_tmall_product_ads.py b/tests/modules/product_commerce/test_import_tmall_product_ads.py new file mode 100644 index 0000000..fee8972 --- /dev/null +++ b/tests/modules/product_commerce/test_import_tmall_product_ads.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import db as product_db +import import_tmall_product_ads as importer + + +class _Cursor: + def execute(self, sql, params=None): + self.sql = sql + self.params = params + + def fetchall(self): + return [("极星", ["1001"])] + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + +class _Connection: + def cursor(self): + return _Cursor() + + +def test_parse_tmall_product_ad_csv_merges_product_rows(tmp_path: Path) -> None: + report = tmp_path / "商品报表.csv" + report.write_text( + "日期,主体ID,主体类型,主体名称,花费,直接成交金额,间接成交金额,总成交金额,投入产出比,展现量\n" + "2026-08-17,1001,商品,极星单肩包,90.25,730.02,0,730.02,8.09,1000\n" + "2026-08-17,1001,商品,极星单肩包,9.75,0,70,70,7.18,200\n" + "2026-08-17,plan-1,计划,测试计划,100,1000,0,1000,10,500\n", + encoding="utf-8-sig", + ) + + records = importer.parse_tmall_product_ad_csv(report) + + assert len(records) == 1 + metrics = records[0]["ad_metrics"] + assert records[0]["product_id"] == "1001" + assert metrics["spend"] == "100.00" + assert metrics["attributed_deal_amount"] == "800.02" + assert metrics["roi"] == "8.00" + assert metrics["impressions"] == 1200 + assert records[0]["duplicate_row_count"] == 2 + + +def test_parse_gb18030_export(tmp_path: Path) -> None: + report = tmp_path / "商品报表.csv" + report.write_bytes( + ( + "日期,主体ID,主体类型,主体名称,花费,总成交金额,投入产出比\n" + "2026-08-17,1001,商品,极星单肩包,90.25,730.02,8.09\n" + ).encode("gb18030") + ) + + records = importer.parse_tmall_product_ad_csv(report) + + assert records[0]["product_name"] == "极星单肩包" + assert records[0]["ad_metrics"]["roi"] == "8.09" + + +def test_parse_export_null_markers_as_missing(tmp_path: Path) -> None: + report = tmp_path / "商品报表.csv" + report.write_text( + "日期,主体ID,主体类型,主体名称,花费,总成交金额,平均点击花费,投入产出比\n" + "2026-08-17,1001,商品,极星单肩包,90.25,730.02,\\N,\\N\n", + encoding="utf-8", + ) + + records = importer.parse_tmall_product_ad_csv(report) + + assert records[0]["ad_metrics"] == { + "spend": "90.25", + "attributed_deal_amount": "730.02", + "roi": "8.09", + } + + +def test_find_report_files_uses_current_manifest_csv(monkeypatch, tmp_path: Path) -> None: + target_date = importer.date(2026, 8, 17) + date_root = tmp_path / target_date.isoformat() + extracted = date_root / "extracted" + extracted.mkdir(parents=True) + old_csv = extracted / "商品报表_旧.csv" + current_csv = extracted / "商品报表_新.csv" + old_csv.write_text("old", encoding="utf-8") + current_csv.write_text("current", encoding="utf-8") + (date_root / "manifest.json").write_text( + json.dumps({"csv_files": ["extracted\\商品报表_新.csv"]}), + encoding="utf-8", + ) + monkeypatch.setattr(importer, "REPORT_ROOT", tmp_path) + + assert importer.find_report_files(target_date) == [current_csv.resolve()] + + +def test_upsert_updates_only_tmall_ad_json_node(monkeypatch) -> None: + captured: dict[str, object] = {} + + def fake_execute_values(_cursor, sql, rows, *, template=None): + captured["sql"] = sql + captured["rows"] = rows + captured["template"] = template + + monkeypatch.setattr(product_db, "execute_values", fake_execute_values) + written = product_db.upsert_tmall_product_ad_raw_metrics( + _Connection(), + [ + { + "stat_date": importer.date(2026, 8, 17), + "product_id": "1001", + "product_name": "极星单肩包", + "ad_metrics": { + "spend": "90.25", + "attributed_deal_amount": "730.02", + "roi": "8.09", + }, + "source_file": "商品报表.csv", + } + ], + ) + + assert written == 1 + sql = str(captured["sql"]) + assert "INSERT INTO product_daily_metrics" in sql + assert "jsonb_set" in sql + assert "tm_product_ad_daily_metrics" not in sql + assert captured["template"] == "('tm', %s, %s, %s, %s::jsonb, %s, NOW())" + row = captured["rows"][0] + payload = json.loads(row[3]) + assert payload["tm_ad_metrics"]["spend"] == "90.25" + assert payload["tm_ad_metrics"]["style_name"] == "极星" + assert payload["tm_ad_metrics"]["style_match_method"] == "product_id" + assert row[1] == "1001" + + +def test_upsert_tmall_ad_uses_product_name_when_id_is_not_mapped(monkeypatch) -> None: + captured: dict[str, object] = {} + + class _NameFallbackCursor(_Cursor): + def fetchall(self): + return [("极星", [])] + + class _NameFallbackConnection: + def cursor(self): + return _NameFallbackCursor() + + def fake_execute_values(_cursor, _sql, rows, *, template=None): + captured["rows"] = rows + + monkeypatch.setattr(product_db, "execute_values", fake_execute_values) + written = product_db.upsert_tmall_product_ad_raw_metrics( + _NameFallbackConnection(), + [ + { + "stat_date": importer.date(2026, 8, 17), + "product_id": "unmapped-id", + "product_name": "GYXX/极星单肩包2026新款", + "ad_metrics": {"spend": "12.00"}, + "source_file": "商品报表.csv", + } + ], + ) + + assert written == 1 + row = captured["rows"][0] + payload = json.loads(row[3]) + assert payload["tm_ad_metrics"]["style_name"] == "极星" + assert payload["tm_ad_metrics"]["style_match_method"] == "product_name" + + +def test_import_date_filter_and_dry_run(tmp_path: Path, capsys) -> None: + report = tmp_path / "商品报表.csv" + report.write_text( + "日期,主体ID,主体类型,主体名称,花费,总成交金额\n" + "2026-08-16,1001,商品,旧日期,1,2\n" + "2026-08-17,1002,商品,目标日期,3,9\n", + encoding="utf-8", + ) + + assert importer.main(["--file", str(report), "--date", "2026-08-17"]) == 0 + assert "DRY-RUN" in capsys.readouterr().out diff --git a/tests/modules/product_commerce/test_jd_market_rank.py b/tests/modules/product_commerce/test_jd_market_rank.py index 8bd825c..53ee1a1 100644 --- a/tests/modules/product_commerce/test_jd_market_rank.py +++ b/tests/modules/product_commerce/test_jd_market_rank.py @@ -7,7 +7,9 @@ from collect_jd_market_rank import ( build_jd_feishu_batch_xml, build_jd_feishu_category_xml, build_jd_feishu_intro_xml, + category_label_is_selected, category_selection_matches, + category_ui_label_candidates, exact_category_label_pattern, group_products_by_category, high_resolution_jd_image_url, @@ -15,6 +17,7 @@ from collect_jd_market_rank import ( parse_jd_market_rank_html, parse_money_range_upper, resize_feishu_table_xml, + select_category_path, select_category_paths, ) @@ -80,14 +83,13 @@ class JdMarketRankTests(unittest.TestCase): set(specs), {"旅行箱", "运动包", "双肩包", "斜挎包", "胸包", "手机包", "电脑包"}, ) - self.assertEqual(specs["旅行箱"]["paths"], (("功能箱包", "行李箱"),)) - self.assertEqual(specs["运动包"]["paths"], (("功能箱包", "休闲运动包"),)) - self.assertEqual( - specs["双肩包"]["paths"], - (("男包", "双肩包"), ("男包", "男士双肩包")), - ) - self.assertEqual(specs["手机包"]["paths"], (("男包", "男士手机包"),)) - self.assertEqual(specs["电脑包"]["paths"], (("功能箱包", "电脑包"),)) + self.assertEqual(specs["旅行箱"]["path"], ("功能箱包", "行李箱")) + self.assertEqual(specs["运动包"]["path"], ("功能箱包", "休闲运动包")) + self.assertEqual(specs["双肩包"]["path"], ("男包", "男士双肩包")) + self.assertEqual(specs["斜挎包"]["path"], ("男包", "男士单肩/斜挎包")) + self.assertEqual(specs["胸包"]["path"], ("男包", "男士腰包/胸包")) + self.assertEqual(specs["手机包"]["path"], ("男包", "男士手机包")) + self.assertEqual(specs["电脑包"]["path"], ("功能箱包", "电脑包")) def test_category_label_matching_is_exact_not_contains(self): pattern = exact_category_label_pattern("双肩包") @@ -96,6 +98,116 @@ class JdMarketRankTests(unittest.TestCase): self.assertIsNone(pattern.fullmatch("男士双肩包")) self.assertIsNone(pattern.fullmatch("双肩包配件")) + def test_live_ui_alias_keeps_the_source_business_category(self): + self.assertEqual( + category_ui_label_candidates("男士双肩包"), + ("男士双肩包", "双肩包"), + ) + self.assertEqual( + category_ui_label_candidates("男士手机包"), + ("男士手机包",), + ) + + def test_category_parent_hover_uses_the_dropdown_option_not_its_label(self): + page = MagicMock() + trigger = MagicMock() + parent_option = MagicMock() + child_option = MagicMock() + with ( + patch("collect_jd_market_rank._visible", return_value=trigger), + patch( + "collect_jd_market_rank._selected_category_labels", + side_effect=[("功能箱包",), ("男包 > 双肩包",)], + ), + patch("collect_jd_market_rank._first_product_key", return_value="old"), + patch( + "collect_jd_market_rank._exact_visible_category_option", + return_value=parent_option, + ) as resolve_option, + patch( + "collect_jd_market_rank._wait_for_category_option", + return_value=(child_option, "双肩包"), + ) as wait_for_child, + patch( + "collect_jd_market_rank._click_category_option" + ) as click_option, + patch("collect_jd_market_rank._wait_for_table_change"), + ): + select_category_path(page, ("男包", "男士双肩包")) + + resolve_option.assert_called_once_with(page, "男包") + wait_for_child.assert_called_once_with( + page, + ("男士双肩包", "双肩包"), + ) + parent_option.hover.assert_called_once_with(timeout=5_000) + click_option.assert_called_once_with(child_option, timeout_ms=10_000) + + def test_same_selected_parent_opens_directly_on_child_options(self): + page = MagicMock() + trigger = MagicMock() + child_option = MagicMock() + with ( + patch("collect_jd_market_rank._visible", return_value=trigger), + patch( + "collect_jd_market_rank._selected_category_labels", + side_effect=[ + ("功能箱包",), + ("功能箱包 > 休闲运动包",), + ], + ), + patch("collect_jd_market_rank._first_product_key", return_value="old"), + patch( + "collect_jd_market_rank._exact_visible_category_option", + return_value=None, + ) as resolve_option, + patch( + "collect_jd_market_rank._wait_for_category_option", + return_value=(child_option, "休闲运动包"), + ), + patch( + "collect_jd_market_rank._click_category_option" + ) as click_option, + patch("collect_jd_market_rank._wait_for_table_change"), + ): + select_category_path(page, ("功能箱包", "休闲运动包")) + + resolve_option.assert_called_once_with(page, "功能箱包") + click_option.assert_called_once_with(child_option, timeout_ms=10_000) + + def test_cross_parent_switch_clicks_parent_when_hover_keeps_stale_children(self): + page = MagicMock() + trigger = MagicMock() + parent_option = MagicMock() + child_option = MagicMock() + with ( + patch("collect_jd_market_rank._visible", return_value=trigger), + patch( + "collect_jd_market_rank._selected_category_labels", + side_effect=[("功能箱包",), ("男包 > 双肩包",)], + ), + patch("collect_jd_market_rank._first_product_key", return_value="old"), + patch( + "collect_jd_market_rank._exact_visible_category_option", + return_value=parent_option, + ), + patch( + "collect_jd_market_rank._wait_for_category_option", + side_effect=[(None, None), (child_option, "双肩包")], + ) as wait_for_child, + patch( + "collect_jd_market_rank._click_category_option" + ) as click_option, + patch("collect_jd_market_rank._wait_for_table_change"), + ): + select_category_path(page, ("男包", "男士双肩包")) + + self.assertEqual(wait_for_child.call_count, 2) + self.assertEqual( + [item.args[0] for item in click_option.call_args_list], + [parent_option, child_option], + ) + def test_selected_category_verification_accepts_only_exact_display_forms(self): path = ("男包", "双肩包") @@ -104,6 +216,10 @@ class JdMarketRankTests(unittest.TestCase): self.assertFalse(category_selection_matches(("男士双肩包",), path)) self.assertFalse(category_selection_matches(("双肩包配件",), path)) + def test_selected_parent_matching_is_exact(self): + self.assertTrue(category_label_is_selected((" 功能 箱包 ",), "功能箱包")) + self.assertFalse(category_label_is_selected(("功能箱包配件",), "功能箱包")) + def test_category_candidates_fall_back_only_after_explicit_failure(self): page = MagicMock() paths = (("男包", "双肩包"), ("男包", "男士双肩包")) diff --git a/tests/modules/product_commerce/test_jd_video_frames.py b/tests/modules/product_commerce/test_jd_video_frames.py new file mode 100644 index 0000000..e60c178 --- /dev/null +++ b/tests/modules/product_commerce/test_jd_video_frames.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import math + +import pytest +from PIL import Image + +from gyxx_flow.modules.product_commerce import jd_video_frames + + +@pytest.mark.parametrize( + ("duration", "max_frames", "expected"), + [ + (2.0, 3, (1.0,)), + (3.0, 3, (1.0, 2.0)), + (7.5, 3, (2.5, 5.0)), + (8.0, 3, (1.6, 4.0, 6.4)), + (8.0, 2, (4.0, 6.4)), + (48.88, 2, (24.44, 39.104)), + (48.88, 1, (24.44,)), + ], +) +def test_choose_reference_frame_times( + duration: float, + max_frames: int, + expected: tuple[float, ...], +) -> None: + assert jd_video_frames.choose_reference_frame_times( + duration, + max_frames=max_frames, + ) == expected + + +@pytest.mark.parametrize("duration", [0.0, -1.0, math.nan, math.inf]) +def test_choose_reference_frame_times_rejects_invalid_duration( + duration: float, +) -> None: + with pytest.raises( + jd_video_frames.VideoFrameExtractionError, + match="视频时长无效", + ): + jd_video_frames.choose_reference_frame_times(duration) + + +class _FakeReader: + def __init__(self, values: list[object], closed: list[bool]) -> None: + self._values = iter(values) + self._closed = closed + + def __iter__(self): + return self + + def __next__(self): + return next(self._values) + + def close(self) -> None: + self._closed.append(True) + + +def test_extract_reference_frames_writes_decodable_jpegs( + tmp_path, + monkeypatch, +) -> None: + source = tmp_path / "source.mp4" + source.write_bytes(b"video-placeholder") + output_root = tmp_path / "allowed" + output_dir = output_root / "probe" + closed: list[bool] = [] + frame = bytes(range(12)) + + def read_frames(_path, *, pix_fmt, input_params, output_params): + assert pix_fmt == "rgb24" + assert output_params == ["-frames:v", "1"] + metadata = {"duration": 12.0, "size": (2, 2)} + values = [metadata] if not input_params else [metadata, frame] + return _FakeReader(values, closed) + + monkeypatch.setattr(jd_video_frames.imageio_ffmpeg, "read_frames", read_frames) + + result = jd_video_frames.extract_reference_frames( + source, + output_dir=output_dir, + allowed_output_root=output_root, + timestamps_seconds=(3.0, 6.0), + max_frames=2, + max_edge=256, + ) + + assert [item.timestamp_seconds for item in result] == [3.0, 6.0] + assert len(closed) == 3 + assert all(item.path.is_file() for item in result) + assert all(len(item.sha256) == 64 for item in result) + with Image.open(result[0].path) as image: + assert image.mode == "RGB" + assert image.size == (2, 2) + + +def test_extract_reference_frames_cleans_partial_outputs_on_failure( + tmp_path, + monkeypatch, +) -> None: + source = tmp_path / "source.mp4" + source.write_bytes(b"video-placeholder") + output_root = tmp_path / "allowed" + output_dir = output_root / "probe" + calls = 0 + + def read_frames(_path, *, pix_fmt, input_params, output_params): + nonlocal calls + calls += 1 + metadata = {"duration": 12.0, "size": (2, 2)} + if not input_params: + return _FakeReader([metadata], []) + if calls == 2: + return _FakeReader([metadata, bytes(range(12))], []) + return _FakeReader([metadata, b"truncated"], []) + + monkeypatch.setattr(jd_video_frames.imageio_ffmpeg, "read_frames", read_frames) + + with pytest.raises( + jd_video_frames.VideoFrameExtractionError, + match="RGB 帧长度无效", + ): + jd_video_frames.extract_reference_frames( + source, + output_dir=output_dir, + allowed_output_root=output_root, + timestamps_seconds=(3.0, 6.0), + max_frames=2, + max_edge=256, + ) + + assert list(output_dir.glob("*")) == [] + + +def test_extract_reference_frames_rejects_output_outside_allowed_root( + tmp_path, +) -> None: + source = tmp_path / "source.mp4" + source.write_bytes(b"video-placeholder") + + with pytest.raises( + jd_video_frames.VideoFrameExtractionError, + match="越出允许的临时目录", + ): + jd_video_frames.extract_reference_frames( + source, + output_dir=tmp_path / "outside", + allowed_output_root=tmp_path / "allowed", + ) diff --git a/tests/modules/product_commerce/test_jd_video_upload.py b/tests/modules/product_commerce/test_jd_video_upload.py new file mode 100644 index 0000000..10200b4 --- /dev/null +++ b/tests/modules/product_commerce/test_jd_video_upload.py @@ -0,0 +1,7072 @@ +from __future__ import annotations + +import json +from dataclasses import asdict +from datetime import date, datetime +from pathlib import Path +from types import SimpleNamespace + +import pytest +from PIL import Image + +from gyxx_flow.modules.product_commerce import jd_video_metadata, upload_video_to_jd +from gyxx_flow.modules.product_commerce.jd_video_metadata import ( + DetectedColor, + JdVideoTitleGenerator, + ProductMappingError, + ProductReference, + TitleGenerationError, + TitleValidationError, + choose_form_tag, + choose_interest_tags, + classify_attachments, + discover_publication_candidates, + infer_cover_color, + load_jd_product_references, + parse_product_names, + parse_record_date, + rank_topic_candidates, + resolve_database_bag_category, + topic_search_terms, +) +from gyxx_flow.modules.product_commerce.jd_video_publication_db import ( + VIDEO_PUBLICATION_DDL, + load_recent_video_publication_titles, + reclassify_failed_pre_submit_publications, + reclassify_legacy_color_failures, + release_video_publication_before_submit, + stable_json_sha256, +) + + +class _LedgerCursor: + def __init__(self, rows: list[tuple[object, ...]]) -> None: + self.rows = rows + self.executions: list[tuple[str, tuple[object, ...]]] = [] + + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + return None + + def execute(self, query: str, params: tuple[object, ...]) -> None: + self.executions.append((query, params)) + + def fetchall(self) -> list[tuple[object, ...]]: + return self.rows + + +class _LedgerConnection: + def __init__(self, cursor: _LedgerCursor) -> None: + self.cursor_instance = cursor + + def cursor(self) -> _LedgerCursor: + return self.cursor_instance + + +class _ContentManagementMatches: + def __init__(self, matches) -> None: + self.matches = list(matches) + + def count(self) -> int: + return len(self.matches) + + def nth(self, index: int): + return self.matches[index] + + +class _ContentManagementTitle: + def __init__(self, candidate) -> None: + self.candidate = candidate + + def is_visible(self) -> bool: + return True + + def evaluate(self, _script: str, options: dict[str, object]): + assert options["channel"] == "逛-竖版视频" + assert "审核中" in options["statuses"] + return self.candidate + + +class _ContentManagementRoot: + def __init__(self, matches=(), *, detached: bool = False) -> None: + self.matches = list(matches) + self.detached = detached + + def get_by_text(self, text: str, *, exact: bool): + assert text == "拾影斜挎相机包" + assert exact is True + return _ContentManagementMatches(self.matches) + + def is_detached(self) -> bool: + return self.detached + + +def _content_management_uploader(*roots: _ContentManagementRoot): + page = _ContentManagementRoot() + main_frame = _ContentManagementRoot() + page.main_frame = main_frame + page.frames = [main_frame, *roots] + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.page = page + uploader.surface = None + uploader._require_page = lambda: page + return uploader + + +def _content_management_candidate( + content_ids: list[str], + *, + publication_time: str = "2026-08-10 19:50:46", +) -> dict[str, object]: + return { + "contentIds": content_ids, + "publicationTimes": [publication_time], + "status": "审核中", + "channel": "逛-竖版视频", + "depth": 4, + "rowLike": True, + } + + +class _CorrectionCursor: + def __init__(self, row) -> None: + self.row = row + self.executed = None + + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + return None + + def execute(self, sql: str, params: tuple[object, ...]) -> None: + self.executed = (sql, params) + + def fetchone(self): + return self.row + + +class _CorrectionConn: + def __init__(self, row) -> None: + self.cursor_instance = _CorrectionCursor(row) + + def cursor(self) -> _CorrectionCursor: + return self.cursor_instance + + +def _correction_db_row( + *, + status: str = "published", + evidence: object = None, + remote_content_id: str | None = None, +): + submit_started_at = datetime(2026, 8, 10, 19, 50, 45) + published_at = datetime(2026, 8, 10, 19, 50, 46) + attempt_run_id = "script.product_commerce.jd_video_upload__test-run" + payload = { + "title": "拾影斜挎相机包,相机配件有序收纳,通勤拍摄拿取顺手!", + "products": [ + { + "style_name": "拾影斜挎相机包", + "spu_id": "10035423416854", + "product_title": "拾影斜挎包单反相机包", + "color_label": "黑色", + } + ], + "interest_tags": ["中性", "旅行拍摄"], + "form_tag": "产品展示", + "source_record_id": "recvqPVwiHTzil", + "video_file_token": "DfYJbbc4QoBtQFxECUScZm1lnFg", + } + confirmation_evidence = ( + evidence + if evidence is not None + else {"reconciliation": {"content_id": "1259059040"}} + ) + return ( + status, + False, + 21, + submit_started_at, + published_at, + payload, + stable_json_sha256(payload), + confirmation_evidence, + {"product_names": ["拾影单肩包"]}, + remote_content_id, + "recvqPVwiHTzil", + "DfYJbbc4QoBtQFxECUScZm1lnFg", + "published", + submit_started_at, + published_at, + payload, + confirmation_evidence, + attempt_run_id, + 21, + ) + + +def _write_correction_effect_receipt(effect_root: Path) -> Path: + effect_root.mkdir(parents=True, exist_ok=True) + attempt_run_id = "script.product_commerce.jd_video_upload__test-run" + receipt = { + "schema_version": 1, + "status": "applied", + "workflow_id": "script.product_commerce.jd_video_upload", + "step_id": "module_script", + "run_id": attempt_run_id, + "reconciliations": [ + { + "action": "applied", + "expected_run_id": attempt_run_id, + "evidence": { + "publication_id": 1, + "content_id": "1259059040", + }, + } + ], + } + (effect_root / "effect_test.json").write_text( + json.dumps(receipt, ensure_ascii=False), + encoding="utf-8", + ) + return effect_root + + +class _CorrectionRunConnContext: + def __init__(self, conn: object) -> None: + self.conn = conn + + def __enter__(self) -> object: + return self.conn + + def __exit__(self, *_args) -> None: + return None + + +class _CorrectionRunUploader: + def __init__( + self, + *, + events: list[str], + confirmation: upload_video_to_jd.PublishConfirmation | None, + ) -> None: + self.events = events + self.confirmation = confirmation + + def start(self) -> None: + self.events.append("start") + + def open_existing_content_for_correction( + self, + *, + content_id: str, + expected_title: str, + ) -> dict[str, object]: + assert content_id == "1259059040" + assert expected_title == "拾影斜挎相机包" + self.events.append("open") + return { + "content_id": content_id, + "video_ids": ["4556761701"], + } + + def prepare_existing_content_correction( + self, + *, + target: upload_video_to_jd.VideoCorrectionTarget, + expected_video_ids: tuple[str, ...], + ) -> dict[str, object]: + assert target.content_id == "1259059040" + assert expected_video_ids == ("4556761701",) + self.events.append("prepare") + return {"screenshot": "correction-prepared.png"} + + def _assert_edit_content_binding(self, expected_content_id: str) -> None: + assert expected_content_id == "1259059040" + self.events.append("binding") + + def _assert_existing_form_identity( + self, + *, + expected_title: str, + expected_video_ids: tuple[str, ...], + ) -> None: + assert expected_title == "拾影斜挎相机包" + assert expected_video_ids == ("4556761701",) + self.events.append("identity") + + def submit_and_confirm( + self, + *, + before_click, + expected_title: str, + ) -> upload_video_to_jd.PublishConfirmation: + assert expected_title == "拾影斜挎相机包" + if self.confirmation is None: + raise AssertionError("correction preview must not submit") + self.events.append("submit") + before_click() + return self.confirmation + + def close(self) -> None: + self.events.append("close") + + +def _install_correction_run_mocks( + monkeypatch, + *, + confirmation: upload_video_to_jd.PublishConfirmation | None, +) -> tuple[_CorrectionRunUploader, list[str]]: + events: list[str] = [] + conn = object() + target = upload_video_to_jd.VideoCorrectionTarget( + publication_id=1, + content_id="1259059040", + attempt_no=21, + attempt_run_id="script.product_commerce.jd_video_upload__test-run", + payload_sha256="payload-sha256", + title="拾影斜挎相机包", + products=(), + product_names=("拾影单肩包",), + interest_tags=("中性", "旅行拍摄"), + form_tag="产品展示", + ) + uploader = _CorrectionRunUploader( + events=events, + confirmation=confirmation, + ) + + monkeypatch.setattr( + upload_video_to_jd, + "get_conn", + lambda: _CorrectionRunConnContext(conn), + ) + monkeypatch.setattr( + upload_video_to_jd, + "ensure_video_publication_tables", + lambda actual: events.append("tables") if actual is conn else None, + ) + + def load_target( + actual_conn, + *, + publication_id: int, + content_id: str, + target_account_key: str, + ) -> upload_video_to_jd.VideoCorrectionTarget: + assert actual_conn is conn + assert publication_id == 1 + assert content_id == "1259059040" + assert target_account_key == "stable-account" + events.append("load") + return target + + monkeypatch.setattr(upload_video_to_jd, "load_video_correction_target", load_target) + monkeypatch.setattr( + upload_video_to_jd, + "_expected_store_name", + lambda *, require_configured: "光影行星" if require_configured else "", + ) + + def make_uploader(**kwargs): + assert kwargs == { + "headless": False, + "manual_login": False, + "debug": True, + "expected_store_name": "光影行星", + } + return uploader + + monkeypatch.setattr(upload_video_to_jd, "JdVideoUploader", make_uploader) + return uploader, events + + +class _CorrectionOpenElement: + def __init__(self, *, on_click=None) -> None: + self.on_click = on_click + + def click(self) -> None: + if self.on_click is not None: + self.on_click() + + def wait_for(self, **kwargs) -> None: + assert kwargs == {"state": "visible", "timeout": 60_000} + + +def test_account_key_resolves_unique_existing_jd_ledger_cohort(monkeypatch) -> None: + class Cursor: + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def execute(self, query, params) -> None: + assert "SELECT DISTINCT target_account_key" in query + assert params == ("jd",) + + @staticmethod + def fetchall(): + return [("stable-ledger-account",)] + + class Conn: + @staticmethod + def cursor(): + return Cursor() + + monkeypatch.delenv("JD_VIDEO_ACCOUNT_KEY", raising=False) + monkeypatch.setattr( + upload_video_to_jd, + "get_conn", + lambda: _CorrectionRunConnContext(Conn()), + ) + monkeypatch.setattr( + upload_video_to_jd, + "ensure_video_publication_tables", + lambda _conn: None, + ) + + assert ( + upload_video_to_jd._account_key(require_configured=True) + == "stable-ledger-account" + ) + + +class _CorrectionOpenRow: + def __init__( + self, + *, + row_key: str, + status: str, + events: list[str], + on_edit=None, + ) -> None: + self.row_key = row_key + self.status = status + self.events = events + self.on_edit = on_edit + + def get_by_text(self, text: str, *, exact: bool): + assert exact is True + if text == "拾影斜挎相机包": + return [object()] + assert text == "编辑" + return [ + _CorrectionOpenElement( + on_click=self._click_edit + ) + ] + + def _click_edit(self) -> None: + self.events.append("edit-click") + if self.on_edit is not None: + self.on_edit() + + def inner_text(self) -> str: + return ( + f"拾影斜挎相机包 1259059040 {upload_video_to_jd.CONTENT_MANAGEMENT_CHANNEL} " + f"{self.status}" + ) + + def get_attribute(self, name: str) -> str: + assert name == "data-row-key" + return self.row_key + + +class _CorrectionOpenSurface: + def __init__(self, row: _CorrectionOpenRow) -> None: + self.row = row + + def locator(self, selector: str): + assert selector == 'tr[data-row-key="1259059040"]' + return [self.row] + + +class _CorrectionEditFrame: + def __init__(self) -> None: + self.url = ( + "https://dr.jd.com/jm/n/publish-video.html" + "?id=1259059040&style=11&platform=jm-pop" + ) + + def get_by_text(self, text: str, *, exact: bool): + assert (text, exact) == ("正文标题", True) + return _CorrectionOpenElement() + + +class _CorrectionOpenPage: + def __init__(self, *, url: str, frames=()) -> None: + self.url = url + self.frames = list(frames) + self.goto_calls: list[tuple[str, dict[str, object]]] = [] + + def goto(self, url: str, **kwargs) -> None: + self.goto_calls.append((url, kwargs)) + + def wait_for_timeout(self, _milliseconds: int) -> None: + return None + + def set_default_timeout(self, timeout: int) -> None: + assert timeout == 15_000 + + def wait_for_load_state(self, state: str, *, timeout: int) -> None: + assert (state, timeout) == ("domcontentloaded", 60_000) + + +def _correction_open_uploader( + *, + row_key: str = "1259059040", + status: str = "审核驳回", +) -> tuple[upload_video_to_jd.JdVideoUploader, _CorrectionOpenPage, list[str]]: + events: list[str] = [] + edit_frame = _CorrectionEditFrame() + list_page = _CorrectionOpenPage(url=upload_video_to_jd.CONTENT_LIST_URL) + edit_page = _CorrectionOpenPage( + url=( + "https://dr.jd.com/jm/#/n/publish-video.html" + "?id=1259059040&style=11&platform=jm-pop" + ), + frames=[edit_frame], + ) + context = SimpleNamespace(pages=[list_page]) + row = _CorrectionOpenRow( + row_key=row_key, + status=status, + events=events, + on_edit=lambda: context.pages.append(edit_page), + ) + surface = _CorrectionOpenSurface(row) + + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.page = list_page + uploader.surface = None + uploader.context = context + uploader._require_page = lambda: list_page + uploader._require_surface = lambda: uploader.surface + + def resolve_visible(*, page, path_fragment, required_text, **_kwargs): + if path_fragment == "/n/content-list.html": + assert page is list_page + return surface + assert path_fragment == "/n/publish-video.html" + assert page is edit_page + assert required_text == "正文标题" + uploader.surface = edit_frame + return edit_frame + + uploader._resolve_visible_content_frame = resolve_visible + uploader._visible_locators = lambda items: list(items) + uploader._track_owned_page = lambda page: page + uploader._current_form_video_ids = lambda: ("4556761701",) + + def assert_identity(*, expected_title: str, expected_video_ids) -> None: + assert expected_title == "拾影斜挎相机包" + assert expected_video_ids == ("4556761701",) + events.append("identity-verified") + + uploader._assert_existing_form_identity = assert_identity + uploader._screenshot = ( + lambda name, **_kwargs: f"{name}.png" + ) + return uploader, list_page, events + + +def test_parse_args_accepts_headed_existing_content_correction() -> None: + args = upload_video_to_jd.parse_args( + [ + "--execute", + "--correct-publication-id", + "1", + "--correct-content-id", + "1259059040", + "--apply-correction", + ] + ) + + assert args.correct_publication_id == 1 + assert args.correct_content_id == "1259059040" + assert args.apply_correction is True + assert args.headless is False + + +@pytest.mark.parametrize( + "argv", + [ + ["--execute", "--correct-publication-id", "1"], + ["--execute", "--correct-content-id", "1259059040"], + [ + "--execute", + "--correct-publication-id", + "1", + "--correct-content-id", + "not-an-id", + ], + [ + "--execute", + "--headless", + "--correct-publication-id", + "1", + "--correct-content-id", + "1259059040", + ], + [ + "--correct-publication-id", + "1", + "--correct-content-id", + "1259059040", + ], + ], +) +def test_parse_args_rejects_unsafe_correction_combinations(argv) -> None: + with pytest.raises(SystemExit): + upload_video_to_jd.parse_args(argv) + + +def test_load_video_correction_target_requires_account_scoped_published_evidence( + tmp_path: Path, +) -> None: + conn = _CorrectionConn(_correction_db_row()) + effect_root = _write_correction_effect_receipt(tmp_path / "effects") + + target = upload_video_to_jd.load_video_correction_target( + conn, + publication_id=1, + content_id="1259059040", + target_account_key="stable-account", + effect_root=effect_root, + ) + + assert target.publication_id == 1 + assert target.content_id == "1259059040" + assert target.attempt_no == 21 + assert target.attempt_run_id.endswith("__test-run") + assert target.product_names == ("拾影单肩包",) + assert target.interest_tags == ("中性", "旅行拍摄") + assert target.form_tag == "产品展示" + assert conn.cursor_instance.executed[1] == (1, "jd", "stable-account") + + +@pytest.mark.parametrize( + "row,match", + [ + (None, "没有找到"), + (_correction_db_row(status="failed"), "published"), + ( + _correction_db_row(evidence={"content_id": "999"}), + "精确证明", + ), + ( + _correction_db_row(remote_content_id="999"), + "remote_content_id", + ), + ], +) +def test_load_video_correction_target_fails_closed(row, match: str) -> None: + with pytest.raises(RuntimeError, match=match): + upload_video_to_jd.load_video_correction_target( + _CorrectionConn(row), + publication_id=1, + content_id="1259059040", + target_account_key="stable-account", + ) + + +def test_correction_confirmation_must_keep_the_same_content_id() -> None: + confirmation = upload_video_to_jd.PublishConfirmation( + remote_content_id="1259059041", + remote_content_url="https://dr.jd.com/jm/", + evidence={}, + ) + + with pytest.raises( + upload_video_to_jd.AmbiguousPublishResult, + match="同一 content_id", + ): + upload_video_to_jd.require_same_correction_content_id( + confirmation, + expected_content_id="1259059040", + expected_title="拾影斜挎相机包", + ) + + +def test_correction_preview_prepares_form_without_submitting( + monkeypatch, + capsys, +) -> None: + _uploader, events = _install_correction_run_mocks( + monkeypatch, + confirmation=None, + ) + args = SimpleNamespace( + correct_publication_id=1, + correct_content_id="1259059040", + apply_correction=False, + manual_login=False, + ) + + result = upload_video_to_jd._run_video_correction( + args, + account_key="stable-account", + ) + + assert result == upload_video_to_jd.EXIT_OK + assert events == ["tables", "load", "start", "open", "prepare", "close"] + output = capsys.readouterr().out + assert '"mode": "correction-preview"' in output + assert '"saved": false' in output + + +def test_correction_apply_accepts_confirmation_for_same_content_id( + monkeypatch, + capsys, +) -> None: + confirmation = upload_video_to_jd.PublishConfirmation( + remote_content_id="1259059040", + remote_content_url="https://dr.jd.com/jm/", + evidence={ + "success_marker": "content-management-row", + "content_id": "1259059040", + "title": "拾影斜挎相机包", + "channel": "逛-竖版视频", + "status": "审核中", + }, + ) + _uploader, events = _install_correction_run_mocks( + monkeypatch, + confirmation=confirmation, + ) + args = SimpleNamespace( + correct_publication_id=1, + correct_content_id="1259059040", + apply_correction=True, + manual_login=False, + ) + + result = upload_video_to_jd._run_video_correction( + args, + account_key="stable-account", + ) + + assert result == upload_video_to_jd.EXIT_OK + assert events == [ + "tables", + "load", + "start", + "open", + "prepare", + "submit", + "binding", + "identity", + "load", + "close", + ] + output = capsys.readouterr().out + assert '"mode": "correction-applied"' in output + assert '"saved": true' in output + + +def test_correction_apply_treats_new_content_id_as_ambiguous_without_retry( + monkeypatch, + capsys, +) -> None: + confirmation = upload_video_to_jd.PublishConfirmation( + remote_content_id="1259059041", + remote_content_url="https://dr.jd.com/jm/", + evidence={ + "success_marker": "content-management-row", + "content_id": "1259059041", + "title": "拾影斜挎相机包", + "channel": "逛-竖版视频", + "status": "审核中", + }, + ) + _uploader, events = _install_correction_run_mocks( + monkeypatch, + confirmation=confirmation, + ) + args = SimpleNamespace( + correct_publication_id=1, + correct_content_id="1259059040", + apply_correction=True, + manual_login=False, + ) + + result = upload_video_to_jd._run_video_correction( + args, + account_key="stable-account", + ) + + assert result == upload_video_to_jd.EXIT_AMBIGUOUS + assert events.count("submit") == 1 + assert events[-1] == "close" + error = capsys.readouterr().err + assert "禁止再次提交" in error + assert "actual='1259059041'" in error + + +def test_open_correction_requires_exact_rejected_row_and_edit_content_id() -> None: + uploader, list_page, events = _correction_open_uploader() + + opened = uploader.open_existing_content_for_correction( + content_id="1259059040", + expected_title="拾影斜挎相机包", + ) + + assert list_page.goto_calls == [ + ( + upload_video_to_jd.CONTENT_LIST_URL, + {"wait_until": "domcontentloaded", "timeout": 60_000}, + ) + ] + assert events == ["edit-click", "identity-verified"] + assert opened == { + "content_id": "1259059040", + "status": "审核驳回", + "title": "拾影斜挎相机包", + "video_ids": ["4556761701"], + "screenshot": "correction-before.png", + } + + +@pytest.mark.parametrize( + ("row_key", "status"), + [ + ("1259059041", "审核驳回"), + ("1259059040", "审核中"), + ], +) +def test_open_correction_rejects_wrong_row_id_or_non_rejected_status( + row_key: str, + status: str, +) -> None: + uploader, _list_page, events = _correction_open_uploader( + row_key=row_key, + status=status, + ) + + with pytest.raises(RuntimeError, match="标题、频道、状态或 ID"): + uploader.open_existing_content_for_correction( + content_id="1259059040", + expected_title="拾影斜挎相机包", + ) + + assert "edit-click" not in events + + +def test_correction_confirmation_accepts_only_strict_same_id_row() -> None: + confirmation = upload_video_to_jd.PublishConfirmation( + remote_content_id="1259059040", + remote_content_url="https://dr.jd.com/jm/", + evidence={ + "success_marker": "content-management-row", + "content_id": "1259059040", + "title": "拾影斜挎相机包", + "channel": "逛-竖版视频", + "status": "审核中", + }, + ) + + upload_video_to_jd.require_same_correction_content_id( + confirmation, + expected_content_id="1259059040", + expected_title="拾影斜挎相机包", + ) + + +def test_edit_frame_content_id_parser_is_route_scoped() -> None: + valid = SimpleNamespace( + url="https://dr.jd.com/jm/n/publish-video.html?id=1259059040" + ) + wrong_route = SimpleNamespace( + url="https://dr.jd.com/jm/n/content-list.html?id=1259059040" + ) + + assert ( + upload_video_to_jd.JdVideoUploader._frame_query_content_id(valid) + == "1259059040" + ) + assert upload_video_to_jd.JdVideoUploader._frame_query_content_id(wrong_route) is None + + +def test_content_management_confirmation_uses_unique_recent_row_in_active_frame() -> None: + detached = _ContentManagementRoot( + [_ContentManagementTitle(_content_management_candidate(["stale"]))], + detached=True, + ) + active = _ContentManagementRoot( + [_ContentManagementTitle(_content_management_candidate(["1259059040"]))] + ) + uploader = _content_management_uploader(detached, active) + + result = uploader._content_management_row_confirmation( + expected_title="拾影斜挎相机包", + submitted_at=datetime(2026, 8, 10, 19, 50, 45), + ) + + assert result == { + "content_id": "1259059040", + "status": "审核中", + "published_at": "2026-08-10 19:50:46", + "channel": "逛-竖版视频", + } + + +def test_content_management_confirmation_rejects_duplicate_exact_titles() -> None: + candidate = _content_management_candidate(["1259059040"]) + root = _ContentManagementRoot( + [_ContentManagementTitle(candidate), _ContentManagementTitle(candidate)] + ) + uploader = _content_management_uploader(root) + + assert ( + uploader._content_management_row_confirmation( + expected_title="拾影斜挎相机包", + submitted_at=datetime(2026, 8, 10, 19, 50, 45), + ) + is None + ) + + +@pytest.mark.parametrize("content_ids", [[], ["1259059040", "1259059041"]]) +def test_content_management_confirmation_requires_exactly_one_content_id( + content_ids: list[str], +) -> None: + root = _ContentManagementRoot( + [_ContentManagementTitle(_content_management_candidate(content_ids))] + ) + uploader = _content_management_uploader(root) + + assert ( + uploader._content_management_row_confirmation( + expected_title="拾影斜挎相机包", + submitted_at=datetime(2026, 8, 10, 19, 50, 45), + ) + is None + ) + + +def test_content_management_confirmation_rejects_pre_submit_row() -> None: + root = _ContentManagementRoot( + [ + _ContentManagementTitle( + _content_management_candidate( + ["1259059040"], + publication_time="2026-08-10 19:50:39", + ) + ) + ] + ) + uploader = _content_management_uploader(root) + + assert ( + uploader._content_management_row_confirmation( + expected_title="拾影斜挎相机包", + submitted_at=datetime(2026, 8, 10, 19, 50, 45), + ) + is None + ) + + +def test_generic_navigation_id_is_not_a_jd_content_id_without_strict_row() -> None: + assert ( + upload_video_to_jd.JdVideoUploader._content_id_from_url( + "https://dr.jd.com/jm/#/n/material-manage.html?id=987654321" + ) + is None + ) + assert ( + upload_video_to_jd.JdVideoUploader._content_id_from_url( + "https://dr.jd.com/jm/?contentId=1259059040" + ) + == "1259059040" + ) + + +def test_submit_does_not_accept_generic_navigation_id_without_strict_row( + monkeypatch, +) -> None: + class Body: + def inner_text(self, **_kwargs) -> str: + return "正文标题" + + class Surface: + def locator(self, selector: str): + assert selector == "body" + return Body() + + def get_by_text(self, text: str, *, exact: bool): + assert (text, exact) == ("正文标题", True) + return object() + + page = SimpleNamespace( + url="https://dr.jd.com/jm/#/n/publish-video.html", + wait_for_timeout=lambda _milliseconds: None, + ) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.page = page + uploader.surface = Surface() + uploader._require_page = lambda: page + uploader._require_surface = lambda: uploader.surface + uploader.dismiss_nps_overlay = lambda: None + uploader._feedback_texts = lambda: [] + uploader._visible_count = lambda _locator: 1 + uploader._content_management_row_confirmation = lambda **_kwargs: None + uploader._screenshot = lambda *_args, **_kwargs: "ambiguous.png" + uploader._final_submit_button = lambda: SimpleNamespace( + click=lambda: setattr( + page, + "url", + "https://dr.jd.com/jm/#/n/material-manage.html?id=987654321", + ) + ) + monotonic_values = iter((0.0, 0.0, 2.0)) + monkeypatch.setattr( + upload_video_to_jd.time, + "monotonic", + lambda: next(monotonic_values), + ) + + with pytest.raises(upload_video_to_jd.AmbiguousPublishResult): + uploader.submit_and_confirm( + before_click=lambda: None, + expected_title="拾影斜挎相机包", + timeout_seconds=1, + ) + + +def test_feedback_texts_skips_stale_or_malformed_toast_nodes() -> None: + class Item: + def __init__(self, *, text: str = "", broken: bool = False) -> None: + self.text = text + self.broken = broken + + def is_visible(self) -> bool: + if self.broken: + raise RuntimeError("value: expected boolean, got object") + return True + + def inner_text(self) -> str: + return self.text + + class Items: + def __init__(self) -> None: + self.values = [Item(broken=True), Item(text="发布成功")] + + def count(self) -> int: + return len(self.values) + + def nth(self, index: int) -> Item: + return self.values[index] + + class DetachedSurface: + def is_detached(self) -> bool: + return False + + def locator(self, _selector: str): + return SimpleNamespace( + count=lambda: (_ for _ in ()).throw(RuntimeError("Frame was detached")) + ) + + page = SimpleNamespace(locator=lambda _selector: Items()) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.page = page + uploader.surface = DetachedSurface() + uploader._require_page = lambda: page + + assert uploader._feedback_texts() == ["发布成功"] + + +def test_manual_security_verification_waits_until_slider_disappears() -> None: + waits: list[int] = [] + screenshots: list[str] = [] + page = SimpleNamespace(wait_for_timeout=waits.append) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.page = page + uploader.surface = None + uploader.headless = False + uploader.manual_login = True + uploader._require_page = lambda: page + visible = iter((True, True, False)) + uploader._security_verification_visible = lambda: next(visible) + uploader._screenshot = lambda marker, **_kwargs: screenshots.append(marker) or "x.png" + + assert uploader._wait_for_manual_security_verification() is True + assert waits == [1000] + assert screenshots == ["publish-security-verification"] + + +def test_security_verification_without_headed_manual_mode_is_ambiguous() -> None: + page = SimpleNamespace(wait_for_timeout=lambda _milliseconds: None) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.page = page + uploader.surface = None + uploader.headless = False + uploader.manual_login = False + uploader._require_page = lambda: page + uploader._security_verification_visible = lambda: True + uploader._screenshot = lambda *_args, **_kwargs: "security.png" + + with pytest.raises( + upload_video_to_jd.AmbiguousPublishResult, + match="安全验证", + ): + uploader._wait_for_manual_security_verification() + + +def _attachment(token: str, name: str, size: int = 12) -> dict[str, object]: + return {"file_token": token, "name": name, "size": size} + + +def _record( + record_id: str, + record_date: str, + attachments: list[dict[str, object]], + *, + product: str = "拾影单肩包", +) -> dict[str, object]: + return { + "record_id": record_id, + "代拍产品": product, + "代拍附件": attachments, + "日期": record_date, + "品牌": ["光影行星"], + "性别": ["男"], + } + + +def test_record_date_boundary_parses_real_feishu_shapes() -> None: + assert parse_record_date("2026-07-28 00:00:00") == date(2026, 7, 28) + assert parse_record_date("2026/07/28") == date(2026, 7, 28) + assert parse_record_date(date(2026, 7, 28)) == date(2026, 7, 28) + with pytest.raises(ValueError, match="empty"): + parse_record_date("") + + +def test_mixed_feishu_attachments_never_treat_images_or_psd_as_video() -> None: + videos, images, unsupported = classify_attachments( + [ + _attachment("video", "demo.MP4"), + _attachment("cover", "cover.jpg"), + _attachment("design", "template.psd"), + ] + ) + + assert [item.file_token for item in videos] == ["video"] + assert [item.file_token for item in images] == ["cover"] + assert [item.file_token for item in unsupported] == ["design"] + + +def test_discovery_allows_blank_dates_and_uses_video_token_as_task_identity() -> None: + records = [ + _record( + "anchor", + "", + [ + _attachment("video-a", "same.mp4"), + _attachment("video-b", "same.mp4"), + _attachment("cover", "cover.png"), + _attachment("psd", "source.psd"), + ], + ), + _record( + "missing-cover", + "2026-08-01 00:00:00", + [_attachment("video-c", "only-video.mp4")], + ), + ] + + candidates, issues = discover_publication_candidates( + records, + target_account_key="account", + source_base_token="base", + source_table_id="table", + source_view_id="view", + ) + + assert [item.source_record_id for item in candidates] == [ + "anchor", + "anchor", + "missing-cover", + ] + assert [item.video_file_token for item in candidates] == [ + "video-a", + "video-b", + "video-c", + ] + assert {item.video_file_name for item in candidates} == { + "same.mp4", + "only-video.mp4", + } + assert [item.cover_file_token for item in candidates] == [ + "cover", + "cover", + None, + ] + assert candidates[-1].source_snapshot["cover"] is None + assert candidates[0].source_record_date is None + assert issues == [] + + +def test_discovery_skips_combination_products_before_media_processing() -> None: + records = [ + _record( + "combo", + "", + [_attachment("combo-video", "combo.m4v")], + product="宙斯3,阿波罗2", + ) + ] + + candidates, issues = discover_publication_candidates( + records, + target_account_key="account", + source_base_token="base", + source_table_id="table", + source_view_id="view", + ) + + assert candidates == [] + assert [issue.message for issue in issues] == [ + "组合款视频已跳过;当前仅自动发布单款视频" + ] + + +def test_product_name_parser_matches_tmall_separator_and_skip_rules() -> None: + assert parse_product_names("宙斯3,阿波罗2,星云 2,逐星") == [ + "宙斯3", + "阿波罗2", + "星云2", + "逐星", + ] + assert parse_product_names("未到货,阿波罗2") == [] + assert parse_product_names("拾影单肩包") == ["拾影斜挎相机包"] + + +def test_jd_title_accepts_strict_json_with_verified_category_in_free_position( + monkeypatch, +) -> None: + monkeypatch.setattr( + jd_video_metadata, + "call_direct_llm", + lambda *_args, **_kwargs: ( + json.dumps( + { + "candidates": [ + { + "title": "今天带哪颗镜头?相机包已经就位", + "angle": "问句", + "category_term": "相机包", + }, + { + "title": "拾影相机包,陪你走进拍摄日常", + "angle": "生活方式", + "category_term": "相机包", + }, + ] + }, + ensure_ascii=False, + ), + {}, + ) + ) + generator = JdVideoTitleGenerator() + + title = generator.generate( + ["拾影单肩包"], + video_key="token-a", + catalog_titles=["拾影斜挎包单反相机包"], + ) + + assert title == "今天带哪颗镜头?相机包已经就位" + assert 5 <= len(title) <= 27 + assert "相机包" in title + assert not title.startswith("拾影") + assert not title.endswith("!") + + +def test_jd_camera_title_does_not_append_a_second_bag_category(monkeypatch) -> None: + def fail_model(*_args, **_kwargs): + raise RuntimeError("offline") + + monkeypatch.setattr(jd_video_metadata, "call_direct_llm", fail_model) + + title = JdVideoTitleGenerator().generate( + ["拾影斜挎相机包"], + video_key="DfYJbbc4QoBtQFxECUScZm1lnFg", + catalog_titles=["拾影斜挎包单反相机包"], + ) + + assert 5 <= len(title) <= 27 + assert sum(term in title for term in ("相机包", "斜挎包")) == 1 + assert not any( + phrase in title + for phrase in ("有序收纳", "拿取更顺手", "相机配件", "随身小物") + ) + + +def test_jd_title_rejects_wrong_bag_type_and_unverified_claim(monkeypatch) -> None: + monkeypatch.setattr( + jd_video_metadata, + "call_direct_llm", + lambda *_args, **_kwargs: ( + json.dumps( + { + "candidates": [ + { + "title": "背上双肩包,今天轻松出发", + "angle": "动作", + "category_term": "双肩包", + }, + { + "title": "防水斜挎包,雨天照常出门", + "angle": "场景", + "category_term": "斜挎包", + }, + { + "title": "街角走一圈,斜挎包自然入镜", + "angle": "生活方式", + "category_term": "斜挎包", + }, + ] + }, + ensure_ascii=False, + ), + {}, + ) + ) + + title = JdVideoTitleGenerator().generate( + ["盖世M1"], + video_key="m1-token", + catalog_titles=["盖世M1男士通勤斜挎包"], + ) + + assert title == "街角走一圈,斜挎包自然入镜" + + +def test_jd_title_uses_next_candidate_when_recent_title_is_similar(monkeypatch) -> None: + monkeypatch.setattr( + jd_video_metadata, + "call_direct_llm", + lambda *_args, **_kwargs: ( + json.dumps( + { + "candidates": [ + { + "title": "日常穿搭里,双肩包刚好入镜", + "angle": "视觉", + "category_term": "双肩包", + }, + { + "title": "忙碌早晨,带上双肩包再出发", + "angle": "场景", + "category_term": "双肩包", + }, + ] + }, + ensure_ascii=False, + ), + {}, + ) + ) + generator = JdVideoTitleGenerator( + recent_titles=["日常穿搭里,电脑包刚好入镜"] + ) + + title = generator.generate( + ["瑞白"], + video_key="ruibai-token", + catalog_titles=["瑞白女士双肩包电脑包"], + ) + + assert title == "忙碌早晨,带上双肩包再出发" + + +def test_jd_title_prefers_specific_double_shoulder_category_over_backpack( + monkeypatch, +) -> None: + monkeypatch.setattr( + jd_video_metadata, + "call_direct_llm", + lambda *_args, **_kwargs: ( + json.dumps( + { + "candidates": [ + { + "title": "背包上身,看看整体搭配", + "angle": "视觉", + "category_term": "背包", + }, + { + "title": "换套穿搭,双肩包依然合拍", + "angle": "生活方式", + "category_term": "双肩包", + }, + ] + }, + ensure_ascii=False, + ), + {}, + ) + ) + + title = JdVideoTitleGenerator().generate( + ["凌云3"], + video_key="lingyun-3-new", + catalog_titles=["凌云3双肩包男士商务背包大容量通勤电脑包"], + ) + + assert title == "换套穿搭,双肩包依然合拍" + assert JdVideoTitleGenerator.allowed_category_terms( + ["凌云3"], + ["凌云3双肩包男士商务背包大容量通勤电脑包"], + )[0] == "双肩包" + + +def test_jd_title_prefers_category_from_style_name_over_catalog_seo_terms( + monkeypatch, +) -> None: + monkeypatch.setattr( + jd_video_metadata, + "call_direct_llm", + lambda *_args, **_kwargs: ( + json.dumps( + { + "candidates": [ + { + "title": "日常穿搭里,双肩包刚好入镜", + "angle": "视觉", + "category_term": "双肩包", + }, + { + "title": "通勤走一圈,托特包自然入镜", + "angle": "生活方式", + "category_term": "托特包", + }, + ] + }, + ensure_ascii=False, + ), + {}, + ) + ) + + title = JdVideoTitleGenerator().generate( + ["黑曜石托特"], + video_key="heiyaoshi-tote-next", + catalog_titles=["黑曜石托特包斜挎包单肩包通勤双肩包"], + ) + + assert title == "通勤走一圈,托特包自然入镜" + + +@pytest.mark.parametrize( + "style_name,catalog_title,category_l2,expected", + [ + ( + "黑曜石托特", + "黑曜石托特包男斜挎包女单肩hobo包通勤双肩包", + "男士单肩/斜挎包", + "托特包", + ), + ( + "凌云3", + "凌云3双肩包男士商务背包大容量旅行书包通勤电脑包", + "男士双肩包", + "双肩包", + ), + ( + "宙斯3", + "宙斯3双肩背包男士电脑包机能旅行书包", + "男士双肩包", + "双肩包", + ), + ( + "盖世m1", + "盖世M1健身包男士运动训练斜挎包手提旅行包", + "休闲运动包", + "健身包", + ), + ], +) +def test_database_bag_category_uses_style_then_first_catalog_identity_type( + style_name, catalog_title, category_l2, expected +) -> None: + assert ( + resolve_database_bag_category(style_name, catalog_title, category_l2) + == expected + ) + + +def test_title_allows_only_database_identity_bag_type_for_keyword_stuffed_title( + monkeypatch, +) -> None: + monkeypatch.setattr( + jd_video_metadata, + "call_direct_llm", + lambda *_args, **_kwargs: ( + json.dumps( + { + "candidates": [ + { + "title": "日常穿搭里,电脑包刚好入镜", + "angle": "视觉", + "category_term": "电脑包", + }, + { + "title": "换套穿搭,双肩包依然合拍", + "angle": "生活方式", + "category_term": "双肩包", + }, + ] + }, + ensure_ascii=False, + ), + {}, + ) + ) + + title = JdVideoTitleGenerator().generate( + ["凌云3"], + video_key="lingyun-identity-category", + catalog_titles=["凌云3双肩包男士商务背包大容量通勤电脑包"], + verified_categories=["双肩包"], + ) + + assert title == "换套穿搭,双肩包依然合拍" + + +def test_strict_title_uses_short_request_and_rejects_unverified_time_claim( + monkeypatch, +) -> None: + captured: dict[str, object] = {} + + def fake_call(system, user, **kwargs): + captured.update(system=system, user=user, **kwargs) + return ( + json.dumps( + { + "candidates": [ + { + "title": "赶高铁的早晨,曜影双肩包取出电脑只要一秒", + "angle": "场景", + "category_term": "双肩包", + }, + { + "title": "曜影双肩包,电脑也有随行位置", + "angle": "细节", + "category_term": "双肩包", + }, + ] + }, + ensure_ascii=False, + ), + {}, + ) + + monkeypatch.setattr(jd_video_metadata, "call_direct_llm", fake_call) + + title = JdVideoTitleGenerator( + require_llm_candidate=True, + require_product_name=True, + require_function_term=True, + candidate_count=4, + ).generate( + ["曜影"], + video_key="yaoying-token", + catalog_titles=["曜影双肩包轻便电脑书包"], + ) + + assert title == "曜影双肩包,电脑也有随行位置" + assert captured["max_tokens"] == 2048 + assert captured["model"] == "MiniMax-M3" + assert "生成4个" in str(captured["system"]) + assert "生成4个" in str(captured["user"]) + + +def test_tmall_title_rules_reject_questions_and_generic_backpack(monkeypatch) -> None: + captured: dict[str, object] = {} + + def fake_call(system, user, **kwargs): + captured.update(system=system, user=user, **kwargs) + return ( + json.dumps( + { + "candidates": [ + { + "title": "曜影双肩包,电脑也有随行位置?", + "angle": "场景", + "category_term": "双肩包", + }, + { + "title": "曜影背包,电脑也有随行位置", + "angle": "细节", + "category_term": "背包", + }, + { + "title": "曜影双肩包,电脑也有随行位置", + "angle": "细节", + "category_term": "双肩包", + }, + ] + }, + ensure_ascii=False, + ), + {}, + ) + + monkeypatch.setattr(jd_video_metadata, "call_direct_llm", fake_call) + + title = JdVideoTitleGenerator( + require_llm_candidate=True, + require_product_name=True, + require_function_term=True, + candidate_count=4, + allow_question_mark=False, + prefer_double_shoulder=True, + ).generate( + ["曜影"], + video_key="yaoying-token-no-question", + catalog_titles=["曜影双肩包轻便电脑背包"], + ) + + assert title == "曜影双肩包,电脑也有随行位置" + assert "禁止使用问号" in str(captured["user"]) + assert "必须统一写“双肩包”" in str(captured["user"]) + + +def test_strict_title_classifies_model_request_failure_separately(monkeypatch) -> None: + def fail_call(*_args, **_kwargs): + raise TimeoutError("timed out") + + monkeypatch.setattr(jd_video_metadata, "call_direct_llm", fail_call) + + with pytest.raises(TitleGenerationError, match="timed out"): + JdVideoTitleGenerator( + require_llm_candidate=True, + require_product_name=True, + require_function_term=True, + candidate_count=6, + ).generate( + ["曜影"], + video_key="yaoying-timeout", + catalog_titles=["曜影双肩包轻便电脑背包"], + ) + + +def test_strict_title_classifies_empty_validated_pool_separately(monkeypatch) -> None: + monkeypatch.setattr( + jd_video_metadata, + "call_direct_llm", + lambda *_args, **_kwargs: ( + json.dumps({"candidates": []}), + {}, + ) + ) + + with pytest.raises(TitleValidationError, match="没有生成满足"): + JdVideoTitleGenerator( + require_llm_candidate=True, + require_product_name=True, + require_function_term=True, + candidate_count=6, + ).generate( + ["曜影"], + video_key="yaoying-empty-candidates", + catalog_titles=["曜影双肩包轻便电脑背包"], + ) + + +def test_strict_title_retries_invalid_pool_then_uses_verified_tmall_fallback( + monkeypatch, +) -> None: + calls: list[tuple[str, str]] = [] + + def fake_call(system, user, **_kwargs): + calls.append((system, user)) + return json.dumps({"candidates": []}), {} + + monkeypatch.setattr(jd_video_metadata, "call_direct_llm", fake_call) + + title = JdVideoTitleGenerator( + require_llm_candidate=True, + require_product_name=True, + require_function_term=True, + candidate_count=6, + allow_question_mark=False, + prefer_double_shoulder=True, + compact_prompt=True, + allow_verified_fallback=True, + ).generate( + ["曜影"], + video_key="yaoying-verified-fallback", + catalog_titles=["曜影双肩包轻便电脑背包"], + ) + + assert len(calls) == 2 + assert "曜影" in title + assert "双肩包" in title + assert any(term in title for term in ("轻便", "电脑")) + + +def test_title_parser_accepts_json_code_fence_without_relaxing_validation( + monkeypatch, +) -> None: + monkeypatch.setattr( + jd_video_metadata, + "call_direct_llm", + lambda *_args, **_kwargs: ( + "```json\n" + '{"candidates":[{"title":"曜影双肩包,电脑通勤更方便",' + '"angle":"功能收益","category_term":"双肩包"}]}' + "\n```", + {}, + ), + ) + + title = JdVideoTitleGenerator( + require_llm_candidate=True, + require_product_name=True, + require_function_term=True, + candidate_count=1, + allow_question_mark=False, + prefer_double_shoulder=True, + compact_prompt=True, + ).generate( + ["曜影"], + video_key="yaoying-code-fence", + catalog_titles=["曜影双肩包轻便电脑背包"], + ) + + assert title == "曜影双肩包,电脑通勤更方便" + + +def test_compact_title_prompt_keeps_strict_local_validation(monkeypatch) -> None: + captured: dict[str, object] = {} + + def fake_call(system, user, **kwargs): + captured.update(system=system, user=user, **kwargs) + return ( + json.dumps( + { + "candidates": [ + { + "title": "星云mini斜挎包,手机通勤随身带", + "angle": "功能收益", + "category_term": "斜挎包", + } + ] + }, + ensure_ascii=False, + ), + {}, + ) + + monkeypatch.setattr(jd_video_metadata, "call_direct_llm", fake_call) + + title = JdVideoTitleGenerator( + require_llm_candidate=True, + require_product_name=True, + require_function_term=True, + candidate_count=1, + allow_question_mark=False, + prefer_double_shoulder=True, + compact_prompt=True, + ).generate( + ["星云mini"], + video_key="xingyun-mini-token", + catalog_titles=["星云mini斜挎包单肩包便携手机包"], + ) + + assert title == "星云mini斜挎包,手机通勤随身带" + assert "场景、动作、轻问句" not in str(captured["system"]) + assert captured["max_tokens"] == 2048 + + +def test_compact_collection_title_uses_generic_copy_without_style_names(monkeypatch) -> None: + captured: dict[str, object] = {} + + def fake_call(system, user, **kwargs): + captured.update(system=system, user=user, **kwargs) + return ( + json.dumps( + { + "candidates": [ + { + "title": "宙斯3通勤包袋,大容量轻便切换场景", + "angle": "功能收益", + "category_term": "通勤包袋", + }, + { + "title": "多款通勤包袋,大容量轻便切换场景", + "angle": "功能收益", + "category_term": "通勤包袋", + }, + ] + }, + ensure_ascii=False, + ), + {}, + ) + + monkeypatch.setattr(jd_video_metadata, "call_direct_llm", fake_call) + + title = JdVideoTitleGenerator( + require_llm_candidate=True, + require_function_term=True, + candidate_count=2, + allow_question_mark=False, + compact_prompt=True, + generic_collection=True, + ).generate( + ["宙斯3", "阿波罗2"], + video_key="combo-token", + catalog_titles=["宙斯3双肩包大容量", "阿波罗2电脑包轻便"], + ) + + assert title == "多款通勤包袋,大容量轻便切换场景" + assert "不得出现任何具体系列名称" in str(captured["system"]) + assert "禁止出现在标题中的具体款名:宙斯3、阿波罗2" in str(captured["user"]) + + +def test_product_category_stem_is_not_duplicated(monkeypatch) -> None: + captured: dict[str, object] = {} + + def fake_call(system, user, **kwargs): + captured.update(system=system, user=user, **kwargs) + return ( + json.dumps( + { + "candidates": [ + { + "title": "极星双肩双肩包,苹果电脑防震防摔", + "angle": "功能收益", + "category_term": "双肩包", + } + ] + }, + ensure_ascii=False, + ), + {}, + ) + + monkeypatch.setattr(jd_video_metadata, "call_direct_llm", fake_call) + + title = JdVideoTitleGenerator( + require_llm_candidate=True, + require_product_name=True, + require_function_term=True, + candidate_count=1, + allow_question_mark=False, + prefer_double_shoulder=True, + compact_prompt=True, + ).generate( + ["极星双肩"], + video_key="jixing-token", + catalog_titles=["极星双肩包苹果电脑防震防摔"], + ) + + assert title == "极星双肩包,苹果电脑防震防摔" + assert "禁止写“极星双肩双肩包”" in str(captured["user"]) + + +def test_jd_title_fails_closed_without_verified_specific_bag_type(monkeypatch) -> None: + monkeypatch.setattr( + jd_video_metadata, + "call_direct_llm", + lambda *_args, **_kwargs: pytest.fail("not called"), + ) + + with pytest.raises(ValueError, match="没有已核验的具体包型"): + JdVideoTitleGenerator().generate( + ["未建档款"], + video_key="unknown-token", + catalog_titles=["光影行星新品"], + ) + + +class _RecentTitleCursor: + def __init__(self, rows) -> None: + self.rows = rows + self.executed = None + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def execute(self, query: str, params: tuple[object, ...]) -> None: + self.executed = (query, params) + + def fetchall(self): + return self.rows + + +class _RecentTitleConnection: + def __init__(self, rows) -> None: + self.cursor_instance = _RecentTitleCursor(rows) + + def cursor(self): + return self.cursor_instance + + +def test_recent_title_history_is_scoped_to_submitted_rows_and_deduplicated() -> None: + connection = _RecentTitleConnection( + [("近期双肩包标题",), (" 近期斜挎包标题 ",), ("近期双肩包标题",)] + ) + + titles = load_recent_video_publication_titles( + connection, + channel="jd", + target_account_key="stable-account", + ) + + assert titles == ("近期双肩包标题", "近期斜挎包标题") + query, params = connection.cursor_instance.executed + assert "status IN ('published', 'ambiguous')" in query + assert "OR submit_started_at IS NOT NULL" in query + assert "publish_payload ->> 'title'" in query + assert params == ("jd", "stable-account", 50) + + +def test_recent_title_history_failure_is_explicit(monkeypatch) -> None: + def fail(*_args, **_kwargs): + raise OSError("database unavailable") + + monkeypatch.setattr(upload_video_to_jd, "load_recent_video_publication_titles", fail) + + with pytest.raises(RuntimeError, match="拒绝在缺少跨进程查重历史时继续"): + upload_video_to_jd._load_recent_title_history( + object(), + account_key="stable-account", + ) + + +def test_interest_and_form_tags_obey_business_cardinality() -> None: + assert choose_interest_tags(["城市户外双肩包"]) == ("中性", "户外装备") + assert choose_interest_tags(["摄影相机包"]) == ("中性", "旅行拍摄") + assert choose_interest_tags(["拾影斜挎相机包"]) == ("中性", "旅行拍摄") + assert len(choose_interest_tags(["电脑双肩包"])) <= 2 + assert choose_form_tag(["阿波罗2", "星云2"], "合集.mp4") == "合集推荐" + assert choose_form_tag(["阿波罗2"], "日常VLOG.mp4") == "VLOG" + assert choose_form_tag(["阿波罗2"], "产品.mp4") == "产品展示" + assert choose_form_tag(["拾影斜挎相机包"], "拾影0724(1).mp4") == "产品展示" + + +def test_topic_ranking_prefers_relevance_then_heat() -> None: + ranked = rank_topic_candidates( + [ + "#开学时间到!书包有答案 999.9W", + "#双肩包一背 通勤氛围感拉满 338.7W", + "#登山包开箱实测 270.7W", + "#摄影包出行清单 18.2W", + "#单肩相机包日常 3.1W", + "#数码先锋 581.9W", + ], + ["拾影斜挎相机包"], + ) + + assert ranked[0].text.startswith("#单肩相机包") + assert ranked[0].heat == 31_000 + assert all("包" in item.text for item in ranked) + assert all( + marker not in item.text for item in ranked for marker in ("书包", "双肩包", "登山包") + ) + + +def test_topic_ranking_uses_heat_only_within_one_relevance_tier() -> None: + ranked = rank_topic_candidates( + [ + "#摄影包低热但同款 1.2W", + "#通勤包超高热 999.9W", + "#相机包同层高热 8.8W", + ], + ["拾影斜挎相机包"], + ) + + assert [item.text for item in ranked[:2]] == [ + "#相机包同层高热 8.8W", + "#摄影包低热但同款 1.2W", + ] + + +def test_topic_search_terms_cover_product_camera_and_shoulder_aliases() -> None: + assert topic_search_terms(["拾影斜挎相机包"]) == ( + "拾影斜挎相机包", + "拾影单肩包", + "相机包", + "摄影包", + "单肩包", + "斜挎包", + "包", + ) + + +def test_topic_context_uses_catalog_backpack_type_for_yaoying() -> None: + product = ProductReference( + style_name="曜影", + spu_id="10034534903226", + product_title=( + "光影行星(GYXX)曜影双肩包男士通勤单肩背包轻便大容量电脑包" + ), + color_label="灰色", + bag_category="双肩包", + ) + + context = upload_video_to_jd.JdVideoUploader._topic_context_names( + ["曜影"], [product] + ) + + assert context == ("曜影", "双肩包") + assert topic_search_terms(context) == ("曜影", "双肩包", "包") + + +def test_topic_context_uses_persisted_tote_type_not_keyword_stuffed_title() -> None: + product = ProductReference( + style_name="黑曜石托特", + spu_id="10033995834580", + product_title="黑曜石托特包斜挎包单肩包通勤双肩包", + color_label="黑色", + bag_category="托特包", + ) + + context = upload_video_to_jd.JdVideoUploader._topic_context_names( + ["黑曜石托特"], [product] + ) + + assert context == ("黑曜石托特", "托特包") + assert topic_search_terms(context) == ("黑曜石托特", "托特包", "包") + + +def test_topic_ranking_prefers_backpack_topic_for_yaoying() -> None: + ranked = rank_topic_candidates( + [ + "#数码先锋 999.9W", + "#单肩包通勤穿搭 500.0W", + "#通勤党必备双肩包 3.1W", + ], + ["曜影", "双肩包"], + ) + + assert [candidate.text for candidate in ranked] == [ + "#通勤党必备双肩包 3.1W" + ] + + +def test_topic_candidate_rows_falls_back_to_generic_div_hashtag_cards() -> None: + class Collection: + def __init__(self, *items) -> None: + self.items = list(items) + + def count(self) -> int: + return len(self.items) + + def nth(self, index: int): + return self.items[index] + + class Node: + def __init__(self, text: str, parent=None) -> None: + self.text = text + self.parent = parent + + def is_visible(self) -> bool: + return True + + def inner_text(self) -> str: + return self.text + + def locator(self, selector: str): + assert selector == "xpath=.." + return Collection(self.parent) if self.parent is not None else Collection() + + first_card = Node("#通勤党必备双肩包\n349.6W\n详情") + second_card = Node("#女士双肩包一背\n338.4W\n详情") + leaves = ( + Node("#通勤党必备双肩包", first_card), + Node("#女士双肩包一背", second_card), + ) + + class Dialog: + def locator(self, selector: str): + if selector.startswith("xpath=.//*"): + return Collection(*leaves) + return Collection() + + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + + rows = uploader._topic_candidate_rows(Dialog()) + + assert [text for text, _row in rows] == [ + "#女士双肩包一背 338.4W 详情", + "#通勤党必备双肩包 349.6W 详情", + ] + + +def test_topic_context_does_not_trust_shiying_catalog_backpack_mislabel() -> None: + product = ProductReference( + style_name="拾影斜挎相机包", + spu_id="10032211469888", + product_title="光影行星(GYXX)拾影双肩包女士新款轻便通勤", + color_label="黑色", + ) + + assert upload_video_to_jd.JdVideoUploader._topic_context_names( + ["拾影斜挎相机包"], [product] + ) == ("拾影斜挎相机包", "相机包") + + +def test_add_best_topic_searches_style_and_rejects_hot_conflicting_topic() -> None: + events: list[str] = [] + state = {"query": "", "selected": "#开学时间到!书包有答案"} + + class Collection: + def __init__(self, *items) -> None: + self.items = items + + def count(self) -> int: + return len(self.items) + + def nth(self, index: int): + return self.items[index] + + class Visible: + def is_visible(self) -> bool: + return True + + class Search(Visible): + def fill(self, value: str) -> None: + state["query"] = value + events.append(f"fill:{value}") + + def press(self, key: str) -> None: + events.append(f"press:{key}") + + class TopicRow(Visible): + def __init__(self, text: str) -> None: + self.text = text + + def inner_text(self) -> str: + return self.text + + def click(self) -> None: + state["selected"] = upload_video_to_jd.JdVideoUploader._topic_name( + self.text + ) + events.append(f"select:{state['selected']}") + + results = { + "拾影斜挎相机包": ["#开学时间到!书包有答案 999.9W"], + "相机包": ["#摄影包出行清单 18.2W"], + "单肩包": ["#单肩相机包日常 3.1W"], + } + search = Search() + + class TopicDialog: + def locator(self, selector: str): + if selector == 'input[placeholder="输入关键词搜索"]': + return Collection(search) + if selector in upload_video_to_jd.TOPIC_OPTION_ROW_SELECTORS: + if selector != upload_video_to_jd.TOPIC_OPTION_ROW_SELECTORS[0]: + return Collection() + return Collection( + *(TopicRow(text) for text in results.get(state["query"], [])) + ) + raise AssertionError(selector) + + class TopicFormItem: + def inner_text(self) -> str: + return state["selected"] + + def get_by_text(self, text: str, *, exact: bool): + assert exact is True + if text == "替换": + return Collection( + SimpleNamespace( + is_visible=lambda: True, + click=lambda: events.append("replace"), + ) + ) + if text == state["selected"] and state["selected"]: + return Collection(Visible()) + return Collection() + + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._require_page = lambda: SimpleNamespace( + wait_for_timeout=lambda milliseconds: events.append(f"wait:{milliseconds}") + ) + uploader._unique_form_item = lambda label: ( + TopicFormItem() if label == "参与话题" else pytest.fail(label) + ) + uploader._active_topic_dialog = lambda: TopicDialog() + + selected = uploader.add_best_topic(["拾影斜挎相机包"]) + + assert selected == "#单肩相机包日常" + assert "fill:拾影斜挎相机包" in events + assert "fill:相机包" in events + assert "fill:单肩包" in events + assert "fill:包" in events + assert "replace" in events + assert "select:#单肩相机包日常" in events + + +@pytest.mark.parametrize("dialog_count", [0, 2]) +def test_active_topic_dialog_rejects_zero_or_multiple_matching_drawers( + dialog_count, +) -> None: + class Collection: + def __init__(self, *items) -> None: + self.items = items + + def count(self) -> int: + return len(self.items) + + def nth(self, index: int): + return self.items[index] + + title = SimpleNamespace(is_visible=lambda: True, inner_text=lambda: "参与话题") + + class Dialog: + def is_visible(self) -> bool: + return True + + def locator(self, selector): + assert selector == upload_video_to_jd.TOPIC_DRAWER_TITLE_SELECTOR + return Collection(title) + + dialogs = Collection(*(Dialog() for _index in range(dialog_count))) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._require_page = lambda: SimpleNamespace( + wait_for_timeout=lambda _milliseconds: pytest.fail("must fail closed") + ) + uploader._require_surface = lambda: SimpleNamespace( + get_by_role=lambda role: dialogs if role == "dialog" else pytest.fail(role) + ) + + with pytest.raises(RuntimeError, match="参与话题抽屉"): + uploader._active_topic_dialog(timeout_ms=0) + + +def test_select_tags_reads_back_each_step_and_exact_final_set() -> None: + selected: list[str] = [] + paths: list[tuple[str, ...]] = [] + control = object() + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._tag_control = lambda: control + uploader._selected_tag_chips = lambda actual_control: ( + tuple(selected) if actual_control is control else pytest.fail("wrong control") + ) + + def select_path(path) -> object: + paths.append(tuple(path)) + selected.append(path[-1]) + return control + + uploader._select_tag_path = select_path + + uploader.select_tags(("中性", "旅行拍摄"), "产品展示") + + assert paths == [ + ("兴趣标签", "其他", "中性"), + ("兴趣标签", "旅行", "旅行拍摄"), + ("体裁标签", "通用体裁", "产品展示"), + ] + assert set(selected) == {"中性", "旅行拍摄", "产品展示"} + + +def test_select_tags_fails_when_click_does_not_update_chips() -> None: + control = object() + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._tag_control = lambda: control + uploader._selected_tag_chips = lambda _control: () + uploader._select_tag_path = lambda _path: control + + with pytest.raises(RuntimeError, match="回读不一致"): + uploader.select_tags(("中性", "旅行拍摄"), "产品展示") + + +@pytest.mark.parametrize( + "initial", + [ + ("中性", "意外标签"), + ("中性", "中性"), + ], +) +def test_select_tags_rejects_extra_or_duplicate_existing_chips(initial) -> None: + control = object() + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._tag_control = lambda: control + uploader._selected_tag_chips = lambda _control: initial + + with pytest.raises(RuntimeError, match="重复或目标外标签"): + uploader.select_tags(("中性", "旅行拍摄"), "产品展示") + + +def test_active_tag_overlay_ignores_hidden_duplicate_and_option_text() -> None: + events: list[str] = [] + + class Collection: + def __init__(self, *items) -> None: + self.items = items + + def count(self) -> int: + return len(self.items) + + def nth(self, index: int): + return self.items[index] + + hidden = SimpleNamespace(is_visible=lambda: False) + + class ActiveOverlay: + def is_visible(self) -> bool: + return True + + def locator(self, selector: str): + assert "menuitemcheckbox" in selector + assert 'title="兴趣标签"' in selector + assert "data-path-key" in selector + return Collection( + SimpleNamespace(is_visible=lambda: False), + SimpleNamespace( + is_visible=lambda: True, + click=lambda: events.append("clicked"), + ), + ) + + active = ActiveOverlay() + + class Surface: + def locator(self, selector: str): + if selector == upload_video_to_jd.TAG_ACTIVE_OVERLAY_SELECTORS[0]: + return Collection(hidden, active) + return Collection() + + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._require_surface = lambda: Surface() + + overlay = uploader._active_tag_overlay() + uploader._click_unique_tag_option(overlay, "兴趣标签") + + assert overlay is active + assert events == ["clicked"] + + +@pytest.mark.parametrize("visible_count", [0, 2]) +def test_tag_control_rejects_missing_or_multiple_visible_controls( + visible_count, +) -> None: + class Collection: + def __init__(self, *items) -> None: + self.items = items + + def count(self) -> int: + return len(self.items) + + def nth(self, index: int): + return self.items[index] + + controls = Collection( + *(SimpleNamespace(is_visible=lambda: True) for _index in range(visible_count)) + ) + form_item = SimpleNamespace( + locator=lambda selector: ( + controls + if selector == upload_video_to_jd.TAG_CONTROL_SELECTOR + else pytest.fail(selector) + ) + ) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._unique_form_item = lambda label: ( + form_item if label == "标签类型" else pytest.fail(label) + ) + + with pytest.raises(RuntimeError, match="多选控件"): + uploader._tag_control() + + +def test_cover_color_is_coarse_and_confidence_bounded(tmp_path) -> None: + image_path = tmp_path / "black-cover.png" + Image.new("RGB", (200, 200), (12, 12, 12)).save(image_path) + + detected = infer_cover_color(image_path) + + assert detected.name == "黑色" + assert 0 <= detected.confidence <= 1 + + +class _CatalogCursor: + def __init__(self, rows_by_query: dict[str, list[tuple[object, ...]]]) -> None: + self.rows_by_query = rows_by_query + self.rows: list[tuple[object, ...]] = [] + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def execute(self, query: str, _params=None) -> None: + if "FROM dim_style" in query: + key = "dim_style" + elif "LOWER(BTRIM(product_no))" in query: + key = "product_daily_by_style" + else: + key = "product_daily_metrics" + self.rows = self.rows_by_query.get(key, []) + + def fetchall(self): + return list(self.rows) + + +class _CatalogConnection: + def __init__(self, rows_by_query: dict[str, list[tuple[object, ...]]]) -> None: + self.rows_by_query = rows_by_query + + def cursor(self): + return _CatalogCursor(self.rows_by_query) + + +def test_product_mapping_keeps_multiple_spus_as_optional_audit_only() -> None: + connection = _CatalogConnection( + { + "dim_style": [("阿波罗2", ["100", "200"])], + "product_daily_metrics": [ + ("100", "光影行星阿波罗2黑色单肩包"), + ("200", "光影行星阿波罗2白色单肩包"), + ], + } + ) + + references = load_jd_product_references( + connection, + ["阿波罗2"], + video_file_token="video", + cover_color=DetectedColor("白色", 0.9), + ) + + assert [(item.style_name, item.spu_id) for item in references] == [ + ("阿波罗2", None) + ] + assert references[0].product_title == "光影行星阿波罗2黑色单肩包" + + +def test_explicit_video_mapping_is_optional_audit_metadata() -> None: + connection = _CatalogConnection( + { + "dim_style": [("阿波罗2", ["100", "200"])], + "product_daily_metrics": [("100", "黑色"), ("200", "白色")], + } + ) + + references = load_jd_product_references( + connection, + ["阿波罗2"], + video_file_token="token", + cover_color=DetectedColor("灰色", 0.1), + overrides={ + "videos": { + "token": { + "阿波罗2": { + "spu_id": "200", + "product_title": "阿波罗2白色", + "color_label": "白色", + } + } + } + }, + ) + assert references[0].spu_id == "200" + assert references[0].color_label == "白色" + audit_only = load_jd_product_references( + connection, + ["阿波罗2"], + video_file_token="token", + cover_color=DetectedColor("灰色", 0.1), + overrides={ + "videos": {"token": {"阿波罗2": {"spu_id": "999"}}} + }, + ) + assert audit_only[0].spu_id == "999" + + +def test_missing_dim_style_spu_still_builds_style_reference() -> None: + connection = _CatalogConnection( + { + "dim_style": [], + "product_daily_metrics": [], + "product_daily_by_style": [ + ( + "宙斯3", + "光影行星(GYXX)宙斯3双肩背包男士电脑包", + ) + ], + } + ) + + references = load_jd_product_references( + connection, + ["宙斯3"], + video_file_token="video", + cover_color=DetectedColor("黑色", 0.8), + ) + + assert references == [ + ProductReference( + style_name="宙斯3", + spu_id=None, + product_title="光影行星(GYXX)宙斯3双肩背包男士电脑包", + color_label="黑色", + bag_category="双肩包", + ) + ] + + +def test_product_mapping_skips_combo_video_before_catalog_lookup() -> None: + connection = _CatalogConnection({}) + + with pytest.raises(ProductMappingError, match="跳过组合款"): + load_jd_product_references( + connection, + ["宙斯3", "阿波罗2"], + video_file_token="combo-video", + cover_color=DetectedColor("黑色", 0.8), + ) + + +def test_single_database_spu_is_selected_without_sku_data() -> None: + connection = _CatalogConnection( + { + "dim_style": [("拾影斜挎相机包", ["10035423416854"])], + "product_daily_metrics": [ + ( + "10035423416854", + "光影行星拾影斜挎包单反相机包", + ) + ], + } + ) + + references = load_jd_product_references( + connection, + ["拾影斜挎相机包"], + video_file_token="video", + cover_color=DetectedColor("黑色", 0.2), + ) + + assert references[0].spu_id == "10035423416854" + assert set(asdict(references[0])) == { + "style_name", + "spu_id", + "product_title", + "color_label", + "bag_category", + } + + +def test_persisted_product_payload_reads_new_spu_and_legacy_product_id() -> None: + new_reference = upload_video_to_jd._persisted_product_references( + { + "products": [ + { + "style_name": "拾影斜挎相机包", + "spu_id": "10035423416854", + "product_title": "拾影", + "color_label": "曜夜黑", + } + ] + } + )[0] + legacy_reference = upload_video_to_jd._persisted_product_references( + { + "products": [ + { + "style_name": "拾影斜挎相机包", + "product_id": "10035423416854", + "product_title": "拾影", + "color_label": "曜夜黑", + } + ] + } + )[0] + + assert new_reference.spu_id == legacy_reference.spu_id == "10035423416854" + + +def test_persisted_product_payload_accepts_style_without_spu() -> None: + reference = upload_video_to_jd._persisted_product_references( + { + "products": [ + { + "style_name": "宙斯3", + "spu_id": None, + "product_title": "宙斯3双肩包", + "color_label": "黑色", + } + ] + } + )[0] + + assert reference.spu_id is None + assert reference.product_id == "" + + +def test_own_store_card_matching_requires_style_store_and_single_color() -> None: + product = ProductReference( + style_name="拾影斜挎相机包", + spu_id="10035423416854", + product_title="拾影", + color_label="曜夜黑", + ) + matching_text = ( + "光影行星(GYXX)拾影斜挎包单反相机包 曜夜黑-小\n" + "光影行星 GYXX官方旗舰店" + ) + + assert upload_video_to_jd.JdVideoUploader._product_card_matches( + card_text=matching_text, + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + assert not upload_video_to_jd.JdVideoUploader._product_card_matches( + card_text=matching_text.replace("曜夜黑", "浅灰"), + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + assert not upload_video_to_jd.JdVideoUploader._product_card_matches( + card_text=matching_text.replace("官方旗舰店", "专营店"), + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + assert ( + upload_video_to_jd.JdVideoUploader._product_color_terms("曜夜黑/浅灰") + == () + ) + assert upload_video_to_jd.JdVideoUploader._product_card_style_store_matches( + card_text=matching_text.replace("曜夜黑", "浅灰"), + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + assert not upload_video_to_jd.JdVideoUploader._product_card_style_store_matches( + card_text=( + "光影行星(GYXX)拾影双肩包男士新款轻便通勤背包" + "女士电脑包旅行书包\n" + "光影行星 GYXX官方旗舰店" + ), + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + assert upload_video_to_jd.JdVideoUploader._product_card_style_store_matches( + card_text=( + "光影行星(GYXX)拾影双肩包女士新款轻便通勤\n" + "光影行星 GYXX官方旗舰店" + ), + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + + +def test_own_store_card_matching_casefolds_model_and_store_text() -> None: + product = ProductReference( + style_name="盖世m1", + spu_id="10026326257225", + product_title="光影行星(GYXX)盖世M1健身包男士运动训练多功能斜挎包", + color_label="黑色", + ) + card_text = ( + "光影行星(GYXX)盖世M1健身包男士运动训练多功能斜挎包 黑色\n" + "光影行星 GYXX官方旗舰店" + ) + + assert upload_video_to_jd.JdVideoUploader._product_card_style_store_matches( + card_text=card_text, + product=product, + expected_store_name="光影行星 gyxx官方旗舰店", + ) + assert upload_video_to_jd.JdVideoUploader._product_card_matches( + card_text=card_text, + product=product, + expected_store_name="光影行星 gyxx官方旗舰店", + ) + + +def test_own_store_card_matching_accepts_strict_inserted_bag_alias() -> None: + product = ProductReference( + style_name="极星双肩2", + spu_id="10000000000002", + product_title="光影行星(GYXX)极星双肩包2男女电脑商务通勤背包", + color_label="黑色", + ) + card_text = ( + "光影行星(GYXX)极星双肩包2男女电脑商务通勤背包 黑色\n" + "光影行星 GYXX官方旗舰店" + ) + + assert upload_video_to_jd.JdVideoUploader._style_card_aliases( + product.style_name + ) == ("极星双肩2", "极星双肩包2", "极星双肩背包2") + assert upload_video_to_jd.JdVideoUploader._product_card_style_store_matches( + card_text=card_text, + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + assert upload_video_to_jd.JdVideoUploader._product_card_matches( + card_text=card_text, + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + + reverse_product = ProductReference( + style_name="极星双肩背包2", + spu_id=product.spu_id, + product_title=product.product_title, + color_label=product.color_label, + ) + assert upload_video_to_jd.JdVideoUploader._product_card_style_store_matches( + card_text=card_text.replace("双肩包2", "双肩2"), + product=reverse_product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + + +def test_own_store_search_terms_use_gaia_series_query_for_all_models() -> None: + assert upload_video_to_jd.JdVideoUploader._own_store_search_terms( + "盖亚斜挎3" + ) == ("盖亚斜跨", "盖亚斜挎") + assert upload_video_to_jd.JdVideoUploader._own_store_search_terms( + "盖亚斜跨1" + ) == ("盖亚斜跨", "盖亚斜挎") + assert upload_video_to_jd.JdVideoUploader._own_store_search_terms( + "极星双肩2" + ) == ("极星双肩2",) + + +def test_gaia_series_search_accepts_all_live_gaia_series_cards() -> None: + product = ProductReference( + style_name="盖亚斜挎3", + spu_id="10000000000003", + product_title="光影行星(GYXX)盖亚斜挎包3男女通勤斜挎包", + color_label="黑色", + ) + expected_store = "光影行星 GYXX官方旗舰店" + model_three = ( + "光影行星(GYXX)盖亚斜挎包3男女通勤斜挎包 黑色\n" + "光影行星 GYXX官方旗舰店" + ) + model_c1 = ( + "光影行星(GYXX)盖亚c1斜挎相机包单双肩两用摄影包霜月白灰\n" + "光影行星 GYXX官方旗舰店" + ) + model_x = ( + "光影行星(GYXX)盖亚x斜挎相机包男士摄影包原野绿\n" + "光影行星 GYXX官方旗舰店" + ) + model_neo = ( + "光影行星(GYXX)盖亚neo斜挎相机包无人机收纳包曜夜黑\n" + "光影行星 GYXX官方旗舰店" + ) + + for card_text in (model_three, model_c1, model_x, model_neo): + assert upload_video_to_jd.JdVideoUploader._product_card_style_store_matches( + card_text=card_text, + product=product, + expected_store_name=expected_store, + ) + + assert not upload_video_to_jd.JdVideoUploader._product_card_style_store_matches( + card_text=( + "光影行星(GYXX)盖亚双肩背包3电脑包曜夜黑\n" + "光影行星 GYXX官方旗舰店" + ), + product=product, + expected_store_name=expected_store, + ) + + +@pytest.mark.parametrize( + "live_title", + ( + "光影行星(GYXX)极星双肩背包2男女电脑包商务通勤16英寸" + "笔记本包手提书包幻迹黑【手提/双肩两用款】适合16寸以内轻薄本 " + "售价¥579.00 光影行星 GYXX官方旗舰店", + "光影行星(GYXX)极星双肩背包2男女电脑包商务通勤16英寸" + "笔记本包手提书包云息白【手提/双肩两用款】适合16寸以内轻薄本 " + "售价¥579.00 光影行星 GYXX官方旗舰店", + ), +) +def test_live_jixing_two_backpack_titles_pass_strict_style_gate( + live_title: str, +) -> None: + product = ProductReference( + style_name="极星双肩2", + spu_id="10035288767154", + product_title="光影行星(GYXX)极星双肩2双肩包", + color_label="黑色", + ) + + diagnostics = upload_video_to_jd.JdVideoUploader._product_card_match_diagnostic( + index=0, + card_text=live_title, + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + + assert diagnostics["style_gate"] is True + assert diagnostics["store_gate"] is True + assert diagnostics["version_gate"] is True + assert upload_video_to_jd.JdVideoUploader._product_card_style_store_matches( + card_text=live_title, + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + + +def test_style_gate_normalizes_spacing_case_width_and_punctuation() -> None: + product = ProductReference( + style_name="极星 双肩 2", + spu_id="10035288767154", + product_title="极星双肩2双肩包", + color_label="幻迹黑", + ) + card_text = ( + "光影行星(GYXX) 极星-双肩 背包2 男女通勤电脑包 幻迹黑\n" + "光影行星 gyxx 官方旗舰店" + ) + + assert upload_video_to_jd.JdVideoUploader._product_card_style_store_matches( + card_text=card_text, + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + + +def test_style_gate_never_changes_the_requested_carrying_form() -> None: + product = ProductReference( + style_name="极星双肩2", + spu_id="10035288767154", + product_title="极星双肩2双肩包", + color_label="黑色", + ) + card_text = ( + "光影行星(GYXX)极星单肩背包2男女通勤包 黑色 " + "光影行星 GYXX官方旗舰店" + ) + + assert not upload_video_to_jd.JdVideoUploader._product_card_style_store_matches( + card_text=card_text, + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + + +@pytest.mark.parametrize( + ("style_name", "product_title", "conflicting_title"), + ( + ("凌云", "凌云双肩包", "凌云air双肩包"), + ("凌云", "凌云双肩包", "凌云3双肩包"), + ("极星双肩包", "极星双肩包", "极星双肩包2"), + ), +) +def test_style_without_model_rejects_numbered_or_editioned_generation( + style_name: str, + product_title: str, + conflicting_title: str, +) -> None: + product = ProductReference( + style_name=style_name, + spu_id="spu", + product_title=product_title, + color_label="黑色", + ) + card_text = ( + f"光影行星(GYXX){conflicting_title} 黑色 " + "光影行星 GYXX官方旗舰店" + ) + + assert not upload_video_to_jd.JdVideoUploader._product_card_style_store_matches( + card_text=card_text, + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + + +def test_bare_style_accepts_a_descriptive_bag_carrying_form() -> None: + product = ProductReference( + style_name="瑞白", + spu_id="10033366316587", + product_title="光影行星(GYXX)瑞白双肩包男女休闲通勤旅行背包", + color_label="白色", + ) + card_text = ( + "光影行星(GYXX)瑞白双肩包男女休闲通勤旅行背包 " + "星尘白 适合16寸以内轻薄包 售价¥429.00 " + "光影行星 GYXX官方旗舰店" + ) + + assert upload_video_to_jd.JdVideoUploader._product_card_style_store_matches( + card_text=card_text, + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + + +@pytest.mark.parametrize( + "conflicting_title", + ("瑞白2双肩包", "瑞白air双肩包", "瑞白双肩包2", "瑞白双肩包升级版"), +) +def test_bare_style_rejects_model_after_series_or_carrying_form( + conflicting_title: str, +) -> None: + product = ProductReference( + style_name="瑞白", + spu_id="10033366316587", + product_title="瑞白双肩包", + color_label="白色", + ) + card_text = ( + f"光影行星(GYXX){conflicting_title} 白色 " + "光影行星 GYXX官方旗舰店" + ) + + assert not upload_video_to_jd.JdVideoUploader._product_card_style_store_matches( + card_text=card_text, + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + + +@pytest.mark.parametrize( + ("style_name", "product_title", "conflicting_title"), + ( + ("凌云air", "凌云air双肩包", "凌云air二代双肩包"), + ("极星双肩2", "极星双肩2双肩包", "极星双肩背包2升级版"), + ("盖世m1", "盖世m1健身包", "盖世m1二代健身包"), + ), +) +def test_style_gate_requires_symmetric_edition_flags( + style_name: str, + product_title: str, + conflicting_title: str, +) -> None: + product = ProductReference( + style_name=style_name, + spu_id="spu", + product_title=product_title, + color_label="黑色", + ) + card_text = ( + f"光影行星(GYXX){conflicting_title} 黑色 " + "光影行星 GYXX官方旗舰店" + ) + + assert not upload_video_to_jd.JdVideoUploader._product_card_style_store_matches( + card_text=card_text, + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + + +@pytest.mark.parametrize( + ("style_name", "product_title", "conflicting_title"), + ( + ("极星双肩2", "极星双肩2双肩包", "极星双肩背包2.0"), + ("极星双肩2", "极星双肩2双肩包", "极星双肩背包2-Pro"), + ("凌云air", "凌云air双肩包", "凌云air-Pro双肩包"), + ("盖世m1", "盖世m1健身包", "盖世M1.1健身包"), + ), +) +def test_punctuated_longer_model_token_does_not_match_requested_model( + style_name: str, + product_title: str, + conflicting_title: str, +) -> None: + product = ProductReference( + style_name=style_name, + spu_id="spu", + product_title=product_title, + color_label="黑色", + ) + card_text = ( + f"光影行星(GYXX){conflicting_title} 黑色 " + "光影行星 GYXX官方旗舰店" + ) + + assert not upload_video_to_jd.JdVideoUploader._product_card_style_store_matches( + card_text=card_text, + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + + +@pytest.mark.parametrize( + "conflicting_style", + ( + "极星双肩", + "极星双肩3", + "极星双肩包3", + "极星双肩包20", + "极星双肩包2pro", + "极星双肩背包", + "极星双肩背包3", + "极星双肩背包20", + "极星双肩背包2pro", + "极星2双肩包", + ), +) +def test_inserted_bag_alias_preserves_exact_model_suffix( + conflicting_style: str, +) -> None: + product = ProductReference( + style_name="极星双肩2", + spu_id="10000000000002", + product_title="光影行星(GYXX)极星双肩包2男女电脑商务通勤背包", + color_label="黑色", + ) + card_text = ( + f"光影行星(GYXX){conflicting_style}男女电脑商务通勤背包 黑色\n" + "光影行星 GYXX官方旗舰店" + ) + + assert not upload_video_to_jd.JdVideoUploader._product_card_style_store_matches( + card_text=card_text, + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + assert not upload_video_to_jd.JdVideoUploader._product_card_matches( + card_text=card_text, + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + + +def test_inserted_bag_alias_keeps_store_color_and_version_gates() -> None: + product = ProductReference( + style_name="极星双肩2", + spu_id="10000000000002", + product_title="光影行星(GYXX)极星双肩包2男女电脑商务通勤背包", + color_label="黑色", + ) + card_text = ( + "光影行星(GYXX)极星双肩包2男女电脑商务通勤背包 黑色\n" + "光影行星 GYXX官方旗舰店" + ) + matcher = upload_video_to_jd.JdVideoUploader._product_card_matches + style_store_matcher = ( + upload_video_to_jd.JdVideoUploader._product_card_style_store_matches + ) + + assert not matcher( + card_text=card_text.replace("黑色", "白色"), + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + assert not style_store_matcher( + card_text=card_text.replace("官方旗舰店", "专营店"), + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + assert not style_store_matcher( + card_text=card_text.replace("极星双肩包2", "极星双肩包2扩展版"), + product=product, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + + +def test_own_store_card_matching_keeps_standard_and_expansion_editions_separate() -> None: + standard = ProductReference( + style_name="盖世m1", + spu_id="10026326257225", + product_title="光影行星(GYXX)盖世M1健身包男士运动训练多功能斜挎包", + color_label="黑色", + ) + expansion = ProductReference( + style_name="盖世m1", + spu_id="10029721108947", + product_title="光影行星(GYXX)盖世M1扩展版健身包", + color_label="黑色", + ) + standard_card = "盖世M1健身包 黑色 光影行星 GYXX官方旗舰店" + expansion_card = "盖世M1扩展版健身包 黑色 光影行星 GYXX官方旗舰店" + matcher = upload_video_to_jd.JdVideoUploader._product_card_style_store_matches + + assert matcher( + card_text=standard_card, + product=standard, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + assert not matcher( + card_text=expansion_card, + product=standard, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + assert matcher( + card_text=expansion_card, + product=expansion, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + assert not matcher( + card_text=standard_card, + product=expansion, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + + +def test_active_product_dialog_ignores_an_unrelated_last_portal() -> None: + class Dialog: + def __init__(self, texts: set[str]) -> None: + self.texts = texts + + def is_visible(self): + return True + + def get_by_text(self, text, **_kwargs): + return SimpleNamespace(count=lambda: int(text in self.texts)) + + product_dialog = Dialog({"关联商品", "本店商品"}) + unrelated_last_portal = Dialog({"消息提示"}) + dialogs = SimpleNamespace( + count=lambda: 2, + nth=lambda index: (product_dialog, unrelated_last_portal)[index], + ) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._require_page = lambda: SimpleNamespace(wait_for_timeout=lambda _ms: None) + uploader._require_surface = lambda: SimpleNamespace( + get_by_role=lambda role: dialogs if role == "dialog" else pytest.fail(role) + ) + + assert uploader._active_product_dialog() is product_dialog + + +def test_own_store_search_uses_new_input_after_enter_replaces_portal() -> None: + events: list[str] = [] + portal_generation = 0 + + class SearchInput: + def __init__(self, generation: int) -> None: + self.generation = generation + + def is_visible(self): + return True + + def is_enabled(self): + return True + + def fill(self, value, **_kwargs): + assert self.generation == portal_generation, "old portal input was reused" + events.append(f"fill:{self.generation}:{value}") + + def press(self, key, **_kwargs): + nonlocal portal_generation + assert key == "Enter" + assert self.generation == portal_generation, "old portal input was reused" + events.append(f"enter:{self.generation}") + portal_generation += 1 + + inputs = (SearchInput(0), SearchInput(1)) + + class Dialog: + def __init__(self, generation: int) -> None: + self.generation = generation + + def locator(self, selector): + assert selector == upload_video_to_jd.OWN_STORE_SEARCH_SELECTOR + current_input = inputs[self.generation] + return SimpleNamespace( + count=lambda: 1, + nth=lambda _index: current_input, + ) + + dialogs = (Dialog(0), Dialog(1)) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._require_page = lambda: SimpleNamespace(wait_for_timeout=lambda _ms: None) + uploader._active_product_dialog = lambda: dialogs[portal_generation] + + uploader._fill_own_store_search("拾影斜挎相机包") + uploader._fill_own_store_search("拾影") + + assert events == [ + "fill:0:拾影斜挎相机包", + "enter:0", + "fill:1:拾影", + "enter:1", + ] + + +def test_product_result_total_treats_jd_empty_state_as_verified_zero() -> None: + dialog = SimpleNamespace( + inner_text=lambda **_kwargs: "本店商品 未查询到关键词商品 已选0/10" + ) + + assert upload_video_to_jd.JdVideoUploader._product_result_total(dialog) == 0 + + +def test_link_products_keeps_active_own_store_tab_when_search_is_ready() -> None: + events: list[str] = [] + checked = False + ready_search = SimpleNamespace( + is_visible=lambda: True, + is_enabled=lambda: True, + ) + ready_searches = SimpleNamespace( + count=lambda: 1, + nth=lambda _index: ready_search, + ) + checkbox = SimpleNamespace(is_checked=lambda: checked) + checkboxes = SimpleNamespace(count=lambda: 1, first=checkbox) + + class CheckboxLabel: + def is_visible(self): + return True + + def locator(self, selector): + assert selector == 'input[type="checkbox"]' + return checkboxes + + def click(self): + nonlocal checked + checked = True + events.append("select_product") + + checkbox_label = CheckboxLabel() + checkbox_labels = SimpleNamespace( + count=lambda: 1, + nth=lambda _index: checkbox_label, + ) + own_store_tab = SimpleNamespace( + count=lambda: 1, + first=SimpleNamespace(click=lambda: events.append("tab_click")), + ) + confirm_button = SimpleNamespace( + is_visible=lambda: True, + click=lambda: events.append("confirm"), + ) + confirm = SimpleNamespace( + count=lambda: 1, + nth=lambda _index: confirm_button, + ) + + class ProductDialog: + def locator(self, selector): + if selector == upload_video_to_jd.OWN_STORE_SEARCH_SELECTOR: + return ready_searches + if selector == upload_video_to_jd.OWN_STORE_PRODUCT_CHECKBOX_SELECTOR: + return checkbox_labels + raise AssertionError(selector) + + def get_by_text(self, text, **_kwargs): + assert text == "本店商品" + return own_store_tab + + def get_by_role(self, role, **_kwargs): + assert role == "button" + return confirm + + def inner_text(self, **_kwargs): + return f"共 1 条 已选{int(checked)}/10" + + def wait_for(self, **_kwargs): + assert _kwargs == {"state": "hidden", "timeout": 20_000} + + dialog = ProductDialog() + association_heading = SimpleNamespace(wait_for=lambda **_kwargs: None) + main_form_card = SimpleNamespace(is_visible=lambda: True) + main_form_cards = SimpleNamespace(count=lambda: 1, nth=lambda _index: main_form_card) + one_counter = SimpleNamespace(count=lambda: 1, nth=lambda _index: main_form_card) + + def surface_text(text, **_kwargs): + if text == "关联商品": + return association_heading + if text == "1/10": + return one_counter + pytest.fail(text) + + surface = SimpleNamespace( + get_by_text=surface_text, + locator=lambda selector: ( + main_form_cards if selector == ".goods-area" else pytest.fail(selector) + ), + ) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.expected_store_name = "光影行星 GYXX官方旗舰店" + uploader._require_page = lambda: SimpleNamespace(wait_for_timeout=lambda _ms: None) + uploader._require_surface = lambda: surface + uploader._click_product_add = lambda: events.append("open_products") + uploader._active_product_dialog = lambda: dialog + uploader._fill_own_store_search = lambda term: events.append(f"search:{term}") + uploader._product_card_text = lambda _label: ( + "光影行星(GYXX)星云mini斜挎包 潜影黑\n" + "光影行星 GYXX官方旗舰店" + ) + image_url = "https://m.360buyimg.com/xingyun-mini.jpg" + uploader._product_card_image_url = lambda _label: image_url + + def classify(matching, *, video_path, cover_path): + key = uploader._product_card_identity(matching[0][1], image_url) + return ( + [(matching[0][0], matching[0][1], key)], + { + "matcher": "test", + "video_path": str(video_path), + "cover_path": str(cover_path), + "selected_candidate_keys": [key], + }, + ) + + uploader._select_product_card_with_direct_model = classify + product = ProductReference( + style_name="星云mini", + spu_id="10032211469888", + product_title="星云mini", + color_label="潜影黑", + ) + + evidence = uploader.link_products( + [product], + video_path=Path("video.mp4"), + cover_path=Path("cover.jpg"), + ) + + assert "tab_click" not in events + assert events[0] == "open_products" + assert events.count("search:星云mini") == 1 + assert events.count("select_product") == 1 + assert events[-1] == "confirm" + assert evidence[0]["search_style"] == "星云mini" + assert evidence[0]["vision"]["matcher"] == "test" + assert evidence[0]["vision"]["cover_path"] == "cover.jpg" + + +def test_prepare_publication_passes_cover_to_product_linking() -> None: + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + video_path = Path("video.mp4") + cover_path = Path("cover.jpg") + product = ProductReference( + style_name="凌云air", + spu_id="10000000000000", + product_title="凌云air通勤双肩包", + color_label="白色", + ) + linked: dict[str, object] = {} + uploader.open_video_form = lambda: None + uploader.dismiss_nps_overlay = lambda: "" + uploader.preflight_own_store_access = lambda: None + uploader.upload_video = lambda path, *, heartbeat: heartbeat() + uploader.set_cover = lambda path: {"path": str(path)} + uploader.fill_title = lambda _title: None + + def link_products(products, *, video_path, cover_path): + linked.update( + { + "products": tuple(products), + "video_path": video_path, + "cover_path": cover_path, + } + ) + return [{"matcher": "test"}] + + uploader.link_products = link_products + uploader.add_best_topic = lambda _names: "#通勤双肩包" + uploader.select_tags = lambda _interest_tags, _form_tag: None + uploader.choose_immediate_publish = lambda: None + uploader._screenshot = lambda *_args, **_kwargs: "prepared.png" + + evidence = uploader.prepare_publication( + video_path=video_path, + cover_path=cover_path, + title="日常通勤双肩包收纳更省心", + products=[product], + product_names=["凌云air"], + interest_tags=["通勤"], + form_tag="双肩包", + heartbeat=lambda: None, + ) + + assert linked == { + "products": (product,), + "video_path": video_path, + "cover_path": cover_path, + } + assert evidence["products"] == [{"matcher": "test"}] + + +def test_prepare_publication_keeps_jd_default_cover_when_source_has_none() -> None: + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + product = ProductReference( + style_name="黑曜石托特", + spu_id=None, + product_title="黑曜石托特包", + color_label="未知", + ) + events: list[str] = [] + uploader.open_video_form = lambda: None + uploader.dismiss_nps_overlay = lambda: "" + uploader.preflight_own_store_access = lambda: None + uploader.upload_video = lambda _path, *, heartbeat: heartbeat() + uploader.set_cover = lambda _path: pytest.fail("must keep JD default cover") + uploader.fill_title = lambda _title: None + uploader.link_products = lambda *_args, **_kwargs: [{"matcher": "test"}] + uploader.add_best_topic = lambda _names: "#托特包" + uploader.select_tags = lambda _interest, _form: None + uploader.choose_immediate_publish = lambda: events.append("publish-mode") + uploader._screenshot = lambda *_args, **_kwargs: "prepared.png" + + evidence = uploader.prepare_publication( + video_path=Path("video.mp4"), + cover_path=None, + title="托特包上身看看容量", + products=[product], + product_names=["黑曜石托特"], + interest_tags=["通勤"], + form_tag="产品展示", + heartbeat=lambda: None, + ) + + assert evidence["cover"] == { + "mode": "jd_default_generated", + "source": "uploaded_video", + } + assert events == ["publish-mode"] + + +def test_link_products_selects_all_five_shiying_single_shoulder_cards() -> None: + events: list[str] = [] + + class Checkbox: + def __init__(self, label) -> None: + self.label = label + + def is_checked(self) -> bool: + return self.label.checked + + class Label: + def __init__(self, name: str, *, double_shoulder: bool = False) -> None: + shoulder = ( + "双肩包男士新款轻便通勤背包女士电脑包旅行书包" + if double_shoulder + else name + ) + self.text = ( + f"光影行星(GYXX)拾影{shoulder}\n" + "售价 ¥569.00\n光影行星 GYXX官方旗舰店" + ) + self.url = f"https://m.360buyimg.com/{name}.jpg" + self.checked = False + self.checkbox = Checkbox(self) + self.name = name + + def is_visible(self) -> bool: + return True + + def locator(self, selector: str): + assert selector == 'input[type="checkbox"]' + return SimpleNamespace(count=lambda: 1, first=self.checkbox) + + def click(self) -> None: + assert not self.checked + self.checked = True + events.append(f"select:{self.name}") + + labels = [ + Label("斜挎包单反相机包男女款A"), + Label("斜挎包单反相机包男女款B"), + Label("斜挎包单反相机包男女款C"), + Label("斜挎包单反相机包男女款D"), + Label("双肩包女士新款轻便通勤款E"), + Label("double", double_shoulder=True), + ] + checkbox_labels = SimpleNamespace( + count=lambda: len(labels), + nth=lambda index: labels[index], + ) + own_store_tab = SimpleNamespace(count=lambda: 1) + confirm_button = SimpleNamespace( + is_visible=lambda: True, + click=lambda: events.append("confirm"), + ) + confirm = SimpleNamespace(count=lambda: 1, nth=lambda _index: confirm_button) + + class ProductDialog: + def locator(self, selector: str): + assert selector == upload_video_to_jd.OWN_STORE_PRODUCT_CHECKBOX_SELECTOR + return checkbox_labels + + def get_by_text(self, text: str, **_kwargs): + assert text == "本店商品" + return own_store_tab + + def get_by_role(self, role: str, **_kwargs): + assert role == "button" + return confirm + + def inner_text(self, **_kwargs) -> str: + checked_count = sum(label.checked for label in labels) + return f"本店商品 共 6 条 已选{checked_count}/10" + + def wait_for(self, **_kwargs) -> None: + assert _kwargs == {"state": "hidden", "timeout": 20_000} + + dialog = ProductDialog() + association_heading = SimpleNamespace(wait_for=lambda **_kwargs: None) + visible_card = SimpleNamespace(is_visible=lambda: True) + form_cards = SimpleNamespace( + count=lambda: 5, + nth=lambda _index: visible_card, + ) + five_counter = SimpleNamespace(count=lambda: 1, nth=lambda _index: visible_card) + + def surface_text(text, **_kwargs): + if text == "关联商品": + return association_heading + if text == "5/10": + return five_counter + pytest.fail(text) + + surface = SimpleNamespace( + get_by_text=surface_text, + locator=lambda selector: ( + form_cards if selector == ".goods-area" else pytest.fail(selector) + ), + ) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.expected_store_name = "光影行星 GYXX官方旗舰店" + uploader._require_page = lambda: SimpleNamespace(wait_for_timeout=lambda _ms: None) + uploader._require_surface = lambda: surface + uploader._click_product_add = lambda: events.append("open_products") + uploader._active_product_dialog = lambda: dialog + uploader._fill_own_store_search = lambda term: events.append(f"search:{term}") + uploader._product_card_text = lambda label: label.text + uploader._product_card_image_url = lambda label: label.url + uploader._select_product_card_with_direct_model = lambda *_args, **_kwargs: pytest.fail( + "拾影同款多选不得再调用粗颜色单选" + ) + uploader._screenshot = lambda *_args, **_kwargs: "evidence.png" + product = ProductReference( + style_name="拾影斜挎相机包", + spu_id="10035423416854", + product_title="拾影", + color_label="曜夜黑", + ) + + evidence = uploader.link_products([product], video_path=Path("video.mp4")) + + assert [label.checked for label in labels] == [True, True, True, True, True, False] + assert events[0] == "open_products" + assert events.count("search:拾影斜挎相机包") == 1 + assert { + "select:斜挎包单反相机包男女款A", + "select:斜挎包单反相机包男女款B", + "select:斜挎包单反相机包男女款C", + "select:斜挎包单反相机包男女款D", + "select:双肩包女士新款轻便通勤款E", + }.issubset(events) + assert events[-1] == "confirm" + assert evidence[0]["eligible_card_count"] == 5 + assert len(evidence[0]["selected_card_texts"]) == 5 + assert evidence[0]["vision"]["selected_count"] == 5 + assert evidence[0]["vision"]["verified_total_count"] == 5 + assert ( + evidence[0]["vision"]["explicitly_excluded_style"] + == "拾影双肩包男士通勤背包" + ) + + +def _shiying_multiselect_fixture( + *, + eligible_count: int = 5, + checked_eligible: bool = False, + checked_excluded: bool = False, + marker_override: int | None = None, +): + events: list[str] = [] + + class Label: + def __init__(self, key: str, text: str, *, checked: bool) -> None: + self.key = key + self.text = text + self.checked = checked + + def click(self) -> None: + self.checked = not self.checked + events.append(self.key) + + product = ProductReference( + style_name="拾影斜挎相机包", + spu_id="10035423416854", + product_title="拾影", + color_label="曜夜黑", + ) + labels: dict[str, Label] = {} + for index in range(eligible_count): + key = f"eligible-{index}" + style_text = ( + "拾影双肩包女士新款轻便通勤" + if index == eligible_count - 1 + else f"拾影斜挎相机包款式{index}" + ) + labels[key] = Label( + key, + ( + f"光影行星(GYXX){style_text}\n" + "光影行星 GYXX官方旗舰店" + ), + checked=checked_eligible, + ) + for index in range(6 - eligible_count): + key = f"excluded-{index}" + labels[key] = Label( + key, + ( + "光影行星(GYXX)拾影双肩包男士新款轻便通勤背包" + f"女士电脑包旅行书包款式{index}\n" + "光影行星 GYXX官方旗舰店" + ), + checked=checked_excluded, + ) + + def snapshot(): + return { + key: ( + label, + label.text, + f"https://img/{key}.jpg", + object(), + label.checked, + ) + for key, label in labels.items() + } + + class Dialog: + def inner_text(self, **_kwargs) -> str: + selected = ( + marker_override + if marker_override is not None + else sum(label.checked for label in labels.values()) + ) + return f"已选{selected}/10" + + dialog = Dialog() + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.expected_store_name = "光影行星 GYXX官方旗舰店" + uploader._require_page = lambda: SimpleNamespace(wait_for_timeout=lambda _ms: None) + uploader._active_product_dialog = lambda: dialog + uploader._product_card_snapshot = lambda _dialog: snapshot() + uploader._screenshot = lambda *_args, **_kwargs: "evidence.png" + return SimpleNamespace( + uploader=uploader, + product=product, + labels=labels, + events=events, + baseline=snapshot(), + ) + + +def test_shiying_multiselect_fails_closed_when_card_dom_drifts() -> None: + fixture = _shiying_multiselect_fixture() + stable_snapshot = fixture.uploader._product_card_snapshot + + def drifting_snapshot(dialog): + current = stable_snapshot(dialog) + if fixture.events: + replaced = current.pop("eligible-4") + current["eligible-replaced"] = replaced + return current + + fixture.uploader._product_card_snapshot = drifting_snapshot + + with pytest.raises(ProductMappingError, match="商品卡片已发生变化"): + fixture.uploader._select_shiying_single_shoulder_cards( + product=fixture.product, + baseline=fixture.baseline, + already_selected_count=0, + ) + + +def test_shiying_multiselect_requires_verified_selected_count() -> None: + fixture = _shiying_multiselect_fixture(marker_override=0) + + with pytest.raises(ProductMappingError, match="已选计数不一致"): + fixture.uploader._select_shiying_single_shoulder_cards( + product=fixture.product, + baseline=fixture.baseline, + already_selected_count=0, + ) + + +def test_shiying_multiselect_requires_exactly_six_search_results() -> None: + fixture = _shiying_multiselect_fixture() + incomplete = dict(fixture.baseline) + incomplete.pop("excluded-0") + + with pytest.raises(ProductMappingError, match="必须恰好有 6 张"): + fixture.uploader._select_shiying_single_shoulder_cards( + product=fixture.product, + baseline=incomplete, + already_selected_count=0, + ) + + +def test_shiying_multiselect_replaces_preselected_backpack_with_five_targets() -> None: + fixture = _shiying_multiselect_fixture(checked_excluded=True) + + selected_texts, evidence = ( + fixture.uploader._select_shiying_single_shoulder_cards( + product=fixture.product, + baseline=fixture.baseline, + already_selected_count=0, + ) + ) + + assert fixture.events[0] == "excluded-0" + assert set(fixture.events[1:]) == { + f"eligible-{index}" for index in range(5) + } + assert all( + label.checked + for key, label in fixture.labels.items() + if key.startswith("eligible-") + ) + assert fixture.labels["excluded-0"].checked is False + assert len(selected_texts) == 5 + assert evidence["removed_candidate_keys"] == ["excluded-0"] + assert set(evidence["added_candidate_keys"]) == { + f"eligible-{index}" for index in range(5) + } + assert evidence["selection_changed"] is True + assert evidence["verified_total_count"] == 5 + + +def test_shiying_multiselect_is_noop_when_exact_five_are_already_selected() -> None: + fixture = _shiying_multiselect_fixture(checked_eligible=True) + + selected_texts, evidence = ( + fixture.uploader._select_shiying_single_shoulder_cards( + product=fixture.product, + baseline=fixture.baseline, + already_selected_count=0, + ) + ) + + assert fixture.events == [] + assert len(selected_texts) == 5 + assert evidence["removed_candidate_keys"] == [] + assert evidence["added_candidate_keys"] == [] + assert evidence["selection_changed"] is False + assert evidence["verified_total_count"] == 5 + + +def test_shiying_multiselect_fails_closed_when_only_four_targets_are_identified() -> None: + fixture = _shiying_multiselect_fixture(eligible_count=4) + + with pytest.raises(ProductMappingError, match="必须恰好识别 5 张"): + fixture.uploader._select_shiying_single_shoulder_cards( + product=fixture.product, + baseline=fixture.baseline, + already_selected_count=0, + ) + + assert fixture.events == [] + + +def test_link_products_selects_all_same_style_store_and_coarse_color_cards() -> None: + events: list[str] = [] + + class Checkbox: + def __init__(self, label) -> None: + self.label = label + + def is_checked(self) -> bool: + return self.label.checked + + class Label: + def __init__(self, name: str, color: str) -> None: + self.name = name + self.color = color + self.checked = False + self.text = ( + f"光影行星(GYXX)星云mini斜挎包 {name} {color}\n" + "光影行星 GYXX官方旗舰店" + ) + self.url = f"https://m.360buyimg.com/{name}.jpg" + self.checkbox = Checkbox(self) + + def is_visible(self) -> bool: + return True + + def locator(self, selector: str): + assert selector == 'input[type="checkbox"]' + return SimpleNamespace(count=lambda: 1, first=self.checkbox) + + def click(self) -> None: + self.checked = not self.checked + events.append(f"toggle:{self.name}") + + labels = [ + Label("black-a", "black"), + Label("black-b", "black"), + Label("white", "white"), + ] + checkbox_labels = SimpleNamespace( + count=lambda: len(labels), + nth=lambda index: labels[index], + ) + own_store_tab = SimpleNamespace(count=lambda: 1) + confirm_button = SimpleNamespace( + is_visible=lambda: True, + click=lambda: events.append("confirm"), + ) + + class ProductDialog: + def get_by_text(self, text, **_kwargs): + assert text == "本店商品" + return own_store_tab + + def get_by_role(self, role, **_kwargs): + assert role == "button" + return SimpleNamespace(count=lambda: 1, nth=lambda _index: confirm_button) + + def locator(self, selector): + assert selector == upload_video_to_jd.OWN_STORE_PRODUCT_CHECKBOX_SELECTOR + return checkbox_labels + + def inner_text(self, **_kwargs) -> str: + checked_count = sum(label.checked for label in labels) + return f"本店商品 共 3 条 已选{checked_count}/10" + + def wait_for(self, **_kwargs) -> None: + assert _kwargs == {"state": "hidden", "timeout": 20_000} + + dialog = ProductDialog() + visible_card = SimpleNamespace(is_visible=lambda: True) + association_heading = SimpleNamespace(wait_for=lambda **_kwargs: None) + + def surface_text(text, **_kwargs): + if text == "关联商品": + return association_heading + if text == "2/10": + return SimpleNamespace(count=lambda: 1, nth=lambda _index: visible_card) + pytest.fail(text) + + surface = SimpleNamespace( + get_by_text=surface_text, + locator=lambda selector: ( + SimpleNamespace(count=lambda: 2, nth=lambda _index: visible_card) + if selector == ".goods-area" + else pytest.fail(selector) + ), + ) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.expected_store_name = "光影行星 GYXX官方旗舰店" + uploader._require_page = lambda: SimpleNamespace(wait_for_timeout=lambda _ms: None) + uploader._require_surface = lambda: surface + uploader._click_product_add = lambda: events.append("open") + uploader._active_product_dialog = lambda: dialog + uploader._fill_own_store_search = lambda term: events.append(f"search:{term}") + uploader._product_card_text = lambda label: label.text + uploader._product_card_image_url = lambda label: label.url + uploader._screenshot = lambda *_args, **_kwargs: "evidence.png" + + def classify(matching, **_kwargs): + selected = [] + colors = {} + for label, text in matching: + key = uploader._product_card_identity(text, label.url) + colors[key] = label.color + if label.color == "black": + selected.append((label, text, key)) + return selected, { + "matcher": "test-coarse-color", + "source_color": "black", + "candidate_colors": colors, + "selected_candidate_keys": [item[2] for item in selected], + } + + uploader._select_product_card_with_direct_model = classify + product = ProductReference( + style_name="星云mini", + spu_id="10032211469888", + product_title="星云mini", + color_label="潜影黑", + ) + + evidence = uploader.link_products([product], video_path=Path("video.mp4")) + + assert [label.checked for label in labels] == [True, True, False] + assert {event for event in events if event.startswith("toggle:")} == { + "toggle:black-a", + "toggle:black-b", + } + assert events[-1] == "confirm" + assert len(evidence[0]["selected_card_texts"]) == 2 + assert len(evidence[0]["vision"]["selected_candidate_keys"]) == 2 + assert evidence[0]["vision"]["verified_total_count"] == 2 + + +def _generic_multiselect_capacity_fixture( + *, + card_count: int, + desired_count: int, + form_count: int | None = None, + result_total: int | None = None, +): + events: list[str] = [] + + class Checkbox: + def __init__(self, label) -> None: + self.label = label + + def is_checked(self) -> bool: + return self.label.checked + + class Label: + def __init__(self, index: int) -> None: + self.index = index + self.checked = False + self.text = ( + f"光影行星(GYXX)极星2双肩包 候选{index} 黑色\n" + "光影行星 GYXX官方旗舰店" + ) + self.url = f"https://m.360buyimg.com/jixing-{index}.jpg" + self.checkbox = Checkbox(self) + + def is_visible(self) -> bool: + return True + + def locator(self, selector: str): + assert selector == 'input[type="checkbox"]' + return SimpleNamespace(count=lambda: 1, first=self.checkbox) + + def click(self) -> None: + self.checked = not self.checked + events.append(f"toggle:{self.index}") + + labels = [Label(index) for index in range(card_count)] + checkbox_labels = SimpleNamespace( + count=lambda: len(labels), + nth=lambda index: labels[index], + ) + confirm_button = SimpleNamespace( + is_visible=lambda: True, + click=lambda: events.append("confirm"), + ) + + class Dialog: + @staticmethod + def get_by_text(text, **_kwargs): + assert text == "本店商品" + return SimpleNamespace(count=lambda: 1) + + @staticmethod + def get_by_role(role, **_kwargs): + assert role == "button" + return SimpleNamespace(count=lambda: 1, nth=lambda _index: confirm_button) + + @staticmethod + def locator(selector): + assert selector == upload_video_to_jd.OWN_STORE_PRODUCT_CHECKBOX_SELECTOR + return checkbox_labels + + @staticmethod + def inner_text(**_kwargs) -> str: + checked = sum(label.checked for label in labels) + return f"共 {result_total or card_count} 条 已选{checked}/10" + + @staticmethod + def wait_for(**_kwargs) -> None: + assert _kwargs == {"state": "hidden", "timeout": 20_000} + + dialog = Dialog() + expected_form_count = desired_count if form_count is None else form_count + visible = SimpleNamespace(is_visible=lambda: True) + association = SimpleNamespace(wait_for=lambda **_kwargs: None) + + def surface_text(text, **_kwargs): + if text == "关联商品": + return association + if text == f"{expected_form_count}/10": + return SimpleNamespace(count=lambda: 1, nth=lambda _index: visible) + return SimpleNamespace(count=lambda: 0, nth=lambda _index: visible) + + surface = SimpleNamespace( + get_by_text=surface_text, + locator=lambda selector: ( + SimpleNamespace( + count=lambda: expected_form_count, + nth=lambda _index: visible, + ) + if selector == ".goods-area" + else pytest.fail(selector) + ), + ) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.expected_store_name = "光影行星 GYXX官方旗舰店" + uploader._require_page = lambda: SimpleNamespace(wait_for_timeout=lambda _ms: None) + uploader._require_surface = lambda: surface + uploader._click_product_add = lambda: events.append("open") + uploader._active_product_dialog = lambda: dialog + uploader._fill_own_store_search = lambda term: events.append(f"search:{term}") + uploader._product_card_text = lambda label: label.text + uploader._product_card_image_url = lambda label: label.url + uploader._screenshot = lambda *_args, **_kwargs: "evidence.png" + + def classify(matching, **_kwargs): + selected = [] + colors = {} + for label, text in matching: + key = uploader._product_card_identity(text, label.url) + colors[key] = "black" + if label.index < desired_count: + selected.append((label, text, key)) + return selected, { + "matcher": "test-coarse-color", + "source_color": "black", + "candidate_colors": colors, + "selected_candidate_keys": [item[2] for item in selected], + } + + uploader._select_product_card_with_direct_model = classify + product = ProductReference( + style_name="极星2", + spu_id="10000000000022", + product_title="极星2双肩包", + color_label="黑色", + ) + return SimpleNamespace( + uploader=uploader, + product=product, + labels=labels, + events=events, + ) + + +def test_link_products_falls_back_after_verified_empty_gaia_series_search() -> None: + fixture = _generic_multiselect_capacity_fixture( + card_count=1, + desired_count=1, + ) + current_search = "" + real_snapshot = upload_video_to_jd.JdVideoUploader._product_card_snapshot + + def fill_search(term: str) -> None: + nonlocal current_search + current_search = term + fixture.events.append(f"search:{term}") + + def snapshot(dialog): + if current_search == "盖亚斜跨": + return {} + return real_snapshot(fixture.uploader, dialog) + + fixture.uploader._fill_own_store_search = fill_search + fixture.uploader._product_card_snapshot = snapshot + fixture.uploader._product_result_total = lambda _dialog: ( + 0 if current_search == "盖亚斜跨" else 1 + ) + fixture.uploader._product_card_style_store_matches = lambda **_kwargs: True + fixture.product = ProductReference( + style_name="盖亚斜跨3", + spu_id="10000000000033", + product_title="盖亚斜跨3", + color_label="黑色", + ) + + evidence = fixture.uploader.link_products( + [fixture.product], + video_path=Path("video.mp4"), + ) + + assert fixture.events[:2] == [ + "open", + "search:盖亚斜跨", + ] + assert "search:盖亚斜挎" in fixture.events + assert evidence[0]["search_style"] == "盖亚斜挎" + + +def test_link_products_accepts_exactly_ten_color_matched_cards() -> None: + fixture = _generic_multiselect_capacity_fixture( + card_count=10, + desired_count=10, + ) + + evidence = fixture.uploader.link_products( + [fixture.product], + video_path=Path("video.mp4"), + ) + + assert all(label.checked for label in fixture.labels) + assert sum(event.startswith("toggle:") for event in fixture.events) == 10 + assert evidence[0]["vision"]["verified_total_count"] == 10 + + +def test_link_products_accepts_complete_first_page_when_total_exceeds_ten() -> None: + fixture = _generic_multiselect_capacity_fixture( + card_count=10, + desired_count=4, + result_total=16, + ) + + evidence = fixture.uploader.link_products( + [fixture.product], + video_path=Path("video.mp4"), + ) + + assert sum(label.checked for label in fixture.labels) == 4 + assert evidence[0]["result_total"] == 16 + assert evidence[0]["result_page_truncated"] is True + + +def test_link_products_rejects_eleven_card_union_before_any_checkbox_click() -> None: + fixture = _generic_multiselect_capacity_fixture( + card_count=11, + desired_count=11, + ) + + with pytest.raises(ProductMappingError, match="并集为 11 个"): + fixture.uploader.link_products( + [fixture.product], + video_path=Path("video.mp4"), + ) + + assert not any(event.startswith("toggle:") for event in fixture.events) + assert "confirm" not in fixture.events + + +def test_link_products_deduplicates_same_card_keys_across_duplicate_plan() -> None: + fixture = _generic_multiselect_capacity_fixture( + card_count=2, + desired_count=2, + ) + + evidence = fixture.uploader.link_products( + [fixture.product, fixture.product], + video_path=Path("video.mp4"), + ) + + assert sum(event.startswith("toggle:") for event in fixture.events) == 2 + assert len(evidence) == 2 + assert {item["vision"]["verified_total_count"] for item in evidence} == {2} + + +def test_link_products_deduplicates_same_style_with_different_audit_spu() -> None: + fixture = _generic_multiselect_capacity_fixture( + card_count=2, + desired_count=2, + ) + conflicting = ProductReference( + style_name=fixture.product.style_name, + spu_id="different-spu", + product_title=fixture.product.product_title, + color_label=fixture.product.color_label, + ) + + evidence = fixture.uploader.link_products( + [fixture.product, conflicting], + video_path=Path("video.mp4"), + ) + + assert sum(event.startswith("toggle:") for event in fixture.events) == 2 + assert len(evidence) == 2 + + +def test_link_products_rechecks_complete_dom_before_first_checkbox_click() -> None: + fixture = _generic_multiselect_capacity_fixture( + card_count=2, + desired_count=2, + ) + image_reads = 0 + + def search(term: str) -> None: + fixture.events.append(f"search:{term}") + + fixture.uploader._fill_own_store_search = search + + def drifting_image(label) -> str: + nonlocal image_reads + image_reads += 1 + return ( + label.url + if image_reads <= len(fixture.labels) + else label.url.replace(".jpg", "-replaced.jpg") + ) + + fixture.uploader._product_card_image_url = drifting_image + + with pytest.raises(ProductMappingError, match="DOM 已发生变化"): + fixture.uploader.link_products( + [fixture.product], + video_path=Path("video.mp4"), + ) + + assert not any(event.startswith("toggle:") for event in fixture.events) + + +def test_product_card_identity_ignores_price_and_jd_cdn_rendition() -> None: + first = upload_video_to_jd.JdVideoUploader._product_card_identity( + "极星双肩背包2 幻迹黑 售价¥579.00 光影行星 GYXX官方旗舰店", + "https://m.360buyimg.com/ceco/jfs/t1/example/item.jpg?width=800", + ) + refreshed = upload_video_to_jd.JdVideoUploader._product_card_identity( + "极星双肩背包2 幻迹黑 售价 ¥479.00 光影行星 gyxx官方旗舰店", + "https://img10.360buyimg.com/n1/jfs/t1/example/item.jpg.webp?quality=90", + ) + + assert first == refreshed + + +def test_link_products_allows_async_price_refresh_before_click() -> None: + fixture = _generic_multiselect_capacity_fixture( + card_count=1, + desired_count=1, + ) + text_reads = 0 + + def search(term: str) -> None: + fixture.events.append(f"search:{term}") + + fixture.uploader._fill_own_store_search = search + + def refreshing_text(label) -> str: + nonlocal text_reads + text_reads += 1 + return f"{label.text} 售价¥{'579.00' if text_reads == 1 else '479.00'}" + + fixture.uploader._product_card_text = refreshing_text + + evidence = fixture.uploader.link_products( + [fixture.product], + video_path=Path("video.mp4"), + ) + + assert fixture.labels[0].checked is True + assert evidence[0]["vision"]["verified_total_count"] == 1 + + +def test_link_products_waits_for_temporarily_incomplete_same_search_dom() -> None: + fixture = _generic_multiselect_capacity_fixture( + card_count=2, + desired_count=2, + ) + original_snapshot = fixture.uploader._product_card_snapshot + snapshot_reads = 0 + + def transient_snapshot(dialog): + nonlocal snapshot_reads + snapshot_reads += 1 + current = original_snapshot(dialog) + if snapshot_reads == 2: + current.pop(sorted(current)[0]) + return current + + fixture.uploader._product_card_snapshot = transient_snapshot + + evidence = fixture.uploader.link_products( + [fixture.product], + video_path=Path("video.mp4"), + ) + + assert all(label.checked for label in fixture.labels) + assert snapshot_reads > 2 + assert evidence[0]["vision"]["verified_total_count"] == 2 + + +def test_link_products_requires_checked_state_after_each_click() -> None: + fixture = _generic_multiselect_capacity_fixture( + card_count=1, + desired_count=1, + ) + + def ineffective_click() -> None: + fixture.events.append("toggle-without-state") + + fixture.labels[0].click = ineffective_click + + with pytest.raises(ProductMappingError, match="点击后的勾选集合未精确生效"): + fixture.uploader.link_products( + [fixture.product], + video_path=Path("video.mp4"), + ) + + assert "confirm" not in fixture.events + + +def test_link_products_requires_n_over_ten_marker_after_each_click() -> None: + fixture = _generic_multiselect_capacity_fixture( + card_count=1, + desired_count=1, + ) + fixture.uploader._selected_product_marker_count = lambda _dialog: 0 + + with pytest.raises(ProductMappingError, match="已选 N/10 计数不一致"): + fixture.uploader.link_products( + [fixture.product], + video_path=Path("video.mp4"), + ) + + assert fixture.labels[0].checked is True + assert "confirm" not in fixture.events + + +def test_product_card_text_prefers_unique_goods_card_root() -> None: + card = SimpleNamespace( + inner_text=lambda **kwargs: ( + "极星双肩包2 黑色\n光影行星 GYXX官方旗舰店" + if kwargs == {"timeout": 1000} + else pytest.fail(kwargs) + ) + ) + cards = SimpleNamespace(count=lambda: 1, first=card) + + class CheckboxLabel: + @staticmethod + def locator(selector: str): + assert selector == upload_video_to_jd.PRODUCT_CARD_CLASS_ROOT_SELECTOR + return cards + + @staticmethod + def evaluate(_script: str): + pytest.fail("known goods-card root must be used before DOM fallback") + + assert upload_video_to_jd.JdVideoUploader._product_card_text( + CheckboxLabel() + ) == "极星双肩包2 黑色\n光影行星 GYXX官方旗舰店" + + +def test_product_card_text_fallback_uses_unique_product_checkbox_scope() -> None: + no_cards = SimpleNamespace(count=lambda: 0) + + class CheckboxLabel: + @staticmethod + def locator(selector: str): + assert selector == upload_video_to_jd.PRODUCT_CARD_CLASS_ROOT_SELECTOR + return no_cards + + @staticmethod + def evaluate(script: str): + assert 'label[data-component-name="Checkbox"]' in script + assert 'input[type="checkbox"]' in script + assert '[data-event-name="checked"]' not in script + assert "node.querySelectorAll(productCheckbox).length === 1" in script + assert "node.querySelector('img')" not in script + return "极星双肩包2 黑色" + + assert ( + upload_video_to_jd.JdVideoUploader._product_card_text(CheckboxLabel()) + == "极星双肩包2 黑色" + ) + + +def test_link_products_fails_closed_when_any_search_card_identity_is_incomplete() -> None: + labels = [ + SimpleNamespace(is_visible=lambda: True), + SimpleNamespace(is_visible=lambda: True), + ] + checkbox_labels = SimpleNamespace( + count=lambda: len(labels), + nth=lambda index: labels[index], + ) + own_store_tab = SimpleNamespace(count=lambda: 1) + + class ProductDialog: + @staticmethod + def get_by_text(text, **_kwargs): + assert text == "本店商品" + return own_store_tab + + @staticmethod + def locator(selector): + assert selector == upload_video_to_jd.OWN_STORE_PRODUCT_CHECKBOX_SELECTOR + return checkbox_labels + + @staticmethod + def inner_text(**_kwargs): + return "共2条" + + dialog = ProductDialog() + surface = SimpleNamespace( + get_by_text=lambda text, **_kwargs: ( + SimpleNamespace(wait_for=lambda **_wait_kwargs: None) + if text == "关联商品" + else pytest.fail(text) + ) + ) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.expected_store_name = "光影行星 GYXX官方旗舰店" + uploader._require_page = lambda: SimpleNamespace(wait_for_timeout=lambda _ms: None) + uploader._require_surface = lambda: surface + uploader._click_product_add = lambda: None + uploader._active_product_dialog = lambda: dialog + uploader._fill_own_store_search = lambda _term: None + card_texts = { + id(labels[0]): "", + id(labels[1]): ( + "光影行星(GYXX)极星双肩包2男女电脑商务通勤背包 黑色 " + "光影行星 GYXX专营店" + "超长描述" * 100 + ), + } + uploader._product_card_text = lambda label: card_texts[id(label)] + uploader._product_card_image_url = lambda label: f"https://img/{id(label)}.jpg" + uploader._screenshot = lambda *_args, **_kwargs: "match-missing.png" + product = ProductReference( + style_name="极星双肩2", + spu_id="10000000000002", + product_title="光影行星(GYXX)极星双肩包2男女电脑商务通勤背包", + color_label="黑色", + ) + + with pytest.raises(ProductMappingError, match="缺少可验证的标题或图片身份"): + uploader.link_products([product]) + + +def test_capture_product_card_image_uses_unique_generic_ancestor( + tmp_path, +) -> None: + destination = tmp_path / "candidate.png" + selectors: list[str] = [] + + class LoadedImage: + @staticmethod + def is_visible() -> bool: + return True + + @staticmethod + def evaluate(script: str) -> bool: + assert "naturalWidth > 0" in script + return True + + @staticmethod + def screenshot(*, path: str) -> None: + Image.new("RGB", (16, 16), "black").save(path) + + images = SimpleNamespace( + count=lambda: 1, + nth=lambda index: LoadedImage() if index == 0 else pytest.fail(index), + ) + card = SimpleNamespace( + locator=lambda selector: ( + images + if selector == ".goods-card-content-left-img img, img" + else pytest.fail(selector) + ) + ) + no_goods_card = SimpleNamespace(count=lambda: 0) + generic_card = SimpleNamespace(count=lambda: 1, first=card) + + class CheckboxLabel: + @staticmethod + def evaluate(script: str) -> int: + assert "node.querySelector('img')" in script + assert "querySelectorAll('input[type=\"checkbox\"]')" in script + assert "getAttribute('role') !== 'dialog'" in script + return 3 + + @staticmethod + def locator(selector: str): + selectors.append(selector) + if selector == upload_video_to_jd.PRODUCT_CARD_CLASS_ROOT_SELECTOR: + return no_goods_card + if selector == "xpath=ancestor::*[3]": + return generic_card + pytest.fail(selector) + + upload_video_to_jd.JdVideoUploader._capture_product_card_image( + CheckboxLabel(), + destination, + ) + + assert destination.is_file() + with Image.open(destination) as captured: + assert captured.size == (16, 16) + assert selectors == [ + upload_video_to_jd.PRODUCT_CARD_CLASS_ROOT_SELECTOR, + "xpath=ancestor::*[3]", + ] + + +@pytest.mark.parametrize("generic_root_count", [0, 2]) +def test_product_card_root_rejects_zero_or_multiple_generic_ancestors( + generic_root_count, +) -> None: + no_goods_card = SimpleNamespace(count=lambda: 0) + generic_cards = SimpleNamespace(count=lambda: generic_root_count) + + class CheckboxLabel: + @staticmethod + def evaluate(_script: str) -> int: + return 2 + + @staticmethod + def locator(selector: str): + if selector == upload_video_to_jd.PRODUCT_CARD_CLASS_ROOT_SELECTOR: + return no_goods_card + if selector == "xpath=ancestor::*[2]": + return generic_cards + pytest.fail(selector) + + with pytest.raises(ProductMappingError, match="无法唯一定位商品卡片"): + upload_video_to_jd.JdVideoUploader._product_card_root(CheckboxLabel()) + + +@pytest.mark.parametrize("visible_image_count", [0, 2]) +def test_capture_product_card_image_rejects_non_unique_loaded_images( + tmp_path, + visible_image_count, +) -> None: + class LoadedImage: + @staticmethod + def is_visible() -> bool: + return True + + @staticmethod + def evaluate(_script: str) -> bool: + return True + + image = LoadedImage() + images = SimpleNamespace( + count=lambda: visible_image_count, + nth=lambda _index: image, + ) + card = SimpleNamespace(locator=lambda _selector: images) + goods_card = SimpleNamespace(count=lambda: 1, first=card) + checkbox_label = SimpleNamespace(locator=lambda _selector: goods_card) + + with pytest.raises(ProductMappingError, match="没有唯一已加载的商品图"): + upload_video_to_jd.JdVideoUploader._capture_product_card_image( + checkbox_label, + tmp_path / "candidate.png", + ) + + +def test_direct_model_product_selection_uses_video_color_and_stable_card_keys( + tmp_path, + monkeypatch, +) -> None: + class Label: + pass + + first = Label() + second = Label() + texts = { + first: "星云mini 潜影黑(考杜拉) 光影行星 GYXX官方旗舰店", + second: "星云mini 潜影黑 光影行星 GYXX官方旗舰店", + } + urls = { + first: "https://m.360buyimg.com/first.jpg", + second: "https://m.360buyimg.com/second.jpg", + } + observed: dict[str, object] = {} + tmp_root = tmp_path / "tmp" + vision_root = tmp_root / "jd-video-upload" / "vision" + monkeypatch.setattr(upload_video_to_jd, "TMP_ROOT", tmp_root) + monkeypatch.setattr(upload_video_to_jd, "VISION_ROOT", vision_root) + + def extract_frames( + video_path, + *, + output_dir, + allowed_output_root, + max_frames, + max_edge, + ): + assert video_path.name == "video.mp4" + assert allowed_output_root == vision_root + assert (max_frames, max_edge) == (2, 1280) + output_dir.mkdir(parents=True, exist_ok=True) + frame_path = output_dir / "frame.jpg" + Image.new("RGB", (12, 12), "black").save(frame_path) + return ( + SimpleNamespace( + path=frame_path, + timestamp_seconds=4.0, + width=12, + height=12, + sha256="a" * 64, + ), + ) + + def capture(label, destination): + destination.parent.mkdir(parents=True, exist_ok=True) + Image.new( + "RGB", + (12, 12), + "#111111" if label is first else "black", + ).save(destination) + + class Classifier: + def classify(self, source_images, candidates): + observed["source_images"] = tuple(source_images) + observed["candidate_keys"] = tuple(item.key for item in candidates) + selected_key = candidates[1].key + return SimpleNamespace( + source_color="black", + candidate_colors={item.key: "black" for item in candidates}, + selected_key=selected_key, + selection_basis="visual_similarity", + ) + + monkeypatch.setattr(upload_video_to_jd, "extract_reference_frames", extract_frames) + monkeypatch.setattr( + upload_video_to_jd, + "DirectVisualColorClassifier", + Classifier, + ) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._product_card_text = lambda label: texts[label] + uploader._product_card_image_url = lambda label: urls[label] + uploader._capture_product_card_image = capture + video_path = tmp_path / "video.mp4" + video_path.write_bytes(b"placeholder") + + selected_cards, evidence = uploader._select_product_card_with_direct_model( + [(first, texts[first]), (second, texts[second])], + video_path=video_path, + ) + + candidate_keys = observed["candidate_keys"] + assert len(candidate_keys) == 2 + assert all(str(key).startswith("card-") for key in candidate_keys) + assert len(set(candidate_keys)) == 2 + assert evidence["selected_candidate_key"] is None + assert evidence["selected_candidate_keys"] == sorted(candidate_keys) + assert [item[2] for item in selected_cards] == sorted(candidate_keys) + assert {item[1] for item in selected_cards} == set(texts.values()) + assert {item["image_url"] for item in evidence["candidates"]} == set( + urls.values() + ) + assert evidence["source_color"] == "black" + assert evidence["selection_basis"] == "batched_coarse_color_business_rule" + assert evidence["batch_count"] == 1 + assert evidence["batch_results"][0]["matcher_selected_candidate_key"] == ( + candidate_keys[1] + ) + + +def test_direct_model_product_selection_batches_six_candidates_and_uses_stable_key( + tmp_path, + monkeypatch, +) -> None: + labels = [object() for _index in range(6)] + texts = { + label: f"拾影单肩包 候选{index} 光影行星 GYXX官方旗舰店" + for index, label in enumerate(labels) + } + urls = { + label: f"https://m.360buyimg.com/candidate-{index}.jpg" + for index, label in enumerate(labels) + } + classifier_calls: list[tuple[tuple[Path, ...], tuple[str, ...]]] = [] + tmp_root = tmp_path / "tmp" + vision_root = tmp_root / "jd-video-upload" / "vision" + monkeypatch.setattr(upload_video_to_jd, "TMP_ROOT", tmp_root) + monkeypatch.setattr(upload_video_to_jd, "VISION_ROOT", vision_root) + + def extract_frames(_video_path, *, output_dir, **_kwargs): + output_dir.mkdir(parents=True, exist_ok=True) + frame_path = output_dir / "frame.jpg" + Image.new("RGB", (12, 12), "black").save(frame_path) + return ( + SimpleNamespace( + path=frame_path, + timestamp_seconds=3.0, + width=12, + height=12, + sha256="c" * 64, + ), + ) + + def capture(_label, destination): + destination.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", (12, 12), "black").save(destination) + + class Classifier: + def classify(self, source_images, candidates): + classifier_calls.append( + ( + tuple(source_images), + tuple(candidate.key for candidate in candidates), + ) + ) + return SimpleNamespace( + source_color="black", + candidate_colors={candidate.key: "black" for candidate in candidates}, + selected_key=None, + selection_basis="unresolved", + ) + + monkeypatch.setattr(upload_video_to_jd, "extract_reference_frames", extract_frames) + monkeypatch.setattr(upload_video_to_jd, "DirectVisualColorClassifier", Classifier) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._product_card_text = texts.__getitem__ + uploader._product_card_image_url = urls.__getitem__ + uploader._capture_product_card_image = capture + video_path = tmp_path / "video.mp4" + video_path.write_bytes(b"placeholder") + + selected_cards, evidence = uploader._select_product_card_with_direct_model( + [(candidate, texts[candidate]) for candidate in labels], + video_path=video_path, + ) + + assert [len(call[1]) for call in classifier_calls] == [4, 2] + assert classifier_calls[0][0] == classifier_calls[1][0] + all_keys = tuple(key for _sources, keys in classifier_calls for key in keys) + assert all_keys == tuple(sorted(all_keys)) + assert evidence["batch_count"] == 2 + assert len(evidence["batch_results"]) == 2 + assert len(evidence["candidate_colors"]) == 6 + assert evidence["selected_candidate_key"] is None + assert evidence["selected_candidate_keys"] == list(all_keys) + assert evidence["selection_basis"] == "batched_coarse_color_business_rule" + assert evidence["business_rule"] == ( + "same_style_store_same_non_unknown_coarse_color" + ) + assert [item[2] for item in selected_cards] == list(all_keys) + assert {item[1] for item in selected_cards} == set(texts.values()) + + +def _direct_model_cover_fallback_fixture( + tmp_path, + monkeypatch, + *, + video_color: str, + cover_color: str, + candidate_count: int = 6, +): + class Label: + def __init__(self, index: int) -> None: + self.index = index + self.click_count = 0 + + def click(self) -> None: + self.click_count += 1 + + labels = [Label(index) for index in range(candidate_count)] + texts = { + label: f"凌云air 候选{label.index} 光影行星 GYXX官方旗舰店" + for label in labels + } + urls = { + label: f"https://m.360buyimg.com/lingyun-{label.index}.jpg" + for label in labels + } + tmp_root = tmp_path / "tmp" + vision_root = tmp_root / "jd-video-upload" / "vision" + monkeypatch.setattr(upload_video_to_jd, "TMP_ROOT", tmp_root) + monkeypatch.setattr(upload_video_to_jd, "VISION_ROOT", vision_root) + + frame_path = tmp_root / "frame.jpg" + frame_path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", (12, 12), "black").save(frame_path) + cover_path = tmp_root / "cover.jpg" + Image.new("RGB", (12, 12), "white").save(cover_path) + + def extract_frames(_video_path, *, output_dir, **_kwargs): + output_dir.mkdir(parents=True, exist_ok=True) + return ( + SimpleNamespace( + path=frame_path, + timestamp_seconds=3.0, + width=12, + height=12, + sha256="e" * 64, + ), + ) + + def capture(_label, destination): + destination.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", (12, 12), "black").save(destination) + + classifier_calls: list[tuple[tuple[Path, ...], tuple[str, ...]]] = [] + + class Classifier: + def classify(self, source_images, candidates): + source_paths = tuple(source_images) + classifier_calls.append( + (source_paths, tuple(candidate.key for candidate in candidates)) + ) + source_color = ( + cover_color if source_paths == (cover_path,) else video_color + ) + candidate_color = "black" if source_color == "unknown" else source_color + return SimpleNamespace( + source_color=source_color, + candidate_colors={ + candidate.key: candidate_color for candidate in candidates + }, + selected_key=None, + selection_basis="unresolved", + ) + + monkeypatch.setattr(upload_video_to_jd, "extract_reference_frames", extract_frames) + monkeypatch.setattr(upload_video_to_jd, "DirectVisualColorClassifier", Classifier) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._product_card_text = texts.__getitem__ + uploader._product_card_image_url = urls.__getitem__ + uploader._capture_product_card_image = capture + video_path = tmp_path / "video.mp4" + video_path.write_bytes(b"placeholder") + return ( + uploader, + labels, + texts, + video_path, + cover_path, + classifier_calls, + ) + + +def test_direct_model_product_selection_falls_back_to_cover_after_video_unknown( + tmp_path, + monkeypatch, +) -> None: + ( + uploader, + labels, + texts, + video_path, + cover_path, + classifier_calls, + ) = _direct_model_cover_fallback_fixture( + tmp_path, + monkeypatch, + video_color="unknown", + cover_color="white", + ) + + selected_cards, evidence = uploader._select_product_card_with_direct_model( + [(label, texts[label]) for label in labels], + video_path=video_path, + cover_path=cover_path, + ) + + assert [len(keys) for _sources, keys in classifier_calls] == [4, 2, 4, 2] + assert all(sources != (cover_path,) for sources, _keys in classifier_calls[:2]) + assert all(sources == (cover_path,) for sources, _keys in classifier_calls[2:]) + assert evidence["source_basis"] == "cover_fallback_after_video_unknown" + assert len(selected_cards) == len(labels) + assert evidence["source_color"] == "white" + assert set(evidence["candidate_colors"].values()) == {"white"} + assert set(evidence["raw_batch_results"]) == { + "video_frames", + "cover_fallback_after_video_unknown", + } + assert { + result["source_color"] + for result in evidence["raw_batch_results"]["video_frames"] + } == {"unknown"} + assert { + result["source_color"] + for result in evidence["raw_batch_results"][ + "cover_fallback_after_video_unknown" + ] + } == {"white"} + + +def test_direct_model_product_selection_rejects_unknown_cover_without_click( + tmp_path, + monkeypatch, +) -> None: + ( + uploader, + labels, + texts, + video_path, + cover_path, + classifier_calls, + ) = _direct_model_cover_fallback_fixture( + tmp_path, + monkeypatch, + video_color="unknown", + cover_color="unknown", + candidate_count=2, + ) + + with pytest.raises(ProductMappingError, match="封面回退仍未识别"): + uploader._select_product_card_with_direct_model( + [(label, texts[label]) for label in labels], + video_path=video_path, + cover_path=cover_path, + ) + + assert len(classifier_calls) == 2 + assert all(label.click_count == 0 for label in labels) + + +def test_direct_model_product_selection_does_not_read_cover_when_video_is_known( + tmp_path, + monkeypatch, +) -> None: + ( + uploader, + labels, + texts, + video_path, + _cover_path, + classifier_calls, + ) = _direct_model_cover_fallback_fixture( + tmp_path, + monkeypatch, + video_color="black", + cover_color="white", + candidate_count=2, + ) + missing_cover = tmp_path / "must-not-be-read.jpg" + + selected_cards, evidence = uploader._select_product_card_with_direct_model( + [(label, texts[label]) for label in labels], + video_path=video_path, + cover_path=missing_cover, + ) + + assert len(classifier_calls) == 1 + assert len(selected_cards) == len(labels) + assert evidence["source_basis"] == "video_frames" + assert set(evidence["raw_batch_results"]) == {"video_frames"} + + +@pytest.mark.parametrize( + ("second_source_color", "should_fail"), + [("white", True), ("unknown", False)], +) +def test_direct_model_product_selection_resolves_batched_source_color( + tmp_path, + monkeypatch, + second_source_color, + should_fail, +) -> None: + labels = [object() for _index in range(6)] + texts = { + label: f"拾影单肩包 候选{index} 光影行星 GYXX官方旗舰店" + for index, label in enumerate(labels) + } + urls = { + label: f"https://m.360buyimg.com/candidate-{index}.jpg" + for index, label in enumerate(labels) + } + calls: list[tuple[str, ...]] = [] + tmp_root = tmp_path / "tmp" + vision_root = tmp_root / "jd-video-upload" / "vision" + monkeypatch.setattr(upload_video_to_jd, "TMP_ROOT", tmp_root) + monkeypatch.setattr(upload_video_to_jd, "VISION_ROOT", vision_root) + + def extract_frames(_video_path, *, output_dir, **_kwargs): + output_dir.mkdir(parents=True, exist_ok=True) + frame_path = output_dir / "frame.jpg" + Image.new("RGB", (12, 12), "black").save(frame_path) + return ( + SimpleNamespace( + path=frame_path, + timestamp_seconds=3.0, + width=12, + height=12, + sha256="d" * 64, + ), + ) + + def capture(_label, destination): + destination.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", (12, 12), "black").save(destination) + + class Classifier: + def classify(self, _source_images, candidates): + calls.append(tuple(candidate.key for candidate in candidates)) + source_color = "black" if len(calls) == 1 else second_source_color + return SimpleNamespace( + source_color=source_color, + candidate_colors={ + candidate.key: "black" for candidate in candidates + }, + selected_key=None, + selection_basis="unresolved", + ) + + monkeypatch.setattr(upload_video_to_jd, "extract_reference_frames", extract_frames) + monkeypatch.setattr(upload_video_to_jd, "DirectVisualColorClassifier", Classifier) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._product_card_text = texts.__getitem__ + uploader._product_card_image_url = urls.__getitem__ + uploader._capture_product_card_image = capture + video_path = tmp_path / "video.mp4" + video_path.write_bytes(b"placeholder") + + if should_fail: + with pytest.raises( + upload_video_to_jd.DirectColorMappingError, + match="已知来源粗颜色不一致", + ): + uploader._select_product_card_with_direct_model( + [(candidate, texts[candidate]) for candidate in labels], + video_path=video_path, + ) + else: + selected, evidence = uploader._select_product_card_with_direct_model( + [(candidate, texts[candidate]) for candidate in labels], + video_path=video_path, + ) + assert len(selected) == 6 + assert evidence["source_color"] == "black" + + assert [len(batch) for batch in calls] == [4, 2] + + +def test_direct_model_product_selection_rejects_candidates_above_total_batch_cap( + monkeypatch, +) -> None: + captures: list[object] = [] + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._capture_product_card_image = lambda label, _path: captures.append(label) + + with pytest.raises(ProductMappingError, match="超过直连大模型分批总上限 12"): + uploader._select_product_card_with_direct_model( + [(object(), f"拾影单肩包 候选{index}") for index in range(13)], + video_path=Path("video.mp4"), + ) + + assert captures == [] + + +def test_direct_model_product_selection_rejects_card_replacement_before_click( + tmp_path, + monkeypatch, +) -> None: + label = object() + original_text = "星云mini 潜影黑 光影行星 GYXX官方旗舰店" + tmp_root = tmp_path / "tmp" + vision_root = tmp_root / "jd-video-upload" / "vision" + monkeypatch.setattr(upload_video_to_jd, "TMP_ROOT", tmp_root) + monkeypatch.setattr(upload_video_to_jd, "VISION_ROOT", vision_root) + + def extract_frames(_video_path, *, output_dir, **_kwargs): + output_dir.mkdir(parents=True, exist_ok=True) + frame_path = output_dir / "frame.jpg" + Image.new("RGB", (12, 12), "black").save(frame_path) + return ( + SimpleNamespace( + path=frame_path, + timestamp_seconds=2.0, + width=12, + height=12, + sha256="b" * 64, + ), + ) + + def capture(_label, destination): + destination.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", (12, 12), "black").save(destination) + + class Classifier: + def classify(self, _sources, candidates): + return SimpleNamespace( + source_color="black", + candidate_colors={candidates[0].key: "black"}, + selected_key=candidates[0].key, + selection_basis="coarse_color", + ) + + monkeypatch.setattr(upload_video_to_jd, "extract_reference_frames", extract_frames) + monkeypatch.setattr( + upload_video_to_jd, + "DirectVisualColorClassifier", + Classifier, + ) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._product_card_text = lambda _label: original_text + " 已替换" + uploader._product_card_image_url = lambda _label: ( + "https://m.360buyimg.com/product.jpg" + ) + uploader._capture_product_card_image = capture + video_path = tmp_path / "video.mp4" + video_path.write_bytes(b"placeholder") + + with pytest.raises(ProductMappingError, match="卡片已发生变化"): + uploader._select_product_card_with_direct_model( + [(label, original_text)], + video_path=video_path, + ) + + +def test_product_result_total_reads_visible_count_copy() -> None: + dialog = SimpleNamespace(inner_text=lambda **_kwargs: "本店商品 共 16 条") + + assert upload_video_to_jd.JdVideoUploader._product_result_total(dialog) == 16 + + +def test_preflight_own_store_access_closes_usable_dialog_and_continues() -> None: + events: list[str] = [] + ready_search = SimpleNamespace( + is_visible=lambda: True, + is_enabled=lambda: True, + ) + ready_searches = SimpleNamespace( + count=lambda: 1, + nth=lambda _index: ready_search, + ) + ready_product = SimpleNamespace(is_visible=lambda: True) + ready_products = SimpleNamespace( + count=lambda: 1, + nth=lambda _index: ready_product, + ) + cancel_button = SimpleNamespace( + is_visible=lambda: True, + click=lambda: events.append("cancel"), + ) + hidden_cancel_button = SimpleNamespace(is_visible=lambda: False) + cancel_buttons = [cancel_button, hidden_cancel_button] + cancel = SimpleNamespace( + count=lambda: len(cancel_buttons), + nth=lambda index: cancel_buttons[index], + ) + + class ProductDialog: + def locator(self, selector): + if selector == upload_video_to_jd.OWN_STORE_SEARCH_SELECTOR: + return ready_searches + assert selector == upload_video_to_jd.OWN_STORE_PRODUCT_CHECKBOX_SELECTOR + return ready_products + + def get_by_role(self, role, **kwargs): + assert role == "button" + assert kwargs == {"name": "取消", "exact": True} + return cancel + + def get_by_text(self, _text, **_kwargs): + return SimpleNamespace(count=lambda: 0) + + def wait_for(self, **kwargs): + assert kwargs == {"state": "hidden", "timeout": 20_000} + events.append("dialog_hidden") + + dialog = ProductDialog() + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._require_page = lambda: SimpleNamespace(wait_for_timeout=lambda _ms: None) + uploader._require_surface = lambda: dialog + uploader._click_product_add = lambda: events.append("open_products") + uploader._active_product_dialog = lambda: dialog + + assert uploader.preflight_own_store_access() is None + assert events == ["open_products", "cancel", "dialog_hidden"] + + +def test_preflight_own_store_access_accepts_delayed_products_with_stale_empty_copy( +) -> None: + events: list[str] = [] + polls = 0 + empty_title = object() + empty_instruction = object() + cancel_button = SimpleNamespace( + is_visible=lambda: True, + click=lambda: events.append("cancel"), + ) + cancel = SimpleNamespace( + count=lambda: 1, + nth=lambda _index: cancel_button, + ) + + class DelayedProductDialog: + def get_by_text(self, text, **kwargs): + assert kwargs == {"exact": True} + if text == "暂无商品": + return empty_title + if text == "请前往京麦商品发布页面发布商品": + return empty_instruction + raise AssertionError(text) + + def get_by_role(self, role, **kwargs): + assert role == "button" + assert kwargs == {"name": "取消", "exact": True} + return cancel + + def wait_for(self, **kwargs): + assert kwargs == {"state": "hidden", "timeout": 20_000} + events.append("dialog_hidden") + + def advance_poll(_milliseconds: int) -> None: + nonlocal polls + polls += 1 + events.append("poll") + + dialog = DelayedProductDialog() + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._require_page = lambda: SimpleNamespace(wait_for_timeout=advance_poll) + uploader._require_surface = lambda: dialog + uploader._click_product_add = lambda: events.append("open_products") + uploader._active_product_dialog = lambda: dialog + uploader._own_store_search_is_ready = lambda _dialog: polls >= 9 + uploader._own_store_products_are_loaded = lambda _dialog: polls >= 9 + uploader._visible_count = lambda locator: int( + locator in {empty_title, empty_instruction} + ) + + assert uploader.preflight_own_store_access() is None + + assert polls == 12 + assert events[-2:] == ["cancel", "dialog_hidden"] + + +def test_preflight_own_store_access_rejects_stable_empty_account() -> None: + events: list[str] = [] + empty_title = object() + empty_instruction = object() + cancel_button = SimpleNamespace( + is_visible=lambda: True, + click=lambda: events.append("cancel"), + ) + cancel = SimpleNamespace( + count=lambda: 1, + nth=lambda _index: cancel_button, + ) + + class EmptyProductDialog: + def get_by_text(self, text, **kwargs): + assert kwargs == {"exact": True} + if text == "暂无商品": + return empty_title + if text == "请前往京麦商品发布页面发布商品": + return empty_instruction + raise AssertionError(text) + + def get_by_role(self, role, **kwargs): + assert role == "button" + assert kwargs == {"name": "取消", "exact": True} + return cancel + + def wait_for(self, **kwargs): + assert kwargs == {"state": "hidden", "timeout": 20_000} + events.append("dialog_hidden") + + dialog = EmptyProductDialog() + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._require_page = lambda: SimpleNamespace( + wait_for_timeout=lambda _ms: events.append("poll") + ) + uploader._require_surface = lambda: dialog + uploader._click_product_add = lambda: events.append("open_products") + uploader._active_product_dialog = lambda: dialog + uploader._own_store_search_is_ready = lambda _dialog: False + uploader._visible_count = lambda locator: int( + locator in {empty_title, empty_instruction} + ) + uploader._screenshot = lambda *_args, **_kwargs: "empty-products.png" + + with pytest.raises(RuntimeError, match="当前京麦会话无法读取本店商品"): + uploader.preflight_own_store_access() + + assert events.count("poll") == 39 + assert events[-2:] == ["cancel", "dialog_hidden"] + + +def test_ledger_ddl_contains_attachment_identity_and_ambiguity_boundary() -> None: + compact = " ".join(VIDEO_PUBLICATION_DDL.split()) + assert "video_file_token" in compact + assert "uq_video_publication_attachment" in compact + assert "status <> 'ambiguous' OR submit_started_at IS NOT NULL" in compact + assert "fact_video_publication_attempts" in compact + assert stable_json_sha256({"a": 1, "b": 2}) == stable_json_sha256( + {"b": 2, "a": 1} + ) + + +def test_jd_pre_submit_failures_are_marked_for_later_retry() -> None: + color_failure = upload_video_to_jd.DirectColorMappingError( + "直连大模型粗颜色识别结果不可安全使用" + ) + + assert upload_video_to_jd._is_retryable_pre_submit_failure(color_failure, 1) + assert not upload_video_to_jd._is_retryable_pre_submit_failure( + color_failure, + upload_video_to_jd.MAX_AUTOMATIC_ATTEMPTS, + ) + assert upload_video_to_jd._is_retryable_pre_submit_failure( + ProductMappingError("本店商品候选无法唯一定位商品卡片"), + 1, + ) + + +def test_failed_pre_submit_publications_are_promoted_to_retryable() -> None: + cursor = _LedgerCursor(rows=[(764,), (765,)]) + connection = _LedgerConnection(cursor) + + count = reclassify_failed_pre_submit_publications( + connection, + channel="jd", + target_account_key="jd-main", + ) + + assert count == 2 + query, params = cursor.executions[0] + assert "status = 'failed'" in query + assert "retryable = FALSE" in query + assert "submit_started_at IS NULL" in query + assert "attempt_count < %s" in query + assert params == ("jd", "jd-main", upload_video_to_jd.MAX_AUTOMATIC_ATTEMPTS) + + +def test_legacy_color_failures_are_promoted_to_retryable() -> None: + cursor = _LedgerCursor(rows=[(663,), (664,)]) + connection = _LedgerConnection(cursor) + + count = reclassify_legacy_color_failures( + connection, + channel="jd", + target_account_key="jd-main", + ) + + assert count == 2 + query, params = cursor.executions[0] + assert "status = 'failed'" in query + assert "retryable = FALSE" in query + assert "submit_started_at IS NULL" in query + assert params[:2] == ("jd", "jd-main") + assert all(str(value).endswith("%") for value in params[2:]) + + +def test_script_defaults_to_read_only_and_validates_single_video_filter() -> None: + args = upload_video_to_jd.parse_args([]) + assert args.execute is False + assert args.headless is False + assert ( + args.anchor_record_id + == upload_video_to_jd.DEFAULT_SOURCE_ANCHOR_RECORD_ID + == "recvrXTRWWFXeJ" + ) + with pytest.raises(SystemExit): + upload_video_to_jd.parse_args(["--video-token", "token"]) + + +def test_source_anchor_selects_inclusive_view_suffix_without_dates() -> None: + records = [ + {"record_id": "old", "日期": ""}, + {"record_id": "anchor", "日期": None}, + {"record_id": "new-blank", "日期": ""}, + ] + + selected = upload_video_to_jd.records_from_source_anchor( + records, + anchor_record_id="anchor", + ) + + assert [record["record_id"] for record in selected] == [ + "anchor", + "new-blank", + ] + + +def test_source_anchor_fails_closed_when_missing_but_exact_record_bypasses_it() -> None: + records = [{"record_id": "maintenance", "日期": ""}] + with pytest.raises(RuntimeError, match="锚点"): + upload_video_to_jd.records_from_source_anchor( + records, + anchor_record_id="missing", + ) + + selected = upload_video_to_jd.records_from_source_anchor( + records, + anchor_record_id="missing", + record_id_filter="maintenance", + ) + assert [record["record_id"] for record in selected] == ["maintenance"] + + +def test_jd_login_and_shop_home_urls_are_fixed_to_the_authorized_flow() -> None: + assert upload_video_to_jd.SHOP_LOGIN_URL == ( + "https://passport.shop.jd.com/login/index.action/jdm?" + "ReturnUrl=https%3A%2F%2Fdr.jd.com%2Fjm%2F%23%2Fn%2Fpost-center.html" + ) + assert upload_video_to_jd.SHOP_HOME_URL == "https://shop.jd.com/jdm/home" + + +def test_start_reuses_device_profile_without_loading_exported_cookies( + tmp_path, + monkeypatch, +) -> None: + monkeypatch.delenv("GYXX_BROWSER_CDP_URL", raising=False) + monkeypatch.delenv("GYXX_BROWSER_CDP_ENDPOINT", raising=False) + session_options: dict[str, object] = {} + events: list[str] = [] + page = SimpleNamespace( + set_default_timeout=lambda timeout: events.append(f"timeout:{timeout}") + ) + + class Context: + pages: list[object] = [] + + def new_page(self): + return page + + def add_cookies(self, _cookies): + pytest.fail("fresh-login context must not receive exported cookies") + + context = Context() + + class Session: + def __init__(self, **kwargs): + session_options.update(kwargs) + self.context = context + self.browser = None + + def start(self): + events.append("session.start") + + monkeypatch.setattr( + upload_video_to_jd, + "ScraplingBrowser", + Session, + ) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.profile_dir = tmp_path / "bound-profile" + uploader.cdp_port = 22137 + uploader.headless = False + uploader.context = None + uploader.page = None + uploader.surface = None + uploader.cookie_store = SimpleNamespace( + load_cookies=lambda: pytest.fail("start must not load exported cookies") + ) + uploader.ensure_login = lambda: events.append("ensure_login") + + uploader.start() + + assert uploader.run_profile_dir == uploader.profile_dir + assert Path(str(session_options["user_data_dir"])) == uploader.run_profile_dir + assert session_options["stealthy"] is True + assert session_options["real_chrome"] is True + assert session_options["headless"] is False + assert session_options["disable_resources"] is False + assert session_options["block_ads"] is False + assert "--remote-debugging-port=22137" in session_options["extra_flags"] + assert events == ["session.start", "timeout:15000", "ensure_login"] + + +def test_unreachable_managed_loopback_cdp_falls_back_to_bound_profile( + tmp_path, + monkeypatch, +) -> None: + endpoint = "http://127.0.0.1:22137" + session_options: dict[str, object] = {} + events: list[str] = [] + reachability_checks: list[tuple[str, int]] = [] + page = SimpleNamespace( + set_default_timeout=lambda timeout: events.append(f"timeout:{timeout}") + ) + + class Context: + pages = [page] + + class Session: + def __init__(self, **kwargs): + session_options.update(kwargs) + self.context = Context() + self.browser = None + + def start(self): + events.append("session.start") + + monkeypatch.setenv("GYXX_BROWSER_CDP_URL", endpoint) + monkeypatch.delenv("GYXX_BROWSER_CDP_ENDPOINT", raising=False) + monkeypatch.setattr( + upload_video_to_jd, + "_managed_cdp_listener_is_reachable", + lambda candidate, port: reachability_checks.append((candidate, port)) or False, + ) + monkeypatch.setattr(upload_video_to_jd, "ScraplingBrowser", Session) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.profile_dir = tmp_path / "bound-profile" + uploader.cdp_port = 22137 + uploader.headless = False + uploader.context = None + uploader.page = None + uploader.surface = None + uploader.cookie_store = SimpleNamespace( + load_cookies=lambda: pytest.fail("fallback must not load exported cookies") + ) + uploader.ensure_existing_login = lambda: pytest.fail( + "unreachable managed CDP must not validate a borrowed session" + ) + uploader.ensure_login = lambda: events.append("ensure_login") + + uploader.start() + + assert reachability_checks == [(endpoint, 22137)] + assert session_options["real_chrome"] is True + assert session_options["headless"] is False + assert session_options["user_data_dir"] == str(uploader.profile_dir) + assert "cdp_url" not in session_options + assert "--remote-debugging-port=22137" in session_options["extra_flags"] + assert events == ["session.start", "timeout:15000", "ensure_login"] + + +@pytest.mark.parametrize( + ("variable", "endpoint"), + [ + ("GYXX_BROWSER_CDP_URL", "http://127.0.0.1:9222"), + ("GYXX_BROWSER_CDP_ENDPOINT", "http://127.0.0.1:22137"), + ], +) +def test_non_managed_cdp_configuration_never_silently_falls_back( + variable: str, + endpoint: str, + monkeypatch, +) -> None: + session_options: dict[str, object] = {} + + class Session: + def __init__(self, **kwargs): + session_options.update(kwargs) + self.context = None + self.browser = None + + def start(self): + raise RuntimeError("configured CDP connection failed") + + monkeypatch.delenv("GYXX_BROWSER_CDP_URL", raising=False) + monkeypatch.delenv("GYXX_BROWSER_CDP_ENDPOINT", raising=False) + monkeypatch.setenv(variable, endpoint) + monkeypatch.setenv("GYXX_BROWSER_CDP_PORT", "22137") + monkeypatch.setattr(upload_video_to_jd, "ScraplingBrowser", Session) + uploader = upload_video_to_jd.JdVideoUploader( + headless=False, + manual_login=False, + debug=False, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + + with pytest.raises(RuntimeError, match="configured CDP connection failed"): + uploader.start() + + assert session_options["cdp_url"] == endpoint + assert session_options["reuse_existing_cdp_context"] is True + assert "real_chrome" not in session_options + + +def test_cdp_start_and_close_borrow_existing_chrome_without_owning_it( + monkeypatch, +) -> None: + endpoint = "http://127.0.0.1:9222" + events: list[str] = [] + + class Locator: + def __init__(self, *, text: str = "", visible: bool = False) -> None: + self.text = text + self.visible = visible + + def count(self) -> int: + return int(self.visible) + + def nth(self, index: int): + assert index == 0 and self.visible + return self + + def is_visible(self) -> bool: + return self.visible + + def inner_text(self, **_kwargs) -> str: + return self.text + + full_account_panel = "\n".join( + ( + "光影行星 GYXX官方旗舰店", + "商家ID:11528908", + "店铺ID:11272270", + "登录账号:configured-subaccount", + "店铺主账号:光影行星旗舰店", + ) + ) + + class Page: + def __init__(self, name: str) -> None: + self.name = name + self.url = "about:blank" + + def goto(self, url: str, **_kwargs) -> None: + events.append(f"goto:{url}") + self.url = url + + def set_default_timeout(self, timeout: int) -> None: + events.append(f"timeout:{timeout}") + + def wait_for_timeout(self, _timeout: int) -> None: + return None + + def locator(self, selector: str): + if selector == "body": + return Locator(text=full_account_panel, visible=True) + return Locator() + + def get_by_text(self, text: str, **_kwargs): + return Locator(visible=text in full_account_panel) + + def close(self) -> None: + if self.name == "existing": + pytest.fail("CDP start/close must not close an existing shared page") + events.append("run_page.close") + + existing_page = Page("existing") + run_page = Page("run") + + class Context: + def __init__(self) -> None: + self.pages = [existing_page] + + def new_page(self): + events.append("context.new_page") + self.pages.append(run_page) + return run_page + + def cookies(self): + return [ + { + "name": "pin", + "value": "configured-subaccount", + "domain": ".jd.com", + } + ] + + def storage_state(self): + pytest.fail("borrowed CDP context must not save storage state") + + def close(self) -> None: + pytest.fail("borrowed CDP context must not be closed") + + context = Context() + + class CreatedContext: + def close(self) -> None: + events.append("created_context.close") + + created_context = CreatedContext() + + class Browser: + contexts = [context, created_context] + + def close(self) -> None: + pytest.fail("borrowed CDP browser must not be closed") + + browser = Browser() + + class Session: + def __init__(self, **kwargs): + assert kwargs["cdp_url"] == endpoint + assert kwargs["stealthy"] is True + assert kwargs["reuse_existing_cdp_context"] is True + self.context = created_context + self.browser = browser + + def start(self) -> None: + events.append("session.start") + self.context.close() + self.context = context + + def close(self) -> None: + events.append("session.close") + + monkeypatch.setenv("GYXX_BROWSER_CDP_ENDPOINT", endpoint) + monkeypatch.setenv("JD_SHOP", "configured-subaccount") + monkeypatch.setattr( + upload_video_to_jd, + "ScraplingBrowser", + Session, + ) + uploader = upload_video_to_jd.JdVideoUploader( + headless=False, + manual_login=False, + debug=False, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + uploader.ensure_login = lambda: pytest.fail( + "CDP mode must verify the borrowed session without password login" + ) + uploader.cookie_store = SimpleNamespace( + save_cookies=lambda *_args: pytest.fail( + "borrowed CDP context must not save cookies" + ), + save_storage_state=lambda *_args: pytest.fail( + "borrowed CDP context must not save storage state" + ), + ) + + uploader.start() + + assert uploader.context is context + assert uploader.page is run_page + assert existing_page in context.pages + assert uploader.login_completed is True + assert uploader.shop_home_store_verified is True + + uploader.close() + + assert events.count("session.start") == 1 + assert events.count("created_context.close") == 1 + assert events.count("context.new_page") == 1 + assert events.count("run_page.close") == 1 + assert events.count("session.close") == 1 + + +def test_cdp_setup_failure_still_preserves_borrowed_chrome(monkeypatch) -> None: + endpoint = "http://127.0.0.1:9222" + events: list[str] = [] + + class ExistingContext: + def close(self) -> None: + pytest.fail("borrowed CDP context must not be closed") + + existing_context = ExistingContext() + + class CreatedContext: + def close(self) -> None: + events.append("created_context.close") + raise RuntimeError("temporary context close failed") + + created_context = CreatedContext() + + class Browser: + contexts = [existing_context, created_context] + + def close(self) -> None: + pytest.fail("borrowed CDP browser must not be closed") + + browser = Browser() + + class Session: + def __init__(self, **kwargs): + assert kwargs["cdp_url"] == endpoint + assert kwargs["stealthy"] is True + assert kwargs["reuse_existing_cdp_context"] is True + self.context = created_context + self.browser = browser + self.closed = False + + def start(self) -> None: + events.append("session.start") + try: + self.context.close() + except Exception: + self.context = None + self.browser = None + self.close() + raise + + def close(self) -> None: + if self.closed: + return + self.closed = True + events.append("session.close") + + monkeypatch.setenv("GYXX_BROWSER_CDP_ENDPOINT", endpoint) + monkeypatch.setattr(upload_video_to_jd, "ScraplingBrowser", Session) + uploader = upload_video_to_jd.JdVideoUploader( + headless=False, + manual_login=False, + debug=False, + expected_store_name="光影行星 GYXX官方旗舰店", + ) + + with pytest.raises(RuntimeError, match="temporary context close failed"): + uploader.start() + + assert uploader.connected_over_cdp is True + uploader.close() + + assert events == [ + "session.start", + "created_context.close", + "session.close", + ] + + +@pytest.mark.parametrize( + "account_placeholder", + ["请输入账号名/邮箱", "请输入京东账号"], +) +def test_ensure_login_accepts_both_account_placeholders_and_enters_shop_home( + account_placeholder: str, + monkeypatch, +) -> None: + goto_calls: list[str] = [] + filled: dict[str, str] = {} + verified_accounts: list[str] = [] + + class Element: + def __init__(self, name: str, page) -> None: + self.name = name + self.page = page + + def is_visible(self) -> bool: + return "passport.shop.jd.com" in self.page.url + + def fill(self, value: str) -> None: + filled[self.name] = value + + def click(self) -> None: + assert self.name == "login_button" + self.page.url = "https://shop.jd.com/jdm/welcome" + + class Locator: + def __init__(self, elements) -> None: + self.elements = list(elements) + + def count(self) -> int: + return len(self.elements) + + def nth(self, index: int): + return self.elements[index] + + class Page: + url = "about:blank" + home_visits = 0 + + def goto(self, url: str, **_kwargs) -> None: + goto_calls.append(url) + if url == upload_video_to_jd.SHOP_HOME_URL: + self.home_visits += 1 + self.url = ( + upload_video_to_jd.SHOP_LOGIN_URL + if self.home_visits == 1 + else url + ) + else: + self.url = url + + def locator(self, selector: str): + mappings = { + f'input[placeholder="{account_placeholder}"]': [ + Element("account", self) + ], + 'input[placeholder="请输入登录密码"]': [ + Element("password", self) + ], + 'button:has-text("立即登录")': [ + Element("login_button", self) + ], + } + return Locator(mappings.get(selector, [])) + + def wait_for_timeout(self, _timeout: int) -> None: + return None + + monkeypatch.setenv("JD_SHOP", "configured-subaccount") + monkeypatch.setenv("JD_PASSWORD", "placeholder") + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.page = Page() + uploader.context = SimpleNamespace() + uploader.manual_login = False + uploader.headless = False + uploader.login_completed = False + uploader._verify_login_account_identity = verified_accounts.append + uploader._verify_shop_home_store_identity = lambda: None + + uploader.ensure_login() + + assert goto_calls == [ + upload_video_to_jd.SHOP_HOME_URL, + upload_video_to_jd.SHOP_LOGIN_URL, + upload_video_to_jd.SHOP_HOME_URL, + ] + assert filled == { + "account": "configured-subaccount", + "password": "placeholder", + } + assert verified_accounts == ["configured-subaccount"] + assert uploader.login_completed is True + + +@pytest.mark.parametrize( + ("account", "password"), + [("", "placeholder"), ("configured-subaccount", "")], +) +def test_ensure_login_fails_closed_when_credentials_are_missing( + account: str, + password: str, + monkeypatch, +) -> None: + goto_calls: list[str] = [] + + class Page: + url = "about:blank" + + def goto(self, url: str, **_kwargs) -> None: + goto_calls.append(url) + self.url = upload_video_to_jd.SHOP_LOGIN_URL + + def wait_for_timeout(self, _timeout: int) -> None: + return None + + def locator(self, _selector: str): + return SimpleNamespace(count=lambda: 0) + + monkeypatch.setenv("JD_SHOP", account) + monkeypatch.setenv("JD_PASSWORD", password) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.page = Page() + uploader.manual_login = False + uploader.headless = False + + with pytest.raises(RuntimeError, match="必须配置非空 JD_SHOP 和 JD_PASSWORD"): + uploader.ensure_login() + + assert goto_calls == [upload_video_to_jd.SHOP_HOME_URL] + + +@pytest.mark.parametrize("account", ["", "configured-subaccount"]) +def test_ensure_login_reuses_exact_shop_home_without_requiring_password( + account: str, + monkeypatch, +) -> None: + goto_calls: list[str] = [] + verified_accounts: list[str] = [] + store_verifications: list[str] = [] + + class Page: + url = "about:blank" + + def goto(self, url: str, **_kwargs) -> None: + goto_calls.append(url) + self.url = upload_video_to_jd.SHOP_HOME_URL + + def wait_for_timeout(self, _timeout: int) -> None: + return None + + def locator(self, _selector: str): + return SimpleNamespace(count=lambda: 0) + + monkeypatch.setenv("JD_SHOP", account) + monkeypatch.delenv("JD_PASSWORD", raising=False) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.page = Page() + uploader.manual_login = False + uploader.headless = False + uploader.login_completed = False + uploader._verify_login_account_identity = verified_accounts.append + uploader._verify_shop_home_store_identity = lambda: store_verifications.append( + "verified" + ) + + uploader.ensure_login() + + assert goto_calls == [upload_video_to_jd.SHOP_HOME_URL] + assert verified_accounts == ([account] if account else []) + assert store_verifications == ["verified"] + assert uploader.login_completed is True + + +def test_ensure_login_rejects_non_shop_host_before_reusing_profile(monkeypatch) -> None: + class Page: + url = "about:blank" + + def goto(self, _url: str, **_kwargs) -> None: + self.url = "https://example.test/not-jingmai" + + def wait_for_timeout(self, _timeout: int) -> None: + return None + + def locator(self, _selector: str): + return SimpleNamespace(count=lambda: 0) + + monkeypatch.delenv("JD_SHOP", raising=False) + monkeypatch.delenv("JD_PASSWORD", raising=False) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.page = Page() + uploader.manual_login = False + uploader.headless = False + uploader._verify_shop_home_store_identity = lambda: pytest.fail( + "wrong hostname must be rejected before store verification" + ) + + with pytest.raises(RuntimeError, match="未进入京麦首页"): + uploader.ensure_login() + + +def test_ensure_login_allows_headed_manual_login_without_password(monkeypatch) -> None: + goto_calls: list[str] = [] + verified_accounts: list[str] = [] + store_verifications: list[str] = [] + + class Page: + url = "about:blank" + home_visits = 0 + + def goto(self, url: str, **_kwargs) -> None: + goto_calls.append(url) + self.home_visits += 1 + self.url = ( + upload_video_to_jd.SHOP_LOGIN_URL + if self.home_visits == 1 + else upload_video_to_jd.SHOP_HOME_URL + ) + + def wait_for_timeout(self, timeout: int) -> None: + if timeout == 1000: + self.url = "https://shop.jd.com/jdm/welcome" + + def locator(self, _selector: str): + return SimpleNamespace(count=lambda: 0) + + monkeypatch.setenv("JD_SHOP", "configured-subaccount") + monkeypatch.delenv("JD_PASSWORD", raising=False) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.page = Page() + uploader.manual_login = True + uploader.headless = False + uploader.login_completed = False + uploader._verify_login_account_identity = verified_accounts.append + uploader._verify_shop_home_store_identity = lambda: store_verifications.append( + "verified" + ) + + uploader.ensure_login() + + assert goto_calls == [ + upload_video_to_jd.SHOP_HOME_URL, + upload_video_to_jd.SHOP_HOME_URL, + ] + assert verified_accounts == ["configured-subaccount"] + assert store_verifications == ["verified"] + assert uploader.login_completed is True + + +def test_ensure_login_rejects_headless_manual_login_without_credentials( + monkeypatch, +) -> None: + class Page: + url = "about:blank" + + def goto(self, _url: str, **_kwargs) -> None: + self.url = upload_video_to_jd.SHOP_LOGIN_URL + + def wait_for_timeout(self, _timeout: int) -> None: + return None + + def locator(self, _selector: str): + return SimpleNamespace(count=lambda: 0) + + monkeypatch.delenv("JD_SHOP", raising=False) + monkeypatch.delenv("JD_PASSWORD", raising=False) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.page = Page() + uploader.manual_login = True + uploader.headless = True + + with pytest.raises(RuntimeError, match="必须使用有头浏览器"): + uploader.ensure_login() + + +@pytest.mark.parametrize("exact_match", [True, False]) +def test_verify_shop_home_store_identity_requires_visible_exact_text( + exact_match: bool, +) -> None: + class Element: + def is_visible(self) -> bool: + return True + + class Locator: + def count(self) -> int: + return int(exact_match) + + def nth(self, _index: int): + return Element() + + class Page: + def get_by_text(self, text: str, *, exact: bool): + assert text == "光影行星 GYXX官方旗舰店" + assert exact is True + return Locator() + + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.page = Page() + uploader.expected_store_name = "光影行星 GYXX官方旗舰店" + uploader.shop_home_store_verified = False + uploader._screenshot = lambda *_args, **_kwargs: "evidence.png" + + if exact_match: + uploader._verify_shop_home_store_identity() + assert uploader.shop_home_store_verified is True + else: + with pytest.raises(RuntimeError, match="未显示目标店铺"): + uploader._verify_shop_home_store_identity() + assert uploader.shop_home_store_verified is False + + +def test_wait_channel_initialized_waits_for_late_video_input( + monkeypatch: pytest.MonkeyPatch, +) -> None: + state = {"polls": 0} + waits: list[int] = [] + + class Element: + def is_visible(self) -> bool: + return True + + class Locator: + def __init__(self) -> None: + self.element = Element() + + def count(self) -> int: + return 1 + + def nth(self, _index: int) -> Element: + return self.element + + class Channel(Locator): + def wait_for(self, **kwargs) -> None: + assert kwargs == {"state": "visible", "timeout": 60_000} + + def inner_text(self) -> str: + return "逛-竖版视频" + + class Surface: + def __init__(self) -> None: + self.channel = Channel() + self.visible = Locator() + + def locator(self, selector: str): + if selector == ".channel-submission-selChannel-channel": + return self.channel + assert selector in { + '[data-spm-click="publishVideoChannelOpenDrawer"]', + ".video-cover-wrapper .wait-select-video", + } + return self.visible + + class Page: + def wait_for_timeout(self, milliseconds: int) -> None: + waits.append(milliseconds) + if milliseconds == 500: + state["polls"] += 1 + + class VideoInput: + def is_enabled(self) -> bool: + return True + + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + surface = Surface() + page = Page() + uploader._require_page = lambda: page + uploader._require_surface = lambda: surface + uploader._available_video_file_input = lambda: ( + None if state["polls"] < 2 else VideoInput() + ) + monkeypatch.setattr(upload_video_to_jd.time, "monotonic", lambda: 0.0) + + uploader._wait_channel_initialized() + + assert state["polls"] == 6 + assert waits[-1] == 3000 + + +def test_open_video_form_clicks_shop_publish_content_then_video() -> None: + events: list[str] = [] + goto_calls: list[str] = [] + + class Element: + def __init__(self, click_event: str | None = None) -> None: + self.click_event = click_event + + def is_visible(self) -> bool: + return True + + def click(self) -> None: + if self.click_event == "publish_content": + events.append("publish_content") + context.pages.append(post_center_page) + elif self.click_event == "video": + events.append("video") + context.pages.append(publish_video_page) + + def wait_for(self, **kwargs) -> None: + assert kwargs == {"state": "visible", "timeout": 60_000} + events.append("publish_title_visible") + + class Locator: + def __init__(self, elements=()) -> None: + self.elements = list(elements) + + def count(self) -> int: + return len(self.elements) + + def nth(self, index: int): + return self.elements[index] + + @property + def first(self): + return self.elements[0] + + class Page: + def __init__(self, url: str) -> None: + self.url = url + + def goto(self, url: str, **_kwargs) -> None: + goto_calls.append(url) + self.url = url + + def wait_for_timeout(self, _timeout: int) -> None: + return None + + def set_default_timeout(self, timeout: int) -> None: + assert timeout == 15_000 + + def wait_for_load_state(self, state: str, **kwargs) -> None: + assert state == "domcontentloaded" + assert kwargs == {"timeout": 60_000} + + def close(self) -> None: + events.append(f"close:{self.url}") + + class PostCenterSurface: + def get_by_text(self, text: str, *, exact: bool): + assert exact is True + if text == "正文标题": + return Locator() + assert text == "视频" + return Locator([Element("video")]) + + class PublishVideoSurface: + def get_by_text(self, text: str, *, exact: bool): + assert (text, exact) == ("正文标题", True) + return Element() + + home_page = Page(upload_video_to_jd.SHOP_HOME_URL) + post_center_page = Page(upload_video_to_jd.POST_CENTER_URL) + publish_video_page = Page( + "https://dr.jd.com/jm/#/n/publish-video.html?platform=jm-pop" + ) + context = SimpleNamespace(pages=[home_page]) + publish_entry = Element("publish_content") + post_surface = PostCenterSurface() + publish_surface = PublishVideoSurface() + + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.page = home_page + uploader.context = context + uploader.surface = None + uploader.owned_pages = [home_page] + uploader._is_login_page = lambda: False + + def wait_first_visible(selectors, *, timeout_seconds: int): + assert selectors == ("text=发布内容",) + assert timeout_seconds == 3 + return publish_entry + + def resolve_content_frame(*, path_fragment: str): + events.append(f"frame:{path_fragment}") + if path_fragment == "/n/post-center.html": + return post_surface + assert path_fragment == "/n/publish-video.html" + return publish_surface + + uploader._wait_first_visible = wait_first_visible + uploader._resolve_content_frame = resolve_content_frame + uploader._verify_store_identity = lambda: events.append("store_verified") + uploader._wait_channel_initialized = lambda: events.append("channel_ready") + + uploader.open_video_form() + + assert goto_calls == [upload_video_to_jd.SHOP_HOME_URL] + assert upload_video_to_jd.POST_CENTER_URL not in goto_calls + assert events == [ + "publish_content", + f"close:{upload_video_to_jd.SHOP_HOME_URL}", + "frame:/n/post-center.html", + "store_verified", + "video", + f"close:{upload_video_to_jd.POST_CENTER_URL}", + "frame:/n/publish-video.html", + "publish_title_visible", + "channel_ready", + ] + assert uploader.page is publish_video_page + + +def test_open_publish_center_expands_content_menu_when_publish_entry_is_hidden() -> None: + events: list[str] = [] + goto_calls: list[str] = [] + publish_lookup_count = 0 + + class Element: + def __init__(self, click_event: str | None = None) -> None: + self.click_event = click_event + + def is_visible(self) -> bool: + return True + + def hover(self) -> None: + events.append("content_hover") + + def click(self) -> None: + if self.click_event == "content_menu": + events.append("content_menu") + elif self.click_event == "publish_content": + events.append("publish_content") + context.pages.append(post_center_page) + + class Locator: + def __init__(self, elements=()) -> None: + self.elements = list(elements) + + def count(self) -> int: + return len(self.elements) + + def nth(self, index: int): + return self.elements[index] + + class Page: + def __init__(self, url: str) -> None: + self.url = url + + def goto(self, url: str, **_kwargs) -> None: + goto_calls.append(url) + self.url = url + + def wait_for_timeout(self, _timeout: int) -> None: + return None + + def set_default_timeout(self, timeout: int) -> None: + assert timeout == 15_000 + + def wait_for_load_state(self, state: str, **kwargs) -> None: + assert state == "domcontentloaded" + assert kwargs == {"timeout": 60_000} + + def close(self) -> None: + events.append(f"close:{self.url}") + + home_page = Page(upload_video_to_jd.SHOP_HOME_URL) + post_center_page = Page(upload_video_to_jd.POST_CENTER_URL) + context = SimpleNamespace(pages=[home_page]) + content_entry = Element("content_menu") + publish_entry = Element("publish_content") + + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.page = home_page + uploader.context = context + uploader.owned_pages = [home_page] + uploader._is_login_page = lambda: False + + def wait_first_visible(selectors, *, timeout_seconds: int): + nonlocal publish_lookup_count + if selectors == ("text=内容",): + assert timeout_seconds == 10 + return content_entry + assert selectors == ("text=发布内容",) + publish_lookup_count += 1 + if publish_lookup_count == 1: + assert timeout_seconds == 3 + return None + assert timeout_seconds == 20 + return publish_entry + + uploader._wait_first_visible = wait_first_visible + + uploader._open_publish_center_from_shop_home() + + assert goto_calls == [upload_video_to_jd.SHOP_HOME_URL] + assert events == [ + "content_hover", + "content_menu", + "publish_content", + f"close:{upload_video_to_jd.SHOP_HOME_URL}", + ] + assert uploader.page is post_center_page + + +def test_open_publish_center_uses_fixed_route_when_menus_have_no_entry() -> None: + goto_calls: list[str] = [] + + class Locator: + def count(self) -> int: + return 0 + + class Page: + def __init__(self) -> None: + self.url = upload_video_to_jd.SHOP_HOME_URL + + def goto(self, url: str, **_kwargs) -> None: + goto_calls.append(url) + self.url = url + + def wait_for_timeout(self, _timeout: int) -> None: + return None + + def locator(self, _selector: str): + return Locator() + + page = Page() + context = SimpleNamespace(pages=[page]) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.page = page + uploader.context = context + uploader.owned_pages = [page] + uploader._is_login_page = lambda: False + uploader._wait_first_visible = lambda *_args, **_kwargs: None + + uploader._open_publish_center_from_shop_home() + + assert goto_calls == [ + upload_video_to_jd.SHOP_HOME_URL, + upload_video_to_jd.POST_CENTER_URL, + ] + assert uploader.page is page + assert uploader.page.url == upload_video_to_jd.POST_CENTER_URL + + +def test_open_video_form_reuses_the_active_publish_tab() -> None: + events: list[str] = [] + goto_calls: list[str] = [] + + class Page: + url = upload_video_to_jd.VIDEO_PUBLISH_URL + + def goto(self, url: str, **_kwargs) -> None: + goto_calls.append(url) + self.url = url + + def set_default_timeout(self, timeout: int) -> None: + assert timeout == 15_000 + + def wait_for_load_state(self, state: str, **kwargs) -> None: + assert state == "domcontentloaded" + assert kwargs == {"timeout": 60_000} + + class Title: + def wait_for(self, **kwargs) -> None: + assert kwargs == {"state": "visible", "timeout": 60_000} + events.append("title_visible") + + class Surface: + def get_by_text(self, text: str, *, exact: bool): + assert (text, exact) == ("正文标题", True) + return Title() + + page = Page() + surface = Surface() + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader.page = page + uploader.surface = None + uploader.reuse_video_form_tab = True + uploader._require_page = lambda: page + def resolve_visible_content_frame( + *, + page, + path_fragment: str, + required_text: str, + timeout_seconds: int, + ): + assert page is uploader.page + assert required_text == "正文标题" + assert timeout_seconds == 60 + events.append(f"frame:{path_fragment}") + return surface + + uploader._resolve_visible_content_frame = resolve_visible_content_frame + uploader._wait_channel_initialized = lambda: events.append("channel_ready") + + uploader.open_video_form() + + assert goto_calls == [upload_video_to_jd.VIDEO_PUBLISH_URL] + assert events == [ + "frame:/n/publish-video.html", + "title_visible", + "channel_ready", + ] + + +def test_discovery_reports_non_target_brand_in_issues() -> None: + record = _record( + "other-brand", + "2026-08-19 00:00:00", + [_attachment("video", "video.mp4")], + ) + record["品牌"] = ["其他品牌"] + + candidates, issues = discover_publication_candidates( + [record], + target_account_key="account", + source_base_token="base", + source_table_id="table", + source_view_id="view", + ) + + assert candidates == [] + assert [issue.message for issue in issues] == [ + "品牌不是光影行星,已跳过: 其他品牌" + ] + + +def test_print_discovery_includes_source_scan_metadata(capsys) -> None: + upload_video_to_jd._print_discovery( + [], + [], + execute=False, + source_scan={ + "scanned_record_count": 379, + "selected_record_count": 15, + "selection_mode": "anchor_suffix", + "anchor_record_id": "anchor", + "records_before_anchor": 364, + }, + ) + + payload = json.loads(capsys.readouterr().out) + assert payload["source_scan"]["records_before_anchor"] == 364 + assert payload["source_scan"]["selected_record_count"] == 15 + + +@pytest.mark.parametrize( + ("method", "url", "expected"), + [ + ("PUT", "https://bucket.jcloudcs.com/jdVideo.mp4?callback=1", True), + ( + "PUT", + "https://bucket.jcloudcs.com/jdVideo.mp4?uploadId=u&partNumber=1", + False, + ), + ("POST", "https://bucket.jcloudcs.com/jdVideo.mp4?uploadId=u", True), + ("POST", "https://bucket.jcloudcs.com/jdVideo.mp4", True), + ("POST", "https://bucket.jcloudcs.com/jdVideo.mp4?uploads", False), + ( + "POST", + "https://bucket.jcloudcs.com/jdVideo.mp4?partNumber=1", + False, + ), + ("GET", "https://bucket.jcloudcs.com/jdVideo.mp4", False), + ("PUT", "https://example.com/jdVideo.mp4", False), + ], +) +def test_upload_transport_completion_requires_final_object_store_response( + method: str, + url: str, + expected: bool, +) -> None: + response = SimpleNamespace( + url=url, + request=SimpleNamespace(method=method), + ) + + assert upload_video_to_jd.JdVideoUploader._is_upload_transport_response( + response + ) is expected + + +@pytest.mark.parametrize( + ( + "transport_complete", + "saw_progress", + "baseline_video_ids", + "current_video_ids", + "upload_panel_text", + "expected", + ), + [ + (True, False, set(), set(), "", True), + (False, True, set(), {"4547028162"}, "本地处理完成", True), + (False, False, set(), {"4547028162"}, "本地处理完成", False), + (False, True, {"4547028162"}, {"4547028162"}, "本地处理完成", False), + (False, True, set(), {"4547028162"}, "本地处理中", False), + ], +) +def test_authoritative_upload_receipt_requires_transport_or_new_stable_video( + transport_complete: bool, + saw_progress: bool, + baseline_video_ids: set[str], + current_video_ids: set[str], + upload_panel_text: str, + expected: bool, +) -> None: + assert upload_video_to_jd.JdVideoUploader._has_authoritative_upload_receipt( + transport_complete=transport_complete, + saw_progress=saw_progress, + baseline_video_ids=baseline_video_ids, + current_video_ids=current_video_ids, + upload_panel_text=upload_panel_text, + ) is expected + + +def _selected_video_state_surface( + *, + body_text: str, + upload_panel_text: str, + fallback_visible: bool, + events: list[str], +): + class Candidates: + def __init__(self, candidates): + self.candidates = candidates + + def count(self): + return len(self.candidates) + + def nth(self, index): + return self.candidates[index] + + class Text: + def __init__(self, value): + self.value = value + + def inner_text(self, **_kwargs): + return self.value + + fallback = SimpleNamespace( + is_visible=lambda: fallback_visible, + click=lambda: events.append("fallback_clicked"), + ) + + class Wrapper: + def get_by_text(self, text, *, exact): + assert text == "修改封面" + assert exact is True + events.append("fallback_checked") + return Candidates([fallback]) + + class Surface: + def locator(self, selector): + if selector == ( + '.video-cover-wrapper [data-spm-click="openVideoCoverModal"]' + ): + events.append("data_selector_missing") + return Candidates([]) + if selector == ".video-cover-wrapper": + return Wrapper() + if selector == "body": + return Text(body_text) + if selector == ".videoupload-preview-container": + return Text(upload_panel_text) + raise AssertionError(selector) + + return Surface() + + +def test_selected_video_state_accepts_slow_upload_and_modify_cover_fallback() -> None: + events: list[str] = [] + surface = _selected_video_state_surface( + body_text="视频ID: 4556559489 已上传 4.7% 本地处理完成 修改封面", + upload_panel_text="视频ID: 4556559489 本地处理完成 已上传 4.7%", + fallback_visible=True, + events=events, + ) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._require_page = lambda: SimpleNamespace( + wait_for_timeout=lambda _milliseconds: events.append("waited") + ) + uploader._require_surface = lambda: surface + uploader._screenshot = lambda *_args, **_kwargs: pytest.fail( + "normal upload state must not be classified as cleared" + ) + + uploader._wait_for_selected_video_state( + baseline_body_text="等待视频上传", + baseline_video_ids=set(), + baseline_upload_panel_text="等待视频上传", + timeout_seconds=1, + ) + + assert events == [] + + +def test_cover_trigger_clicks_modify_cover_fallback_without_data_attribute() -> None: + events: list[str] = [] + surface = _selected_video_state_surface( + body_text="视频ID: 4556559489", + upload_panel_text="本地处理完成", + fallback_visible=True, + events=events, + ) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._require_surface = lambda: surface + + uploader._cover_trigger().click() + + assert events == [ + "data_selector_missing", + "fallback_checked", + "fallback_clicked", + ] + + +@pytest.mark.parametrize("fallback_visible", [False, True]) +def test_selected_video_state_fails_closed_when_video_was_cleared( + monkeypatch, + fallback_visible: bool, +) -> None: + events: list[str] = [] + surface = _selected_video_state_surface( + body_text="等待视频上传", + upload_panel_text="等待视频上传", + fallback_visible=fallback_visible, + events=events, + ) + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._require_page = lambda: SimpleNamespace( + wait_for_timeout=lambda milliseconds: events.append(f"waited:{milliseconds}") + ) + uploader._require_surface = lambda: surface + uploader._screenshot = lambda name, *, always: ( + events.append(f"screenshot:{name}:{always}") or "cleared.png" + ) + monotonic_values = iter((0.0, 0.0, 0.2)) + monkeypatch.setattr( + upload_video_to_jd.time, + "monotonic", + lambda: next(monotonic_values), + ) + + with pytest.raises(RuntimeError, match="本地视频状态.*cleared.png"): + uploader._wait_for_selected_video_state( + baseline_body_text="等待视频上传", + baseline_video_ids=set(), + baseline_upload_panel_text="等待视频上传", + timeout_seconds=0.1, + ) + + assert "waited:500" in events + assert events[-1] == "screenshot:video-file-state-cleared:True" + + +@pytest.mark.parametrize( + ("count", "visible", "enabled", "expected"), + [ + (0, True, True, False), + (1, False, True, False), + (1, True, False, False), + (1, True, True, True), + ], +) +def test_cover_confirm_requires_a_visible_enabled_button( + count: int, + visible: bool, + enabled: bool, + expected: bool, +) -> None: + confirm = SimpleNamespace( + count=lambda: count, + nth=lambda _index: SimpleNamespace( + is_visible=lambda: visible, + is_enabled=lambda: enabled, + ), + ) + + assert upload_video_to_jd.JdVideoUploader._cover_confirm_is_ready( + confirm + ) is expected + + +def test_cover_confirm_ignores_a_hidden_last_portal_copy() -> None: + ready = SimpleNamespace( + is_visible=lambda: True, + is_enabled=lambda: True, + ) + hidden = SimpleNamespace( + is_visible=lambda: False, + is_enabled=lambda: True, + ) + buttons = [ready, hidden] + confirm = SimpleNamespace( + count=lambda: len(buttons), + nth=lambda index: buttons[index], + ) + + assert upload_video_to_jd.JdVideoUploader._cover_confirm_button(confirm) is ready + + +@pytest.mark.parametrize( + ( + "previous_src", + "current_src", + "placeholder_visible", + "opener_visible", + "expected", + ), + [ + (None, "https://img.example/cover.jpg", False, True, True), + ("old", "new", False, True, True), + ("same", "same", False, True, False), + (None, "", False, True, False), + (None, "new", True, True, True), + (None, "new", False, False, False), + ], +) +def test_cover_preview_requires_a_new_preview_and_live_trigger( + previous_src: str | None, + current_src: str | None, + placeholder_visible: bool, + opener_visible: bool, + expected: bool, +) -> None: + assert upload_video_to_jd.JdVideoUploader._cover_preview_has_landed( + previous_src=previous_src, + current_src=current_src, + placeholder_visible=placeholder_visible, + opener_visible=opener_visible, + ) is expected + + +def test_set_cover_clicks_modify_then_manual_upload_before_selecting_file( + tmp_path, +) -> None: + events: list[str] = [] + image_input = SimpleNamespace( + click=lambda: events.append("manual_upload") + ) + image_inputs = SimpleNamespace(count=lambda: 1, last=image_input) + manual_upload_button = SimpleNamespace( + is_visible=lambda: True, + click=lambda: events.append("manual_upload"), + ) + manual_upload = SimpleNamespace( + count=lambda: 1, + nth=lambda _index: manual_upload_button, + ) + confirm_button = SimpleNamespace( + is_visible=lambda: True, + is_enabled=lambda: True, + click=lambda: events.append("confirm"), + ) + confirm = SimpleNamespace( + count=lambda: 1, + nth=lambda _index: confirm_button, + ) + dialog_preview_values = ["old-dialog-preview", "old-dialog-preview", "new-dialog-preview"] + dialog_preview_reads = 0 + + class DialogPreview: + def is_visible(self): + return True + + def get_attribute(self, name): + nonlocal dialog_preview_reads + assert name == "src" + value = dialog_preview_values[ + min(dialog_preview_reads, len(dialog_preview_values) - 1) + ] + dialog_preview_reads += 1 + events.append(f"dialog_preview:{value}") + return value + + dialog_preview = DialogPreview() + dialog_previews = SimpleNamespace( + count=lambda: 1, + nth=lambda _index: dialog_preview, + ) + + class CoverDialog: + def is_visible(self): + return True + + def locator(self, selector): + if selector == 'input[type="file"][accept="image/*"]': + return image_inputs + if selector == ( + "img.reactEasyCrop_Image, .reactEasyCrop_Container img, " + 'img[src^="blob:"]' + ): + return dialog_previews + raise AssertionError(selector) + + def get_by_role(self, role, **_kwargs): + assert role == "button" + return confirm + + def get_by_text(self, text, **_kwargs): + assert text == "手动上传" + return manual_upload + + def wait_for(self, **_kwargs): + events.append("dialog_hidden") + + dialog = CoverDialog() + dialogs = SimpleNamespace(count=lambda: 1, nth=lambda _index: dialog) + placeholder = object() + opener = object() + + class Surface: + def get_by_role(self, role): + assert role == "dialog" + return dialogs + + def locator(self, selector): + if "wait-select-video" in selector: + return placeholder + if "openVideoCoverModal" in selector: + return opener + raise AssertionError(selector) + + trigger = SimpleNamespace(click=lambda: events.append("modify_cover")) + chooser = SimpleNamespace( + set_files=lambda path: events.append(f"set_files:{path}") + ) + + class FileChooserContext: + value = chooser + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + uploader = object.__new__(upload_video_to_jd.JdVideoUploader) + uploader._require_page = lambda: SimpleNamespace( + wait_for_timeout=lambda _ms: None, + expect_file_chooser=lambda **_kwargs: FileChooserContext(), + ) + uploader._require_surface = lambda: Surface() + uploader.dismiss_nps_overlay = lambda: events.append("dismiss") + uploader._cover_trigger = lambda: trigger + uploader._visible_cover_trigger = lambda: trigger + uploader._visible_count = lambda locator: 0 if locator is placeholder else 1 + preview_sources = iter(("old-preview", "new-preview")) + uploader._visible_cover_preview_src = lambda: next(preview_sources) + + cover_path = tmp_path / "cover.jpg" + cover_path.write_bytes(b"x" * upload_video_to_jd.MIN_JD_MANUAL_COVER_BYTES) + result = uploader.set_cover(cover_path) + + assert events[:4] == [ + "dismiss", + "modify_cover", + "dialog_preview:old-dialog-preview", + "manual_upload", + ] + set_files_event = f"set_files:{cover_path}" + assert set_files_event in events + assert events.index(set_files_event) < events.index( + "dialog_preview:new-dialog-preview" + ) + assert events.index("dialog_preview:new-dialog-preview") < events.index("confirm") + assert events.count("dialog_preview:old-dialog-preview") >= 2 + assert events[-2:] == ["confirm", "dialog_hidden"] + assert result["status"] == "applied" + + +def test_prepare_jd_cover_asset_reencodes_small_copy_without_mutating_source( + tmp_path, + monkeypatch, +) -> None: + source_path = tmp_path / "small-cover.jpg" + Image.effect_noise((720, 960), 100).convert("RGB").save( + source_path, + format="JPEG", + quality=10, + ) + assert source_path.stat().st_size < upload_video_to_jd.MIN_JD_MANUAL_COVER_BYTES + source_bytes = source_path.read_bytes() + prepared_root = tmp_path / "prepared-covers" + monkeypatch.setattr(upload_video_to_jd, "PREPARED_COVER_ROOT", prepared_root) + + upload_path, evidence = upload_video_to_jd.prepare_jd_cover_asset(source_path) + + assert upload_path is not None + assert upload_path != source_path + assert upload_path.parent == prepared_root + assert upload_video_to_jd.MIN_JD_MANUAL_COVER_BYTES <= upload_path.stat().st_size + assert upload_path.stat().st_size <= upload_video_to_jd.MAX_JD_MANUAL_COVER_BYTES + assert source_path.read_bytes() == source_bytes + assert evidence["status"] == "reencoded" + assert evidence["source_size_bytes"] == len(source_bytes) + assert evidence["upload_size_bytes"] == upload_path.stat().st_size + + +def test_prepare_jd_cover_asset_reuses_an_eligible_original(tmp_path) -> None: + source_path = tmp_path / "eligible-cover.jpg" + source_bytes = b"x" * upload_video_to_jd.MIN_JD_MANUAL_COVER_BYTES + source_path.write_bytes(source_bytes) + + upload_path, evidence = upload_video_to_jd.prepare_jd_cover_asset(source_path) + + assert upload_path == source_path + assert source_path.read_bytes() == source_bytes + assert evidence == { + "status": "original_eligible", + "source_size_bytes": len(source_bytes), + "upload_size_bytes": len(source_bytes), + } + + +class _ReleaseCursor: + def __init__(self) -> None: + self.calls: list[tuple[str, tuple[object, ...]]] = [] + self.rowcount = 1 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def execute(self, sql, params): + self.calls.append((sql, tuple(params))) + + +class _ReleaseConnection: + def __init__(self) -> None: + self.cursor_instance = _ReleaseCursor() + + def cursor(self): + return self.cursor_instance + + +def test_pre_submit_release_can_clear_stale_generated_payload() -> None: + connection = _ReleaseConnection() + claim = SimpleNamespace(publication_id=7, attempt_no=3) + + release_video_publication_before_submit( + connection, + claim, + reason="title corrected before submit", + clear_publish_payload=True, + ) + + publication_sql, publication_params = connection.cursor_instance.calls[0] + assert "publish_payload = CASE WHEN %s THEN NULL" in publication_sql + assert publication_params[:3] == (True, True, True) + assert publication_params[-2:] == (7, 3) + assert len(connection.cursor_instance.calls) == 2 + + +def test_feishu_second_page_failure_is_not_reported_as_empty(monkeypatch) -> None: + calls = 0 + + def fake_run(_args, **_kwargs): + nonlocal calls + calls += 1 + if calls == 1: + return { + "data": { + "fields": list(upload_video_to_jd.SOURCE_FIELDS), + "data": [["款式", [], "2026-07-28", ["光影行星"], ["男"]]], + "record_id_list": ["record"], + "has_more": True, + } + } + raise RuntimeError("page two failed") + + monkeypatch.setattr(upload_video_to_jd, "run_lark_cli", fake_run) + + with pytest.raises(RuntimeError, match="page two failed"): + upload_video_to_jd.fetch_source_records(page_size=1, max_records=10) + + +def test_feishu_scan_preserves_view_order_and_assigns_stable_positions( + monkeypatch, +) -> None: + captured_args: list[str] = [] + + def fake_run(args, **_kwargs): + captured_args.extend(args) + return { + "data": { + "fields": list(upload_video_to_jd.SOURCE_FIELDS), + "data": [ + ["凌云air", [], "", ["光影行星"], ["女"]], + ["新款", [], None, ["光影行星"], ["男"]], + ], + "record_id_list": ["anchor", "new"], + "has_more": False, + } + } + + monkeypatch.setattr(upload_video_to_jd, "run_lark_cli", fake_run) + records = upload_video_to_jd.fetch_source_records(page_size=200, max_records=10) + + assert "--sort-json" not in captured_args + assert [record["record_id"] for record in records] == ["anchor", "new"] + assert [record["source_view_position"] for record in records] == [0, 1] diff --git a/tests/modules/product_commerce/test_market_rank_hermes_notification.py b/tests/modules/product_commerce/test_market_rank_hermes_notification.py index 20c48fd..ff17599 100644 --- a/tests/modules/product_commerce/test_market_rank_hermes_notification.py +++ b/tests/modules/product_commerce/test_market_rank_hermes_notification.py @@ -1,6 +1,6 @@ from __future__ import annotations -import json +import uuid from datetime import date, datetime from unittest.mock import MagicMock, patch @@ -50,53 +50,6 @@ def test_build_notification_message_supports_partial_platform_success(): assert "当前成功平台 1 个" in message -def test_build_hermes_payload_requires_direct_message_to_recipient(): - payload = notification.build_hermes_payload( - recipient_open_id="ou_wangyunlong", - message="三个链接", - ) - - encoded = json.dumps(payload, ensure_ascii=False) - assert "ou_wangyunlong" in encoded - assert "飞书私聊" in encoded - assert "三个链接" in encoded - - -def test_extracts_real_feishu_message_id_from_hermes_completion(): - response = { - "id": "api-response-id", - "choices": [ - { - "message": { - "content": "发送成功,消息ID om_x100b696f1169f0a0b1f9c311e8dc323" - } - } - ], - } - - assert ( - notification.extract_feishu_message_id(response) - == "om_x100b696f1169f0a0b1f9c311e8dc323" - ) - assert notification.hermes_send_succeeded(response) is True - - -def test_hermes_success_text_without_real_message_id_is_not_success(): - response = { - "choices": [{"message": {"content": "发送成功,但没有返回消息回执"}}] - } - - assert notification.extract_feishu_message_id(response) is None - assert notification.hermes_send_succeeded(response) is False - - -def test_extracts_nested_feishu_message_id_and_accepts_safe_suffixes(): - response = {"data": {"message_id": "om_real-id_123"}} - - assert notification.extract_feishu_message_id(response) == "om_real-id_123" - assert notification.hermes_send_succeeded(response) is True - - def test_notify_skips_when_same_day_recipient_was_already_sent(): conn = MagicMock() with ( @@ -104,7 +57,7 @@ def test_notify_skips_when_same_day_recipient_was_already_sent(): patch.object(notification, "ensure_market_rank_notification_table"), patch.object(notification, "get_market_rank_reports_for_date", return_value=REPORTS), patch.object(notification, "market_rank_notification_was_sent", return_value=True), - patch.object(notification, "post_hermes") as post_hermes, + patch.object(notification, "send_lark_bot_message") as send_message, ): get_conn.return_value.__enter__.return_value = conn result = notification.notify_market_rank_reports( @@ -114,21 +67,12 @@ def test_notify_skips_when_same_day_recipient_was_already_sent(): assert result["status"] == "skipped" assert result["reason"] == "already_sent" - post_hermes.assert_not_called() + send_message.assert_not_called() -def test_notify_sends_only_after_all_three_links_exist_and_persists_success(): +def test_notify_uses_lark_user_sender_and_persists_success(): conn = MagicMock() - response = { - "id": "hermes-response-id", - "choices": [ - { - "message": { - "content": "发送成功,消息ID om_x100b696f1169f0a0b1f9c311e8dc323" - } - } - ], - } + response = {"message_id": "om_x100b696f1169f0a0b1f9c311e8dc323"} with ( patch.object(notification, "get_conn") as get_conn, patch.object(notification, "ensure_market_rank_notification_table"), @@ -138,7 +82,11 @@ def test_notify_sends_only_after_all_three_links_exist_and_persists_success(): "get_market_rank_reports_for_date", return_value=REPORTS, ) as get_reports, - patch.object(notification, "post_hermes", return_value=response) as post_hermes, + patch.object( + notification, + "send_lark_bot_message", + return_value=response, + ) as send_message, patch.object(notification, "upsert_market_rank_notification") as upsert, ): get_conn.return_value.__enter__.return_value = conn @@ -156,9 +104,16 @@ def test_notify_sends_only_after_all_three_links_exist_and_persists_success(): notification.PLATFORM_ORDER, updated_after=cutoff, ) - post_hermes.assert_called_once() + send_message.assert_called_once() + sent = send_message.call_args.kwargs + assert sent["user_id"] == "ou_wangyunlong" + assert sent["profile"] == "hermes-analyzer" + assert "天猫:https://example.com/tm" in sent["text"] + uuid.UUID(sent["idempotency_key"]) saved = upsert.call_args.args[1] assert saved["status"] == "sent" + assert saved["hermes_message_id"] == response["message_id"] + assert result["message_id"] == response["message_id"] assert saved["platform_links"] == { "tm": "https://example.com/tm", "jd": "https://example.com/jd", @@ -168,15 +123,17 @@ def test_notify_sends_only_after_all_three_links_exist_and_persists_success(): def test_notify_sends_available_links_when_other_platforms_are_missing(): conn = MagicMock() - response = { - "choices": [{"message": {"content": "发送成功,消息ID om_partial123"}}] - } + response = {"message_id": "om_partial123"} with ( patch.object(notification, "get_conn") as get_conn, patch.object(notification, "ensure_market_rank_notification_table"), patch.object(notification, "market_rank_notification_was_sent", return_value=False), patch.object(notification, "get_market_rank_reports_for_date", return_value=REPORTS[:2]), - patch.object(notification, "post_hermes", return_value=response) as post_hermes, + patch.object( + notification, + "send_lark_bot_message", + return_value=response, + ) as send_message, patch.object(notification, "upsert_market_rank_notification") as upsert, ): get_conn.return_value.__enter__.return_value = conn @@ -187,10 +144,41 @@ def test_notify_sends_available_links_when_other_platforms_are_missing(): assert result["status"] == "sent" assert result["missing_platforms"] == ["dy"] - post_hermes.assert_called_once() + send_message.assert_called_once() assert upsert.call_args.args[1]["status"] == "sent" +def test_notify_archives_lark_user_send_failure() -> None: + conn = MagicMock() + with ( + patch.object(notification, "get_conn") as get_conn, + patch.object(notification, "ensure_market_rank_notification_table"), + patch.object(notification, "market_rank_notification_was_sent", return_value=False), + patch.object( + notification, + "get_market_rank_reports_for_date", + return_value=REPORTS, + ), + patch.object( + notification, + "send_lark_bot_message", + side_effect=RuntimeError("lark unavailable"), + ), + patch.object(notification, "upsert_market_rank_notification") as upsert, + ): + get_conn.return_value.__enter__.return_value = conn + result = notification.notify_market_rank_reports( + report_date=date(2026, 7, 26), + recipient_open_id="ou_wangyunlong", + ) + + assert result["status"] == "failed" + assert "lark unavailable" in result["error"] + saved = upsert.call_args.args[1] + assert saved["status"] == "failed" + assert saved["hermes_message_id"] is None + + def test_notify_skips_only_when_current_run_has_no_links(): conn = MagicMock() with ( @@ -198,7 +186,7 @@ def test_notify_skips_only_when_current_run_has_no_links(): patch.object(notification, "ensure_market_rank_notification_table"), patch.object(notification, "get_market_rank_reports_for_date", return_value=[]), patch.object(notification, "market_rank_notification_was_sent") as was_sent, - patch.object(notification, "post_hermes") as post_hermes, + patch.object(notification, "send_lark_bot_message") as send_message, patch.object(notification, "upsert_market_rank_notification") as upsert, ): get_conn.return_value.__enter__.return_value = conn @@ -210,5 +198,5 @@ def test_notify_skips_only_when_current_run_has_no_links(): assert result["status"] == "skipped" assert result["reason"] == "no_platform_reports" was_sent.assert_not_called() - post_hermes.assert_not_called() + send_message.assert_not_called() assert upsert.call_args.args[1]["status"] == "incomplete" diff --git a/tests/modules/product_commerce/test_market_rank_workflow.py b/tests/modules/product_commerce/test_market_rank_workflow.py index 28349bd..a8268fe 100644 --- a/tests/modules/product_commerce/test_market_rank_workflow.py +++ b/tests/modules/product_commerce/test_market_rank_workflow.py @@ -5,6 +5,7 @@ import sys from datetime import date, timedelta from pathlib import Path from threading import Barrier +from types import SimpleNamespace import orchestrate_market_rank_collection as workflow import pytest @@ -219,6 +220,50 @@ def test_workflow_notifies_after_all_parallel_steps_finish(tmp_path): assert saved["notification"]["status"] == "sent" +def test_workflow_notifies_each_dynamic_recipient_and_aggregates_results(tmp_path): + notifications: list[str] = [] + + def successful_executor(*, platform, command, log_path, dry_run): + return { + "platform": platform, + "label": workflow.PLATFORM_LABELS[platform], + "command": command, + "log_path": str(log_path), + "started_at": "2026-08-07T10:00:00", + "finished_at": "2026-08-07T10:01:00", + "duration_seconds": 60.0, + "exit_code": 0, + "status": "success", + } + + def fake_notifier(**kwargs): + recipient = kwargs["recipient_open_id"] + notifications.append(recipient) + return {"status": "sent", "recipient_open_id": recipient} + + summary = workflow.execute_workflow( + workflow.DEFAULT_PLATFORMS, + run_id="multi-notify-run", + log_dir=tmp_path, + executor=successful_executor, + report_date=date.today(), + notify_open_ids=("ou_first", "ou_second"), + notifier=fake_notifier, + ) + + assert notifications == ["ou_first", "ou_second"] + assert summary["notification"] == { + "status": "sent", + "recipient_count": 2, + "deliveries": [ + {"status": "sent", "recipient_open_id": "ou_first"}, + {"status": "sent", "recipient_open_id": "ou_second"}, + ], + } + assert summary["notifications"] == summary["notification"]["deliveries"] + assert summary["exit_code"] == 0 + + def test_partial_current_run_links_can_still_send_successfully(tmp_path): def successful_executor(*, platform, command, log_path, dry_run): return { @@ -249,6 +294,36 @@ def test_partial_current_run_links_can_still_send_successfully(tmp_path): assert summary["status"] == "success" +def test_no_current_run_links_is_a_valid_no_notification_outcome(tmp_path): + def successful_executor(*, platform, command, log_path, dry_run): + return { + "platform": platform, + "command": command, + "log_path": str(log_path), + "started_at": "2026-08-07T10:00:00", + "finished_at": "2026-08-07T10:01:00", + "duration_seconds": 60.0, + "exit_code": 0, + "status": "success", + } + + summary = workflow.execute_workflow( + workflow.DEFAULT_PLATFORMS, + run_id="no-current-links-run", + log_dir=tmp_path, + executor=successful_executor, + notify_open_id="ou_owner", + notifier=lambda **_kwargs: { + "status": "skipped", + "reason": "no_platform_reports", + }, + ) + + assert summary["notification"]["reason"] == "no_platform_reports" + assert summary["exit_code"] == 0 + assert summary["status"] == "success" + + def test_platform_parser_deduplicates_while_preserving_order(): assert workflow.parse_platforms("dy,tm,dy,jd") == ("dy", "tm", "jd") @@ -289,6 +364,41 @@ def test_main_allows_explicit_no_notify_without_recipient(monkeypatch, tmp_path) assert observed["notify_open_id"] is None +def test_main_passes_all_dynamic_route_recipients(monkeypatch, tmp_path): + observed = {} + + def fake_execute(*args, **kwargs): + observed.update(kwargs) + return {"exit_code": 0} + + monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False) + monkeypatch.setattr(workflow, "DEFAULT_NOTIFY_OPEN_ID", "") + monkeypatch.setattr( + workflow, + "resolve_notification_route", + lambda *_args, **_kwargs: SimpleNamespace( + enabled=True, + open_ids=("ou_first", "ou_second"), + ), + ) + monkeypatch.setattr(workflow, "execute_workflow", fake_execute) + monkeypatch.setattr(workflow, "managed_data_path", Path) + monkeypatch.setattr( + sys, + "argv", + [ + "orchestrate_market_rank_collection.py", + "--dry-run", + "--log-dir", + str(tmp_path), + ], + ) + + assert workflow.main() == 0 + assert observed["notify_open_ids"] == ("ou_first", "ou_second") + assert observed["notify_open_id"] is None + + def test_weekly_launcher_uses_market_rank_orchestrator(): launcher = ( HISTORY_ROOT / "launchers_reference" / "run_weekly_market_rank.bat" diff --git a/tests/modules/product_commerce/test_persona_integrity.py b/tests/modules/product_commerce/test_persona_integrity.py index e831f81..7b59b0a 100644 --- a/tests/modules/product_commerce/test_persona_integrity.py +++ b/tests/modules/product_commerce/test_persona_integrity.py @@ -19,6 +19,26 @@ WORKERS = (tm_worker, dy_worker, jd_worker) BUSINESS_DATE = date(2026, 8, 4) +@pytest.mark.parametrize("worker", WORKERS) +def test_missing_time_select_option_is_treated_as_no_existing_record(monkeypatch, worker) -> None: + monkeypatch.setattr( + worker.subprocess, + "run", + lambda *_a, **_k: SimpleNamespace( + returncode=1, + stdout="", + stderr=json.dumps( + { + "ok": False, + "error": {"code": 800030005, "message": "not_found"}, + } + ), + ), + ) + + assert worker.find_record_id_by_time("bas_test", "tbl_test", "8.13") is None + + @pytest.mark.parametrize("worker", WORKERS) def test_platform_collector_failure_forces_nonzero(worker) -> None: assert worker.combined_result_exit_code( @@ -78,6 +98,30 @@ def test_formal_base_upsert_accepts_verified_response_record_id(monkeypatch, wor assert message == "record_id=rec12345678" +@pytest.mark.parametrize("worker", WORKERS) +def test_formal_base_update_reuses_valid_requested_record_id(monkeypatch, worker) -> None: + monkeypatch.setattr(acceptance_policy, "skip_feishu_table_write", lambda *_a, **_k: False) + monkeypatch.setattr( + worker.subprocess, + "run", + lambda *_a, **_k: SimpleNamespace( + returncode=0, + stdout=json.dumps({"ok": True, "data": {"updated": True}}), + stderr="", + ), + ) + + ok, message = worker.upsert_record( + "bas_test", + "tbl_test", + {"时间": "8.4", "男性比例": 0.5}, + record_id="rec_existing123", + ) + + assert ok is True + assert message == "record_id=rec_existing123" + + @pytest.mark.parametrize("worker", WORKERS) def test_acceptance_skip_is_not_formal_success(monkeypatch, worker) -> None: monkeypatch.setattr(acceptance_policy, "skip_feishu_table_write", lambda *_a, **_k: True) @@ -265,6 +309,21 @@ def test_zero_distributions_are_not_valid_persona() -> None: ) is False +def test_dy_persistent_profile_is_not_overwritten_by_exported_cookies() -> None: + assert dy_collector.persistent_profile_launch_options("bound-profile") == { + "user_data_dir": "bound-profile" + } + + +def test_dy_profileless_fallback_can_restore_exported_cookies() -> None: + cookies = [{"name": "session", "value": "new", "domain": ".jinritemai.com"}] + + assert dy_collector.cookie_fallback_launch_options(cookies) == { + "cookies": cookies + } + assert dy_collector.cookie_fallback_launch_options(None) == {} + + def test_dmp_skips_are_not_valid_style_results() -> None: styles = {"款A": ["123"]} assert dmp.records_exit_code( @@ -317,3 +376,24 @@ def test_tm_collector_command_redacts_credentials() -> None: assert "secret-user" not in rendered assert "secret-pass" not in rendered assert rendered.count("***") == 2 + + +def test_jd_persona_login_home_navigation_does_not_wait_for_networkidle() -> None: + project_root = Path(__file__).resolve().parents[3] + source_path = ( + project_root + / "src" + / "gyxx_flow" + / "modules" + / "product_commerce" + / "vendors" + / "jd-data-flow" + / "jd_data_collector.py" + ) + source = source_path.read_text(encoding="utf-8") + + step_login = source[source.index("def step_login") :] + home_block = step_login[step_login.index("shop.jd.com/jdm/home") :] + assert 'wait_until="networkidle"' not in home_block[:400] + assert 'wait_until="domcontentloaded"' in home_block[:400] + assert "timeout=60_000" in home_block[:400] diff --git a/tests/modules/product_commerce/test_persona_launcher.py b/tests/modules/product_commerce/test_persona_launcher.py index 59fdd7a..7778753 100644 --- a/tests/modules/product_commerce/test_persona_launcher.py +++ b/tests/modules/product_commerce/test_persona_launcher.py @@ -15,6 +15,46 @@ class PersonaLauncherTests(unittest.TestCase): def test_all_platforms_success_makes_launcher_success(self): self.assertEqual(run_daily_persona.combined_exit_code({"tm": 0, "dy": 0, "jd": 0}), 0) + def test_partial_platform_results_are_accepted_when_real_rows_were_written(self): + results = {"tm": 2, "dy": 2, "jd": 2} + summaries = { + "tm": {"ok": 31, "skipped": 0, "failed": 4, "total": 35}, + "dy": {"ok": 2, "skipped": 0, "failed": 30, "total": 31}, + "jd": {"ok": 18, "skipped": 0, "failed": 13, "total": 31}, + } + + self.assertEqual(run_daily_persona.combined_exit_code(results, summaries), 0) + + def test_partial_platform_without_real_rows_still_fails(self): + results = {"tm": 2, "dy": 2, "jd": 2} + summaries = { + "tm": {"ok": 31}, + "dy": {"ok": 0}, + "jd": {"ok": 18}, + } + + self.assertEqual(run_daily_persona.combined_exit_code(results, summaries), 2) + + def test_result_summary_is_scoped_to_current_log_segment(self): + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as directory: + path = Path(directory) / "persona.log" + path.write_text( + "[DONE] ok=0 permission_skipped=0 failed=31 total=31\n", + encoding="utf-8", + ) + offset = path.stat().st_size + with path.open("a", encoding="utf-8") as stream: + stream.write( + "[DONE] ok=18 permission_skipped=0 failed=13 total=31\n" + ) + + self.assertEqual( + run_daily_persona.read_result_summary(path, offset), + {"ok": 18, "skipped": 0, "failed": 13, "total": 31}, + ) + def test_launcher_passes_business_date_to_each_worker(self): command = run_daily_persona.build_child_command( Path("collect_persona_to_bitable.py"), diff --git a/tests/modules/product_commerce/test_product_acceptance_feishu_boundaries.py b/tests/modules/product_commerce/test_product_acceptance_feishu_boundaries.py index 45e8819..c614625 100644 --- a/tests/modules/product_commerce/test_product_acceptance_feishu_boundaries.py +++ b/tests/modules/product_commerce/test_product_acceptance_feishu_boundaries.py @@ -1,6 +1,5 @@ from __future__ import annotations -import io import json from pathlib import Path from types import SimpleNamespace @@ -11,7 +10,7 @@ import lark_cli_runtime import market_rank_hermes_notification as market_notification import pytest -from gyxx_flow.adapters import WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID +from gyxx_flow.adapters import ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID def _enable_acceptance(monkeypatch, tmp_path: Path) -> Path: @@ -103,39 +102,38 @@ def test_product_shared_lark_cli_allows_safe_profile_override(monkeypatch) -> No ] -def test_decline_hermes_prompt_forces_wang_yunlong( +def test_decline_lark_user_send_forces_acceptance_recipient( monkeypatch, tmp_path: Path, ) -> None: _enable_acceptance(monkeypatch, tmp_path) - monkeypatch.setattr(decline, "HERMES_API_KEY", "test-only") captured = {} - class Response: - def __enter__(self): - return self + def fake_send(**kwargs): + captured.update(kwargs) + return {"message_id": "om_test"} - def __exit__(self, *args): - return False - - def read(self): - return json.dumps({"choices": [{"message": {"content": "ok"}}]}).encode() - - def fake_urlopen(request, timeout): - captured["body"] = json.load(io.BytesIO(request.data)) - return Response() - - monkeypatch.setattr(decline.urllib.request, "urlopen", fake_urlopen) - decline.notify_hermes( - [{"style_name": "test", "platform": "tm"}], + monkeypatch.setattr(decline, "send_lark_bot_message", fake_send) + decline.send_decline_notification( + [ + { + "style_name": "test", + "platform": "tm", + "seg1_sales": 30, + "seg2_sales": 20, + "seg3_sales": 10, + "drop_pct_1to2": 33.3, + "drop_pct_2to3": 50.0, + "drop_pct_1to3": 66.7, + } + ], "2026-08-01", 9, openid="ou_someone_else", ) - encoded = json.dumps(captured["body"], ensure_ascii=False) - assert WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID in encoded - assert "ou_someone_else" not in encoded + assert captured["user_id"] == ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID + assert captured["profile"] == "hermes-analyzer" def test_market_rank_notification_forces_wang_yunlong( @@ -175,14 +173,14 @@ def test_market_rank_notification_forces_wang_yunlong( lambda *args, **kwargs: False, ) - def fake_post(payload): - captured["payload"] = payload + def fake_send(**kwargs): + captured["send"] = kwargs return {"message_id": "om_test"} def fake_archive(conn, record): captured["record"] = record - monkeypatch.setattr(market_notification, "post_hermes", fake_post) + monkeypatch.setattr(market_notification, "send_lark_bot_message", fake_send) monkeypatch.setattr( market_notification, "upsert_market_rank_notification", @@ -194,11 +192,13 @@ def test_market_rank_notification_forces_wang_yunlong( recipient_open_id="ou_someone_else", ) - encoded = json.dumps(captured["payload"], ensure_ascii=False) - assert WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID in encoded - assert "ou_someone_else" not in encoded - assert result["recipient_open_id"] == WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID + assert ( + captured["send"]["user_id"] + == ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID + ) + assert captured["send"]["profile"] == "hermes-analyzer" + assert result["recipient_open_id"] == ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID assert ( captured["record"]["recipient_open_id"] - == WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID + == ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID ) diff --git a/tests/modules/product_commerce/test_product_daily_raw_data_protection.py b/tests/modules/product_commerce/test_product_daily_raw_data_protection.py new file mode 100644 index 0000000..17f9517 --- /dev/null +++ b/tests/modules/product_commerce/test_product_daily_raw_data_protection.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from datetime import date + +import db as product_db + + +class _Cursor: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + +class _Connection: + def cursor(self): + return _Cursor() + + +def test_product_daily_upsert_keeps_existing_ad_metrics(monkeypatch) -> None: + captured: dict[str, object] = {} + + def fake_execute_values(_cursor, sql, rows): + captured["sql"] = sql + captured["rows"] = rows + + monkeypatch.setattr(product_db, "execute_values", fake_execute_values) + + written = product_db.upsert_product_daily_metrics( + _Connection(), + [{ + "platform": "tm", + "stat_date": date(2026, 8, 17), + "product_id": "1001", + "product_name": "测试商品", + "raw_data": {"paid_amount": "100.00"}, + "source_file": "商品经营日报.xls", + }], + ) + + assert written == 1 + assert ( + "COALESCE(product_daily_metrics.raw_data, '{}'::jsonb)\n" + " || EXCLUDED.raw_data" + ) in str(captured["sql"]) diff --git a/tests/modules/product_commerce/test_product_nested_child_bindings.py b/tests/modules/product_commerce/test_product_nested_child_bindings.py index 9bad766..e1b560b 100644 --- a/tests/modules/product_commerce/test_product_nested_child_bindings.py +++ b/tests/modules/product_commerce/test_product_nested_child_bindings.py @@ -8,6 +8,7 @@ import backfill_collect import backfill_one_day import collect_dy_persona_to_bitable as dy_persona_worker import collect_persona_to_bitable as tm_persona_worker +import dy_product_scraping as dy_product import orchestrate_daily_collection as daily import orchestrate_market_rank_collection as market_rank import orchestrate_review_collection as reviews @@ -115,6 +116,40 @@ def test_daily_run_step_rebinds_to_command_target(monkeypatch) -> None: _assert_child_binding(observed["environment"]) +def test_daily_command_logging_redacts_sensitive_values() -> None: + command = [ + "python", + "collector.py", + "--account", + "secret-user", + "--password=placeholder", + "--api-key", + "example", + "--target-date", + "2026-08-04", + ] + + rendered = daily.redacted_command(command) + + assert "secret-user" not in rendered + assert "placeholder" not in rendered + assert "example" not in rendered + assert rendered.count("[REDACTED]") == 3 + assert "2026-08-04" in rendered + + +def test_daily_saved_cookie_binding_refreshes_existing_profile(tmp_path) -> None: + profile = tmp_path / "profile" + cookie_db = profile / "Default" / "Network" / "Cookies" + cookie_db.parent.mkdir(parents=True) + cookie_db.write_bytes(b"x" * 5000) + cookies = [{"name": "session", "value": "secret", "domain": ".jinritemai.com"}] + + assert dy_product._saved_cookie_launch_options(str(profile), cookies) == { + "cookies": cookies + } + + def test_market_rank_rebinds_to_each_platform_target(monkeypatch) -> None: observed: dict[str, object] = {} monkeypatch.setattr( diff --git a/tests/modules/product_commerce/test_product_workflow_business_dates.py b/tests/modules/product_commerce/test_product_workflow_business_dates.py index 0480bb5..c421754 100644 --- a/tests/modules/product_commerce/test_product_workflow_business_dates.py +++ b/tests/modules/product_commerce/test_product_workflow_business_dates.py @@ -13,12 +13,20 @@ def _workflow(workflow_id: str) -> dict: return next(item for item in catalog["workflows"] if item["id"] == workflow_id) +def _schedule(workflow_id: str) -> dict: + schedules = json.loads( + (REPOSITORY_ROOT / "config" / "schedules.json").read_text(encoding="utf-8") + ) + return next(item for item in schedules["schedules"] if item["workflow_id"] == workflow_id) + + def test_product_workflows_forward_the_selected_business_date() -> None: persona = _workflow("product.persona.daily")["execution"]["steps"][0] style = _workflow("product.style_analysis.interval")["execution"]["steps"][0] main_image = _workflow("product.main_image.weekly")["execution"]["steps"] market_rank = _workflow("product.market_rank")["execution"]["steps"][0] monthly_sales = _workflow("product.sales_sheet.daily")["execution"]["steps"][0] + all_shop_daily = _workflow("product.erp_all_shop_daily")["execution"]["steps"][0] assert persona["args"] == ["--date", "{business_date}"] assert style["args"][-2:] == ["--end-date", "{business_date}"] @@ -33,3 +41,46 @@ def test_product_workflows_forward_the_selected_business_date() -> None: "--execute", "--allow-missing-erp-as-zero", ] + assert all_shop_daily["args"] == [ + "--from", + "{business_date}", + "--to", + "{business_date}", + "--execute", + "--force", + ] + + +def test_ecommerce_costs_workflow_runs_three_platforms_in_parallel() -> None: + workflow = _workflow("product.ecommerce_costs.daily") + steps = workflow["execution"]["steps"] + by_id = {step["id"]: step for step in steps} + + assert [step["id"] for step in steps] == [ + "tmall_download", + "tmall_import", + "jd_download", + "jd_import", + "douyin_download", + "douyin_import", + ] + for platform, collector, importer in ( + ("tmall", "collect_tmall_wanxiang_ads.py", "import_tmall_product_ads.py"), + ("jd", "collect_jd_ad_costs.py", "import_jd_ad_costs.py"), + ("douyin", "collect_douyin_qianchuan_ads.py", "import_douyin_qianchuan_ads.py"), + ): + download = by_id[f"{platform}_download"] + import_step = by_id[f"{platform}_import"] + assert download["entry"] == collector + assert download["args"] == ["--date", "{business_date}", "--headless"] + assert download.get("depends_on", []) == [] + assert import_step["entry"] == importer + assert import_step["args"] == ["--date", "{business_date}", "--execute"] + assert import_step["depends_on"] == [f"{platform}_download"] + + assert "点击下载报表" in by_id["tmall_download"]["description"] + assert "当前时间" in by_id["jd_import"]["description"] + assert "SKU" in by_id["jd_import"]["description"] + schedule = _schedule("product.ecommerce_costs.daily") + assert schedule["at"] == "10:00" + assert schedule["business_date_offset_days"] == -1 diff --git a/tests/modules/product_commerce/test_style_analysis_orchestration.py b/tests/modules/product_commerce/test_style_analysis_orchestration.py index 402390f..5af2a8d 100644 --- a/tests/modules/product_commerce/test_style_analysis_orchestration.py +++ b/tests/modules/product_commerce/test_style_analysis_orchestration.py @@ -1,5 +1,6 @@ import json import os +import re import tempfile import unittest from datetime import date @@ -7,7 +8,7 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import patch -import analyze_style_with_hermes as analysis +import analyze_style as analysis import orchestrate_daily_collection as orchestrator from config.style_config_loader import StyleConfigLoader @@ -140,6 +141,60 @@ class StyleAnalysisWindowTests(unittest.TestCase): for heading in headings: self.assertIn(heading, analysis.SYSTEM) + def test_system_prompt_and_validator_require_pure_chinese_report(self): + self.assertIn("纯中文硬约束", analysis.SYSTEM) + self.assertIn("不得出现英文单词", analysis.SYSTEM) + + valid_report = "\n".join([ + *analysis.REPORT_HEADINGS, + *analysis.REPORT_SUBHEADINGS, + "**P0**", "**重点**", "**重点**", "**重点**", "**重点**", + "**重点**", "**重点**", "**重点**", + ]) + normalized, error = analysis.validate_report_markdown(valid_report) + self.assertIsNone(error) + self.assertIn("**P0**", normalized) + + english_report = valid_report.replace("**P0**", "English text") + _, error = analysis.validate_report_markdown(english_report) + self.assertEqual(error, "MiniMax 报告包含英文内容,必须改为纯中文") + + def test_validator_removes_separate_reasoning_before_language_check(self): + valid_report = "\n".join([ + "English reasoning", + *analysis.REPORT_HEADINGS, + *analysis.REPORT_SUBHEADINGS, + "**P0**", "**重点**", "**重点**", "**重点**", "**重点**", + "**重点**", "**重点**", "**重点**", + ]) + normalized, error = analysis.validate_report_markdown(valid_report) + self.assertIsNone(error) + self.assertNotIn("English reasoning", normalized) + + def test_localization_fallback_converts_source_english_to_chinese(self): + report = "英文前缀\nSwitchLite、SKU、IP、ROI、A/B、Apollo、notes_recent_top8、**P0**" + localized = analysis._localize_report_latin(report) + + latin_tokens = set(re.findall(r"[A-Za-z][A-Za-z0-9_-]*", localized)) + self.assertEqual(latin_tokens, {"P0"}) + self.assertIn("游戏主机", localized) + self.assertIn("规格", localized) + self.assertIn("地域", localized) + self.assertIn("投入产出比", localized) + self.assertIn("甲/乙", localized) + self.assertIn("**P0**", localized) + + def test_direct_checkpoint_namespace_isolated_for_language_gate_revision(self): + self.assertEqual(analysis.CHECKPOINT_NAMESPACE, "direct_minimax_v2") + self.assertIn("direct_minimax_v2", str(analysis.StyleAnalysisCheckpointSpec.for_report( + "测试款", + date(2026, 8, 1), + date(2026, 8, 3), + 3, + "测试款 单款式多维度分析 2026-08-01~2026-08-03", + "# 内容", + ).path())) + def test_plain_seven_section_titles_are_normalized_to_markdown(self): report = """盖亚斜挎 单款式多维度分析报告 (2026-07-11~2026-07-13) @@ -164,6 +219,99 @@ class StyleAnalysisWindowTests(unittest.TestCase): self.assertTrue(normalized.startswith("# 盖亚斜挎")) self.assertTrue(analysis.has_exact_report_headings(normalized)) + def test_number_variants_and_bare_total_title_are_canonicalized(self): + blocks = ["盖亚斜挎 单款式多维度分析报告 (2026-07-11~2026-07-13)"] + for index, heading in enumerate(analysis.REPORT_HEADINGS): + subject = heading.split("、", 1)[1] + raw_heading = heading + if index == 4: + raw_heading = f"## 六、{subject}" + elif index == 6: + raw_heading = "## 总评" + blocks.extend([ + raw_heading, + *analysis.REPORT_SUBHEADINGS[index * 3:(index + 1) * 3], + "**重点**", + ]) + + normalized = analysis.normalize_report_markdown("\n".join(blocks)) + + self.assertEqual( + [ + line.strip() + for line in normalized.splitlines() + if line.strip().startswith("## ") + ], + analysis.REPORT_HEADINGS, + ) + + def test_priority_action_heading_is_inserted_for_unheaded_priority_lines(self): + blocks = ["盖亚斜挎 单款式多维度分析报告 (2026-07-11~2026-07-13)"] + for index, heading in enumerate(analysis.REPORT_HEADINGS[:-1]): + blocks.extend([ + heading, + *analysis.REPORT_SUBHEADINGS[index * 3:(index + 1) * 3], + "**重点**", + ]) + blocks.extend([ + analysis.REPORT_HEADINGS[-1], + "### 核心结论", + "**当前核心问题是转化偏低。**", + "### 转化链路", + "曝光到访客再到成交。", + "- **P0**:48小时内完成整改。", + ]) + + normalized = analysis.normalize_report_markdown("\n".join(blocks)) + + self.assertTrue(analysis.has_clear_report_hierarchy(normalized)) + self.assertIn("### 优先级行动", normalized) + + def test_minimax_adapter_disables_tools_and_uses_extended_timeout(self): + captured = {} + + def fake_call(system, user, **kwargs): + captured.update(system=system, user=user, kwargs=kwargs) + return "报告", {} + + with ( + patch.object(analysis, "call_direct_llm", fake_call), + ): + content, _ = analysis.call_minimax("system", "user") + + self.assertEqual(content, "报告") + self.assertEqual(captured["system"], "system") + self.assertEqual(captured["user"], "user") + self.assertEqual(captured["kwargs"]["model"], analysis.MINIMAX_MODEL) + self.assertEqual(captured["kwargs"]["temperature"], 0.3) + self.assertEqual(captured["kwargs"]["timeout"], analysis.MINIMAX_TIMEOUT_SECONDS) + self.assertEqual(captured["kwargs"]["max_tokens"], analysis.MINIMAX_MAX_TOKENS) + self.assertEqual(captured["kwargs"]["thinking_mode"], "disabled") + + def test_invalid_report_is_regenerated_once_before_failure(self): + valid_lines = [] + for index, heading in enumerate(analysis.REPORT_HEADINGS): + valid_lines.extend([ + heading, + *analysis.REPORT_SUBHEADINGS[index * 3:(index + 1) * 3], + "**重点**", + ]) + valid_report = "\n".join(valid_lines) + + with ( + patch.object(analysis, "MINIMAX_FORMAT_RETRIES", 1), + patch.object( + analysis, + "call_minimax", + side_effect=[("不完整报告", {"attempt": 1}), (valid_report, {"attempt": 2})], + ) as call, + ): + report, usage = analysis.generate_validated_report("user prompt") + + self.assertEqual(report, analysis.normalize_report_markdown(valid_report)) + self.assertEqual(usage, {"attempt": 2}) + self.assertEqual(call.call_count, 2) + def test_report_gets_clear_subheadings_and_key_emphasis(self): blocks = ["盖亚斜挎 单款式多维度分析报告 (2026-07-11~2026-07-13)"] for heading in analysis.REPORT_HEADINGS[:-1]: @@ -190,6 +338,32 @@ class StyleAnalysisWindowTests(unittest.TestCase): self.assertIn("**P0**", normalized) self.assertIn("**24小时内**", normalized) + def test_report_normalization_is_idempotent_for_checkpoint_recovery(self): + blocks = ["逐星GT 单款式多维度分析报告 (2026-08-01~2026-08-03)"] + for heading in analysis.REPORT_HEADINGS[:-1]: + blocks.extend([ + heading, + "### 数据快照", + "**三日访客 100**。", + "### 核心判断", + "**当前转化偏低。**", + "### 优化建议", + "P1,3天内完成优化。", + ]) + blocks.extend([ + analysis.REPORT_HEADINGS[-1], + "### 核心结论", + "**当前核心问题是转化偏低。**", + "### 转化链路", + "曝光到访客再到成交。", + "### 优先级行动", + "- **P0(48小时内)**:完成整改。", + ]) + + normalized = analysis.normalize_report_markdown("\n".join(blocks)) + + self.assertEqual(analysis.normalize_report_markdown(normalized), normalized) + def test_extra_helpful_h3_does_not_reject_complete_required_hierarchy(self): required = "\n".join(analysis.REPORT_SUBHEADINGS) report = required.replace( @@ -212,6 +386,78 @@ class StyleAnalysisWindowTests(unittest.TestCase): self.assertEqual(missing, [date(2026, 7, 15)]) + def test_batch_style_selection_requires_a_complete_calendar_window(self): + class Cursor: + def __init__(self, rows): + self.rows = rows + self.sql = "" + self.params = None + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, sql, params): + self.sql = sql + self.params = params + + def fetchall(self): + return self.rows + + class Connection: + def __init__(self, cursor): + self.cursor_value = cursor + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def cursor(self): + return self.cursor_value + + cursor = Cursor([("完整款", 100)]) + with patch.object(analysis, "get_conn", return_value=Connection(cursor)): + styles = analysis.fetch_styles_with_data(date(2026, 7, 15), 3) + + self.assertEqual(styles, ["完整款"]) + self.assertIn("HAVING COUNT(DISTINCT metric_date) = %s", cursor.sql) + self.assertEqual( + cursor.params, + (date(2026, 7, 13), date(2026, 7, 15), 3), + ) + + def test_incomplete_batch_styles_are_reported_with_present_dates(self): + cursor = type( + "Cursor", + (), + { + "__enter__": lambda self: self, + "__exit__": lambda self, exc_type, exc, tb: False, + "execute": lambda self, sql, params: setattr(self, "params", params), + "fetchall": lambda self: [("云栖", [date(2026, 7, 15)])], + }, + )() + connection = type( + "Connection", + (), + { + "__enter__": lambda self: self, + "__exit__": lambda self, exc_type, exc, tb: False, + "cursor": lambda self: cursor, + }, + )() + + with patch.object(analysis, "get_conn", return_value=connection): + incomplete = analysis.fetch_styles_with_incomplete_data( + date(2026, 7, 15), 3, + ) + + self.assertEqual(incomplete, {"云栖": [date(2026, 7, 15)]}) + class LarkEnvironmentTests(unittest.TestCase): def test_legacy_lark_config_is_used_when_default_is_missing(self): @@ -607,10 +853,6 @@ class TargetBaseWriteTests(unittest.TestCase): {"name": "时间", "type": "select"}, {"name": "笔记分析汇总", "type": "text"}, ]}}, - {"data": {"field": { - "id": "fld_time", "name": "时间", "type": "select", - "multiple": False, "options": [{"name": "2026-07-13~07-15"}], - }}}, {"data": { "data": [[['2026-07-13~07-15'], "old link"]], "fields": ["时间", "笔记分析汇总"], @@ -627,33 +869,117 @@ class TargetBaseWriteTests(unittest.TestCase): ) self.assertEqual(record_id, "rec_existing") - upsert_args = run.call_args_list[3].args[0] + upsert_args = run.call_args_list[2].args[0] self.assertIn("--record-id", upsert_args) self.assertIn("rec_existing", upsert_args) payload = json.loads(upsert_args[upsert_args.index("--json") + 1]) self.assertIn("笔记分析汇总", payload) - def test_missing_period_is_appended_without_dropping_existing_options(self): + def test_missing_period_is_created_without_rewriting_select_field(self): responses = [ - {"data": {"field": { - "id": "fld_time", "name": "时间", "type": "select", - "multiple": False, "options": [{"name": "7月第二周", "hue": "Blue"}], - }}}, - {"ok": True}, + {"data": {"fields": [ + {"name": "时间", "type": "select"}, + {"name": "笔记分析汇总", "type": "text"}, + ]}}, + {"data": { + "data": [], + "fields": ["时间", "笔记分析汇总"], + "record_id_list": [], + }}, + {"ok": True, "data": {"record_id": "rec_created"}}, ] with patch.object(analysis, "run_lark_cli", side_effect=responses) as run: - analysis.ensure_select_option( + record_id = analysis.write_analysis_link_to_base( {"base_token": "base", "table_id": "table"}, - "时间", "2026-07-13~07-15", "盖亚斜挎", + "2026-08-01~08-03", + "https://example.feishu.cn/docx/doc", + "盖亚斜挎", ) - update_args = run.call_args_list[1].args[0] - definition = json.loads(update_args[update_args.index("--json") + 1]) + self.assertEqual(record_id, "rec_created") self.assertEqual( - [option["name"] for option in definition["options"]], - ["7月第二周", "2026-07-13~07-15"], + [call.args[0][1] for call in run.call_args_list], + ["+field-list", "+record-list", "+record-upsert"], ) - self.assertIn("--yes", update_args) + payload_args = run.call_args_list[2].args[0] + payload = json.loads(payload_args[payload_args.index("--json") + 1]) + self.assertEqual(payload["时间"], "2026-08-01~08-03") + + def test_timeout_reconciles_remote_upsert_without_second_write(self): + document_url = "https://example.feishu.cn/docx/doc" + responses = [ + {"data": {"fields": [ + {"name": "时间", "type": "select"}, + {"name": "笔记分析汇总", "type": "text"}, + ]}}, + {"data": { + "data": [], + "fields": ["时间", "笔记分析汇总"], + "record_id_list": [], + }}, + RuntimeError( + 'lark-cli failed (exit=4): {"error":{"type":"network",' + '"subtype":"timeout"}}' + ), + {"data": { + "data": [[ + ["2026-08-01~08-03"], + f"[盖亚斜挎 三日单品分析]({document_url})", + ]], + "fields": ["时间", "笔记分析汇总"], + "record_id_list": ["rec_reconciled"], + }}, + ] + with ( + patch.object(analysis, "run_lark_cli", side_effect=responses) as run, + patch.object(analysis, "BASE_WRITE_RECONCILE_DELAYS_SECONDS", (0,)), + ): + record_id = analysis.write_analysis_link_to_base( + {"base_token": "base", "table_id": "table"}, + "2026-08-01~08-03", + document_url, + "盖亚斜挎", + ) + + self.assertEqual(record_id, "rec_reconciled") + self.assertEqual( + [call.args[0][1] for call in run.call_args_list], + ["+field-list", "+record-list", "+record-upsert", "+record-list"], + ) + self.assertEqual(run.call_args_list[2].kwargs["timeout"], 180) + + def test_timeout_reconciliation_requires_the_expected_report_link(self): + responses = [ + {"data": {"fields": [ + {"name": "时间", "type": "select"}, + {"name": "笔记分析汇总", "type": "text"}, + ]}}, + {"data": { + "data": [], + "fields": ["时间", "笔记分析汇总"], + "record_id_list": [], + }}, + RuntimeError("API call failed: server time out error"), + {"data": { + "data": [[ + ["2026-08-01~08-03"], + "[盖亚斜挎 三日单品分析](https://example.feishu.cn/docx/old)", + ]], + "fields": ["时间", "笔记分析汇总"], + "record_id_list": ["rec_old"], + }}, + ] + with ( + patch.object(analysis, "run_lark_cli", side_effect=responses), + patch.object(analysis, "BASE_WRITE_RECONCILE_DELAYS_SECONDS", (0,)), + ): + with self.assertRaisesRegex(RuntimeError, "server time out error"): + analysis.write_analysis_link_to_base( + {"base_token": "base", "table_id": "table"}, + "2026-08-01~08-03", + "https://example.feishu.cn/docx/new", + "盖亚斜挎", + ) class OrchestratorTests(unittest.TestCase): @@ -663,7 +989,7 @@ class OrchestratorTests(unittest.TestCase): orchestrator.run_style_analysis(args, "2026-07-15", failed=None) command = run.call_args.args[1] - self.assertIn("analyze_style_with_hermes.py", [Path(item).name for item in command]) + self.assertIn("analyze_style.py", [Path(item).name for item in command]) self.assertIn("--all-styles", command) self.assertEqual(command[command.index("--days") + 1], "3") self.assertEqual(command[command.index("--min-interval-days") + 1], "3") diff --git a/tests/modules/product_commerce/test_style_config_loader_erp_source.py b/tests/modules/product_commerce/test_style_config_loader_erp_source.py index 987e232..28b2094 100644 --- a/tests/modules/product_commerce/test_style_config_loader_erp_source.py +++ b/tests/modules/product_commerce/test_style_config_loader_erp_source.py @@ -39,25 +39,32 @@ def test_non_tm_erp_code_is_not_used_when_tm_code_is_missing() -> None: assert "天猫分组未填写" in output.getvalue() -def test_exact_style_field_wins_over_style_content_prefix(monkeypatch) -> None: - loader = StyleConfigLoader() - monkeypatch.setattr( - loader, - "_run_lark", - lambda _args: { - "data": { - "fields": ["款式内容", "款式", "ERP款式编码", "平台"], - "data": [["营销文案", "盖世m1", "10416,10455", ["天猫"]]], +def test_database_row_uses_explicit_style_name_not_style_content() -> None: + loader = StyleConfigLoader( + records_provider=lambda: [ + { + "style_content": "营销文案", + "style_name": "盖世m1", + "erp_codes": ["10416", "10455"], + "platform": "天猫", } - }, + ] ) - records = loader._load_records_from_lark() + records = loader._load_records_from_database() assert records == [ { "style": "盖世m1", - "erp_codes": "10416,10455", + "erp_codes": ["10416", "10455"], "platform": "天猫", + "item_ids": [], + "brand": "", + "sales_bitable_url": "", + "main_image_bitable_url": "", + "persona_bitable_url": "", + "dy_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "style_analysis_bitable_url": "", } ] diff --git a/tests/modules/product_commerce/test_sycm_market_rank.py b/tests/modules/product_commerce/test_sycm_market_rank.py index 9c43458..a5f2140 100644 --- a/tests/modules/product_commerce/test_sycm_market_rank.py +++ b/tests/modules/product_commerce/test_sycm_market_rank.py @@ -14,6 +14,7 @@ from collect_sycm_market_rank import ( collect_all_rank_pages, create_feishu_doc, detect_risk_control_text, + dismiss_floating_overlays, ensure_market_rank_login, group_products_by_category, inspect_feishu_batch_tables, @@ -49,6 +50,15 @@ SAMPLE_HTML = """ class SycmMarketRankTests(unittest.TestCase): + def test_floating_overlays_are_removed_before_pagination(self): + page = MagicMock() + page.evaluate.return_value = 3 + + self.assertEqual(dismiss_floating_overlays(page), 3) + script = page.evaluate.call_args.args[0] + self.assertIn("scenario-widget", script) + self.assertIn("aes-survey-hanging", script) + def test_expired_login_without_credentials_fails_clearly(self): with ( patch("collect_sycm_market_rank.sycm.ACCOUNT", ""), diff --git a/tests/modules/product_commerce/test_sync_monthly_sales_sheet.py b/tests/modules/product_commerce/test_sync_monthly_sales_sheet.py index 19928c1..0203ca1 100644 --- a/tests/modules/product_commerce/test_sync_monthly_sales_sheet.py +++ b/tests/modules/product_commerce/test_sync_monthly_sales_sheet.py @@ -1,5 +1,6 @@ from __future__ import annotations +import io from datetime import date import pytest @@ -17,6 +18,7 @@ from gyxx_flow.modules.product_commerce.sync_monthly_sales_sheet import ( MonthlySalesConfig, ProductTarget, SheetInfo, + _reconfigure_stream, _write_rows, build_parser, choose_template, @@ -43,6 +45,17 @@ def test_month_contract_uses_natural_month_and_existing_title_convention() -> No assert month_sheet_title(month) == "26年8月" +def test_reconfigure_stream_switches_gbk_stream_to_utf8() -> None: + raw = io.BytesIO() + stream = io.TextIOWrapper(raw, encoding="gbk", errors="strict") + + _reconfigure_stream(stream) + stream.write("商品\u200c名\n") + stream.flush() + + assert "商品\u200c名".encode("utf-8") in raw.getvalue() + + def test_erp_month_interval_uses_inclusive_natural_month_for_day_input() -> None: assert monthly_report_interval(parse_month("2026-07-02")) == ( "2026-07-01", diff --git a/tests/modules/product_commerce/test_tm_brand_profiles.py b/tests/modules/product_commerce/test_tm_brand_profiles.py new file mode 100644 index 0000000..1084b85 --- /dev/null +++ b/tests/modules/product_commerce/test_tm_brand_profiles.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest +import taobao_sycm_collect as collect + +from gyxx_flow.core.exit_codes import COOKIE_SKIP_EXIT_CODE + + +@pytest.fixture(autouse=True) +def stub_collection_chrome(monkeypatch): + """Keep wrapper tests from starting a real browser on collector ports.""" + + monkeypatch.setattr(collect, "is_chrome_debug_running", lambda _port: False) + monkeypatch.setattr( + collect, + "launch_chrome_with_debug", + lambda *_args, **_kwargs: True, + ) + + +def test_tm_identity_markers_require_the_complete_store_name(monkeypatch) -> None: + monkeypatch.delenv("GYXX_ACCOUNT_LOGIN_IDENTITY_MARKERS", raising=False) + + assert collect is not None + # The child collector owns the actual page identity check; import it here + # so this regression test also guards its brand fallback markers. + import taobao_sycm_products as products + + monkeypatch.setattr(products, "BRAND", "ozko") + monkeypatch.setattr(products, "ACCOUNT", "") + assert products._login_identity_markers() == ("ozko旗舰店", "OZKO旗舰店") + + monkeypatch.setattr(products, "BRAND", "光影行星") + assert products._login_identity_markers() == ("光影行星旗舰店",) + + +def test_dynamic_tm_brand_route_selects_the_requested_account_vault( + monkeypatch, +) -> None: + calls: list[str] = [] + + class FakeCatalog: + def environment_for_account(self, account_id, base_environment): + calls.append(account_id) + return { + **base_environment, + "GYXX_ACCOUNT_ID": account_id, + "GYXX_ACCOUNT_COOKIE_FILE": f"{account_id}-cookies.json", + "GYXX_ACCOUNT_STORAGE_STATE_FILE": f"{account_id}-state.json", + } + + monkeypatch.setattr( + collect.RuntimeIntegrationCatalog, + "load_default", + lambda **_kwargs: FakeCatalog(), + ) + + environment = collect._account_environment( + "tmall-ozko", + { + "GYXX_PROJECT_ROOT": "D:/project", + "GYXX_DATA_ROOT": "D:/data", + "KEEP": "yes", + }, + ) + + assert calls == ["tmall-ozko"] + assert environment["KEEP"] == "yes" + assert environment["GYXX_ACCOUNT_ID"] == "tmall-ozko" + assert environment["GYXX_ACCOUNT_COOKIE_FILE"] == "tmall-ozko-cookies.json" + + +@pytest.mark.parametrize( + ("extra_args", "expects_backfill"), + [ + ([], False), + (["--backfill"], True), + ], +) +def test_each_tm_brand_is_injected_into_its_own_child_profile( + monkeypatch, + tmp_path: Path, + extra_args: list[str], + expects_backfill: bool, +) -> None: + profile_root = tmp_path / "tm-profile" + environments: dict[str, dict[str, str]] = {} + ports: dict[str, str] = {} + launched_ports: list[int] = [] + child_scripts: list[str] = [] + child_commands: dict[str, list[str]] = {} + binding_scripts: list[str] = [] + + monkeypatch.setattr( + collect, + "get_tm_style_id_map", + lambda: {"极星pro": ["main-id"], "布谷": ["ozko-id"]}, + ) + monkeypatch.setattr( + collect, + "get_tm_brand_map", + lambda: {"极星pro": "光影行星", "布谷": "ozko"}, + ) + monkeypatch.setattr( + collect, + "get_daily_styles", + lambda: { + "tm": { + "collectable": ["极星pro", "布谷"], + "skipped": [], + } + }, + ) + def fake_child_environment(script, *_args): + binding_scripts.append(Path(script).name) + return { + "GYXX_BROWSER_PROFILE_DIR": str(profile_root), + "GYXX_BROWSER_CDP_PORT": "22096", + } + + monkeypatch.setattr(collect, "environment_for_child_script", fake_child_environment) + monkeypatch.setattr(collect, "_kill_port_listeners", lambda _port: 0) + monkeypatch.setattr( + collect, + "launch_chrome_with_debug", + lambda *_args, **kwargs: launched_ports.append(kwargs["debug_port"]) or True, + ) + + def fake_run(command, **kwargs): + brand = command[command.index("--brand") + 1] + environments[brand] = kwargs["env"] + ports[brand] = kwargs["env"]["GYXX_BROWSER_CDP_PORT"] + child_scripts.append(Path(command[1]).name) + child_commands[brand] = command + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(collect.subprocess, "run", fake_run) + monkeypatch.setattr( + sys, + "argv", + [ + "taobao_sycm_collect.py", + "--target-date", + "2026-08-06", + *extra_args, + ], + ) + + assert collect.main() == 0 + assert child_scripts == ["taobao_sycm_products.py", "taobao_sycm_products.py"] + assert binding_scripts == ["taobao_sycm_products.py"] + for brand, expected_profile in { + "光影行星": profile_root, + "ozko": profile_root.parent / f"{profile_root.name}_ozko_tmall-ozko", + }.items(): + command = child_commands[brand] + assert ("--backfill" in command) is expects_backfill + assert command[command.index("--cdp-url") + 1] == ( + "http://127.0.0.1:" + ports[brand] + ) + assert command[command.index("--brand-user-data-dir") + 1] == str( + expected_profile + ) + assert environments["光影行星"]["GYXX_BROWSER_PROFILE_DIR"] == str( + profile_root + ) + assert environments["光影行星"]["TM_USER_DATA_DIR"] == str(profile_root) + assert environments["ozko"]["GYXX_BROWSER_PROFILE_DIR"] == str( + profile_root.parent / f"{profile_root.name}_ozko_tmall-ozko" + ) + assert environments["ozko"]["TM_USER_DATA_DIR"] == str( + profile_root.parent / f"{profile_root.name}_ozko_tmall-ozko" + ) + assert ports["光影行星"] != ports["ozko"] + assert sorted(launched_ports) == [22196, 22197] + + +def test_cookie_recovery_marks_all_tm_brands_for_relogin(monkeypatch, tmp_path: Path) -> None: + calls: list[str] = [] + monkeypatch.setattr(collect, "get_tm_style_id_map", lambda: {"a": ["1"], "b": ["2"]}) + monkeypatch.setattr(collect, "get_tm_brand_map", lambda: {"a": "ozko", "b": "光影行星"}) + monkeypatch.setattr( + collect, + "get_daily_styles", + lambda: {"tm": {"collectable": ["a", "b"], "skipped": []}}, + ) + monkeypatch.setattr( + collect, + "environment_for_child_script", + lambda *_args: { + "GYXX_BROWSER_PROFILE_DIR": str(tmp_path / "profile"), + "GYXX_BROWSER_CDP_PORT": "22096", + }, + ) + + def fake_run(command, **_kwargs): + calls.append(command[command.index("--brand") + 1]) + return SimpleNamespace(returncode=COOKIE_SKIP_EXIT_CODE) + + monkeypatch.setattr(collect.subprocess, "run", fake_run) + + assert collect.main(["--target-date", "2026-08-11", "--backfill"]) == COOKIE_SKIP_EXIT_CODE + assert sorted(calls) == ["ozko", "光影行星"] + + +def test_transient_brand_child_failure_is_retried_once(monkeypatch, tmp_path: Path) -> None: + calls: list[str] = [] + monkeypatch.setattr(collect, "get_tm_style_id_map", lambda: {"a": ["1"]}) + monkeypatch.setattr(collect, "get_tm_brand_map", lambda: {"a": "ozko"}) + monkeypatch.setattr( + collect, + "get_daily_styles", + lambda: {"tm": {"collectable": ["a"], "skipped": []}}, + ) + monkeypatch.setattr( + collect, + "environment_for_child_script", + lambda *_args: { + "GYXX_BROWSER_PROFILE_DIR": str(tmp_path / "profile"), + "GYXX_BROWSER_CDP_PORT": "22096", + }, + ) + monkeypatch.setattr(collect, "_kill_port_listeners", lambda _port: 0) + + def fake_run(command, **_kwargs): + calls.append(command[command.index("--brand") + 1]) + return SimpleNamespace(returncode=1 if len(calls) == 1 else 0) + + monkeypatch.setattr(collect.subprocess, "run", fake_run) + + assert collect.main(["--target-date", "2026-08-11"]) == 0 + assert calls == ["ozko", "ozko"] + + +def test_account_routed_brand_uses_a_fresh_collection_profile(monkeypatch, tmp_path: Path) -> None: + profiles: list[str] = [] + monkeypatch.setattr(collect, "get_tm_style_id_map", lambda: {"布谷": ["1"]}) + monkeypatch.setattr(collect, "get_tm_brand_map", lambda: {"布谷": "ozko"}) + monkeypatch.setattr( + collect, + "get_daily_styles", + lambda: {"tm": {"collectable": ["布谷"], "skipped": []}}, + ) + monkeypatch.setattr( + collect, + "environment_for_child_script", + lambda *_args: { + "GYXX_PROJECT_ROOT": "D:/project", + "GYXX_DATA_ROOT": "D:/data", + "GYXX_BROWSER_PROFILE_DIR": str(tmp_path / "legacy-profile"), + "GYXX_BROWSER_CDP_PORT": "22096", + "GYXX_BROWSER_CDP_URL": "http://127.0.0.1:22096", + }, + ) + monkeypatch.setattr( + collect, + "_account_environment", + lambda account_id, environment: { + **environment, + "GYXX_ACCOUNT_ID": account_id, + "GYXX_ACCOUNT_PROFILE_DIR": str(tmp_path / "account" / "profile"), + "GYXX_ACCOUNT_CDP_PORT": "22097", + "GYXX_ACCOUNT_CDP_URL": "http://127.0.0.1:22097", + "GYXX_ACCOUNT_COLLECTION_CDP_PORT": "22143", + "GYXX_ACCOUNT_COLLECTION_CDP_URL": "http://127.0.0.1:22143", + "GYXX_ACCOUNT_COOKIE_FILE": "vault-cookies.json", + "GYXX_ACCOUNT_STORAGE_STATE_FILE": "vault-state.json", + "GYXX_ACCOUNT_REQUIRED_COOKIE_DOMAINS": "[\"taobao.com\"]", + "GYXX_ACCOUNT_REQUIRED_COOKIE_NAMES": "[\"cookie2\"]", + "GYXX_ACCOUNT_CREDENTIAL_ENV_NAMES": "[]", + "GYXX_ACCOUNT_LOGIN_IDENTITY_MARKERS": "[\"ozko\"]", + }, + ) + monkeypatch.setattr(collect, "_kill_port_listeners", lambda _port: 0) + + def fake_run(command, **kwargs): + profiles.append(kwargs["env"]["TM_USER_DATA_DIR"]) + assert kwargs["env"]["GYXX_BROWSER_CDP_PORT"] == "22143" + assert kwargs["env"]["GYXX_BROWSER_CDP_URL"] == ( + "http://127.0.0.1:22143" + ) + assert kwargs["env"]["AUTOFLOW_CDP_PORT"] == "22143" + assert kwargs["env"]["GYXX_ACCOUNT_CDP_PORT"] == "22097" + assert command[command.index("--cdp-url") + 1] == ( + "http://127.0.0.1:22143" + ) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(collect.subprocess, "run", fake_run) + + assert collect.main(["--target-date", "2026-08-11"]) == 0 + assert len(profiles) == 1 + assert Path(profiles[0]).parent == tmp_path / "account" / "collector-runs" + assert Path(profiles[0]).name.startswith("tmall-ozko-2026-08-11-") diff --git a/tests/modules/product_commerce/test_tm_daily_exit_contract.py b/tests/modules/product_commerce/test_tm_daily_exit_contract.py index aa781c7..51ef34f 100644 --- a/tests/modules/product_commerce/test_tm_daily_exit_contract.py +++ b/tests/modules/product_commerce/test_tm_daily_exit_contract.py @@ -4,6 +4,11 @@ from pathlib import Path import taobao_sycm_products as tm +from gyxx_flow.core.exit_codes import ( + COOKIE_SKIP_EXIT_CODE, + NON_RETRYABLE_EXIT_CODE, +) + class _Session: def __init__(self) -> None: @@ -14,12 +19,171 @@ class _Session: self.closed = True +class _LoginPage: + url = "https://sycm.taobao.com/cc/item_rank" + + def __init__(self, state: dict[str, bool]) -> None: + self.state = state + + def evaluate(self, *_args): + return self.state + + +def test_tm_login_state_requires_the_configured_shop(monkeypatch) -> None: + monkeypatch.setattr(tm, "ACCOUNT", "光影行星旗舰店:operator") + + wrong_shop = _LoginPage( + {"hasStoreName": False, "hasTopNav": True, "hasAvatar": True} + ) + requested_shop = _LoginPage( + {"hasStoreName": True, "hasTopNav": True, "hasAvatar": True} + ) + + assert tm.is_logged_in(wrong_shop) is False + assert tm.is_logged_in(requested_shop) is True + + +def test_tm_account_vault_replaces_stale_profile_cookies(monkeypatch, tmp_path: Path) -> None: + class Context: + def __init__(self) -> None: + self.cleared = False + + def clear_cookies(self) -> None: + self.cleared = True + + restored: list[tuple[str, str]] = [] + context = Context() + cookie_file = tmp_path / "vault-cookies.json" + storage_state_file = tmp_path / "vault-state.json" + cookie_file.write_text("[]", encoding="utf-8") + storage_state_file.write_text("{}", encoding="utf-8") + monkeypatch.setenv("GYXX_ACCOUNT_ID", "tmall-ozko") + monkeypatch.setattr( + tm, + "_runtime_state_paths", + lambda: ((str(cookie_file), str(storage_state_file)), ("", "")), + ) + monkeypatch.setattr( + tm, + "restore_browser_state", + lambda _context, *, cookie_file, storage_state_file: restored.append( + (cookie_file, storage_state_file) + ), + ) + + tm._restore_runtime_account_state(context) + + assert context.cleared is True + assert restored == [(str(cookie_file), str(storage_state_file))] + + def test_tm_main_fails_when_browser_connection_is_missing(monkeypatch) -> None: monkeypatch.setattr(tm, "connect_browser", lambda **_kwargs: (None, None)) assert tm.main(["--target-date", "2026-08-03"]) == 1 +def test_tm_backfill_delegates_inside_the_daily_browser_binding(monkeypatch) -> None: + import taobao_sycm_collect_backfill as backfill + + delegated: list[str] = [] + monkeypatch.setattr( + backfill, + "main", + lambda argv=None: delegated.extend(argv or []) or 0, + ) + + assert tm.main([ + "--target-date", + "2026-08-03", + "--brand-user-data-dir", + "brand-profile", + "--brand", + "ozko", + "--backfill", + ]) == 0 + assert delegated[:4] == [ + "--target-date", + "2026-08-03", + "--brand-user-data-dir", + "brand-profile", + ] + assert delegated[-2:] == ["--brand", "ozko"] + + +def test_tm_backfill_returns_cookie_recovery_code_without_credentials( + monkeypatch, +) -> None: + import taobao_sycm_collect_backfill as backfill + + session = _Session() + monkeypatch.setattr( + backfill, + "start_stealthy_session", + lambda *_args: (session, object()), + ) + monkeypatch.setattr( + backfill, + "ensure_logged_in", + lambda *_args: (_ for _ in ()).throw( + RuntimeError("登录态失效且未配置 SYCM_ACCOUNT/SYCM_PASSWORD") + ), + ) + + assert backfill.main(["--target-date", "2026-08-03"]) == COOKIE_SKIP_EXIT_CODE + assert session.closed is True + + +def test_tm_manual_login_waits_for_the_operator_and_closes_session( + monkeypatch, +) -> None: + session = _Session() + page = type("Page", (), {"goto": lambda *_args, **_kwargs: None})() + monkeypatch.setattr( + tm, + "connect_native_manual_login", + lambda _profile_dir: (session, page), + ) + monkeypatch.setattr(tm, "is_logged_in", lambda _page: True) + + assert tm.main([ + "--brand-user-data-dir", + "ozko-profile", + "--manual-login", + "--login-timeout-seconds", + "1", + ]) == 0 + assert tm.USER_DATA_DIR == "ozko-profile" + assert session.closed is True + + +def test_tm_login_target_returns_to_the_product_page() -> None: + assert "_target=https%3A%2F%2Fsycm.taobao.com%2Fcc%2Fitem_rank" in tm.LOGIN_URL + assert "/custom/login" not in tm.LOGIN_URL.split("_target=", 1)[1] + + +def test_tm_native_manual_login_starts_with_one_blank_tab(monkeypatch) -> None: + launched: list[tuple[str, str]] = [] + monkeypatch.setattr( + tm, + "launch_chrome_with_debug", + lambda profile, start_url=tm.LOGIN_URL: launched.append((profile, start_url)) + or True, + ) + monkeypatch.setattr( + tm, + "connect_browser", + lambda **kwargs: ("session", kwargs["cdp_url"]), + ) + monkeypatch.setattr(tm, "DEBUG_PORT", 22096) + + assert tm.connect_native_manual_login("ozko-profile") == ( + "session", + "http://127.0.0.1:22096", + ) + assert launched == [("ozko-profile", "about:blank")] + + def test_tm_main_fails_when_login_does_not_complete(monkeypatch) -> None: session = _Session() page = object() @@ -73,6 +237,9 @@ def test_tm_main_fails_when_download_has_no_valid_style( monkeypatch.setattr(tm, "export_excel_to_json_and_md", lambda _path: (None, None)) monkeypatch.setattr(tm, "summarize_styles_from_excel", lambda _path: []) monkeypatch.setattr(tm, "_kill_port_listeners", lambda _port: 0) + persisted: list[bool] = [] + monkeypatch.setattr(tm, "_persist_runtime_account_state", lambda _context: persisted.append(True)) - assert tm.main(["--target-date", "2026-08-03"]) == 1 + assert tm.main(["--target-date", "2026-08-03"]) == NON_RETRYABLE_EXIT_CODE assert session.closed is True + assert persisted == [] diff --git a/tests/modules/product_commerce/test_tm_persona_recovery.py b/tests/modules/product_commerce/test_tm_persona_recovery.py index a95ee2f..7a1c4ed 100644 --- a/tests/modules/product_commerce/test_tm_persona_recovery.py +++ b/tests/modules/product_commerce/test_tm_persona_recovery.py @@ -1,11 +1,73 @@ import unittest from pathlib import Path -from unittest.mock import Mock +from unittest.mock import Mock, patch import taobao_dmp_item_crowd_insight_screenshots as dmp class TmPersonaRecoveryTests(unittest.TestCase): + def test_cdp_connection_uses_adapter_existing_context_mode(self): + page = Mock() + context = Mock() + context.new_page.return_value = page + session = Mock() + session.context = context + browser_factory = Mock() + browser_factory.return_value.start.return_value = session + + with patch.object(dmp, "ScraplingBrowser", browser_factory): + actual_session, actual_page = dmp._connect_cdp() + + browser_factory.assert_called_once_with( + stealthy=True, + reuse_existing_cdp_context=True, + cdp_url=dmp.CDP_URL, + timeout=60000, + network_idle=False, + disable_resources=False, + block_ads=False, + ) + self.assertIs(actual_session, session) + self.assertIs(actual_page, page) + page.set_viewport_size.assert_called_once_with( + {"width": 1920, "height": 1080} + ) + + def test_item_insight_spa_route_is_authenticated_before_widgets_render(self): + page = Mock() + page.url = "https://dmp.taobao.com/index_new.html#!/items/item-insight" + + with ( + patch.object(dmp, "login_form_visible", return_value=False), + patch.object(dmp, "dmp_app_ready", return_value=False), + patch.object(dmp, "page_text", return_value=""), + ): + self.assertTrue(dmp.is_logged_in(page)) + + def test_login_redirect_is_not_authenticated_when_spa_text_leaks(self): + page = Mock() + page.url = ( + "https://dmp.taobao.com/login.html?mxredirectUrl=" + "https%3A%2F%2Fdmp.taobao.com%2Findex_new.html%23!%2Fitems%2Fitem-insight" + ) + + with ( + patch.object(dmp, "login_form_visible", return_value=False), + patch.object(dmp, "dmp_app_ready", return_value=False), + patch.object(dmp, "page_text", return_value="单品信息 单品人群洞察"), + ): + self.assertFalse(dmp.is_logged_in(page)) + + def test_login_redirect_is_not_ready_when_spa_text_leaks(self): + page = Mock() + page.url = ( + "https://dmp.taobao.com/login.html?mxredirectUrl=" + "https%3A%2F%2Fdmp.taobao.com%2Findex_new.html%23!%2Fitems%2Fitem-insight" + ) + + self.assertFalse(dmp.dmp_app_ready(page)) + page.evaluate.assert_not_called() + def test_zero_chart_skip_is_not_platform_success(self): self.assertEqual( dmp.records_exit_code( diff --git a/tests/modules/product_commerce/test_tmall_baibu_apply.py b/tests/modules/product_commerce/test_tmall_baibu_apply.py new file mode 100644 index 0000000..d5fc07b --- /dev/null +++ b/tests/modules/product_commerce/test_tmall_baibu_apply.py @@ -0,0 +1,548 @@ +from __future__ import annotations + +import json +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace + +import pytest +import tmall_baibu_apply_common as baibu + + +def _entry() -> baibu.BaibuEntry: + return baibu.BaibuEntry( + entry_id="demo", + name="测试入口", + account_id="tmall-shop", + account_marker="测试店铺", + template_url="https://feishu.example/wiki/template", + apply_url="https://myseller.example/apply", + script="demo.py", + ) + + +def test_latest_batch_import_status_parses_processing_and_complete_summary() -> None: + processing = baibu._latest_batch_import_status( + "您于2026-08-26 09:23:37执行了商品批量导入操作,正在处理中。" + ) + assert processing is not None + assert processing.state == "processing" + + completed = baibu._latest_batch_import_status( + "最近操作:您于2026-08-26 09:23:37执行了商品批量导入操作," + "总数量20件,已成功20件,失败0件,待查看0件。" + ) + assert completed is not None + assert completed.state == "success" + assert completed.detail == "总数量20件,已成功20件,失败0件,待查看0件" + + +def test_latest_batch_import_status_marks_partial_summary_as_failure() -> None: + result = baibu._latest_batch_import_status( + "您于2026-08-26 16:53:04执行了商品批量导入操作," + "总数量95件,已成功8件,失败87件,待查看0件。" + ) + assert result is not None + assert result.state == "failure" + assert "失败87件" in result.detail + + +def test_credentials_are_loaded_from_account_declared_environment_names( + monkeypatch, +) -> None: + monkeypatch.setenv( + "GYXX_ACCOUNT_CREDENTIAL_ENV_NAMES", + '["TMALL_BAG_ACCOUNT", "TMALL_BAG_PASSWORD"]', + ) + monkeypatch.delenv("GYXX_BROWSER_CREDENTIAL_ENV_NAMES", raising=False) + monkeypatch.setenv("TMALL_BAG_ACCOUNT", "demo-account") + monkeypatch.setenv("TMALL_BAG_PASSWORD", "demo-password") + + credentials, names = baibu._credentials_from_environment() + + assert names == ("TMALL_BAG_ACCOUNT", "TMALL_BAG_PASSWORD") + assert credentials is not None + assert credentials.username == "demo-account" + assert credentials.password == "demo-password" + assert credentials.username_env == "TMALL_BAG_ACCOUNT" + assert credentials.password_env == "TMALL_BAG_PASSWORD" + + +def test_credentials_prefer_the_tmall_shop_pair_over_legacy_pairs( + monkeypatch, +) -> None: + monkeypatch.setenv( + "GYXX_ACCOUNT_CREDENTIAL_ENV_NAMES", + '["TMALL_SHOP_ACCOUNT", "TMALL_SHOP_PASSWORD", ' + '"SYCM_ACCOUNT", "SYCM_PASSWORD"]', + ) + monkeypatch.setenv("TMALL_SHOP_ACCOUNT", "完整店铺名:子账号") + monkeypatch.setenv("TMALL_SHOP_PASSWORD", "shop-password") + monkeypatch.setenv("SYCM_ACCOUNT", "legacy-account") + monkeypatch.setenv("SYCM_PASSWORD", "legacy-password") + + credentials, names = baibu._credentials_from_environment() + + assert names == ( + "TMALL_SHOP_ACCOUNT", + "TMALL_SHOP_PASSWORD", + "SYCM_ACCOUNT", + "SYCM_PASSWORD", + ) + assert credentials is not None + assert credentials.username == "完整店铺名:子账号" + assert credentials.username_env == "TMALL_SHOP_ACCOUNT" + assert credentials.password_env == "TMALL_SHOP_PASSWORD" + + +class _LoginLocator: + def __init__(self, *, visible: bool = True) -> None: + self.visible = visible + self.filled: list[str] = [] + self.click_count = 0 + + def count(self) -> int: + return 1 if self.visible else 0 + + def nth(self, _index: int): + return self + + def is_visible(self) -> bool: + return self.visible + + def is_enabled(self) -> bool: + return True + + def fill(self, value: str) -> None: + self.filled.append(value) + + def click(self, **_kwargs) -> None: + self.click_count += 1 + + +class _LoginFrame: + def __init__(self) -> None: + self.username = _LoginLocator() + self.password = _LoginLocator() + self.submit = _LoginLocator() + + def locator(self, selector: str): + return { + "input[placeholder='账号名/邮箱/手机号']": self.username, + "input[placeholder='请输入登录密码']": self.password, + "button[type='submit']": _LoginLocator(visible=False), + "input[type='submit']": _LoginLocator(visible=False), + }.get(selector, _LoginLocator(visible=False)) + + def get_by_role(self, role: str, *, name: str, exact: bool): + if role == "button" and name == "登录" and exact: + return self.submit + return _LoginLocator(visible=False) + + def get_by_text(self, label: str, *, exact: bool): + return _LoginLocator(visible=False) + + +def test_login_if_needed_fills_the_password_form_inside_an_iframe(monkeypatch) -> None: + monkeypatch.setenv( + "GYXX_ACCOUNT_CREDENTIAL_ENV_NAMES", + '["TMALL_BAG_ACCOUNT", "TMALL_BAG_PASSWORD"]', + ) + monkeypatch.setenv("TMALL_BAG_ACCOUNT", "demo-account") + monkeypatch.setenv("TMALL_BAG_PASSWORD", "demo-password") + monkeypatch.setattr(baibu, "_authenticated", lambda _entry, _page: False) + frame = _LoginFrame() + page = SimpleNamespace( + url="https://loginmyseller.taobao.com/?redirect_url=https%3A%2F%2Fqn.taobao.com", + frames=[frame], + ) + + attempted = baibu._login_if_needed(page=page, entry=_entry()) + + assert attempted is True + assert frame.username.filled == ["demo-account"] + assert frame.password.filled == ["demo-password"] + assert frame.submit.click_count == 1 + + +def test_manual_login_waits_without_filling_the_password_form(monkeypatch) -> None: + monkeypatch.setattr(baibu, "_authenticated", lambda _entry, _page: False) + frame = _LoginFrame() + page = SimpleNamespace( + url="https://loginmyseller.taobao.com/", + frames=[frame], + ) + + attempted = baibu._login_if_needed( + page=page, + entry=_entry(), + manual_login=True, + ) + + assert attempted is True + assert frame.username.filled == [] + assert frame.password.filled == [] + assert frame.submit.click_count == 0 + + +def test_login_if_needed_reports_missing_credentials_without_filling(monkeypatch) -> None: + monkeypatch.setenv( + "GYXX_ACCOUNT_CREDENTIAL_ENV_NAMES", + '["TMALL_BAG_ACCOUNT", "TMALL_BAG_PASSWORD"]', + ) + monkeypatch.delenv("TMALL_BAG_ACCOUNT", raising=False) + monkeypatch.delenv("TMALL_BAG_PASSWORD", raising=False) + monkeypatch.setattr(baibu, "_authenticated", lambda _entry, _page: False) + frame = _LoginFrame() + page = SimpleNamespace( + url="https://loginmyseller.taobao.com/", + frames=[frame], + ) + + try: + baibu._login_if_needed(page=page, entry=_entry()) + except baibu.BaibuApplyError as exc: + assert "TMALL_BAG_ACCOUNT" in str(exc) + assert "TMALL_BAG_PASSWORD" in str(exc) + else: + raise AssertionError("missing credentials must stop before filling") + + assert frame.username.filled == [] + assert frame.password.filled == [] + + +def test_wait_for_authenticated_stops_on_explicit_login_error(monkeypatch) -> None: + monkeypatch.setattr(baibu, "_authenticated", lambda _entry, _page: False) + monkeypatch.setattr(baibu, "_page_text", lambda _page: "账密错误") + + with pytest.raises(baibu.BaibuApplyError, match="账密错误"): + baibu._wait_for_authenticated( + _entry(), + SimpleNamespace(url="https://loginmyseller.taobao.com/"), + login_attempted=True, + ) + + +def test_login_second_factor_marker_is_detected(monkeypatch) -> None: + monkeypatch.setattr(baibu, "_page_text", lambda _page: "你正在使用手机短信验证身份") + + assert baibu._login_second_factor_marker(SimpleNamespace()) == "短信验证" + + +def test_persist_account_state_filters_cookies_to_the_account_domains( + monkeypatch, + tmp_path: Path, +) -> None: + account_cookie_file = tmp_path / "state" / "accounts" / "tmall-bag" / "cookies.json" + account_storage_file = ( + tmp_path / "state" / "accounts" / "tmall-bag" / "storage_state.json" + ) + monkeypatch.setenv("GYXX_ACCOUNT_COOKIE_FILE", str(account_cookie_file)) + monkeypatch.setenv("GYXX_ACCOUNT_STORAGE_STATE_FILE", str(account_storage_file)) + monkeypatch.setenv( + "GYXX_ACCOUNT_REQUIRED_COOKIE_DOMAINS", + '["taobao.com", "alimama.com"]', + ) + monkeypatch.setenv( + "GYXX_ACCOUNT_REQUIRED_COOKIE_NAMES", + '["unb", "cookie2", "_tb_token_"]', + ) + + cookies = [ + {"name": "unb", "domain": ".taobao.com", "value": "valid"}, + {"name": "other", "domain": "example.com", "value": "unrelated"}, + ] + + class Context: + def cookies(self): + return cookies + + def storage_state(self): + return { + "cookies": cookies, + "origins": [ + {"origin": "https://qn.taobao.com", "localStorage": []}, + {"origin": "https://example.com", "localStorage": []}, + ], + } + + browser = SimpleNamespace(context=Context()) + + assert baibu._persist_account_state(browser, _entry()) is True + + saved_cookies = json.loads(account_cookie_file.read_text(encoding="utf-8")) + saved_state = json.loads(account_storage_file.read_text(encoding="utf-8")) + assert saved_cookies == [cookies[0]] + assert saved_state["cookies"] == [cookies[0]] + assert saved_state["origins"] == [ + {"origin": "https://qn.taobao.com", "localStorage": []} + ] + + +def test_upload_template_supports_tmall_dialog_import_button( + monkeypatch, + tmp_path: Path, +) -> None: + calls: list[tuple[str, ...]] = [] + + class Chooser: + def __init__(self) -> None: + self.files: str | None = None + + def set_files(self, value: str) -> None: + self.files = value + + chooser = Chooser() + + class ExpectChooser: + def __enter__(self): + return SimpleNamespace(value=chooser) + + def __exit__(self, *_args) -> None: + return None + + monkeypatch.setattr(baibu, "_file_inputs", lambda _page: []) + + def click(_page, labels): + labels = tuple(labels) + calls.append(labels) + return "导入表格" in labels + + monkeypatch.setattr(baibu, "_click_text", click) + page = SimpleNamespace( + expect_file_chooser=lambda **_kwargs: ExpectChooser(), + ) + template = tmp_path / "template.xlsx" + template.write_bytes(b"xlsx") + + baibu._upload_template(page, template) + + assert calls[0] == ("导入表格",) + assert chooser.files == str(template) + + +def test_open_batch_import_refuses_an_active_previous_operation(monkeypatch) -> None: + page = SimpleNamespace() + monkeypatch.setattr( + baibu, + "_page_text", + lambda _page: "您于2026-08-26 09:23:37执行了商品批量导入操作,正在处理中。", + ) + monkeypatch.setattr(baibu, "_refresh_batch_results", lambda _page: True) + + try: + baibu._open_batch_import(page, _entry()) + except baibu.BaibuApplyError as exc: + assert "正在处理" in str(exc) + else: + raise AssertionError("active batch import must not be submitted again") + + +def test_open_batch_import_waits_for_hydrated_previous_summary(monkeypatch) -> None: + page = SimpleNamespace() + texts = iter( + [ + "测试店铺 商品批量导入", + "测试店铺 最近操作:您于2026-08-26 16:53:04执行了商品批量导入操作," + "总数量2件,已成功2件,失败0件,待查看0件。", + ] + ) + monkeypatch.setattr(baibu, "_page_text", lambda _page: next(texts)) + monkeypatch.setattr(baibu.time, "sleep", lambda _seconds: None) + monkeypatch.setattr(baibu.time, "monotonic", lambda: 0.0) + monkeypatch.setattr( + baibu, + "_click_text", + lambda _page, _labels: True, + ) + + previous = baibu._open_batch_import(page, _entry()) + + assert previous is not None + assert previous.state == "success" + + +def test_wait_import_result_refreshes_and_accepts_new_success_summary( + monkeypatch, +) -> None: + texts = iter( + [ + "您于2026-08-26 16:53:04执行了商品批量导入操作,正在处理中。", + "您于2026-08-26 16:53:04执行了商品批量导入操作," + "总数量2件,已成功2件,失败0件,待查看0件。", + ] + ) + refreshes: list[bool] = [] + monkeypatch.setattr(baibu, "_page_text", lambda _page: next(texts)) + monkeypatch.setattr( + baibu, + "_refresh_batch_results", + lambda _page: refreshes.append(True) or True, + ) + monkeypatch.setattr(baibu.time, "sleep", lambda _seconds: None) + monkeypatch.setattr(baibu.time, "monotonic", lambda: 0.0) + monkeypatch.setattr(baibu, "_RESULT_REFRESH_INTERVAL_SECONDS", 0) + + baibu._wait_import_result(SimpleNamespace(), _entry()) + + assert refreshes + + +def test_wait_import_result_accepts_new_failure_summary_as_submitted( + monkeypatch, +) -> None: + texts = iter( + [ + "您于2026-08-26 16:53:04执行了商品批量导入操作,正在处理中。", + "您于2026-08-26 16:53:04执行了商品批量导入操作," + "总数量95件,已成功0件,失败95件,待查看0件。", + ] + ) + monkeypatch.setattr(baibu, "_page_text", lambda _page: next(texts)) + monkeypatch.setattr(baibu, "_refresh_batch_results", lambda _page: True) + monkeypatch.setattr(baibu.time, "sleep", lambda _seconds: None) + monkeypatch.setattr(baibu.time, "monotonic", lambda: 0.0) + monkeypatch.setattr(baibu, "_RESULT_REFRESH_INTERVAL_SECONDS", 0) + + # Tmall's row-level result is informational; submitting the batch is the + # success condition for this workflow. + baibu._wait_import_result(SimpleNamespace(), _entry()) + + +def test_wait_import_result_ignores_stale_summary_until_signature_changes( + monkeypatch, +) -> None: + previous = baibu._latest_batch_import_status( + "您于2026-08-26 16:53:04执行了商品批量导入操作," + "总数量95件,已成功8件,失败87件,待查看0件。" + ) + assert previous is not None + texts = iter( + [ + "最近操作:您于2026-08-26 16:53:04执行了商品批量导入操作," + "总数量95件,已成功8件,失败87件,待查看0件。", + "您于2026-08-26 17:30:01执行了商品批量导入操作,正在处理中。", + "您于2026-08-26 17:30:01执行了商品批量导入操作," + "总数量2件,已成功2件,失败0件,待查看0件。", + ] + ) + monkeypatch.setattr(baibu, "_page_text", lambda _page: next(texts)) + monkeypatch.setattr(baibu, "_refresh_batch_results", lambda _page: True) + monkeypatch.setattr(baibu.time, "sleep", lambda _seconds: None) + monkeypatch.setattr(baibu.time, "monotonic", lambda: 0.0) + monkeypatch.setattr(baibu, "_RESULT_REFRESH_INTERVAL_SECONDS", 0) + + baibu._wait_import_result( + SimpleNamespace(), + _entry(), + previous_operation=previous, + ) + + +def test_complete_draft_products_runs_required_sequence_until_count_is_zero( + monkeypatch, +) -> None: + entry = replace(_entry(), complete_drafts_after_import=True) + page = SimpleNamespace() + page_texts = iter(["草稿 (2)", "草稿 (1)", "草稿 (0)"]) + events: list[str] = [] + clicked: list[tuple[str, ...]] = [] + opened: list[bool] = [] + + monkeypatch.setattr( + baibu, + "_click_draft_tab", + lambda _page: events.append("draft") or True, + ) + monkeypatch.setattr(baibu, "_page_text", lambda _page: next(page_texts)) + monkeypatch.setattr(baibu, "_visible_text_count", lambda _page, _label: 1) + monkeypatch.setattr(baibu, "_has_visible_text", lambda _page, _labels: True) + monkeypatch.setattr( + baibu, + "_open_draft_editor", + lambda _page, _entry, *, first: events.append( + f"open:{first}" + ) or opened.append(first), + ) + monkeypatch.setattr( + baibu, + "_click_text_until_found", + lambda _page, labels, **_kwargs: events.append(next(iter(labels))) + or clicked.append(tuple(labels)), + ) + + baibu._complete_draft_products(page, entry) + + assert opened == [True, False] + assert events == [ + "draft", + "open:True", + "否", + "下一步", + "draft", + "open:False", + "否", + "下一步", + "draft", + ] + assert clicked == [ + ("否",), + ("下一步",), + ("否",), + ("下一步",), + ] + + +def test_complete_draft_products_is_disabled_for_other_entries(monkeypatch) -> None: + monkeypatch.setattr( + baibu, + "_click_draft_tab", + lambda _page: pytest.fail("其他入口不得进入草稿处理流程"), + ) + + baibu._complete_draft_products(SimpleNamespace(), _entry()) + + +def test_complete_draft_products_accepts_empty_data_page(monkeypatch) -> None: + entry = replace(_entry(), complete_drafts_after_import=True) + page = SimpleNamespace() + + monkeypatch.setattr(baibu, "_click_draft_tab", lambda _page: True) + monkeypatch.setattr(baibu, "_page_text", lambda _page: "草稿 数据为空") + monkeypatch.setattr(baibu, "_visible_text_count", lambda _page, _label: 0) + + baibu._complete_draft_products(page, entry) + + +def test_return_to_draft_list_falls_back_to_browser_history(monkeypatch) -> None: + events: list[str] = [] + + class Page: + def go_back(self, **_kwargs) -> None: + events.append("back") + + monkeypatch.setattr( + baibu, + "_click_draft_tab", + lambda _page: events.append("draft") or False, + ) + monkeypatch.setattr(baibu, "_close_editor", lambda _page: False) + monkeypatch.setattr( + baibu, + "_has_visible_text", + lambda _page, labels: "完善商品" in labels and "back" in events, + ) + + baibu._return_to_draft_list(Page(), _entry()) + + assert events == ["draft", "back"] + + +def test_tmall_config_enables_draft_completion_for_only_old_all_3c() -> None: + old_all_3c = baibu._load_entry("old_all_3c") + old_all_bag = baibu._load_entry("old_all_bag") + new_all_bag = baibu._load_entry("new_all_bag") + + assert old_all_3c.complete_drafts_after_import is True + assert old_all_bag.complete_drafts_after_import is False + assert new_all_bag.complete_drafts_after_import is False diff --git a/tests/modules/product_commerce/test_tmall_video_attachment_idempotency.py b/tests/modules/product_commerce/test_tmall_video_attachment_idempotency.py new file mode 100644 index 0000000..e336045 --- /dev/null +++ b/tests/modules/product_commerce/test_tmall_video_attachment_idempotency.py @@ -0,0 +1,572 @@ +from __future__ import annotations + +import sys +from collections import defaultdict +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +import upload_video_to_guanghe as tmall + + +def _record( + record_id: str, + attachments: list[dict[str, Any]], + *, + product_names: str = "极星双肩", + record_date: object = None, +) -> dict[str, Any]: + return { + "record_id": record_id, + "代拍产品": product_names, + "代拍附件": attachments, + "日期": record_date, + "品牌": ["光影行星"], + "性别": ["女"], + tmall.TM_UPLOAD_FIELD: False, + } + + +def _one_candidate(*, store: str = tmall.STORE_LUGGAGE): + candidates, issues = tmall.discover_tmall_publication_candidates( + [ + _record( + "record-one", + [ + { + "type": "file", + "name": "video.mp4", + "file_token": "video-token", + "size": 123, + } + ], + ) + ], + store=store, + ) + assert issues == [] + return candidates[0] + + +def test_catalog_matching_accepts_variant_after_bag_stem() -> None: + assert tmall._match_tm_item_ids_from_product_titles( + ["盖亚斜挎3"], + [ + ( + "1059764111499", + "GYXX/光影行星盖亚斜挎包3单反相机包男款微单摄影包", + ) + ], + ) == {"盖亚斜挎3": ["1059764111499"]} + + +def test_catalog_search_pattern_keeps_variant_alias_rows_in_scope() -> None: + assert tmall._tm_product_title_search_pattern("盖亚斜挎3") == "%盖亚斜挎%" + + +def test_invalid_compatible_video_cache_is_rebuilt( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + source = tmp_path / "source.mov" + source.write_bytes(b"source") + cached = source.with_name("source_compatible.mp4") + cached.write_bytes(b"truncated-cache") + + class _Reader: + def __init__(self, metadata: dict[str, Any]) -> None: + self.metadata = metadata + + def __iter__(self) -> _Reader: + return self + + def __next__(self) -> dict[str, Any]: + return self.metadata + + def close(self) -> None: + return None + + class _FakeImageioFfmpeg: + @staticmethod + def get_ffmpeg_exe() -> str: + return "ffmpeg" + + @staticmethod + def read_frames(path: str) -> _Reader: + if path == str(cached): + raise OSError("moov atom not found") + return _Reader({"codec": "hevc", "source_size": (1920, 1080)}) + + monkeypatch.setitem(sys.modules, "imageio_ffmpeg", _FakeImageioFfmpeg) + + def fake_run(command: list[str], **_kwargs: Any) -> SimpleNamespace: + Path(command[-1]).write_bytes(b"rebuilt") + return SimpleNamespace(returncode=0, stderr="", stdout="") + + monkeypatch.setattr(tmall.subprocess, "run", fake_run) + + assert tmall.ensure_upload_compatible_video(source) == cached + assert cached.read_bytes() == b"rebuilt" + + +def test_discovery_creates_one_candidate_per_exact_video_attachment() -> None: + candidates, issues = tmall.discover_tmall_publication_candidates( + [ + _record( + "record-with-two-videos", + [ + { + "type": "file", + "name": "same-name.mp4", + "file_token": "video-token-a", + "size": "101", + }, + { + "type": "file", + "name": "same-name.mp4", + "file_token": "video-token-b", + "size": 202, + }, + { + "type": "file", + "name": "cover.jpg", + "file_token": "image-token", + }, + { + "type": "file", + "name": "generic-attachment", + "file_token": "generic-file-token", + }, + ], + record_date=None, + ), + _record( + "record-marked-skip", + [ + { + "type": "file", + "name": "must-not-upload.mp4", + "file_token": "skipped-token", + } + ], + product_names="极星双肩 / 未到货", + ), + ], + store=tmall.STORE_LUGGAGE, + ) + + assert { + (candidate.source_record_id, candidate.video_file_token) + for candidate in candidates + } == { + ("record-with-two-videos", "video-token-a"), + ("record-with-two-videos", "video-token-b"), + } + assert {candidate.video_file_name for candidate in candidates} == { + "same-name.mp4" + } + assert all(candidate.source_record_date is None for candidate in candidates) + assert [ + (issue.source_record_id, issue.reason) for issue in issues + ] == [("record-marked-skip", "代拍产品包含未到货/不发标记")] + + +def test_flagship_and_luggage_use_distinct_account_scopes() -> None: + flagship = _one_candidate(store=tmall.STORE_FLAGSHIP) + luggage = _one_candidate(store=tmall.STORE_LUGGAGE) + + assert flagship.target_account_key == "guanghe-flagship" + assert luggage.target_account_key == "guanghe-luggage" + assert flagship.target_account_key != luggage.target_account_key + assert tmall.STORE_ACCOUNT_KEYS == { + tmall.STORE_FLAGSHIP: flagship.target_account_key, + tmall.STORE_LUGGAGE: luggage.target_account_key, + } + + +def test_luggage_runtime_uses_new_store_account_label_without_changing_password_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("GUANGHE_LUGGAGE_USERNAME", "光影行星鑫华达专卖店:龙虾仔") + monkeypatch.setenv("GUANGHE_LUGGAGE_PASSWORD", "same-runtime-secret") + + store_name, username, password, _profile = tmall._store_runtime(tmall.STORE_LUGGAGE) + + assert store_name == "光影行星鑫华达专卖店:龙虾仔" + assert username == "光影行星鑫华达专卖店:龙虾仔" + assert password == "same-runtime-secret" + + +def test_account_identity_markers_accept_store_name_with_login_nickname() -> None: + assert tmall.GuangHeUploader._account_identity_markers( + "光影行星鑫华达专卖店:龙虾仔" + ) == ( + "光影行星鑫华达专卖店:龙虾仔", + "光影行星鑫华达专卖店", + ) + + +def test_flagship_creator_identity_uses_visible_name_and_account_number() -> None: + assert tmall._expected_creator_identity_markers( + tmall.STORE_FLAGSHIP, + "光影行星旗舰店", + ) == ("光影行星天猫官方", "逛逛号:3301283916") + + +def test_creator_identity_requires_every_configured_marker() -> None: + class Body: + def __init__(self, text: str) -> None: + self.text = text + + def inner_text(self, **_kwargs: object) -> str: + return self.text + + class Page: + def __init__(self, text: str) -> None: + self.text = text + + def locator(self, selector: str) -> Body: + assert selector == "body" + return Body(self.text) + + uploader = object.__new__(tmall.GuangHeUploader) + uploader.expected_account_markers = ( + "光影行星天猫官方", + "逛逛号:3301283916", + ) + uploader.page = Page("光影行星天猫官方\n逛逛号:3301283916\n账号正常") + assert uploader._page_matches_expected_account() + + uploader.page = Page("光影行星天猫官方\n账号正常") + assert not uploader._page_matches_expected_account() + + +def test_wait_for_existing_login_allows_account_card_to_render( + monkeypatch: pytest.MonkeyPatch, +) -> None: + uploader = object.__new__(tmall.GuangHeUploader) + states = iter((False, True)) + uploader._sync_page = lambda: None + uploader._is_logged_in = lambda: next(states) + monkeypatch.setattr(tmall.time, "monotonic", lambda: 0.0) + monkeypatch.setattr(tmall.time, "sleep", lambda _seconds: None) + + assert uploader._wait_for_existing_login(timeout_seconds=8) + + +def test_legacy_backfill_has_25_exact_attachment_matches_and_fails_on_drift() -> None: + entries = tmall.load_legacy_publication_backfill() + assert len(entries) == 25 + + attachments_by_record: dict[str, list[dict[str, Any]]] = defaultdict(list) + for entry in entries: + attachments_by_record[entry["source_record_id"]].append( + { + "type": "file", + "name": entry["video_file_name"], + "file_token": entry["video_file_token"], + } + ) + records = [ + _record(record_id, attachments) + for record_id, attachments in attachments_by_record.items() + ] + candidates, issues = tmall.discover_tmall_publication_candidates( + records, + store=tmall.STORE_LUGGAGE, + ) + + assert issues == [] + matched = tmall._legacy_backfill_for_candidates( + tmall.STORE_LUGGAGE, + candidates, + ) + assert len(matched) == 25 + assert { + ( + candidate.source_record_id, + candidate.video_file_token, + candidate.video_file_name, + ) + for candidate, _entry in matched + } == { + ( + entry["source_record_id"], + entry["video_file_token"], + entry["video_file_name"], + ) + for entry in entries + } + + drifted = [ + replace(candidates[0], video_file_token="drifted-token"), + *candidates[1:], + ] + with pytest.raises(RuntimeError, match="record_id/file_token/文件名不一致"): + tmall._legacy_backfill_for_candidates( + tmall.STORE_LUGGAGE, + drifted, + ) + + +def test_parse_args_is_preview_by_default_and_requires_explicit_execute( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("GYXX_VIDEO_UPLOAD_STORE", raising=False) + monkeypatch.setattr(sys, "argv", ["upload_video_to_guanghe.py"]) + preview = tmall.parse_args() + assert preview.execute is False + assert preview.store == tmall.STORE_FLAGSHIP + + monkeypatch.setattr( + sys, + "argv", + ["upload_video_to_guanghe.py", "--execute"], + ) + execution = tmall.parse_args() + assert execution.execute is True + + +def test_parse_args_uses_frontend_selected_store_from_runtime_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("GYXX_VIDEO_UPLOAD_STORE", tmall.STORE_LUGGAGE) + monkeypatch.setattr(sys, "argv", ["upload_video_to_guanghe.py", "--execute"]) + + execution = tmall.parse_args() + + assert execution.store == tmall.STORE_LUGGAGE + + +def test_parse_args_uses_configured_topic_keyword_and_removes_spacing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(tmall.TMALL_TOPIC_KEYWORD_ENV, " 秋 上新 ") + monkeypatch.setattr(sys, "argv", ["upload_video_to_guanghe.py"]) + + execution = tmall.parse_args() + + assert execution.topic_keyword == "秋上新" + + +def test_parse_args_rejects_invalid_runtime_store( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("GYXX_VIDEO_UPLOAD_STORE", "unknown") + monkeypatch.setattr(sys, "argv", ["upload_video_to_guanghe.py"]) + + with pytest.raises(ValueError, match="GYXX_VIDEO_UPLOAD_STORE"): + tmall.parse_args() + + +def test_tmall_pre_submit_failures_are_automatically_retryable() -> None: + assert tmall._is_retryable_pre_submit_failure( + tmall.TmallTitleGenerationError("no valid candidate"), + 1, + ) + assert tmall._is_retryable_pre_submit_failure(RuntimeError("timed out"), 1) + assert not tmall._is_retryable_pre_submit_failure(RuntimeError("timed out"), 3) + + +def test_tmall_strict_title_requests_a_candidate_pool_and_classifies_validation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + class ValidationGenerator: + def __init__(self, **kwargs: object) -> None: + captured.update(kwargs) + + def generate(self, *_args: object, **_kwargs: object) -> str: + raise tmall.TitleValidationError("no valid candidate") + + monkeypatch.setattr(tmall, "JdVideoTitleGenerator", ValidationGenerator) + uploader = object.__new__(tmall.GuangHeUploader) + uploader.require_llm_titles = True + uploader._used_titles_global = [] + uploader._catalog_product_titles = lambda _name: ("极星双肩包轻便电脑包",) + + with pytest.raises(tmall.TmallTitleGenerationError, match="no valid candidate"): + uploader._generate_title(["极星双肩"], video_key="video-token") + + assert captured["candidate_count"] == tmall.TMALL_TITLE_CANDIDATE_COUNT + assert int(captured["candidate_count"]) > 1 + assert captured["allow_verified_fallback"] is True + + +def test_tmall_strict_title_keeps_model_request_failures_retryable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class RequestGenerator: + def __init__(self, **_kwargs: object) -> None: + pass + + def generate(self, *_args: object, **_kwargs: object) -> str: + raise tmall.TitleGenerationError("timed out") + + monkeypatch.setattr(tmall, "JdVideoTitleGenerator", RequestGenerator) + uploader = object.__new__(tmall.GuangHeUploader) + uploader.require_llm_titles = True + uploader._used_titles_global = [] + uploader._catalog_product_titles = lambda _name: ("极星双肩包轻便电脑包",) + + with pytest.raises(tmall.TitleGenerationError, match="timed out"): + uploader._generate_title(["极星双肩"], video_key="video-token") + + +def test_execute_with_no_selected_store_candidates_does_not_start_browser( + monkeypatch: pytest.MonkeyPatch, +) -> None: + args = SimpleNamespace( + execute=True, + republish_from_date=None, + video_name=None, + exclude_video_name=[], + record_id=None, + ) + monkeypatch.setattr( + tmall, + "_store_runtime", + lambda _store: ("光影行星鑫华达专卖店:龙虾仔", "configured", "configured", Path("profile")), + ) + monkeypatch.setattr(tmall, "select_pending_records", lambda *_args, **_kwargs: []) + monkeypatch.setattr( + tmall, + "discover_tmall_publication_candidates", + lambda *_args, **_kwargs: ([], []), + ) + monkeypatch.setattr( + tmall, + "_legacy_backfill_for_candidates", + lambda *_args, **_kwargs: [], + ) + + class UnexpectedUploader: + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + raise AssertionError("筛选为空时不应启动浏览器") + + monkeypatch.setattr(tmall, "GuangHeUploader", UnexpectedUploader) + + result = tmall._run_store( + args, + store=tmall.STORE_LUGGAGE, + records=[], + luggage_style_whitelist={"极星双肩"}, + ) + + assert result == tmall.EXIT_OK + + +class _FakeLocator: + def __init__( + self, + *, + count: int = 1, + on_click: Any = None, + ) -> None: + self._count = count + self._on_click = on_click + + @property + def first(self) -> _FakeLocator: + return self + + def count(self) -> int: + return self._count + + def fill(self, _value: str) -> None: + return None + + def click(self, **_kwargs: Any) -> None: + if self._on_click is not None: + self._on_click() + + +def test_publish_video_persists_submit_boundary_before_publish_click( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + events: list[str] = [] + upload_timeouts: list[int] = [] + uploader = object.__new__(tmall.GuangHeUploader) + uploader.page = SimpleNamespace(url="https://creator.guanghe.taobao.com/page/") + uploader._upload_video_file = lambda _path: None + uploader._wait_upload_done = lambda **kwargs: (upload_timeouts.append(kwargs["timeout"]) or True) + uploader._snapshot = lambda _name: None + uploader._generate_title = lambda *_args, **_kwargs: "极星双肩包视频标题" + uploader._prepare_publish_metadata = lambda _products, **_kwargs: None + uploader._link_product = lambda _products, _item_ids: True + + def locate(selector: str) -> _FakeLocator: + if selector == "text=立即发布": + return _FakeLocator(on_click=lambda: events.append("publish-click")) + if selector == "text=发布成功": + return _FakeLocator(count=1) + if selector.startswith("text=") and selector not in { + "text=内容无需标注", + }: + return _FakeLocator(count=0) + return _FakeLocator(count=1) + + uploader._loc = locate + monkeypatch.setattr(tmall.time, "sleep", lambda _seconds: None) + + def before_submit(payload: Any) -> None: + assert payload["video_file_name"] == "video.mp4" + events.append("before-submit") + + published = uploader.publish_video( + tmp_path / "video.mp4", + ["极星双肩"], + before_submit=before_submit, + ) + + assert published is True + assert events == ["before-submit", "publish-click"] + assert upload_timeouts == [tmall.TMALL_VIDEO_UPLOAD_TIMEOUT_SECONDS] + + +class _SeedCursor: + def __init__(self) -> None: + self.executions: list[tuple[str, tuple[Any, ...]]] = [] + + def __enter__(self) -> _SeedCursor: + return self + + def __exit__(self, *_args: object) -> None: + return None + + def execute(self, sql: str, params: tuple[Any, ...]) -> None: + self.executions.append((sql, params)) + + def fetchone(self) -> tuple[int]: + return (1,) + + +class _SeedConnection: + def __init__(self) -> None: + self.cursor_instance = _SeedCursor() + + def cursor(self) -> _SeedCursor: + return self.cursor_instance + + +def test_legacy_seed_inserts_published_without_overwriting_conflicts() -> None: + connection = _SeedConnection() + candidate = _one_candidate() + + inserted = tmall.seed_legacy_published_candidates( + connection, + [(candidate, {"evidence": {"publish_success_line": 72}})], + run_id="test-run", + ) + + assert inserted == 1 + sql, params = connection.cursor_instance.executions[0] + normalized_sql = " ".join(sql.split()) + assert "'published'" in normalized_sql + assert "ON CONFLICT (" in normalized_sql + assert ") DO NOTHING RETURNING id" in normalized_sql + assert params[-3:-1] == ("test-run", "test-run") diff --git a/tests/modules/product_commerce/test_video_publication_ledger_shared.py b/tests/modules/product_commerce/test_video_publication_ledger_shared.py new file mode 100644 index 0000000..b25b1e9 --- /dev/null +++ b/tests/modules/product_commerce/test_video_publication_ledger_shared.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import inspect +from datetime import date +from pathlib import Path +from typing import get_type_hints + +import pytest + +from gyxx_flow.modules.product_commerce.jd_video_publication_db import ( + CHANNEL_TMALL, + VIDEO_PUBLICATION_DDL, + ClaimedVideoPublication, + VideoPublicationCandidate, + VideoPublicationState, + claim_next_video_publication, + finish_video_publication, + finish_video_publication_rejected, + load_video_publication_states, +) + + +class FakeCursor: + def __init__(self, rows: list[tuple[object, ...]]) -> None: + self.rows = rows + self.executions: list[tuple[str, tuple[object, ...]]] = [] + self.rowcount = 1 + + def __enter__(self) -> FakeCursor: + return self + + def __exit__(self, *_args: object) -> None: + return None + + def execute(self, query: str, params: tuple[object, ...]) -> None: + self.executions.append((query, params)) + + def fetchall(self) -> list[tuple[object, ...]]: + return self.rows + + +class FakeConnection: + def __init__(self, cursor: FakeCursor) -> None: + self.fake_cursor = cursor + + def cursor(self) -> FakeCursor: + return self.fake_cursor + + +def test_load_video_publication_states_scopes_query_and_maps_attachments() -> None: + cursor = FakeCursor( + [ + ("rec_1", "video_1", 101, "published", False), + ("rec_2", "video_2", 102, "failed", True), + ] + ) + + states = load_video_publication_states( + FakeConnection(cursor), + CHANNEL_TMALL, + "tmall-luggage-store", + "base_token", + "table_id", + ) + + assert cursor.executions[0][1] == ( + "tmall", + "tmall-luggage-store", + "base_token", + "table_id", + ) + assert "FROM fact_video_publications" in cursor.executions[0][0] + assert states == { + ("rec_1", "video_1"): VideoPublicationState(101, "published", False), + ("rec_2", "video_2"): VideoPublicationState(102, "failed", True), + } + + +def test_load_video_publication_states_does_not_hide_database_errors() -> None: + class MissingTableCursor(FakeCursor): + def execute(self, query: str, params: tuple[object, ...]) -> None: + raise RuntimeError("fact_video_publications does not exist") + + with pytest.raises(RuntimeError, match="does not exist"): + load_video_publication_states( + FakeConnection(MissingTableCursor([])), + CHANNEL_TMALL, + "tmall-flagship-store", + "base_token", + "table_id", + ) + + +def test_shared_ledger_preserves_missing_source_dates() -> None: + assert get_type_hints(VideoPublicationCandidate)["source_record_date"] == date | None + assert get_type_hints(ClaimedVideoPublication)["source_record_date"] == date | None + assert "source_record_date DATE," in VIDEO_PUBLICATION_DDL + assert "ALTER COLUMN source_record_date DROP NOT NULL;" in VIDEO_PUBLICATION_DDL + claim_source = inspect.getsource(claim_next_video_publication) + assert "source_snapshot ->> 'source_view_position'" in claim_source + assert "source_record_date NULLS LAST" in claim_source + + schema_path = ( + Path(__file__).resolve().parents[3] + / "src" + / "gyxx_flow" + / "modules" + / "product_commerce" + / "db" + / "schema.sql" + ) + schema = schema_path.read_text(encoding="utf-8") + assert "source_record_date DATE," in schema + assert "ALTER COLUMN source_record_date DROP NOT NULL;" in schema + + +def _sample_claim() -> ClaimedVideoPublication: + return ClaimedVideoPublication( + publication_id=764, + attempt_no=1, + source_record_id="rec_1", + source_record_date=None, + video_file_token="video_1", + video_file_name="video.mp4", + cover_file_token=None, + cover_file_name=None, + source_snapshot={}, + publish_payload={}, + ) + + +def test_published_and_ambiguous_outcomes_are_not_retryable() -> None: + published_cursor = FakeCursor([]) + finish_video_publication( + FakeConnection(published_cursor), + _sample_claim(), + status="published", + retryable=True, + ) + assert published_cursor.executions[0][1][0:2] == ("published", False) + + ambiguous_cursor = FakeCursor([]) + finish_video_publication( + FakeConnection(ambiguous_cursor), + _sample_claim(), + status="ambiguous", + retryable=True, + ) + assert ambiguous_cursor.executions[0][1][0:2] == ("ambiguous", False) + + +def test_clear_remote_rejection_is_retryable_but_keeps_submit_audit() -> None: + cursor = FakeCursor([]) + finish_video_publication_rejected( + FakeConnection(cursor), + _sample_claim(), + error="平台明确拒绝", + evidence={"form_visible": True}, + ) + + params = cursor.executions[0][1] + assert params[0:2] == ("failed", True) + assert params[3] is True diff --git a/tests/modules/shop_intelligence/test_collector_exit_semantics.py b/tests/modules/shop_intelligence/test_collector_exit_semantics.py index 82e6f12..c2c98bd 100644 --- a/tests/modules/shop_intelligence/test_collector_exit_semantics.py +++ b/tests/modules/shop_intelligence/test_collector_exit_semantics.py @@ -14,21 +14,17 @@ from gyxx_flow.modules.shop_intelligence.collectors import ( ) -class _PlaywrightContext: +class _ScraplingBrowserContext: def __init__(self, page: object) -> None: self.page = page def __enter__(self): context = SimpleNamespace( + pages=[self.page], new_page=lambda: self.page, close=lambda: None, ) - browser = SimpleNamespace( - new_context=lambda **kwargs: context, - close=lambda: None, - ) - chromium = SimpleNamespace(launch=lambda **kwargs: browser) - return SimpleNamespace(chromium=chromium) + return SimpleNamespace(context=context) def __exit__(self, exc_type, exc, traceback) -> None: return None @@ -39,8 +35,8 @@ def test_jd_shop_collector_returns_nonzero_when_collection_fails( ) -> None: monkeypatch.setattr( jd_data_collector, - "sync_playwright", - lambda: _PlaywrightContext(object()), + "ScraplingBrowser", + lambda **_kwargs: _ScraplingBrowserContext(object()), ) monkeypatch.setattr( jd_data_collector, @@ -88,8 +84,8 @@ def test_jd_peer_collector_returns_nonzero_when_collection_fails( ) -> None: monkeypatch.setattr( jd_peer_store_data_collector, - "sync_playwright", - lambda: _PlaywrightContext(object()), + "ScraplingBrowser", + lambda **_kwargs: _ScraplingBrowserContext(object()), ) monkeypatch.setattr( jd_peer_store_data_collector, diff --git a/tests/modules/shop_intelligence/test_douyin_price_appeal.py b/tests/modules/shop_intelligence/test_douyin_price_appeal.py index 2447352..619a13b 100644 --- a/tests/modules/shop_intelligence/test_douyin_price_appeal.py +++ b/tests/modules/shop_intelligence/test_douyin_price_appeal.py @@ -254,6 +254,53 @@ class _LateShopIdentityPage: self.wait_calls += 1 +class _RetryNavigationPage(_LateShopIdentityPage): + def __init__(self, *, failures: int, url: str | None = None) -> None: + super().__init__() + self.failures = failures + self.goto_calls = 0 + if url is not None: + self.url = url + + def goto(self, *args: object, **kwargs: object) -> None: + del args, kwargs + self.goto_calls += 1 + if self.goto_calls <= self.failures: + raise RuntimeError( + "Page.goto: net::ERR_ABORTED at " + f"{appeal.CAMPAIGN_SQUARE_URL} Call log: - navigating" + ) + + +class _FeedbackTextControl: + def __init__(self, text: str) -> None: + self.text_value = text + + def count(self) -> int: + return 1 + + def nth(self, index: int) -> _FeedbackTextControl: + assert index == 0 + return self + + def is_visible(self, timeout: int | None = None) -> bool: + del timeout + return True + + def inner_text(self, timeout: int | None = None) -> str: + del timeout + return self.text_value + + +class _FeedbackPage: + def __init__(self, items: list[_VisibleControl]) -> None: + self.items = items + + def locator(self, selector: str) -> _LocatorCollection: + assert "toast" in selector + return _LocatorCollection(self.items) + + class _FakeClock: def __init__(self) -> None: self.now = 0.0 @@ -319,6 +366,38 @@ class _DynamicPanel: return _EmptyLocator() +class _TransientTextPanel(_DynamicPanel): + def __init__( + self, + values: tuple[str, ...], + *, + transient_failures: int, + ) -> None: + super().__init__(values, action_count=0) + self.transient_failures = transient_failures + + def inner_text(self, timeout: int | None = None) -> str: + del timeout + self.read_calls += 1 + if self.transient_failures > 0: + self.transient_failures -= 1 + raise RuntimeError("drawer locator detached during transition") + return next(self.values, self.latest) + + +class _DetachedFeedbackControl(_TransientTextPanel): + def count(self) -> int: + return 1 + + def nth(self, index: int) -> _DetachedFeedbackControl: + assert index == 0 + return self + + def is_visible(self, timeout: int | None = None) -> bool: + del timeout + return True + + class _PanelNode(_VisibleControl): def __init__(self, name: str, box: dict[str, float]) -> None: super().__init__() @@ -465,6 +544,90 @@ def test_shop_identity_can_render_after_campaign_content( assert page.wait_calls == 1 +def test_navigation_recovers_after_first_abort( + monkeypatch: pytest.MonkeyPatch, +) -> None: + page = _RetryNavigationPage(failures=1) + monkeypatch.setattr( + appeal, + "_body_text", + lambda page: "活动广场 待办事项 待改价 9 光影行星箱包旗舰店", + ) + + appeal._navigate_to_campaign_square( # noqa: SLF001 + page, + expected_shop_name=appeal.EXPECTED_SHOP_NAME, + ) + + assert page.goto_calls == 2 + assert page.wait_calls == 1 + + +def test_navigation_aborted_to_login_page_reports_session_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + page = _RetryNavigationPage( + failures=2, + url=( + "https://fxg.jinritemai.com/login/common" + "?extra=%7B%22target_url%22%3A%22https%3A%2F%2F" + "fxg.jinritemai.com%2Fffa%2Fmerchant%2Fcampaign-square%22%7D" + ), + ) + monkeypatch.setattr(appeal, "_body_text", lambda page: "") + + with pytest.raises(appeal.PriceAppealSessionError, match="Cookie 已失效"): + appeal._navigate_to_campaign_square( # noqa: SLF001 + page, + expected_shop_name=appeal.EXPECTED_SHOP_NAME, + ) + + assert page.goto_calls == 2 + + +def test_navigation_aborted_twice_reports_navigation_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + page = _RetryNavigationPage(failures=2) + monkeypatch.setattr(appeal, "_body_text", lambda page: "") + + with pytest.raises(appeal.PriceAppealError, match="活动广场导航失败") as exc: + appeal._navigate_to_campaign_square( # noqa: SLF001 + page, + expected_shop_name=appeal.EXPECTED_SHOP_NAME, + ) + + assert page.goto_calls == 2 + assert "ERR_ABORTED" in str(exc.value) + + +def test_pending_content_signature_returns_none_while_drawer_detached( + monkeypatch: pytest.MonkeyPatch, +) -> None: + clock = _FakeClock() + panel = _TransientTextPanel( + ("待改价 报名信息 商品 ID: 12345678",), + transient_failures=100, + ) + monkeypatch.setattr(appeal.time, "monotonic", clock.monotonic) + monkeypatch.setattr(appeal.time, "sleep", clock.advance) + + assert appeal._pending_content_signature(panel) is None # noqa: SLF001 + + +def test_visible_feedback_texts_skips_detached_feedback_node( + monkeypatch: pytest.MonkeyPatch, +) -> None: + clock = _FakeClock() + detached = _DetachedFeedbackControl(("提交成功",), transient_failures=100) + healthy = _FeedbackTextControl(" 提交成功 ") + page = _FeedbackPage([detached, healthy]) + monkeypatch.setattr(appeal.time, "monotonic", clock.monotonic) + monkeypatch.setattr(appeal.time, "sleep", clock.advance) + + assert appeal._visible_feedback_texts(page) == ("提交成功",) + + def test_pending_count_can_render_after_campaign_content( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -801,6 +964,111 @@ def test_item_panel_waits_for_async_product_and_sku_content( assert page.wait_calls == 2 +def test_item_panel_reacquires_after_transient_drawer_detach( + monkeypatch: pytest.MonkeyPatch, +) -> None: + clock = _FakeClock() + page = _PendingMetricPage([], clock=clock) + expected_item_id = "3774363265803616739" + detached_panel = _TransientTextPanel( + ("选择申诉SKU",), + transient_failures=1, + ) + ready_panel = _DynamicPanel( + ( + "选择申诉SKU 商品 ID: 3774363265803616739 " + "SKU规格 ID: 3619462493048834 原价 ¥839", + ), + action_count=0, + ) + panels = iter((detached_panel, ready_panel)) + monkeypatch.setattr(appeal.time, "monotonic", clock.monotonic) + monkeypatch.setattr(appeal, "_page_requires_login", lambda page: False) + monkeypatch.setattr( + appeal, + "_find_active_panel", + lambda page, markers: next(panels, ready_panel), + ) + + result = appeal._wait_for_item_panel_ready( # noqa: SLF001 + page, + ("选择申诉SKU",), + expected_item_id=expected_item_id, + timeout_seconds=2, + ) + + assert result is ready_panel + assert detached_panel.read_calls == 2 + assert ready_panel.read_calls == 1 + assert page.wait_calls == 1 + + +def test_item_panel_persistent_read_failure_still_fails_closed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + clock = _FakeClock() + page = _PendingMetricPage([], clock=clock) + panel = _TransientTextPanel( + ("选择申诉SKU",), + transient_failures=100, + ) + monkeypatch.setattr(appeal.time, "monotonic", clock.monotonic) + monkeypatch.setattr(appeal, "_page_requires_login", lambda page: False) + monkeypatch.setattr( + appeal, + "_find_active_panel", + lambda page, markers: panel, + ) + + with pytest.raises(appeal.PriceAppealError, match="未加载目标商品 ID") as exc: + appeal._wait_for_item_panel_ready( # noqa: SLF001 + page, + ("选择申诉SKU",), + expected_item_id="3774363265803616739", + timeout_seconds=1, + ) + + assert isinstance(exc.value.__cause__, appeal.PriceAppealError) + assert str(exc.value.__cause__) == "无法读取活动抽屉内容" + + +def test_verify_item_identity_recovers_after_transient_detach( + monkeypatch: pytest.MonkeyPatch, +) -> None: + clock = _FakeClock() + panel = _TransientTextPanel( + ( + "选择申诉SKU 商品 ID: 3774363265803616739 " + "SKU规格 ID: 3619462493048834 原价 ¥839", + ), + transient_failures=1, + ) + monkeypatch.setattr(appeal.time, "monotonic", clock.monotonic) + monkeypatch.setattr(appeal.time, "sleep", clock.advance) + + appeal._verify_item_identity(panel, "3774363265803616739") # noqa: SLF001 + + assert panel.read_calls == 2 + + +def test_verify_item_identity_persistent_read_failure_fails_closed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + clock = _FakeClock() + panel = _TransientTextPanel( + ("选择申诉SKU",), + transient_failures=100, + ) + monkeypatch.setattr(appeal.time, "monotonic", clock.monotonic) + monkeypatch.setattr(appeal.time, "sleep", clock.advance) + + with pytest.raises(appeal.PriceAppealError, match="与待处理行不一致") as exc: + appeal._verify_item_identity(panel, "3774363265803616739") # noqa: SLF001 + + assert isinstance(exc.value.__cause__, appeal.PriceAppealError) + assert str(exc.value.__cause__) == "无法读取活动抽屉内容" + + def test_pending_panel_with_headers_only_never_becomes_ready( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/modules/shop_intelligence/test_dy_market_category_selection.py b/tests/modules/shop_intelligence/test_dy_market_category_selection.py index 78b29f5..7105a20 100644 --- a/tests/modules/shop_intelligence/test_dy_market_category_selection.py +++ b/tests/modules/shop_intelligence/test_dy_market_category_selection.py @@ -67,6 +67,37 @@ def test_market_category_option_uses_visible_titled_menu_item() -> None: assert option.clicked +class _UnstableOption(_Locator): + def scroll_into_view_if_needed(self, **_kwargs) -> None: + return None + + def click(self, **_kwargs) -> None: + raise TimeoutError( + "Locator.click: Timeout 5000ms exceeded ... element is not stable" + ) + + +def test_market_category_option_falls_back_to_native_click_when_unstable() -> None: + option = _UnstableOption() + page = SimpleNamespace( + locator=lambda _selector: option, + evaluate=lambda *_args, **_kwargs: True, + ) + + assert collector._click_market_category_option(page, "双肩包") + assert option.waited + + +def test_market_category_option_fails_closed_when_native_click_also_fails() -> None: + option = _UnstableOption() + page = SimpleNamespace( + locator=lambda _selector: option, + evaluate=lambda *_args, **_kwargs: False, + ) + + assert not collector._click_market_category_option(page, "双肩包") + + def test_select_market_category_requires_path_and_fresh_table(monkeypatch) -> None: expected = collector._expected_market_category_path("双肩包") paths = iter([collector._expected_market_category_path("腰包/胸包"), expected]) diff --git a/tests/modules/shop_intelligence/test_jd_old_datepicker_click_resilience.py b/tests/modules/shop_intelligence/test_jd_old_datepicker_click_resilience.py new file mode 100644 index 0000000..3ca2f22 --- /dev/null +++ b/tests/modules/shop_intelligence/test_jd_old_datepicker_click_resilience.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from datetime import date + +import pytest + +from gyxx_flow.modules.shop_intelligence.collectors import jd_data_collector as collector + + +class _FlakyCellLocator: + def __init__(self) -> None: + self.click_calls = 0 + + def scroll_into_view_if_needed(self, **_kwargs) -> None: + return None + + def click(self, **_kwargs) -> None: + self.click_calls += 1 + raise TimeoutError("element is not visible") + + +class _DatePickerPage: + """Stub page: date already unset, month aligned, cell found, then results.""" + + def __init__(self, native_click_result: bool) -> None: + self.cell = _FlakyCellLocator() + self.native_click_result = native_click_result + self.calls = 0 + self.native_click_script: str | None = None + + def evaluate(self, script: str, arg: object = None): + del arg + self.calls += 1 + if self.calls == 1: + assert "textContent" in script # already-set check + return False + if self.calls == 2: + assert "targetYear" in script # month navigation + return {"status": "aligned"} + if self.calls == 3: + assert "gridcell" in script # locate target day cell + return {"status": "found_cell", "id": "__jd_target"} + assert self.calls == 4 + assert "document.getElementById" in script # native click fallback + self.native_click_script = script + return self.native_click_result + + def locator(self, selector: str) -> _FlakyCellLocator: + assert selector == "#__jd_target" + return self.cell + + def wait_for_timeout(self, _milliseconds: int) -> None: + return None + + +def _prepare(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + collector, + "get_last_week_range", + lambda: (date(2026, 8, 10), date(2026, 8, 16)), + ) + monkeypatch.setattr(collector, "save_date_picker_debug", lambda *_args, **_kwargs: None) + + +def test_old_datepicker_cell_click_falls_back_to_native_click( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _prepare(monkeypatch) + page = _DatePickerPage(native_click_result=True) + + collector.select_last_week_in_old_datepicker(page) + + assert page.cell.click_calls == 1 + assert page.native_click_script is not None + assert page.calls == 4 + + +def test_old_datepicker_fails_closed_when_native_click_also_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _prepare(monkeypatch) + page = _DatePickerPage(native_click_result=False) + + with pytest.raises(RuntimeError, match="旧版日期控件未能选择上周日期"): + collector.select_last_week_in_old_datepicker(page) + + assert page.cell.click_calls == 1 + assert page.native_click_script is not None + + +def test_old_datepicker_happy_path_uses_playwright_click( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _prepare(monkeypatch) + page = _DatePickerPage(native_click_result=True) + + class StableCell(_FlakyCellLocator): + def click(self, **_kwargs) -> None: + self.click_calls += 1 + + page.cell = StableCell() + + collector.select_last_week_in_old_datepicker(page) + + assert page.cell.click_calls == 1 + assert page.native_click_script is None diff --git a/tests/modules/shop_intelligence/test_jd_peer_category_switch.py b/tests/modules/shop_intelligence/test_jd_peer_category_switch.py index cddc20a..810e52d 100644 --- a/tests/modules/shop_intelligence/test_jd_peer_category_switch.py +++ b/tests/modules/shop_intelligence/test_jd_peer_category_switch.py @@ -48,6 +48,7 @@ def test_category_menu_closes_stale_portals_before_opening_selector() -> None: class Trigger: def __init__(self, page) -> None: self.page = page + self.first = self def click(self, **_kwargs) -> None: self.page.menu_count = 1 @@ -255,3 +256,129 @@ def test_failed_category_switch_never_extracts_previous_table( assert "类目切换或榜单刷新失败" in result["男士双肩包"]["error"] assert result["男士单肩/斜挎包"]["rank"] == "7" assert extracted == ["called"] + + +class _SearchBox: + def __init__(self) -> None: + self.calls: list[str] = [] + + def is_visible(self, **_kwargs) -> bool: + return True + + def click(self, **_kwargs) -> None: + self.calls.append("click") + + def fill(self, value: str, **_kwargs) -> None: + self.calls.append(f"fill:{value}") + + def press(self, key: str, **_kwargs) -> None: + self.calls.append(f"press:{key}") + + +class _OneLocator: + def __init__(self, item: _SearchBox) -> None: + self.item = item + + @property + def first(self) -> _OneLocator: + return self + + def count(self) -> int: + return 1 + + def is_visible(self, **_kwargs) -> bool: + return self.item.is_visible() + + def click(self, **_kwargs) -> None: + self.item.click() + + def fill(self, value: str, **_kwargs) -> None: + self.item.fill(value) + + def press(self, key: str, **_kwargs) -> None: + self.item.press(key) + + +class _EmptyLocator: + @property + def first(self) -> _EmptyLocator: + return self + + def count(self) -> int: + return 0 + + def is_visible(self, **_kwargs) -> bool: + raise TimeoutError("no elements matched") + + def click(self, **_kwargs) -> None: + raise TimeoutError("no elements matched") + + +class _SearchPage: + def __init__(self, box: _SearchBox, *, section_result: str | None) -> None: + self.box = box + self.section_result = section_result + self.evaluate_calls = 0 + + def evaluate(self, _script: str, _arg: object = None): + self.evaluate_calls += 1 + return self.section_result + + def locator(self, selector: str) -> _OneLocator | _EmptyLocator: + if selector.startswith("#__peer_search_input"): + return _OneLocator(self.box) + if selector == "input": + return _OneLocator(self.box) + if "店铺" in selector: + return _OneLocator(self.box) + return _EmptyLocator() + + def wait_for_timeout(self, _milliseconds: int) -> None: + return None + + +def test_search_shop_prefers_trade_rank_section_input() -> None: + box = _SearchBox() + page = _SearchPage(box, section_result="__peer_search_input_1") + + collector.search_shop(page, "Bellroy") + + assert page.evaluate_calls == 1 + assert box.calls == [ + "click", + "fill:", + "fill:Bellroy", + "press:Enter", + ] + + +def test_search_shop_falls_back_to_legacy_selectors_without_section() -> None: + box = _SearchBox() + page = _SearchPage(box, section_result=None) + + collector.search_shop(page, "Bellroy") + + assert page.evaluate_calls == 1 + assert box.calls == [ + "click", + "fill:", + "fill:Bellroy", + "press:Enter", + ] + + +def test_locate_peer_search_input_returns_none_when_evaluate_fails() -> None: + box = _SearchBox() + + class FailingPage(_SearchPage): + def evaluate(self, _script: str, _arg: object = None): + raise RuntimeError("page crashed") + + assert collector._locate_peer_search_input(FailingPage(box, section_result="x")) is None + + +def test_locate_peer_search_input_returns_none_without_section() -> None: + box = _SearchBox() + page = _SearchPage(box, section_result=None) + + assert collector._locate_peer_search_input(page) is None diff --git a/tests/modules/shop_intelligence/test_jd_self_operated_brand_daily.py b/tests/modules/shop_intelligence/test_jd_self_operated_brand_daily.py index 6d3930d..cee5ff1 100644 --- a/tests/modules/shop_intelligence/test_jd_self_operated_brand_daily.py +++ b/tests/modules/shop_intelligence/test_jd_self_operated_brand_daily.py @@ -27,7 +27,7 @@ def test_browser_runtime_defaults_to_bundled_chromium(monkeypatch): def test_browser_runtime_can_use_installed_chrome(monkeypatch): monkeypatch.setenv("GYXX_BROWSER_CHANNEL", "chrome") - assert browser_runtime_options() == {"channel": "chrome"} + assert browser_runtime_options() == {"real_chrome": True} class DateRangeTests(unittest.TestCase): diff --git a/tests/modules/shop_intelligence/test_shop_metrics_hardening.py b/tests/modules/shop_intelligence/test_shop_metrics_hardening.py index b21268b..74c8a6c 100644 --- a/tests/modules/shop_intelligence/test_shop_metrics_hardening.py +++ b/tests/modules/shop_intelligence/test_shop_metrics_hardening.py @@ -455,6 +455,36 @@ def test_shop_nonpositive_persistence_stops_feishu_write( assert errors == [f"{platform_code} DB 持久化失败: 未写入任何记录"] +@pytest.mark.parametrize( + ("platform", "persist_name"), + [ + (run_shop.JD_PLATFORM, "persist_jd_shop"), + (run_shop.DY_PLATFORM, "persist_dy_shop"), + ], +) +def test_shop_skip_feishu_preserves_database_write_without_calling_writer( + monkeypatch, + platform: str, + persist_name: str, +) -> None: + monkeypatch.setattr(run_shop, "ensure_ai_analysis", lambda *_args: "") + monkeypatch.setattr(run_shop, persist_name, lambda _path: 1) + monkeypatch.setattr( + run_shop, + "write_shop", + lambda *_args: pytest.fail("weekly shop workflow must not write Feishu"), + ) + + errors = run_shop._persist_and_write( + platform, + True, + "D:/fresh-shop.json", + skip_feishu=True, + ) + + assert errors == [] + + @pytest.mark.parametrize( ("platform", "persist_name", "platform_code"), [ @@ -625,19 +655,16 @@ def test_jd_shop_uses_bound_profile_port_and_persists_state( context = Context() - class Chromium: - def launch_persistent_context(self, **kwargs): + class Scrapling: + def __init__(self, **kwargs): observed.update(kwargs) - return context - - class Playwright: - chromium = Chromium() + self.context = context def __enter__(self): return self def __exit__(self, *_args) -> None: - return None + context.close() cookie_file = tmp_path / "state" / "cookies.json" storage_file = tmp_path / "state" / "storage.json" @@ -646,7 +673,7 @@ def test_jd_shop_uses_bound_profile_port_and_persists_state( monkeypatch.setenv("GYXX_BROWSER_CDP_PORT", "22105") monkeypatch.setenv("GYXX_BROWSER_COOKIE_FILE", str(cookie_file)) monkeypatch.setenv("GYXX_BROWSER_STORAGE_STATE_FILE", str(storage_file)) - monkeypatch.setattr(jd_data_collector, "sync_playwright", Playwright) + monkeypatch.setattr(jd_data_collector, "ScraplingBrowser", Scrapling) monkeypatch.setattr(jd_data_collector, "step_login", lambda *_args: None) monkeypatch.setattr( jd_data_collector, @@ -670,8 +697,13 @@ def test_jd_shop_uses_bound_profile_port_and_persists_state( assert jd_data_collector.main(["--password", "test-only"]) == 0 assert observed["user_data_dir"] == str(profile) - assert "--remote-debugging-port=22105" in observed["args"] - assert "--no-proxy-server" in observed["args"] + assert "--remote-debugging-port=22105" in observed["extra_flags"] + assert "--no-proxy-server" in observed["extra_flags"] + assert observed["additional_args"]["viewport"] == { + "width": 1920, + "height": 1080, + } + assert observed["retries"] == 1 assert json.loads(cookie_file.read_text(encoding="utf-8"))[0]["name"] == "session" assert storage_file.is_file() assert context.closed is True @@ -699,19 +731,16 @@ def test_jd_peer_uses_bound_profile_port_and_persists_state( context = Context() - class Chromium: - def launch_persistent_context(self, **kwargs): + class Scrapling: + def __init__(self, **kwargs): observed.update(kwargs) - return context - - class Playwright: - chromium = Chromium() + self.context = context def __enter__(self): return self def __exit__(self, *_args) -> None: - return None + context.close() cookie_file = tmp_path / "state" / "cookies.json" storage_file = tmp_path / "state" / "storage.json" @@ -720,7 +749,11 @@ def test_jd_peer_uses_bound_profile_port_and_persists_state( monkeypatch.setenv("GYXX_BROWSER_CDP_PORT", "22106") monkeypatch.setenv("GYXX_BROWSER_COOKIE_FILE", str(cookie_file)) monkeypatch.setenv("GYXX_BROWSER_STORAGE_STATE_FILE", str(storage_file)) - monkeypatch.setattr(jd_peer_store_data_collector, "sync_playwright", Playwright) + monkeypatch.setattr( + jd_peer_store_data_collector, + "ScraplingBrowser", + Scrapling, + ) monkeypatch.setattr(jd_peer_store_data_collector, "step_login", lambda *_args: None) monkeypatch.setattr( jd_peer_store_data_collector, @@ -765,7 +798,12 @@ def test_jd_peer_uses_bound_profile_port_and_persists_state( assert jd_peer_store_data_collector.main(["--password", "test-only"]) == 0 assert observed["user_data_dir"] == str(profile) - assert "--remote-debugging-port=22106" in observed["args"] + assert "--remote-debugging-port=22106" in observed["extra_flags"] + assert observed["additional_args"]["viewport"] == { + "width": 1920, + "height": 1080, + } + assert observed["retries"] == 1 assert json.loads(cookie_file.read_text(encoding="utf-8"))[0]["name"] == "peer-session" assert storage_file.is_file() assert context.closed is True diff --git a/tests/modules/shop_intelligence/test_source_business_logic_parity.py b/tests/modules/shop_intelligence/test_source_business_logic_parity.py index 9570157..bf921cb 100644 --- a/tests/modules/shop_intelligence/test_source_business_logic_parity.py +++ b/tests/modules/shop_intelligence/test_source_business_logic_parity.py @@ -133,6 +133,12 @@ TARGET_AST_SHA256_OVERRIDES = { ("jd_data_collector.py", "select_last_week_any_day"): ( "8f6aed11b39d8e19ec4146e9b32eee96f5977fdbef694311534346bacec5d847" ), + # Reviewed after production runs showed Playwright's actionability check + # rejecting the legacy date-picker cell ("element is not visible") while the + # panel re-renders; a native click on the same node is the approved fallback. + ("jd_data_collector.py", "select_last_week_in_old_datepicker"): ( + "43d1056eeac1faeff32f0c01d0867e6673d2ab57999a14a3ebeb1b51c40075d4" + ), ("jd_data_collector.py", "step_shop_star_and_trade"): ( "77aa873543861f1fad63d0bd1ace60528ab20592b0bffef3d46ad192f2103161" ), diff --git a/tests/test_acceptance_cookie_fail_closed.py b/tests/test_acceptance_cookie_fail_closed.py index ae43118..e1c0ad4 100644 --- a/tests/test_acceptance_cookie_fail_closed.py +++ b/tests/test_acceptance_cookie_fail_closed.py @@ -173,8 +173,8 @@ def _forbidden_scrape(*_args, **_kwargs): ("module_name", "manager_name", "login_name"), [ ("ProductReplenishment", "JstManager", "login"), - ("PurchaseConfirmation", "ERPLoginManager", "login_with_playwright"), - ("PurchaseOrderUpdate", "ERPLoginManager", "login_with_playwright"), + ("PurchaseConfirmation", "ERPLoginManager", "login_with_browser"), + ("PurchaseOrderUpdate", "ERPLoginManager", "login_with_browser"), ], ) def test_supply_acceptance_without_credentials_never_starts_browser_login( @@ -194,7 +194,7 @@ def test_supply_acceptance_without_credentials_never_starts_browser_login( manager = manager_type(str(_config_file(tmp_path)), cdp_port=None) monkeypatch.setattr( module, - "sync_playwright", + "ScraplingBrowser", _forbidden("acceptance mode must not start an embedded browser"), ) @@ -220,15 +220,10 @@ def test_supply_missing_credentials_do_not_navigate_or_fill_login( page = SimpleNamespace(context=SimpleNamespace(cookies=lambda: [])) navigation_flags: list[bool] = [] - class PlaywrightStarter: - def start(self): - return object() - def prepare(_url, *, navigate=True): navigation_flags.append(navigate) return page - monkeypatch.setattr(module, "sync_playwright", PlaywrightStarter) monkeypatch.setattr(manager, "_prepare_login_page", prepare) monkeypatch.setattr( manager, @@ -236,7 +231,7 @@ def test_supply_missing_credentials_do_not_navigate_or_fill_login( _forbidden("acceptance mode must not fill login credentials"), ) - assert manager.login_with_playwright() is False + assert manager.login_with_browser() is False assert manager._acceptance_cookie_skipped is True assert navigation_flags == [True] @@ -276,17 +271,12 @@ def test_purchase_confirmation_uses_credential_fallback_but_skips_captcha( def locator(self, selector): return Locator(selector) - class PlaywrightStarter: - def start(self): - return object() - page = Page() def prepare(_url, *, navigate=True): navigation_flags.append(navigate) return page - monkeypatch.setattr(module, "sync_playwright", PlaywrightStarter) monkeypatch.setattr(manager, "_prepare_login_page", prepare) monkeypatch.setattr( manager, @@ -296,7 +286,7 @@ def test_purchase_confirmation_uses_credential_fallback_but_skips_captcha( monkeypatch.setattr(manager, "_dismiss_post_login_dialogs", lambda *_a, **_k: None) monkeypatch.setattr(module.time, "sleep", lambda _seconds: None) - assert manager.login_with_playwright() is False + assert manager.login_with_browser() is False assert manager._acceptance_cookie_skipped is True assert navigation_flags == [True] assert filled == ["username", "password"] @@ -434,7 +424,7 @@ def test_purchase_confirmation_main_exits_with_cookie_skip_code( def __init__(self, _config_path, cdp_port=None): pass - def login_with_playwright(self): + def login_with_browser(self): return False def close_browser(self): @@ -477,7 +467,7 @@ def test_purchase_order_update_main_exits_with_cookie_skip_code( def __init__(self, _config_path, cdp_port=None): pass - def login_with_playwright(self): + def login_with_browser(self): return False def close_browser(self): diff --git a/tests/test_account_keepalive.py b/tests/test_account_keepalive.py new file mode 100644 index 0000000..cf76a98 --- /dev/null +++ b/tests/test_account_keepalive.py @@ -0,0 +1,608 @@ +from __future__ import annotations + +import json +from dataclasses import replace +from datetime import datetime +from pathlib import Path +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +import pytest + +import gyxx_flow.accounts as accounts +from gyxx_flow.adapters.browser import BrowserCookieStore +from gyxx_flow.adapters.integration import AccountBinding +from gyxx_flow.catalog import WorkflowCatalog +from gyxx_flow.core.config import Settings +from gyxx_flow.scheduler_service import PythonScheduler + + +def _account(tmp_path: Path) -> AccountBinding: + root = tmp_path / "data" / "state" / "accounts" / "demo-shop" + return AccountBinding( + account_id="demo-shop", + cdp_port=22200, + cdp_url="http://127.0.0.1:22200", + profile_dir=root / "profile", + cookie_file=root / "cookies.json", + storage_state_file=root / "storage_state.json", + required_cookie_domains=("example.test",), + required_cookie_names=("session",), + keepalive_enabled=True, + keepalive_url="https://example.test/console/home", + keepalive_interval_seconds=600, + ) + + +class _FakeContext: + def __init__(self, cookies: list[dict[str, object]]) -> None: + self._cookies = cookies + + def cookies(self) -> list[dict[str, object]]: + return list(self._cookies) + + def storage_state(self) -> dict[str, object]: + return {"cookies": self.cookies(), "origins": []} + + +class _FakeBrowser: + final_url = "https://example.test/console/home" + cookies = [ + { + "name": "session", + "value": "fresh-session", + "domain": "example.test", + "path": "/", + "expires": 4_102_444_800, + } + ] + instances: list[_FakeBrowser] = [] + + def __init__(self, **options: object) -> None: + self.options = options + self.context = _FakeContext(self.cookies) + self.persisted = False + self.closed = False + self.instances.append(self) + + def start(self) -> _FakeBrowser: + return self + + def run_fetch_action(self, url: str, action, **_options: object): + page = SimpleNamespace(url=self.final_url) + assert url == "https://example.test/console/home" + return SimpleNamespace(status=200), action(page) + + def persist_bound_state(self) -> None: + store = BrowserCookieStore( + self.options["cookie_file"], # type: ignore[arg-type] + self.options["storage_state_file"], # type: ignore[arg-type] + ) + store.save_cookies(self.context.cookies()) + store.save_storage_state(self.context.storage_state()) + self.persisted = True + + def close(self) -> None: + self.closed = True + + +class _FakeCatalog: + def __init__(self, account: AccountBinding) -> None: + self.accounts = (account,) + self.sync_calls = 0 + + def account_for(self, account_id: str) -> AccountBinding: + assert account_id == self.accounts[0].account_id + return self.accounts[0] + + def sync_account_state(self, account_id: str) -> list[dict[str, object]]: + assert account_id == self.accounts[0].account_id + self.sync_calls += 1 + return [{"script_id": "demo:first.py", "synced": True}] + + +def _write_old_state(account: AccountBinding) -> None: + old_cookie = { + "name": "session", + "value": "old-session", + "domain": "example.test", + "path": "/", + "expires": 4_102_444_800, + } + store = BrowserCookieStore(account.cookie_file, account.storage_state_file) + store.save_cookies([old_cookie]) + store.save_storage_state({"cookies": [old_cookie], "origins": []}) + + +def test_keepalive_publishes_only_a_verified_authenticated_snapshot( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + account = _account(tmp_path) + _write_old_state(account) + account.cookie_file.with_name("keepalive_status.json").write_text( + json.dumps( + { + "status": "expired", + "identity_mismatch": True, + "final_url_login": False, + "error_type": "TargetClosedError", + "http_status": 502, + } + ), + encoding="utf-8", + ) + catalog = _FakeCatalog(account) + _FakeBrowser.instances = [] + monkeypatch.setattr(accounts, "ScraplingBrowser", _FakeBrowser) + + manager = accounts.AccountKeepaliveManager( + Settings(project_root=tmp_path, data_root=tmp_path / "data"), + catalog=catalog, # type: ignore[arg-type] + ) + + assert manager.refresh_account("demo-shop") == { + "status": "success", + "cookie_count": 1, + "synced_members": 1, + } + assert json.loads(account.cookie_file.read_text(encoding="utf-8"))[0]["value"] == ( + "fresh-session" + ) + assert catalog.sync_calls == 1 + assert _FakeBrowser.instances[0].persisted is True + assert _FakeBrowser.instances[0].closed is True + + status = json.loads( + account.cookie_file.with_name("keepalive_status.json").read_text( + encoding="utf-8" + ) + ) + assert status["status"] == "success" + assert "identity_mismatch" not in status + assert "final_url_login" not in status + assert "error_type" not in status + assert "http_status" not in status + assert "fresh-session" not in json.dumps(status) + + +def test_keepalive_does_not_overwrite_vault_after_login_redirect( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + account = _account(tmp_path) + _write_old_state(account) + catalog = _FakeCatalog(account) + _FakeBrowser.instances = [] + _FakeBrowser.final_url = "https://example.test/passport/login" + monkeypatch.setattr(accounts, "ScraplingBrowser", _FakeBrowser) + + try: + manager = accounts.AccountKeepaliveManager( + Settings(project_root=tmp_path, data_root=tmp_path / "data"), + catalog=catalog, # type: ignore[arg-type] + ) + assert manager.refresh_account("demo-shop")["status"] == "expired" + assert json.loads(account.cookie_file.read_text(encoding="utf-8"))[0]["value"] == ( + "old-session" + ) + assert catalog.sync_calls == 0 + assert _FakeBrowser.instances[0].persisted is False + finally: + _FakeBrowser.final_url = "https://example.test/console/home" + + +def test_login_url_is_not_an_authenticated_identity_without_markers( + tmp_path: Path, +) -> None: + account = _account(tmp_path) + + assert not accounts._page_matches_account_identity( + SimpleNamespace(url="https://example.test/passport/login"), account + ) + assert accounts._looks_like_login_url( + "https://loginmyseller.taobao.com/?redirect_url=https%3A%2F%2Fqn.taobao.com" + ) + + +def test_probe_account_page_waits_for_client_redirects( + tmp_path: Path, +) -> None: + account = _account(tmp_path) + waits: list[int] = [] + page = SimpleNamespace( + url="https://example.test/console/home", + wait_for_timeout=waits.append, + ) + + state = accounts._probe_account_page(page, account) + + assert state == { + "url": "https://example.test/console/home", + "identity_matches": True, + } + assert waits == [3_000] + + +def test_keepalive_rejects_a_valid_cookie_for_the_wrong_shop( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + base = _account(tmp_path) + account = AccountBinding( + account_id=base.account_id, + cdp_port=base.cdp_port, + cdp_url=base.cdp_url, + profile_dir=base.profile_dir, + cookie_file=base.cookie_file, + storage_state_file=base.storage_state_file, + required_cookie_domains=base.required_cookie_domains, + required_cookie_names=base.required_cookie_names, + login_identity_markers=("demo shop",), + keepalive_enabled=True, + keepalive_url=base.keepalive_url, + keepalive_interval_seconds=600, + ) + _write_old_state(account) + catalog = _FakeCatalog(account) + _FakeBrowser.instances = [] + + class _WrongShopPage: + url = _FakeBrowser.final_url + + def evaluate(self, _script: str, _markers: list[str]) -> bool: + return False + + class _WrongShopBrowser(_FakeBrowser): + def run_fetch_action(self, url: str, action, **_options: object): + page = _WrongShopPage() + assert url == "https://example.test/console/home" + return SimpleNamespace(status=200), action(page) + + monkeypatch.setattr(accounts, "ScraplingBrowser", _WrongShopBrowser) + manager = accounts.AccountKeepaliveManager( + Settings(project_root=tmp_path, data_root=tmp_path / "data"), + catalog=catalog, # type: ignore[arg-type] + ) + + assert manager.refresh_account(account.account_id) == { + "status": "expired", + "final_url_login": False, + "identity_mismatch": True, + } + assert catalog.sync_calls == 0 + assert _WrongShopBrowser.instances[0].persisted is False + + +def test_identity_mismatch_requires_login_even_with_valid_cookie() -> None: + assert accounts._keepalive_requires_relogin( + {"status": "success", "identity_mismatch": True} + ) + assert accounts._keepalive_requires_relogin({"status": "expired"}) + assert not accounts._keepalive_requires_relogin({"status": "success"}) + + +def test_keepalive_skips_expired_vault_without_starting_browser( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + account = _account(tmp_path) + store = BrowserCookieStore(account.cookie_file, account.storage_state_file) + store.save_cookies( + [ + { + "name": "session", + "value": "expired", + "domain": "example.test", + "path": "/", + "expires": 1, + } + ] + ) + monkeypatch.setattr( + accounts, + "ScraplingBrowser", + lambda **_options: pytest.fail("expired vault must not start a browser"), + ) + + manager = accounts.AccountKeepaliveManager( + Settings(project_root=tmp_path, data_root=tmp_path / "data"), + catalog=_FakeCatalog(account), # type: ignore[arg-type] + ) + + assert manager.refresh_account("demo-shop")["status"] == "not_logged_in" + + +def test_keepalive_runs_configured_login_when_account_vault_is_expired( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + base = _account(tmp_path) + account = replace( + base, + login_mode="C", + login_command_id="demo.login", + credential_env_names=("DEMO_ACCOUNT", "DEMO_PASSWORD"), + ) + store = BrowserCookieStore(account.cookie_file, account.storage_state_file) + store.save_cookies( + [ + { + "name": "session", + "value": "expired", + "domain": "example.test", + "path": "/", + "expires": 1, + } + ] + ) + + login_calls: list[str] = [] + + def login_runner(login_account: AccountBinding) -> dict[str, object]: + login_calls.append(login_account.account_id) + fresh_cookie = { + "name": "session", + "value": "recovered-session", + "domain": "example.test", + "path": "/", + "expires": 4_102_444_800, + } + store.save_cookies([fresh_cookie]) + store.save_storage_state({"cookies": [fresh_cookie], "origins": []}) + return {"status": "success"} + + monkeypatch.setattr( + accounts, + "ScraplingBrowser", + lambda **_options: pytest.fail("login recovery should run the login flow"), + ) + catalog = _FakeCatalog(account) + manager = accounts.AccountKeepaliveManager( + Settings(project_root=tmp_path, data_root=tmp_path / "data"), + catalog=catalog, # type: ignore[arg-type] + login_runner=login_runner, + ) + + assert manager.refresh_account(account.account_id) == { + "status": "success", + "login_recovered": True, + "cookie_count": 1, + "synced_members": 1, + } + assert login_calls == [account.account_id] + assert json.loads(account.cookie_file.read_text(encoding="utf-8"))[0]["value"] == ( + "recovered-session" + ) + + +def test_keepalive_runs_configured_login_after_server_side_expiry( + tmp_path: Path, +) -> None: + base = _account(tmp_path) + account = replace( + base, + login_mode="C", + login_command_id="demo.login", + credential_env_names=("DEMO_ACCOUNT", "DEMO_PASSWORD"), + ) + _write_old_state(account) + account.cookie_file.with_name("keepalive_status.json").write_text( + json.dumps({"status": "expired", "final_url_login": True}), + encoding="utf-8", + ) + calls: list[str] = [] + + def login_runner(login_account: AccountBinding) -> dict[str, object]: + calls.append(login_account.account_id) + return {"status": "success"} + + manager = accounts.AccountKeepaliveManager( + Settings(project_root=tmp_path, data_root=tmp_path / "data"), + catalog=_FakeCatalog(account), # type: ignore[arg-type] + login_runner=login_runner, + ) + + assert manager.refresh_account(account.account_id)["status"] == "success" + assert calls == [account.account_id] + + +def test_configured_login_keeps_credentials_out_of_process_arguments( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + base = _account(tmp_path) + account = replace( + base, + login_mode="C", + login_command_id="demo.login", + credential_env_names=("DEMO_ACCOUNT", "DEMO_PASSWORD"), + ) + + class _LoginCatalog(_FakeCatalog): + def binding_for(self, binding_id: str) -> SimpleNamespace: + assert binding_id == "demo.login" + return SimpleNamespace( + command_id="demo.login", + module="demo", + entry="login.py", + ) + + def environment_for( + self, + binding_id: str, + base_environment: dict[str, str], + ) -> dict[str, str]: + assert binding_id == "demo.login" + return dict(base_environment) + + monkeypatch.setenv("DEMO_ACCOUNT", "demo-user") + monkeypatch.setenv("DEMO_PASSWORD", "demo-password") + captured: dict[str, object] = {} + + def fake_run(command: list[str], **kwargs: object) -> SimpleNamespace: + captured["command"] = command + captured["env"] = kwargs["env"] + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(accounts.subprocess, "run", fake_run) + manager = accounts.AccountKeepaliveManager( + Settings(project_root=tmp_path, data_root=tmp_path / "data"), + catalog=_LoginCatalog(account), # type: ignore[arg-type] + ) + monkeypatch.setattr( + manager, + "_login_script_path", + lambda _binding: tmp_path / "login.py", + ) + + result = manager._run_configured_login(account) + + assert result == { + "status": "success", + "login_attempted": True, + "login_exit_code": 0, + } + assert captured["command"][-1] == "--login-only" + assert "demo-password" not in captured["command"] + environment = captured["env"] + assert isinstance(environment, dict) + assert environment["ERP_USERNAME"] == "demo-user" + assert environment["ERP_PASSWORD"] == "demo-password" + + +def test_configured_login_can_use_runtime_product_config_without_env_credentials( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + base = _account(tmp_path) + account = replace( + base, + login_mode="C", + login_command_id="demo.login", + credential_env_names=("DEMO_ACCOUNT", "DEMO_PASSWORD"), + ) + config_path = ( + tmp_path / "data" / "state" / "product_commerce" / "auto-flow-config.json" + ) + config_path.parent.mkdir(parents=True) + config_path.write_text( + json.dumps({"login": {"username": "configured-user", "password": "secret"}}), + encoding="utf-8", + ) + monkeypatch.delenv("DEMO_ACCOUNT", raising=False) + monkeypatch.delenv("DEMO_PASSWORD", raising=False) + + captured: dict[str, object] = {} + + def fake_run(command: list[str], **kwargs: object) -> SimpleNamespace: + captured["command"] = command + captured["env"] = kwargs["env"] + return SimpleNamespace(returncode=0) + + class _LoginCatalog(_FakeCatalog): + def binding_for(self, binding_id: str) -> SimpleNamespace: + assert binding_id == "demo.login" + return SimpleNamespace( + command_id="demo.login", + module="demo", + entry="login.py", + ) + + def environment_for( + self, + binding_id: str, + base_environment: dict[str, str], + ) -> dict[str, str]: + assert binding_id == "demo.login" + return dict(base_environment) + + monkeypatch.setattr(accounts.subprocess, "run", fake_run) + manager = accounts.AccountKeepaliveManager( + Settings(project_root=tmp_path, data_root=tmp_path / "data"), + catalog=_LoginCatalog(account), # type: ignore[arg-type] + ) + monkeypatch.setattr( + manager, + "_login_script_path", + lambda _binding: tmp_path / "login.py", + ) + + result = manager._run_configured_login(account) + + assert result == { + "status": "success", + "login_attempted": True, + "login_exit_code": 0, + } + assert captured["command"][-1] == "--login-only" + environment = captured["env"] + assert isinstance(environment, dict) + assert environment["GYXX_PRODUCT_CONFIG"] == str(config_path) + assert "secret" not in captured["command"] + + +def test_keepalive_initial_delay_staggers_first_refresh(tmp_path: Path) -> None: + base = _account(tmp_path) + account = AccountBinding( + account_id=base.account_id, + cdp_port=base.cdp_port, + cdp_url=base.cdp_url, + profile_dir=base.profile_dir, + cookie_file=base.cookie_file, + storage_state_file=base.storage_state_file, + required_cookie_domains=base.required_cookie_domains, + required_cookie_names=base.required_cookie_names, + keepalive_enabled=True, + keepalive_url=base.keepalive_url, + keepalive_initial_delay_seconds=60, + keepalive_interval_seconds=600, + ) + clock = [100.0] + manager = accounts.AccountKeepaliveManager( + Settings(project_root=tmp_path, data_root=tmp_path / "data"), + catalog=_FakeCatalog(account), # type: ignore[arg-type] + clock=lambda: clock[0], + ) + calls: list[str] = [] + manager.refresh_account = lambda account_id: ( # type: ignore[method-assign] + calls.append(account_id) or {"status": "success"} + ) + + assert manager.tick() == [] + clock[0] = 159.0 + assert manager.tick() == [] + clock[0] = 160.0 + assert manager.tick() == [{"status": "success"}] + assert calls == ["demo-shop"] + + +def test_scheduler_maintenance_runs_in_execute_mode_but_not_dry_run( + tmp_path: Path, +) -> None: + catalog = WorkflowCatalog( + timezone="Asia/Shanghai", + workflows=(), + schedules=(), + ) + calls: list[str] = [] + now = datetime(2026, 8, 20, 10, 0, tzinfo=ZoneInfo("Asia/Shanghai")) + + scheduler = PythonScheduler( + catalog, + tmp_path / "execute-data", + launcher=object(), # no workflows are present in this boundary test + maintenance=lambda: calls.append("execute"), + ) + scheduler.tick(now) + + dry_run_scheduler = PythonScheduler( + catalog, + tmp_path / "dry-run-data", + launcher=object(), + dry_run=True, + maintenance=lambda: calls.append("dry-run"), + ) + dry_run_scheduler.tick(now) + + assert calls == ["execute"] diff --git a/tests/test_business_modules.py b/tests/test_business_modules.py index 5588f5a..cb5be25 100644 --- a/tests/test_business_modules.py +++ b/tests/test_business_modules.py @@ -34,7 +34,7 @@ def test_catalog_composition_registers_all_workflows() -> None: registry = create_default_registry(catalog=catalog) - assert len(registry.workflow_ids) == 23 + assert len(registry.workflow_ids) == 29 assert set(registry.workflow_ids) == { "shop.metrics.weekly", "shop.competitor.weekly", @@ -43,22 +43,28 @@ def test_catalog_composition_registers_all_workflows() -> None: "supply.purchase_confirmation.daily", "supply.replenishment.weekly", "supply.replenishment_alert.daily", + "supply.replenishment_arrival_sync.daily", "content.metrics.daily", + "content.metrics.backfill", "content.marketing_report.daily", "content.relogin.weekly", "content.creator_report.monthly", "content.summary.monthly", - "content.cooperations.daily", + "content.notes_master.daily", "content.comments.weekly", "content.summary.weekly", "product.persona.daily", "product.daily", - "product.alert.daily", - "product.import.daily", - "product.style_analysis.interval", + "product.alert.daily", + "product.ecommerce_costs.daily", + "product.style_analysis.interval", "product.main_image.weekly", "product.sales_sheet.daily", + "product.erp_all_shop_daily", "product.market_rank", + "product.video_upload", + "product.jd_video_upload", + "product.tmall_baibu_apply", } diff --git a/tests/test_catalog.py b/tests/test_catalog.py index a7203b6..cf4896d 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -11,17 +11,17 @@ from gyxx_flow.catalog import CatalogError, WorkflowCatalog PROJECT_ROOT = Path(__file__).resolve().parents[1] -def test_catalog_maps_23_project_scheduled_tasks() -> None: +def test_catalog_maps_27_project_scheduled_tasks() -> None: catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") scheduled = catalog.scheduled_workflows() - assert len(scheduled) == 23 - assert len({item.source_task_name for item in scheduled}) == 23 + assert len(scheduled) == 27 + assert len({item.source_task_name for item in scheduled}) == 27 assert Counter(item.module for item in scheduled) == { - "content_marketing": 8, - "product_commerce": 8, + "content_marketing": 9, + "product_commerce": 10, "shop_intelligence": 4, - "supply_chain": 3, + "supply_chain": 4, } @@ -29,8 +29,11 @@ def test_catalog_schedules_douyin_price_appeal() -> None: catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") schedule = catalog.schedule_for("shop.douyin_price_appeal") - assert len(catalog.workflows) == 23 - assert catalog.manual_workflows() == () + assert len(catalog.workflows) == 29 + assert tuple(item.workflow_id for item in catalog.manual_workflows()) == ( + "product.video_upload", + "product.jd_video_upload", + ) assert "shop.douyin_price_appeal" in { schedule.workflow_id for schedule in catalog.schedules } @@ -39,6 +42,96 @@ def test_catalog_schedules_douyin_price_appeal() -> None: assert schedule.effective_times == ("08:00", "16:00", "22:00") +def test_tmall_video_upload_is_an_idempotent_store_selected_manual_workflow() -> None: + catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") + workflow = next( + item for item in catalog.workflows if item.workflow_id == "product.video_upload" + ) + + assert workflow.trigger == "manual" + assert [step.step_id for step in workflow.steps] == ["upload"] + step = workflow.steps[0] + assert step.entry == "upload_video_to_guanghe.py" + assert step.args == ( + "--execute", + "--keep-browser-open-on-failure", + ) + assert step.replay_policy == "idempotent" + assert step.timeout_seconds == 21600 + assert workflow.topic_config is not None + assert workflow.topic_config.default_keyword == "我的夏日焕新清单" + with pytest.raises(KeyError): + catalog.schedule_for(workflow.workflow_id) + + +def test_jd_video_upload_is_an_idempotent_manual_workflow() -> None: + catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") + workflow = next( + item + for item in catalog.workflows + if item.workflow_id == "product.jd_video_upload" + ) + + assert workflow.trigger == "manual" + assert [step.step_id for step in workflow.steps] == ["upload"] + step = workflow.steps[0] + assert step.entry == "upload_video_to_jd.py" + assert step.args == ( + "--anchor-record-id", + "recvrXTRWWFXeJ", + "--manual-login", + "--execute", + ) + assert step.replay_policy == "idempotent" + assert step.timeout_seconds == 21600 + with pytest.raises(KeyError): + catalog.schedule_for(workflow.workflow_id) + + +def test_tmall_baibu_apply_is_a_six_step_hourly_workflow() -> None: + catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") + workflow = next( + item + for item in catalog.workflows + if item.workflow_id == "product.tmall_baibu_apply" + ) + + assert workflow.trigger == "scheduled" + assert [step.step_id for step in workflow.steps] == [ + "old_chain_bag", + "old_all_bag", + "old_all_3c", + "old_chain_3c", + "new_chain_bag", + "new_all_bag", + ] + assert [step.entry for step in workflow.steps] == [ + "tmall_baibu_apply_old_chain_bag.py", + "tmall_baibu_apply_old_all_bag.py", + "tmall_baibu_apply_old_all_3c.py", + "tmall_baibu_apply_old_chain_3c.py", + "tmall_baibu_apply_new_chain_bag.py", + "tmall_baibu_apply_new_all_bag.py", + ] + assert workflow.steps[0].depends_on == () + assert all( + step.depends_on == (previous.step_id,) + for previous, step in zip(workflow.steps, workflow.steps[1:]) + ) + assert all(step.run_after_failure for step in workflow.steps[1:]) + assert all(step.replay_policy == "repeatable" for step in workflow.steps) + assert [step.step_id for step in workflow.steps if step.has_hyperlink] == [ + "old_chain_bag", + "old_chain_3c", + "new_chain_bag", + ] + schedule = catalog.schedule_for(workflow.workflow_id) + assert schedule.kind == "daily" + assert schedule.enabled is True + assert schedule.business_date_offset_days == 0 + assert schedule.effective_times == tuple(f"{hour:02d}:00" for hour in range(24)) + + def test_product_daily_passes_the_previous_business_day_explicitly() -> None: catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") workflow = next( @@ -49,16 +142,46 @@ def test_product_daily_passes_the_previous_business_day_explicitly() -> None: "--target-date", "{business_date}", "--stages", - "collect,analyze,export,insert", + "collect,import,analyze,export,insert", ) assert catalog.schedule_for(workflow.workflow_id).business_date_offset_days == -1 +def test_style_analysis_uses_the_previous_complete_business_day() -> None: + catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") + schedule = catalog.schedule_for("product.style_analysis.interval") + + assert schedule.kind == "interval_days" + assert schedule.every_days == 3 + assert schedule.business_date_offset_days == -1 + + +def test_erp_all_shop_daily_runs_at_nine_for_the_previous_day() -> None: + catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") + workflow = next( + item + for item in catalog.workflows + if item.workflow_id == "product.erp_all_shop_daily" + ) + + assert workflow.steps[0].args == ( + "--from", + "{business_date}", + "--to", + "{business_date}", + "--execute", + "--force", + ) + schedule = catalog.schedule_for(workflow.workflow_id) + assert schedule.at == "09:00" + assert schedule.business_date_offset_days == -1 + + def test_every_scheduled_workflow_has_one_valid_asia_shanghai_schedule() -> None: catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") assert catalog.timezone == "Asia/Shanghai" - assert len(catalog.schedules) == 23 + assert len(catalog.schedules) == 27 for workflow in catalog.scheduled_workflows(): schedule = catalog.schedule_for(workflow.workflow_id) assert schedule.workflow_id == workflow.workflow_id @@ -98,12 +221,36 @@ def test_scheduled_workflows_use_python_entries_and_content_tasks_are_graphs() - "collect_self_douyin", "sync", ] + assert daily.steps[0].depends_on == () assert daily.steps[1].depends_on == ("collect_collaborators",) - assert daily.steps[2].entry == "self_bilibili_scraper.py" - assert daily.steps[3].entry == "chanmama_scraper.py" + assert daily.steps[2].depends_on == ("refresh_self_mapping",) + assert daily.steps[3].depends_on == ("collect_self_bilibili",) + assert daily.steps[4].depends_on == ("collect_self_douyin",) assert "--include-self-operated" not in daily.steps[0].args - assert all(step.run_after_failure for step in daily.steps[1:]) - assert catalog.schedule_for(daily.workflow_id).at == "22:00" + assert all(step.replay_policy == "repeatable" for step in daily.steps) + assert catalog.schedule_for(daily.workflow_id).effective_times == ( + "01:00", + "05:00", + ) + + backfill = next( + workflow + for workflow in catalog.workflows + if workflow.workflow_id == "content.metrics.backfill" + ) + assert [step.step_id for step in backfill.steps] == [ + "collect_collaborators", + "sync", + ] + assert backfill.steps[0].entry == "run_all.py" + assert backfill.steps[0].args == ("--daily-scope",) + assert backfill.steps[1].entry == "data/tools/sync_metrics_to_cmt_notes.py" + assert backfill.steps[1].depends_on == ("collect_collaborators",) + assert backfill.steps[1].run_after_failure is True + backfill_schedule = catalog.schedule_for(backfill.workflow_id) + assert backfill_schedule.kind == "weekly" + assert backfill_schedule.days == ("Monday",) + assert backfill_schedule.at == "14:00" marketing_report = next( workflow @@ -122,6 +269,15 @@ def test_scheduled_workflows_use_python_entries_and_content_tasks_are_graphs() - for destination in report_flow.destinations ) + notes_master = next( + workflow + for workflow in catalog.workflows + if workflow.workflow_id == "content.notes_master.daily" + ) + assert notes_master.steps[0].entry == "data/tools/sync_notes_master.py" + assert notes_master.steps[0].args == ("--execute",) + assert catalog.schedule_for(notes_master.workflow_id).at == "09:00" + comments = next( workflow for workflow in catalog.workflows @@ -150,6 +306,21 @@ def test_scheduled_workflows_use_python_entries_and_content_tasks_are_graphs() - assert "并行执行京东与天猫" in main_image.note assert catalog.schedule_for(main_image.workflow_id).at == "08:30" + shop_metrics = next( + workflow + for workflow in catalog.workflows + if workflow.workflow_id == "shop.metrics.weekly" + ) + assert [step.step_id for step in shop_metrics.steps] == ["jd", "dy", "tm"] + assert shop_metrics.steps[0].args == ("--platform", "jd", "--skip-feishu") + assert shop_metrics.steps[1].args == ("--platform", "dy", "--skip-feishu") + assert all( + destination.system != "飞书 Base" + for step in shop_metrics.steps + for destination in step.data_flow.destinations + ) + assert "店铺经营指标周采集不写飞书 Base" in shop_metrics.note + def test_shop_steps_preserve_declared_replay_policy_at_module_boundary() -> None: from gyxx_flow.modules.shop_intelligence import ShopIntelligenceModule @@ -168,16 +339,18 @@ def test_shop_steps_preserve_declared_replay_policy_at_module_boundary() -> None assert policies["shop.douyin_price_appeal"] == {"idempotent"} -def test_product_daily_import_is_declared_idempotent() -> None: +def test_product_daily_includes_the_idempotent_import_stage() -> None: catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") workflow = next( item for item in catalog.workflows - if item.workflow_id == "product.import.daily" + if item.workflow_id == "product.daily" ) - assert len(workflow.steps) == 1 - assert workflow.steps[0].replay_policy == "idempotent" + assert "collect,import,analyze,export,insert" in workflow.steps[0].args + assert not any( + item.workflow_id == "product.import.daily" for item in catalog.workflows + ) def test_catalog_loads_optional_step_timeout_seconds(tmp_path: Path) -> None: diff --git a/tests/test_cli_workflows.py b/tests/test_cli_workflows.py index 4889e15..720fdfd 100644 --- a/tests/test_cli_workflows.py +++ b/tests/test_cli_workflows.py @@ -51,11 +51,13 @@ def test_default_cli_registry_exposes_all_project_scheduled_workflows( scheduled_ids = { entry.workflow_id for entry in registry.catalog.scheduled_workflows() } - assert len(scheduled_ids) == 23 + assert len(scheduled_ids) == 27 assert scheduled_ids == { schedule.workflow_id for schedule in registry.catalog.schedules } assert all(registry.is_registered(workflow_id) for workflow_id in scheduled_ids) + assert registry.is_registered("product.video_upload") + assert registry.is_registered("product.jd_video_upload") def _catalog(tmp_path: Path) -> WorkflowCatalog: diff --git a/tests/test_console.py b/tests/test_console.py index e2ecdfd..71b7b6e 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -43,7 +43,11 @@ def console_settings(tmp_path: Path) -> Settings: project_root = tmp_path / "project" config_dir = project_root / "config" config_dir.mkdir(parents=True) - for name in ("workflows.json", "schedules.json"): + for name in ( + "workflows.json", + "schedules.json", + "notification-routing.json", + ): shutil.copyfile(PROJECT_ROOT / "config" / name, config_dir / name) return Settings(project_root=project_root, data_root=tmp_path / "data") @@ -61,6 +65,10 @@ class FakeConsoleLauncher: *, execute: bool, shadow: bool, + video_upload_store: str | None = None, + tmall_topic_keyword: str | None = None, + force_refresh: bool = False, + tmall_baibu_import_mode: str | None = None, ) -> dict[str, object]: if workflow_id in self._active: raise ConsoleConflictError("already active") @@ -73,6 +81,14 @@ class FakeConsoleLauncher: "started_at": "2026-08-01T00:00:00+00:00", "status": "accepted", } + if video_upload_store is not None: + operation["store"] = video_upload_store + if tmall_topic_keyword is not None: + operation["topic_keyword"] = tmall_topic_keyword + if force_refresh: + operation["force_refresh"] = True + if tmall_baibu_import_mode is not None: + operation["tmall_baibu_import_mode"] = tmall_baibu_import_mode self.calls.append(operation) self._active[workflow_id] = operation return dict(operation) @@ -121,6 +137,12 @@ def _write_config(settings: Settings, name: str, payload: dict[str, Any]) -> Non ) +def _write_scheduler_state(settings: Settings, payload: dict[str, Any]) -> None: + path = settings.data_root / "state" / "scheduler" / "state.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload), encoding="utf-8") + + def _append_non_scheduled_workflow( settings: Settings, *, @@ -242,6 +264,259 @@ def test_error_sanitizer_preserves_context_and_root_cause_tail() -> None: assert len(sanitized) <= console_module.MAX_ERROR_CHARS +def test_notification_mobile_response_is_app_scoped_and_deduplicated() -> None: + payload = { + "ok": True, + "data": { + "user_list": [ + { + "mobile": "+86 138-0013-8000", + "user_id": "ou_89bcff110ccbb09a23548dc0fb3d880c", + }, + { + "mobile": "+8613800138000", + "open_id": "ou_89bcff110ccbb09a23548dc0fb3d880c", + }, + ] + }, + } + + assert console_module._normalize_mobile("+86 138-0013-8000") == ( + "+8613800138000" + ) + assert console_module._open_ids_from_mobile_response( + payload, + "+8613800138000", + ) == ["ou_89bcff110ccbb09a23548dc0fb3d880c"] + + +@pytest.mark.parametrize("mobile", ["", "123", "not-a-phone", "+86 138*0013"]) +def test_notification_mobile_rejects_unsafe_values(mobile: str) -> None: + with pytest.raises(ConsoleRequestError, match="手机号格式无效"): + console_module._normalize_mobile(mobile) + + +def test_notification_config_persists_app_scoped_multi_recipient_routes( + console_settings: Settings, +) -> None: + service = WorkflowConsoleService( + console_settings, + launcher=FakeConsoleLauncher(), + ) + initial = service.notification_config() + + assert len(initial["people"]) == 16 + assert initial["available_app_profiles"] == ["hermes-analyzer"] + assert [app["profile"] for app in initial["apps"]] == ["hermes-analyzer"] + wang = next(person for person in initial["people"] if person["id"] == "wang_yunlong") + assert wang["bindings"]["collector"]["open_id"] == ( + "ou_8ee224968aa26a74c7d30ba27fed5eeb" + ) + assert wang["bindings"]["hermes-analyzer"]["open_id"] == ( + "ou_7ad5fc8012e2f741afc5346e05ffd447" + ) + assert all(route["configured"] is False for route in initial["routes"]) + + routes = [dict(route) for route in initial["routes"]] + daily = next( + route + for route in routes + if route["workflow_id"] == "content.marketing_report.daily" + ) + daily.update( + configured=True, + enabled=True, + person_ids=["he_yingwei", "huang_shaoji"], + ) + body_routes = [ + { + "workflow_id": route["workflow_id"], + "configured": route["configured"], + "enabled": route["enabled"], + "person_ids": route["person_ids"], + } + for route in routes + ] + updated = service.update_notification_config( + { + "app_profile": initial["app_profile"], + "people": initial["people"], + "routes": body_routes, + }, + expected_revision=str(initial["revision"]), + ) + + updated_daily = next( + route + for route in updated["routes"] + if route["workflow_id"] == "content.marketing_report.daily" + ) + assert updated_daily["enabled"] is True + assert updated_daily["person_ids"] == ["he_yingwei", "huang_shaoji"] + assert updated["revision"] != initial["revision"] + assert ( + console_settings.data_root + / "state" + / "notifications" + / "routing.json" + ).is_file() + + with pytest.raises(ConsoleConflictError, match="refresh|刷新"): + service.update_notification_config( + { + "app_profile": initial["app_profile"], + "people": initial["people"], + "routes": body_routes, + }, + expected_revision=str(initial["revision"]), + ) + + +def test_notification_phone_binding_uses_selected_profile_without_persisting( + monkeypatch: pytest.MonkeyPatch, + console_settings: Settings, +) -> None: + observed: dict[str, object] = {} + service = WorkflowConsoleService( + console_settings, + launcher=FakeConsoleLauncher(), + ) + initial = service.notification_config() + initial_revision = initial["revision"] + monkeypatch.setattr( + console_module, + "_configured_lark_apps", + lambda _environment: { + "hermes-analyzer": "cli_aa8c4fc918b85cce", + }, + ) + + def fake_resolve( + profile: str, + mobile: str, + *, + environment: object, + ) -> str: + observed.update( + profile=profile, + mobile=mobile, + environment=environment, + ) + return "ou_c1faf3d3498201d27cd73c388acde067" + + monkeypatch.setattr( + console_module, + "_resolve_feishu_open_id_by_mobile", + fake_resolve, + ) + + resolved = service.resolve_notification_recipient( + { + "person_id": "feng_renyun", + "mobile": "13800138000", + "app_profile": "hermes-analyzer", + } + ) + + assert resolved == { + "person_id": "feng_renyun", + "mobile": "+8613800138000", + "app_profile": "hermes-analyzer", + "open_id": "ou_c1faf3d3498201d27cd73c388acde067", + } + assert observed["profile"] == "hermes-analyzer" + assert observed["mobile"] == "+8613800138000" + assert service.notification_config()["revision"] == initial_revision + + people = initial["people"] + feng = next(person for person in people if person["id"] == "feng_renyun") + feng["mobile"] = resolved["mobile"] + feng["bindings"]["hermes-analyzer"] = { + "open_id": resolved["open_id"], + "verified": True, + "source": "mobile_lookup", + } + updated = service.update_notification_config( + { + "app_profile": initial["app_profile"], + "people": people, + "routes": [ + { + "workflow_id": route["workflow_id"], + "configured": route["configured"], + "enabled": route["enabled"], + "person_ids": route["person_ids"], + } + for route in initial["routes"] + ], + }, + expected_revision=str(initial_revision), + ) + saved_feng = next( + person for person in updated["people"] if person["id"] == "feng_renyun" + ) + assert saved_feng["bindings"]["hermes-analyzer"]["verified"] is True + + +def test_notification_phone_binding_rejects_profile_with_wrong_app_id( + monkeypatch: pytest.MonkeyPatch, + console_settings: Settings, +) -> None: + service = WorkflowConsoleService( + console_settings, + launcher=FakeConsoleLauncher(), + ) + monkeypatch.setattr( + console_module, + "_configured_lark_apps", + lambda _environment: {"hermes-analyzer": "cli_wrong_application"}, + ) + + with pytest.raises(ConsoleRequestError, match="App ID"): + service.resolve_notification_recipient( + { + "person_id": "feng_renyun", + "mobile": "13800138000", + "app_profile": "hermes-analyzer", + } + ) + + +def test_notification_update_rejects_forged_mobile_lookup_verification( + console_settings: Settings, +) -> None: + service = WorkflowConsoleService( + console_settings, + launcher=FakeConsoleLauncher(), + ) + initial = service.notification_config() + he = next(person for person in initial["people"] if person["id"] == "he_yingwei") + he["mobile"] = "+8613800138000" + he["bindings"]["hermes-analyzer"] = { + "open_id": "ou_89bcff110ccbb09a23548dc0fb3d880c", + "verified": True, + "source": "mobile_lookup", + } + + with pytest.raises(ConsoleRequestError, match="重新通过手机号绑定"): + service.update_notification_config( + { + "app_profile": initial["app_profile"], + "people": initial["people"], + "routes": [ + { + "workflow_id": route["workflow_id"], + "configured": route["configured"], + "enabled": route["enabled"], + "person_ids": route["person_ids"], + } + for route in initial["routes"] + ], + }, + expected_revision=str(initial["revision"]), + ) + + def test_overview_tracks_the_current_complete_catalog_without_leaking_paths( console_settings: Settings, ) -> None: @@ -272,6 +547,14 @@ def test_overview_tracks_the_current_complete_catalog_without_leaking_paths( assert failed["last_run"]["error"] == "critical steps failed: collect" assert all(step["name"] and step["description"] for step in failed["steps"]) assert {step["replay_policy"] for step in failed["steps"]} == {"guarded"} + notes_master = next( + item + for item in initial["workflows"] + if item["id"] == "content.notes_master.daily" + ) + assert notes_master["name"] == "笔记清单与合作同步" + assert notes_master["registered"] is True + assert notes_master["schedule"]["at"] == "09:00" shop = next( item for item in initial["workflows"] if item["id"] == "shop.metrics.weekly" ) @@ -305,6 +588,14 @@ def test_overview_tracks_the_current_complete_catalog_without_leaking_paths( if item["id"] == "product.sales_sheet.daily" ) assert monthly_sales["run_date_mode"] == "month" + all_shop_daily = next( + item + for item in initial["workflows"] + if item["id"] == "product.erp_all_shop_daily" + ) + assert all_shop_daily["run_date_mode"] == "date" + assert all_shop_daily["schedule"]["at"] == "09:00" + assert all_shop_daily["schedule"]["business_date_offset_days"] == -1 assert failed["run_date_mode"] == "date" report = next( item @@ -351,6 +642,61 @@ def test_overview_tracks_the_current_complete_catalog_without_leaking_paths( assert probe["schedule"] is None +def test_start_scheduler_launches_resident_process_with_console_environment( + monkeypatch: pytest.MonkeyPatch, + console_settings: Settings, + tmp_path: Path, +) -> None: + env_file = tmp_path / "runtime.env" + env_file.write_text("PG_PASSWORD=redacted-for-test\n", encoding="utf-8") + process = type( + "FakeSchedulerProcess", + (), + {"pid": 43210, "poll": lambda self: None}, + )() + calls: list[tuple[list[str], dict[str, object]]] = [] + + def fake_popen(argv: list[str], **kwargs: object) -> object: + calls.append((argv, kwargs)) + return process + + process_info = console_module._SchedulerProcessInfo( + pid=43210, + started_at="2026-08-31T01:00:00+00:00", + ) + lock_checks = iter((None, process_info)) + monkeypatch.setattr(console_module.subprocess, "Popen", fake_popen) + monkeypatch.setattr( + console_module, + "_scheduler_process_from_lock", + lambda _settings: next(lock_checks), + ) + + result = WorkflowConsoleService( + console_settings, + launcher=FakeConsoleLauncher(), + scheduler_env_files=[env_file], + ).start_scheduler() + + assert result == { + "status": "running", + "service_running": True, + "pid": 43210, + "started_at": "2026-08-31T01:00:00+00:00", + } + assert len(calls) == 1 + argv, kwargs = calls[0] + assert argv[1:5] == ["-m", "gyxx_flow", "schedule", "run"] + assert argv[5:] == ["--env-file", str(env_file.resolve())] + assert kwargs["cwd"] == console_settings.project_root + assert kwargs["stdin"] is subprocess.DEVNULL + assert isinstance(kwargs["env"], dict) + assert kwargs["env"]["GYXX_PROJECT_ROOT"] == str( + console_settings.project_root + ) + assert kwargs["env"]["GYXX_DATA_ROOT"] == str(console_settings.data_root) + + def test_workflow_display_name_is_persistent_revision_safe_and_resettable( console_settings: Settings, ) -> None: @@ -908,8 +1254,19 @@ def test_invalid_schedule_values_leave_the_original_file_unchanged( def test_service_trigger_supports_dry_run_confirmed_execute_and_conflict( + monkeypatch: pytest.MonkeyPatch, console_settings: Settings, ) -> None: + for name, value in { + "PG_HOST": "database.example", + "PG_PORT": "5432", + "PG_DB": "business", + "PG_USER": "runtime", + "PG_PASSWORD": "placeholder", + }.items(): + monkeypatch.setenv(name, value) + monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False) + monkeypatch.delenv("GYXX_FEISHU_TABLE_WRITE_DISABLED", raising=False) launcher = FakeConsoleLauncher() service = WorkflowConsoleService(console_settings, launcher=launcher) workflow_id = "content.metrics.daily" @@ -927,6 +1284,18 @@ def test_service_trigger_supports_dry_run_confirmed_execute_and_conflict( ) assert len(launcher.calls) == 1 + with pytest.raises(ConsoleRequestError, match="强制重新采集"): + service.trigger( + workflow_id, + {"business_date": "2026-08-01", "force_refresh": "yes"}, + ) + + with pytest.raises(ConsoleRequestError, match="必须选择真实执行"): + service.trigger( + workflow_id, + {"business_date": "2026-08-01", "force_refresh": True}, + ) + executed = service.trigger( workflow_id, { @@ -934,16 +1303,496 @@ def test_service_trigger_supports_dry_run_confirmed_execute_and_conflict( "execute": True, "confirmed": True, "shadow": True, + "force_refresh": True, }, ) assert executed["mode"] == "execute" assert executed["shadow"] is True + assert executed["force_refresh"] is True with pytest.raises(ConsoleConflictError): service.trigger(workflow_id, {"business_date": "2026-08-01"}) assert len(launcher.calls) == 2 +def test_tmall_baibu_trigger_defaults_to_no_hyperlinks_and_accepts_all_mode( + console_settings: Settings, +) -> None: + launcher = FakeConsoleLauncher() + service = WorkflowConsoleService(console_settings, launcher=launcher) + + overview = service.overview() + baibu = next( + item for item in overview["workflows"] if item["id"] == "product.tmall_baibu_apply" + ) + assert baibu["tmall_baibu_import"]["default_mode"] == "without_hyperlinks" + assert [option["step_count"] for option in baibu["tmall_baibu_import"]["options"]] == [3, 6] + + default_run = service.trigger( + "product.tmall_baibu_apply", + {"business_date": "2026-08-27"}, + ) + assert default_run["tmall_baibu_import_mode"] == "without_hyperlinks" + assert launcher.calls[-1]["tmall_baibu_import_mode"] == "without_hyperlinks" + launcher.complete("product.tmall_baibu_apply") + + all_run = service.trigger( + "product.tmall_baibu_apply", + { + "business_date": "2026-08-27", + "tmall_baibu_import_mode": "all", + }, + ) + assert all_run["tmall_baibu_import_mode"] == "all" + assert launcher.calls[-1]["tmall_baibu_import_mode"] == "all" + + with pytest.raises(ConsoleRequestError, match="导入范围"): + service.trigger( + "product.tmall_baibu_apply", + { + "business_date": "2026-08-27", + "tmall_baibu_import_mode": "invalid", + }, + ) + + +def test_tmall_baibu_force_trigger_does_not_read_business_date_receipts( + console_settings: Settings, +) -> None: + from gyxx_flow.ops import EffectLedger + + ledger = EffectLedger(console_settings.data_root) + claim = ledger.begin( + workflow_id="product.tmall_baibu_apply", + business_date="2026-08-27", + step_id="old_all_bag", + run_id="run-original", + ) + ledger.mark_ambiguous(claim, reason="page-result-needs-reconciliation") + launcher = FakeConsoleLauncher() + service = WorkflowConsoleService(console_settings, launcher=launcher) + + result = service.trigger( + "product.tmall_baibu_apply", + { + "business_date": "2026-08-27", + "execute": True, + "confirmed": True, + "force_refresh": True, + "tmall_baibu_import_mode": "without_hyperlinks", + }, + ) + assert result["status"] == "accepted" + assert launcher.calls[-1]["workflow_id"] == "product.tmall_baibu_apply" + + +def test_overview_exposes_only_verified_scheduled_runs_as_cancellable( + monkeypatch: pytest.MonkeyPatch, + console_settings: Settings, +) -> None: + _write_scheduler_state( + console_settings, + { + "schema_version": 1, + "updated_at": "2026-08-14T02:00:00+00:00", + "jobs": { + "content.relogin.weekly": { + "status": "running", + "pid": 4310, + "business_date": "2026-08-14", + "started_at": "2026-08-14T02:00:00+00:00", + }, + "content.metrics.daily": { + "status": "running", + "pid": 4311, + "business_date": "2026-08-14", + "started_at": "2026-08-14T02:00:00+00:00", + }, + }, + }, + ) + + class VerifiedProcess: + pid = 4310 + + def fake_process( + workflow_id: str, + _job: dict[str, Any], + ) -> tuple[bool, Any, float | None]: + if workflow_id == "content.relogin.weekly": + return True, VerifiedProcess(), 1_800_000_000.0 + return False, None, None + + monkeypatch.setattr(console_module, "_scheduler_job_process", fake_process) + overview = WorkflowConsoleService( + console_settings, + launcher=FakeConsoleLauncher(), + ).overview() + workflows = {item["id"]: item for item in overview["workflows"]} + + verified = workflows["content.relogin.weekly"]["scheduler_job"] + assert verified["status"] == "running" + assert verified["cancellable"] is True + assert "pid" not in verified + stale = workflows["content.metrics.daily"]["scheduler_job"] + assert stale["status"] == "interrupted" + assert stale["cancellable"] is False + assert overview["summary"]["running"] == 1 + + +def test_service_cancels_verified_scheduled_run_and_finalizes_journal( + monkeypatch: pytest.MonkeyPatch, + console_settings: Settings, +) -> None: + workflow_id = "content.relogin.weekly" + _write_scheduler_state( + console_settings, + { + "schema_version": 1, + "updated_at": "2026-08-14T02:00:00+00:00", + "jobs": { + workflow_id: { + "status": "running", + "pid": 4321, + "business_date": "2026-08-14", + "started_at": "2026-08-14T02:00:00+00:00", + } + }, + }, + ) + + class VerifiedProcess: + pid = 4321 + + process = VerifiedProcess() + terminated: list[tuple[Any, float]] = [] + monkeypatch.setattr( + console_module, + "_scheduler_job_process", + lambda _workflow_id, _job: (True, process, 1_800_000_000.0), + ) + monkeypatch.setattr( + console_module, + "_scheduler_run_journal_path", + lambda *_args: console_settings.data_root / "runs" / "run.json", + ) + monkeypatch.setattr( + console_module, + "_terminate_scheduler_process_tree", + lambda value, started_at: terminated.append((value, started_at)), + ) + monkeypatch.setattr( + console_module, + "_finalize_scheduler_cancelled_journal", + lambda *_args: ("cancelled", "scheduled-run-id"), + ) + + result = WorkflowConsoleService( + console_settings, + launcher=FakeConsoleLauncher(), + ).cancel_scheduled(workflow_id) + + assert result["status"] == "cancelled" + assert result["source"] == "scheduler" + assert result["run_id"] == "scheduled-run-id" + assert terminated == [(process, 1_800_000_000.0)] + + +def test_tmall_video_trigger_routes_only_the_selected_store( + console_settings: Settings, +) -> None: + launcher = FakeConsoleLauncher() + service = WorkflowConsoleService(console_settings, launcher=launcher) + + overview = service.overview() + video_workflow = next( + item for item in overview["workflows"] if item["id"] == "product.video_upload" + ) + assert video_workflow["topic_config"]["default_keyword"] == "我的夏日焕新清单" + + luggage = service.trigger( + "product.video_upload", + { + "business_date": "2026-08-11", + "store": "luggage", + "tmall_topic_keyword": "秋上新", + }, + ) + + assert luggage["store"] == "luggage" + assert luggage["topic_keyword"] == "秋上新" + assert launcher.calls[-1]["store"] == "luggage" + assert launcher.calls[-1]["topic_keyword"] == "秋上新" + launcher.complete("product.video_upload") + + flagship = service.trigger( + "product.video_upload", + {"business_date": "2026-08-11", "store": "flagship"}, + ) + + assert flagship["store"] == "flagship" + assert flagship["topic_keyword"] == "我的夏日焕新清单" + assert launcher.calls[-1]["store"] == "flagship" + + +@pytest.mark.parametrize("value", [123, True, "x" * 121]) +def test_tmall_video_trigger_rejects_invalid_topic_keyword( + console_settings: Settings, + value: object, +) -> None: + service = WorkflowConsoleService( + console_settings, + launcher=FakeConsoleLauncher(), + ) + + with pytest.raises(ConsoleRequestError, match="话题关键词"): + service.trigger( + "product.video_upload", + {"business_date": "2026-08-11", "tmall_topic_keyword": value}, + ) + + +def test_video_trigger_execute_uses_direct_model_preflight( + monkeypatch: pytest.MonkeyPatch, + console_settings: Settings, +) -> None: + monkeypatch.setenv("PG_HOST", "database.example") + monkeypatch.setenv("PG_PORT", "5432") + monkeypatch.setenv("PG_DB", "business") + monkeypatch.setenv("PG_USER", "runtime") + monkeypatch.setenv("PG_PASSWORD", "placeholder") + monkeypatch.setenv( + "STYLE_ANALYSIS_LLM_BASE_URL", + "https://api.minimaxi.com/v1", + ) + monkeypatch.setenv("STYLE_ANALYSIS_LLM_API_KEY", "test-only-secret") + monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False) + monkeypatch.delenv("GYXX_FEISHU_TABLE_WRITE_DISABLED", raising=False) + monkeypatch.delenv("GYXX_HERMES_API_KEY", raising=False) + monkeypatch.delenv("HERMES_API_KEY", raising=False) + + launcher = FakeConsoleLauncher() + result = WorkflowConsoleService( + console_settings, + launcher=launcher, + ).trigger( + "product.video_upload", + { + "business_date": "2026-08-11", + "store": "flagship", + "execute": True, + "confirmed": True, + }, + ) + + assert result["mode"] == "execute" + assert launcher.calls[-1]["store"] == "flagship" + + +@pytest.mark.parametrize("store", ["all", "unknown", "", None, 1]) +def test_tmall_video_trigger_rejects_invalid_frontend_store( + console_settings: Settings, + store: object, +) -> None: + service = WorkflowConsoleService( + console_settings, + launcher=FakeConsoleLauncher(), + ) + + with pytest.raises(ConsoleRequestError, match="店铺参数无效"): + service.trigger( + "product.video_upload", + {"business_date": "2026-08-11", "store": store}, + ) + + +def test_other_workflow_rejects_tmall_store_parameter( + console_settings: Settings, +) -> None: + service = WorkflowConsoleService( + console_settings, + launcher=FakeConsoleLauncher(), + ) + + with pytest.raises(ConsoleRequestError, match="不支持的字段"): + service.trigger( + "content.metrics.daily", + {"business_date": "2026-08-11", "store": "flagship"}, + ) + + +@pytest.mark.parametrize( + "workflow_id", + [ + "content.notes_master.daily", + "content.metrics.daily", + "content.metrics.backfill", + "content.marketing_report.daily", + "content.creator_report.monthly", + "content.summary.monthly", + "content.comments.weekly", + "content.summary.weekly", + ], +) +def test_content_production_preflight_accepts_complete_cloud_postgres( + workflow_id: str, +) -> None: + catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") + entry = next( + item for item in catalog.workflows if item.workflow_id == workflow_id + ) + + environment = { + "PG_HOST": "database.example", + "PG_PORT": "5432", + "PG_DB": "business", + "PG_USER": "runtime", + "PG_PASSWORD": "placeholder", + } + if workflow_id in console_module._CONTENT_DIRECT_ANALYSIS_WORKFLOW_IDS: + environment["CONTENT_ANALYSIS_LLM_API_KEY"] = "test-only-secret" + + console_module._validate_content_production_runtime(entry, environment) + + +@pytest.mark.parametrize( + "workflow_id", + ["content.summary.monthly", "content.summary.weekly"], +) +def test_content_summary_preflight_requires_direct_model_key( + workflow_id: str, +) -> None: + catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") + entry = next( + item for item in catalog.workflows if item.workflow_id == workflow_id + ) + + with pytest.raises(ConsoleRequestError, match="直连 MiniMax"): + console_module._validate_content_production_runtime( + entry, + { + "PG_HOST": "database.example", + "PG_PORT": "5432", + "PG_DB": "business", + "PG_USER": "runtime", + "PG_PASSWORD": "placeholder", + }, + ) + + +def test_content_summary_child_does_not_inherit_hermes_credentials() -> None: + resolved = console_module._console_runtime_environment( + { + "GYXX_HERMES_API_KEY": "legacy-secret", + "HERMES_API_KEY": "legacy-secret", + "HERMES_ANALYZER_TOKEN": "legacy-secret", + "GYXX_SUPPLY_HERMES_TOKEN": "legacy-secret", + "CONTENT_ANALYSIS_LLM_API_KEY": "direct-secret", + }, + workflow_id="content.summary.weekly", + ) + + assert "GYXX_HERMES_API_KEY" not in resolved + assert "HERMES_API_KEY" not in resolved + assert "HERMES_ANALYZER_TOKEN" not in resolved + assert "GYXX_SUPPLY_HERMES_TOKEN" not in resolved + assert resolved["CONTENT_ANALYSIS_LLM_API_KEY"] == "direct-secret" + + +@pytest.mark.parametrize("host", ["localhost", "127.0.0.1", "[::1]"]) +def test_content_production_preflight_rejects_loopback_postgres(host: str) -> None: + catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") + entry = next( + item + for item in catalog.workflows + if item.workflow_id == "content.metrics.daily" + ) + + with pytest.raises(ConsoleRequestError, match="云端 PostgreSQL.*loopback"): + console_module._validate_content_production_runtime( + entry, + { + "PG_HOST": host, + "PG_PORT": "5432", + "PG_DB": "business", + "PG_USER": "runtime", + "PG_PASSWORD": "placeholder", + }, + ) + + +@pytest.mark.parametrize( + ("flag", "message"), + [ + ("GYXX_WORKFLOW_ACCEPTANCE", "验收隔离"), + ("GYXX_FEISHU_TABLE_WRITE_DISABLED", "禁用真实写入"), + ], +) +def test_content_production_preflight_rejects_non_production_modes( + flag: str, + message: str, +) -> None: + catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") + relogin = next( + item + for item in catalog.workflows + if item.workflow_id == "content.relogin.weekly" + ) + + with pytest.raises(ConsoleRequestError, match=message): + console_module._validate_content_production_runtime(relogin, {flag: "1"}) + + +def test_content_relogin_preflight_does_not_require_postgres() -> None: + catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") + relogin = next( + item + for item in catalog.workflows + if item.workflow_id == "content.relogin.weekly" + ) + + console_module._validate_content_production_runtime(relogin, {}) + + +@pytest.mark.parametrize( + "workflow_id", + ["content.notes_master.daily", "content.metrics.daily"], +) +def test_content_execute_missing_database_fails_before_popen( + monkeypatch: pytest.MonkeyPatch, + console_settings: Settings, + workflow_id: str, +) -> None: + for name in ("PG_HOST", "PG_PORT", "PG_DB", "PG_USER", "PG_PASSWORD"): + monkeypatch.delenv(name, raising=False) + monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False) + monkeypatch.delenv("GYXX_FEISHU_TABLE_WRITE_DISABLED", raising=False) + popen_called = False + + def forbidden_popen(*_args: object, **_kwargs: object) -> None: + nonlocal popen_called + popen_called = True + pytest.fail("Popen must not run when content production preflight fails") + + monkeypatch.setattr(console_module.subprocess, "Popen", forbidden_popen) + service = WorkflowConsoleService(console_settings) + + with pytest.raises( + ConsoleRequestError, + match="PostgreSQL.*PG_HOST.*PG_PASSWORD.*--env-file", + ): + service.trigger( + workflow_id, + { + "business_date": "2026-08-01", + "execute": True, + "confirmed": True, + }, + ) + + assert popen_called is False + + def test_product_production_preflight_requires_real_database_and_lark_profile( tmp_path: Path, ) -> None: @@ -954,7 +1803,16 @@ def test_product_production_preflight_requires_real_database_and_lark_profile( config_dir = tmp_path / ".lark-cli" config_dir.mkdir() (config_dir / "config.json").write_text( - json.dumps({"apps": [{"name": "hermes-analyzer"}]}), + json.dumps( + { + "apps": [ + { + "name": "hermes-analyzer", + "appId": "cli_aa8c4fc918b85cce", + } + ] + } + ), encoding="utf-8", ) environment = { @@ -983,6 +1841,27 @@ def test_product_production_preflight_requires_real_database_and_lark_profile( with pytest.raises(ConsoleRequestError, match="hermes-analyzer"): console_module._validate_product_production_runtime(daily, without_lark) + wrong_app_dir = tmp_path / "wrong-lark-app" + wrong_app_dir.mkdir() + (wrong_app_dir / "config.json").write_text( + json.dumps( + { + "apps": [ + { + "name": "hermes-analyzer", + "appId": "cli_wrong_application", + } + ] + } + ), + encoding="utf-8", + ) + with pytest.raises(ConsoleRequestError, match="hermes-analyzer"): + console_module._validate_product_production_runtime( + daily, + {**environment, "LARKSUITE_CLI_CONFIG_DIR": str(wrong_app_dir)}, + ) + def test_monthly_sales_preflight_does_not_require_postgres_or_hermes() -> None: catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") @@ -1012,7 +1891,16 @@ def test_product_production_preflight_rejects_acceptance_and_missing_owner( config_dir = tmp_path / ".lark-cli" config_dir.mkdir() (config_dir / "config.json").write_text( - json.dumps({"apps": [{"name": "hermes-analyzer"}]}), + json.dumps( + { + "apps": [ + { + "name": "hermes-analyzer", + "appId": "cli_aa8c4fc918b85cce", + } + ] + } + ), encoding="utf-8", ) environment = { @@ -1025,11 +1913,157 @@ def test_product_production_preflight_rejects_acceptance_and_missing_owner( console_module._validate_product_production_runtime(alert, environment) environment["GYXX_NOTIFICATION_RECIPIENT_OPEN_ID"] = "configured-owner" + environment.pop("GYXX_HERMES_API_KEY") + console_module._validate_product_production_runtime(alert, environment) + environment["GYXX_WORKFLOW_ACCEPTANCE"] = "1" with pytest.raises(ConsoleRequestError, match="验收隔离"): console_module._validate_product_production_runtime(alert, environment) +def test_disabled_product_alert_preflight_requires_only_postgres_output_sink( + console_settings: Settings, +) -> None: + catalog = WorkflowCatalog.load(console_settings.project_root / "config") + alert = next( + item + for item in catalog.workflows + if item.workflow_id == "product.alert.daily" + ) + service = WorkflowConsoleService( + console_settings, + launcher=FakeConsoleLauncher(), + ) + routing, revision = service.notifications.snapshot() + routing["routes"]["product.alert.daily"] = { + "enabled": False, + "app_profile": "hermes-analyzer", + "person_ids": [], + } + service.notifications.update(routing, expected_revision=revision) + + console_module._validate_product_production_runtime( + alert, + { + "GYXX_POSTGRES_DSN": "postgresql://runtime:secret@database.example/business", + "GYXX_PROJECT_ROOT": str(console_settings.project_root), + "GYXX_DATA_ROOT": str(console_settings.data_root), + }, + ) + + +def test_product_production_preflight_resolves_local_hermes_profile( + tmp_path: Path, +) -> None: + catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") + style_analysis = next( + item + for item in catalog.workflows + if item.workflow_id == "product.style_analysis.interval" + ) + config_dir = tmp_path / ".lark-cli" + config_dir.mkdir() + (config_dir / "config.json").write_text( + json.dumps( + { + "apps": [ + { + "name": "hermes-analyzer", + "appId": "cli_aa8c4fc918b85cce", + } + ] + } + ), + encoding="utf-8", + ) + hermes_home = tmp_path / ".hermes" + profile_dir = hermes_home / "profiles" / "data-analyzer" + profile_dir.mkdir(parents=True) + (profile_dir / ".env").write_text( + "API_SERVER_KEY=test-profile-key\n", + encoding="utf-8", + ) + environment = { + "PG_HOST": "database.example", + "PG_PORT": "5432", + "PG_DB": "business", + "PG_USER": "runtime", + "PG_PASSWORD": "placeholder", + "HERMES_HOME": str(hermes_home), + "LARKSUITE_CLI_CONFIG_DIR": str(config_dir), + } + + console_module._validate_product_production_runtime( + style_analysis, + environment, + ) + resolved = console_module._console_runtime_environment(environment) + assert resolved["GYXX_HERMES_API_KEY"] == "test-profile-key" + assert resolved["HERMES_API_KEY"] == "test-profile-key" + + +@pytest.mark.parametrize( + "workflow_id", + ("product.video_upload", "product.jd_video_upload"), +) +def test_video_production_preflight_uses_direct_model_without_hermes( + monkeypatch: pytest.MonkeyPatch, + workflow_id: str, +) -> None: + catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") + entry = next(item for item in catalog.workflows if item.workflow_id == workflow_id) + environment = { + "PG_HOST": "database.example", + "PG_PORT": "5432", + "PG_DB": "business", + "PG_USER": "runtime", + "PG_PASSWORD": "placeholder", + "STYLE_ANALYSIS_LLM_BASE_URL": "https://api.minimaxi.com/v1", + "STYLE_ANALYSIS_LLM_API_KEY": "test-only-secret", + "STYLE_ANALYSIS_LLM_MODEL": "MiniMax-M3", + "GYXX_HERMES_API_KEY": "legacy-secret", + "HERMES_API_KEY": "legacy-secret", + } + + def forbidden_hermes_resolver(*_args: object, **_kwargs: object) -> str: + pytest.fail("video console execution must not resolve Hermes credentials") + + monkeypatch.setattr( + console_module, + "resolve_hermes_profile_api_key", + forbidden_hermes_resolver, + ) + + console_module._validate_product_production_runtime(entry, environment) + + resolved = console_module._console_runtime_environment( + environment, + workflow_id=workflow_id, + ) + assert resolved["STYLE_ANALYSIS_LLM_BASE_URL"] == "https://api.minimaxi.com/v1" + assert "GYXX_HERMES_API_KEY" not in resolved + assert "HERMES_API_KEY" not in resolved + + +def test_video_production_preflight_rejects_missing_direct_model_config() -> None: + catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") + entry = next( + item + for item in catalog.workflows + if item.workflow_id == "product.video_upload" + ) + environment = { + "PG_HOST": "database.example", + "PG_PORT": "5432", + "PG_DB": "business", + "PG_USER": "runtime", + "PG_PASSWORD": "placeholder", + } + + with pytest.raises(ConsoleRequestError, match="直连大模型配置无效"): + console_module._validate_product_production_runtime(entry, environment) + + class StubProcess: def __init__(self, pid: int) -> None: self.pid = pid @@ -1098,6 +2132,16 @@ def test_subprocess_launcher_uses_fixed_argv_environment_and_unique_operations( data_root=tmp_path / "data", ) python = tmp_path / "runtime" / "python.exe" + hermes_home = tmp_path / ".hermes" + profile_dir = hermes_home / "profiles" / "data-analyzer" + profile_dir.mkdir(parents=True) + (profile_dir / ".env").write_text( + "API_SERVER_KEY=test-launcher-key\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("GYXX_HERMES_API_KEY", raising=False) + monkeypatch.delenv("HERMES_API_KEY", raising=False) calls: list[tuple[list[str], dict[str, Any], StubProcess]] = [] def fake_popen(argv: list[str], **kwargs: Any) -> StubProcess: @@ -1120,8 +2164,26 @@ def test_subprocess_launcher_uses_fixed_argv_environment_and_unique_operations( execute=False, shadow=False, ) + third = launcher.launch( + "product.video_upload", + date(2026, 8, 11), + execute=True, + shadow=False, + video_upload_store="luggage", + tmall_topic_keyword="秋上新", + force_refresh=True, + ) + fourth = launcher.launch( + "product.tmall_baibu_apply", + date(2026, 8, 27), + execute=False, + shadow=False, + tmall_baibu_import_mode="without_hyperlinks", + ) assert first["operation_id"] != second["operation_id"] + assert third["store"] == "luggage" + assert fourth["tmall_baibu_import_mode"] == "without_hyperlinks" assert re.fullmatch(r"op-[0-9a-f]{16}", str(first["operation_id"])) assert calls[0][0] == [ str(python.resolve()), @@ -1143,16 +2205,37 @@ def test_subprocess_launcher_uses_fixed_argv_environment_and_unique_operations( "--date", "2026-07-31", ] - for _argv, kwargs, _process in calls: + assert calls[2][0] == [ + str(python.resolve()), + "-m", + "gyxx_flow", + "run", + "product.video_upload", + "--date", + "2026-08-11", + "--execute", + ] + assert calls[2][1]["env"]["GYXX_VIDEO_UPLOAD_STORE"] == "luggage" + assert calls[2][1]["env"]["GYXX_TMALL_TOPIC_KEYWORD"] == "秋上新" + assert calls[2][1]["env"]["GYXX_FORCE_REFRESH"] == "true" + assert calls[3][0][-2:] == ["--tmall-baibu-import-mode", "without_hyperlinks"] + for argv, kwargs, _process in calls: assert kwargs["cwd"] == settings.project_root assert kwargs["shell"] is False assert kwargs["stdin"] is subprocess.DEVNULL assert kwargs["stderr"] is subprocess.STDOUT assert kwargs["env"]["GYXX_PROJECT_ROOT"] == str(settings.project_root) assert kwargs["env"]["GYXX_DATA_ROOT"] == str(settings.data_root) + if "product.video_upload" in argv: + assert "GYXX_HERMES_API_KEY" not in kwargs["env"] + assert "HERMES_API_KEY" not in kwargs["env"] + else: + assert kwargs["env"]["GYXX_HERMES_API_KEY"] == "test-launcher-key" + assert kwargs["env"]["HERMES_API_KEY"] == "test-launcher-key" assert str(kwargs["stdout"].name).endswith(".log") assert str(first["operation_id"]) in str(calls[0][1]["stdout"].name) assert str(second["operation_id"]) in str(calls[1][1]["stdout"].name) + assert str(third["operation_id"]) in str(calls[2][1]["stdout"].name) for _argv, _kwargs, process in calls: process.return_code = 0 @@ -1561,6 +2644,28 @@ def test_http_home_assets_and_overview_have_security_headers( assert 'role="alertdialog"' in page assert 'id="drawer-rename-button"' in page assert 'id="drawer-name-form"' in page + assert 'id="schedule-distribution"' in page + assert 'id="schedule-day-filter"' in page + assert 'id="service-pill"' in page + assert 'id="scheduler-start-button"' in page + assert "启动调度器" in page + assert 'id="run-store-field"' in page + assert 'id="run-tmall-baibu-field"' in page + assert 'id="run-tmall-baibu-mode"' in page + assert '' in page + assert '' in page + assert '' in page + assert 'id="run-tmall-topic-field"' in page + assert 'id="run-tmall-topic-keyword"' in page + assert "存在未上传附件时才登录该店铺账号" in page + assert 'data-schedule-day="Monday"' in page + assert "工作流时间分布" in page + assert 'id="hidden-workflows-toggle"' in page + assert 'id="drawer-force-run-button"' in page assert "执行流程" in page for path, content_type in ( @@ -1582,6 +2687,9 @@ def test_http_home_assets_and_overview_have_security_headers( assert "结果去向" in decoded_asset assert "renderStepBusinessFlow" in decoded_asset assert "persistDrawerDisplayName" in decoded_asset + assert "HIDDEN_WORKFLOWS_STORAGE_KEY" in decoded_asset + assert "setWorkflowHidden" in decoded_asset + assert 'data-action="${hidden ? "unhide" : "hide"}"' in decoded_asset assert 'method: "PUT"' in decoded_asset assert "display-name" in decoded_asset assert "跳过原因" in decoded_asset @@ -1601,16 +2709,47 @@ def test_http_home_assets_and_overview_have_security_headers( assert "多个时间用逗号分隔" in decoded_asset assert 'times.length === 1 ? times[0] : times' in decoded_asset assert "replaceHtmlIfChanged" in decoded_asset + assert 'workflow.id === "product.erp_all_shop_daily"' in decoded_asset + assert '"补采日期"' in decoded_asset + assert '"确认并补采该日"' in decoded_asset + assert "默认选择昨天" in decoded_asset assert "HISTORY_CACHE_TTL_MS" in decoded_asset assert 'id="topology-refreshed-at"' in decoded_asset assert 'data-workflow-id="${esc(workflow.id)}"' in decoded_asset assert 'openDrawer(workflow.id, "steps")' in decoded_asset assert "state.data?.scheduler?.service_running" in decoded_asset + assert '"/api/scheduler/start"' in decoded_asset + assert "function startScheduler()" in decoded_asset + assert "schedulerStartTimer" in decoded_asset assert "节点错误详情" in decoded_asset assert "naturalMonthRange" in decoded_asset assert "聚水潭查询范围" in decoded_asset assert 'periodInput.type = monthMode ? "month" : "date"' in decoded_asset - assert 'const businessDate = monthMode ? `${selectedPeriod}-01`' in decoded_asset + assert 'const businessDate = repeatableTmallBaibu ? todayForTimezone(0) : monthMode ? `${selectedPeriod}-01` : selectedPeriod;' in decoded_asset + assert 'workflow.id === "product.video_upload"' in decoded_asset + assert 'workflow?.id === "content.notes_master.daily"' in decoded_asset + assert 'return isNotesMasterWorkflow(workflow) ? "手动同步" : "立即运行"' in decoded_asset + assert "function runWorkflowNow(workflowId, { forceRefresh = false } = {})" in decoded_asset + assert "force_refresh: forceRefresh" in decoded_asset + assert "tmall_baibu_import_mode" in decoded_asset + assert "syncTmallBaibuImportField" in decoded_asset + assert 'data-action="force-run"' in decoded_asset + assert 'openRun(id, { forceRefresh: true })' in decoded_asset + assert "当天已处理(幂等跳过)" in decoded_asset + assert 'openRun(workflowId, { forceRefresh })' in decoded_asset + assert 'byId("run-force").checked' in decoded_asset + assert 'byId("run-force-row").hidden = !execute' in decoded_asset + assert 'confirmed: execute && byId("run-confirmed").checked' in decoded_asset + assert 'shadow: byId("run-shadow").checked' in decoded_asset + assert "state.launchingWorkflows.add(workflow.id)" in decoded_asset + assert 'data-running="${running ? "true" : "false"}"' in decoded_asset + assert "有工作流正在运行" in decoded_asset + assert '"确认并正式同步"' in decoded_asset + assert '"已提交笔记清单同步"' in decoded_asset + assert "...(store ? { store } : {})" in decoded_asset + assert "tmall_topic_keyword" in decoded_asset + assert "syncTmallTopicField" in decoded_asset + assert "光影行星鑫华达专卖店:龙虾仔" in decoded_asset assert "renderCardLiveState" in decoded_asset assert 'data-live-progress="${completed}/${steps.length}"' in decoded_asset assert 'data-current-step="${esc(currentNames.join("、"))}"' in decoded_asset @@ -1622,9 +2761,22 @@ def test_http_home_assets_and_overview_have_security_headers( assert 'aria-haspopup="listbox"' in decoded_asset assert 'role="option"' in decoded_asset assert "closeOpenCustomSelect" in decoded_asset + assert "function renderScheduleDistribution()" in decoded_asset + assert "scheduleCadenceLabel" in decoded_asset + assert "runningScheduleTime" in decoded_asset + assert "scheduleRuntimeMeta" in decoded_asset + assert "centerSelectedSchedulePoint" in decoded_asset + assert "positionScheduleChartPoints" in decoded_asset + assert 'data-schedule-point="${esc(node.time)}"' in decoded_asset + assert 'data-running="${node.running ? "true" : "false"}"' in decoded_asset + assert 'data-action="schedule"' in decoded_asset + assert "scheduleMatchesDay" in decoded_asset + assert "个启动项" in decoded_asset assert "initCustomSelects" in decoded_asset assert 'data-action="stop"' in decoded_asset assert 'method: "DELETE"' in decoded_asset + assert "scheduled-run" in decoded_asset + assert 'request.source === "scheduler"' in decoded_asset assert "body: {}" in decoded_asset assert "正在停止…" in decoded_asset if path.endswith("app.css"): @@ -1643,6 +2795,21 @@ def test_http_home_assets_and_overview_have_security_headers( assert ".topology-node__business-flow" in decoded_asset assert ".business-flow__destination" in decoded_asset assert ".drawer-name-editor" in decoded_asset + assert ".hidden-workflows-toggle" in decoded_asset + assert ".card-action--visibility" in decoded_asset + assert ".schedule-distribution" in decoded_asset + assert ".schedule-chart__line" in decoded_asset + assert ".schedule-chart__point" in decoded_asset + assert ".schedule-node-detail" in decoded_asset + assert ".schedule-node-workflow" in decoded_asset + assert ".scheduler-control" in decoded_asset + assert ".scheduler-start-button" in decoded_asset + assert "background: linear-gradient(180deg, #fbfcff, #f5f8ff)" in decoded_asset + assert "@keyframes schedule-running-pulse" in decoded_asset + assert "@keyframes schedule-running-sweep" in decoded_asset + assert '.module-nav__item[data-running="true"]' in decoded_asset + assert "@keyframes module-nav-running-breathe" in decoded_asset + assert "@keyframes module-nav-running-sweep" in decoded_asset status, api_headers, body = _http_request(server, "GET", "/api/overview") assert status == HTTPStatus.OK @@ -1657,6 +2824,39 @@ def test_http_home_assets_and_overview_have_security_headers( } +def test_http_scheduler_start_requires_same_origin_and_returns_service_state( + monkeypatch: pytest.MonkeyPatch, + console_settings: Settings, +) -> None: + result = { + "status": "running", + "service_running": True, + "pid": 43210, + } + with _running_server(console_settings, FakeConsoleLauncher()) as server: + monkeypatch.setattr( + server.console_service, + "start_scheduler", + lambda: dict(result), + ) + origin = f"http://127.0.0.1:{server.server_port}" + status, headers, body = _http_request( + server, + "POST", + "/api/scheduler/start", + body=b"{}", + headers={ + "Content-Type": "application/json; charset=utf-8", + "X-GYXX-Console": "1", + "Origin": origin, + }, + ) + + assert status == HTTPStatus.ACCEPTED + _assert_security_headers(headers) + assert json.loads(body) == result + + def test_http_token_protects_api_and_never_relaxes_security_headers( console_settings: Settings, ) -> None: @@ -1913,6 +3113,88 @@ def test_http_updates_and_resets_workflow_display_name( assert json.loads(reset_response)["name"] == "营销日报生成与发送" +def test_http_notification_config_requires_revision_and_supports_multi_recipient( + console_settings: Settings, +) -> None: + launcher = FakeConsoleLauncher() + with _running_server(console_settings, launcher) as server: + origin = f"http://127.0.0.1:{server.server_port}" + status, response_headers, body = _http_request( + server, + "GET", + "/api/notifications", + ) + assert status == HTTPStatus.OK + _assert_security_headers(response_headers) + config = json.loads(body) + assert len(config["people"]) == 16 + assert { + route["workflow_id"] for route in config["routes"] + } == set(console_module.NOTIFICATION_CAPABILITIES) + + for route in config["routes"]: + if route["workflow_id"] == "content.marketing_report.daily": + route.update( + configured=True, + enabled=True, + person_ids=["he_yingwei", "li_jingxian"], + ) + request = { + "app_profile": config["app_profile"], + "people": config["people"], + "routes": [ + { + "workflow_id": route["workflow_id"], + "configured": route["configured"], + "enabled": route["enabled"], + "person_ids": route["person_ids"], + } + for route in config["routes"] + ], + } + encoded = json.dumps(request, ensure_ascii=False).encode("utf-8") + headers = { + "Content-Type": "application/json", + "X-GYXX-Console": "1", + "Origin": origin, + } + + status, _response_headers, _body = _http_request( + server, + "PUT", + "/api/notifications", + body=encoded, + headers=headers, + ) + assert status == HTTPStatus.PRECONDITION_REQUIRED + + status, response_headers, updated_body = _http_request( + server, + "PUT", + "/api/notifications", + body=encoded, + headers={**headers, "If-Match": config["revision"]}, + ) + assert status == HTTPStatus.OK + _assert_security_headers(response_headers) + updated = json.loads(updated_body) + daily = next( + route + for route in updated["routes"] + if route["workflow_id"] == "content.marketing_report.daily" + ) + assert daily["person_ids"] == ["he_yingwei", "li_jingxian"] + + status, _response_headers, _body = _http_request( + server, + "PUT", + "/api/notifications", + body=encoded, + headers={**headers, "If-Match": config["revision"]}, + ) + assert status == HTTPStatus.CONFLICT + + def test_non_loopback_binding_requires_a_long_token( console_settings: Settings, ) -> None: diff --git a/tests/test_content_marketing_module.py b/tests/test_content_marketing_module.py index 677fc61..8609bf8 100644 --- a/tests/test_content_marketing_module.py +++ b/tests/test_content_marketing_module.py @@ -5,6 +5,7 @@ from pathlib import Path from gyxx_flow.adapters.native import DeferredModuleCommandStep from gyxx_flow.catalog import WorkflowCatalog from gyxx_flow.modules.content_marketing import ( + CONTENT_COMMENT_TIMEOUT_SECONDS, CONTENT_RESOURCE, CONTENT_SCHEDULED_WORKFLOW_IDS, CONTENT_TIMEOUT_SECONDS, @@ -22,7 +23,7 @@ def test_content_module_registers_only_scheduled_entries() -> None: definitions = module.workflow_definitions() assert tuple(item.workflow_id for item in definitions) == CONTENT_WORKFLOW_IDS - assert len(CONTENT_SCHEDULED_WORKFLOW_IDS) == 8 + assert len(CONTENT_SCHEDULED_WORKFLOW_IDS) == 9 def test_content_workflows_use_project_owned_module_commands() -> None: @@ -34,12 +35,42 @@ def test_content_workflows_use_project_owned_module_commands() -> None: assert definition.steps for step in definition.steps: assert isinstance(step.action, DeferredModuleCommandStep) - assert step.timeout_seconds == CONTENT_TIMEOUT_SECONDS == 4 * 60 * 60 + expected_timeout = ( + CONTENT_COMMENT_TIMEOUT_SECONDS + if definition.workflow_id == "content.comments.weekly" + else CONTENT_TIMEOUT_SECONDS + ) + assert step.timeout_seconds == expected_timeout + assert CONTENT_TIMEOUT_SECONDS == 4 * 60 * 60 + assert CONTENT_COMMENT_TIMEOUT_SECONDS == 6 * 60 * 60 assert step.max_attempts == 1 if definition.workflow_id == "content.comments.weekly": assert step.resources == (f"{CONTENT_RESOURCE}:{step.step_id}",) + elif definition.workflow_id == "content.metrics.daily": + assert step.resources == ( + f"{CONTENT_RESOURCE}:content.metrics.daily:{step.step_id}", + ) else: assert step.resources == (CONTENT_RESOURCE,) == ( "module:content_marketing", ) assert step.production_sink is True + + +def test_daily_content_steps_have_independent_resources() -> None: + module = ContentMarketingModule.from_catalog( + WorkflowCatalog.load(PROJECT_ROOT / "config") + ) + daily = next( + item for item in module.workflow_definitions() + if item.workflow_id == "content.metrics.daily" + ) + + assert {step.step_id for step in daily.steps} == { + "collect_collaborators", + "refresh_self_mapping", + "collect_self_bilibili", + "collect_self_douyin", + "sync", + } + assert len({step.resources for step in daily.steps}) == len(daily.steps) diff --git a/tests/test_content_source_snapshot.py b/tests/test_content_source_snapshot.py index 7d20d76..e00cdee 100644 --- a/tests/test_content_source_snapshot.py +++ b/tests/test_content_source_snapshot.py @@ -71,7 +71,7 @@ def test_content_source_snapshot_manifest_is_complete_and_well_formed() -> None: assert manifest["module"] == "content_marketing" files = manifest["files"] assert isinstance(files, list) - assert len(files) == 65 + assert len(files) == 66 sources = [entry["source_relative_path"] for entry in files] targets = [entry["target_relative_path"] for entry in files] @@ -116,6 +116,26 @@ def test_content_source_snapshot_targets_exist_and_match_manifest_hashes() -> No assert _sha256(target) == entry["target_sha256"] +def test_content_mapping_snapshot_includes_latest_source_styles() -> None: + config_root = MODULE_ROOT / "data" / "config" + collaborator = json.loads( + (config_root / "款式_多维表格_对照.json").read_text(encoding="utf-8") + ) + self_operated = json.loads( + (config_root / "款式_多维表格_对照_自营.json").read_text(encoding="utf-8") + ) + + collaborator_by_name = {item["name"]: item for item in collaborator["tables"]} + self_by_name = {item["name"]: item for item in self_operated["tables"]} + assert collaborator_by_name["极星2"]["field_map"]["note_title"] == { + "field_id": "fldF40Tdh4", + "field_name": "发布笔记标题", + } + assert collaborator_by_name["极星托特"]["table_id"] == "tbl6L5fNUNsDGGcN" + assert self_by_name["凌云3"]["table_id"] == "tblMCT9eDe9QGTP2" + assert self_by_name["极星双肩2"]["table_id"] == "tblzVYspLYZHrKMD" + + def test_content_source_snapshot_exclusions_cover_runtime_and_sensitive_state() -> None: excluded = {entry["pattern"] for entry in _manifest()["intentionally_excluded"]} diff --git a/tests/test_daily_summary.py b/tests/test_daily_summary.py new file mode 100644 index 0000000..07df873 --- /dev/null +++ b/tests/test_daily_summary.py @@ -0,0 +1,476 @@ +from __future__ import annotations + +import http.client +import json +import threading +from datetime import datetime, timezone +from pathlib import Path +from zoneinfo import ZoneInfo + +from gyxx_flow.catalog import ScheduleEntry +from gyxx_flow.console import create_console_server +from gyxx_flow.core.config import Settings +from gyxx_flow.core.context import RunContext +from gyxx_flow.core.layout import DataLayout +from gyxx_flow.core.records import RunJournal +from gyxx_flow.daily_summary import DailySummaryBuilder, _schedule_slots +from gyxx_flow.ops import RunIndex + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +class FakeAnalyzer: + def __init__(self) -> None: + self.calls: list[dict[str, object]] = [] + + @property + def identity(self) -> str: + return "fake:v1" + + def analyze(self, payload: dict[str, object]) -> dict[str, object]: + self.calls.append(payload) + workflows = [] + for item in payload["workflows"]: + workflow_id = item["workflow_id"] + status = item["status"] + if workflow_id == "content.success.daily": + execution_result = "当天的数据采集已顺利完成,预定内容已经产出。" + anomalies = [] + repair_actions = [] + elif workflow_id == "content.missed.daily": + execution_result = "计划执行时间已过,但当天没有发现任务启动记录。" + anomalies = ["常驻调度服务没有按计划启动这个工作流,日报内容因此没有生成。"] + repair_actions = ["检查常驻调度服务和计划设置,恢复后按原业务日期补跑。"] + else: + execution_result = "当天的工作流执行情况已经完成检查。" + anomalies = ( + [] + if status in {"normal", "recovered"} + else ["该工作流没有按预期完成,相关业务结果可能缺失。"] + ) + repair_actions = ( + [] + if status in {"normal", "recovered"} + else ["检查运行环境和计划设置,排除原因后按原业务日期重新执行。"] + ) + workflows.append( + { + "workflow_id": workflow_id, + "execution_result": execution_result, + "anomalies": anomalies, + "repair_actions": repair_actions, + } + ) + return { + "overview": "模型已逐个检查运行证据。", + "workflows": workflows, + } + + +def _write_catalog(project_root: Path) -> None: + config = project_root / "config" + config.mkdir(parents=True) + workflows = { + "schema_version": 3, + "workflows": [ + { + "id": workflow_id, + "module": "content_marketing", + "trigger": "scheduled", + "execution": {"entry": "daily.py"}, + "provenance": { + "source_project": "content", + "task_name": name, + }, + } + for workflow_id, name in ( + ("content.success.daily", "成功日报"), + ("content.missed.daily", "漏跑日报"), + ) + ], + } + schedules = { + "schema_version": 1, + "timezone": "Asia/Shanghai", + "schedules": [ + { + "workflow_id": workflow_id, + "kind": "daily", + "at": at, + "enabled": True, + } + for workflow_id, at in ( + ("content.success.daily", "09:00"), + ("content.missed.daily", "10:00"), + ) + ], + } + (config / "workflows.json").write_text( + json.dumps(workflows, ensure_ascii=False), + encoding="utf-8", + ) + (config / "schedules.json").write_text( + json.dumps(schedules, ensure_ascii=False), + encoding="utf-8", + ) + + +def _write_multi_slot_catalog(project_root: Path) -> None: + config = project_root / "config" + config.mkdir(parents=True) + workflows = { + "schema_version": 3, + "workflows": [ + { + "id": "content.success.daily", + "module": "content_marketing", + "trigger": "scheduled", + "execution": {"entry": "daily.py"}, + "provenance": { + "source_project": "content", + "task_name": "成功日报", + }, + } + ], + } + schedules = { + "schema_version": 1, + "timezone": "Asia/Shanghai", + "schedules": [ + { + "workflow_id": "content.success.daily", + "kind": "daily", + "at": ["08:00", "16:00", "22:00"], + "enabled": True, + } + ], + } + (config / "workflows.json").write_text( + json.dumps(workflows, ensure_ascii=False), + encoding="utf-8", + ) + (config / "schedules.json").write_text( + json.dumps(schedules, ensure_ascii=False), + encoding="utf-8", + ) + + +def _successful_run(data_root: Path) -> None: + context = RunContext.create( + "content.success.daily", + "2026-08-12", + now=datetime(2026, 8, 13, 1, 0, tzinfo=timezone.utc), + random_suffix="abc123", + ) + journal = RunJournal.create(DataLayout(data_root), context, mode="execute") + journal.start_step("collect.attempt-1", attempt=1) + journal.finish_step("collect.attempt-1", status="success", exit_code=0) + journal.finalize("success") + RunIndex(data_root).index_journal(journal) + + +def test_daily_summary_keeps_status_deterministic_and_analyzes_each_workflow( + tmp_path: Path, +) -> None: + project_root = tmp_path / "project" + data_root = tmp_path / "data" + _write_catalog(project_root) + _successful_run(data_root) + log_root = data_root / "logs" / "scheduler" + log_root.mkdir(parents=True) + (log_root / "content.success.daily-20260813T090000+0800.log").write_text( + "completed token=top-secret-value", + encoding="utf-8", + ) + analyzer = FakeAnalyzer() + builder = DailySummaryBuilder( + project_root=project_root, + data_root=data_root, + analyzer=analyzer, + workflow_name=lambda workflow_id: { + "content.success.daily": "成功日报", + "content.missed.daily": "漏跑日报", + }[workflow_id], + ) + + report = builder.build(datetime(2026, 8, 13).date()) + + by_id = {item["workflow_id"]: item for item in report["workflows"]} + assert report["overview"] == "模型已逐个检查运行证据。" + assert report["summary"] == { + "total": 2, + "normal": 1, + "recovered": 0, + "abnormal": 0, + "running": 0, + "missed": 1, + } + assert by_id["content.success.daily"]["status"] == "normal" + assert by_id["content.success.daily"]["execution_result"] == ( + "当天的数据采集已顺利完成,预定内容已经产出。" + ) + assert by_id["content.missed.daily"]["status"] == "missed" + assert by_id["content.missed.daily"]["repair_actions"] == [ + "检查常驻调度服务和计划设置,恢复后按原业务日期补跑。" + ] + assert "top-secret-value" not in json.dumps(analyzer.calls, ensure_ascii=False) + assert "[REDACTED]" in json.dumps(analyzer.calls, ensure_ascii=False) + serialized_report = json.dumps(report, ensure_ascii=False) + assert "content.success.daily-20260813T090000+0800.log" not in serialized_report + assert "completed token" not in serialized_report + assert "[REDACTED]" not in serialized_report + + cached = builder.build(datetime(2026, 8, 13).date()) + assert cached == report + assert len(analyzer.calls) == 1 + + +def test_daily_summary_detects_failed_run_and_returns_rule_based_repair( + tmp_path: Path, +) -> None: + project_root = tmp_path / "project" + data_root = tmp_path / "data" + _write_catalog(project_root) + context = RunContext.create( + "content.success.daily", + "2026-08-12", + now=datetime(2026, 8, 13, 1, 0, tzinfo=timezone.utc), + random_suffix="def456", + ) + journal = RunJournal.create(DataLayout(data_root), context, mode="execute") + journal.start_step("notify.attempt-1", attempt=1) + journal.finish_step( + "notify.attempt-1", + status="failed", + exit_code=1, + error=( + 'Traceback: File "D:\\secret\\notify.py", line 42; ' + "open_id cross app (99992361) token=do-not-publish" + ), + ) + journal.finalize( + "failed", + error=( + 'Traceback: File "D:\\secret\\notify.py", line 42; ' + "open_id cross app (99992361) token=do-not-publish" + ), + ) + RunIndex(data_root).index_journal(journal) + + class UnavailableAnalyzer(FakeAnalyzer): + @property + def identity(self) -> str: + return "unavailable:v1" + + def analyze(self, payload: dict[str, object]) -> dict[str, object]: + from gyxx_flow.daily_summary import DailySummaryAnalysisError + + raise DailySummaryAnalysisError("Hermes 未启动") + + report = DailySummaryBuilder( + project_root=project_root, + data_root=data_root, + analyzer=UnavailableAnalyzer(), + ).build(datetime(2026, 8, 13).date()) + + by_id = {item["workflow_id"]: item for item in report["workflows"]} + failed = by_id["content.success.daily"] + assert report["analysis"] == { + "status": "unavailable", + "source": "rules", + "message": "大模型分析服务暂时不可用,当前展示系统生成的通俗基础说明。", + } + assert failed["status"] == "abnormal" + assert "身份绑定不一致" in failed["anomalies"][0] + assert "重新绑定" in failed["repair_actions"][0] + serialized = json.dumps(report, ensure_ascii=False) + assert "Traceback" not in serialized + assert "notify.py" not in serialized + assert "open_id cross app" not in serialized + assert "99992361" not in serialized + assert "do-not-publish" not in serialized + assert all("error" not in run and "steps" not in run for run in failed["runs"]) + assert "log_evidence" not in failed + + +def test_daily_summary_rejects_model_output_that_copies_raw_diagnostics( + tmp_path: Path, +) -> None: + project_root = tmp_path / "project" + data_root = tmp_path / "data" + _write_catalog(project_root) + _successful_run(data_root) + + class EchoingAnalyzer(FakeAnalyzer): + @property + def identity(self) -> str: + return "echoing:v1" + + def analyze(self, payload: dict[str, object]) -> dict[str, object]: + result = super().analyze(payload) + result["workflows"][0]["execution_result"] = ( + 'Traceback: File "D:\\internal\\worker.py", line 17' + ) + return result + + report = DailySummaryBuilder( + project_root=project_root, + data_root=data_root, + analyzer=EchoingAnalyzer(), + ).build(datetime(2026, 8, 13).date()) + + serialized = json.dumps(report, ensure_ascii=False) + assert report["analysis"]["status"] == "unavailable" + assert "Traceback" not in serialized + assert "worker.py" not in serialized + assert "正式执行 1 次" in serialized + + +def test_daily_summary_normalizes_relative_dates_in_model_output(tmp_path: Path) -> None: + project_root = tmp_path / "project" + data_root = tmp_path / "data" + _write_catalog(project_root) + _successful_run(data_root) + + class RelativeDateAnalyzer(FakeAnalyzer): + def analyze(self, payload: dict[str, object]) -> dict[str, object]: + result = super().analyze(payload) + result["overview"] = "今日工作流已完成检查。" + result["workflows"][0]["execution_result"] = "今天的数据采集已完成。" + return result + + report = DailySummaryBuilder( + project_root=project_root, + data_root=data_root, + analyzer=RelativeDateAnalyzer(), + ).build(datetime(2026, 8, 13).date()) + + serialized = json.dumps(report, ensure_ascii=False) + assert "今日" not in serialized + assert "今天" not in serialized + assert "当天工作流已完成检查" in serialized + + +def test_daily_summary_http_get_and_forced_refresh(tmp_path: Path) -> None: + analyzer = FakeAnalyzer() + settings = Settings(project_root=PROJECT_ROOT, data_root=tmp_path / "data") + server = create_console_server( + settings, + host="127.0.0.1", + port=0, + daily_summary_analyzer=analyzer, + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + connection = http.client.HTTPConnection( + "127.0.0.1", + server.server_port, + timeout=10, + ) + connection.request("GET", "/api/daily-summary?date=2026-08-13") + response = connection.getresponse() + payload = json.loads(response.read().decode("utf-8")) + assert response.status == 200 + assert payload["report_date"] == "2026-08-13" + assert payload["schema_version"] == 2 + assert payload["workflows"] + assert all( + {"workflow_id", "status", "execution_result", "repair_actions"} + <= set(item) + for item in payload["workflows"] + ) + + body = json.dumps({"date": "2026-08-13"}).encode("utf-8") + connection.request( + "POST", + "/api/daily-summary/refresh", + body=body, + headers={ + "Content-Type": "application/json", + "Content-Length": str(len(body)), + "X-GYXX-Console": "1", + "Origin": f"http://127.0.0.1:{server.server_port}", + }, + ) + refreshed = connection.getresponse() + refreshed.read() + assert refreshed.status == 200 + assert len(analyzer.calls) == 2 + connection.close() + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def test_schedule_slots_filters_future_times_when_now_is_given() -> None: + schedule = ScheduleEntry( + workflow_id="content.success.daily", + kind="daily", + at="08:00", + at_times=("08:00", "16:00", "22:00"), + ) + timezone_info = ZoneInfo("Asia/Shanghai") + target = datetime(2026, 8, 18).date() + + all_slots = _schedule_slots(schedule, target, timezone_info) + assert [slot.strftime("%H:%M") for slot in all_slots] == [ + "08:00", + "16:00", + "22:00", + ] + + morning = _schedule_slots( + schedule, + target, + timezone_info, + now=datetime(2026, 8, 18, 9, 55, tzinfo=timezone_info), + ) + assert [slot.strftime("%H:%M") for slot in morning] == ["08:00"] + + +def test_daily_summary_does_not_flag_future_multi_slots_as_missing( + tmp_path: Path, +) -> None: + project_root = tmp_path / "project" + data_root = tmp_path / "data" + _write_multi_slot_catalog(project_root) + + context = RunContext.create( + "content.success.daily", + "2026-08-18", + now=datetime(2026, 8, 18, 0, 1, tzinfo=timezone.utc), + random_suffix="abc123", + ) + journal = RunJournal.create(DataLayout(data_root), context, mode="execute") + journal.start_step("collect.attempt-1", attempt=1) + journal.finish_step("collect.attempt-1", status="success", exit_code=0) + journal.finalize("success") + RunIndex(data_root).index_journal(journal) + + log_root = data_root / "logs" / "scheduler" + log_root.mkdir(parents=True) + (log_root / "content.success.daily-20260818T080000+0800.log").write_text( + "completed", + encoding="utf-8", + ) + + analyzer = FakeAnalyzer() + builder = DailySummaryBuilder( + project_root=project_root, + data_root=data_root, + analyzer=analyzer, + workflow_name=lambda workflow_id: "成功日报", + ) + report = builder.build( + datetime(2026, 8, 18).date(), + now=datetime(2026, 8, 18, 9, 55, tzinfo=ZoneInfo("Asia/Shanghai")), + ) + + item = report["workflows"][0] + assert item["workflow_id"] == "content.success.daily" + assert item["expected_slots"] == ["2026-08-18T08:00:00+08:00"] + assert item["missed_slots"] == 0 + assert item["status"] == "normal" + assert "没有发现调度日志或运行记录" not in item["execution_result"] diff --git a/tests/test_effect_ledger.py b/tests/test_effect_ledger.py index 216e854..72f6545 100644 --- a/tests/test_effect_ledger.py +++ b/tests/test_effect_ledger.py @@ -40,6 +40,11 @@ def test_effect_ledger_blocks_applied_and_ambiguous_replays(tmp_path: Path) -> N run_id="run-003", ) ledger.mark_ambiguous(ambiguous, reason="nonzero-exit") + assert ledger.status( + workflow_id="product.daily", + business_date="2026-07-28", + step_id="legacy_execute", + ) == "ambiguous" with pytest.raises(EffectStateAmbiguous): ledger.begin( workflow_id="product.daily", diff --git a/tests/test_engineering_baseline.py b/tests/test_engineering_baseline.py index de722e1..766c200 100644 --- a/tests/test_engineering_baseline.py +++ b/tests/test_engineering_baseline.py @@ -32,6 +32,8 @@ def test_repository_keeps_the_simplified_public_structure() -> None: "acceptance-report.md", "architecture.md", "deployment.md", + "dynamic-workflow-config.md", + "note-inventory.md", "plan.md", "runbook.md", "workflow-acceptance-test-report.md", diff --git a/tests/test_erp_runtime_state.py b/tests/test_erp_runtime_state.py new file mode 100644 index 0000000..43a8d09 --- /dev/null +++ b/tests/test_erp_runtime_state.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from pathlib import Path + +from gyxx_flow.adapters.browser import BrowserCookieStore +from gyxx_flow.adapters.erp_state import ( + has_erp_session_cookie, + persist_erp_runtime_state, + restore_erp_runtime_state, +) + + +class _FakeContext: + def __init__(self) -> None: + self._cookies: list[dict[str, object]] = [] + self._storage_state: dict[str, object] = {"cookies": [], "origins": []} + + def set_storage_state(self, state: dict[str, object]) -> None: + self._storage_state = state + + def add_cookies(self, cookies: list[dict[str, object]]) -> None: + self._cookies.extend(cookies) + + def cookies(self) -> list[dict[str, object]]: + return list(self._cookies) + + def storage_state(self) -> dict[str, object]: + return dict(self._storage_state) + + +def _set_state_environment(monkeypatch, root: Path) -> tuple[Path, Path, Path, Path]: + account_cookie = root / "account" / "cookies.json" + account_storage = root / "account" / "storage_state.json" + binding_cookie = root / "binding" / "cookies.json" + binding_storage = root / "binding" / "storage_state.json" + monkeypatch.setenv("GYXX_ACCOUNT_ID", "erp-jushuitan") + monkeypatch.setenv("GYXX_ACCOUNT_COOKIE_FILE", str(account_cookie)) + monkeypatch.setenv("GYXX_ACCOUNT_STORAGE_STATE_FILE", str(account_storage)) + monkeypatch.setenv("GYXX_BROWSER_COOKIE_FILE", str(binding_cookie)) + monkeypatch.setenv("GYXX_BROWSER_STORAGE_STATE_FILE", str(binding_storage)) + return account_cookie, account_storage, binding_cookie, binding_storage + + +def test_account_vault_is_restored_first_and_verified_state_is_persisted( + tmp_path: Path, monkeypatch +) -> None: + account_cookie, account_storage, binding_cookie, binding_storage = ( + _set_state_environment(monkeypatch, tmp_path) + ) + account_state = [ + { + "name": "ASP.NET_SessionId", + "value": "vault-session", + "domain": ".erp321.com", + "path": "/", + } + ] + BrowserCookieStore(account_cookie, account_storage).save_cookies(account_state) + BrowserCookieStore(account_cookie, account_storage).save_storage_state( + {"cookies": account_state, "origins": []} + ) + binding_state = [ + { + "name": "ASP.NET_SessionId", + "value": "old-binding-session", + "domain": ".erp321.com", + "path": "/", + } + ] + BrowserCookieStore(binding_cookie, binding_storage).save_cookies(binding_state) + BrowserCookieStore(binding_cookie, binding_storage).save_storage_state( + {"cookies": binding_state, "origins": []} + ) + + context = _FakeContext() + assert restore_erp_runtime_state(context) == "account vault" + assert context.cookies()[0]["value"] == "vault-session" + + context._cookies = [ + { + "name": "ASP.NET_SessionId", + "value": "fresh-session", + "domain": ".erp321.com", + "path": "/", + } + ] + context._storage_state = {"cookies": context.cookies(), "origins": []} + assert persist_erp_runtime_state(context) == 1 + assert BrowserCookieStore(account_cookie, account_storage).load_cookies()[0][ + "value" + ] == "fresh-session" + assert BrowserCookieStore(binding_cookie, binding_storage).load_cookies()[0][ + "value" + ] == "fresh-session" + + +def test_erp_restore_uses_cookies_without_replaying_indexeddb_origins( + tmp_path: Path, monkeypatch +) -> None: + account_cookie, account_storage, _, _ = _set_state_environment(monkeypatch, tmp_path) + cookies = [ + { + "name": "ASP.NET_SessionId", + "value": "vault-session", + "domain": ".erp321.com", + "path": "/", + } + ] + store = BrowserCookieStore(account_cookie, account_storage) + store.save_cookies(cookies) + store.save_storage_state( + { + "cookies": cookies, + "origins": [ + { + "origin": "https://www.erp321.com", + "localStorage": [{"name": "session", "value": "state"}], + "indexedDB": [{"name": "erp", "version": 1}], + } + ], + } + ) + + context = _FakeContext() + assert restore_erp_runtime_state(context) == "account vault" + assert context._storage_state == {"cookies": cookies} + assert context.cookies()[0]["value"] == "vault-session" + + +def test_erp_session_cookie_detection_accepts_jushuitan_identity_cookie() -> None: + assert has_erp_session_cookie( + [{"name": "u_id", "value": "22469720", "domain": ".erp321.com"}] + ) + assert not has_erp_session_cookie( + [{"name": "u_id", "value": "", "domain": ".erp321.com"}] + ) + + +def test_erp_session_cookie_detection_rejects_expired_identity_cookie() -> None: + assert not has_erp_session_cookie( + [{"name": "u_id", "value": "expired", "expires": 1}] + ) diff --git a/tests/test_feishu_messages.py b/tests/test_feishu_messages.py new file mode 100644 index 0000000..96fa826 --- /dev/null +++ b/tests/test_feishu_messages.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from gyxx_flow.adapters import feishu_messages + + +def _completed(payload: dict, *, returncode: int = 0) -> SimpleNamespace: + return SimpleNamespace( + returncode=returncode, + stdout=json.dumps(payload), + stderr="", + ) + + +def test_bot_text_send_has_one_fixed_profile_identity_and_stable_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + commands: list[list[str]] = [] + monkeypatch.setattr(feishu_messages, "_lark_command", lambda: ["node", "run.js"]) + monkeypatch.setattr( + feishu_messages, + "resolve_notification_recipients", + lambda recipients, *, app_profile: tuple(recipients), + ) + + def fake_run(command: list[str], **_kwargs: object) -> SimpleNamespace: + commands.append(command) + return _completed({"message_id": "om_receipt123", "chat_id": "oc_chat123"}) + + monkeypatch.setattr(feishu_messages.subprocess, "run", fake_run) + + first = feishu_messages.send_lark_bot_message( + user_id="ou_recipient123", + text="告警正文", + idempotency_key="product.alert:2026-08-10", + ) + second = feishu_messages.send_lark_bot_message( + user_id="ou_recipient123", + text="告警正文", + idempotency_key="product.alert:2026-08-10", + ) + + assert first["message_id"] == second["message_id"] == "om_receipt123" + assert len(commands) == 2 + assert commands[0][:4] == ["node", "run.js", "--profile", "hermes-analyzer"] + assert commands[0][commands[0].index("--as") + 1] == "bot" + assert commands[0][commands[0].index("--user-id") + 1] == "ou_recipient123" + assert commands[0][commands[0].index("--text") + 1] == "告警正文" + key_index = commands[0].index("--idempotency-key") + 1 + assert commands[0][key_index] == commands[1][key_index] + assert commands[0][key_index].startswith("gyxx-") + assert len(commands[0][key_index]) == 45 + + +def test_interactive_bot_send_preserves_card_json_and_nested_receipt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + observed: dict[str, object] = {} + monkeypatch.setattr(feishu_messages, "_lark_command", lambda: ["node", "run.js"]) + monkeypatch.setattr( + feishu_messages, + "resolve_notification_recipients", + lambda recipients, *, app_profile: tuple(recipients), + ) + + def fake_run(command: list[str], **kwargs: object) -> SimpleNamespace: + observed["command"] = command + observed["kwargs"] = kwargs + return _completed({"data": {"message_id": "om_nested-123"}}) + + monkeypatch.setattr(feishu_messages.subprocess, "run", fake_run) + card = {"header": {"title": {"tag": "plain_text", "content": "日报"}}} + + result = feishu_messages.send_lark_bot_message( + user_id="ou_recipient123", + content=card, + msg_type="interactive", + ) + + command = observed["command"] + assert isinstance(command, list) + assert json.loads(command[command.index("--content") + 1]) == card + assert command[command.index("--msg-type") + 1] == "interactive" + assert command[command.index("--as") + 1] == "bot" + assert result["message_id"] == "om_nested-123" + + +def test_chat_and_image_messages_use_bot_identity( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + commands: list[list[str]] = [] + image = tmp_path / "qr.png" + image.write_bytes(b"png") + monkeypatch.setattr(feishu_messages, "_lark_command", lambda: ["node", "run.js"]) + + def fake_run(command: list[str], **_kwargs: object) -> SimpleNamespace: + commands.append(command) + return _completed({"message_id": f"om_image{len(commands)}"}) + + monkeypatch.setattr(feishu_messages.subprocess, "run", fake_run) + + feishu_messages.send_lark_bot_message(chat_id="oc_group123", text="群通知") + feishu_messages.send_lark_bot_message(user_id="ou_owner123", image=image) + + assert commands[0][commands[0].index("--chat-id") + 1] == "oc_group123" + assert commands[1][commands[1].index("--image") + 1] == "./qr.png" + assert all(command[command.index("--as") + 1] == "bot" for command in commands) + + +@pytest.mark.parametrize( + ("completed", "message"), + ( + (_completed({}, returncode=1), "exit=1"), + (_completed({"ok": False, "error": "denied"}), "denied"), + (_completed({"id": "chatcmpl-not-a-receipt"}), "no valid message_id"), + ), +) +def test_bot_send_fails_without_a_verified_lark_receipt( + monkeypatch: pytest.MonkeyPatch, + completed: SimpleNamespace, + message: str, +) -> None: + monkeypatch.setattr(feishu_messages, "_lark_command", lambda: ["node", "run.js"]) + monkeypatch.setattr( + feishu_messages, + "resolve_notification_recipients", + lambda recipients, *, app_profile: tuple(recipients), + ) + monkeypatch.setattr(feishu_messages.subprocess, "run", lambda *_args, **_kwargs: completed) + + with pytest.raises(RuntimeError, match=message): + feishu_messages.send_lark_bot_message( + user_id="ou_recipient123", + text="告警正文", + ) + + +def test_sender_accepts_collector_bot_and_rejects_other_profiles( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(feishu_messages, "_lark_command", lambda: ["node", "run.js"]) + monkeypatch.setattr( + feishu_messages.subprocess, + "run", + lambda *_args, **_kwargs: _completed({"message_id": "om_collector123"}), + ) + result = feishu_messages.send_lark_bot_message( + user_id="ou_recipient123", + text="采集心跳", + profile=feishu_messages.COLLECTOR_NOTIFICATION_APP_PROFILE, + ) + assert result["message_id"] == "om_collector123" + + with pytest.raises(ValueError, match="Hermes application profile"): + feishu_messages.send_lark_bot_message( + user_id="ou_recipient123", + text="告警正文", + profile="collector", + ) diff --git a/tests/test_langgraph_workflow_engine.py b/tests/test_langgraph_workflow_engine.py index 433e0e3..d158986 100644 --- a/tests/test_langgraph_workflow_engine.py +++ b/tests/test_langgraph_workflow_engine.py @@ -18,7 +18,7 @@ from gyxx_flow.core.locks import LockManager from gyxx_flow.core.records import RunJournal from gyxx_flow.modules.shop_intelligence import ShopIntelligenceModule from gyxx_flow.ops.effects import EffectLedger -from gyxx_flow.workflow.engine import WorkflowEngine +from gyxx_flow.workflow.engine import WorkflowEngine, WorkflowRunResult from gyxx_flow.workflow.graph import ( GraphStepResult, WorkflowGraphState, @@ -173,6 +173,119 @@ def test_compiler_joins_uneven_dependency_branches_before_running_once() -> None assert publish_inputs == [{"source", "parallel", "derived"}] +def test_linear_platform_chain_imports_without_waiting_for_other_roots( + tmp_path: Path, +) -> None: + jd_started = threading.Event() + douyin_started = threading.Event() + release_jd = threading.Event() + release_douyin = threading.Event() + tmall_import_started = threading.Event() + + class BlockingDownload: + def __init__(self, started: threading.Event, release: threading.Event) -> None: + self.started = started + self.release = release + + def execute( + self, + *, + context: RunContext, + timeout_seconds: float | None, + dry_run: bool, + ) -> StepExecution: + del context, timeout_seconds, dry_run + self.started.set() + if not self.release.wait(timeout=5): + return StepExecution(exit_code=1, error="test release timeout") + return StepExecution(exit_code=0) + + class TmallDownload: + def execute( + self, + *, + context: RunContext, + timeout_seconds: float | None, + dry_run: bool, + ) -> StepExecution: + del context, timeout_seconds, dry_run + return StepExecution(exit_code=0) + + class TmallImport: + def execute( + self, + *, + context: RunContext, + timeout_seconds: float | None, + dry_run: bool, + ) -> StepExecution: + del context, timeout_seconds, dry_run + tmall_import_started.set() + return StepExecution(exit_code=0) + + workflow = WorkflowDefinition( + "product.ecommerce_costs.daily", + ( + StepDefinition("tmall_download", TmallDownload()), + StepDefinition( + "tmall_import", + TmallImport(), + depends_on=("tmall_download",), + ), + StepDefinition( + "jd_download", + BlockingDownload(jd_started, release_jd), + ), + StepDefinition("jd_import", FakeStep(), depends_on=("jd_download",)), + StepDefinition( + "douyin_download", + BlockingDownload(douyin_started, release_douyin), + ), + StepDefinition( + "douyin_import", + FakeStep(), + depends_on=("douyin_download",), + ), + ), + ) + context = RunContext.create( + "product.ecommerce_costs.daily", + "2026-07-27", + now=datetime(2026, 7, 27, 4, 0, tzinfo=timezone.utc), + random_suffix="pipeline1", + ) + journal = RunJournal.create(DataLayout(tmp_path), context) + result_holder: dict[str, WorkflowRunResult] = {} + errors: list[BaseException] = [] + + def execute() -> None: + try: + result_holder["result"] = WorkflowEngine( + LockManager(tmp_path / "locks") + ).execute(workflow, context=context, journal=journal) + except BaseException as exc: # pragma: no cover - test harness guard + errors.append(exc) + + worker = threading.Thread(target=execute) + worker.start() + jd_ready = jd_started.wait(timeout=3) + douyin_ready = douyin_started.wait(timeout=3) + tmall_import_ready = tmall_import_started.wait(timeout=3) + assert not release_jd.is_set() + assert not release_douyin.is_set() + + release_jd.set() + release_douyin.set() + worker.join(timeout=5) + + assert not worker.is_alive() + assert errors == [] + assert jd_ready + assert douyin_ready + assert tmall_import_ready + assert result_holder["result"].status == "success" + + def test_engine_preserves_retry_dependency_and_journal_contract(tmp_path: Path) -> None: flaky = FakeStep( StepExecution(exit_code=7, error="temporary"), diff --git a/tests/test_module_command_adapter.py b/tests/test_module_command_adapter.py index bb35e98..a7b8278 100644 --- a/tests/test_module_command_adapter.py +++ b/tests/test_module_command_adapter.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json +import shutil import sys from datetime import datetime, timezone from pathlib import Path @@ -17,9 +19,23 @@ from gyxx_flow.adapters.native import ( ModuleSourceRoots, ) from gyxx_flow.catalog import WorkflowEntry +from gyxx_flow.core.config import Settings from gyxx_flow.core.context import RunContext +from gyxx_flow.notification_routing import ( + ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID, + GYXX_NOTIFICATION_APP_PROFILE, + GYXX_NOTIFICATION_HERMES_PROFILE, + GYXX_NOTIFICATION_RECIPIENTS_JSON, + GYXX_NOTIFICATION_ROUTE_MODE, + GYXX_NOTIFICATION_ROUTE_REVISION, + GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID, + NotificationRoutingStore, + resolve_notification_route, +) from gyxx_flow.workflow.steps import StepExecution +PROJECT_ROOT = Path(__file__).resolve().parents[1] + def _entry(**overrides: object) -> WorkflowEntry: values: dict[str, object] = { @@ -43,6 +59,15 @@ def _context() -> RunContext: ) +def _context_for(workflow_id: str) -> RunContext: + return RunContext.create( + workflow_id, + "2026-07-27", + now=datetime(2026, 7, 27, tzinfo=timezone.utc), + random_suffix="native2", + ) + + def test_module_adapter_resolves_only_inside_new_module_root(tmp_path: Path) -> None: root = tmp_path / "product_commerce" / "runtime" entry = root / "jobs" / "backfill.py" @@ -273,3 +298,143 @@ def test_module_adapter_rejects_unsafe_acceptance_recipient(tmp_path: Path) -> N with pytest.raises(WorkflowAcceptancePolicyError, match="Wang Yunlong"): adapter.build(_entry(entry="job.py", args=()), context=_context()) + + +def test_module_adapter_injects_stable_notification_route_before_acceptance( + tmp_path: Path, +) -> None: + project_root = tmp_path / "project" + config_root = project_root / "config" + config_root.mkdir(parents=True) + shutil.copyfile( + PROJECT_ROOT / "config" / "notification-routing.json", + config_root / "notification-routing.json", + ) + data_root = tmp_path / "data" + settings = Settings(project_root=project_root, data_root=data_root) + store = NotificationRoutingStore(settings) + payload, revision = store.snapshot() + payload["routes"]["content.marketing_report.daily"] = { + "enabled": True, + "app_profile": "hermes-analyzer", + "person_ids": ["he_yingwei", "wang_yunlong"], + } + store.update(payload, expected_revision=revision) + + module_root = tmp_path / "content_marketing" + module_root.mkdir() + (module_root / "daily.py").write_text("pass", encoding="utf-8") + adapter = ModuleCommandAdapter( + ModuleSourceRoots({"content_marketing": module_root}), + base_env={"GYXX_WORKFLOW_ACCEPTANCE": "1"}, + project_root=project_root, + data_root=data_root, + ) + entry = _entry( + workflow_id="content.marketing_report.daily", + module="content_marketing", + entry="daily.py", + args=(), + ) + context = RunContext.create( + entry.workflow_id, + "2026-07-27", + now=datetime(2026, 7, 27, tzinfo=timezone.utc), + random_suffix="notify1", + ) + + command = adapter.build(entry, context=context) + + assert command.env[GYXX_NOTIFICATION_ROUTE_MODE] == "enabled" + assert command.env[GYXX_NOTIFICATION_APP_PROFILE] == "hermes-analyzer" + assert json.loads(command.env[GYXX_NOTIFICATION_RECIPIENTS_JSON]) == [ + ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID + ] + assert ( + command.env["GYXX_NOTIFICATION_RECIPIENT_OPEN_ID"] + == WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID + ) + assert resolve_notification_route(entry.workflow_id, (), command.env).open_ids == ( + ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID, + ) + + +def test_module_adapter_strips_route_snapshot_from_synthetic_manual_command( + tmp_path: Path, +) -> None: + module_root = tmp_path / "product_commerce" + module_root.mkdir() + (module_root / "run_alerts.py").write_text("pass", encoding="utf-8") + adapter = ModuleCommandAdapter( + ModuleSourceRoots({"product_commerce": module_root}), + base_env={ + GYXX_NOTIFICATION_ROUTE_MODE: "enabled", + GYXX_NOTIFICATION_APP_PROFILE: "hermes-analyzer", + GYXX_NOTIFICATION_HERMES_PROFILE: "data-analyzer", + GYXX_NOTIFICATION_RECIPIENTS_JSON: ( + '["ou_89bcff110ccbb09a23548dc0fb3d880c"]' + ), + GYXX_NOTIFICATION_ROUTE_REVISION: "0" * 64, + GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID: "content.marketing_report.daily", + }, + project_root=tmp_path, + data_root=tmp_path / "var", + ) + + synthetic_id = "script.product_commerce.0123456789ab" + command = adapter.build( + _entry( + workflow_id=synthetic_id, + entry="run_alerts.py", + args=(), + ), + context=_context_for(synthetic_id), + ) + + for variable in ( + GYXX_NOTIFICATION_ROUTE_MODE, + GYXX_NOTIFICATION_APP_PROFILE, + GYXX_NOTIFICATION_HERMES_PROFILE, + GYXX_NOTIFICATION_RECIPIENTS_JSON, + GYXX_NOTIFICATION_ROUTE_REVISION, + GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID, + ): + assert variable not in command.env + + +def test_module_adapter_snapshots_declared_route_for_public_manual_command( + tmp_path: Path, +) -> None: + project_root = tmp_path / "project" + config_root = project_root / "config" + config_root.mkdir(parents=True) + shutil.copyfile( + PROJECT_ROOT / "config" / "notification-routing.json", + config_root / "notification-routing.json", + ) + module_root = tmp_path / "content_marketing" + module_root.mkdir() + (module_root / "daily.py").write_text("pass", encoding="utf-8") + synthetic_id = "script.content_marketing.0123456789ab" + adapter = ModuleCommandAdapter( + ModuleSourceRoots({"content_marketing": module_root}), + project_root=project_root, + data_root=tmp_path / "data", + ) + + command = adapter.build( + _entry( + workflow_id=synthetic_id, + module="content_marketing", + entry="daily.py", + args=(), + notification_workflow_id="content.marketing_report.daily", + ), + context=_context_for(synthetic_id), + ) + + assert command.env[GYXX_NOTIFICATION_ROUTE_MODE] == "legacy" + assert ( + command.env[GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID] + == "content.marketing_report.daily" + ) diff --git a/tests/test_nested_workflow_browser_bindings.py b/tests/test_nested_workflow_browser_bindings.py index ac05dc8..ad85faa 100644 --- a/tests/test_nested_workflow_browser_bindings.py +++ b/tests/test_nested_workflow_browser_bindings.py @@ -38,7 +38,7 @@ NESTED_TARGETS = { "aggregate_daily_final.py", "export_bitable_records.py", "insert_bitable_records.py", - "analyze_style_with_hermes.py", + "analyze_style.py", "check_nine_day_decline.py", "collect_sycm_market_rank.py", "collect_jd_market_rank.py", diff --git a/tests/test_no_native_browser_automation.py b/tests/test_no_native_browser_automation.py new file mode 100644 index 0000000..1e6fe0f --- /dev/null +++ b/tests/test_no_native_browser_automation.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import ast +import re +import tomllib +import warnings +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SOURCE_ROOT = PROJECT_ROOT / "src" / "gyxx_flow" +SOURCE_RESOURCE_EXCLUSIONS = { + SOURCE_ROOT + / "modules" + / "product_commerce" + / "vendors" + / "dy-data-flow" + / "dynamic_session_src.py", +} +BLOCKED_BROWSER_PACKAGES = {"patchright", "playwright", "selenium"} +BLOCKED_ENGINE_FACTORIES = {"async_playwright", "sync_playwright"} +BLOCKED_ENGINE_METHODS = { + "connect_over_cdp", + "launch_persistent_context", +} + + +def _native_browser_references(path: Path) -> list[str]: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", SyntaxWarning) + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + failures: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name.partition(".")[0] in BLOCKED_BROWSER_PACKAGES: + failures.append(f"line {node.lineno}: import {alias.name}") + elif isinstance(node, ast.ImportFrom) and node.module: + if node.module.partition(".")[0] in BLOCKED_BROWSER_PACKAGES: + failures.append(f"line {node.lineno}: from {node.module} import ...") + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id in BLOCKED_ENGINE_FACTORIES + ): + failures.append(f"line {node.lineno}: {node.func.id}()") + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr in BLOCKED_ENGINE_METHODS + ): + failures.append(f"line {node.lineno}: .{node.func.attr}()") + elif isinstance(node, ast.Attribute) and node.attr == "playwright": + failures.append(f"line {node.lineno}: .playwright") + return failures + + +def test_production_code_uses_scrapling_instead_of_native_browser_packages() -> None: + failures: list[str] = [] + for path in SOURCE_ROOT.rglob("*.py"): + if path in SOURCE_RESOURCE_EXCLUSIONS: + continue + references = _native_browser_references(path) + if references: + relative = path.relative_to(PROJECT_ROOT).as_posix() + failures.extend(f"{relative}: {reference}" for reference in references) + + assert failures == [] + + +def test_project_declares_only_scrapling_as_direct_browser_framework() -> None: + project = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + dependencies = project["project"]["dependencies"] + normalized = { + re.split(r"[\[<>=!~]", item, maxsplit=1)[0].casefold() + for item in dependencies + } + + assert not (normalized & BLOCKED_BROWSER_PACKAGES) + assert "scrapling[fetchers]==0.4.13" in dependencies diff --git a/tests/test_notification_routing.py b/tests/test_notification_routing.py new file mode 100644 index 0000000..bb7a82a --- /dev/null +++ b/tests/test_notification_routing.py @@ -0,0 +1,401 @@ +from __future__ import annotations + +import json +import shutil +from copy import deepcopy +from pathlib import Path + +import pytest + +from gyxx_flow.core.config import Settings +from gyxx_flow.notification_routing import ( + ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID, + ANALYZER_NOTIFICATION_APP_ID, + GYXX_NOTIFICATION_APP_PROFILE, + GYXX_NOTIFICATION_HERMES_PROFILE, + GYXX_NOTIFICATION_RECIPIENTS_JSON, + GYXX_NOTIFICATION_ROUTE_MODE, + GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID, + NOTIFICATION_CAPABILITIES, + NotificationRoutingConflictError, + NotificationRoutingError, + NotificationRoutingPreconditionError, + NotificationRoutingStore, + notification_environment_for_workflow, + resolve_notification_route, +) + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def routing_settings(tmp_path: Path) -> Settings: + project_root = tmp_path / "project" + config_root = project_root / "config" + config_root.mkdir(parents=True) + shutil.copyfile( + PROJECT_ROOT / "config" / "notification-routing.json", + config_root / "notification-routing.json", + ) + return Settings(project_root=project_root, data_root=tmp_path / "data") + + +def _environment(settings: Settings) -> dict[str, str]: + return { + "GYXX_PROJECT_ROOT": str(settings.project_root), + "GYXX_DATA_ROOT": str(settings.data_root), + } + + +def _route( + *, + enabled: bool = True, + app_profile: str = "hermes-analyzer", + person_ids: list[str] | None = None, +) -> dict[str, object]: + return { + "enabled": enabled, + "app_profile": app_profile, + "person_ids": ["he_yingwei", "wang_yunlong"] + if person_ids is None + else person_ids, + } + + +def test_baseline_contains_all_provided_people_and_routable_capabilities( + routing_settings: Settings, +) -> None: + payload, revision = NotificationRoutingStore(routing_settings).snapshot() + + assert len(payload["people"]) == 16 + assert payload["app_profile"] == "hermes-analyzer" + assert ( + payload["app_profiles"]["hermes-analyzer"]["app_id"] + == ANALYZER_NOTIFICATION_APP_ID + ) + assert all(person["mobile"] is None for person in payload["people"].values()) + assert set(payload["capabilities"]) == set(NOTIFICATION_CAPABILITIES) + assert payload["routes"] == {} + assert len(revision) == 64 + + wang = payload["people"]["wang_yunlong"] + assert wang["bindings"]["collector"]["open_id"] == ( + "ou_8ee224968aa26a74c7d30ba27fed5eeb" + ) + assert wang["bindings"]["hermes-analyzer"]["open_id"] == ( + "ou_7ad5fc8012e2f741afc5346e05ffd447" + ) + provided = { + person["name"]: binding["open_id"] + for person_id, person in payload["people"].items() + if person_id != "wang_yunlong" + for profile, binding in person["bindings"].items() + if profile == "hermes-analyzer" + } + assert provided == { + "何颖威": "ou_89bcff110ccbb09a23548dc0fb3d880c", + "冯任运": "ou_c1faf3d3498201d27cd73c388acde067", + "张育基": "ou_339c1d396b397c97b08e1bf377963d40", + "李静娴": "ou_2eda5eec112109ae6d19a1f6813eadcb", + "黄坤平": "ou_b76e4cbdb24fe28cebd45ad091b60224", + "马兆基": "ou_54141d75b24d22ddc8c1c4d66e00b6e9", + "尹江涛": "ou_96fea927e6eef671f019428cc5075e69", + "余鹏辉": "ou_531782527ea7164a4abfb23dc1c37b2b", + "何嘉琪": "ou_bfcfa9585a0cc9c0a989816c12de661f", + "黄韶基": "ou_fa8d81a16527ad06352dbecc575285b8", + "谢钧宇": "ou_0d407e24fcf61bf6bb3a176bc84006e9", + "赵静": "ou_24cc944d6e43c69c59d6560ad4e2ae6e", + "李凤仪": "ou_e557867b29d756e57d3a1b656e9d3ce1", + "陈燕": "ou_63945c9e1b6d4c9ced7cce1c495d8bea", + "邓红梅": "ou_3799e39cfc5f78da1b7c54c2f677f5e3", + } + assert all( + binding["verified"] is False and binding["source"] == "provided" + for person_id, person in payload["people"].items() + if person_id != "wang_yunlong" + for profile, binding in person["bindings"].items() + if profile == "hermes-analyzer" + ) + + +def test_business_adapter_boundary_reexports_notification_resolution() -> None: + from gyxx_flow.adapters import ( + ResolvedNotificationRoute as ExportedResolvedNotificationRoute, + ) + from gyxx_flow.adapters import resolve_notification_route as exported_resolver + from gyxx_flow.notification_routing import ResolvedNotificationRoute + + assert ExportedResolvedNotificationRoute is ResolvedNotificationRoute + assert exported_resolver is resolve_notification_route + + +def test_enabled_route_supports_multiple_people_and_deduplicates_ids( + routing_settings: Settings, +) -> None: + store = NotificationRoutingStore(routing_settings) + payload, revision = store.snapshot() + payload["routes"]["content.marketing_report.daily"] = _route( + person_ids=["he_yingwei", "wang_yunlong", "he_yingwei"] + ) + + updated, updated_revision = store.update( + payload, + expected_revision=f'"{revision}"', + ) + resolved = resolve_notification_route( + "content.marketing_report.daily", + ("ou_legacy",), + _environment(routing_settings), + ) + + assert store.path == ( + routing_settings.data_root / "state" / "notifications" / "routing.json" + ) + assert store.path.is_file() + assert updated_revision != revision + assert updated["routes"]["content.marketing_report.daily"]["person_ids"] == [ + "he_yingwei", + "wang_yunlong", + ] + assert resolved.configured is True + assert resolved.enabled is True + assert resolved.app_profile == "hermes-analyzer" + assert resolved.open_ids == ( + "ou_89bcff110ccbb09a23548dc0fb3d880c", + "ou_7ad5fc8012e2f741afc5346e05ffd447", + ) + + +def test_disabled_route_returns_no_recipients_and_absent_route_is_legacy( + routing_settings: Settings, +) -> None: + legacy = resolve_notification_route( + "product.alert.daily", + ("ou_old", "ou_old", "ou_other"), + _environment(routing_settings), + ) + assert legacy.configured is False + assert legacy.enabled is True + assert legacy.open_ids == ("ou_old", "ou_other") + + store = NotificationRoutingStore(routing_settings) + payload, revision = store.snapshot() + payload["routes"]["product.alert.daily"] = _route( + enabled=False, + person_ids=[], + ) + store.update(payload, expected_revision=revision) + + disabled = resolve_notification_route( + "product.alert.daily", + ("ou_old",), + _environment(routing_settings), + ) + assert disabled.configured is True + assert disabled.enabled is False + assert disabled.open_ids == () + + +def test_acceptance_recipient_does_not_reenable_an_explicitly_disabled_route( + routing_settings: Settings, +) -> None: + store = NotificationRoutingStore(routing_settings) + payload, revision = store.snapshot() + payload["routes"]["product.alert.daily"] = _route( + enabled=False, + person_ids=[], + ) + store.update(payload, expected_revision=revision) + environment = { + **_environment(routing_settings), + "GYXX_WORKFLOW_ACCEPTANCE": "1", + "GYXX_NOTIFICATION_RECIPIENT_OPEN_ID": ( + "ou_8ee224968aa26a74c7d30ba27fed5eeb" + ), + } + + resolved = resolve_notification_route( + "product.alert.daily", + ("ou_legacy",), + environment, + ) + + assert resolved.enabled is False + assert resolved.open_ids == () + + +def test_app_profile_identity_is_required_and_open_ids_remain_app_scoped( + routing_settings: Settings, +) -> None: + store = NotificationRoutingStore(routing_settings) + payload, revision = store.snapshot() + invalid = deepcopy(payload) + invalid["routes"]["product.market_rank"] = _route( + app_profile="collector", + person_ids=["he_yingwei"], + ) + with pytest.raises(NotificationRoutingError, match="analyzer Hermes"): + store.update(invalid, expected_revision=revision) + assert not store.path.exists() + + invalid_default = deepcopy(payload) + invalid_default["app_profile"] = "collector" + with pytest.raises(NotificationRoutingError, match="analyzer Hermes"): + store.update(invalid_default, expected_revision=revision) + assert not store.path.exists() + + +def test_analyzer_capability_uses_analyzer_scoped_acceptance_recipient( + routing_settings: Settings, +) -> None: + resolved = resolve_notification_route( + "content.marketing_report.daily", + ("ou_89bcff110ccbb09a23548dc0fb3d880c",), + { + **_environment(routing_settings), + "GYXX_WORKFLOW_ACCEPTANCE": "1", + "GYXX_NOTIFICATION_RECIPIENT_OPEN_ID": ( + "ou_8ee224968aa26a74c7d30ba27fed5eeb" + ), + }, + ) + + assert resolved.open_ids == (ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID,) + + +@pytest.mark.parametrize( + "snapshot_workflow", + [None, "product.alert.daily"], +) +def test_environment_snapshot_requires_the_exact_workflow_id( + snapshot_workflow: str | None, +) -> None: + environment = { + GYXX_NOTIFICATION_ROUTE_MODE: "enabled", + GYXX_NOTIFICATION_APP_PROFILE: "hermes-analyzer", + GYXX_NOTIFICATION_HERMES_PROFILE: "data-analyzer", + GYXX_NOTIFICATION_RECIPIENTS_JSON: ( + '["ou_89bcff110ccbb09a23548dc0fb3d880c"]' + ), + } + if snapshot_workflow is not None: + environment[GYXX_NOTIFICATION_ROUTE_WORKFLOW_ID] = snapshot_workflow + + with pytest.raises(NotificationRoutingError, match="another workflow"): + resolve_notification_route( + "content.marketing_report.daily", + (), + environment, + ) + + +def test_unsupported_workflow_cannot_gain_a_route( + routing_settings: Settings, +) -> None: + store = NotificationRoutingStore(routing_settings) + payload, revision = store.snapshot() + payload["routes"]["content.metrics.daily"] = _route() + + with pytest.raises(NotificationRoutingError, match="does not support"): + store.update(payload, expected_revision=revision) + assert not store.path.exists() + + +def test_unsafe_people_fields_and_cross_person_identity_collisions_are_rejected( + routing_settings: Settings, +) -> None: + store = NotificationRoutingStore(routing_settings) + payload, revision = store.snapshot() + unsafe = deepcopy(payload) + unsafe["people"]["he_yingwei"]["name"] = "何颖威\u200b" + with pytest.raises(NotificationRoutingError, match="person name is invalid"): + store.update(unsafe, expected_revision=revision) + + collision = deepcopy(payload) + collision["people"]["feng_renyun"]["bindings"]["hermes-analyzer"][ + "open_id" + ] = collision["people"]["he_yingwei"]["bindings"]["hermes-analyzer"][ + "open_id" + ] + with pytest.raises(NotificationRoutingError, match="multiple people"): + store.update(collision, expected_revision=revision) + assert not store.path.exists() + + +def test_corrupt_runtime_state_fails_closed_instead_of_using_defaults( + routing_settings: Settings, +) -> None: + store = NotificationRoutingStore(routing_settings) + store.path.parent.mkdir(parents=True) + store.path.write_text("{broken", encoding="utf-8") + + with pytest.raises(NotificationRoutingError, match="JSON is invalid"): + resolve_notification_route( + "content.marketing_report.daily", + ("ou_legacy",), + _environment(routing_settings), + ) + + +def test_update_requires_current_revision_and_preserves_state_on_conflict( + routing_settings: Settings, +) -> None: + store = NotificationRoutingStore(routing_settings) + payload, revision = store.snapshot() + payload["routes"]["product.alert.daily"] = _route( + person_ids=["wang_yunlong"] + ) + + with pytest.raises(NotificationRoutingPreconditionError): + store.update(payload, expected_revision=None) + with pytest.raises(NotificationRoutingConflictError): + store.update(payload, expected_revision="0" * 64) + assert not store.path.exists() + + _updated, current_revision = store.update(payload, expected_revision=revision) + before = store.path.read_bytes() + with pytest.raises(NotificationRoutingConflictError): + store.update(payload, expected_revision=revision) + assert store.path.read_bytes() == before + assert current_revision != revision + + +def test_environment_snapshot_is_stable_after_routing_file_changes( + routing_settings: Settings, +) -> None: + store = NotificationRoutingStore(routing_settings) + payload, revision = store.snapshot() + payload["routes"]["supply.replenishment.weekly"] = _route( + person_ids=["he_yingwei", "wang_yunlong"] + ) + payload, revision = store.update(payload, expected_revision=revision) + + snapshot_environment = notification_environment_for_workflow( + "supply.replenishment.weekly", + project_root=routing_settings.project_root, + data_root=routing_settings.data_root, + environment={}, + ) + payload["routes"]["supply.replenishment.weekly"] = _route( + person_ids=["feng_renyun"] + ) + store.update(payload, expected_revision=revision) + + resolved = resolve_notification_route( + "supply.replenishment.weekly", + (), + snapshot_environment, + ) + assert snapshot_environment[GYXX_NOTIFICATION_ROUTE_MODE] == "enabled" + assert ( + snapshot_environment[GYXX_NOTIFICATION_HERMES_PROFILE] + == "data-analyzer" + ) + assert json.loads(snapshot_environment[GYXX_NOTIFICATION_RECIPIENTS_JSON]) == [ + "ou_89bcff110ccbb09a23548dc0fb3d880c", + "ou_7ad5fc8012e2f741afc5346e05ffd447", + ] + assert resolved.open_ids == ( + "ou_89bcff110ccbb09a23548dc0fb3d880c", + "ou_7ad5fc8012e2f741afc5346e05ffd447", + ) diff --git a/tests/test_product_commerce_module.py b/tests/test_product_commerce_module.py index 7ef0587..09a2b51 100644 --- a/tests/test_product_commerce_module.py +++ b/tests/test_product_commerce_module.py @@ -6,25 +6,72 @@ from pathlib import Path from gyxx_flow.adapters.native import DeferredModuleCommandStep from gyxx_flow.catalog import WorkflowCatalog from gyxx_flow.modules.product_commerce import ( + PRODUCT_MANUAL_WORKFLOW_IDS, PRODUCT_RESOURCE, PRODUCT_SCHEDULED_WORKFLOW_IDS, PRODUCT_TIMEOUT_SECONDS, PRODUCT_WORKFLOW_IDS, + TMALL_BAIBU_IMPORT_MODE_ALL, + TMALL_BAIBU_IMPORT_MODE_WITHOUT_HYPERLINKS, ProductCommerceModule, ) PROJECT_ROOT = Path(__file__).resolve().parents[1] -def test_product_module_registers_only_scheduled_entries() -> None: +def test_product_module_registers_scheduled_and_manual_entries() -> None: module = ProductCommerceModule.from_catalog( WorkflowCatalog.load(PROJECT_ROOT / "config") ) - assert tuple(item.workflow_id for item in module.workflow_definitions()) == PRODUCT_WORKFLOW_IDS - assert len(PRODUCT_SCHEDULED_WORKFLOW_IDS) == 8 + assert len(PRODUCT_SCHEDULED_WORKFLOW_IDS) == 10 + assert PRODUCT_MANUAL_WORKFLOW_IDS == ( + "product.video_upload", + "product.jd_video_upload", + ) +def test_tmall_baibu_import_mode_filters_hyperlink_steps_and_rewires_dependencies() -> None: + catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") + + all_steps = next( + definition + for definition in ProductCommerceModule.from_catalog( + catalog, + tmall_baibu_import_mode=TMALL_BAIBU_IMPORT_MODE_ALL, + ).workflow_definitions() + if definition.workflow_id == "product.tmall_baibu_apply" + ).steps + filtered_steps = next( + definition + for definition in ProductCommerceModule.from_catalog( + catalog, + tmall_baibu_import_mode=TMALL_BAIBU_IMPORT_MODE_WITHOUT_HYPERLINKS, + ).workflow_definitions() + if definition.workflow_id == "product.tmall_baibu_apply" + ).steps + + assert [step.step_id for step in all_steps] == [ + "old_chain_bag", + "old_all_bag", + "old_all_3c", + "old_chain_3c", + "new_chain_bag", + "new_all_bag", + ] + assert [step.step_id for step in filtered_steps] == [ + "old_all_bag", + "old_all_3c", + "new_all_bag", + ] + assert [step.depends_on for step in filtered_steps] == [ + (), + ("old_all_bag",), + ("old_all_3c",), + ] + assert all(step.replay_policy == "repeatable" for step in all_steps) + assert all(step.replay_policy == "repeatable" for step in filtered_steps) + def test_product_workflows_use_project_owned_module_commands() -> None: module = ProductCommerceModule.from_catalog( WorkflowCatalog.load(PROJECT_ROOT / "config") @@ -36,8 +83,23 @@ def test_product_workflows_use_project_owned_module_commands() -> None: assert isinstance(step.action, DeferredModuleCommandStep) assert step.timeout_seconds == PRODUCT_TIMEOUT_SECONDS == 6 * 60 * 60 assert step.max_attempts == 1 - if definition.workflow_id == "product.main_image.weekly": + if definition.workflow_id == "product.ecommerce_costs.daily": + assert step.resources == ( + f"{PRODUCT_RESOURCE}:{definition.workflow_id}:{step.step_id}", + ) + elif definition.workflow_id == "product.main_image.weekly": assert step.resources == (f"{PRODUCT_RESOURCE}:{step.step_id}",) + elif definition.workflow_id in { + "product.daily", + "product.persona.daily", + "product.style_analysis.interval", + "product.video_upload", + "product.jd_video_upload", + "product.tmall_baibu_apply", + }: + assert step.resources == ( + f"{PRODUCT_RESOURCE}:{definition.workflow_id}", + ) else: assert step.resources == (PRODUCT_RESOURCE,) == ( "module:product_commerce", @@ -45,6 +107,62 @@ def test_product_workflows_use_project_owned_module_commands() -> None: assert step.production_sink is True +def test_workflow_scoped_product_resources_are_distinct() -> None: + module = ProductCommerceModule.from_catalog( + WorkflowCatalog.load(PROJECT_ROOT / "config") + ) + resources = { + definition.workflow_id: definition.steps[0].resources + for definition in module.workflow_definitions() + if definition.workflow_id + in { + "product.daily", + "product.persona.daily", + "product.style_analysis.interval", + "product.ecommerce_costs.daily", + "product.video_upload", + "product.jd_video_upload", + "product.tmall_baibu_apply", + } + } + + assert resources == { + "product.daily": ("module:product_commerce:product.daily",), + "product.persona.daily": ( + "module:product_commerce:product.persona.daily", + ), + "product.style_analysis.interval": ( + "module:product_commerce:product.style_analysis.interval", + ), + "product.ecommerce_costs.daily": ( + "module:product_commerce:product.ecommerce_costs.daily:tmall_download", + ), + "product.video_upload": ( + "module:product_commerce:product.video_upload", + ), + "product.jd_video_upload": ( + "module:product_commerce:product.jd_video_upload", + ), + "product.tmall_baibu_apply": ( + "module:product_commerce:product.tmall_baibu_apply", + ), + } + + +def test_persona_workflow_allows_idempotent_recovery_after_partial_platform_runs() -> None: + module = ProductCommerceModule.from_catalog( + WorkflowCatalog.load(PROJECT_ROOT / "config") + ) + + workflow = next( + item + for item in module.workflow_definitions() + if item.workflow_id == "product.persona.daily" + ) + + assert workflow.steps[0].replay_policy == "idempotent" + + def test_combined_main_image_workflow_runs_platforms_independently() -> None: module = ProductCommerceModule.from_catalog( WorkflowCatalog.load(PROJECT_ROOT / "config") @@ -65,6 +183,50 @@ def test_combined_main_image_workflow_runs_platforms_independently() -> None: ] +def test_tmall_video_upload_workflow_allows_idempotent_selected_store_rescans() -> None: + module = ProductCommerceModule.from_catalog( + WorkflowCatalog.load(PROJECT_ROOT / "config") + ) + + workflow = next( + item + for item in module.workflow_definitions() + if item.workflow_id == "product.video_upload" + ) + assert len(workflow.steps) == 1 + step = workflow.steps[0] + assert step.step_id == "upload" + assert step.replay_policy == "idempotent" + assert step.action.command_entry == "upload_video_to_guanghe.py" + assert step.action.command_args == ( + "--execute", + "--keep-browser-open-on-failure", + ) + + +def test_jd_video_upload_workflow_allows_idempotent_rescans() -> None: + module = ProductCommerceModule.from_catalog( + WorkflowCatalog.load(PROJECT_ROOT / "config") + ) + + workflow = next( + item + for item in module.workflow_definitions() + if item.workflow_id == "product.jd_video_upload" + ) + assert len(workflow.steps) == 1 + step = workflow.steps[0] + assert step.step_id == "upload" + assert step.replay_policy == "idempotent" + assert step.action.command_entry == "upload_video_to_jd.py" + assert step.action.command_args == ( + "--anchor-record-id", + "recvrXTRWWFXeJ", + "--manual-login", + "--execute", + ) + + def test_product_runtime_compatibility_package_points_to_flattened_module() -> None: module_root = ( PROJECT_ROOT diff --git a/tests/test_product_source_migration.py b/tests/test_product_source_migration.py index 017397c..378a8f5 100644 --- a/tests/test_product_source_migration.py +++ b/tests/test_product_source_migration.py @@ -25,7 +25,7 @@ MANIFEST_PATH = ( PRODUCTION_PYTHON = { "aggregate_daily_final.py", - "analyze_style_with_hermes.py", + "analyze_style.py", "backfill_poseidon_sales.py", "backfill_collect.py", "backfill_one_day.py", @@ -152,7 +152,7 @@ INTENTIONALLY_EXCLUDED = { SUBPROCESS_TARGETS = { "aggregate_daily_final.py", - "analyze_style_with_hermes.py", + "analyze_style.py", "check_nine_day_decline.py", "collect_dy_market_rank.py", "collect_dy_persona_to_bitable.py", @@ -373,11 +373,12 @@ def test_product_runtime_has_no_hardcoded_long_hex_keys() -> None: text = path.read_text(encoding="utf-8-sig") assert hardcoded_key.search(text) is None, path - analyzer = (MODULE_ROOT / "analyze_style_with_hermes.py").read_text( + analyzer = (MODULE_ROOT / "analyze_style.py").read_text( encoding="utf-8-sig" ) - assert 'os.getenv("HERMES_API_KEY")' in analyzer - assert "HERMES_API_KEY is required" in analyzer + assert "call_direct_llm" in analyzer + assert "STYLE_ANALYSIS_LLM_MODEL" in analyzer + assert "analyze_style_with_hermes" not in analyzer def test_product_core_runtime_has_no_known_f821_regressions() -> None: diff --git a/tests/test_relocated_project.py b/tests/test_relocated_project.py index 858207d..86b7798 100644 --- a/tests/test_relocated_project.py +++ b/tests/test_relocated_project.py @@ -53,10 +53,10 @@ def test_catalog_and_scripts_work_after_project_and_data_roots_move( assert workflows.returncode == 0, workflows.stderr assert scripts.returncode == 0, scripts.stderr - assert len(json.loads(workflows.stdout)) == 23 + assert len(json.loads(workflows.stdout)) == 27 commands = json.loads(scripts.stdout) - assert len(commands) == 33 - assert len({row["command_id"] for row in commands}) == 33 + assert len(commands) == 41 + assert len({row["command_id"] for row in commands}) == 41 assert all(row["legacy_script_id"].startswith(f"{row['module']}:") for row in commands) diff --git a/tests/test_runtime_env_loading.py b/tests/test_runtime_env_loading.py index f4076b7..767645b 100644 --- a/tests/test_runtime_env_loading.py +++ b/tests/test_runtime_env_loading.py @@ -52,6 +52,24 @@ def test_runtime_env_files_load_values_without_overriding_process_env( assert os.environ["GYXX_RUNTIME_ENV_TEST_KEEP"] == "process" +def test_runtime_env_file_fills_an_empty_process_placeholder( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + env_file = tmp_path / "production.env" + env_file.write_text( + "GYXX_RUNTIME_ENV_TEST_EMPTY=from-file\n", + encoding="utf-8", + ) + monkeypatch.setenv("GYXX_RUNTIME_ENV_TEST_EMPTY", "") + + _load_runtime_environment_files(argparse.Namespace(env_file=[env_file])) + + import os + + assert os.environ["GYXX_RUNTIME_ENV_TEST_EMPTY"] == "from-file" + + def test_runtime_env_file_must_exist(tmp_path: Path) -> None: missing = tmp_path / "missing.env" diff --git a/tests/test_runtime_integrations.py b/tests/test_runtime_integrations.py index 1116f56..658c807 100644 --- a/tests/test_runtime_integrations.py +++ b/tests/test_runtime_integrations.py @@ -17,7 +17,10 @@ from gyxx_flow.adapters import ( environment_for_child_script, resolve_hermes_profile_api_key, ) -from gyxx_flow.adapters.bootstrap import _rewrite_browser_arguments +from gyxx_flow.adapters.bootstrap import ( + _rewrite_browser_arguments, + bootstrap_current_process, +) from gyxx_flow.adapters.native import ModuleCommandAdapter, ModuleSourceRoots from gyxx_flow.catalog import WorkflowEntry from gyxx_flow.core.context import RunContext @@ -99,18 +102,96 @@ def test_project_registry_covers_every_script_with_unique_stable_port() -> None: data_root=project_root / "var", ) - assert len(scripts.command_ids) == 33 - assert len(first.command_ids) == 137 + assert len(scripts.command_ids) == 49 + assert len(first.command_ids) == 152 bindings = [first.binding_for(command_id) for command_id in first.command_ids] - assert len({binding.cdp_port for binding in bindings}) == 137 + assert len({binding.cdp_port for binding in bindings}) == 152 assert all(22000 <= binding.cdp_port <= 22999 for binding in bindings) assert all(binding.cdp_url.startswith("http://127.0.0.1:") for binding in bindings) assert bindings == [second.binding_for(item.script_id) for item in bindings] + tmall_ads = first.binding_for("product.import.tmall_ads") + assert tmall_ads is first.binding_for( + "product_commerce:import_tmall_product_ads.py" + ) + assert tmall_ads.cdp_port == 22140 appeal = first.binding_for("shop.douyin_price_appeal") assert appeal is first.binding_for( "shop_intelligence:collectors/dy_store_competitor_store_scraping.py" ) assert appeal.cdp_port == 22104 + video_upload = first.binding_for("product.video_upload.run") + assert video_upload is first.binding_for( + "product_commerce:upload_video_to_guanghe.py" + ) + assert video_upload.cdp_port == 22098 + assert video_upload.login_mode == "C" + assert video_upload.required_cookie_domains == ("taobao.com",) + assert video_upload.credential_env_names == ( + "GUANGHE_USERNAME", + "GUANGHE_PASSWORD", + "GUANGHE_LUGGAGE_USERNAME", + "GUANGHE_LUGGAGE_PASSWORD", + ) + video_environment = first.environment_for("product.video_upload.run", {}) + assert video_environment["GUANGHE_USER_DATA_DIR"] == str( + video_upload.profile_dir + ) + assert video_environment["GUANGHE_LUGGAGE_USER_DATA_DIR"] != str( + video_upload.profile_dir + ) + assert video_environment["GUANGHE_LUGGAGE_USER_DATA_DIR"].endswith( + "-luggage" + ) + jd_video_upload = first.binding_for("product.jd_video_upload.run") + assert jd_video_upload is first.binding_for( + "product_commerce:upload_video_to_jd.py" + ) + assert jd_video_upload.cdp_port == 22137 + assert jd_video_upload.login_mode == "C" + assert jd_video_upload.required_cookie_domains == ("jd.com",) + assert jd_video_upload.credential_env_names == ("JD_SHOP", "JD_PASSWORD") + assert jd_video_upload.profile_dir != video_upload.profile_dir + erp_all_shop = first.binding_for("product.erp_all_shop_daily") + assert erp_all_shop.cdp_port == 22138 + assert erp_all_shop.required_cookie_domains == ("erp321.com",) + assert jd_video_upload.cookie_file != video_upload.cookie_file + assert jd_video_upload.storage_state_file != video_upload.storage_state_file + + baibu_old = first.binding_for("product.tmall_baibu_apply.old_chain_bag") + assert baibu_old.account == "tmall-shop" + assert baibu_old.cdp_port == 22148 + assert baibu_old.required_cookie_domains == ("taobao.com", "alimama.com") + baibu_new = first.binding_for("product.tmall_baibu_apply.new_chain_bag") + assert baibu_new.account == "tmall-bag" + assert baibu_new.cdp_port == 22152 + assert baibu_new.login_mode == "C" + assert baibu_new.credential_env_names == ( + "TMALL_BAG_ACCOUNT", + "TMALL_BAG_PASSWORD", + ) + assert baibu_new.profile_dir != baibu_old.profile_dir + + jd_ad_costs = first.binding_for("product.jd_ad_costs.collect") + assert jd_ad_costs is first.binding_for( + "product_commerce:collect_jd_ad_costs.py" + ) + assert jd_ad_costs.cdp_port == 22144 + assert jd_ad_costs.account == "jd-shop" + assert jd_ad_costs.required_cookie_domains == ("jd.com",) + + collaborator_bilibili = first.binding_for( + "content_marketing:bilibili_scraper.py" + ) + self_bilibili = first.binding_for( + "content_marketing:self_bilibili_scraper.py" + ) + for binding in (collaborator_bilibili, self_bilibili): + assert binding.required_cookie_domains == () + assert binding.required_cookie_names == () + + comment_bilibili = first.binding_for("content.comments.collect_bilibili") + assert comment_bilibili.required_cookie_domains == ("bilibili.com",) + assert comment_bilibili.required_cookie_names == ("SESSDATA",) def test_bindings_isolate_browser_state_and_relocate_with_data_root(tmp_path: Path) -> None: @@ -135,6 +216,282 @@ def test_bindings_isolate_browser_state_and_relocate_with_data_root(tmp_path: Pa assert not first.profile_dir.exists() +def _write_account_config(path: Path) -> None: + path.write_text( + json.dumps( + { + "schema_version": 3, + "cdp_host": "127.0.0.1", + "accounts": { + "demo-shop": { + "cdp_port": 22200, + "login_mode": "D", + "required_cookie_domains": ["example.test"], + "required_cookie_names": ["session", "sid_tt"], + } + }, + "scripts": { + "demo.first": { + "script_id": "demo:first.py", + "state_key": "demo:first.py", + "aliases": ["demo:first.py"], + "cdp_port": 22001, + "login_mode": "A", + "required_cookie_domains": ["example.test"], + "account": "demo-shop", + }, + "demo.second": { + "script_id": "demo:nested/second.py", + "state_key": "demo:nested/second.py", + "aliases": ["demo:nested/second.py"], + "cdp_port": 22002, + }, + }, + "services": { + "feishu": "legacy", + "postgres": "cloud", + "hermes": "local", + "hermes_url": "http://127.0.0.1:8642/v1/chat/completions", + "hermes_collector_url": "http://127.0.0.1:8643/v1/chat/completions", + "hermes_analyzer_gateway_url": "http://127.0.0.1:8642/v1", + "hermes_collector_gateway_url": "http://127.0.0.1:8643/v1", + }, + } + ), + encoding="utf-8", + ) + + +def test_accounts_parse_and_sync_vault_into_member_bindings(tmp_path: Path) -> None: + scripts = _catalog(tmp_path) + config = tmp_path / "bindings.json" + _write_account_config(config) + data_root = tmp_path / "data" + catalog = RuntimeIntegrationCatalog.load(config, scripts=scripts, data_root=data_root) + + account = catalog.account_for("demo-shop") + assert account.cdp_port == 22200 + assert account.login_mode == "D" + assert account.required_cookie_names == ("session", "sid_tt") + assert account.cookie_file == ( + data_root / "state" / "accounts" / "demo-shop" / "cookies.json" + ) + members = catalog.bindings_for_account("demo-shop") + assert [member.command_id for member in members] == ["demo.first"] + assert catalog.binding_for("demo.second").account == "" + environment = catalog.environment_for("demo:first.py", {}) + assert environment["GYXX_ACCOUNT_ID"] == "demo-shop" + assert environment["GYXX_ACCOUNT_PROFILE_DIR"] == str(account.profile_dir) + assert environment["GYXX_ACCOUNT_COOKIE_FILE"] == str(account.cookie_file) + dynamic = catalog.environment_for_account("demo-shop", {"KEEP": "yes"}) + assert dynamic["KEEP"] == "yes" + assert dynamic["GYXX_ACCOUNT_STORAGE_STATE_FILE"] == str( + account.storage_state_file + ) + with pytest.raises(RuntimeIntegrationError, match="unknown runtime account"): + catalog.account_for("nope") + + vault_cookies = [ + { + "name": "session", + "value": "VAULT_SESSION", + "domain": "example.test", + "path": "/", + }, + {"name": "sid_tt", "value": "VAULT_SID", "domain": "example.test", "path": "/"}, + ] + BrowserCookieStore(account.cookie_file, account.storage_state_file).save_cookies( + vault_cookies + ) + BrowserCookieStore( + account.cookie_file, account.storage_state_file + ).save_storage_state( + { + "cookies": vault_cookies, + "origins": [ + { + "origin": "https://example.test", + "localStorage": [{"name": "k", "value": "v"}], + } + ], + } + ) + + member = catalog.binding_for("demo.first") + member_store = BrowserCookieStore(member.cookie_file, member.storage_state_file) + member_store.save_cookies( + [ + { + "name": "session", + "value": "OLD_SESSION", + "domain": "example.test", + "path": "/", + }, + {"name": "local_only", "value": "keep", "domain": "other.test", "path": "/"}, + ] + ) + member_store.save_storage_state( + { + "cookies": [], + "origins": [ + { + "origin": "https://legacy.test", + "localStorage": [{"name": "a", "value": "b"}], + } + ], + } + ) + + synced = catalog.sync_binding_state(member) + assert synced["synced"] is True + merged = member_store.load_cookies() + by_key = { + (item["name"], item["domain"]): item["value"] for item in merged + } + assert by_key[("session", "example.test")] == "VAULT_SESSION" + assert by_key[("sid_tt", "example.test")] == "VAULT_SID" + assert by_key[("local_only", "other.test")] == "keep" + state = member_store.load_storage_state() + origins = {item["origin"] for item in state["origins"]} + assert origins == {"https://example.test", "https://legacy.test"} + + # A second sync with an unchanged vault is a no-op. + assert catalog.sync_binding_state(member)["synced"] is False + + # environment_for pushes the vault before returning the environment. + catalog.environment_for("demo.first", {}) + assert catalog.binding_for("demo.first").cookie_file.read_text( + encoding="utf-8" + ).count("VAULT_SESSION") == 1 + + +def test_account_unknown_reference_and_port_collisions_are_rejected( + tmp_path: Path, +) -> None: + scripts = _catalog(tmp_path) + config = tmp_path / "bindings.json" + _write_account_config(config) + payload = json.loads(config.read_text(encoding="utf-8")) + payload["scripts"]["demo.first"]["account"] = "missing-account" + config.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(RuntimeIntegrationError, match="unknown runtime account"): + RuntimeIntegrationCatalog.load(config, scripts=scripts, data_root=tmp_path / "d") + + _write_account_config(config) + payload = json.loads(config.read_text(encoding="utf-8")) + payload["accounts"]["demo-shop"]["cdp_port"] = 22001 # collides with demo.first + config.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(RuntimeIntegrationError, match="unique integers"): + RuntimeIntegrationCatalog.load(config, scripts=scripts, data_root=tmp_path / "d") + + +def test_project_account_pilot_covers_douyin_shop_members() -> None: + project_root = Path(__file__).resolve().parents[1] + catalog = RuntimeIntegrationCatalog.load( + project_root / "config" / "runtime-bindings.json", + scripts=ScriptCatalog.discover_default(), + data_root=project_root / "var", + ) + account = catalog.account_for("douyin-shop") + assert account.cdp_port == 22200 + members = catalog.bindings_for_account("douyin-shop") + assert {member.command_id for member in members} == { + "shop.douyin_price_appeal", + "product_commerce:dy_product_scraping.py", + "product_commerce:dy_audience_profile_collect.py", + "product_commerce:collect_dy_market_rank.py", + "product.douyin_qianchuan_ads.collect", + "product_commerce:vendors/dy-data-flow/dy_store_competitor_store_scraping.py", + "content_marketing:xingtu_scraper_v2.py", + } + for member in members: + assert member.required_cookie_domains == ("jinritemai.com",) + + +def test_project_accounts_cover_all_configured_platform_login_identities() -> None: + project_root = Path(__file__).resolve().parents[1] + catalog = RuntimeIntegrationCatalog.load( + project_root / "config" / "runtime-bindings.json", + scripts=ScriptCatalog.discover_default(), + data_root=project_root / "var", + ) + + expected_delays = { + "douyin-shop": 0, + "jd-shop": 60, + "jd-self-operated": 120, + "jd-market-rank": 540, + "erp-jushuitan": 660, + "tmall-shop": 180, + "tmall-ozko": 600, + "tmall-bag": 300, + "xiaohongshu-content": 240, + "douyin-content": 300, + "xingtu": 360, + "chanmama": 420, + "bilibili-content": 480, + } + assert {account.account_id for account in catalog.accounts} == set(expected_delays) + assert { + account.account_id: account.keepalive_initial_delay_seconds + for account in catalog.accounts + } == expected_delays + assert all(account.keepalive_enabled for account in catalog.accounts) + assert all(account.keepalive_real_chrome for account in catalog.accounts) + assert all( + catalog.bindings_for_account(account.account_id) + for account in catalog.accounts + if account.account_id != "tmall-ozko" + ) + assert catalog.bindings_for_account("tmall-ozko") == () + assert catalog.account_for("jd-shop").credential_env_names == ( + "JD_SHOP", + "JD_PASSWORD", + ) + assert catalog.account_for("jd-self-operated").credential_env_names == ( + "JD_SELF_OPERATED_ACCOUNT", + "JD_SELF_OPERATED_PASSWORD", + ) + assert catalog.account_for("tmall-shop").credential_env_names == ( + "TMALL_SHOP_ACCOUNT", + "TMALL_SHOP_PASSWORD", + "SYCM_ACCOUNT", + "SYCM_PASSWORD", + "WANXIANG_ACCOUNT", + "WANXIANG_PASSWORD", + ) + assert catalog.account_for("tmall-shop").login_identity_markers == ( + "光影行星旗舰店", + ) + assert catalog.account_for("tmall-bag").login_identity_markers == ( + "光影行星鑫华达专卖店", + ) + assert catalog.account_for("erp-jushuitan").credential_env_names == ( + "ERP_USERNAME", + "ERP_PASSWORD", + "GYXX_SUPPLY_ERP_PASSWORD", + ) + assert catalog.account_for("erp-jushuitan").login_command_id == ( + "product_commerce:erp_login_product_analysis.py" + ) + + jd_market = catalog.binding_for("product_commerce:collect_jd_market_rank.py") + assert jd_market.account == "jd-market-rank" + assert jd_market.required_cookie_names == ("pin", "thor") + assert jd_market.credential_env_names == ( + "JD_MARKET_SHOP", + "JD_MARKET_PASSWORD", + ) + + erp_daily = catalog.binding_for( + "product_commerce:collect_erp_yesterday_metrics.py" + ) + assert erp_daily.account == "erp-jushuitan" + assert catalog.binding_for("product.erp_all_shop_daily").account == "erp-jushuitan" + assert catalog.binding_for("product.sales_sheet.sync").account == "erp-jushuitan" + assert catalog.binding_for("supply.replenishment.arrival_sync").account == "erp-jushuitan" + + def test_schema_v1_keeps_legacy_script_ids_and_existing_state_paths(tmp_path: Path) -> None: scripts = _catalog(tmp_path) stable_config = tmp_path / "stable.json" @@ -373,6 +730,21 @@ def test_cookie_store_round_trip_and_storage_state_are_atomic(tmp_path: Path) -> assert "secret-sentinel" not in repr(store) +def test_cookie_store_reads_legacy_cookie_envelope(tmp_path: Path) -> None: + store = BrowserCookieStore( + cookie_file=tmp_path / "cookies.json", + storage_state_file=tmp_path / "storage_state.json", + ) + cookies = [{"name": "session", "value": "legacy", "domain": ".example.test"}] + store.cookie_file.parent.mkdir(parents=True, exist_ok=True) + store.cookie_file.write_text( + json.dumps({"cookies": cookies, "timestamp": "legacy"}), + encoding="utf-8", + ) + + assert store.load_cookies() == cookies + + def test_service_policy_preserves_feishu_and_uses_cloud_pg_and_local_hermes() -> None: policy = RuntimeServicePolicy( hermes_url="http://127.0.0.1:8642/v1/chat/completions" @@ -561,3 +933,43 @@ def test_nested_script_browser_arguments_are_rebound(monkeypatch, tmp_path: Path assert sys.argv[2] == str(binding.profile_dir) assert sys.argv[3] == f"--cdp-url={binding.cdp_url}" assert sys.argv[5] == str(binding.cdp_port) + + +def test_parent_routed_child_preserves_dynamic_runtime_environment( + monkeypatch, + tmp_path: Path, +) -> None: + scripts = _catalog(tmp_path) + config = tmp_path / "bindings.json" + _write_config(config) + catalog = RuntimeIntegrationCatalog.load( + config, scripts=scripts, data_root=tmp_path / "data" + ) + entry = scripts.get("demo.second") + + monkeypatch.setattr( + "gyxx_flow.adapters.bootstrap.RuntimeIntegrationCatalog.load_default", + lambda **_kwargs: catalog, + ) + monkeypatch.setenv("GYXX_MODULE_ID", "demo") + monkeypatch.setenv("GYXX_MODULE_ROOT", str(tmp_path / "runtime")) + monkeypatch.setenv("GYXX_PROJECT_ROOT", str(tmp_path)) + monkeypatch.setenv("GYXX_DATA_ROOT", str(tmp_path / "data")) + monkeypatch.setenv("GYXX_PRESERVE_RUNTIME_ENV", "1") + monkeypatch.setenv("GYXX_BROWSER_CDP_PORT", "22143") + monkeypatch.setenv("GYXX_BROWSER_CDP_URL", "http://127.0.0.1:22143") + monkeypatch.setattr( + sys, + "argv", + [ + str(entry.path), + "--cdp-url", + "http://127.0.0.1:22143", + ], + ) + + binding = bootstrap_current_process() + + assert binding is catalog.binding_for("demo:nested/second.py") + assert os.environ["GYXX_BROWSER_CDP_PORT"] == "22143" + assert sys.argv[2] == "http://127.0.0.1:22143" diff --git a/tests/test_scheduler_cli.py b/tests/test_scheduler_cli.py index 18031da..d80630a 100644 --- a/tests/test_scheduler_cli.py +++ b/tests/test_scheduler_cli.py @@ -54,7 +54,7 @@ def test_python_scheduler_status_is_project_owned_and_cross_platform(tmp_path: P assert exit_code == 0 payload = json.loads(output.getvalue()) assert payload["timezone"] == "Asia/Shanghai" - assert payload["scheduled_count"] == 23 + assert payload["scheduled_count"] == 27 assert payload["state"]["schema_version"] == 1 @@ -70,5 +70,8 @@ def test_python_scheduler_dry_run_never_launches_or_writes_state(tmp_path: Path) assert exit_code == 0 payload = json.loads(output.getvalue()) assert payload["mode"] == "dry-run" + assert "content.metrics.daily" in payload["due_or_started"] + assert "content.metrics.backfill" not in payload["due_or_started"] + assert "content.relogin.weekly" not in payload["due_or_started"] assert "product.market_rank" in payload["due_or_started"] assert not (settings.data_root / "state" / "scheduler" / "state.json").exists() diff --git a/tests/test_scheduler_service.py b/tests/test_scheduler_service.py index 3f2f087..679790f 100644 --- a/tests/test_scheduler_service.py +++ b/tests/test_scheduler_service.py @@ -84,9 +84,10 @@ def test_tick_launches_due_workflow_once_and_persists_slot(tmp_path: Path) -> No scheduler.tick(now) assert {item[0] for item in launcher.launched} == { - "content.marketing_report.daily", "content.summary.weekly", "product.persona.daily", + "product.ecommerce_costs.daily", + "product.tmall_baibu_apply", } assert set(started) == {item[0] for item in launcher.launched} state = json.loads((tmp_path / "state" / "scheduler" / "state.json").read_text(encoding="utf-8")) @@ -157,6 +158,44 @@ def test_multiple_daily_slots_launch_once_without_overlap(tmp_path: Path) -> Non ) +def test_content_metrics_schedule_has_initial_and_retry_slots(tmp_path: Path) -> None: + workflow = WorkflowEntry( + workflow_id="content.metrics.daily", + module="content_marketing", + trigger="scheduled", + entry="run_all.py", + source_task_name="content metrics daily", + ) + schedule = ScheduleEntry( + workflow_id=workflow.workflow_id, + kind="daily", + at="01:00", + at_times=("01:00", "05:00"), + ) + catalog = WorkflowCatalog( + timezone="Asia/Shanghai", + workflows=(workflow,), + schedules=(schedule,), + ) + launcher = FakeLauncher() + scheduler = PythonScheduler( + catalog, + tmp_path, + launcher=launcher, + misfire_grace_seconds=600, + ) + + scheduler.tick(datetime(2026, 9, 2, 1, 5, tzinfo=SHANGHAI)) + launcher.launched[0][3].return_code = 0 + scheduler.tick(datetime(2026, 9, 2, 1, 6, tzinfo=SHANGHAI)) + scheduler.tick(datetime(2026, 9, 2, 5, 5, tzinfo=SHANGHAI)) + + assert [item[1].strftime("%H:%M") for item in launcher.launched] == [ + "01:00", + "05:00", + ] + + def test_misfire_older_than_grace_is_not_launched(tmp_path: Path) -> None: catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") launcher = FakeLauncher() @@ -165,11 +204,18 @@ def test_misfire_older_than_grace_is_not_launched(tmp_path: Path) -> None: assert launcher.launched == [] -def test_all_configured_schedules_are_enabled() -> None: +def test_configured_schedules_disable_cookie_refresh_job() -> None: catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") - assert len(catalog.schedules) == 23 - assert all(schedule.enabled for schedule in catalog.schedules) + assert len(catalog.schedules) == 27 + assert [ + schedule.workflow_id for schedule in catalog.schedules if not schedule.enabled + ] == [ + "content.metrics.backfill", + "content.marketing_report.daily", + "content.relogin.weekly", + "supply.replenishment.weekly", + ] def test_dry_run_reports_due_without_writing_state(tmp_path: Path) -> None: @@ -213,7 +259,7 @@ def test_scheduler_instance_lock_rejects_second_owner(tmp_path: Path) -> None: assert not lock_path.exists() -def test_schedule_business_date_offset_uses_previous_day(tmp_path: Path) -> None: +def test_schedule_business_date_offset_uses_two_days_before(tmp_path: Path) -> None: catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") launcher = FakeLauncher() scheduler = PythonScheduler( @@ -223,14 +269,14 @@ def test_schedule_business_date_offset_uses_previous_day(tmp_path: Path) -> None misfire_grace_seconds=600, ) - scheduler.tick(datetime(2026, 8, 1, 16, 5, tzinfo=SHANGHAI)) + scheduler.tick(datetime(2026, 8, 1, 9, 5, tzinfo=SHANGHAI)) launched = next( item for item in launcher.launched if item[0] == "shop.jd_self_operated.daily" ) assert launched[1].date() == date(2026, 8, 1) - assert launched[2] == date(2026, 7, 31) + assert launched[2] == date(2026, 7, 30) def test_product_daily_schedule_passes_previous_business_day(tmp_path: Path) -> None: @@ -284,4 +330,41 @@ def test_subprocess_launcher_passes_explicit_business_date_and_execute( "2026-07-31", "--execute", ] + assert captured["kwargs"]["env"]["GYXX_SCHEDULE_SLOT"] == "2026-08-01T10:00:00+08:00" + assert captured["kwargs"]["env"]["GYXX_SCHEDULE_TIME"] == "10:00" assert "--scheduled" not in captured["argv"] + + +def test_subprocess_launcher_selects_tmall_no_hyperlink_scope( + monkeypatch, + tmp_path: Path, +) -> None: + captured: dict[str, object] = {} + + class Process: + pid = 9091 + + def poll(self): + return None + + def fake_popen(argv, **kwargs): + captured["argv"] = argv + return Process() + + monkeypatch.setattr("gyxx_flow.scheduler_service.subprocess.Popen", fake_popen) + launcher = SubprocessWorkflowLauncher(tmp_path, tmp_path / "var") + process = launcher.launch( + "product.tmall_baibu_apply", + datetime(2026, 8, 31, 18, 0, tzinfo=SHANGHAI), + date(2026, 8, 31), + ) + launcher.close(process) + + assert captured["argv"][-6:] == [ + "product.tmall_baibu_apply", + "--date", + "2026-08-31", + "--tmall-baibu-import-mode", + "without_hyperlinks", + "--execute", + ] diff --git a/tests/test_scrapling_adapter.py b/tests/test_scrapling_adapter.py new file mode 100644 index 0000000..47b9e0e --- /dev/null +++ b/tests/test_scrapling_adapter.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +import gyxx_flow.adapters.scrapling as scrapling_adapter +from gyxx_flow.adapters.scrapling import ( + BrowserTimeoutError, + ScraplingBrowser, + is_browser_timeout_error, +) + + +class _FakeContext: + def __init__(self, *, browser: _FakeBrowser | None = None) -> None: + self.browser = browser + self.close_count = 0 + self.added_cookies: list[list[dict[str, Any]]] = [] + self.restored_states: list[dict[str, Any]] = [] + + def close(self) -> None: + self.close_count += 1 + + def add_cookies(self, cookies: list[dict[str, Any]]) -> None: + self.added_cookies.append(cookies) + + def set_storage_state(self, state: dict[str, Any]) -> None: + self.restored_states.append(state) + + def cookies(self) -> list[dict[str, Any]]: + return [{"name": "saved", "value": "cookie"}] + + def storage_state(self) -> dict[str, Any]: + return {"cookies": self.cookies(), "origins": []} + + +class _ClosedOnExportContext(_FakeContext): + def cookies(self) -> list[dict[str, Any]]: + raise RuntimeError( + "BrowserContext.cookies: Target page, context or browser has been closed" + ) + + +class _FakeBrowser: + def __init__(self, contexts: list[_FakeContext] | None = None) -> None: + self.contexts = contexts or [] + self.close_count = 0 + + def close(self) -> None: + self.close_count += 1 + + +class _FakeSession: + def __init__( + self, + *, + context: _FakeContext, + browser: _FakeBrowser | None = None, + page: object | None = None, + response: object | None = None, + ) -> None: + self.context = context + self.browser = browser + self.page = page or object() + self.response = response or object() + self.start_count = 0 + self.close_count = 0 + self.fetch_calls: list[tuple[str, dict[str, Any]]] = [] + + def start(self) -> None: + self.start_count += 1 + + def close(self) -> None: + self.close_count += 1 + if self.context is not None: + self.context.close() + self.context = None + if self.browser is not None: + self.browser.close() + self.browser = None + + def fetch(self, url: str, **options: Any) -> object: + self.fetch_calls.append((url, options)) + options["page_action"](self.page) + return self.response + + +def _install_session_factory( + monkeypatch: pytest.MonkeyPatch, + name: str, + session: _FakeSession, +) -> list[dict[str, Any]]: + constructor_calls: list[dict[str, Any]] = [] + + def factory(**options: Any) -> _FakeSession: + constructor_calls.append(options) + return session + + monkeypatch.setattr(scrapling_adapter, name, factory) + return constructor_calls + + +def test_dynamic_session_supports_persistent_profile_and_fetch_result( + monkeypatch: pytest.MonkeyPatch, +) -> None: + browser = _FakeBrowser() + context = _FakeContext(browser=browser) + page = object() + response = object() + session = _FakeSession(context=context, page=page, response=response) + calls = _install_session_factory(monkeypatch, "DynamicSession", session) + + with ScraplingBrowser(user_data_dir="profile-a") as client: + actual_response, result = client.run_fetch_action( + "https://example.test", + lambda actual_page: (actual_page, "done"), + wait=0, + ) + + assert client.session is session + assert client.browser is browser + assert client.context is context + assert client.page is page + assert actual_response is response + assert result == (page, "done") + + assert calls == [{"user_data_dir": "profile-a", "retries": 1}] + assert session.start_count == 1 + assert session.close_count == 1 + assert context.close_count == 1 + assert session.fetch_calls[0][0] == "https://example.test" + assert session.fetch_calls[0][1]["wait"] == 0 + + +def test_stealthy_session_is_selected(monkeypatch: pytest.MonkeyPatch) -> None: + context = _FakeContext() + session = _FakeSession(context=context) + calls = _install_session_factory(monkeypatch, "StealthySession", session) + + browser = ScraplingBrowser(stealthy=True, user_data_dir="profile-b").start() + browser.close() + + assert calls == [{"user_data_dir": "profile-b", "retries": 1}] + assert session.start_count == 1 + + +def test_run_fetch_action_propagates_scrapling_swallowed_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + context = _FakeContext() + session = _FakeSession(context=context) + _install_session_factory(monkeypatch, "DynamicSession", session) + expected = ValueError("action failed") + + with ScraplingBrowser() as client: + with pytest.raises(ValueError) as captured: + client.run_fetch_action( + "https://example.test", + lambda _page: (_ for _ in ()).throw(expected), + ) + + assert captured.value is expected + + +def test_context_manager_preserves_action_error_when_state_export_target_closed( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + context = _ClosedOnExportContext() + session = _FakeSession(context=context) + _install_session_factory(monkeypatch, "DynamicSession", session) + expected = ValueError("action failed") + + with pytest.raises(ValueError) as captured: + with ScraplingBrowser(cookie_file=tmp_path / "cookies.json"): + raise expected + + assert captured.value is expected + assert session.close_count == 1 + + +def test_explicit_close_ignores_target_closed_state_export( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + context = _ClosedOnExportContext() + session = _FakeSession(context=context) + _install_session_factory(monkeypatch, "DynamicSession", session) + + client = ScraplingBrowser(cookie_file=tmp_path / "cookies.json").start() + client.close() + + assert session.close_count == 1 + + +def test_cdp_reuses_only_existing_context_and_preserves_browser( + monkeypatch: pytest.MonkeyPatch, +) -> None: + existing_context = _FakeContext() + created_context = _FakeContext() + remote_browser = _FakeBrowser([existing_context, created_context]) + session = _FakeSession(context=created_context, browser=remote_browser) + _install_session_factory(monkeypatch, "DynamicSession", session) + + client = ScraplingBrowser( + cdp_url="http://127.0.0.1:9222", + reuse_existing_cdp_context=True, + ).start() + + assert created_context.close_count == 1 + assert client.browser is remote_browser + assert client.context is existing_context + assert session.context is existing_context + + client.close() + + assert session.close_count == 1 + assert existing_context.close_count == 0 + assert remote_browser.close_count == 0 + + +@pytest.mark.parametrize("existing_context_count", [0, 2]) +def test_cdp_reuse_requires_exactly_one_existing_context( + monkeypatch: pytest.MonkeyPatch, + existing_context_count: int, +) -> None: + existing_contexts = [_FakeContext() for _ in range(existing_context_count)] + created_context = _FakeContext() + remote_browser = _FakeBrowser([*existing_contexts, created_context]) + session = _FakeSession(context=created_context, browser=remote_browser) + _install_session_factory(monkeypatch, "DynamicSession", session) + + with pytest.raises(RuntimeError, match="exactly one existing context"): + ScraplingBrowser( + cdp_url="http://127.0.0.1:9222", + reuse_existing_cdp_context=True, + ).start() + + assert created_context.close_count == 1 + assert remote_browser.close_count == 0 + assert all(context.close_count == 0 for context in existing_contexts) + + +def test_browser_timeout_detection_uses_engine_type_metadata() -> None: + playwright_timeout = type( + "TimeoutError", + (Exception,), + {"__module__": "playwright._impl._errors"}, + ) + patchright_timeout = type( + "TimeoutError", + (Exception,), + {"__module__": "patchright._impl._errors"}, + ) + unrelated_timeout = type( + "TimeoutError", + (Exception,), + {"__module__": "unrelated.browser"}, + ) + + assert is_browser_timeout_error(BrowserTimeoutError()) + assert is_browser_timeout_error(playwright_timeout()) + assert is_browser_timeout_error(patchright_timeout()) + assert not is_browser_timeout_error(TimeoutError()) + assert not is_browser_timeout_error(unrelated_timeout()) + + +def test_cdp_reuse_requires_cdp_url() -> None: + with pytest.raises(ValueError, match="requires cdp_url"): + ScraplingBrowser(reuse_existing_cdp_context=True) + + +def test_bound_cookie_and_storage_state_are_restored_and_persisted( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + cookie_file = tmp_path / "cookies.json" + storage_file = tmp_path / "storage.json" + cookie_file.write_text( + json.dumps([{"name": "loaded", "value": "cookie"}]), + encoding="utf-8", + ) + storage_file.write_text( + json.dumps({"cookies": [], "origins": [{"origin": "https://example.test"}]}), + encoding="utf-8", + ) + context = _FakeContext() + session = _FakeSession(context=context) + _install_session_factory(monkeypatch, "DynamicSession", session) + + with ScraplingBrowser( + cookie_file=cookie_file, + storage_state_file=storage_file, + ): + assert context.added_cookies == [ + [{"name": "loaded", "value": "cookie"}] + ] + assert context.restored_states == [ + {"cookies": [], "origins": [{"origin": "https://example.test"}]} + ] + + assert json.loads(cookie_file.read_text(encoding="utf-8")) == [ + {"name": "saved", "value": "cookie"} + ] + assert json.loads(storage_file.read_text(encoding="utf-8")) == { + "cookies": [{"name": "saved", "value": "cookie"}], + "origins": [], + } + + +def test_borrowed_cdp_context_does_not_import_or_export_bound_state( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + cookie_file = tmp_path / "cookies.json" + cookie_file.write_text("[]", encoding="utf-8") + existing_context = _FakeContext() + created_context = _FakeContext() + browser = _FakeBrowser([existing_context, created_context]) + session = _FakeSession(context=created_context, browser=browser) + _install_session_factory(monkeypatch, "DynamicSession", session) + + with ScraplingBrowser( + cdp_url="http://127.0.0.1:9222", + reuse_existing_cdp_context=True, + cookie_file=cookie_file, + ): + pass + + assert cookie_file.read_text(encoding="utf-8") == "[]" + assert existing_context.added_cookies == [] diff --git a/tests/test_script_catalog.py b/tests/test_script_catalog.py index f2cf82f..2d6b33c 100644 --- a/tests/test_script_catalog.py +++ b/tests/test_script_catalog.py @@ -80,10 +80,10 @@ def test_default_registry_covers_every_executable_workflow_entry() -> None: for entry in entries: workflow_entries.add(f"{workflow['module']}:{entry}") - assert scheduled_workflows == 23 - assert manual_workflows == 0 - assert len(workflow_entries) == 29 - assert len(catalog.scripts) == 33 + assert scheduled_workflows == 27 + assert manual_workflows == 2 + assert len(workflow_entries) == 44 + assert len(catalog.scripts) == 49 assert workflow_entries < set(catalog.script_ids) assert { "content.mapping.rebuild", @@ -91,7 +91,10 @@ def test_default_registry_covers_every_executable_workflow_entry() -> None: "shop.douyin_price_appeal", "shop.jd_self_operated.collect_brand", "product.backfill.run", + "product.erp_all_shop_daily", "product.review.orchestrate", + "product.video_upload.run", + "product.jd_video_upload.run", "supply.workflow.run", } < set(catalog.command_ids) assert {item.module for item in catalog.scripts} == { @@ -118,14 +121,33 @@ def test_default_registry_covers_every_executable_workflow_entry() -> None: "--to", "{business_date}", ) + assert catalog.get("product.erp_all_shop_daily").default_args == ( + "--from", + "{business_date}", + "--to", + "{business_date}", + ) assert catalog.get("product.review.orchestrate").default_args == ( "--target-date", "{business_date}", ) + assert catalog.get("product.import.tmall_ads").default_args == ( + "--date", + "{business_date}", + "--execute", + ) assert catalog.get("supply.workflow.run").default_args == ( "mcp-run", "purchase-order-update", ) + assert ( + catalog.get("content.marketing_report.generate").notification_workflow_id + == "content.marketing_report.daily" + ) + assert ( + catalog.get("content.login.refresh").notification_workflow_id + == "content.relogin.weekly" + ) @pytest.mark.parametrize( @@ -168,6 +190,16 @@ def test_default_registry_covers_every_executable_workflow_entry() -> None: }, "invalid command default_args", ), + ( + { + "id": "content.bad.notification", + "module": "content_marketing", + "entry": "job.py", + "kind": "python", + "notification_workflow_id": "content.not-a-capability", + }, + "invalid command notification_workflow_id", + ), ), ) def test_catalog_rejects_unsafe_or_invalid_declarations( @@ -217,7 +249,7 @@ def test_cli_lists_and_dry_runs_registered_commands(tmp_path: Path) -> None: assert main(["scripts", "list", "--json"], settings=settings, stdout=output) == 0 rows = json.loads(output.getvalue()) - assert len(rows) == 33 + assert len(rows) == 49 assert {row["module"] for row in rows} == { "content_marketing", "product_commerce", @@ -232,6 +264,7 @@ def test_cli_lists_and_dry_runs_registered_commands(tmp_path: Path) -> None: "entry", "kind", "default_args", + "notification_workflow_id", } <= rows[0].keys() output = io.StringIO() diff --git a/tests/test_server_scheduler_deployment.py b/tests/test_server_scheduler_deployment.py index 01f447a..f1c0976 100644 --- a/tests/test_server_scheduler_deployment.py +++ b/tests/test_server_scheduler_deployment.py @@ -47,7 +47,7 @@ def test_deployment_documents_one_scheduler_and_external_data_root() -> None: assert "GYXX_DATA_ROOT" in guide assert "/var/lib/gyxx-flow" in guide assert "Windows Task Scheduler" in guide - assert len(payload["schedules"]) == 23 + assert len(payload["schedules"]) == 27 assert sum(item.get("enabled", True) for item in payload["schedules"]) == 23 diff --git a/tests/test_source_migration_acceptance.py b/tests/test_source_migration_acceptance.py index e55b341..7bb814f 100644 --- a/tests/test_source_migration_acceptance.py +++ b/tests/test_source_migration_acceptance.py @@ -57,6 +57,6 @@ def test_every_workflow_and_manual_script_resolves_inside_new_project() -> None: assert target.is_relative_to(PROJECT_ROOT) commands = ScriptCatalog.load_default() - assert len(commands.scripts) == 33 + assert len(commands.scripts) == 41 assert len(commands.command_ids) == len(set(commands.command_ids)) assert all(command.path.is_relative_to(PROJECT_ROOT) for command in commands.scripts) diff --git a/tests/test_supply_acceptance_isolation.py b/tests/test_supply_acceptance_isolation.py index 3215a85..4a649de 100644 --- a/tests/test_supply_acceptance_isolation.py +++ b/tests/test_supply_acceptance_isolation.py @@ -7,6 +7,7 @@ from pathlib import Path import pytest from gyxx_flow.adapters import WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID +from gyxx_flow.notification_routing import ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID @pytest.fixture @@ -73,23 +74,71 @@ def test_replenishment_bitable_run_is_physically_skipped( def test_supply_card_sender_forces_only_wang_yunlong( acceptance_environment: Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, ) -> None: module = importlib.import_module( "gyxx_flow.modules.supply_chain.orchestrator.scripts." "send_card_notification" ) + monkeypatch.setattr(module, "STATE_ROOT", tmp_path) + run_id = "acceptance-run" + snapshot_path = module._notification_snapshot_path("replenishment", run_id) + snapshot_path.parent.mkdir(parents=True) + snapshot_path.write_text( + json.dumps( + { + "schema_version": 1, + "workflow": "replenishment", + "run_id": run_id, + "enabled": True, + "recipients": [ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID], + "acceptance": True, + } + ), + encoding="utf-8", + ) sent: list[str] = [] - monkeypatch.setattr(module, "_get_tenant_token", lambda: "test-token") monkeypatch.setattr( module, "_send_card", - lambda _token, open_id, _card: sent.append(open_id) or "mid-1", + lambda open_id, _card, _key: sent.append(open_id) or "om_acceptance1", + ) + monkeypatch.setattr( + module.pg_writer, + "mark_notification_sent", + lambda *_args, **_kwargs: None, + ) + monkeypatch.setattr( + module.pg_writer, + "notification_already_sent", + lambda *_args, **_kwargs: True, ) - result = module.run(["ou_other", "oc_group"], {"elements": []}) + with pytest.raises(RuntimeError, match="snapshot mismatch"): + module.run( + ["ou_other123"], + {"elements": []}, + workflow="replenishment", + run_id=run_id, + idempotency_key="replenishment:acceptance-run", + ) - assert sent == [WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID] - assert result == {"message_ids": ["mid-1"], "errors": []} + result = module.run( + [ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID], + {"elements": []}, + workflow="replenishment", + run_id=run_id, + idempotency_key="replenishment:acceptance-run", + ) + + assert sent == [ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID] + assert result == { + "message_ids": ["om_acceptance1"], + "recipient_message_ids": { + ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID: "om_acceptance1", + }, + "errors": [], + } def test_supply_config_uses_the_two_local_hermes_api_servers( diff --git a/tests/test_supply_chain_module.py b/tests/test_supply_chain_module.py index 03ac446..5c065e0 100644 --- a/tests/test_supply_chain_module.py +++ b/tests/test_supply_chain_module.py @@ -20,7 +20,7 @@ def test_supply_module_registers_only_scheduled_workflows() -> None: ) assert tuple(item.workflow_id for item in module.workflow_definitions()) == SUPPLY_WORKFLOW_IDS - assert len(SUPPLY_WORKFLOW_IDS) == 3 + assert len(SUPPLY_WORKFLOW_IDS) == 4 def test_supply_workflows_use_project_owned_module_commands() -> None: diff --git a/tests/test_supply_gateway_acceptance_preflight.py b/tests/test_supply_gateway_acceptance_preflight.py index 76f21f4..7093c0c 100644 --- a/tests/test_supply_gateway_acceptance_preflight.py +++ b/tests/test_supply_gateway_acceptance_preflight.py @@ -11,7 +11,10 @@ from types import SimpleNamespace import pytest -from gyxx_flow.adapters import COOKIE_SKIP_EXIT_CODE +from gyxx_flow.adapters import ( + COOKIE_SKIP_EXIT_CODE, + WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID, +) from gyxx_flow.modules.supply_chain import run as supply_run from gyxx_flow.modules.supply_chain.orchestrator import config as supply_config from gyxx_flow.modules.supply_chain.orchestrator import ( @@ -19,6 +22,9 @@ from gyxx_flow.modules.supply_chain.orchestrator import ( pg_writer, runner, ) +from gyxx_flow.modules.supply_chain.orchestrator.scripts import ( + send_card_notification, +) PROJECT_ROOT = Path(__file__).resolve().parents[1] SUPPLY_WRAPPERS = ( @@ -429,6 +435,11 @@ def test_purchase_order_update_block_precedes_cookie_preflight( "_send_collector_heartbeat", _forbidden("blocked ERP workflow must not send a heartbeat"), ) + monkeypatch.setattr( + mcp_workflow, + "_send_purchase_order_update_notification", + _forbidden("blocked ERP workflow must not notify a user or group"), + ) result = mcp_workflow.run_mcp_workflow( "purchase-order-update", @@ -490,16 +501,832 @@ def test_notification_receipt_rejects_empty_message_ids() -> None: pg_writer.mark_notification_sent("replenishment", "run-1", []) +def test_notification_disabled_callback_accepts_an_explicit_empty_receipt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + statements: list[tuple[str, object]] = [] + + class FakeConnection: + def cursor(self): + return self + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def execute(self, sql, params): + statements.append((sql, params)) + + def commit(self): + return None + + def close(self): + return None + + monkeypatch.setattr(pg_writer, "_conn", FakeConnection) + + pg_writer.mark_notification_sent( + "replenishment", + "run-disabled", + [], + notification_disabled=True, + expected_recipients=[], + ) + + assert len(statements) == 1 + sql, params = statements[0] + assert "notification_disabled" in sql + assert "notification_disabled_at" in sql + assert "notification_message_ids" not in sql + assert params == ("replenishment", "run-disabled") + + +def test_notification_receipt_requires_complete_explicit_recipient_mapping( + monkeypatch: pytest.MonkeyPatch, +) -> None: + statements: list[tuple[str, object]] = [] + + class FakeConnection: + def cursor(self): + return self + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def execute(self, sql, params): + statements.append((sql, params)) + + def commit(self): + return None + + def close(self): + return None + + monkeypatch.setattr(pg_writer, "_conn", FakeConnection) + recipients = ["ou_first", "ou_second", "ou_first"] + + with pytest.raises(ValueError, match="recipient_message_ids"): + pg_writer.mark_notification_sent( + "replenishment", + "run-partial", + message_ids=["mid-first"], + expected_recipients=recipients, + recipient_message_ids={"ou_first": "mid-first"}, + ) + + assert statements == [] + + pg_writer.mark_notification_sent( + "replenishment", + "run-complete", + expected_recipients=recipients, + recipient_message_ids={ + "ou_first": "mid-first", + "ou_second": "mid-second", + }, + ) + + assert len(statements) == 3 + run_statement = next( + (sql, params) + for sql, params in statements + if "UPDATE workflow_runs" in sql + ) + assert "notification_recipient_message_ids" in run_statement[0] + assert json.loads(run_statement[1][1]) == { + "ou_first": "mid-first", + "ou_second": "mid-second", + } + assert "'notification_disabled', false" in run_statement[0] + + +def test_reenabled_notification_writes_receipts_without_disabled_overwrite( + monkeypatch: pytest.MonkeyPatch, +) -> None: + statements: list[tuple[str, object]] = [] + + class FakeConnection: + def cursor(self): + return self + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def execute(self, sql, params): + statements.append((sql, params)) + + def commit(self): + return None + + def close(self): + return None + + monkeypatch.setattr(pg_writer, "_conn", FakeConnection) + + pg_writer.mark_notification_sent( + "purchase-confirmation", + "same-run", + notification_disabled=True, + expected_recipients=[], + ) + pg_writer.mark_notification_sent( + "purchase-confirmation", + "same-run", + expected_recipients=["ou_owner"], + recipient_message_ids={"ou_owner": "mid-owner"}, + ) + + disabled_sql = statements[0][0] + assert "notification_message_ids" not in disabled_sql + enabled_run_sql = next( + sql + for sql, _params in statements[1:] + if "UPDATE workflow_runs" in sql + ) + assert "notification_recipient_message_ids" in enabled_run_sql + assert "'notification_disabled', false" in enabled_run_sql + + +def test_supply_route_resolution_does_not_recurse_and_keeps_all_recipients( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str, tuple[str, ...]]] = [] + monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False) + + def fake_resolve(workflow_id, defaults): + calls.append((workflow_id, tuple(defaults))) + return SimpleNamespace(enabled=True, open_ids=("ou_first", "ou_second")) + + monkeypatch.setattr(mcp_workflow, "resolve_notification_route", fake_resolve) + + enabled, recipients = mcp_workflow._resolve_business_notification_route( + "purchase-confirmation" + ) + + assert enabled is True + assert recipients == ("ou_first", "ou_second") + assert calls == [ + ( + "supply.purchase_confirmation.daily", + mcp_workflow.PURCHASE_CONFIRMATION_TARGETS, + ) + ] + + +def test_supply_sender_hard_rejects_disabled_or_drifted_route_snapshot( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr(send_card_notification, "STATE_ROOT", tmp_path) + + disabled_run = "disabled-run" + disabled_path = send_card_notification._notification_snapshot_path( + "replenishment", + disabled_run, + ) + disabled_path.parent.mkdir(parents=True) + disabled_path.write_text( + json.dumps( + { + "schema_version": 1, + "workflow": "replenishment", + "run_id": disabled_run, + "enabled": False, + "recipients": [], + "acceptance": False, + } + ), + encoding="utf-8", + ) + + with pytest.raises(RuntimeError, match="route is disabled"): + send_card_notification._resolved_receivers( + "replenishment", + disabled_run, + ["ou_launch123"], + ) + + enabled_run = "enabled-run" + enabled_path = send_card_notification._notification_snapshot_path( + "replenishment", + enabled_run, + ) + enabled_path.write_text( + json.dumps( + { + "schema_version": 1, + "workflow": "replenishment", + "run_id": enabled_run, + "enabled": True, + "recipients": ["ou_launch123"], + "acceptance": False, + } + ), + encoding="utf-8", + ) + with pytest.raises(RuntimeError, match="snapshot mismatch"): + send_card_notification._resolved_receivers( + "replenishment", + enabled_run, + ["ou_arbitrary123"], + ) + + +def test_supply_sender_keeps_exact_launch_snapshot_and_partial_delivery_mapping( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr(send_card_notification, "STATE_ROOT", tmp_path) + run_id = "run-1" + launch_recipients = ["ou_first123", "ou_middle123", "ou_last1234"] + snapshot_path = send_card_notification._notification_snapshot_path( + "replenishment", + run_id, + ) + snapshot_path.parent.mkdir(parents=True) + snapshot_path.write_text( + json.dumps( + { + "schema_version": 1, + "workflow": "replenishment", + "run_id": run_id, + "enabled": True, + "recipients": launch_recipients, + "acceptance": False, + } + ), + encoding="utf-8", + ) + def fake_send(open_id, _card, _key): + if open_id == "ou_middle123": + raise RuntimeError("middle failed") + return f"om_{open_id.removeprefix('ou_')}" + + monkeypatch.setattr(send_card_notification, "_send_card", fake_send) + monkeypatch.setattr(send_card_notification.pg_writer, "is_available", lambda: True) + + result = send_card_notification.run( + launch_recipients, + {"elements": []}, + workflow="replenishment", + run_id=run_id, + idempotency_key="replenishment:run-1", + ) + + assert result == { + "message_ids": ["om_first123", "om_last1234"], + "recipient_message_ids": { + "ou_first123": "om_first123", + "ou_last1234": "om_last1234", + }, + "errors": [{"open_id": "ou_middle123", "error": "middle failed"}], + } + + +@pytest.mark.parametrize( + "workflow", + ("purchase-confirmation", "replenishment-alert", "replenishment"), +) +def test_supply_sender_persists_and_verifies_complete_receipts( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + workflow: str, +) -> None: + monkeypatch.setattr(send_card_notification, "STATE_ROOT", tmp_path) + run_id = "run-complete" + recipients = ["ou_first123", "ou_second123"] + snapshot_path = send_card_notification._notification_snapshot_path( + workflow, + run_id, + ) + snapshot_path.parent.mkdir(parents=True) + snapshot_path.write_text( + json.dumps( + { + "schema_version": 1, + "workflow": workflow, + "run_id": run_id, + "enabled": True, + "recipients": recipients, + "acceptance": False, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr( + send_card_notification, + "_send_card", + lambda open_id, _card, _key: f"om_{open_id.removeprefix('ou_')}", + ) + monkeypatch.setattr(send_card_notification.pg_writer, "is_available", lambda: True) + persisted: list[tuple[tuple[object, ...], dict[str, object]]] = [] + monkeypatch.setattr( + send_card_notification.pg_writer, + "mark_notification_sent", + lambda *args, **kwargs: persisted.append((args, kwargs)), + ) + monkeypatch.setattr( + send_card_notification.pg_writer, + "notification_already_sent", + lambda checked_workflow, checked_run_id, *, expected_recipients: ( + checked_workflow == workflow + and checked_run_id == run_id + and tuple(expected_recipients) == tuple(recipients) + ), + ) + + result = send_card_notification.run( + recipients, + {"elements": []}, + workflow=workflow, + run_id=run_id, + idempotency_key=f"{workflow}:{run_id}", + ) + + assert result["errors"] == [] + assert persisted == [ + ( + (workflow, run_id), + { + "expected_recipients": recipients, + "recipient_message_ids": { + "ou_first123": "om_first123", + "ou_second123": "om_second123", + }, + }, + ) + ] + + +def test_supply_sender_fails_closed_when_complete_receipt_is_not_readable( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr(send_card_notification, "STATE_ROOT", tmp_path) + workflow = "replenishment-alert" + run_id = "run-unverified" + recipients = ["ou_owner123"] + snapshot_path = send_card_notification._notification_snapshot_path( + workflow, + run_id, + ) + snapshot_path.parent.mkdir(parents=True) + snapshot_path.write_text( + json.dumps( + { + "schema_version": 1, + "workflow": workflow, + "run_id": run_id, + "enabled": True, + "recipients": recipients, + "acceptance": False, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr( + send_card_notification, + "_send_card", + lambda _open_id, _card, _key: "om_owner123", + ) + monkeypatch.setattr(send_card_notification.pg_writer, "is_available", lambda: True) + monkeypatch.setattr( + send_card_notification.pg_writer, + "mark_notification_sent", + lambda *_args, **_kwargs: None, + ) + monkeypatch.setattr( + send_card_notification.pg_writer, + "notification_already_sent", + lambda *_args, **_kwargs: False, + ) + + with pytest.raises(RuntimeError, match="could not be verified"): + send_card_notification.run( + recipients, + {"elements": []}, + workflow=workflow, + run_id=run_id, + idempotency_key=f"{workflow}:{run_id}", + ) + + +def test_supply_sender_refuses_send_when_receipt_store_is_unavailable( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + workflow = "purchase-confirmation" + run_id = "run-no-pg" + recipients = ["ou_owner123"] + monkeypatch.setattr(send_card_notification, "STATE_ROOT", tmp_path) + snapshot_path = send_card_notification._notification_snapshot_path(workflow, run_id) + snapshot_path.parent.mkdir(parents=True) + snapshot_path.write_text( + json.dumps({"schema_version": 1, "workflow": workflow, "run_id": run_id, "enabled": True, "recipients": recipients, "acceptance": False}), + encoding="utf-8", + ) + sent: list[str] = [] + monkeypatch.setattr(send_card_notification, "_send_card", lambda *_args: sent.append("sent")) + monkeypatch.setattr(send_card_notification.pg_writer, "is_available", lambda: False) + + with pytest.raises(RuntimeError, match="receipt store is unavailable"): + send_card_notification.run(recipients, {"elements": []}, workflow=workflow, run_id=run_id) + + assert sent == [] + + +def test_supply_acceptance_snapshot_survives_missing_wrapper_environment( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + acceptance_recipient = supply_config.FEISHU_CONFIG["apps"]["analyzer"][ + "owner_open_id" + ] + monkeypatch.setenv("GYXX_WORKFLOW_ACCEPTANCE", "1") + monkeypatch.setenv("GYXX_FEISHU_TABLE_WRITE_DISABLED", "1") + monkeypatch.setenv("GYXX_COOKIE_INVALID_SKIP", "1") + monkeypatch.setenv( + "GYXX_NOTIFICATION_RECIPIENT_OPEN_ID", + WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID, + ) + monkeypatch.setattr(mcp_workflow, "STATE_ROOT", tmp_path) + monkeypatch.setattr(send_card_notification, "STATE_ROOT", tmp_path) + monkeypatch.setattr( + mcp_workflow, + "_resolve_business_notification_route", + lambda _workflow: (True, (acceptance_recipient,)), + ) + run_id = "acceptance-run" + snapshot = mcp_workflow._load_or_create_notification_snapshot( + "replenishment", + run_id, + ) + assert snapshot == (True, (acceptance_recipient,), True) + prompt = mcp_workflow._build_analyze_prompt( + "replenishment", + notification_route=(snapshot[0], snapshot[1]), + notification_acceptance=snapshot[2], + run_id=run_id, + ) + assert "Hermes must not send any Feishu message" in prompt + assert "--acceptance-override" not in prompt + assert acceptance_recipient in prompt + + # The analyzer wrapper is a separate process and does not inherit the + # orchestrator's acceptance environment. The external run snapshot remains the + # sole trust source even if the mutable page route changes after launch. + monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False) + monkeypatch.setattr( + mcp_workflow, + "_resolve_business_notification_route", + lambda _workflow: (True, ("ou_newroute123",)), + ) + + assert mcp_workflow._load_or_create_notification_snapshot( + "replenishment", + run_id, + ) == snapshot + assert send_card_notification._resolved_receivers( + "replenishment", + run_id, + [acceptance_recipient], + ) == [acceptance_recipient] + with pytest.raises(RuntimeError, match="snapshot mismatch"): + send_card_notification._resolved_receivers( + "replenishment", + run_id, + ["ou_newroute123"], + ) + + +def test_supply_sender_uses_analyzer_bot_with_stable_business_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sends: list[dict[str, object]] = [] + + def fake_send(**kwargs): + sends.append(kwargs) + return {"message_id": f"om_{len(sends)}"} + + monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False) + monkeypatch.setattr(send_card_notification, "send_lark_bot_message", fake_send) + + send_card_notification._send_card( + "ou_first123", + {"elements": []}, + "replenishment:run-1", + ) + send_card_notification._send_card( + "ou_first123", + {"elements": []}, + "replenishment:run-1", + ) + send_card_notification._send_card( + "ou_second123", + {"elements": []}, + "replenishment:run-1", + ) + + assert sends == [ + { + "user_id": "ou_first123", + "content": {"elements": []}, + "msg_type": "interactive", + "idempotency_key": "replenishment:run-1", + }, + { + "user_id": "ou_first123", + "content": {"elements": []}, + "msg_type": "interactive", + "idempotency_key": "replenishment:run-1", + }, + { + "user_id": "ou_second123", + "content": {"elements": []}, + "msg_type": "interactive", + "idempotency_key": "replenishment:run-1", + }, + ] + + +def test_supply_heartbeat_uses_collector_bot_and_collector_open_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sends: list[dict[str, object]] = [] + + def forbidden_resolve(*_args, **_kwargs): + pytest.fail("heartbeat must not translate a collector-app open_id") + + def fake_send(**kwargs): + sends.append(kwargs) + return {"message_id": "om_heartbeat1"} + + monkeypatch.setattr( + mcp_workflow, + "resolve_notification_recipients", + forbidden_resolve, + ) + monkeypatch.setattr(mcp_workflow, "send_lark_bot_message", fake_send) + result = { + "workflow": "replenishment", + "workflow_name": "replenishment 补货建议", + "workflow_id": "run-1", + "status": "running", + "current_phase": "collecting", + "heartbeat_response": "", + } + + mcp_workflow._send_collector_heartbeat(result, phase="progress") + + assert sends == [ + { + "user_id": mcp_workflow.COLLECTOR_OWNER_OPEN_ID, + "content": mcp_workflow._build_heartbeat_card(result, phase="progress"), + "msg_type": "interactive", + "profile": mcp_workflow.COLLECTOR_FEISHU["app_id"], + "timeout": 30, + } + ] + assert "feishu_msg_id=om_heartbeat1" in result["heartbeat_response"] + + +def test_purchase_order_update_notification_uses_chat_id_and_user_id( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + result_file = tmp_path / "update_result.json" + result_file.write_text( + json.dumps( + { + "total_updated": 1, + "target_date": "2026-08-18", + "sku_list": ["SKU-1"], + "updated_records": [ + {"sku": "SKU-1", "name": "旅行箱", "spec": "20寸黑色"} + ], + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + sends: list[dict[str, object]] = [] + + def fake_resolve(recipients, *, app_profile=None): + assert tuple(recipients) == (mcp_workflow.ANALYZER_OWNER_OPEN_ID,) + assert app_profile == "hermes-analyzer" + return ("ou_analyzerowner123",) + + def fake_send(**kwargs): + sends.append(kwargs) + return {"message_id": f"om_delivery{len(sends)}"} + + monkeypatch.setattr(mcp_workflow, "resolve_notification_recipients", fake_resolve) + monkeypatch.setattr(mcp_workflow, "send_lark_bot_message", fake_send) + + receipt = mcp_workflow._send_purchase_order_update_notification( + [str(result_file)], + "purchase-order-update_20260810", + ) + + assert sends[0]["chat_id"] == mcp_workflow.SHENDAN_GROUP_CHAT_ID + assert "user_id" not in sends[0] + assert sends[1]["user_id"] == "ou_analyzerowner123" + assert "chat_id" not in sends[1] + assert all(send["profile"] == "hermes-analyzer" for send in sends) + assert all(send["msg_type"] == "interactive" for send in sends) + assert { + send["idempotency_key"] for send in sends + } == {"purchase-order-update:purchase-order-update_20260810"} + card_text = json.dumps(sends[0]["content"], ensure_ascii=False) + assert all( + value in card_text + for value in ("SKU-1", "旅行箱", "20寸黑色", "2026-08-18", "1") + ) + assert receipt == { + "message_ids": ["om_delivery1", "om_delivery2"], + "recipient_message_ids": { + mcp_workflow.SHENDAN_GROUP_CHAT_ID: "om_delivery1", + "ou_analyzerowner123": "om_delivery2", + }, + "errors": [], + } + + +def test_supply_analyzer_contract_has_no_feishu_message_plugin() -> None: + configured_steps = [ + step + for workflow in supply_config.WORKFLOWS.values() + for step in workflow.get("analyzer_scripts", []) + ] + + assert "plugin:feishu.im.send_message" not in configured_steps + assert "adapter:send_lark_bot_message" in configured_steps + + +@pytest.mark.parametrize( + "workflow_name", + ["purchase-confirmation", "replenishment-alert", "replenishment"], +) +def test_supply_disabled_route_forbids_send_and_requires_empty_callback( + workflow_name: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False) + monkeypatch.setattr( + mcp_workflow, + "_resolve_business_notification_route", + lambda _workflow_name: (False, ()), + ) + + prompt = mcp_workflow._build_analyze_prompt(workflow_name) + + assert "动态通知配置已禁用" in prompt + assert "不得向任何用户或群聊发送业务消息" in prompt + assert "message_ids=[]" in prompt + assert "notification_disabled=True" in prompt + assert "Dynamic notification routing is disabled" in prompt + assert "No final business notification is required or allowed" in prompt + assert "must be sent as exactly ONE" not in prompt + + +@pytest.mark.parametrize( + "workflow_name", + ["purchase-confirmation", "replenishment-alert", "replenishment"], +) +def test_supply_enabled_prompt_forbids_llm_delivery_and_keeps_route_context( + workflow_name: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False) + monkeypatch.setattr( + mcp_workflow, + "_resolve_business_notification_route", + _forbidden("prompt must preserve the route snapshot resolved at launch"), + ) + + prompt = mcp_workflow._build_analyze_prompt( + workflow_name, + notification_route=(True, ("ou_first", "ou_second")), + run_id="run-20260807", + ) + + assert "Hermes must not send any Feishu message" in prompt + assert "Python orchestrator alone delivers the final business card" in prompt + assert f"--workflow {workflow_name}" not in prompt + assert f"p.mark_notification_sent('{workflow_name}','run-20260807'" not in prompt + + def test_notification_receipt_requires_persisted_message_id( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(pg_writer, "notification_already_sent", lambda *_args: False) + monkeypatch.setattr( + pg_writer, + "notification_already_sent", + lambda *_args, **_kwargs: False, + ) assert not mcp_workflow._notification_receipt_exists("replenishment", "run-1") - monkeypatch.setattr(pg_writer, "notification_already_sent", lambda *_args: True) + monkeypatch.setattr( + pg_writer, + "notification_already_sent", + lambda *_args, **_kwargs: True, + ) assert mcp_workflow._notification_receipt_exists("replenishment", "run-1") +def test_notification_receipt_check_forwards_current_route_recipients( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + def fake_already_sent(workflow, run_id, *, expected_recipients=None): + captured.update( + workflow=workflow, + run_id=run_id, + expected_recipients=expected_recipients, + ) + return True + + monkeypatch.setattr(pg_writer, "notification_already_sent", fake_already_sent) + + assert mcp_workflow._notification_receipt_exists( + "replenishment", + "run-routed", + ("ou_first", "ou_second"), + ) + assert captured == { + "workflow": "replenishment", + "run_id": "run-routed", + "expected_recipients": ("ou_first", "ou_second"), + } + + +@pytest.mark.parametrize( + ("mapping", "recipients", "expected"), + [ + ( + {"ou_first": "mid-first"}, + ("ou_first", "ou_second"), + False, + ), + ( + {"ou_first": "mid-first", "ou_second": "mid-second"}, + ("ou_first", "ou_second"), + True, + ), + ( + {"ou_first": "mid-first", "ou_second": "mid-second"}, + ("ou_new",), + False, + ), + ], +) +def test_notification_already_sent_requires_current_recipient_coverage( + mapping: dict[str, str], + recipients: tuple[str, ...], + expected: bool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeCursor: + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def execute(self, _sql, _params): + return None + + def fetchone(self): + return ({"notification_recipient_message_ids": mapping},) + + class FakeConnection: + def cursor(self): + return FakeCursor() + + def close(self): + return None + + monkeypatch.setattr(pg_writer, "_conn", FakeConnection) + + assert ( + pg_writer.notification_already_sent( + "replenishment", + "run-multi", + expected_recipients=recipients, + ) + is expected + ) + + def test_notification_receipt_falls_back_to_zero_row_workflow_batch( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -621,6 +1448,127 @@ def test_batch_exit_code_uses_failure_then_block_then_cookie_precedence() -> Non ) == 1 +def test_idempotent_replay_uses_a_distinct_skip_exit_code() -> None: + assert runner.result_exit_code( + { + "workflow": "purchase-confirmation", + "status": "completed", + "idempotent_replay": True, + } + ) == runner.IDEMPOTENT_REPLAY_EXIT_CODE + + +@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "on"]) +def test_force_refresh_environment_values( + value: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("GYXX_FORCE_REFRESH", value) + assert mcp_workflow._force_refresh_requested() is True + + +def test_force_refresh_is_opt_in(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("GYXX_FORCE_REFRESH", raising=False) + assert mcp_workflow._force_refresh_requested() is False + + +def test_force_refresh_recollects_and_notifies_deterministically_instead_of_reusing( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False) + monkeypatch.setenv("GYXX_FORCE_REFRESH", "true") + collected_file = tmp_path / "purchase-confirmation.csv" + collected_file.write_text("sku\n1001\n", encoding="utf-8") + collection_calls: list[str] = [] + notification_calls: list[tuple[str, str, list[str]]] = [] + receipt_calls: list[tuple[str, str, tuple[str, ...] | None]] = [] + + monkeypatch.setattr(mcp_workflow, "_acquire_workflow_lock", lambda *_args: (True, "")) + monkeypatch.setattr(mcp_workflow, "_release_workflow_lock", lambda *_args: None) + monkeypatch.setattr(mcp_workflow, "_acquire_collection_lock", lambda *_args: (True, "")) + monkeypatch.setattr(mcp_workflow, "_release_collection_lock", lambda *_args: None) + monkeypatch.setattr( + mcp_workflow, + "_reusable_collected_files", + _forbidden("force refresh must not inspect reusable collection files"), + ) + monkeypatch.setattr(mcp_workflow, "_clear_shared_outputs", lambda *_args: []) + + def collect(workflow_name: str, workflow_id: str) -> tuple[bool, str]: + collection_calls.append(f"{workflow_name}:{workflow_id}") + return True, "fresh collection" + + monkeypatch.setattr(mcp_workflow, "_run_local_collection_script", collect) + monkeypatch.setattr( + mcp_workflow, + "_ensure_shared_outputs", + lambda *_args: [str(collected_file)], + ) + monkeypatch.setattr(mcp_workflow, "_validate_run_manifest", lambda *_args: (True, "")) + monkeypatch.setattr(mcp_workflow, "_extract_pending_run_id", lambda *_args: None) + monkeypatch.setattr( + mcp_workflow, + "_load_or_create_notification_snapshot", + lambda *_args: (True, ("ou_test",), False), + ) + + def receipt_exists( + workflow_name: str, + run_id: str, + recipients: tuple[str, ...] | None = None, + ) -> bool: + receipt_calls.append((workflow_name, run_id, recipients)) + return True + + monkeypatch.setattr(mcp_workflow, "_notification_receipt_exists", receipt_exists) + + def send_purchase_confirmation(workflow_name, workflow_run_id, collected_files): + notification_calls.append((workflow_name, workflow_run_id, list(collected_files))) + return { + "message_ids": ["om_test"], + "recipient_message_ids": {"ou_test": "om_test"}, + "errors": [], + } + + monkeypatch.setattr( + mcp_workflow, + "_send_purchase_confirmation_notification", + send_purchase_confirmation, + ) + monkeypatch.setattr(mcp_workflow, "_send_collector_heartbeat", lambda *_args, **_kwargs: "") + monkeypatch.setattr(mcp_workflow, "_start_progress_heartbeat_worker", lambda *_args: None) + monkeypatch.setattr(mcp_workflow, "_close_workflow_browser", lambda *_args: []) + monkeypatch.setattr(mcp_workflow, "_cleanup_workflow_artifacts", lambda *_args: []) + monkeypatch.setattr(pg_writer, "upsert_run", lambda **_kwargs: None) + + result = mcp_workflow.run_mcp_workflow( + "purchase-confirmation", + "purchase-confirmation_20260814", + ) + + assert result["status"] == "completed" + assert result["force_refresh"] is True + assert result["collection_reused"] is False + assert collection_calls == [ + "purchase-confirmation:purchase-confirmation_20260814" + ] + assert notification_calls == [ + ( + "purchase-confirmation", + "purchase-confirmation_20260814", + [str(collected_file)], + ) + ] + assert receipt_calls == [ + ( + "purchase-confirmation", + "purchase-confirmation_20260814", + ("ou_test",), + ) + ] + + @pytest.mark.parametrize( "script_name", ["collect_confirmation.ps1", "collect_replenishment.ps1"], @@ -721,3 +1669,421 @@ def test_direct_wrapper_process_never_calls_start_process_when_gated( assert completed.returncode == expected_code, output assert expected_marker in output assert "START_PROCESS_CALLED" not in output + + +def test_replenishment_alert_card_builds_deterministic_content() -> None: + payload = { + "alert_count": 1, + "generated_at": "2026-08-18 07:00:45", + "alerts": [ + { + "sku": "10362011", + "threshold": 300, + "actual_stock": 184.0, + "purchase_all": 0.0, + "product_name": "L型数据线", + "product_spec": "弯头磁吸线CTOC;活力橙色 100W", + "sales_7": 28.0, + "sales_15": 46.0, + "sales_30": 92.0, + } + ], + } + + card = mcp_workflow._replenishment_alert_card(payload) + + assert card["header"]["template"] == "red" + assert card["header"]["title"]["content"] == "库存阈值预警" + text = json.dumps(card, ensure_ascii=False) + assert "10362011" in text + assert "L型数据线" in text + assert "库存缺口" in text + assert "116" in text + assert "92" in text + + +def test_replenishment_alert_notification_disabled_records_disabled_receipt( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + run_id = "run-disabled" + collected = tmp_path / "threshold_alert_data.json" + collected.write_text( + json.dumps( + { + "alert_count": 1, + "alerts": [ + { + "sku": "10362011", + "threshold": 300, + "actual_stock": 184.0, + } + ], + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(mcp_workflow, "STATE_ROOT", tmp_path / "state") + snapshot = mcp_workflow._notification_snapshot_path( + "replenishment-alert", + run_id, + ) + snapshot.parent.mkdir(parents=True) + snapshot.write_text( + json.dumps( + { + "schema_version": 1, + "workflow": "replenishment-alert", + "run_id": run_id, + "enabled": False, + "recipients": [], + "acceptance": False, + } + ), + encoding="utf-8", + ) + + calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] + + def fake_mark_notification_sent(*args, **kwargs): + calls.append((args, kwargs)) + + monkeypatch.setattr( + pg_writer, + "mark_notification_sent", + fake_mark_notification_sent, + ) + + result = mcp_workflow._send_replenishment_alert_notification( + "replenishment-alert", + run_id, + [str(collected)], + ) + + assert result["notification_disabled"] is True + assert len(calls) == 1 + _, kwargs = calls[0] + assert kwargs["notification_disabled"] is True + assert kwargs["message_ids"] == [] + assert kwargs["expected_recipients"] == [] + + +def test_replenishment_alert_notification_sends_and_returns_receipt( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + run_id = "run-enabled" + recipients = ["ou_recipient0001", "ou_recipient0002"] + collected = tmp_path / "threshold_alert_data.json" + collected.write_text( + json.dumps( + { + "alert_count": 1, + "alerts": [ + { + "sku": "10362011", + "threshold": 300, + "actual_stock": 184.0, + } + ], + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(mcp_workflow, "STATE_ROOT", tmp_path / "state") + snapshot = mcp_workflow._notification_snapshot_path( + "replenishment-alert", + run_id, + ) + snapshot.parent.mkdir(parents=True) + snapshot.write_text( + json.dumps( + { + "schema_version": 1, + "workflow": "replenishment-alert", + "run_id": run_id, + "enabled": True, + "recipients": recipients, + "acceptance": False, + } + ), + encoding="utf-8", + ) + + captured: dict[str, object] = {} + + def fake_send_card_run( + receivers, + card, + *, + workflow, + run_id, + idempotency_key, + ): + captured.update( + receivers=list(receivers), + card=card, + workflow=workflow, + run_id=run_id, + idempotency_key=idempotency_key, + ) + return { + "message_ids": ["om_1", "om_2"], + "recipient_message_ids": { + "ou_recipient0001": "om_1", + "ou_recipient0002": "om_2", + }, + "errors": [], + } + + monkeypatch.setattr(send_card_notification, "run", fake_send_card_run) + + result = mcp_workflow._send_replenishment_alert_notification( + "replenishment-alert", + run_id, + [str(collected)], + ) + + assert result["message_ids"] == ["om_1", "om_2"] + assert captured["receivers"] == recipients + assert captured["workflow"] == "replenishment-alert" + assert captured["run_id"] == run_id + assert captured["idempotency_key"] == f"replenishment-alert:{run_id}" + assert captured["card"]["header"]["template"] == "red" + + +def test_replenishment_alert_workflow_uses_deterministic_notification( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False) + monkeypatch.delenv("GYXX_FORCE_REFRESH", raising=False) + run_id = "replenishment-alert_20260818" + collected_file = tmp_path / "threshold_alert_data.json" + collected_file.write_text( + json.dumps( + { + "run_id": run_id, + "alert_count": 1, + "alerts": [ + { + "sku": "10362011", + "threshold": 300, + "actual_stock": 184.0, + } + ], + } + ), + encoding="utf-8", + ) + + monkeypatch.setattr(mcp_workflow, "_acquire_workflow_lock", lambda *_args: (True, "")) + monkeypatch.setattr(mcp_workflow, "_release_workflow_lock", lambda *_args: None) + monkeypatch.setattr(mcp_workflow, "_acquire_collection_lock", lambda *_args: (True, "")) + monkeypatch.setattr(mcp_workflow, "_release_collection_lock", lambda *_args: None) + monkeypatch.setattr( + mcp_workflow, + "_reusable_collected_files", + lambda *_args: [], + ) + monkeypatch.setattr(mcp_workflow, "_clear_shared_outputs", lambda *_args: []) + monkeypatch.setattr( + mcp_workflow, + "_run_local_collection_script", + lambda *_args: (True, "fresh collection"), + ) + monkeypatch.setattr( + mcp_workflow, + "_ensure_shared_outputs", + lambda *_args: [str(collected_file)], + ) + monkeypatch.setattr( + mcp_workflow, + "_validate_run_manifest", + lambda *_args: (True, ""), + ) + monkeypatch.setattr( + mcp_workflow, + "_extract_pending_run_id", + lambda *_args: run_id, + ) + monkeypatch.setattr( + mcp_workflow, + "_load_or_create_notification_snapshot", + lambda *_args: (True, ("ou_test_recipient",), False), + ) + monkeypatch.setattr( + mcp_workflow, + "_notification_receipt_exists", + lambda *_args: False, + ) + + deterministic_calls: list[tuple[str, str, list[str]]] = [] + + def fake_send_notification(workflow_name, workflow_run_id, collected_files): + deterministic_calls.append( + (workflow_name, workflow_run_id, list(collected_files)) + ) + return { + "message_ids": ["om_1"], + "recipient_message_ids": {"ou_test_recipient": "om_1"}, + "errors": [], + } + + monkeypatch.setattr( + mcp_workflow, + "_send_replenishment_alert_notification", + fake_send_notification, + ) + + analyzer_calls: list[str] = [] + + def fake_analyze(role: str, _prompt: str): + analyzer_calls.append(role) + return True, 1, "analyzer must not run for replenishment-alert" + + monkeypatch.setattr(mcp_workflow, "_call_once", fake_analyze) + monkeypatch.setattr( + mcp_workflow, + "_send_collector_heartbeat", + lambda *_args, **_kwargs: "", + ) + monkeypatch.setattr( + mcp_workflow, + "_start_progress_heartbeat_worker", + lambda *_args: None, + ) + monkeypatch.setattr(mcp_workflow, "_close_workflow_browser", lambda *_args: []) + monkeypatch.setattr(mcp_workflow, "_cleanup_workflow_artifacts", lambda *_args: []) + monkeypatch.setattr(pg_writer, "upsert_run", lambda **_kwargs: None) + + result = mcp_workflow.run_mcp_workflow("replenishment-alert", run_id) + + assert result["status"] == "completed" + assert result["current_phase"] == "finishing" + assert result["notification_receipt"]["message_ids"] == ["om_1"] + assert deterministic_calls == [ + ("replenishment-alert", run_id, [str(collected_file)]) + ] + assert analyzer_calls == [] + + +def test_purchase_confirmation_notification_uses_collector_csv_and_receipt( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + run_id = "purchase-confirmation_20260822" + collected = tmp_path / "purchase-confirmation.csv" + collected.write_text( + "商品编码,清洗后的名称,清洗后的规格,可用数,7天销量,采购在途数,汇总原始数据,缺口,协议到货时间\n" + "'10483001,宙斯ZAir,大号;曜夜黑,108,698.0,1756.0,宙斯Z Air,-590.0,2026-08-21 超时2\n", + encoding="utf-8", + ) + recipients = ("ou_test_recipient",) + captured: dict[str, object] = {} + + monkeypatch.setattr( + mcp_workflow, + "_load_or_create_notification_snapshot", + lambda *_args: (True, recipients, False), + ) + from gyxx_flow.modules.supply_chain.orchestrator.scripts import ( + send_card_notification, + ) + + def fake_send_card_run(receivers, card, **kwargs): + captured.update(receivers=receivers, card=card, **kwargs) + return { + "message_ids": ["om_confirmation"], + "recipient_message_ids": {recipients[0]: "om_confirmation"}, + "errors": [], + } + + monkeypatch.setattr(send_card_notification, "run", fake_send_card_run) + + receipt = mcp_workflow._send_purchase_confirmation_notification( + "purchase-confirmation", run_id, [str(collected)] + ) + + assert receipt["message_ids"] == ["om_confirmation"] + assert captured["receivers"] == list(recipients) + assert captured["workflow"] == "purchase-confirmation" + assert captured["run_id"] == run_id + assert captured["idempotency_key"] == f"purchase-confirmation:{run_id}" + card = captured["card"] + assert card["header"]["title"]["content"] == "采购确认通知" + body = "\n".join(item["text"]["content"] for item in card["elements"] if item["tag"] == "div") + assert "10483001" in body + assert "宙斯ZAir" in body + assert "大号;曜夜黑" in body + assert "108" in body + assert "-590.0" in body + assert "2026-08-21 超时2" in body + + +def test_replenishment_notification_uses_collector_payloads_and_receipt( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + run_id = "replenishment_20260822" + pending = tmp_path / "pending_insert.json" + notify = tmp_path / "notify_data.json" + pending.write_text( + json.dumps( + { + "records": [ + { + "商品编号": "10483001", + "商品名称": "宙斯ZAir", + "规格名称(新)": "大号;曜夜黑", + "补货量": 300, + "实际可用数": 18, + "采购在途": 50, + }, + {"商品编号": "10483002", "补货量": float("nan")}, + ] + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + notify.write_text( + json.dumps({"pending_count": 1, "skipped_count": 2}, ensure_ascii=False), + encoding="utf-8", + ) + recipients = ("ou_test_recipient",) + captured: dict[str, object] = {} + monkeypatch.setattr( + mcp_workflow, + "_load_or_create_notification_snapshot", + lambda *_args: (True, recipients, False), + ) + from gyxx_flow.modules.supply_chain.orchestrator.scripts import ( + send_card_notification, + ) + + def fake_send_card_run(receivers, card, **kwargs): + captured.update(receivers=receivers, card=card, **kwargs) + return { + "message_ids": ["om_replenishment"], + "recipient_message_ids": {recipients[0]: "om_replenishment"}, + "errors": [], + } + + monkeypatch.setattr(send_card_notification, "run", fake_send_card_run) + + receipt = mcp_workflow._send_replenishment_notification( + "replenishment", run_id, [str(pending), str(notify)] + ) + + assert receipt["message_ids"] == ["om_replenishment"] + assert captured["receivers"] == list(recipients) + assert captured["workflow"] == "replenishment" + assert captured["idempotency_key"] == f"replenishment:{run_id}" + card = captured["card"] + assert card["header"]["title"]["content"] == "补货建议通知" + body = "\n".join(item["text"]["content"] for item in card["elements"] if item["tag"] == "div") + assert "10483001" in body + assert "宙斯ZAir" in body + assert "大号;曜夜黑" in body + assert "建议补货:300" in body diff --git a/tests/test_supply_replenishment_arrival_sync.py b/tests/test_supply_replenishment_arrival_sync.py new file mode 100644 index 0000000..666177f --- /dev/null +++ b/tests/test_supply_replenishment_arrival_sync.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +import json +from contextlib import contextmanager +from datetime import date, datetime +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from gyxx_flow.catalog import WorkflowCatalog +from gyxx_flow.modules.supply_chain.orchestrator.arrival_sync_queue import ( + ArrivalSyncQueue, + ArrivalSyncTask, +) +from gyxx_flow.modules.supply_chain.orchestrator.scripts import ( + PurchaseOrderUpdate, + sync_replenishment_arrivals, +) +from gyxx_flow.modules.supply_chain.orchestrator.scripts.PurchaseOrderUpdate import ( + ERPLoginManager, +) +from gyxx_flow.modules.supply_chain.orchestrator.scripts.sync_replenishment_arrivals import ( + arrival_date_text, +) + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +class _Cursor: + def __init__(self, connection: "_Connection") -> None: + self.connection = connection + + def __enter__(self) -> "_Cursor": + return self + + def __exit__(self, *_args: object) -> None: + return None + + def execute(self, sql: str, params: tuple[object, ...]) -> None: + self.connection.calls.append((sql, params)) + + def fetchall(self) -> list[dict[str, object]]: + return self.connection.claim_rows + + def fetchone(self) -> dict[str, object] | None: + return self.connection.return_row + + +class _Connection: + def __init__(self, *, claim_rows: list[dict[str, object]] | None = None) -> None: + self.claim_rows = claim_rows or [] + self.return_row: dict[str, object] | None = {"id": 1} + self.calls: list[tuple[str, tuple[object, ...]]] = [] + + def __enter__(self) -> "_Connection": + return self + + def __exit__(self, *_args: object) -> None: + return None + + @contextmanager + def cursor(self, **_kwargs: object): + yield _Cursor(self) + + +def test_arrival_date_text_uses_date_part_and_supports_clear() -> None: + assert arrival_date_text(datetime(2026, 8, 20, 15, 30)) == "2026-08-20" + assert arrival_date_text(date(2026, 8, 21)) == "2026-08-21" + assert arrival_date_text("2026-08-22T00:00:00") == "2026-08-22" + assert arrival_date_text(None) == "" + + +def test_arrival_date_text_rejects_invalid_values() -> None: + with pytest.raises(ValueError, match="格式无效"): + arrival_date_text("not-a-date") + + +def test_detail_row_lookup_uses_loaded_sku_row_not_ambiguous_tab_name() -> None: + class _Cell: + def __init__(self, text: str) -> None: + self.text = text + + def inner_text(self) -> str: + return self.text + + class _Cells: + def __init__(self, values: list[str]) -> None: + self.values = values + + def count(self) -> int: + return len(self.values) + + def nth(self, index: int) -> _Cell: + return _Cell(self.values[index]) + + class _Row: + def __init__(self, values: list[str]) -> None: + self.values = values + + def locator(self, selector: str) -> _Cells: + assert selector == "._jt_cell" + return _Cells(self.values) + + class _Rows: + def __init__(self, rows: list[_Row]) -> None: + self.rows = rows + + def count(self) -> int: + return len(self.rows) + + def nth(self, index: int) -> _Row: + return self.rows[index] + + class _Frame: + def __init__(self, rows: list[_Row]) -> None: + self.rows = rows + + def locator(self, selector: str) -> _Rows: + assert selector == "div._jt_row._jt_rh, div._jt_row" + return _Rows(self.rows) + + irrelevant_tab0 = _Frame([_Row(["973798", "采购单列表"])] ) + purchase_detail = _Frame([_Row(["10504002", "星迹2", "时野绿中号"])] ) + page = SimpleNamespace(frames=[irrelevant_tab0, purchase_detail]) + + frame, row = PurchaseOrderUpdate.PurchaseManager._find_target_detail_row( + page, "10504002" + ) + + assert frame is purchase_detail + assert row is not None + + +def test_detail_filter_renders_the_target_sku_in_a_virtualized_grid() -> None: + calls: list[tuple[str, str]] = [] + + class _SearchBox: + first: "_SearchBox" + + def __init__(self) -> None: + self.first = self + + @staticmethod + def count() -> int: + return 1 + + def fill(self, value: str) -> None: + calls.append(("fill", value)) + + def press(self, value: str) -> None: + calls.append(("press", value)) + + class _Frame: + @staticmethod + def locator(selector: str) -> _SearchBox: + assert selector == "#sku_search" + return _SearchBox() + + page = SimpleNamespace(frames=[_Frame()]) + + assert PurchaseOrderUpdate.PurchaseManager._filter_purchase_detail_by_sku( + page, "10504002" + ) + assert calls == [("fill", "10504002"), ("press", "Enter")] + + +def test_retryable_item_failures_do_not_fail_the_worker() -> None: + result = { + "errors": ["SKU 10465007: 搜索商品编码失败"], + "retryable_failures": ["SKU 10465007: 搜索商品编码失败"], + "execution_errors": [], + } + + assert sync_replenishment_arrivals._worker_exit_code(result) == 0 + + +def test_execution_errors_still_fail_the_worker() -> None: + result = { + "errors": ["队列 PostgreSQL 连接失败"], + "retryable_failures": [], + "execution_errors": ["队列 PostgreSQL 连接失败"], + } + + assert sync_replenishment_arrivals._worker_exit_code(result) == 1 + + +def test_run_records_item_failure_for_retry_without_failing_worker( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = tmp_path / "config.json" + config.write_text("{}", encoding="utf-8") + task = ArrivalSyncTask( + id=8, + tenant_id=1, + sku_code="SKU-8", + expected_arrival_time=datetime(2026, 8, 20, 9, 0), + ) + + class _Queue: + def __init__(self) -> None: + self.failed: list[tuple[ArrivalSyncTask, str]] = [] + + def claim(self, **_kwargs: object) -> list[ArrivalSyncTask]: + return [task] + + def mark_failed(self, claimed_task: ArrivalSyncTask, error: str) -> bool: + self.failed.append((claimed_task, error)) + return True + + class _Manager: + _acceptance_cookie_skipped = False + + def __init__(self, *_args: object, **_kwargs: object) -> None: + return None + + def login_with_browser(self) -> bool: + return True + + def open_purchase_page(self) -> bool: + return True + + def search_sku(self, _sku_code: str) -> bool: + return False + + def close_browser(self) -> None: + return None + + monkeypatch.setattr(sync_replenishment_arrivals, "ArrivalSyncQueue", _Queue) + monkeypatch.setattr(sync_replenishment_arrivals, "CONFIG_PATH", config) + monkeypatch.setattr( + sync_replenishment_arrivals, + "WORK_ROOT", + tmp_path / "work", + ) + monkeypatch.setattr( + sync_replenishment_arrivals, + "current_acceptance_policy", + lambda: SimpleNamespace(enabled=False), + ) + monkeypatch.setattr(PurchaseOrderUpdate, "PurchaseManager", _Manager) + monkeypatch.setenv("GYXX_RUN_ID", "retryable-item-failure") + + assert sync_replenishment_arrivals.run(cdp_port=22139) == 0 + + result_path = ( + tmp_path + / "work" + / "arrival-sync" + / "run_id=retryable-item-failure" + / "result.json" + ) + result = json.loads(result_path.read_text(encoding="utf-8")) + assert result["failed"] == 1 + assert result["retryable_failures"] == ["SKU SKU-8: 搜索商品编码失败"] + assert result["execution_errors"] == [] + + +def test_arrival_sync_workflow_uses_daily_python_browser_entry() -> None: + catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") + workflow = next( + item + for item in catalog.scheduled_workflows() + if item.workflow_id == "supply.replenishment_arrival_sync.daily" + ) + + assert workflow.steps[0].entry == ( + "orchestrator/scripts/collect_replenishment_arrival_sync.py" + ) + assert workflow.steps[0].replay_policy == "idempotent" + assert catalog.schedule_for(workflow.workflow_id).at == "09:00" + + +def test_queue_claims_rows_with_skip_locked_and_maps_tasks() -> None: + connection = _Connection( + claim_rows=[ + { + "id": 8, + "tenant_id": 1, + "sku_code": " SKU-8 ", + "expected_arrival_time": datetime(2026, 8, 20, 9, 0), + } + ] + ) + queue = ArrivalSyncQueue( + {"GYXX_ARRIVAL_SYNC_OPERATOR": "worker"}, + connect_factory=lambda: connection, + ) + + tasks = queue.claim(tenant_id=1, stale_after_seconds=60) + + assert tasks == [ + ArrivalSyncTask( + id=8, + tenant_id=1, + sku_code="SKU-8", + expected_arrival_time=datetime(2026, 8, 20, 9, 0), + ) + ] + assert "FOR UPDATE SKIP LOCKED" in connection.calls[0][0] + assert "LIMIT" not in connection.calls[0][0] + assert "deleted = 0" in connection.calls[0][0] + assert "deleted = FALSE" not in connection.calls[0][0] + assert connection.calls[0][1] == (1, 60, "worker") + + +def test_queue_completion_is_fenced_by_the_claimed_target_timestamp() -> None: + connection = _Connection() + queue = ArrivalSyncQueue(connect_factory=lambda: connection) + task = ArrivalSyncTask( + id=8, + tenant_id=1, + sku_code="SKU-8", + expected_arrival_time=None, + ) + + assert queue.mark_success(task) is True + success_sql, success_params = connection.calls[-1] + assert "IS NOT DISTINCT FROM" in success_sql + assert success_params == ("gyxx-flow", 8, 1, None) + + connection.return_row = None + assert queue.mark_failed(task, " 浏览器失败\n原因 ") is False + failed_sql, failed_params = connection.calls[-1] + assert "IS NOT DISTINCT FROM" in failed_sql + assert failed_params == ("浏览器失败 原因", "gyxx-flow", 8, 1, None) + + +def test_cdp_session_is_checked_before_optional_password_validation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = tmp_path / "config.json" + config.write_text( + '{"login": {"url": "https://login.example.test", ' + '"username": "erp-user"}}', + encoding="utf-8", + ) + monkeypatch.delenv("GYXX_SUPPLY_ERP_PASSWORD", raising=False) + monkeypatch.delenv("GYXX_WORKFLOW_ACCEPTANCE", raising=False) + + class _Page: + class _Context: + @staticmethod + def cookies() -> list[dict[str, str]]: + return [{"name": "token", "value": "session"}] + + context = _Context() + + manager = ERPLoginManager(str(config), cdp_port=22139) + page = _Page() + monkeypatch.setattr(manager, "_prepare_login_page", lambda *_args, **_kwargs: page) + monkeypatch.setattr( + manager, + "_page_has_purchase_access", + lambda *_args, **_kwargs: True, + ) + + assert manager.login_with_browser() is True diff --git a/tests/test_workflow_acceptance_policy.py b/tests/test_workflow_acceptance_policy.py index 7c36463..9bc3f45 100644 --- a/tests/test_workflow_acceptance_policy.py +++ b/tests/test_workflow_acceptance_policy.py @@ -12,6 +12,7 @@ import pytest import gyxx_flow.adapters.acceptance_policy as acceptance_policy_module from gyxx_flow.adapters import ( + ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID, WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID, RuntimeIntegrationBinding, WorkflowAcceptancePolicy, @@ -96,6 +97,23 @@ def test_acceptance_policy_is_fail_closed_and_forces_wang_yunlong( ) +def test_acceptance_policy_recognizes_snapshot_declared_analyzer_app( + tmp_path: Path, +) -> None: + policy = WorkflowAcceptancePolicy.from_environment( + { + **_environment(tmp_path), + "GYXX_NOTIFICATION_APP_PROFILE": "second-analyzer-app", + "GYXX_NOTIFICATION_HERMES_PROFILE": "data-analyzer", + } + ) + + assert policy.notification_recipients( + ("ou_other",), + app_profile="second-analyzer-app", + ) == (ANALYZER_ACCEPTANCE_RECIPIENT_OPEN_ID,) + + def test_feishu_write_skip_appends_run_scoped_evidence( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_workflow_dynamic_config.py b/tests/test_workflow_dynamic_config.py new file mode 100644 index 0000000..779e1f2 --- /dev/null +++ b/tests/test_workflow_dynamic_config.py @@ -0,0 +1,503 @@ +from __future__ import annotations + +import json +import threading +from pathlib import Path +from typing import Any, Mapping +from urllib.request import Request, urlopen + +import pytest + +from gyxx_flow.adapters.workflow_config import ( + PostgresWorkflowConfigStore, + WorkflowConfigConflictError, + normalize_config, + normalize_style_config, +) +from gyxx_flow.console import ( + ConsoleConflictError, + WorkflowConsoleService, + create_console_server, +) +from gyxx_flow.core.config import Settings +from gyxx_flow.modules.product_commerce.config.style_config_loader import ( + StyleConfigLoader, +) + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +class FakeDynamicConfigStore: + def __init__(self) -> None: + self.records = [ + { + "id": 1, + "style_name": "极星pro", + "erp_codes": ["10398"], + "platforms": [ + { + "id": 11, + "platform": "天猫", + "item_ids": ["832214449817"], + "persona_bitable_url": "", + "enabled": True, + } + ], + "enabled": True, + "revision": 1, + } + ] + + def snapshot( + self, + *, + search: str = "", + platform: str = "", + enabled: bool | None = None, + ) -> dict[str, Any]: + del search, platform, enabled + return { + "styles": self.records, + "summary": {"total": 1, "enabled": 1, "platform_configs": 1, "matched": 1}, + "platforms": [{"name": "天猫", "count": 1}], + "source": "postgresql", + } + + def runtime_records(self) -> list[dict[str, Any]]: + return self.records + + def create(self, payload: Mapping[str, Any]) -> dict[str, Any]: + return {"id": 2, **normalize_style_config(payload), "revision": 1} + + def update( + self, + config_id: int, + payload: Mapping[str, Any], + *, + expected_revision: int, + ) -> dict[str, Any]: + if expected_revision != 1: + raise WorkflowConfigConflictError("配置已被其他人更新,请重新载入") + return { + "id": config_id, + **normalize_style_config(payload), + "revision": 2, + } + + def delete(self, config_id: int, *, expected_revision: int) -> None: + del config_id + if expected_revision != 1: + raise WorkflowConfigConflictError("配置已被其他人更新,请重新载入") + + +def _payload() -> dict[str, Any]: + return { + "style_name": "极星pro", + "brand": "光影行星", + "erp_codes": "10398,10398, 10400", + "note": "", + "sales_bitable_url": "https://example.feishu.cn/base/abc?table=tbl1", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "enabled": True, + "platforms": [ + { + "platform": "天猫", + "item_ids": ["832214449817", "832214449817"], + "persona_bitable_url": "", + "enabled": True, + } + ], + } + + +def test_normalize_config_cleans_ids_and_markdown_urls() -> None: + payload = { + "platform": "天猫", + "style_name": "极星pro", + "brand": "光影行星", + "erp_codes": "10398,10398, 10400", + "item_ids": ["832214449817", "832214449817"], + "note": "", + "sales_bitable_url": "https://example.feishu.cn/base/abc?table=tbl1", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "enabled": True, + } + payload["sales_bitable_url"] = "[销量表](https://example.feishu.cn/base/abc?table=tbl1)" + + normalized = normalize_config(payload) + + assert normalized["erp_codes"] == ["10398", "10400"] + assert normalized["item_ids"] == ["832214449817"] + assert normalized["sales_bitable_url"] == ( + "https://example.feishu.cn/base/abc?table=tbl1" + ) + + +def test_normalize_style_config_owns_erp_once_and_platform_ids_per_child() -> None: + normalized = normalize_style_config(_payload()) + + assert normalized["erp_codes"] == ["10398", "10400"] + assert normalized["platforms"] == [ + { + "platform": "天猫", + "item_ids": ["832214449817"], + "persona_bitable_url": "", + "enabled": True, + } + ] + + +def test_normalize_style_config_supports_extensible_business_targets() -> None: + payload = _payload() + payload["destinations"] = [ + { + "key": "sales_bitable_url", + "label": "销量表", + "url": "https://example.feishu.cn/base/sales?table=tblSales", + "description": "销量日报写入目标", + "enabled": True, + }, + { + "key": "style_quality_analysis", + "label": "款式质量分析", + "url": "https://example.feishu.cn/base/quality?table=tblQuality", + "description": "未来分析逻辑写入的结果表", + "enabled": True, + }, + ] + + normalized = normalize_style_config(payload) + + assert [item["key"] for item in normalized["destinations"]] == [ + "sales_bitable_url", + "style_quality_analysis", + ] + assert normalized["sales_bitable_url"] == ( + "https://example.feishu.cn/base/sales?table=tblSales" + ) + assert normalized["destinations"][1]["description"] == ( + "未来分析逻辑写入的结果表" + ) + + +def test_postgres_store_omits_empty_dsn_for_split_connection_fields( + monkeypatch, +) -> None: + from gyxx_flow.adapters import workflow_config + + calls: list[dict[str, Any]] = [] + + class FakeConnection: + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + pass + + def close(self) -> None: + pass + + def connect(**kwargs): + calls.append(kwargs) + return FakeConnection() + + monkeypatch.setattr(workflow_config.psycopg, "connect", connect) + store = PostgresWorkflowConfigStore( + { + "PG_HOST": "db.example.test", + "PG_PORT": "5432", + "PG_DB": "app", + "PG_USER": "app", + "PG_PASSWORD": "test-only", + } + ) + + with store._connection(): + pass + + assert calls[0]["host"] == "db.example.test" + assert "conninfo" not in calls[0] + + +def test_migration_seed_captures_all_feishu_records() -> None: + seed = json.loads( + (PROJECT_ROOT / "config" / "workflow-dynamic-config-seed.json").read_text( + encoding="utf-8" + ) + ) + + assert seed["record_count"] == 215 + assert len(seed["records"]) == 215 + assert len({item["source_record_id"] for item in seed["records"]}) == 215 + assert any( + item["config"]["style_name"] == "极星pro" + and item["config"]["platform"] == "天猫" + for item in seed["records"] + ) + + +def test_style_loader_reads_database_provider_and_never_needs_feishu( + tmp_path: Path, +) -> None: + loader = StyleConfigLoader( + cache_path=tmp_path / "style-cache.json", + records_provider=lambda: [ + { + **normalize_config( + { + "platform": "天猫", + "style_name": "极星pro", + "brand": "光影行星", + "erp_codes": ["10398", "10400"], + "item_ids": ["832214449817"], + "note": "", + "sales_bitable_url": "https://example.feishu.cn/base/abc?table=tbl1", + "main_image_bitable_url": "", + "creator_bitable_url": "", + "tm_persona_bitable_url": "", + "jd_persona_bitable_url": "", + "dy_persona_bitable_url": "", + "self_creator_bitable_url": "", + "weekly_note_analysis_url": "", + "style_analysis_bitable_url": "", + "style_content": "", + "enabled": True, + } + ), + "sales_bitable_url": ( + "https://example.feishu.cn/base/abc?table=tbl1&view=vew1" + ), + } + ], + ) + + loaded = loader.load() + + assert loaded["source"] == "database" + assert loaded["styles"]["极星pro"]["tm"]["item_ids"] == [ + "832214449817" + ] + assert loaded["styles"]["极星pro"]["erp_codes"] == ["10398", "10400"] + + +def test_style_loader_preserves_extensible_business_targets(tmp_path: Path) -> None: + destinations = [ + { + "key": "style_quality_analysis", + "label": "款式质量分析", + "url": "https://example.feishu.cn/base/quality?table=tblQuality", + "description": "质量分析结果表", + "enabled": True, + } + ] + loader = StyleConfigLoader( + cache_path=tmp_path / "style-cache.json", + records_provider=lambda: [ + { + "platform": "天猫", + "style_name": "极星pro", + "brand": "光影行星", + "erp_codes": ["10398"], + "item_ids": ["832214449817"], + "sales_bitable_url": ( + "https://example.feishu.cn/base/sales?table=tblSales" + ), + "destinations": destinations, + } + ], + ) + + loaded = loader.load() + + assert loaded["styles"]["极星pro"]["destinations"] == destinations + + +def test_grouped_database_keeps_original_platform_and_target_contracts() -> None: + shared = { + "style_name": "款式甲", + "brand": "光影行星", + "erp_codes": ["ERP-1"], + "sales_bitable_url": "https://example.feishu.cn/base/sales?table=tblSales", + "main_image_bitable_url": "https://example.feishu.cn/base/image?table=tblImage", + "tm_persona_bitable_url": "https://example.feishu.cn/base/tm?table=tblTm", + "jd_persona_bitable_url": "https://example.feishu.cn/base/jd?table=tblJd", + "dy_persona_bitable_url": "https://example.feishu.cn/base/dy?table=tblDy", + "style_analysis_bitable_url": "https://example.feishu.cn/base/report?table=tblReport", + } + rows = [ + {**shared, "platform": "天猫", "item_ids": ["TM-1"]}, + {**shared, "platform": "京东旗舰店", "item_ids": ["JD-SPU-1"]}, + {**shared, "platform": "京东自营", "item_ids": ["JD-SKU-1"]}, + {**shared, "platform": "抖音", "item_ids": ["DY-1"]}, + ] + loader = StyleConfigLoader(records_provider=lambda: rows) + + style = loader._aggregate(loader._load_records_from_database())["styles"]["款式甲"] + + assert style["erp_codes"] == ["ERP-1"] + assert style["tm"]["item_ids"] == ["TM-1"] + assert style["jd"]["spus"] == ["JD-SPU-1"] + assert style["jd_self"]["skus"] == ["JD-SKU-1"] + assert style["dy"]["item_ids"] == ["DY-1"] + assert style["sales_bitable"]["table_id"] == "tblSales" + assert style["main_image_bitable"]["table_id"] == "tblImage" + assert style["persona_bitable"]["table_id"] == "tblTm" + assert style["jd_persona_bitable"]["table_id"] == "tblJd" + assert style["dy_persona_bitable"]["table_id"] == "tblDy" + assert style["style_analysis_bitable"]["table_id"] == "tblReport" + + +def test_console_dynamic_config_crud_maps_optimistic_conflicts( + tmp_path: Path, +) -> None: + service = WorkflowConsoleService( + Settings(project_root=PROJECT_ROOT, data_root=tmp_path), + dynamic_configs=FakeDynamicConfigStore(), + ) + + assert service.dynamic_config_snapshot()["summary"]["total"] == 1 + created = service.create_dynamic_config(_payload()) + assert created["revision"] == 1 + updated = service.update_dynamic_config(1, _payload(), expected_revision=1) + assert updated["revision"] == 2 + with pytest.raises(ConsoleConflictError, match="其他人更新"): + service.update_dynamic_config(1, _payload(), expected_revision=9) + + +def test_console_assets_include_dynamic_config_crud_page() -> None: + html = (PROJECT_ROOT / "src" / "gyxx_flow" / "web" / "index.html").read_text( + encoding="utf-8" + ) + script = (PROJECT_ROOT / "src" / "gyxx_flow" / "web" / "app.js").read_text( + encoding="utf-8" + ) + + assert 'id="dynamic-config-view"' in html + assert 'id="dynamic-config-form"' in html + assert "/api/dynamic-configs" in script + assert "#/configs/products" in script + + +def test_content_summary_targets_are_loaded_from_project_database(monkeypatch) -> None: + from gyxx_flow.modules.content_marketing import weekly_summary_all + + monkeypatch.setattr( + weekly_summary_all, + "runtime_workflow_configs", + lambda: [ + { + "style_name": "极星pro", + "weekly_note_analysis_url": ( + "https://example.feishu.cn/base/base1?table=table1&view=view1" + ), + } + ], + ) + + assert weekly_summary_all.load_index_styles() == { + "极星pro": ( + "base1", + "table1", + "https://example.feishu.cn/base/base1?table=table1&view=view1", + ) + } + + +def test_content_mapping_rejects_legacy_static_cache( + monkeypatch, tmp_path: Path +) -> None: + from gyxx_flow.modules.content_marketing import feishu_mapping + + cache_path = tmp_path / "mapping.json" + cache_path.write_text( + json.dumps({"platform": "xhs_pgy", "tables": [{"name": "旧款"}]}), + encoding="utf-8", + ) + monkeypatch.setattr(feishu_mapping, "MAPPING_PATH", cache_path) + monkeypatch.setattr(feishu_mapping, "refresh_mapping", lambda **_kwargs: None) + + with pytest.raises(RuntimeError, match="项目数据库不可用"): + feishu_mapping.load_mapping(force_refresh=False) + + +def test_retired_feishu_master_is_not_referenced_by_runtime_modules() -> None: + retired_token = "TtoCb1NuQaDy3NsZWTpc0GIvnph" + runtime_root = PROJECT_ROOT / "src" / "gyxx_flow" + offenders = [] + for path in runtime_root.rglob("*.py"): + if path == runtime_root / "migration" / "feishu_style_config_seed.py": + continue + if retired_token in path.read_text(encoding="utf-8"): + offenders.append(path.relative_to(PROJECT_ROOT).as_posix()) + + assert offenders == [] + + +def test_dynamic_config_http_crud_uses_revision_preconditions(tmp_path: Path) -> None: + server = create_console_server( + Settings(project_root=PROJECT_ROOT, data_root=tmp_path), + host="127.0.0.1", + port=0, + dynamic_configs=FakeDynamicConfigStore(), + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + def request( + method: str, + path: str, + payload: dict[str, Any] | None = None, + revision: int | None = None, + ) -> tuple[int, dict[str, Any]]: + data = json.dumps(payload).encode("utf-8") if payload is not None else None + headers = { + "Content-Type": "application/json", + "X-GYXX-Console": "1", + "Origin": base_url, + } + if revision is not None: + headers["If-Match"] = str(revision) + response = urlopen( + Request(base_url + path, data=data, headers=headers, method=method), + timeout=5, + ) + return response.status, json.loads(response.read().decode("utf-8")) + + try: + status, snapshot = request("GET", "/api/dynamic-configs") + assert status == 200 + assert snapshot["summary"]["total"] == 1 + + status, created = request("POST", "/api/dynamic-configs", _payload()) + assert status == 201 + assert created["revision"] == 1 + + status, updated = request( + "PUT", "/api/dynamic-configs/1", _payload(), revision=1 + ) + assert status == 200 + assert updated["revision"] == 2 + + status, deleted = request( + "DELETE", "/api/dynamic-configs/1", revision=1 + ) + assert status == 200 + assert deleted == {"deleted": True, "id": 1} + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) diff --git a/tests/test_workflow_engine.py b/tests/test_workflow_engine.py index 5aaf119..6cd229a 100644 --- a/tests/test_workflow_engine.py +++ b/tests/test_workflow_engine.py @@ -16,7 +16,7 @@ import pytest import gyxx_flow.workflow.steps as steps_module from gyxx_flow.core.context import RunContext from gyxx_flow.core.layout import DataLayout -from gyxx_flow.core.locks import LockManager +from gyxx_flow.core.locks import LockManager, ResourceBusyError from gyxx_flow.core.records import RunJournal from gyxx_flow.ops.effects import EffectLedger from gyxx_flow.workflow.engine import WorkflowEngine @@ -159,6 +159,22 @@ def test_command_step_uses_argv_explicit_cwd_env_and_timeout( assert captured["stderr"].closed is True # type: ignore[union-attr] +def test_command_step_preserves_chinese_child_diagnostics(tmp_path: Path) -> None: + child_env = dict(os.environ) + child_env.pop("PYTHONIOENCODING", None) + expected = "天猫百亿补贴导入失败" + step = CommandStep( + argv=(sys.executable, "-c", f"print({expected!r})"), + cwd=tmp_path, + env=child_env, + ) + + outcome = step.execute(context=_context(), timeout_seconds=10, dry_run=False) + + assert outcome.exit_code == 0 + assert outcome.stdout.strip() == expected + + def test_command_step_dry_run_never_starts_a_process( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -359,6 +375,31 @@ def test_command_step_maps_reserved_cookie_exit_to_a_skip( assert outcome.reason == "cookie-preflight" +def test_command_step_maps_idempotent_replay_exit_to_a_skip( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + class SkippedProcess: + pid = 424243 + + def wait(self, timeout: float | None = None) -> int: + del timeout + return 76 + + monkeypatch.setattr( + steps_module, + "_start_process", + lambda *_args, **_kwargs: (SkippedProcess(), None), + ) + step = CommandStep(argv=("collector.exe",), cwd=tmp_path, env={}) + + outcome = step.execute(context=_context(), timeout_seconds=2, dry_run=False) + + assert outcome.exit_code == 76 + assert outcome.skipped is True + assert outcome.reason == "idempotent-replay" + + def test_engine_retries_records_every_attempt_and_aggregates_failures(tmp_path: Path) -> None: flaky = FakeStep( StepExecution(exit_code=7, error="temporary"), @@ -528,6 +569,49 @@ def test_effect_ledger_skips_a_successful_production_sink_replay(tmp_path: Path) assert len(action.calls) == 1 +def test_repeatable_sink_runs_again_without_business_date_effect_receipt( + tmp_path: Path, +) -> None: + action = FakeStep(StepExecution(exit_code=0), StepExecution(exit_code=0)) + workflow = WorkflowDefinition( + "product.tmall_baibu_apply", + ( + StepDefinition( + "old_all_bag", + action, + production_sink=True, + replay_policy="repeatable", + ), + ), + ) + ledger = EffectLedger(tmp_path) + engine = WorkflowEngine( + LockManager(tmp_path / "locks"), effect_ledger=ledger + ) + first_context = RunContext.create( + "product.tmall_baibu_apply", "2026-07-27", random_suffix="repeatable1" + ) + first = engine.execute( + workflow, + context=first_context, + journal=RunJournal.create(DataLayout(tmp_path), first_context), + ) + second_context = RunContext.create( + "product.tmall_baibu_apply", "2026-07-27", random_suffix="repeatable2" + ) + second = engine.execute( + workflow, + context=second_context, + journal=RunJournal.create(DataLayout(tmp_path), second_context), + ) + + assert first.status == "success" + assert second.status == "success" + assert second.steps["old_all_bag"].reason is None + assert len(action.calls) == 2 + assert list((tmp_path / "state" / "ops" / "effects").glob("*.json")) == [] + + def test_idempotent_sink_runs_again_for_same_business_date(tmp_path: Path) -> None: action = FakeStep(StepExecution(exit_code=0), StepExecution(exit_code=0)) workflow = WorkflowDefinition( @@ -593,6 +677,49 @@ def test_skipped_production_sink_cancels_effect_claim_for_future_runs( assert list((tmp_path / "state" / "ops" / "effects").glob("*.json")) == [] +def test_resource_busy_before_action_cancels_effect_claim(tmp_path: Path) -> None: + class BusyStepLockManager: + def acquire(self, resource, *, owner): + del owner + if resource == "module:product_commerce:product.daily": + raise ResourceBusyError(f"resource is busy: {resource}") + return LockManager(tmp_path / "locks").acquire( + resource, + owner="workflow-lock", + ) + + action = FakeStep() + workflow = WorkflowDefinition( + "product.daily", + ( + StepDefinition( + "collect", + action, + resources=("module:product_commerce:product.daily",), + production_sink=True, + ), + ), + ) + context = RunContext.create( + "product.daily", + "2026-08-11", + random_suffix="busy", + ) + result = WorkflowEngine( + BusyStepLockManager(), # type: ignore[arg-type] + effect_ledger=EffectLedger(tmp_path), + ).execute( + workflow, + context=context, + journal=RunJournal.create(DataLayout(tmp_path), context), + ) + + assert result.status == "failed" + assert result.steps["collect"].reason == "resource-busy-before-execution" + assert action.calls == [] + assert list((tmp_path / "state" / "ops" / "effects").glob("*.json")) == [] + + def test_failed_dependency_skips_downstream_critical_step(tmp_path: Path) -> None: downstream = FakeStep() workflow = WorkflowDefinition( diff --git a/uv.lock b/uv.lock index 67aadce..1fefe3e 100644 --- a/uv.lock +++ b/uv.lock @@ -53,42 +53,11 @@ wheels = [ [[package]] name = "apify-fingerprint-datapoints" -version = "0.13.0" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/f1/b74f95767581372ab849c8b13e384b62f60d034584892c60c4a3442d9312/apify_fingerprint_datapoints-0.13.0.tar.gz", hash = "sha256:263141c19e9bc90a821e6b4e2b845925f17e0b8fbd53a897fc71546bd50df7f1", size = 934827, upload-time = "2026-05-04T09:08:45.036Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/3e/d52b92dd3efe64d21b7652cc8b85d6948beef220c24ceaafd95f4d587047/apify_fingerprint_datapoints-0.15.0.tar.gz", hash = "sha256:5776fe73feaa3910265cae55599552b098f3a716d8872c9a0c2295ff2bb680dc", size = 943698, upload-time = "2026-08-05T14:08:57.213Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/58/8402442bf6af5a3a8068fe5431c42ea4f73c1eb18f621f9bf7c5de80caf5/apify_fingerprint_datapoints-0.13.0-py3-none-any.whl", hash = "sha256:0213d42297be19e8035202b41fb2e840a1e5d79874c99c882a5027a7d0b1a0eb", size = 761652, upload-time = "2026-05-04T09:08:43.347Z" }, -] - -[[package]] -name = "asttokens" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" }, -] - -[[package]] -name = "attrs" -version = "26.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, -] - -[[package]] -name = "beautifulsoup4" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "soupsieve" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/05/14521197ae61dba29f01dd23264d7457aa402568b92bf3fb89fd12a5d6db/apify_fingerprint_datapoints-0.15.0-py3-none-any.whl", hash = "sha256:fc9299b3136880f47b468897cd00ac622ad2c2a93e9cc9f972d10dbf5ed2b3aa", size = 761805, upload-time = "2026-08-05T14:08:55.518Z" }, ] [[package]] @@ -294,105 +263,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "cryptography" -version = "49.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, - { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, - { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, - { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, - { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, - { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, - { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, - { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, -] - [[package]] name = "cssselect" -version = "1.4.0" +version = "1.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/2e/cdfd8b01c37cbf4f9482eefd455853a3cf9c995029a46acd31dfaa9c1dd6/cssselect-1.4.0.tar.gz", hash = "sha256:fdaf0a1425e17dfe8c5cf66191d211b357cf7872ae8afc4c6762ddd8ac47fc92", size = 40589, upload-time = "2026-01-29T07:00:26.701Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/5a/6d6fcf922709391fac986f0a03ad4546f4f45b94d10aeb6c1ee041599993/cssselect-1.5.0.tar.gz", hash = "sha256:3cbe82dd7acbee9ba9e5723b5f9e4749826912f1fb31cd7f92aabed5fde15b15", size = 47598, upload-time = "2026-07-27T09:17:34.189Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/0c/7bb51e3acfafd16c48875bf3db03607674df16f5b6ef8d056586af7e2b8b/cssselect-1.4.0-py3-none-any.whl", hash = "sha256:c0ec5c0191c8ee39fcc8afc1540331d8b55b0183478c50e9c8a79d44dbceb1d8", size = 18540, upload-time = "2026-01-29T07:00:24.994Z" }, + { url = "https://files.pythonhosted.org/packages/60/e9/6734502f67533a752ea8b1c8f7f227c94eecf300252ba8bf23e3e59d8a36/cssselect-1.5.0-py3-none-any.whl", hash = "sha256:1d1aded98e82bdde447ded990a191fd6916177c4f0c914fb62eccd58e2ffcdcc", size = 20797, upload-time = "2026-07-27T09:17:33.04Z" }, ] [[package]] name = "curl-cffi" -version = "0.15.0" +version = "0.16.1b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "cffi" }, - { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded", size = 196437, upload-time = "2026-04-03T11:12:31.525Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/dd/c82ac7733392eb4abc52d021a303e01fbfe5537f3995bfe79c978360b5b0/curl_cffi-0.16.1b1.tar.gz", hash = "sha256:e8ebf9c4f199aebdfb240209454a9689df8cecd2493ff2bcdd21315dc4b19b8a", size = 238371, upload-time = "2026-08-08T15:36:23.359Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28", size = 2795267, upload-time = "2026-04-03T11:11:46.48Z" }, - { url = "https://files.pythonhosted.org/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b", size = 2573544, upload-time = "2026-04-03T11:11:47.951Z" }, - { url = "https://files.pythonhosted.org/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527", size = 10515369, upload-time = "2026-04-03T11:11:50.126Z" }, - { url = "https://files.pythonhosted.org/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3", size = 10160045, upload-time = "2026-04-03T11:11:52.664Z" }, - { url = "https://files.pythonhosted.org/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc", size = 11090433, upload-time = "2026-04-03T11:11:55.049Z" }, - { url = "https://files.pythonhosted.org/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a", size = 10479178, upload-time = "2026-04-03T11:11:57.685Z" }, - { url = "https://files.pythonhosted.org/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b", size = 10317051, upload-time = "2026-04-03T11:12:00.295Z" }, - { url = "https://files.pythonhosted.org/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13", size = 11299660, upload-time = "2026-04-03T11:12:02.791Z" }, - { url = "https://files.pythonhosted.org/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61", size = 11945049, upload-time = "2026-04-03T11:12:05.912Z" }, - { url = "https://files.pythonhosted.org/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572", size = 1661649, upload-time = "2026-04-03T11:12:07.948Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d", size = 1410741, upload-time = "2026-04-03T11:12:10.073Z" }, - { url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427, upload-time = "2026-04-03T11:12:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/11/56/132225cb3491d07cc6adcce5fe395e059bde87c68cff1ef87a31c88c7819/curl_cffi-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:967ad7355bd8e9586f8c2d02eaa99953747549e7ea4a9b25cd53353e6b67fe6d", size = 2795723, upload-time = "2026-04-03T11:12:13.668Z" }, - { url = "https://files.pythonhosted.org/packages/07/8f/f4f83cd303bef7e8f1749512e5dd157e7e5d08b0a36c8211f9640a2757bf/curl_cffi-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e63539d0d839d0a8c5eacf86229bc68c57803547f35e0db7ee0986328b478c3", size = 2573739, upload-time = "2026-04-03T11:12:15.08Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5c/643d65c7fc9acd742876aa55c2d7823c438cb7665810acd2e66c9976c4d9/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08c799b89740b9bc49c09fbc3d5907f13ac1f845ca52620507ef9466d4639dd5", size = 10521046, upload-time = "2026-04-03T11:12:17.034Z" }, - { url = "https://files.pythonhosted.org/packages/7f/0b/9b8037113c93f4c5323096163471fa7c35c7676c3f608eeaf1287cd99d58/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b7a92767a888ee90147e18964b396d8435ff42737030d6fb00824ffd6094805", size = 11096115, upload-time = "2026-04-03T11:12:19.694Z" }, - { url = "https://files.pythonhosted.org/packages/5f/96/fff2fcbd924ef4042e0d67379f751a8a4e3186a91e75e35a4cf218b306ee/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:829cc357061ecb99cc2d406301f609a039e05665322f5c025ec67c38b0dc49ce", size = 11305346, upload-time = "2026-04-03T11:12:22.151Z" }, - { url = "https://files.pythonhosted.org/packages/53/1b/304b253a45ab28691c8c5e8cca1e6cbb9cf8e46dfceae4648dd536f75e73/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:408d6f14e346841cd889c2e0962832bb235ba3b6749ebf609f347f747da5e60f", size = 11949834, upload-time = "2026-04-03T11:12:24.986Z" }, - { url = "https://files.pythonhosted.org/packages/5a/ff/4723d92f08259c707a974aba27a08d0a822b9555e35ca581bf18d055a364/curl_cffi-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b624c7ce087bfda967a013ed0a64702a525444e5b6e97d23534d567ccc6525aa", size = 1702771, upload-time = "2026-04-03T11:12:28.201Z" }, - { url = "https://files.pythonhosted.org/packages/59/8c/36bbe06d66fa2b765e4a07199f643a59a9cd1a754207a96335402a9520f4/curl_cffi-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0b6c0543b993996670e9e4b78e305a2d60809d5681903ffb5568e21a387434d3", size = 1466312, upload-time = "2026-04-03T11:12:30.054Z" }, -] - -[[package]] -name = "decorator" -version = "5.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/64/1f/02632e01614e15cf61cd7d8a0b1b2e4737f0cf1f8eee655c56ccb32214ef/curl_cffi-0.16.1b1-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:60b2c4ff2a77d8e6b5b09ff107015d0b1883832f8b548c6a9a7db1cfa47f481a", size = 3024104, upload-time = "2026-08-08T15:35:43.771Z" }, + { url = "https://files.pythonhosted.org/packages/c1/76/73606685b25d12e3f093cb731a798e820d6c9c07b898691c598d6fc4925a/curl_cffi-0.16.1b1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:30988e850d26b31baa428211a139bd464e22485cc75080f1cfa7e2ee02f8358b", size = 2781130, upload-time = "2026-08-08T15:35:46.035Z" }, + { url = "https://files.pythonhosted.org/packages/f1/80/78bf627eb22c8f3361829598258d557297ab786092b6525e36510715f060/curl_cffi-0.16.1b1-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9e76e9053ebabd3a2d2042a8828d1aabf702048819a9b319bce65dad30fcab6e", size = 12824762, upload-time = "2026-08-08T15:35:47.954Z" }, + { url = "https://files.pythonhosted.org/packages/86/5c/4cec0b7208c27132f5e8cf8797df709e688a9687e82a00a7a98a42e32a31/curl_cffi-0.16.1b1-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:7b362db3f74203e58d2b752242aea412df840a3f7cdbe9f81590e2a43360eb38", size = 12649202, upload-time = "2026-08-08T15:35:50.193Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/c1a75571f1881e5484d2cce8cf863a9f7ebcfbaf723908ce0a7d25346067/curl_cffi-0.16.1b1-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a069c9b87a5b8cbe08c678bf6af715f9768b4faab1e9ce5f4696d803f44202d8", size = 13473345, upload-time = "2026-08-08T15:35:52.23Z" }, + { url = "https://files.pythonhosted.org/packages/bc/57/ba98b7ebbf7136d313773d052352122a670205a38996b217f819c0e4fb95/curl_cffi-0.16.1b1-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ec14c165787bbcdeb7060deb8ea69c1038b0b3c13313b3175b30e70a3c770115", size = 12831686, upload-time = "2026-08-08T15:35:54.351Z" }, + { url = "https://files.pythonhosted.org/packages/b8/46/23fb9df510aa2065605c7b8aeac419300d9e4594861969e1158a2d174e75/curl_cffi-0.16.1b1-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c416727f7ee3585f5671d0b80ad812ae672910184e12a19286aa9dd461801e81", size = 12603747, upload-time = "2026-08-08T15:35:56.413Z" }, + { url = "https://files.pythonhosted.org/packages/ee/d9/f59e0c010084e67ccf35cbfb9dd5e11b4ef9bdccebe504306ed9047c5e85/curl_cffi-0.16.1b1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a307db6704331c774634699034a8fd4d63955737872fcabaf29824daa2858dce", size = 12578247, upload-time = "2026-08-08T15:35:58.32Z" }, + { url = "https://files.pythonhosted.org/packages/f8/57/52d626e885514b46b224e154fa9933e164c5d8f5dfc10c37d245502bdf44/curl_cffi-0.16.1b1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3412c8dc19913af6f25d6962bac2384102cc934b161c5a9a67c60f2119768de8", size = 13240190, upload-time = "2026-08-08T15:36:00.348Z" }, + { url = "https://files.pythonhosted.org/packages/c0/72/7590486dc8a6f5accbcd4dd19317b07304fff018fce1f0a0ddd66ad6e5a4/curl_cffi-0.16.1b1-cp310-abi3-win_amd64.whl", hash = "sha256:4f9fb919a775442fa97e8acba2bd6ef3b3f5597576c909acd097bb5a82beb0a2", size = 1977029, upload-time = "2026-08-08T15:36:02.508Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/fba1292d4524a6ee3c38969fb7e9a96d4cde17e09f503649c43754501c07/curl_cffi-0.16.1b1-cp310-abi3-win_arm64.whl", hash = "sha256:d8e375cd3ce1aba54934657d64f6f94236beb89c463adc66e5f3cb745807966f", size = 1711478, upload-time = "2026-08-08T15:36:04Z" }, + { url = "https://files.pythonhosted.org/packages/0e/3c/4cce5d9d498e9da9bbc401c857af8d0bf678bfb1a5f448cb9d27b40bf3a3/curl_cffi-0.16.1b1-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:f2e14688c5ff173f6d68db334a561c456bb8f949374401ca2cfbcbd8c45dcfc2", size = 8605344, upload-time = "2026-08-08T15:36:05.325Z" }, + { url = "https://files.pythonhosted.org/packages/23/5a/2d69c77ec808045cbbafd46129c80d4d0777dcf35ae7b690f9596071c780/curl_cffi-0.16.1b1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b1130e313cec3aa920ec125f86c1a3e68092a24950ae6328cf315518d96cf900", size = 3024593, upload-time = "2026-08-08T15:36:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/cc/15/427ffcb57a4460f05830786c1d385c55b7374b79a8c01b6517ca9515db27/curl_cffi-0.16.1b1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b210d1decd8c1e5661ef09560a59660f1eabbd0595c3773e459bb9dd95a073b", size = 2781363, upload-time = "2026-08-08T15:36:09.356Z" }, + { url = "https://files.pythonhosted.org/packages/04/e2/a3f92e661331943039323c6728a56b445b5643998de6259d7f860ef9330c/curl_cffi-0.16.1b1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08d8e6b0563c02384f0978bbdd629e900969bf7160bd26ad87b96727443515ef", size = 12829350, upload-time = "2026-08-08T15:36:11.219Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5d/71e8eeaabbc3e28023047f36d2bb9bfd07cbafca3bfcb3d5a6ca1aa0725e/curl_cffi-0.16.1b1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7651d5a1b2f31805eb4a9643edf1a92dc93eac96d435cfc079bf2501a0688a8", size = 13481684, upload-time = "2026-08-08T15:36:14.086Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/0f298f393c64a23c4ae2b69cd026f2e8ecc107877546cf7547ad2412a9c8/curl_cffi-0.16.1b1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf6e4c3b69abcabf430c3c77c42e83ca627ca400b0b51675d44b0a6293ff4baa", size = 12585108, upload-time = "2026-08-08T15:36:16.344Z" }, + { url = "https://files.pythonhosted.org/packages/13/9e/26abad15430ba0876815ba3fd1d5cb9e7c12b0168e28069d4d4d5c9da48b/curl_cffi-0.16.1b1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:02aed7c105073e5e5f6f2a4dc77304c85afe93a304773a8033d7fe760ab46c7e", size = 13246680, upload-time = "2026-08-08T15:36:18.506Z" }, + { url = "https://files.pythonhosted.org/packages/a3/bd/5c81275a1e175923e357e92361da21e5699d3a672c46cf165c418c93939b/curl_cffi-0.16.1b1-cp314-cp314t-win_amd64.whl", hash = "sha256:0ce63291b88689fb46880a1360bcdaf23f53d934042c7a7616032f5f985dcb8a", size = 2029956, upload-time = "2026-08-08T15:36:20.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/d1/4233bb3c066d6f9dc4ae1513ecf6372047195299e8c4abded248ed3b279a/curl_cffi-0.16.1b1-cp314-cp314t-win_arm64.whl", hash = "sha256:51628f157b116ad2ebe0af6f33a70937bce4f9f90d82be5df959ec5f02ca02d0", size = 1779317, upload-time = "2026-08-08T15:36:22.047Z" }, ] [[package]] @@ -422,15 +331,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, ] -[[package]] -name = "executing" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, -] - [[package]] name = "greenlet" version = "3.5.4" @@ -518,16 +418,13 @@ dependencies = [ { name = "openpyxl" }, { name = "ormsgpack" }, { name = "pandas" }, - { name = "patchright" }, { name = "pillow" }, - { name = "playwright" }, { name = "psutil" }, { name = "psycopg", extra = ["binary", "pool"] }, { name = "psycopg2-binary" }, { name = "python-dotenv" }, { name = "requests" }, - { name = "scrapling", extra = ["all"] }, - { name = "selenium" }, + { name = "scrapling", extra = ["fetchers"] }, { name = "xlrd" }, ] @@ -546,7 +443,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "browserforge", specifier = ">=1.2.4" }, - { name = "curl-cffi", specifier = ">=0.15.0" }, + { name = "curl-cffi", specifier = "==0.16.1b1" }, { name = "httpx", specifier = ">=0.27" }, { name = "imageio-ffmpeg", specifier = ">=0.6.0" }, { name = "langchain", specifier = ">=0.3.0" }, @@ -560,17 +457,14 @@ requires-dist = [ { name = "openpyxl", specifier = ">=3.1.5" }, { name = "ormsgpack", specifier = ">=1.2.0" }, { name = "pandas", specifier = ">=3.0.3" }, - { name = "patchright", specifier = ">=1.60.1" }, { name = "pillow", specifier = ">=10" }, - { name = "playwright", specifier = ">=1.60.0" }, { name = "psutil", specifier = ">=5.9" }, { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.1" }, { name = "psycopg2-binary", specifier = ">=2.9.12" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "requests", specifier = ">=2.31.0" }, - { name = "scrapling", extras = ["all"], specifier = ">=0.4.9" }, - { name = "selenium", specifier = ">=4.20" }, + { name = "scrapling", extras = ["fetchers"], specifier = "==0.4.13" }, { name = "xlrd", specifier = ">=2.0.1" }, ] provides-extras = ["test"] @@ -619,15 +513,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] -[[package]] -name = "httpx-sse" -version = "0.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, -] - [[package]] name = "idna" version = "3.18" @@ -660,52 +545,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "ipython" -version = "9.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", size = 630895, upload-time = "2026-06-26T11:03:33.809Z" }, -] - -[[package]] -name = "ipython-pygments-lexers" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, -] - -[[package]] -name = "jedi" -version = "0.20.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "parso" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, -] - [[package]] name = "jiter" version = "0.16.0" @@ -795,33 +634,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, ] -[[package]] -name = "jsonschema" -version = "4.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, -] - [[package]] name = "langchain" version = "1.3.14" @@ -1076,77 +888,6 @@ wheels = [ { 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 = "markdown-it-py" -version = "4.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, -] - -[[package]] -name = "markdownify" -version = "1.2.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beautifulsoup4" }, - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/92/ab/d1297139c0e2ceb151ae564c8c4f57ac0155d8f1f8b4cbd5d6523c82ea36/markdownify-1.2.3.tar.gz", hash = "sha256:1a176f05522c8a2cb1dd3ab9d307dcdadbed5c26ae717855bfc42b3b6d38d937", size = 18852, upload-time = "2026-06-30T20:27:39.06Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/10/fa543d484e8b1199243fe20eedd02cc5af050edebce98a7293a5773df592/markdownify-1.2.3-py3-none-any.whl", hash = "sha256:a189a0bedfd14009030fde5f85bb6f77c56897cb839b5c25315dd7d4e3e290ba", size = 15732, upload-time = "2026-06-30T20:27:38.094Z" }, -] - -[[package]] -name = "matplotlib-inline" -version = "0.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, -] - -[[package]] -name = "mcp" -version = "1.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "jsonschema" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "python-multipart" }, - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "sse-starlette" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - [[package]] name = "msgspec" version = "0.21.1" @@ -1361,18 +1102,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, ] -[[package]] -name = "outcome" -version = "1.3.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, -] - [[package]] name = "packaging" version = "26.2" @@ -1428,15 +1157,6 @@ wheels = [ { 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 = "parso" -version = "0.8.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, -] - [[package]] name = "patchright" version = "1.61.2" @@ -1456,18 +1176,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/96/67a791c6b7cd76424b44d356beaf414ab97c3aef2d42fad4641e7905b41d/patchright-1.61.2-py3-none-win_arm64.whl", hash = "sha256:3dee1e2daada9f98653763831f688d39c924e0cf0eeb988e8047b703766cee73", size = 34268624, upload-time = "2026-07-05T21:18:15.966Z" }, ] -[[package]] -name = "pexpect" -version = "4.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, -] - [[package]] name = "pillow" version = "12.3.0" @@ -1567,18 +1275,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] -[[package]] -name = "prompt-toolkit" -version = "3.0.53" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wcwidth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, -] - [[package]] name = "protego" version = "0.6.2" @@ -1730,24 +1426,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, ] -[[package]] -name = "ptyprocess" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, -] - -[[package]] -name = "pure-eval" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, -] - [[package]] name = "pycparser" version = "3.0" @@ -1877,20 +1555,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.14.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, -] - [[package]] name = "pyee" version = "13.0.1" @@ -1912,20 +1576,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] -[[package]] -name = "pyjwt" -version = "2.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, -] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography" }, -] - [[package]] name = "pyproject-hooks" version = "1.2.0" @@ -1935,15 +1585,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, ] -[[package]] -name = "pysocks" -version = "1.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, -] - [[package]] name = "pytest" version = "9.1.1" @@ -1981,34 +1622,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] -[[package]] -name = "python-multipart" -version = "0.0.32" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, -] - -[[package]] -name = "pywin32" -version = "312" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, - { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, - { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, - { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, - { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, - { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, - { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, - { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, - { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, - { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, - { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -2055,20 +1668,6 @@ wheels = [ { 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 = "referencing" -version = "0.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, -] - [[package]] name = "regex" version = "2026.7.19" @@ -2184,115 +1783,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] -[[package]] -name = "rich" -version = "15.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, -] - -[[package]] -name = "rpds-py" -version = "2026.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, - { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, - { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, - { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, - { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, - { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, - { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, - { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, - { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, - { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, - { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, - { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, - { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, - { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, - { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, - { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, - { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, - { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, - { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, - { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, - { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, - { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, - { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, - { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, - { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, - { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, - { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, - { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, - { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, - { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, - { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, - { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, - { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, - { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, - { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, - { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, - { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, - { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, - { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, - { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, - { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, - { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, - { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, - { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, - { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, - { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, - { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, - { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, - { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, - { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, - { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, - { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, - { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, - { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, - { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, - { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, - { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, - { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, - { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, - { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, - { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, - { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, - { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, - { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, - { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, - { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, - { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, - { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, - { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, - { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, -] - [[package]] name = "ruff" version = "0.16.0" @@ -2320,7 +1810,7 @@ wheels = [ [[package]] name = "scrapling" -version = "0.4.12" +version = "0.4.13" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cssselect" }, @@ -2330,44 +1820,24 @@ dependencies = [ { name = "typing-extensions" }, { name = "w3lib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/a1/f1dc696450c65bd2b12e0b2b5c88de9d38ddec3f13f584c1859e12725f2b/scrapling-0.4.12.tar.gz", hash = "sha256:c6f06d0ea54208d430d47402c4e66760280718dc5b116fa99985beb7b9a517f4", size = 167523, upload-time = "2026-07-26T20:24:03.506Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/67/8c5ecba72823b880af1f5dd4a605a80391facc4d48a12de76c499e837410/scrapling-0.4.13.tar.gz", hash = "sha256:06a0e1c5a00bed06ace77da16f1bd4970223afcb3a57a699355f5d6d5a99ed63", size = 170860, upload-time = "2026-08-09T19:07:12.182Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/64/845a2c5e4dbda0403e0662d553b94c84bd08a60f9ded5b04abcb9994e43e/scrapling-0.4.12-py3-none-any.whl", hash = "sha256:bff83185a226f14c1e50e4408a2d24c93bc6ffe4b83a904d2f11ad1ad3823a91", size = 169304, upload-time = "2026-07-26T20:24:01.958Z" }, + { url = "https://files.pythonhosted.org/packages/74/ee/407b6704915a6e88f35d00202d077e7f617d71728763fc5ccd4e69a00b68/scrapling-0.4.13-py3-none-any.whl", hash = "sha256:4e7ec50f158cfe273aeb5a6f26f537f0767fa94493b7633c31843611c27e6d13", size = 173326, upload-time = "2026-08-09T19:07:10.432Z" }, ] [package.optional-dependencies] -all = [ +fetchers = [ { name = "anyio" }, { name = "apify-fingerprint-datapoints" }, { name = "browserforge" }, { name = "click" }, { name = "curl-cffi" }, - { name = "ipython" }, - { name = "markdownify" }, - { name = "mcp" }, { name = "msgspec" }, { name = "patchright" }, { name = "playwright" }, { name = "protego" }, ] -[[package]] -name = "selenium" -version = "4.46.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "trio" }, - { name = "trio-websocket" }, - { name = "typing-extensions" }, - { name = "urllib3", extra = ["socks"] }, - { name = "websocket-client" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a0/53/053df98ef0c38535a74ea732217eb2dbfddc7ff5442a47a4b315ba577f85/selenium-4.46.0.tar.gz", hash = "sha256:54f7e1a4df5f7508ac8c38ce2ea584db1b27083dd79962b22524219219df5cbe", size = 1006000, upload-time = "2026-07-11T00:38:41.11Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/30/f4/365c723a37c7e9ba6a6a19bf0c9e407137703f56a72c6201d0e4cb1432cb/selenium-4.46.0-py3-none-any.whl", hash = "sha256:f03e864770a21ab32009961c9d015cb2c6275eba45c926c272e02d90af423aad", size = 9428587, upload-time = "2026-07-11T00:38:38.84Z" }, -] - [[package]] name = "six" version = "1.17.0" @@ -2386,64 +1856,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] -[[package]] -name = "sortedcontainers" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, -] - -[[package]] -name = "soupsieve" -version = "2.9.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d9/38/e12680bbe6b4f8f3d17adcaf38d26850aa756c85cf4a80e79fc12a018fe8/soupsieve-2.9.1.tar.gz", hash = "sha256:c33e6605bbc71dd628b00c632d58ae607c22bade247e52553928f83bbb75b4ba", size = 122261, upload-time = "2026-07-21T16:57:17.452Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl", hash = "sha256:4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c", size = 37404, upload-time = "2026-07-21T16:57:16.421Z" }, -] - -[[package]] -name = "sse-starlette" -version = "3.4.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "starlette" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6c/10/a34c656829ffc1c4b22ef36d70d9ebb6b99c020e2aeb17cee5485099f028/sse_starlette-3.4.6.tar.gz", hash = "sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627", size = 32542, upload-time = "2026-07-20T14:16:32.201Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/36/e10c1d1b7ca881d2625db2ec28508578499187bb1c389952c398474e1834/sse_starlette-3.4.6-py3-none-any.whl", hash = "sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6", size = 16516, upload-time = "2026-07-20T14:16:30.978Z" }, -] - -[[package]] -name = "stack-data" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "asttokens" }, - { name = "executing" }, - { name = "pure-eval" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, -] - -[[package]] -name = "starlette" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, -] - [[package]] name = "tenacity" version = "9.1.4" @@ -2521,46 +1933,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/50/5817619a0fca56aff06383dbfde7ae017b3ca383915b3f1e4713164273cf/tqdm-4.69.1-py3-none-any.whl", hash = "sha256:0a654b96f7a2660cceb615b56f307ec2bef96c515409014a429a561981ab52b4", size = 675452, upload-time = "2026-07-24T14:22:00.048Z" }, ] -[[package]] -name = "traitlets" -version = "5.15.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, -] - -[[package]] -name = "trio" -version = "0.33.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, - { name = "idna" }, - { name = "outcome" }, - { name = "sniffio" }, - { name = "sortedcontainers" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/52/b6/c744031c6f89b18b3f5f4f7338603ab381d740a7f45938c4607b2302481f/trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970", size = 605109, upload-time = "2026-02-14T18:40:55.386Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" }, -] - -[[package]] -name = "trio-websocket" -version = "0.12.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "outcome" }, - { name = "trio" }, - { name = "wsproto" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549, upload-time = "2025-02-25T05:16:58.947Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" }, -] - [[package]] name = "typing-extensions" version = "4.16.0" @@ -2600,11 +1972,6 @@ 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" }, ] -[package.optional-dependencies] -socks = [ - { name = "pysocks" }, -] - [[package]] name = "uuid-utils" version = "0.17.0" @@ -2671,19 +2038,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/11/bd1c70e1ad3301163cebe66c8d26de26e6814d52f642a849448bd2833626/uuid_utils-0.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1776a80d16369999b21627028cc5dbce819be83e1e079fdd7a51b587d2916db9", size = 175004, upload-time = "2026-07-09T13:49:45.591Z" }, ] -[[package]] -name = "uvicorn" -version = "0.51.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click", marker = "sys_platform != 'emscripten'" }, - { name = "h11", marker = "sys_platform != 'emscripten'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, -] - [[package]] name = "w3lib" version = "2.4.1" @@ -2693,24 +2047,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/c3/f8b216cbd742e5b84c40f045204c764ccb7524d2aeab021054ec69446b0a/w3lib-2.4.1-py3-none-any.whl", hash = "sha256:40930132907e68de906a5b89331ab8c8ff4f01bd35b5539ef7896017d814138d", size = 21695, upload-time = "2026-03-20T09:50:26.187Z" }, ] -[[package]] -name = "wcwidth" -version = "0.8.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, -] - -[[package]] -name = "websocket-client" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, -] - [[package]] name = "websockets" version = "15.0.1" @@ -2742,18 +2078,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] -[[package]] -name = "wsproto" -version = "1.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, -] - [[package]] name = "xlrd" version = "2.0.2"