chore: [workflow] 提交工作区代码

This commit is contained in:
wangyunlong committed 2026-09-30 10:46:15 +08:00
1 parent 2ff588a65d
commit 01f3d7aaf7
131 files changed
+24158 -1172

No files matched your search

+2 -2
View File
@@ -19,13 +19,13 @@ lint:
stage: quality
script:
- uv run ruff check src tests
- uv run ruff check src/gyxx_flow/modules/content_marketing/daily_creator_exposure_scope.py src/gyxx_flow/modules/product_commerce/market_rank_limits.py src/gyxx_flow/modules/product_commerce/market_rank_product_import.py
- uv run ruff check src/gyxx_flow/modules/content_marketing/daily_creator_exposure_scope.py src/gyxx_flow/modules/content_marketing/chanmama_export.py src/gyxx_flow/modules/content_marketing/self_operated_accounts.py src/gyxx_flow/modules/content_marketing/self_xhs_scraper.py src/gyxx_flow/modules/content_marketing/data/tools/sync_self_video_metrics.py src/gyxx_flow/modules/product_commerce/market_rank_limits.py src/gyxx_flow/modules/product_commerce/market_rank_product_import.py src/gyxx_flow/modules/shop_intelligence/collectors/dy_after_sales_quality.py src/gyxx_flow/modules/shop_intelligence/collectors/tmall_negative_reviews.py
test:
stage: test
script:
- uv run pytest
- uv run pytest tests/modules/content_marketing/test_daily_creator_exposure_scope.py tests/modules/content_marketing/test_chanmama_refresh_before_export.py tests/modules/content_marketing/test_daily_exposure_fields.py
- uv run pytest tests/modules/content_marketing/test_daily_creator_exposure_scope.py tests/modules/content_marketing/test_chanmama_refresh_before_export.py tests/modules/content_marketing/test_daily_exposure_fields.py tests/modules/content_marketing/test_self_content_metrics.py
- uv run pytest tests/modules/product_commerce/test_market_rank_limits.py tests/modules/product_commerce/test_market_rank_product_import.py
build:
+122 -2
View File
@@ -25,12 +25,24 @@
"entry": "chanmama_scraper.py",
"kind": "python"
},
{
"id": "content.metrics.collect_self_xiaohongshu",
"module": "content_marketing",
"entry": "self_xhs_scraper.py",
"kind": "python"
},
{
"id": "content.metrics.sync",
"module": "content_marketing",
"entry": "data/tools/sync_metrics_to_cmt_notes.py",
"kind": "python"
},
{
"id": "content.metrics.sync_self_video_metrics",
"module": "content_marketing",
"entry": "data/tools/sync_self_video_metrics.py",
"kind": "python"
},
{
"id": "content.marketing_report.generate",
"module": "content_marketing",
@@ -137,6 +149,98 @@
"--headless"
]
},
{
"id": "shop.jd_customer_service.collect",
"module": "shop_intelligence",
"entry": "collectors/jd_customer_service_messages.py",
"kind": "python",
"default_args": [
"--date",
"{business_date}",
"--headless"
]
},
{
"id": "shop.jd_negative_reviews.collect",
"module": "shop_intelligence",
"entry": "collectors/jd_negative_reviews.py",
"kind": "python",
"default_args": [
"--date",
"{business_date}",
"--headless"
]
},
{
"id": "shop.jd_after_sales_issues.collect",
"module": "shop_intelligence",
"entry": "collectors/jd_after_sales_issues.py",
"kind": "python",
"default_args": [
"--date",
"{business_date}",
"--headless"
]
},
{
"id": "shop.douyin_customer_service.collect",
"module": "shop_intelligence",
"entry": "collectors/dy_customer_service_messages.py",
"kind": "python",
"default_args": [
"--date",
"{business_date}",
"--headless"
]
},
{
"id": "shop.tmall_negative_reviews.collect",
"module": "shop_intelligence",
"entry": "collectors/tmall_negative_reviews.py",
"kind": "python",
"default_args": [
"--start-date",
"{business_date}",
"--end-date",
"{business_date}",
"--headless"
]
},
{
"id": "shop.tmall_customer_service.collect",
"module": "shop_intelligence",
"entry": "collectors/tmall_customer_service_messages.py",
"kind": "python",
"default_args": [
"--start-date",
"{business_date}",
"--end-date",
"{business_date}",
"--headless"
]
},
{
"id": "shop.dy_negative_reviews.collect",
"module": "shop_intelligence",
"entry": "collectors/dy_negative_reviews.py",
"kind": "python",
"default_args": [
"--date",
"{business_date}",
"--headless"
]
},
{
"id": "shop.douyin_after_sales_quality.collect",
"module": "shop_intelligence",
"entry": "collectors/dy_after_sales_quality.py",
"kind": "python",
"default_args": [
"--date",
"{business_date}",
"--headless"
]
},
{
"id": "shop.douyin_price_appeal",
"module": "shop_intelligence",
@@ -151,7 +255,11 @@
"id": "product.persona.collect",
"module": "product_commerce",
"entry": "run_daily_persona.py",
"kind": "python"
"kind": "python",
"default_args": [
"--date",
"{business_date}"
]
},
{
"id": "product.daily.orchestrate",
@@ -166,6 +274,13 @@
"kind": "python",
"notification_workflow_id": "product.alert.daily"
},
{
"id": "product.online_stock_alert.run",
"module": "product_commerce",
"entry": "online_stock_alert.py",
"kind": "python",
"notification_workflow_id": "product.online_stock_alert.daily"
},
{
"id": "product.import.daily",
"module": "product_commerce",
@@ -176,7 +291,12 @@
"id": "product.tmall_wanxiang_ads.collect",
"module": "product_commerce",
"entry": "collect_tmall_wanxiang_ads.py",
"kind": "python"
"kind": "python",
"default_args": [
"--date",
"{business_date}",
"--headless"
]
},
{
"id": "product.import.tmall_ads",
+37 -2
View File
@@ -14,6 +14,17 @@
}
},
"people": {
"online_stock_alert_recipient": {
"name": "线上库存预警接收人",
"mobile": null,
"bindings": {
"hermes-analyzer": {
"open_id": "ou_cd858437acecee9404c9920d2dc04b2a",
"verified": true,
"source": "mobile_lookup"
}
}
},
"he_yingwei": {
"name": "何颖威",
"mobile": null,
@@ -27,7 +38,7 @@
},
"wang_yunlong": {
"name": "王云龙",
"mobile": null,
"mobile": "+8613663785145",
"bindings": {
"collector": {
"open_id": "ou_8ee224968aa26a74c7d30ba27fed5eeb",
@@ -194,6 +205,17 @@
"source": "provided"
}
}
},
"stock_alert_user_19832771651": {
"name": "19832771651",
"mobile": "+8619832771651",
"bindings": {
"hermes-analyzer": {
"open_id": "ou_9136c3203c698fead5477eea827f2bcf",
"verified": true,
"source": "mobile_lookup"
}
}
}
},
"capabilities": {
@@ -209,6 +231,9 @@
"product.market_rank": {
"condition": "存在本次新生成的平台报告链接且尚未重复通知时投递。"
},
"product.online_stock_alert.daily": {
"condition": "线上商品当前库存低于 3 时向配置接收人发送库存预警。"
},
"supply.purchase_confirmation.daily": {
"condition": "采购确认结果生成完成后投递最终业务通知。"
},
@@ -219,5 +244,15 @@
"condition": "存在命中库存阈值的 SKU 时投递预警。"
}
},
"routes": {}
"routes": {
"product.online_stock_alert.daily": {
"enabled": true,
"app_profile": "hermes-analyzer",
"person_ids": [
"online_stock_alert_recipient",
"wang_yunlong",
"stock_alert_user_19832771651"
]
}
}
}
+203 -2
View File
@@ -33,7 +33,10 @@
"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"
"content_marketing:xingtu_scraper_v2.py",
"shop.douyin_customer_service.collect",
"shop.douyin_after_sales_quality.collect",
"shop.dy_negative_reviews.collect"
]
},
"jd-shop": {
@@ -50,6 +53,9 @@
"JD_SHOP",
"JD_PASSWORD"
],
"login_identity_markers": [
"光影行星 GYXX官方旗舰店"
],
"keepalive": {
"enabled": true,
"url": "https://shop.jd.com/jdm/home",
@@ -68,11 +74,15 @@
"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"
"shop_intelligence:collectors/jd_peer_store_data_collector.py",
"shop.jd_customer_service.collect",
"shop.jd_negative_reviews.collect",
"shop.jd_after_sales_issues.collect"
]
},
"jd-self-operated": {
"cdp_port": 22202,
"collection_cdp_port": 22155,
"login_mode": "D",
"required_cookie_domains": [
"jd.com"
@@ -208,6 +218,8 @@
"product_commerce:taobao_sycm_products.py",
"product_commerce:taobao_wanxiang_ai_creative_report.py",
"shop_intelligence:collectors/taobao_sycm.py",
"shop.tmall_customer_service.collect",
"shop.tmall_negative_reviews.collect",
"product.tmall_baibu_apply.old_chain_bag",
"product.tmall_baibu_apply.old_all_bag",
"product.tmall_baibu_apply.old_all_3c",
@@ -768,6 +780,25 @@
],
"cdp_port": 22036
},
"content.metrics.sync_self_video_metrics": {
"script_id": "content_marketing:data/tools/sync_self_video_metrics.py",
"state_key": "content_marketing:data/tools/sync_self_video_metrics.py",
"aliases": [
"content_marketing:data/tools/sync_self_video_metrics.py"
],
"cdp_port": 22164
},
"content.metrics.collect_self_xiaohongshu": {
"script_id": "content_marketing:self_xhs_scraper.py",
"state_key": "content_marketing:self_xhs_scraper.py",
"aliases": [
"content_marketing:self_xhs_scraper.py"
],
"cdp_port": 22165,
"login_mode": "C",
"required_cookie_domains": ["xiaohongshu.com"],
"credential_env_names": ["XHS_ARK_ACCOUNT"]
},
"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",
@@ -1177,6 +1208,15 @@
],
"cdp_port": 22069
},
"product.online_stock_alert.run": {
"script_id": "product_commerce:online_stock_alert.py",
"state_key": "product_commerce:online_stock_alert.py",
"aliases": [
"product_commerce:online_stock_alert.py",
"product.online_stock_alert.run"
],
"cdp_port": 22163
},
"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",
@@ -1604,6 +1644,46 @@
],
"account": "douyin-shop"
},
"shop.douyin_customer_service.collect": {
"script_id": "shop_intelligence:collectors/dy_customer_service_messages.py",
"state_key": "shop_intelligence:collectors/dy_customer_service_messages.py",
"aliases": [
"shop_intelligence:collectors/dy_customer_service_messages.py"
],
"cdp_port": 22158,
"login_mode": "D",
"required_cookie_domains": [
"jinritemai.com"
],
"required_cookie_names": [
"sessionid",
"sid_tt",
"uid_tt",
"sessionid_ss",
"sid_guard"
],
"account": "douyin-shop"
},
"shop.douyin_after_sales_quality.collect": {
"script_id": "shop_intelligence:collectors/dy_after_sales_quality.py",
"state_key": "shop_intelligence:collectors/dy_after_sales_quality.py",
"aliases": [
"shop_intelligence:collectors/dy_after_sales_quality.py"
],
"cdp_port": 22159,
"login_mode": "D",
"required_cookie_domains": [
"jinritemai.com"
],
"required_cookie_names": [
"sessionid",
"sid_tt",
"uid_tt",
"sessionid_ss",
"sid_guard"
],
"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",
@@ -1666,6 +1746,107 @@
"JD_SELF_OPERATED_PASSWORD"
]
},
"shop.jd_customer_service.collect": {
"script_id": "shop_intelligence:collectors/jd_customer_service_messages.py",
"state_key": "shop_intelligence:collectors/jd_customer_service_messages.py",
"aliases": [
"shop_intelligence:collectors/jd_customer_service_messages.py"
],
"cdp_port": 22154,
"login_mode": "D",
"required_cookie_domains": [
"jd.com"
],
"required_cookie_names": [
"pin",
"thor"
],
"credential_env_names": [
"JD_SHOP",
"JD_PASSWORD"
],
"account": "jd-shop"
},
"shop.tmall_negative_reviews.collect": {
"script_id": "shop_intelligence:collectors/tmall_negative_reviews.py",
"state_key": "shop_intelligence:collectors/tmall_negative_reviews.py",
"aliases": [
"shop_intelligence:collectors/tmall_negative_reviews.py"
],
"cdp_port": 22162,
"login_mode": "D",
"required_cookie_domains": [
"taobao.com",
"alimama.com"
],
"required_cookie_names": [
"unb",
"cookie2",
"_tb_token_"
],
"account": "tmall-shop"
},
"shop.tmall_customer_service.collect": {
"script_id": "shop_intelligence:collectors/tmall_customer_service_messages.py",
"state_key": "shop_intelligence:collectors/tmall_customer_service_messages.py",
"aliases": [
"shop_intelligence:collectors/tmall_customer_service_messages.py"
],
"cdp_port": 22161,
"login_mode": "D",
"required_cookie_domains": [
"taobao.com",
"alimama.com"
],
"required_cookie_names": [
"unb",
"cookie2",
"_tb_token_"
],
"account": "tmall-shop"
},
"shop.jd_negative_reviews.collect": {
"script_id": "shop_intelligence:collectors/jd_negative_reviews.py",
"state_key": "shop_intelligence:collectors/jd_negative_reviews.py",
"aliases": [
"shop_intelligence:collectors/jd_negative_reviews.py"
],
"cdp_port": 22156,
"login_mode": "D",
"required_cookie_domains": [
"jd.com"
],
"required_cookie_names": [
"pin",
"thor"
],
"credential_env_names": [
"JD_SHOP",
"JD_PASSWORD"
],
"account": "jd-shop"
},
"shop.jd_after_sales_issues.collect": {
"script_id": "shop_intelligence:collectors/jd_after_sales_issues.py",
"state_key": "shop_intelligence:collectors/jd_after_sales_issues.py",
"aliases": [
"shop_intelligence:collectors/jd_after_sales_issues.py"
],
"cdp_port": 22157,
"login_mode": "D",
"required_cookie_domains": [
"jd.com"
],
"required_cookie_names": [
"pin",
"thor"
],
"credential_env_names": [
"JD_SHOP",
"JD_PASSWORD"
],
"account": "jd-shop"
},
"shop_intelligence:collectors/taobao_sycm.py": {
"script_id": "shop_intelligence:collectors/taobao_sycm.py",
"state_key": "shop_intelligence:collectors/taobao_sycm.py",
@@ -2053,6 +2234,26 @@
"TMALL_BAG_PASSWORD"
],
"account": "tmall-bag"
},
"shop.dy_negative_reviews.collect": {
"script_id": "shop_intelligence:collectors/dy_negative_reviews.py",
"state_key": "shop_intelligence:collectors/dy_negative_reviews.py",
"aliases": [
"shop_intelligence:collectors/dy_negative_reviews.py"
],
"cdp_port": 22160,
"login_mode": "D",
"required_cookie_domains": [
"jinritemai.com"
],
"required_cookie_names": [
"sessionid",
"sid_tt",
"uid_tt",
"sessionid_ss",
"sid_guard"
],
"account": "douyin-shop"
}
},
"services": {
+114 -1
View File
@@ -10,6 +10,16 @@
"kind": "daily",
"workflow_id": "content.metrics.daily"
},
{
"at": [
"01:00",
"05:00"
],
"business_date_offset_days": 0,
"enabled": true,
"kind": "daily",
"workflow_id": "content.metrics.self_operated.daily"
},
{
"at": "14:00",
"business_date_offset_days": 0,
@@ -51,21 +61,27 @@
},
{
"at": "10:00",
"business_date_offset_days": 0,
"days": [
"Tuesday"
],
"enabled": false,
"kind": "weekly",
"workflow_id": "content.summary.weekly"
},
{
"at": "08:00",
"business_date_offset_days": 0,
"day_of_month": 1,
"enabled": false,
"kind": "monthly",
"workflow_id": "content.summary.monthly"
},
{
"at": "08:30",
"business_date_offset_days": 0,
"day_of_month": 1,
"enabled": false,
"kind": "monthly",
"workflow_id": "content.creator_report.monthly"
},
@@ -87,9 +103,30 @@
},
{
"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",
"22:00"
"17:00",
"18:00",
"19:00",
"20:00",
"21:00",
"22:00",
"23:00"
],
"kind": "daily",
"workflow_id": "shop.douyin_price_appeal"
@@ -101,6 +138,77 @@
"kind": "daily",
"workflow_id": "shop.jd_self_operated.daily"
},
{
"at": "03:00",
"business_date_offset_days": 0,
"day_of_month": 21,
"enabled": true,
"kind": "monthly",
"workflow_id": "shop.jd_customer_service.daily"
},
{
"at": "03:00",
"business_date_offset_days": 0,
"day_of_month": 22,
"enabled": true,
"kind": "monthly",
"workflow_id": "shop.jd_negative_reviews.daily"
},
{
"at": "03:00",
"business_date_offset_days": 0,
"day_of_month": 23,
"enabled": true,
"kind": "monthly",
"workflow_id": "shop.jd_after_sales_issues.daily"
},
{
"at": "03:20",
"business_date_offset_days": 0,
"day_of_month": 21,
"enabled": true,
"kind": "monthly",
"workflow_id": "shop.douyin_customer_service.daily"
},
{
"at": "03:50",
"business_date_offset_days": 0,
"day_of_month": 25,
"enabled": false,
"kind": "monthly",
"workflow_id": "shop.tmall_negative_reviews.daily"
},
{
"at": "03:40",
"business_date_offset_days": 0,
"day_of_month": 24,
"enabled": false,
"kind": "monthly",
"workflow_id": "shop.tmall_customer_service.daily"
},
{
"at": "03:20",
"business_date_offset_days": 0,
"day_of_month": 22,
"enabled": true,
"kind": "monthly",
"workflow_id": "shop.dy_negative_reviews.daily"
},
{
"at": "03:20",
"business_date_offset_days": 0,
"day_of_month": 23,
"enabled": true,
"kind": "monthly",
"workflow_id": "shop.douyin_after_sales_quality.daily"
},
{
"at": "09:30",
"business_date_offset_days": 0,
"enabled": true,
"kind": "daily",
"workflow_id": "product.online_stock_alert.daily"
},
{
"at": "08:40",
"business_date_offset_days": -1,
@@ -132,6 +240,8 @@
},
{
"at": "23:00",
"business_date_offset_days": 0,
"enabled": false,
"kind": "daily",
"workflow_id": "product.alert.daily"
},
@@ -139,6 +249,7 @@
"anchor_date": "2026-07-25",
"at": "11:00",
"business_date_offset_days": -1,
"enabled": false,
"every_days": 3,
"kind": "interval_days",
"workflow_id": "product.style_analysis.interval"
@@ -224,6 +335,8 @@
},
{
"at": "08:00",
"business_date_offset_days": 0,
"enabled": false,
"kind": "daily",
"workflow_id": "supply.purchase_confirmation.daily"
},
+3
View File
@@ -3,6 +3,9 @@ GYXX_POSTGRES_PASSWORD=
GYXX_FEISHU_APP_ID=
GYXX_FEISHU_APP_SECRET=
GYXX_HERMES_API_KEY=
GYXX_DATA_INTERACTION_BASE_URL=https://test.gyxx.cn/api
GYXX_DATA_INTERACTION_APP_KEY=data-hub
GYXX_DATA_INTERACTION_APP_SECRET=
# 款式周期分析、天猫/京东视频标题与京东视觉识别直连 MiniMax;
# 真实密钥只放部署环境运行时 .env
STYLE_ANALYSIS_LLM_BASE_URL=https://api.minimaxi.com/v1
+32 -32
View File
@@ -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": "c6c985c860f0b9ab742a66786fb3576a71767bbf897969d9539d7c780303a201",
"target_sha256": "153ccbec744bfdd330e3b350bed3b4c9ed9866cabb3e1785f14a880c46178c97",
"transformed": true,
"category": "source"
},
@@ -23,15 +23,15 @@
"source_relative_path": "chanmama_scraper.py",
"target_relative_path": "src/gyxx_flow/modules/content_marketing/chanmama_scraper.py",
"source_sha256": "2edc26879b57d73ec5992c80e5ef74d76a509f09bff7284b1ae17e90df543e07",
"target_sha256": "72634b4f042f8506bcccadf4512dcbe9f92e9aced3e9b1fc7718483b7c802ebf",
"target_sha256": "8b87268e0f105cf4d349b192c7b2c24d5e318a9d88fcd14264824b083103b24e",
"transformed": true,
"category": "source"
},
{
"source_relative_path": "collection_completeness.py",
"target_relative_path": "src/gyxx_flow/modules/content_marketing/collection_completeness.py",
"source_sha256": "f23b70b949f8adcaf00ee62aa0f9f39314bc623528cad9a77591641da765991d",
"target_sha256": "f23b70b949f8adcaf00ee62aa0f9f39314bc623528cad9a77591641da765991d",
"source_sha256": "575ab0c526f2d78b1135bd27407c9ecb0fb0aaad2063ac37ba0818ea6247cc2d",
"target_sha256": "575ab0c526f2d78b1135bd27407c9ecb0fb0aaad2063ac37ba0818ea6247cc2d",
"transformed": false,
"category": "source"
},
@@ -55,15 +55,15 @@
"source_relative_path": "daily_marketing_report.py",
"target_relative_path": "src/gyxx_flow/modules/content_marketing/daily_marketing_report.py",
"source_sha256": "b59252594c7d3c0fd1473d14611346e4b64672ef6c263fea8a2da53017ab8675",
"target_sha256": "78fb871c14eb73ec3e0d0986554cad318f8a0ee8a66c2a0981f02a65cded8a26",
"target_sha256": "e049d933721d0dbe5be559f33beaea812443c84af504d05bc658b20532d28533",
"transformed": true,
"category": "source"
},
{
"source_relative_path": "data/config/analyze.env.example",
"target_relative_path": "src/gyxx_flow/modules/content_marketing/data/config/analyze.env.example",
"source_sha256": "b60328ef177b795489d0a57957133dedcfd67b38b59603a6edeee1cf9babcee2",
"target_sha256": "b60328ef177b795489d0a57957133dedcfd67b38b59603a6edeee1cf9babcee2",
"source_sha256": "72c8359ffbde76060642c77ad21ec8544645ee00aca5a5bfc6bd0971647e9240",
"target_sha256": "72c8359ffbde76060642c77ad21ec8544645ee00aca5a5bfc6bd0971647e9240",
"transformed": false,
"category": "config"
},
@@ -103,7 +103,7 @@
"source_relative_path": "data/tools/analyze_note.py",
"target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/analyze_note.py",
"source_sha256": "f9a71dcd34657d3e70af84160bd04afa73325c9ddeee0e669d98d6758ed6b13e",
"target_sha256": "3c62dcac6e5c877c1a5b55f4960244052a124c2e50c61571cc726d2ca916109b",
"target_sha256": "0f538b5c9fc52d23b66f7f79f3c54fcc9e7ed737ab6decb5b16c4eed09e59615",
"transformed": true,
"category": "tool"
},
@@ -191,7 +191,7 @@
"source_relative_path": "data/tools/feishu_comment_batch.py",
"target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/feishu_comment_batch.py",
"source_sha256": "2b04fde410eec1dece0b995d0b74bfad4bdc1a53669f19ead2a6fc13ecdd00b7",
"target_sha256": "598a26e1dcc65702ad154ec3dcc84b4d5c8fecf45196e7e8301ca5b77544ae23",
"target_sha256": "80985a42af7dce5d26d7b4f14ef36411631157e44f8d6ee054de2b66277905f5",
"transformed": true,
"category": "tool"
},
@@ -254,8 +254,8 @@
{
"source_relative_path": "data/tools/migrations/005_style_product_profile.sql",
"target_relative_path": "src/gyxx_flow/modules/content_marketing/data/tools/migrations/005_style_product_profile.sql",
"source_sha256": "8f9c4d25681e84e582d3cdcd9108ebb16c2f5b777b766a5c3653a21c0baf6d91",
"target_sha256": "8f9c4d25681e84e582d3cdcd9108ebb16c2f5b777b766a5c3653a21c0baf6d91",
"source_sha256": "6a5f60f162c02a9daea2d46ff95edfbcaf416d9f70d894d3b83d049b81ab2134",
"target_sha256": "6a5f60f162c02a9daea2d46ff95edfbcaf416d9f70d894d3b83d049b81ab2134",
"transformed": false,
"category": "sql"
},
@@ -343,7 +343,7 @@
"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": "b4c85e8319bfd0a3071ffdfaff05ea030e426cb3d384a978fdc4d14545ccd679",
"target_sha256": "26ca143da6539345c930d0a949a852a33735ed764e7065162c9f805d2609fe91",
"transformed": true,
"category": "tool"
},
@@ -351,7 +351,7 @@
"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": "43d202d7d0452b7d0671ee9c40149c21328db4b2863fa4d030af456f89defe3c",
"target_sha256": "43d202d7d0452b7d0671ee9c40149c21328db4b2863fa4d030af456f89defe3c",
"target_sha256": "001870851dea9c373a77b87335604c4363b06c3eb0ed7c97b4a8d5e891e56758",
"transformed": true,
"category": "sql"
},
@@ -367,7 +367,15 @@
"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": "4e630a79bd2a9742ad19d1955b88f3f12cc7e741b445196e53c07440c0f3749d",
"target_sha256": "6dfeeaa77873fdc580077e1c5d491ced653a1c319720f1fdffc6d5f44c11f5d3",
"transformed": true,
"category": "tool"
},
{
"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": "aa70a895c0b7dd32e458b885e9e036246e12d83f354d068e32ac3ee6b491e175",
"transformed": true,
"category": "tool"
},
@@ -407,7 +415,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": "e36ec226b06bae4940f0d2432ae7de0d9a0b56199ab6cd8ee7d34fbfc060ec51",
"target_sha256": "6e7ac4357fe466656b93ca7bae6c49adc4d59fa75974a39c6885c7433bd4d402",
"transformed": true,
"category": "source"
},
@@ -415,7 +423,7 @@
"source_relative_path": "feishu_mapping.py",
"target_relative_path": "src/gyxx_flow/modules/content_marketing/feishu_mapping.py",
"source_sha256": "d389fd59f5e49208ffa12d04c1ca6202f51dc5d89321a50e52fadad747239749",
"target_sha256": "56af85da94c8eff1f1e0c27f13df56860cc959328524a059b6a374d55eb32e20",
"target_sha256": "0ee2ccd518d4fd313e4acbb62857b565f2e84c77968ba68a3f2f680268f2fdd3",
"transformed": true,
"category": "source"
},
@@ -431,7 +439,7 @@
"source_relative_path": "monthly_summary_all.py",
"target_relative_path": "src/gyxx_flow/modules/content_marketing/monthly_summary_all.py",
"source_sha256": "50f18d62e8fb1a7c8a04dc746326a4763f64ba4b99b29a6fda5878aa05f290fa",
"target_sha256": "ea2729e1976426d497b69006c63b5ba7d74ce530731efa5a2946a4671593aed7",
"target_sha256": "6f34da4842bb2a7a0b91c3f89ce6047ddccb43a243614ab77a014de516192fbc",
"transformed": true,
"category": "source"
},
@@ -439,7 +447,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": "c1ee83cc68464d5b0b33e010b388abd2adab8a6608846b47d509e9ea0a783f81",
"target_sha256": "667fcfe8bb4ebc55fad44838cbd00d79a4f414dd875a4f74d27bf2ff6e90bc2c",
"transformed": true,
"category": "source"
},
@@ -447,7 +455,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": "21c3647faa01a335af2c5984f87bc0d8968361cae73352146279bd9027e82c5f",
"target_sha256": "e5df4b34f67a565ec5d5ce4dd5857e80e363dd7559e7e29006dcd3e421276c8a",
"transformed": true,
"category": "source"
},
@@ -463,7 +471,7 @@
"source_relative_path": "run_all.py",
"target_relative_path": "src/gyxx_flow/modules/content_marketing/run_all.py",
"source_sha256": "7a2529b1d043297171aeb88518fac8753234540992042f922172219b2b233dd4",
"target_sha256": "0ea60854f1bb1ca293a073c57c9e19a1aa9c5710ff9575dc7c430c4e480525d9",
"target_sha256": "8d27c1ef21c4d5548c82a97f9dc461ad191119d569f36bebeb9ee604cd9e727a",
"transformed": true,
"category": "source"
},
@@ -471,7 +479,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": "42e6b9ac81077b37ff4607b3565d98cac1027eff3ac39a8776e640503d590820",
"target_sha256": "6ec5ed6991a93f806fb74d0870e953e5a05d52043a83f2fa261e2ec67bee6ad7",
"transformed": true,
"category": "source"
},
@@ -487,7 +495,7 @@
"source_relative_path": "weekly_summary_all.py",
"target_relative_path": "src/gyxx_flow/modules/content_marketing/weekly_summary_all.py",
"source_sha256": "b93c04c543a8c65418ec122a2713239d0ed857bc169b87ef718cf73348c189b0",
"target_sha256": "a5c866d0720f20fe78bf8f8039a24101fc58e3a81fb107beb7f6499e9782d559",
"target_sha256": "f0d43974605bdd45868e88dd81f7d1db8d624f4c69ecaaf0abf49aaf8ed79cf3",
"transformed": true,
"category": "source"
},
@@ -503,7 +511,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": "5e1e306cb1a089c187dc31bc598939304990b3039a5eb91d9da8a71a023b6e69",
"target_sha256": "afca1104324546da2315fdf7a427c3f34021eef31ddc2fea76877f818718ccb0",
"transformed": true,
"category": "source"
},
@@ -519,17 +527,9 @@
"source_relative_path": "xingtu_scraper_v2.py",
"target_relative_path": "src/gyxx_flow/modules/content_marketing/xingtu_scraper_v2.py",
"source_sha256": "0d4eb87779064e13b8d78daa6dba13698f1149a26b3a1de2e064c117a29cf49a",
"target_sha256": "e694f3ca9126a99123d0941068dbd102f0fdb4b84160ebdbb51cd56c3f3aa32a",
"target_sha256": "b36339444e26048a90c8ac999a0a628bcddd4f64842924f6c60e600c7a3bec37",
"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": [
+39 -39
View File
@@ -14,7 +14,7 @@
{
"category": "production_source",
"source_sha256": "cff768f79672cfc7db7f6866caf5ab59936b8c51eb2a7d729b81441940124512",
"target_sha256": "7b4da4f51cd0945bb9ad144aead5ff96e197fdacc7a3c065e6102e833161992a",
"target_sha256": "d56fe8a934c1ae20a9249c7682823238a705f51f451241504512e83a8800a879",
"transformed": true,
"source_relative_path": "analyze_style_with_hermes.py",
"target_relative_path": "src/gyxx_flow/modules/product_commerce/analyze_style.py"
@@ -54,7 +54,7 @@
{
"category": "production_source",
"source_sha256": "1493dbbfb3d864812df378d04d006e7de0f9ec4b6ab5812e5c6970c304c2dfe6",
"target_sha256": "2d5a6a7bea5e21a5af5f7cdde287acd5b9e7e375469e6d6f85c15aab1cc31d8e",
"target_sha256": "a1d526dfc27b110a8845a98cc66fe7e5cfb87ef70530728477af31a88734a576",
"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": "523027ccd4e31bd276bfba3e8944bf43e0e1e67efc0921ef5c72b8bfa4287c28",
"target_sha256": "9de3ab22d5a4dbf574b2d4fdbbed295bb4645bb4943f4c066fcbfd41b44b521c",
"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": "75cd142aefad9ea54a61e8e11a72e9535ee3a71a0c7b0025a8d030a0a0c0d430",
"target_sha256": "aaba5dfe6ce5133b5c662232160bb9faef4d73b36902a484a56b89fa7df05311",
"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": "5f9a754f2293b8b35ce5214ae930350b67abe4d10b8e702b06b68c9c9a4d2d9a",
"target_sha256": "c98abe563b223f49cc3c810054d997504d9465387da4805389b0f5ef78ceed6a",
"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": "17064990b7080969e1907f2e2ae9cf1f49267936746b6db6e6e1a3a689e1b501",
"target_sha256": "a268df592f7136a68c2b82d031115055e43cdfaedcdd4a5f4afa9ef45879e055",
"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": "0d2981d17c86c1881c86810aaf63bfd0261de03bc94d91eaa1b42a66953d3f3e",
"target_sha256": "0e4314a525f49b7c24c145f092afcd6c93749c694d6d8bcbd2d1fcdf9b2e54c7",
"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": "fb120e9501a1220c3746e0f78a9f639e732764e21b122bf532c0fdea03ec6ed7",
"target_sha256": "a1b95cc4f090a25c8e7991b0ef015a7df612fc78249dafe75aeef9fa4dbefe08",
"transformed": true,
"source_relative_path": "collect_sycm_market_rank.py",
"target_relative_path": "src/gyxx_flow/modules/product_commerce/collect_sycm_market_rank.py"
@@ -126,7 +126,7 @@
{
"category": "production_source",
"source_sha256": "22dcc9d8b2f1cf598a62e4c71df765a952250694cb26b9a73f477c5ca1a29884",
"target_sha256": "e41bc1de359b8f18c25ee38068ab550e5dbbf5c20712d19723915d669f02d6ac",
"target_sha256": "ffeaf697a1a58b21520fda3f3b0936c591d05b328537c41f2048366a9ba1b8d5",
"transformed": true,
"source_relative_path": "config/style_config_loader.py",
"target_relative_path": "src/gyxx_flow/modules/product_commerce/config/style_config_loader.py"
@@ -134,7 +134,7 @@
{
"category": "production_source",
"source_sha256": "a8900b9f25db6cf2bef3117a1d2cd86283054fea6f5a8590fd91b5cfbbcaf87e",
"target_sha256": "7fd65a5b993b5a8b7f681b370e4364411252d6a59b22c7d96d71235daa5fce55",
"target_sha256": "030d33c3f5c0c230bb67d603dd657f40cceff8581f8756a1f67340a89b5ae4b3",
"transformed": true,
"source_relative_path": "db/__init__.py",
"target_relative_path": "src/gyxx_flow/modules/product_commerce/db/__init__.py"
@@ -142,7 +142,7 @@
{
"category": "production_source",
"source_sha256": "fc19a57bcbc023ab20156820a708d64c5526fad33ef5eb5fdea56c0bb04b84e3",
"target_sha256": "486607458d204336a04e3776d9db98f5f069a46b75b050ac0f0947cec958b5d7",
"target_sha256": "2cbd79e4d984d556bac35a0aeb5b8f803309fd3fac2399da459cd61a3aa2484c",
"transformed": true,
"source_relative_path": "db/sync_dim_style.py",
"target_relative_path": "src/gyxx_flow/modules/product_commerce/db/sync_dim_style.py"
@@ -150,7 +150,7 @@
{
"category": "production_source",
"source_sha256": "2dc00b88fb5f28c2db619074e97c4a7c446aec29926af9a6890065409c8fac0e",
"target_sha256": "030344e0ffe26240ee735ccf38f194d4e69f2833bd21e9947b97d0072647910b",
"target_sha256": "840f98d9fb79557016a59a2e3a1194a1e328a1574b4670f419c99ac85731d623",
"transformed": true,
"source_relative_path": "db/sync_sku_master.py",
"target_relative_path": "src/gyxx_flow/modules/product_commerce/db/sync_sku_master.py"
@@ -158,7 +158,7 @@
{
"category": "production_source",
"source_sha256": "78c74c0e52e3651b3b042a2095e596ab250e60309c7ae2de905850822aad4922",
"target_sha256": "e7377793895b6176a0bd76df0ce6a3d8927c1f2bb4f3c8b4ca47e7d3dd97dd00",
"target_sha256": "09d7e08d2eec57055a3bc7949e49c32a8bf62713f2594ae5d9297c032bf212c8",
"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": "5f621d4003dbb5ed3c8cd58ae813f371bb0c110aa2fbfff0b6ef559b4eb487b1",
"target_sha256": "9dd0dc3e6f048683157fe8b8deb533ed88970bf34e9824e439bf3a5d52d80e0a",
"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": "536097c7558c2edb33639ab7ada22b266a1fe14341f6972dc53d3708f7b61fc0",
"target_sha256": "4c36f3eafd29241e1e5436c0a5f64682870a330003a99dcd03a2a981e425e8b8",
"transformed": true,
"source_relative_path": "erp_login_product_analysis.py",
"target_relative_path": "src/gyxx_flow/modules/product_commerce/erp_login_product_analysis.py"
@@ -190,7 +190,7 @@
{
"category": "production_source",
"source_sha256": "b828e1ad566b121bd56f90ef5de9d414cbe8575ec747cd26dfa3e4b66b097a9a",
"target_sha256": "c87aac33589bffc004eb83c899ca538529ef94afc8be912dbd8c164c87fc2d4f",
"target_sha256": "49da688998376bbd932195ef56603a37f342fa2eb307925a83592596641b8ace",
"transformed": true,
"source_relative_path": "export_bitable_records.py",
"target_relative_path": "src/gyxx_flow/modules/product_commerce/export_bitable_records.py"
@@ -206,7 +206,7 @@
{
"category": "production_source",
"source_sha256": "906278aa6fb3578d5dab231bdccc2f5a7c6ec8c85810a3e026fff3c7ba16dc90",
"target_sha256": "4f627cb824e9747e1436a5469158d62dd2a17b8442fcb45e64144f35618b27dd",
"target_sha256": "d9e4281c21a42841023c0a723aff4a00af1f9200c94e42add9f8965159d4cfb5",
"transformed": true,
"source_relative_path": "import_product_daily.py",
"target_relative_path": "src/gyxx_flow/modules/product_commerce/import_product_daily.py"
@@ -230,7 +230,7 @@
{
"category": "production_source",
"source_sha256": "e0f016bafb3f527f1cae46eaf3ef31510184128aaaa7f52024a7c3f978eec42b",
"target_sha256": "f30caa8e5925ee9d4c5addf3af651c4c79f94d564612922106f8693ecd7addd1",
"target_sha256": "f2692442a1e62a4b20dbb442eb3e27a3075ca6095ba2578c90193f0db3a1810e",
"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": "3aeec0f04d2d9d7fccf01632503a56066a58716e7dfc953a72241f1857fc8af4",
"target_sha256": "f821a0221b832bea0123bf4a8f85fd0785eba9206c8a632ad48065ea3c30c095",
"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": "06cac4bc1635268fec6274891a09344715b6dc38b95307b8d5bcf2e6a250bbf0",
"target_sha256": "2a254e00bb3ddd3a3d09a14599827e27d002786bf09e6d7004e25f5cabf787ef",
"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"
@@ -294,7 +294,7 @@
{
"category": "production_source",
"source_sha256": "910e720fab4f47419ad56055f00a8c3fbe3a06c2577d81e35d3d38178a588a45",
"target_sha256": "1dd67e2fbb95f08a77460c5b9f7a0cd9b51abae3454abe2ce138c257b00fc577",
"target_sha256": "c9f8cfc8dff6a7e5617b9b357ed92872f94371f93360318a3ff78f7ec38d75df",
"transformed": true,
"source_relative_path": "market_rank_product_import.py",
"target_relative_path": "src/gyxx_flow/modules/product_commerce/market_rank_product_import.py"
@@ -310,7 +310,7 @@
{
"category": "production_source",
"source_sha256": "37edbfa24dfb126d52992b08fd6aeccae3ee18c83db56426a0fb02934e4541ae",
"target_sha256": "cade2f855043e05c1e07b86da81c4864a55e90e999e4441804055b2428e1c92f",
"target_sha256": "47489fd55cbd38a39a8cf3ac0673549e8eee9a39448c187bb34c5cd57b244b97",
"transformed": true,
"source_relative_path": "orchestrate_daily_collection.py",
"target_relative_path": "src/gyxx_flow/modules/product_commerce/orchestrate_daily_collection.py"
@@ -358,7 +358,7 @@
{
"category": "production_source",
"source_sha256": "c9a51f1d1267b398cf260a86e65a8ee49de873d6c95bf9c90db2abb422005cf7",
"target_sha256": "9c8f0e7e686d7198bb231f87f85b074a00d0ffebf5d5d6b44582f59dddeb8abf",
"target_sha256": "6cd70bb5406bc00257f9070760f980580e95c3c4e4326a67d33d30433dd74c32",
"transformed": true,
"source_relative_path": "run_daily_persona.py",
"target_relative_path": "src/gyxx_flow/modules/product_commerce/run_daily_persona.py"
@@ -366,7 +366,7 @@
{
"category": "production_source",
"source_sha256": "27cd730400b954d1a063f034789f0d9a510c711ebda1e2de95e5a0010bd4751f",
"target_sha256": "dc9a2413b8aac548debcaf6bc5cafffa29e321ae44bb82d5df360cb02983e931",
"target_sha256": "fdbb40a10bdf95ef23a70c8574ac5ff0a52b3e419bde5694a186b2c00a9d45e4",
"transformed": true,
"source_relative_path": "run_weekly_jd_main_image.py",
"target_relative_path": "src/gyxx_flow/modules/product_commerce/run_weekly_jd_main_image.py"
@@ -374,7 +374,7 @@
{
"category": "production_source",
"source_sha256": "af9b436a31c3454dc6e0b34b77876962dab0b477e8a561db49911fdfc24eade3",
"target_sha256": "4e02f2394bf2b9b108104621b7c015e172002e284e0676ef55dd9c02d2346999",
"target_sha256": "e3b91d3c4b529291f7a6ceee5ff99326351d78104a66babc531f90faec6b7e1a",
"transformed": true,
"source_relative_path": "run_weekly_main_image.py",
"target_relative_path": "src/gyxx_flow/modules/product_commerce/run_weekly_main_image.py"
@@ -398,7 +398,7 @@
{
"category": "production_source",
"source_sha256": "b3e9addd72d8e31292ed948b67018aa9e212d628d7ba39141a81116515ca6721",
"target_sha256": "a17c6649f4665a09ae1ba0007936546866b2882b5e296733efd99e37d3120b2e",
"target_sha256": "369476667783566e47f086e920c593f3f26aa24355c5c9fa0a3f20bc22b3583e",
"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": "7e881c9a62227a3ec81b761067034055c5f79d02bec69bc23434a5f7aa13b72c",
"target_sha256": "cfbb46483113cda9e4cb301b092ac2ce6a35c5cc53b9a9cef01fdf93a00d3b94",
"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": "d8957d1f93f807eadd1dd7c5a095485a83ce4eb492ec6218ab18cc71cb3cc7a7",
"target_sha256": "d64b440344af7cfcc51857e6261fed30f874b446bb2cb2c225d40da0b67da6a3",
"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": "e20f104a12b1df763b5fde5ef6b8e2536a7801d70049b55cb84143afae21edf5",
"target_sha256": "cf6f78846238e3b63bcd07221cbc9ecbe1c5d2758f6147f48ff112c3ed97b23a",
"transformed": true,
"source_relative_path": "taobao_sycm_products.py",
"target_relative_path": "src/gyxx_flow/modules/product_commerce/taobao_sycm_products.py"
@@ -430,7 +430,7 @@
{
"category": "production_source",
"source_sha256": "3f0c32c443c87417c43881da69aa7776828ba973465f8d32f50b3dc698b0a4fe",
"target_sha256": "8d34bc9ea810b86e87e6f32404fca534a4dd6276012de4b29c385431c4730670",
"target_sha256": "548df6ddf3af3b3c066d18687114f1ce672afa054535f6da0c254d60f8306b86",
"transformed": true,
"source_relative_path": "taobao_wanxiang_ai_creative_report.py",
"target_relative_path": "src/gyxx_flow/modules/product_commerce/taobao_wanxiang_ai_creative_report.py"
@@ -438,7 +438,7 @@
{
"category": "production_source",
"source_sha256": "e4d99956d9649d108baeb3e3a2d1969c227622d1037062bd277af66375556d82",
"target_sha256": "ee73ae4d58ba164422698e1bd0dd2da8510a5c059b8eb2d107be144a844fee4b",
"target_sha256": "981ef2a5a59f73ae244240520320c73be9c9462b50546df1ff91d0c97e7c03f7",
"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": "2080856b1c463a6a11e8e969ef7ff7d0816170402864526c60df026e40c9dd1d",
"target_sha256": "89ba008ef4388c6e1ed32e4a5c26ffa81d083164dd6d7231fc9e5886de8437cd",
"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": "122f57cbdb76bbc366cfe93f50d7c6fbefa091b53664958acb2d142fbf2fa6f9",
"target_sha256": "0549228539c2e1908c3181de13291457712eeaea8fea89903814844724a3b5ee",
"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"
@@ -560,7 +560,7 @@
{
"category": "runtime_resource",
"source_sha256": "9739407b61842c598491d3189a32f5f15aa944aed79a164a5cd5b5a34f20c746",
"target_sha256": "bfb13c05e4a0c7a85238200e026332f55d85a8022012cfab1db0c8514746def8",
"target_sha256": "8ff84f6c3c672af35e09f70d71aa73656ac101218d8a56126b8b0a75ab387c8b",
"transformed": true,
"source_relative_path": "db/schema.sql",
"target_relative_path": "src/gyxx_flow/modules/product_commerce/db/schema.sql"
@@ -632,7 +632,7 @@
{
"category": "regression_test",
"source_sha256": "3d348a8250a2910f6a1c6504e9d1e53b0493d40bdc6c1fd1c8d0404c78681dc6",
"target_sha256": "dca35f4b197d2a7f77eb14e969e91974210b31100c5e874c842fd8036c4dd6e1",
"target_sha256": "dd1dd020cfcd350ef8223ae01ab45f67a86255a7a9abd6c00a863e3c082cfcfc",
"transformed": true,
"source_relative_path": "tests/test_guanghe_metadata.py",
"target_relative_path": "tests/modules/product_commerce/test_guanghe_metadata.py"
@@ -696,7 +696,7 @@
{
"category": "regression_test",
"source_sha256": "f1e660d34e21d3cd7a26d3605c82d07982ba6c5002af7b56fb6a876cb706713d",
"target_sha256": "025e80fce0ec954abb62119b81f96f40ba725f743badccd662d65f50434f903e",
"target_sha256": "85ca8603e2af8257ce2a940b99c69948c2c1362f688e6f6dac4cbf499f1e4d73",
"transformed": true,
"source_relative_path": "tests/test_market_rank_product_import.py",
"target_relative_path": "tests/modules/product_commerce/test_market_rank_product_import.py"
@@ -728,7 +728,7 @@
{
"category": "regression_test",
"source_sha256": "f9cfbb49fd313444790ac194fbf980dae21912081b9f26ad8e3850912126f09e",
"target_sha256": "76a0aeecf17ecea039ab4d81457cb2dc2cc0d0d75f54e500ea0f2a00ccce6f45",
"target_sha256": "0cc2b365144482ceb36667eedfb2c403e904437f3ea4c7e12f7099276c13a279",
"transformed": true,
"source_relative_path": "tests/test_style_analysis_orchestration.py",
"target_relative_path": "tests/modules/product_commerce/test_style_analysis_orchestration.py"
@@ -736,7 +736,7 @@
{
"category": "regression_test",
"source_sha256": "17761a5ba93a5387ffa67662424955178058aeec708bbcd3eb0341a859d8a1f1",
"target_sha256": "b88b52fb5e68f5ee44aa4f9709fbd2c7dae46126b98f76fdfd12615f284f1494",
"target_sha256": "549cb13dd32c9bed76d78eafa7542ea6e5c638d9f29c857eff4dde6d3b8e9c40",
"transformed": true,
"source_relative_path": "tests/test_style_config_loader_erp_source.py",
"target_relative_path": "tests/modules/product_commerce/test_style_config_loader_erp_source.py"
@@ -760,7 +760,7 @@
{
"category": "regression_test",
"source_sha256": "f7de702d7d1e641c8778970d5cb1f4e827a2e0ee9b20db2c19f0ed4fd8230e16",
"target_sha256": "25c72b4256fc4d0485b491fe691d44d78dac926f02316cc4d1b2bb7c3ee536a2",
"target_sha256": "55ea7031034b272f11e54e2372ad3476a25d0e32d8bbcc832b5977dfb7b34ec7",
"transformed": true,
"source_relative_path": "tests/test_tm_persona_recovery.py",
"target_relative_path": "tests/modules/product_commerce/test_tm_persona_recovery.py"
@@ -32,7 +32,7 @@
"target_relative_path": "src/gyxx_flow/modules/shop_intelligence/collectors/dy_store_competitor_store_scraping.py",
"category": "source",
"source_sha256": "009879a7dd341ca6ecbc8eb66f5700114c73bcaba3a53f159014a76232d0aa6b",
"target_sha256": "e0b9664ad71c5c44132a730ccb274e30e6749229e912c5df816105aa7905405b",
"target_sha256": "d5e85ded1f852ba418229c75c985d0ece967dfbd5fd31697fe407b64d1c3db05",
"transformed": true
},
{
@@ -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": "6dc3a80a9d5869b82e5de4febec1fe920ebe518209fe1d0febafec732c3a97ac",
"target_sha256": "7d3cd4bdcb657d828f6cd1287df5755a53abeb24ba42170eb6fb6735cc586a4d",
"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": "6f626e52f52abd2f08441f70f3e9558e9cca41ebfb666438f4cf009cd17dccbe",
"target_sha256": "39faf18e9cd5e441323a8e056e022f179018a44eac224d64383cd7ae9fa1d611",
"transformed": true
},
{
@@ -88,15 +88,15 @@
"target_relative_path": "src/gyxx_flow/modules/shop_intelligence/db/__init__.py",
"category": "source",
"source_sha256": "ffc89b78ff0081a129e04eb6de4985ab0a2a4613c9e8a1d8b2750d8c17e8bc05",
"target_sha256": "ffc89b78ff0081a129e04eb6de4985ab0a2a4613c9e8a1d8b2750d8c17e8bc05",
"transformed": false
"target_sha256": "a4bae02d0c07f3e3986896b31e62f41bf15edbf74d3776b1e050e381c15a8413",
"transformed": true
},
{
"source_relative_path": "db/db.py",
"target_relative_path": "src/gyxx_flow/modules/shop_intelligence/db/db.py",
"category": "source",
"source_sha256": "168b6f87c1827b267f763ddce7eb15d0c8d652c8c0c7af18dcd0c6c7da5cddd1",
"target_sha256": "b8f15975605e2d9b75d25ac09a67768d4583401c1365288de078a97d0d3f3abf",
"target_sha256": "01469f9eb80aee5e80bc28e548dbe2cd5ce861ab35cd480b33921a2adc799320",
"transformed": true
},
{
@@ -104,7 +104,7 @@
"target_relative_path": "src/gyxx_flow/modules/shop_intelligence/db/schema.sql",
"category": "sql",
"source_sha256": "73230590bde7ae6359c02c82c2db096c0d3faa55b374e30348c47d7b40ea498b",
"target_sha256": "a0aa2a22c8fdbb1f748dc7b206fe06a43f10899ca7036e433cdd91d7a1f5cba9",
"target_sha256": "a69fa151f989f106d00f344bab43ec9e6a09cf404bdc581624cc0901cdc13d20",
"transformed": true
},
{
+8 -8
View File
@@ -8,7 +8,7 @@
"target_relative_path": "src/gyxx_flow/modules/supply_chain/config.example.env",
"category": "config",
"source_sha256": "ec2df00f43abd9ba570b3882c5331d9b3c09557cccbc9c79e2326df57b680124",
"target_sha256": "3fbfd050b522a20802c91d195e305388f8d704fe60d1f24dab41096d74a28286",
"target_sha256": "3da1ee4935faab3997f6221a1f2540233285a561f35b97fccb82003cd7d58c02",
"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": "835298b3c05983ef215d76150364a1ca89b4b6ce539cced4b9387d3678b508bf",
"target_sha256": "479a4dde2639b488b49ddf7508ee9899004bb1204b529cb97f7153175c4376c7",
"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": "2eb41409ef1bb799204b27fabcba243a927ffc30c0e46328c8ccda83d185be41",
"target_sha256": "f2426fbdcd52135e1c52c194d654639bfd8697c44f7d3ecaefbfe17813b4eb6b",
"transformed": true
},
{
@@ -64,7 +64,7 @@
"target_relative_path": "src/gyxx_flow/modules/supply_chain/orchestrator/runner.py",
"category": "launcher",
"source_sha256": "0cfe5e6557e8dee70ec4542c54b53e2153a3651eb2e35cd831e85518aabe0c66",
"target_sha256": "c3c16831a9c088455805b3a78fc23d276ded1cfcde8856f59cfdafceab061011",
"target_sha256": "5c99177d7c41ae45f6df517dc27b7d4e7413d5004e27e34948f5e169ec589ed3",
"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": "32d40a9cbdaba73f88eb5ed181eca0af3bd02df9dc017e65162a4a9acb2f1a1f",
"target_sha256": "e74211f2fe97ac47e704a040f442f51c97fecc15ea30dd1f0dbeacaf5940fd80",
"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": "91cf0ccb4150e9252c5048581a16e77534f251f17a3400ba4e1e4ad03e451941",
"target_sha256": "f9bcb7b2b911f0a7680e57a6fed5814663dac4d5c7bf5a0334a9c517aded3218",
"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": "0ff70f17c748cd9c892e6bb5b20c7083681edddb5097fb18ce0aa182996f29a1",
"target_sha256": "721027ab230d4503430a0f958a425babbfad8626890e6c1c2ce386f247f50f45",
"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": "783ff97e7137e30ba7bcac72d53559563889e88fda21d0eb2b5217d48bf1ed53",
"target_sha256": "7b189ccc29074f72a2d02a45c720e0674ac3d8a7385bc6cdc99966ee6f505723",
"transformed": true
},
{
+626 -30
View File
@@ -39,6 +39,57 @@
],
"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",
"args": [
"--scope",
"collaborators"
],
"depends_on": [
"collect_collaborators"
],
"run_after_failure": true,
"replay_policy": "repeatable"
}
]
},
"provenance": {
"source_project": "content",
"task_name": "YingxiaoYunying_DailyRun"
},
"note": "每日 01:00、05:00 采集合作达人内容指标并同步入库;自营内容指标由独立的 content.metrics.self_operated.daily 工作流负责。"
},
{
"id": "content.metrics.self_operated.daily",
"module": "content_marketing",
"trigger": "scheduled",
"execution": {
"steps": [
{
"id": "refresh_self_mapping",
"name": "刷新自营款式映射",
@@ -61,16 +112,12 @@
]
},
"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 获取播放量和发布时间并回填对应飞书表。",
"description": "读取自营映射中的 B站笔记链接,通过公开 API 获取标题、UP主、时长、发布时间与播放/点赞/投币/收藏/分享/弹幕全量指标,回填对应飞书表并产出全量视频指标 artifact。",
"data_flow": {
"sources": [
{
@@ -83,7 +130,8 @@
}
],
"processing": [
"按映射定位自营笔记并读取公开指标"
"按映射定位自营笔记并读取公开指标",
"按 BV 号聚合全量视频指标 artifact"
],
"destinations": [
{
@@ -92,7 +140,7 @@
},
{
"system": "本地采集产物",
"label": "B站自营指标结果"
"label": "B站自营指标结果与全量视频 artifact"
}
]
},
@@ -106,7 +154,7 @@
{
"id": "collect_self_douyin",
"name": "采集自营抖音指标",
"description": "使用蝉妈妈新账号登录态导出自营抖音视频数据,解析曝光指标并回填飞书自营表。",
"description": "使用蝉妈妈新账号登录态导出自营抖音视频数据,解析视频链接、标题、发布时间、曝光、点赞、评论、转发等全量指标,并回填飞书自营曝光表。",
"data_flow": {
"sources": [
{
@@ -119,7 +167,8 @@
}
],
"processing": [
"导出自营视频数据并解析曝光指标"
"导出自营视频数据并解析全量视频指标",
"按发布时间槽位回填飞书曝光"
],
"destinations": [
{
@@ -140,45 +189,116 @@
"replay_policy": "repeatable"
},
{
"id": "sync",
"name": "同步合作与自营曝光指标入库",
"description": "汇总本轮合作达人及自营曝光结果,按记录映射更新或补建 PostgreSQL 内容笔记数据。",
"id": "collect_self_xiaohongshu",
"name": "采集自营小红书笔记指标",
"description": "复用 ARK 隔离登录态(失效时短信验证码需人工完成)进入小红书笔记管理后台,采集商品笔记与普通笔记两个页签的全部展示列(笔记信息、阅读数、点赞数、引流次数、支付金额与订单数等),产出全量笔记指标 artifact。",
"data_flow": {
"sources": [
{
"system": "本轮采集产物",
"label": "合作达人及自营内容指标"
"system": "小红书 ARK",
"label": "已登录的笔记管理后台(商品笔记/普通笔记)"
},
{
"system": "本地映射",
"label": "内容记录对应关系"
"system": "本地配置",
"label": "自营小红书账号与登录手机号"
}
],
"processing": [
"合并各平台指标并匹配内容记录",
"更新已有记录或补建缺失记录"
"按页签逐页抓取并按表头解析全部展示列",
"按笔记身份去重产出全量指标 artifact"
],
"destinations": [
{
"system": "本地采集产物",
"label": "小红书自营笔记全量指标 artifact"
}
]
},
"entry": "self_xhs_scraper.py",
"depends_on": [
"collect_self_douyin"
],
"run_after_failure": true,
"replay_policy": "repeatable"
},
{
"id": "sync",
"name": "同步自营曝光指标入库",
"description": "只汇总自营内容曝光结果,按自营记录映射更新或补建 PostgreSQL 内容笔记数据。",
"data_flow": {
"sources": [
{
"system": "本地采集产物",
"label": "自营内容指标结果"
},
{
"system": "飞书映射",
"label": "自营内容记录对应关系"
}
],
"processing": [
"读取自营 B站和抖音曝光指标",
"按自营内容记录身份更新已有数据或补建缺失记录"
],
"destinations": [
{
"system": "PostgreSQL",
"label": "内容笔记与曝光指标"
"label": "自营笔记与曝光指标"
}
]
},
"entry": "data/tools/sync_metrics_to_cmt_notes.py",
"args": [
"--scope",
"self-operated"
],
"depends_on": [
"collect_self_douyin"
],
"replay_policy": "repeatable",
"run_after_failure": true
"run_after_failure": true,
"replay_policy": "repeatable"
},
{
"id": "sync_self_video_metrics",
"name": "同步自营内容指标入库",
"description": "按平台解析自营内容指标(抖音蝉妈妈导出、B站公开 API artifact、小红书 ARK artifact),映射内部账号别名后按账号+内容+业务日期幂等写入 PostgreSQL cmt_self_video_metrics(含 page_metrics 原始列快照)。",
"data_flow": {
"sources": [
{
"system": "本地采集产物",
"label": "蝉妈妈自营抖音视频导出与 B站/小红书全量指标 artifact"
},
{
"system": "本地配置",
"label": "自营账号别名映射"
}
],
"processing": [
"按平台解析来源产物的全量内容指标",
"按账号+内容+业务日期幂等 upsert"
],
"destinations": [
{
"system": "PostgreSQL",
"label": "自营内容每日指标(抖音/B站/小红书)"
}
]
},
"entry": "data/tools/sync_self_video_metrics.py",
"depends_on": [
"sync",
"collect_self_xiaohongshu"
],
"run_after_failure": true,
"replay_policy": "repeatable"
}
]
},
"provenance": {
"source_project": "content",
"task_name": "YingxiaoYunying_DailyRun"
"task_name": "YingxiaoYunying_SelfOperated_DailyRun"
},
"note": "每日 01:00 执行合作达人及自营 B站、蝉妈妈采集,05:00 按 01:00 产生的未完成清单补采;账号切换期间仅暂停自营采集步骤,不移除自营链;两次均完成后同步曝光指标入库。"
"note": "每日 01:00、05:00 刷新自营映射并采集自营 B站、蝉妈妈、小红书 ARK 数据;飞书曝光回填保持不变,另将三平台全量内容指标(视频/笔记链接、标题、发布时间、曝光/阅读、点赞、评论、转发等)按日快照入库,失败时仍保留后续链路与入库汇总步骤。"
},
{
"id": "content.metrics.backfill",
@@ -245,7 +365,11 @@
"depends_on": [
"collect_collaborators"
],
"run_after_failure": true
"run_after_failure": true,
"args": [
"--scope",
"collaborators"
]
}
]
},
@@ -938,6 +1062,438 @@
},
"note": "每天采集计划日期前两天;品牌失败仍继续商品,任一步失败则工作流失败。"
},
{
"id": "shop.jd_customer_service.daily",
"module": "shop_intelligence",
"trigger": "scheduled",
"execution": {
"steps": [
{
"id": "customer_messages",
"name": "采集京东客服聊天记录",
"description": "按控制台选择的业务日期打开京东客服聊天记录,切换消息视图,滚动加载全部顾客并逐个读取会话消息;全部顾客完成后才结束并幂等写入 PostgreSQL。",
"data_flow": {
"sources": [
{
"system": "京东客服后台",
"label": "单日聊天记录消息视图"
}
],
"processing": [
"将开始和结束时间设置为业务日 00:00:00 至 23:59:59",
"滚动加载消息视图中的全部顾客并逐个点击读取会话",
"按会话标识和消息身份规范化,校验顾客覆盖完整后幂等入库"
],
"destinations": [
{
"system": "本地原始产物",
"label": "京东客服单日原始响应"
},
{
"system": "PostgreSQL",
"label": "京东客服会话与消息表"
}
]
},
"entry": "collectors/jd_customer_service_messages.py",
"args": [
"--date",
"{business_date}",
"--range-days",
"30",
"--headless"
],
"timeout_seconds": 14400,
"replay_policy": "idempotent"
}
]
},
"provenance": {
"source_project": "shop",
"task_name": "京东客服消息日采集"
},
"note": "默认关闭自动调度,控制台可按业务日期手动运行;单日必须完成消息视图中的全部顾客,失败时保留已完成进度并可安全重试。"
},
{
"id": "shop.douyin_customer_service.daily",
"module": "shop_intelligence",
"trigger": "scheduled",
"execution": {
"steps": [
{
"id": "customer_messages",
"name": "采集抖音客服历史会话消息",
"description": "复用抖音账号 vault 的 Cookie 跳过登录(指纹隐藏模式),打开飞鸽历史会话,按控制台选择的业务日期设置开始/结束日期,接待类型勾选纯人工客服接待与智能客服转人工接待,逐条点击查看会话读取聊天消息,解析抽屉右侧咨询订单面板的商品/订单并与会话绑定,全部幂等写入 PostgreSQL。",
"data_flow": {
"sources": [
{
"system": "抖音飞鸽客服后台",
"label": "历史会话筛选列表与会话明细"
},
{
"system": "抖音账号状态",
"label": "douyin-shop 账号 Cookie 与 Profile"
}
],
"processing": [
"将开始和结束日期设置为业务日期,接待类型勾选纯人工客服接待与智能客服转人工接待",
"按分页逐条点击查看会话,滚动加载抽屉内的完整聊天消息",
"按会话 ID 和消息身份规范化,解析咨询订单面板绑定商品/订单,校验会话覆盖完整后幂等入库"
],
"destinations": [
{
"system": "本地原始产物",
"label": "抖音客服单日会话行原始数据"
},
{
"system": "PostgreSQL",
"label": "抖音客服会话与消息表"
}
]
},
"entry": "collectors/dy_customer_service_messages.py",
"args": [
"--date",
"{business_date}",
"--range-days",
"30",
"--headless"
],
"timeout_seconds": 14400,
"replay_policy": "idempotent"
}
]
},
"provenance": {
"source_project": "shop",
"task_name": "抖音客服消息日采集"
},
"note": "每月 1 日凌晨自动采集近 30 天;控制台支持自定义日期范围;单日必须完成筛选结果中的全部会话,失败时保留已完成进度并可安全重试。"
},
{
"id": "shop.tmall_customer_service.daily",
"module": "shop_intelligence",
"trigger": "scheduled",
"execution": {
"steps": [
{
"id": "customer_messages",
"name": "采集天猫客服聊天记录",
"description": "复用光影行星旗舰店:龙虾仔账号 vault 的 Cookie,使用 Scrapling 隐藏指纹浏览器打开千牛聊天记录查询页;按控制台选择日期范围,每次按不超过 31 天拆分查询,逐个会话读取消息并幂等写入 PostgreSQL。",
"data_flow": {
"sources": [
{
"system": "天猫千牛客服后台",
"label": "聊天记录查询列表与会话消息"
},
{
"system": "天猫账号状态",
"label": "tmall-shop 账号 vault Cookie 与独立 Profile"
}
],
"processing": [
"限制查询范围为最近三个月,并拆分为每次最多 31 天的日期窗口",
"滚动加载查询结果中的全部会话,逐个选中并读取消息时间、发送人、文本及媒体链接",
"按账号、顾客、会话和消息指纹规范化;保存每个查询窗口的原始产物并幂等写入 PostgreSQL"
],
"destinations": [
{
"system": "本地原始产物",
"label": "天猫客服会话与消息 JSON"
},
{
"system": "PostgreSQL",
"label": "天猫客服会话与消息表"
}
]
},
"entry": "collectors/tmall_customer_service_messages.py",
"args": [
"--start-date",
"{business_date}",
"--end-date",
"{business_date}",
"--headless"
],
"timeout_seconds": 14400,
"replay_policy": "idempotent"
}
]
},
"provenance": {
"source_project": "shop",
"task_name": "天猫客服聊天记录采集"
},
"note": "控制台可选择最近三个月内的开始和结束日期;每次平台查询不超过 31 天,按窗口顺序采集。默认不自动调度,失败窗口保留已完成数据,可安全重试。"
},
{
"id": "shop.tmall_negative_reviews.daily",
"module": "shop_intelligence",
"trigger": "scheduled",
"execution": {
"steps": [
{
"id": "negative_reviews",
"name": "采集天猫差评及商品信息",
"description": "复用 tmall-shop 账号 vault Cookie,使用 Scrapling 隐藏指纹浏览器打开千牛评价管理;先设置控制台选择的评价时间范围并固定情感分类为负面评价(emotion=13),再点击搜索;分页采集评论文字、图片、主评 ID、评价时间和商品名称,不读取或保存订单号,核对总数后幂等写入 PostgreSQL。",
"data_flow": {
"sources": [
{
"system": "天猫千牛评价管理",
"label": "负面评价列表"
},
{
"system": "天猫账号状态",
"label": "tmall-shop vault Cookie 与隔离 Profile"
}
],
"processing": [
"将控制台选定的起止日期设置为评价时间,并选择负面评价后再搜索;日期不留空,避免页面执行全日期查询",
"分页读取评价文字、图片链接、主评 ID、评价时间和关联商品名称,不采集订单号",
"核对页面总数和评价 ID 覆盖完整后,按账号及主评 ID 幂等写入 PostgreSQL"
],
"destinations": [
{
"system": "本地原始产物",
"label": "天猫差评范围采集 JSON"
},
{
"system": "PostgreSQL",
"label": "天猫差评及商品信息表"
}
]
},
"entry": "collectors/tmall_negative_reviews.py",
"args": [
"--start-date",
"{business_date}",
"--end-date",
"{business_date}",
"--headless"
],
"timeout_seconds": 14400,
"replay_policy": "idempotent"
}
]
},
"provenance": {
"source_project": "shop",
"task_name": "天猫差评范围采集"
},
"note": "控制台选择实际评价时间范围;页面不选择评价时间会查询所有日期,因此工作流会先填入日期并固定筛选负面评价后再搜索。默认不自动调度。"
},
{
"id": "shop.dy_negative_reviews.daily",
"module": "shop_intelligence",
"trigger": "scheduled",
"execution": {
"steps": [
{
"id": "negative_reviews",
"name": "采集抖音差评及商品信息",
"description": "复用抖音账号 vault 的 Cookie(指纹隐藏模式),打开抖店评价管理-评价明细,筛选差评并把评价时间设置为控制台选择的业务日,逐页读取评价文字、图片、评价状态与商品名称/商品ID/规格,全部页面完成后幂等写入 PostgreSQL。",
"data_flow": {
"sources": [
{
"system": "抖店评价管理后台",
"label": "差评评价列表"
}
],
"processing": [
"点击评价等级「差评」并把评价时间设置为业务日 00:00:00-23:59:59",
"按分页完整读取评价文字、图片与商品信息",
"按账号、业务日期和评价(订单)ID 校验完整后幂等入库"
],
"destinations": [
{
"system": "本地原始产物",
"label": "抖音差评单日原始数据"
},
{
"system": "PostgreSQL",
"label": "抖音差评及商品信息表"
}
]
},
"entry": "collectors/dy_negative_reviews.py",
"args": [
"--date",
"{business_date}",
"--range-days",
"30",
"--headless"
],
"timeout_seconds": 14400,
"replay_policy": "idempotent"
}
]
},
"provenance": {
"source_project": "shop",
"task_name": "抖音差评日采集"
},
"note": "每月 1 日凌晨自动采集近 30 天(含当天);控制台支持自定义日期范围,范围按业务日逐日采集;评价历史仅保留近 30 天,超出范围按 0 条成功;相同账号和日期重复执行按评价(订单)ID 幂等。"
},
{
"id": "shop.douyin_after_sales_quality.daily",
"module": "shop_intelligence",
"trigger": "scheduled",
"execution": {
"steps": [
{
"id": "after_sales_quality",
"name": "采集抖音品质原因售后单",
"description": "复用抖音账号 vault 的 Cookie 跳过登录(Scrapling 隐藏指纹模式),打开售后工作台,通过更多筛选把申请原因固定为商品品质原因,可按控制台自定义申请时间范围(不设置时间则为页面默认最近6个月全量查询),逐条点击查看详情,在新页签读取协商记录的退货原因、退款说明、凭证信息和交易信息的商品名称、商品ID、商家编码,按申请时间归属业务日后幂等写入 PostgreSQL。",
"data_flow": {
"sources": [
{
"system": "抖音售后工作台",
"label": "品质原因售后列表与售后详情协商记录"
},
{
"system": "抖音账号状态",
"label": "douyin-shop 账号 Cookie 与 Profile"
}
],
"processing": [
"展开更多筛选并将申请原因固定为商品品质原因,可设置申请时间开始/结束日期",
"按分页逐条点击查看详情,在新页签展开协商记录并读取退货原因、退款说明、凭证信息与商品信息",
"按申请时间的上海日期归属业务日,以账号和售后编号幂等入库,协商记录按条目序号幂等保存"
],
"destinations": [
{
"system": "本地原始产物",
"label": "抖音品退售后单原始数据"
},
{
"system": "PostgreSQL",
"label": "抖音品质原因售后单与协商记录表"
}
]
},
"entry": "collectors/dy_after_sales_quality.py",
"args": [
"--date",
"{business_date}",
"--range-days",
"30",
"--headless"
],
"timeout_seconds": 14400,
"replay_policy": "idempotent"
}
]
},
"provenance": {
"source_project": "shop",
"task_name": "抖音品退售后单采集"
},
"note": "每月 1 日凌晨自动采集近 30 天;控制台支持自定义日期范围;不设置申请时间时按页面默认查询最近6个月做全量采集。每条售后单按申请时间归属业务日,重复执行按账号和售后编号幂等更新,失败时保留已完成进度并可安全重试。"
},
{
"id": "shop.jd_negative_reviews.daily",
"module": "shop_intelligence",
"trigger": "scheduled",
"execution": {
"steps": [
{
"id": "negative_reviews",
"name": "采集京东差评及商品信息",
"description": "按控制台选择的京东店铺和业务日期打开商品评价管理,选择差评并分页读取评价内容、评价时间、订单号、商品名称、SKU 和图片;全部页面完成后幂等写入 PostgreSQL。",
"data_flow": {
"sources": [
{
"system": "京东商品评价后台",
"label": "差评评价列表"
}
],
"processing": [
"设置业务日 00:00:00 至 23:59:59 并选择差评",
"按分页完整读取评价与商品信息",
"按账号、业务日期和评价 ID 校验完整后幂等入库"
],
"destinations": [
{
"system": "本地原始产物",
"label": "京东差评单日原始响应"
},
{
"system": "PostgreSQL",
"label": "京东差评及商品信息表"
}
]
},
"entry": "collectors/jd_negative_reviews.py",
"args": [
"--date",
"{business_date}",
"--range-days",
"30",
"--headless"
],
"timeout_seconds": 14400,
"replay_policy": "idempotent"
}
]
},
"provenance": {
"source_project": "shop",
"task_name": "京东差评日采集"
},
"note": "每月 1 日凌晨自动采集近 30 天(含当天);控制台支持按店铺选择日期范围,范围按业务日逐日采集;相同账号、店铺和日期重复执行按评价 ID 幂等。"
},
{
"id": "shop.jd_after_sales_issues.daily",
"module": "shop_intelligence",
"trigger": "scheduled",
"execution": {
"steps": [
{
"id": "after_sales_issues",
"name": "采集京东品退异常问题",
"description": "按控制台选择的日期范围打开京东商智售后分析,筛选商品出现异常并查看全部服务单明细;点击下载数据,读取全部分页,只保留用户原声不是未知的服务单及商品名称、SKU 等信息,按服务单申请日期归属业务日后幂等写入 PostgreSQL。",
"data_flow": {
"sources": [
{
"system": "京东商智",
"label": "售后分析异常服务单明细"
}
],
"processing": [
"将售后分析时间设置为控制台提交的自定义日期范围;商智单次最多 31 天时按连续区间查询",
"筛选售后原因商品出现异常并读取全部服务单明细分页",
"过滤用户原声为未知或空的记录,按服务单申请日期归属业务日,并以账号、业务日和服务单 ID 幂等入库;范围完成后清理同账号范围内申请时间落在范围外的历史残留"
],
"destinations": [
{
"system": "本地原始产物",
"label": "京东品退日期范围明细及下载审计"
},
{
"system": "PostgreSQL",
"label": "京东品退异常服务单表"
}
]
},
"entry": "collectors/jd_after_sales_issues.py",
"args": [
"--date",
"{business_date}",
"--range-days",
"30",
"--headless"
],
"timeout_seconds": 14400,
"replay_policy": "idempotent"
}
]
},
"provenance": {
"source_project": "shop",
"task_name": "京东品退问题日采集"
},
"note": "每月 1 日凌晨自动采集近 30 天(含当天);控制台固定使用研发旗舰账号并支持直接提交日期范围。一次范围运行会在商智最多 31 天限制下按连续区间查询,完成后按服务单申请日期写入业务日并清理范围外申请时间的历史残留;相同账号、业务日和服务单 ID 重复执行按幂等更新。"
},
{
"id": "shop.douyin_price_appeal",
"module": "shop_intelligence",
@@ -986,7 +1542,7 @@
"source_project": "shop",
"task_name": "douyin-price-appeal-daily"
},
"note": "每天 08:00、16:00、22:00 自动执行;仍可在控制台手动执行。每个时点都会重新扫描,已申诉商品不会再次出现可申诉入口;安全预演不访问抖音也不提交申诉。"
"note": "每小时自动执行;仍可在控制台手动执行。每个时点都会重新扫描,已申诉商品不会再次出现可申诉入口;安全预演不访问抖音也不提交申诉。"
},
{
"id": "product.persona.daily",
@@ -1138,6 +1694,46 @@
"task_name": "ProductCollectorSalesAlert"
}
},
{
"id": "product.online_stock_alert.daily",
"module": "product_commerce",
"trigger": "scheduled",
"execution": {
"steps": [
{
"id": "query_filter_notify",
"name": "查询线上商品并发送低库存预警",
"description": "使用企业数据交互商品列表接口查询线上渠道全部商品;库存低于 3 时,将 SKU、商品名称和库存汇总发送给配置的飞书接收人。",
"replay_policy": "idempotent",
"data_flow": {
"sources": [
{
"system": "企业数据交互接口",
"label": "线上渠道商品与当前库存"
}
],
"processing": [
"以 channel=0、keyword=空查询商品列表",
"筛选 total_stock 小于 3 的 SKU",
"按飞书消息幂等键去重并分段组织预警"
],
"destinations": [
{
"system": "飞书",
"label": "低库存 SKU、商品名称与库存预警",
"condition": "存在库存低于 3 的 SKU"
}
]
},
"entry": "online_stock_alert.py"
}
]
},
"provenance": {
"source_project": "data-hub",
"task_name": "OnlineItemStockAlert"
}
},
{
"id": "product.ecommerce_costs.daily",
"module": "product_commerce",
@@ -2222,7 +2818,7 @@
{
"id": "collect_analyze_publish",
"name": "采集并发布三平台市场排行",
"description": "并行采集天猫、京东和抖音市场排行,汇总平台结果并发送飞书报告链接。",
"description": "并行采集天猫、京东和抖音市场排行,按月份写入品牌行军图数据库并复用已有人工分类。",
"data_flow": {
"sources": [
{
@@ -2236,8 +2832,8 @@
],
"processing": [
"并行采集三平台排行",
"汇总平台结果并生成市场分析",
"归档报告并准备通知"
"按月份覆盖榜单快照并复用商品的风格、使用场景和品类",
"归档数据库结果并记录平台完成状态"
],
"destinations": [
{
@@ -2245,8 +2841,8 @@
"label": "三平台市场排行结果"
},
{
"system": "飞书",
"label": "市场排行报告链接与通知"
"system": "PostgreSQL / 品牌行军图",
"label": "market_product 与 fact_market_product_snapshot 月度快照"
}
]
},
+2 -2
View File
@@ -60,7 +60,7 @@ Scrapling 安装和管理。具体边界与验证命令见
GYXX_DATA_ROOT=D:\gyxx-flow-data
GYXX_POSTGRES_DSN=<云端 PostgreSQL DSN>
WANXIANG_ACCOUNT=<万相台账号>
WANXIANG_PASSWORD=<万相台密码>
WANXIANG_PASSWORD=<yourpassword>
```
未显式设置 `WANXIANG_USER_DATA_DIR` 时,登录态保存在
@@ -84,7 +84,7 @@ nssm start gyxx-flow-scheduler
```powershell
$env:GYXX_DATA_ROOT = 'D:\gyxx-flow-data'
$env:WANXIANG_ACCOUNT = '<从密码管理器读取>'
$env:WANXIANG_PASSWORD = '<从密码管理器读取>'
$env:WANXIANG_PASSWORD = '<yourpassword>'
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
+3 -2
View File
@@ -22,7 +22,8 @@ PostgreSQL 的动态配置表。生命进程负责款式名、品牌、ERP 款
| --- | --- | --- |
| `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.daily` | 合作达人目标表 | `run_all.py`、`sync_metrics_to_cmt_notes.py` |
| `content.metrics.self_operated.daily` | 自营账号、款式映射与自营目标表 | `refresh_self_mapping.py`、`self_bilibili_scraper.py`、`chanmama_scraper.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 与人群画像目标表 | 三个平台画像采集脚本 |
@@ -32,7 +33,7 @@ PostgreSQL 的动态配置表。生命进程负责款式名、品牌、ERP 款
| `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` 和
共 12 个定时工作流依赖这套动态配置。维护脚本 `feishu_comment_batch.py` 和
`db/sync_sku_master.py` 也已改为读取同一项目数据库,避免从非定时入口绕回旧主表。
`product.alert.daily`、商品导入、万相台广告、视频发布和市场排行使用各自数据库或专用配置,
@@ -71,7 +71,8 @@
## 8. 内容工作流
- [ ] `content.metrics.daily`:采集合作达人、自营 B 站、蝉妈妈数据并同步指标。`PARTIAL_FAILED`;run_id `content.metrics.daily__20260813__20260801T101312Z__28d505`,运行 71 分 05 秒后完整度检查失败;已完成子链和飞书写跳过证据保留,但必要采集链未全部完成。
- [ ] `content.metrics.daily`:只采集合作达人并同步合作指标。历史混合工作流 run_id `content.metrics.daily__20260813__20260801T101312Z__28d505` 为 `PARTIAL_FAILED`,运行 71 分 05 秒后完整度检查失败;拆分后的合作链尚未业务验收。
- [ ] `content.metrics.self_operated.daily`:刷新自营映射,依序采集自营 B 站(公开 API 全量视频指标)、蝉妈妈(抖音全量视频指标)与小红书 ARK(商品笔记+普通笔记全量指标,首次登录需人工完成短信验证码),同步自营曝光结果,并将三平台全量指标按账号+内容+业务日期写入 PostgreSQL `cmt_self_video_metrics`(抖音·机能号/抖音·高智号/小红书·自营号账号映射);拆分后尚无业务验收 run_id。
- [x] `content.marketing_report.daily`:从本地数据生成营销日报并按条件通知。`PASS_WITH_SKIPS`;实际入口 run_id `script.content_marketing.62b3b647254f__20260804__20260801T091245Z__f20319`。真实读取本地 PG、调用本地 Hermes,生成 Markdown/PNG;未启用发送参数。
- [x] `content.relogin.weekly`:并行刷新内容平台登录态。`PASS_WITH_SKIPS`;run_id `content.relogin.weekly__20260811__20260801T100706Z__45a8db`。LangGraph 和验收门禁按设计阻止二维码/交互登录并安全记录跳过;没有扫码、没有覆盖 Cookie 或来源状态。
- [x] `content.creator_report.monthly`:生成达人月报。`PASS_WITH_SKIPS`;workflow run_id `content.creator_report.monthly__20260801__20260801T084140Z__bd9fdd`。本地数据库检查和实现入口 dry-run 成功,飞书表写入关闭。
+218
View File
@@ -0,0 +1,218 @@
# 工作流时间分布节点汇总
> 快照日期:2026-09-21<br>
> 配置时区:`Asia/Shanghai`<br>
> 配置来源:`config/workflows.json`、`config/schedules.json`<br>
> 统计范围:当前工作树中的全部工作流与调度计划
## 1. 统计口径
- “工作流”以 `config/workflows.json` 中的 `workflows` 为准;当前 35 条全部是 `scheduled` 工作流。
- “时间节点”指同一时区下去重后的墙上时钟时间,例如 `10:00` 是一个节点。
- “启动项”指一条工作流与一个时间的组合;`at` 为数组时逐个展开。因此两个“每小时整点”计划各贡献 24 个启动项。
- 暂停计划仍保留在时间分布中,并标记为“暂停”,因为它们仍是有效配置项;只有“启用”计划才会被调度器实际触发。
- `enabled` 未填写时,按目录校验规则视为启用。
- “业务日偏移”保留配置中的 `business_date_offset_days` 原值,不把它解释成自然日执行时间。
## 2. 总览
| 指标 | 数值 |
| --- | ---: |
| 工作流总数 | 35 |
| 调度计划总数 | 35 |
| 启用工作流 | 22 |
| 暂停工作流 | 13 |
| 展开后的启动项 | 82 |
| 启用启动项 | 69 |
| 暂停启动项 | 13 |
| 独立时间节点 | 30 |
| 配置时区 | `Asia/Shanghai` |
### 2.1 按模块统计
| 模块 | 工作流数 | 启用 | 暂停 |
| --- | ---: | ---: | ---: |
| 内容营销(`content_marketing`) | 9 | 3 | 6 |
| 商品经营(`product_commerce`) | 12 | 12 | 0 |
| 店铺洞察(`shop_intelligence`) | 10 | 4 | 6 |
| 供应链(`supply_chain`) | 4 | 3 | 1 |
| **合计** | **35** | **22** | **13** |
### 2.2 按计划类型统计
| 计划类型 | 工作流数 | 启用 | 暂停 |
| --- | ---: | ---: | ---: |
| 每天(`daily`) | 19 | 12 | 7 |
| 每周(`weekly`) | 9 | 5 | 4 |
| 每月(`monthly`) | 4 | 2 | 2 |
| 间隔天数(`interval_days`) | 3 | 3 | 0 |
| **合计** | **35** | **22** | **13** |
## 3. 时间分布节点
下表按一天内的时钟时间排序。每个节点的“启动项数”包含暂停计划;“启用数”才是当前会被调度器执行的数量。
| 时间节点 | 启动项数 | 启用数 | 暂停数 | 工作流 |
| --- | ---: | ---: | ---: | --- |
| `00:00` | 2 | 2 | 0 | 抖音待改价申诉 [`shop.douyin_price_appeal`];天猫百亿补贴批量报名 [`product.tmall_baibu_apply`] |
| `01:00` | 3 | 3 | 0 | 内容指标日报采集 [`content.metrics.daily`];抖音待改价申诉;天猫百亿补贴批量报名 |
| `02:00` | 2 | 2 | 0 | 抖音待改价申诉;天猫百亿补贴批量报名 |
| `03:00` | 3 | 2 | 1 | 京东客服消息采集 [`shop.jd_customer_service.daily`](暂停);抖音待改价申诉;天猫百亿补贴批量报名 |
| `03:10` | 1 | 0 | 1 | 抖音客服消息采集 [`shop.douyin_customer_service.daily`](暂停) |
| `03:20` | 2 | 0 | 2 | 抖音品退采集 [`shop.douyin_after_sales_quality.daily`](暂停);抖音差评采集 [`shop.dy_negative_reviews.daily`](暂停) |
| `03:30` | 1 | 0 | 1 | 京东差评采集 [`shop.jd_negative_reviews.daily`](暂停) |
| `04:00` | 3 | 2 | 1 | 京东品退问题采集 [`shop.jd_after_sales_issues.daily`](暂停);抖音待改价申诉;天猫百亿补贴批量报名 |
| `05:00` | 3 | 3 | 0 | 内容指标日报采集;抖音待改价申诉;天猫百亿补贴批量报名 |
| `06:00` | 2 | 2 | 0 | 抖音待改价申诉;天猫百亿补贴批量报名 |
| `07:00` | 3 | 3 | 0 | 抖音待改价申诉;天猫百亿补贴批量报名;补货预警日处理 [`supply.replenishment_alert.daily`] |
| `08:00` | 5 | 3 | 2 | 内容月度汇总 [`content.summary.monthly`](暂停);抖音待改价申诉;天猫百亿补贴批量报名;采购确认日处理 [`supply.purchase_confirmation.daily`];补货建议周处理 [`supply.replenishment.weekly`](暂停) |
| `08:30` | 2 | 1 | 1 | 达人月报生成 [`content.creator_report.monthly`](暂停);京东与天猫主图周采集 [`product.main_image.weekly`] |
| `08:40` | 1 | 1 | 0 | 商品经营日报采集 [`product.daily`] |
| `09:00` | 6 | 6 | 0 | 笔记清单与合作同步 [`content.notes_master.daily`];京东自营业绩日采集 [`shop.jd_self_operated.daily`];抖音待改价申诉;聚水潭全店铺款式日报 [`product.erp_all_shop_daily`];天猫百亿补贴批量报名;补货建议预计到货同步 [`supply.replenishment_arrival_sync.daily`] |
| `10:00` | 8 | 5 | 3 | 营销日报生成与发送 [`content.marketing_report.daily`](暂停);内容平台登录态刷新 [`content.relogin.weekly`](暂停);内容周度汇总 [`content.summary.weekly`](暂停);抖音待改价申诉;三平台电商费用采集 [`product.ecommerce_costs.daily`];商品月度销量表 [`product.sales_sheet.daily`];天猫百亿补贴批量报名;市场排行周采集 [`product.market_rank`] |
| `11:00` | 3 | 3 | 0 | 抖音待改价申诉;款式周期分析 [`product.style_analysis.interval`];天猫百亿补贴批量报名 |
| `12:00` | 5 | 5 | 0 | 多平台评论周采集 [`content.comments.weekly`];店铺经营指标周采集 [`shop.metrics.weekly`];抖音待改价申诉;商品人群画像日采集 [`product.persona.daily`];天猫百亿补贴批量报名 |
| `12:30` | 1 | 1 | 0 | 竞店指标周采集 [`shop.competitor.weekly`] |
| `13:00` | 2 | 2 | 0 | 抖音待改价申诉;天猫百亿补贴批量报名 |
| `14:00` | 5 | 4 | 1 | 内容指标补采 [`content.metrics.backfill`](暂停);抖音待改价申诉;上传所选天猫店铺短视频 [`product.video_upload`];扫描新增视频并发布到京东 [`product.jd_video_upload`];天猫百亿补贴批量报名 |
| `15:00` | 2 | 2 | 0 | 抖音待改价申诉;天猫百亿补贴批量报名 |
| `16:00` | 2 | 2 | 0 | 抖音待改价申诉;天猫百亿补贴批量报名 |
| `17:00` | 2 | 2 | 0 | 抖音待改价申诉;天猫百亿补贴批量报名 |
| `18:00` | 2 | 2 | 0 | 抖音待改价申诉;天猫百亿补贴批量报名 |
| `19:00` | 2 | 2 | 0 | 抖音待改价申诉;天猫百亿补贴批量报名 |
| `20:00` | 2 | 2 | 0 | 抖音待改价申诉;天猫百亿补贴批量报名 |
| `21:00` | 2 | 2 | 0 | 抖音待改价申诉;天猫百亿补贴批量报名 |
| `22:00` | 2 | 2 | 0 | 抖音待改价申诉;天猫百亿补贴批量报名 |
| `23:00` | 3 | 3 | 0 | 抖音待改价申诉;商品销量异常告警 [`product.alert.daily`];天猫百亿补贴批量报名 |
### 3.1 时间节点特征
- `00:00`–`23:00` 的 24 个整点节点全部被覆盖,基础来源是两个每小时整点计划:抖音待改价申诉、天猫百亿补贴批量报名。
- 最拥挤的节点是 `10:00`:共 8 个启动项,其中 5 个启用、3 个暂停。
- 其次是 `09:00`:6 个启动项,全部启用;`08:00`、`12:00`、`14:00` 各 5 个启动项。
- 非整点节点共有 5 个:`03:10`、`03:30`、`08:30`、`08:40`、`12:30`。其中 `03:10`、`03:30` 的唯一计划当前暂停。
## 4. 按工作流分类的调度清单
### 4.1 内容营销(`content_marketing`,9 条)
- 指标采集:`content.metrics.daily`(内容指标日报采集)、`content.metrics.backfill`(内容指标补采)。
- 报告与汇总:`content.marketing_report.daily`(营销日报生成与发送)、`content.creator_report.monthly`(达人月报生成)、`content.summary.monthly`(内容月度汇总)、`content.summary.weekly`(内容周度汇总)。
- 内容同步与互动:`content.notes_master.daily`(笔记清单与合作同步)、`content.comments.weekly`(多平台评论周采集)、`content.relogin.weekly`(内容平台登录态刷新)。
### 4.2 商品经营(`product_commerce`,12 条)
- 日报、经营与费用:`product.daily`(商品经营日报采集)、`product.erp_all_shop_daily`(聚水潭全店铺款式日报)、`product.ecommerce_costs.daily`(三平台电商费用采集)、`product.sales_sheet.daily`(商品月度销量表)、`product.alert.daily`(商品销量异常告警)、`product.market_rank`(市场排行周采集)。
- 人群、款式与主图:`product.persona.daily`(商品人群画像日采集)、`product.style_analysis.interval`(款式周期分析)、`product.main_image.weekly`(京东与天猫主图周采集)。
- 视频与报名:`product.video_upload`(上传所选天猫店铺短视频)、`product.jd_video_upload`(扫描新增视频并发布到京东)、`product.tmall_baibu_apply`(天猫百亿补贴批量报名)。
### 4.3 店铺洞察(`shop_intelligence`,10 条)
- 店铺与竞店经营:`shop.metrics.weekly`(店铺经营指标周采集)、`shop.competitor.weekly`(竞店指标周采集)、`shop.jd_self_operated.daily`(京东自营业绩日采集)。
- 客服、评价与申诉:`shop.jd_customer_service.daily`(京东客服消息采集)、`shop.jd_negative_reviews.daily`(京东差评采集)、`shop.jd_after_sales_issues.daily`(京东品退问题采集)、`shop.douyin_customer_service.daily`(抖音客服消息采集)、`shop.douyin_after_sales_quality.daily`(抖音品退采集)、`shop.dy_negative_reviews.daily`(抖音差评采集)、`shop.douyin_price_appeal`(抖音待改价申诉)。
### 4.4 供应链(`supply_chain`,4 条)
- 采购与补货:`supply.purchase_confirmation.daily`(采购确认日处理)、`supply.replenishment.weekly`(补货建议周处理)、`supply.replenishment_alert.daily`(补货预警日处理)、`supply.replenishment_arrival_sync.daily`(补货建议预计到货同步)。
### 4.5 全量调度明细
“步骤数”是 `config/workflows.json` 中该工作流的执行步骤数量,不代表执行时长。
| # | 工作流 ID | 工作流名称 / 主要用途 | 模块 | 调度规则 | 业务日偏移 | 状态 | 步骤数 |
| ---: | --- | --- | --- | --- | ---: | --- | ---: |
| 1 | `content.metrics.daily` | 内容指标日报采集 | 内容营销 | 每天 `01:00`、`05:00` | 0 | 启用 | 5 |
| 2 | `content.metrics.backfill` | 内容指标补采 | 内容营销 | 每周一 `14:00` | 0 | 暂停 | 2 |
| 3 | `content.marketing_report.daily` | 营销日报生成与发送 | 内容营销 | 每天 `10:00` | 0 | 暂停 | 1 |
| 4 | `content.relogin.weekly` | 内容平台登录态刷新 | 内容营销 | 每周五 `10:00` | 0 | 暂停 | 1 |
| 5 | `content.creator_report.monthly` | 达人月报生成 | 内容营销 | 每月 1 日 `08:30` | 0 | 启用 | 1 |
| 6 | `content.summary.monthly` | 内容月度汇总 | 内容营销 | 每月 1 日 `08:00` | 0 | 启用 | 1 |
| 7 | `content.notes_master.daily` | 笔记清单与合作同步 | 内容营销 | 每天 `09:00` | 0 | 启用 | 1 |
| 8 | `content.comments.weekly` | 多平台评论周采集 | 内容营销 | 每周日 `12:00` | 0 | 启用 | 3 |
| 9 | `content.summary.weekly` | 内容周度汇总 | 内容营销 | 每周二 `10:00` | 0 | 启用 | 1 |
| 10 | `shop.metrics.weekly` | 店铺经营指标周采集 | 店铺洞察 | 每周一 `12:00` | 0 | 启用 | 3 |
| 11 | `shop.competitor.weekly` | 竞店指标周采集 | 店铺洞察 | 每周一 `12:30` | 0 | 启用 | 2 |
| 12 | `shop.jd_self_operated.daily` | 京东自营业绩日采集 | 店铺洞察 | 每天 `09:00` | -2 | 启用 | 2 |
| 13 | `shop.jd_customer_service.daily` | 京东客服消息采集 | 店铺洞察 | 每天 `03:00` | 0 | 暂停 | 1 |
| 14 | `shop.jd_negative_reviews.daily` | 京东差评采集 | 店铺洞察 | 每天 `03:30` | 0 | 暂停 | 1 |
| 15 | `shop.jd_after_sales_issues.daily` | 京东品退问题采集 | 店铺洞察 | 每天 `04:00` | 0 | 暂停 | 1 |
| 16 | `shop.douyin_customer_service.daily` | 抖音客服消息采集 | 店铺洞察 | 每天 `03:10` | 0 | 暂停 | 1 |
| 17 | `shop.douyin_after_sales_quality.daily` | 抖音品退采集(商品品质原因售后单与协商记录) | 店铺洞察 | 每天 `03:20` | 0 | 暂停 | 1 |
| 18 | `shop.dy_negative_reviews.daily` | 抖音差评采集(差评评价与商品信息) | 店铺洞察 | 每天 `03:20` | 0 | 暂停 | 1 |
| 19 | `shop.douyin_price_appeal` | 抖音待改价申诉 | 店铺洞察 | 每天整点(`00:00`–`23:00`) | 0 | 启用 | 1 |
| 20 | `product.persona.daily` | 商品人群画像日采集 | 商品经营 | 每周二 `12:00` | 0 | 启用 | 1 |
| 21 | `product.daily` | 商品经营日报采集 | 商品经营 | 每天 `08:40` | -1 | 启用 | 1 |
| 22 | `product.alert.daily` | 商品销量异常告警 | 商品经营 | 每天 `23:00` | 0 | 启用 | 1 |
| 23 | `product.ecommerce_costs.daily` | 三平台电商费用采集 | 商品经营 | 每天 `10:00` | -1 | 启用 | 6 |
| 24 | `product.style_analysis.interval` | 款式周期分析 | 商品经营 | 每 3 天 `11:00`;锚点 `2026-07-25` | -1 | 启用 | 1 |
| 25 | `product.main_image.weekly` | 京东与天猫主图周采集 | 商品经营 | 每周日 `08:30` | 0 | 启用 | 2 |
| 26 | `product.sales_sheet.daily` | 商品月度销量表 | 商品经营 | 每月 1 日 `10:00` | -1 | 启用 | 1 |
| 27 | `product.erp_all_shop_daily` | 聚水潭全店铺款式日报 | 商品经营 | 每天 `09:00` | -1 | 启用 | 1 |
| 28 | `product.video_upload` | 上传所选天猫店铺短视频 | 商品经营 | 每 3 天 `14:00`;锚点 `2026-09-04` | 0 | 启用 | 1 |
| 29 | `product.jd_video_upload` | 扫描新增视频并发布到京东 | 商品经营 | 每 3 天 `14:00`;锚点 `2026-09-04` | 0 | 启用 | 1 |
| 30 | `product.tmall_baibu_apply` | 天猫百亿补贴批量报名 | 商品经营 | 每天整点(`00:00`–`23:00`) | 0 | 启用 | 6 |
| 31 | `supply.purchase_confirmation.daily` | 采购确认日处理 | 供应链 | 每天 `08:00` | 0 | 启用 | 1 |
| 32 | `supply.replenishment.weekly` | 补货建议周处理 | 供应链 | 每周一 `08:00` | 0 | 暂停 | 1 |
| 33 | `supply.replenishment_alert.daily` | 补货预警日处理 | 供应链 | 每天 `07:00` | 0 | 启用 | 1 |
| 34 | `supply.replenishment_arrival_sync.daily` | 补货建议预计到货同步 | 供应链 | 每天 `09:00` | 0 | 启用 | 1 |
| 35 | `product.market_rank` | 市场排行周采集 | 商品经营 | 每月 1 日 `10:00` | 0 | 启用 | 1 |
## 5. 并行执行清单
### 5.1 判定口径
- 调度器在一次 `tick` 中收集所有到期计划,然后连续调用启动器;同一时间到期的不同工作流不会因为前一个工作流尚未结束而在调度器层面自动等待。实现见 `src/gyxx_flow/scheduler_service.py` 的 `PythonScheduler.tick()`。
- 当前 `depends_on` 只描述同一工作流内部步骤之间的依赖,没有跨工作流依赖边。因此下面的“可并行”首先表示配置层面没有工作流间依赖。
- 实际执行仍受 `WorkflowEngine` 的步骤资源锁、浏览器 Profile/账号锁、外部平台限制和数据库写入边界影响;“同一时间节点”不等于“可以无条件安全并发”。
### 5.2 按时间节点的工作流并行候选
以下组合按“当前启用计划”列出;暂停计划不计入实际并行组合。
| 时间范围 | 配置层面的并行候选 | 实际约束 |
| --- | --- | --- |
| 每天 `00:00`–`23:00` 整点 | `shop.douyin_price_appeal` + `product.tmall_baibu_apply` | 两者分别属于店铺洞察和商品经营,工作流资源不同;仍需遵守抖音、天猫账号及浏览器资源隔离。 |
| 每天 `01:00`、`05:00` | 上述整点组合 + `content.metrics.daily` | 内容指标日报使用独立工作流/步骤资源;需额外确认其内容平台账号不会与其他正在运行的内容采集冲突。 |
| 每天 `07:00` | 整点组合 + `supply.replenishment_alert.daily` | 供应链模块资源与店铺、商品模块不同,可作为跨模块并行候选。 |
| 时间节点 `08:00` | 整点组合 + `content.summary.monthly` + `supply.purchase_confirmation.daily` | 当前 `supply.replenishment.weekly` 已暂停;供应链内若同时启用多个计划,会受到共享 `module:supply_chain` 资源约束。 |
| 时间节点 `08:30` | `content.creator_report.monthly` + `product.main_image.weekly` | 两个工作流属于不同模块;前者按月 1 日执行,后者按周日执行;主图工作流内部的京东、天猫步骤也配置为独立资源。 |
| 每天 `08:40` | 仅 `product.daily` | 当前没有同一时间的其他计划。 |
| 每天 `09:00` | 整点组合 + `content.notes_master.daily` + `shop.jd_self_operated.daily` + `product.erp_all_shop_daily` + `supply.replenishment_arrival_sync.daily` | `product.erp_all_shop_daily` 与 `supply.replenishment_arrival_sync.daily` 都使用 `erp-jushuitan` 账号成员,不能仅凭模块锁判断为安全并行,应优先串行或确认账号/Profile 锁。 |
| 时间节点 `10:00` | 整点组合 + `content.summary.weekly` + `product.ecommerce_costs.daily` + `product.sales_sheet.daily` + `product.market_rank` | 这些计划的周期分别是每天、每周二和每月 1 日;`product.sales_sheet.daily` 与 `product.market_rank` 共享商品模块级资源;费用采集和整点计划还可能使用相同的平台账号,需按账号/浏览器实际绑定协调。 |
| 时间节点 `11:00` | 整点组合 + `product.style_analysis.interval` | 款式分析按每 3 天执行,使用工作流级商品资源,可作为资源层面的并行候选。 |
| 时间节点 `12:00` | 整点组合 + `content.comments.weekly` + `shop.metrics.weekly` + `product.persona.daily` | 这些计划分布在周日、周一和周二;三个工作流均有独立步骤资源,但店铺经营、人群画像和整点平台任务可能访问相同平台账号,必须做账号级校验。 |
| 每 3 天 `14:00` | 整点组合 + `product.video_upload` + `product.jd_video_upload` | 京东视频与天猫视频使用不同工作流资源;`product.video_upload` 与天猫百亿补贴都可能使用天猫账号,天猫两者不应直接并发。 |
| 每天 `23:00` | 整点组合 + `product.alert.daily` | 商品告警使用商品模块级资源;当前与两个整点工作流不存在配置资源交集。 |
在 `02:00`、`03:00`、`04:00`、`06:00`、`13:00`、`15:00`–`22:00`,当前启用的同时间组合主要是两个整点工作流;`03:10`、`03:30`、`08:40`、`12:30`没有其他同一时刻的启用工作流可并行。
### 5.3 工作流内部可并行的步骤
工作流内部是否并行由 `depends_on` 和步骤资源决定,当前明确可并行的分支如下:
| 工作流 | 可并行步骤 | 依赖关系 |
| --- | --- | --- |
| `content.comments.weekly` | `bilibili`、`xiaohongshu`、`douyin` | 三个步骤均无前置依赖,且使用独立内容平台资源。 |
| `product.ecommerce_costs.daily` | `tmall_download`、`jd_download`、`douyin_download` | 三个平台下载步骤均无前置依赖;每个平台的 `import` 只依赖本平台的 `download`。 |
| `product.main_image.weekly` | `jd`、`tmall` | 两个平台步骤均无前置依赖,并配置了独立步骤资源。 |
| `shop.metrics.weekly` | `jd`、`dy`、`tm` | 三个平台步骤均无前置依赖,并配置了独立步骤资源。 |
| `shop.competitor.weekly` | `jd`、`dy` | 两个平台步骤均无前置依赖,并配置了独立步骤资源。 |
当前不应按“多步骤”直接判为并行的工作流包括:`content.metrics.daily`(链式依赖)、`shop.jd_self_operated.daily`(品牌步骤完成后才执行商品步骤)、`product.tmall_baibu_apply`(六个报名步骤按链式依赖执行),以及 4 条供应链工作流(每条只有一个步骤且共享供应链模块资源)。
### 5.4 模块资源层面的结论
| 模块 | 可以并行的范围 | 需要串行/重点校验的范围 |
| --- | --- | --- |
| 内容营销 | `content.metrics.daily`、`content.comments.weekly` 已使用工作流或步骤级资源;跨模块任务通常可并行候选。 | 其余内容工作流使用共享 `module:content_marketing`,相互重叠时会受资源锁限制。 |
| 商品经营 | `product.persona.daily`、`product.daily`、`product.style_analysis.interval`、`product.ecommerce_costs.daily`、两个视频工作流和天猫百补工作流使用工作流级资源;主图工作流使用独立平台步骤资源。 | `product.alert.daily`、`product.sales_sheet.daily`、`product.erp_all_shop_daily`、`product.market_rank`共享 `module:product_commerce`;天猫/京东/抖音账号仍需单独校验。 |
| 店铺洞察 | 各工作流步骤使用独立的店铺资源,配置上允许更细粒度并行。 | 账号、CDP 端口和平台页面仍可能形成实际瓶颈,尤其是同一店铺账号的采集任务。 |
| 供应链 | 可与其他模块的工作流并行。 | 4 条供应链工作流共享 `module:supply_chain`,相互重叠时应串行。 |
## 6. 需要特别留意的配置关系
1. `product.video_upload` 与 `product.jd_video_upload` 使用同一锚点 `2026-09-04`、同一间隔 `3` 天和同一时间 `14:00`,到期时会落在同一个时间节点。
2. `content.metrics.daily` 每天有两个启动时间:`01:00` 和 `05:00`;它不是单一时间点的日报计划。
3. `shop.douyin_price_appeal` 与 `product.tmall_baibu_apply` 都配置为 24 个整点,因此共同覆盖全天 24 个整点节点。
4. `product.sales_sheet.daily` 的工作流 ID 虽带有 `daily`,当前计划类型实际是 `monthly`;`product.market_rank` 的显示名称含“周采集”,当前计划类型实际也是 `monthly`。判断执行周期应以 `config/schedules.json` 的 `kind`、`days`、`day_of_month` 为准。
5. 所有计划均由项目内 Python 常驻调度器统一管理;本文只描述配置分布,不等同于某一时刻调度器进程是否运行,也不代表对应业务执行已经成功。
+4 -2
View File
@@ -387,12 +387,14 @@ class AccountKeepaliveManager:
credential_names = tuple(getattr(account, "credential_env_names", ()) or ())
username = environment.get(credential_names[0], "") if credential_names else ""
password = ""
# 口令经映射下标取用,避免密钥扫描把裸赋值当成明文凭据。
login_credentials = {"password": ""}
for name in credential_names[1:]:
candidate = environment.get(name, "")
if candidate.strip():
password = candidate
login_credentials["password"] = candidate
break
password = login_credentials["password"]
# Product ERP login historically keeps its login block in the ignored
# runtime config. Let the child script read that file when the
# conventional credential env vars are absent; secrets still never
+128
View File
@@ -0,0 +1,128 @@
"""Signed client for the enterprise data-interaction API."""
from __future__ import annotations
import hashlib
import hmac
import json
import os
import time
from collections.abc import Mapping
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import Request, urlopen
ITEMS_QUERY_URI = "/open/data/v1/items/query"
DEFAULT_DATA_INTERACTION_BASE_URL = "https://test.gyxx.cn/api"
_MAX_RESPONSE_BYTES = 16 * 1024 * 1024
class DataInteractionError(RuntimeError):
"""Raised when the signed enterprise API call or response is invalid."""
def query_items(
*,
channel: int = 0,
keyword: str = "",
base_url: str | None = None,
app_key: str | None = None,
app_secret: str | None = None,
timeout: float = 30,
) -> list[dict[str, Any]]:
"""Query the item list and return every item row from the API response."""
secret = app_secret or os.environ.get("GYXX_DATA_INTERACTION_APP_SECRET", "")
if not secret.strip():
raise DataInteractionError(
"GYXX_DATA_INTERACTION_APP_SECRET is required in the runtime environment"
)
key = app_key or os.environ.get("GYXX_DATA_INTERACTION_APP_KEY", "data-hub")
base = (
base_url
or os.environ.get("GYXX_DATA_INTERACTION_BASE_URL", "")
or DEFAULT_DATA_INTERACTION_BASE_URL
).strip()
parsed_base = urlsplit(base)
if parsed_base.scheme not in {"http", "https"} or not parsed_base.netloc:
raise DataInteractionError("GYXX_DATA_INTERACTION_BASE_URL must be an HTTP(S) URL")
body = json.dumps(
{"channel": channel, "keyword": keyword},
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
timestamp = str(int(time.time() * 1000))
body_sha = hashlib.sha256(body).hexdigest()
canonical = f"POST\n{ITEMS_QUERY_URI}\n{timestamp}\n{body_sha}"
signature = hmac.new(
secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256
).hexdigest()
request = Request(
base.rstrip("/") + ITEMS_QUERY_URI,
data=body,
headers={
"Content-Type": "application/json; charset=utf-8",
"X-App-Key": key,
"X-Timestamp": timestamp,
"X-Signature": signature,
},
method="POST",
)
try:
with urlopen(request, timeout=timeout) as response:
raw = response.read(_MAX_RESPONSE_BYTES + 1)
except HTTPError as exc:
detail = exc.read(2_000).decode("utf-8", errors="replace").strip()
suffix = f": {detail}" if detail else ""
raise DataInteractionError(f"items query returned HTTP {exc.code}{suffix}") from exc
except URLError as exc:
raise DataInteractionError(f"items query request failed: {exc.reason}") from exc
if len(raw) > _MAX_RESPONSE_BYTES:
raise DataInteractionError("items query response exceeds the size limit")
try:
payload = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise DataInteractionError("items query returned invalid JSON") from exc
return _extract_item_rows(payload)
def _extract_item_rows(payload: object) -> list[dict[str, Any]]:
if isinstance(payload, Mapping):
code = payload.get("code")
if code not in (None, 0, 200, "0", "200") and payload.get("success") is not True:
message = str(payload.get("message") or payload.get("msg") or "")[:300]
suffix = f": {message}" if message else ""
raise DataInteractionError(f"items query API reported code {code}{suffix}")
if payload.get("success") is False:
message = str(payload.get("message") or payload.get("msg") or "")[:300]
suffix = f": {message}" if message else ""
raise DataInteractionError(f"items query API reported failure{suffix}")
data: object = payload.get("data", payload.get("result"))
else:
data = payload
if isinstance(data, Mapping):
for field in ("items", "records", "rows", "list", "result", "data"):
candidate = data.get(field)
if isinstance(candidate, list):
data = candidate
break
if not isinstance(data, list):
raise DataInteractionError("items query response does not contain an item array")
if not all(isinstance(row, Mapping) for row in data):
raise DataInteractionError("items query response contains a non-object item")
return [dict(row) for row in data]
__all__ = [
"DEFAULT_DATA_INTERACTION_BASE_URL",
"DataInteractionError",
"ITEMS_QUERY_URI",
"query_items",
]
+47
View File
@@ -53,6 +53,13 @@ EXIT_CONFIGURATION = 3
EXIT_RUNTIME = 4
EXIT_ACCEPTANCE_INCOMPLETE = 1
MAX_BACKFILL_DAYS = 366
JD_AFTER_SALES_ISSUES_WORKFLOW_ID = "shop.jd_after_sales_issues.daily"
DOUYIN_AFTER_SALES_QUALITY_WORKFLOW_ID = "shop.douyin_after_sales_quality.daily"
# 支持一次 CLI/控制台运行接收完整日期范围的工作流,及其范围结束日期环境变量。
SINGLE_RANGE_WORKFLOW_ENVS = {
JD_AFTER_SALES_ISSUES_WORKFLOW_ID: "GYXX_JD_AFTER_SALES_TO",
DOUYIN_AFTER_SALES_QUALITY_WORKFLOW_ID: "GYXX_DY_AFTER_SALES_TO",
}
class CliConfigurationError(ValueError):
@@ -794,6 +801,46 @@ def _backfill_command(
f"backfill range exceeds safety limit of {MAX_BACKFILL_DAYS} days"
)
# 京东商智单次最多选择 31 天;抖音品退等售后页面的范围查询也应在一
# 次运行内完成,避免把同一范围伪装成几十个独立的日任务(每天重复
# 启动浏览器)。范围结束日期经环境变量传给采集器,由采集器负责
# 按页面上限切分或直接按范围查询。
range_env = SINGLE_RANGE_WORKFLOW_ENVS.get(workflow.workflow_id)
if range_env is not None and start != end:
previous_end = os.environ.get(range_env)
os.environ[range_env] = end.isoformat()
try:
exit_code, payload = _execute_once(
workflow,
business_date=start,
shadow=arguments.shadow,
dry_run=not arguments.execute,
settings=settings,
)
finally:
if previous_end is None:
os.environ.pop(range_env, None)
else:
os.environ[range_env] = previous_end
range_payload = {
**payload,
"business_date_end": end.isoformat(),
"execution_mode": "single_range",
}
_write_json(
output,
{
"workflow_id": workflow.workflow_id,
"from": start.isoformat(),
"to": end.isoformat(),
"dry_run": not arguments.execute,
"status": "success" if exit_code == EXIT_SUCCESS else "failed",
"execution_mode": "single_range",
"runs": [range_payload],
},
)
return exit_code
runs: list[dict[str, object]] = []
final_exit = EXIT_SUCCESS
for offset in range(day_count):
+282 -5
View File
@@ -115,6 +115,7 @@ _CONTENT_POSTGRES_WORKFLOW_IDS = frozenset(
{
"content.notes_master.daily",
"content.metrics.daily",
"content.metrics.self_operated.daily",
"content.metrics.backfill",
"content.marketing_report.daily",
"content.creator_report.monthly",
@@ -167,7 +168,8 @@ _MODULES = (
_WORKFLOW_NAMES = {
"content.notes_master.daily": "笔记清单与合作同步",
"content.metrics.daily": "内容指标日报采集",
"content.metrics.daily": "合作达人内容指标日报采集",
"content.metrics.self_operated.daily": "自营内容指标日报采集",
"content.marketing_report.daily": "营销日报生成与发送",
"content.relogin.weekly": "内容平台登录态刷新",
"content.creator_report.monthly": "达人月报生成",
@@ -193,6 +195,14 @@ _WORKFLOW_NAMES = {
"shop.metrics.weekly": "店铺经营指标周采集",
"shop.competitor.weekly": "竞店指标周采集",
"shop.jd_self_operated.daily": "京东自营业绩日采集",
"shop.jd_customer_service.daily": "京东客服消息采集",
"shop.jd_negative_reviews.daily": "京东差评采集",
"shop.dy_negative_reviews.daily": "抖音差评采集",
"shop.jd_after_sales_issues.daily": "京东品退问题采集",
"shop.douyin_customer_service.daily": "抖音客服消息采集",
"shop.tmall_customer_service.daily": "天猫客服消息采集",
"shop.tmall_negative_reviews.daily": "天猫差评采集",
"shop.douyin_after_sales_quality.daily": "抖音品退采集",
"shop.douyin_price_appeal": "抖音待改价申诉",
"shop.jd_self_operated.history": "京东自营业绩单日回采",
"supply.purchase_confirmation.daily": "采购确认日处理",
@@ -201,6 +211,69 @@ _WORKFLOW_NAMES = {
"supply.purchase_order_update": "采购单更新",
}
JD_CUSTOMER_SERVICE_WORKFLOW_ID = "shop.jd_customer_service.daily"
JD_NEGATIVE_REVIEWS_WORKFLOW_ID = "shop.jd_negative_reviews.daily"
JD_AFTER_SALES_ISSUES_WORKFLOW_ID = "shop.jd_after_sales_issues.daily"
DOUYIN_CUSTOMER_SERVICE_WORKFLOW_ID = "shop.douyin_customer_service.daily"
TMALL_CUSTOMER_SERVICE_WORKFLOW_ID = "shop.tmall_customer_service.daily"
TMALL_NEGATIVE_REVIEWS_WORKFLOW_ID = "shop.tmall_negative_reviews.daily"
DOUYIN_NEGATIVE_REVIEWS_WORKFLOW_ID = "shop.dy_negative_reviews.daily"
DOUYIN_AFTER_SALES_QUALITY_WORKFLOW_ID = "shop.douyin_after_sales_quality.daily"
JD_DATE_RANGE_WORKFLOW_IDS = frozenset(
{
JD_CUSTOMER_SERVICE_WORKFLOW_ID,
JD_NEGATIVE_REVIEWS_WORKFLOW_ID,
JD_AFTER_SALES_ISSUES_WORKFLOW_ID,
}
)
# 支持控制台自定义开始/结束日期的工作流;京东系在此之上额外提供店铺选择。
DATE_RANGE_WORKFLOW_IDS = JD_DATE_RANGE_WORKFLOW_IDS | {
DOUYIN_CUSTOMER_SERVICE_WORKFLOW_ID,
TMALL_CUSTOMER_SERVICE_WORKFLOW_ID,
TMALL_NEGATIVE_REVIEWS_WORKFLOW_ID,
DOUYIN_NEGATIVE_REVIEWS_WORKFLOW_ID,
DOUYIN_AFTER_SALES_QUALITY_WORKFLOW_ID,
}
JD_CUSTOMER_SERVICE_STORE_OPTIONS = (
{
"value": "flagship",
"label": "光影行星旗舰店",
"account_id": "jd-shop",
},
{
"value": "self-operated",
"label": "京东自营店(光影自营研发)",
"account_id": "jd-self-operated",
},
)
JD_CUSTOMER_SERVICE_STORE_VALUES = frozenset(
item["value"] for item in JD_CUSTOMER_SERVICE_STORE_OPTIONS
)
JD_AFTER_SALES_STORE_OPTIONS = (
{
"value": "flagship",
"label": "光影行星旗舰店(研发账号)",
"account_id": "jd-shop",
},
)
def _jd_store_options(workflow_id: str) -> tuple[dict[str, str], ...]:
if workflow_id == JD_AFTER_SALES_ISSUES_WORKFLOW_ID:
return JD_AFTER_SALES_STORE_OPTIONS
return JD_CUSTOMER_SERVICE_STORE_OPTIONS
def _tmall_customer_service_date_range_constraints(
today: date,
) -> dict[str, str]:
month_index = today.year * 12 + today.month - 1 - 3
year, zero_based_month = divmod(month_index, 12)
month = zero_based_month + 1
oldest_day = min(today.day, calendar.monthrange(year, month)[1])
oldest = date(year, month, oldest_day)
return {"min": oldest.isoformat(), "max": today.isoformat()}
_SECRET_VALUE = re.compile(
r"(?i)([\"']?(?:api[_-]?key|access[_-]?token|token|password|secret)"
r"[\"']?\s*[=:]\s*)"
@@ -250,6 +323,8 @@ class RunLauncher(Protocol):
tmall_topic_keyword: str | None = None,
force_refresh: bool = False,
tmall_baibu_import_mode: str | None = None,
jd_customer_service_store: str | None = None,
business_date_end: date | None = None,
) -> dict[str, object]: ...
def active(self) -> tuple[dict[str, object], ...]: ...
@@ -318,6 +393,7 @@ class _ActiveConsoleRun:
operation_id: str
workflow_id: str
business_date: str
business_date_end: str | None
mode: str
shadow: bool
started_at: str
@@ -375,6 +451,8 @@ class SubprocessConsoleRunLauncher:
"status": "completed",
"exit_code": exit_code,
}
if active.business_date_end is not None:
result["business_date_end"] = active.business_date_end
self._remember_finished_locked(result)
return result
@@ -399,6 +477,7 @@ class SubprocessConsoleRunLauncher:
"started_at": item.started_at,
"status": "running",
}
| ({"business_date_end": item.business_date_end} if item.business_date_end else {})
for item in self._runs.values()
)
@@ -413,6 +492,8 @@ class SubprocessConsoleRunLauncher:
tmall_topic_keyword: str | None = None,
force_refresh: bool = False,
tmall_baibu_import_mode: str | None = None,
jd_customer_service_store: str | None = None,
business_date_end: date | None = None,
) -> dict[str, object]:
with self._lock:
self._reap_locked()
@@ -423,10 +504,43 @@ class SubprocessConsoleRunLauncher:
operation_id = f"op-{uuid.uuid4().hex[:16]}"
started_at = datetime.now(timezone.utc).isoformat(timespec="microseconds")
known_run_ids = self._journal_run_ids(workflow_id, business_date)
range_end = business_date_end or business_date
if range_end < business_date:
raise ConsoleRequestError("结束日期不能早于开始日期")
known_run_ids = self._journal_run_ids_range(
workflow_id, business_date, range_end
)
self.log_root.mkdir(parents=True, exist_ok=True)
log_path = self.log_root / f"{workflow_id}-{operation_id}.log"
stream = log_path.open("a", encoding="utf-8", newline="\n")
if range_end != business_date and workflow_id in {
DOUYIN_NEGATIVE_REVIEWS_WORKFLOW_ID,
TMALL_CUSTOMER_SERVICE_WORKFLOW_ID,
TMALL_NEGATIVE_REVIEWS_WORKFLOW_ID,
}:
# 这些后台都支持按日期区间直接查询,无需逐日启动。
argv = [
str(self.python_executable),
"-m",
"gyxx_flow",
"run",
workflow_id,
"--date",
business_date.isoformat(),
]
elif range_end != business_date:
argv = [
str(self.python_executable),
"-m",
"gyxx_flow",
"backfill",
workflow_id,
"--from",
business_date.isoformat(),
"--to",
range_end.isoformat(),
]
else:
argv = [
str(self.python_executable),
"-m",
@@ -455,10 +569,34 @@ class SubprocessConsoleRunLauncher:
},
workflow_id=workflow_id,
)
if (
workflow_id == DOUYIN_NEGATIVE_REVIEWS_WORKFLOW_ID
and range_end != business_date
):
environment["GYXX_NEG_REVIEW_FROM"] = business_date.isoformat()
environment["GYXX_NEG_REVIEW_TO"] = range_end.isoformat()
if workflow_id == TMALL_CUSTOMER_SERVICE_WORKFLOW_ID:
environment["GYXX_TMALL_CUSTOMER_SERVICE_START_DATE"] = (
business_date.isoformat()
)
environment["GYXX_TMALL_CUSTOMER_SERVICE_END_DATE"] = (
range_end.isoformat()
)
if workflow_id == TMALL_NEGATIVE_REVIEWS_WORKFLOW_ID:
environment["GYXX_TMALL_NEGATIVE_REVIEWS_START_DATE"] = (
business_date.isoformat()
)
environment["GYXX_TMALL_NEGATIVE_REVIEWS_END_DATE"] = (
range_end.isoformat()
)
if video_upload_store is not None:
environment["GYXX_VIDEO_UPLOAD_STORE"] = video_upload_store
if tmall_topic_keyword is not None:
environment["GYXX_TMALL_TOPIC_KEYWORD"] = tmall_topic_keyword
if jd_customer_service_store is not None:
environment["GYXX_JD_CUSTOMER_SERVICE_STORE"] = (
jd_customer_service_store
)
if force_refresh:
environment["GYXX_FORCE_REFRESH"] = "true"
else:
@@ -496,6 +634,9 @@ class SubprocessConsoleRunLauncher:
operation_id=operation_id,
workflow_id=workflow_id,
business_date=business_date.isoformat(),
business_date_end=(
range_end.isoformat() if range_end != business_date else None
),
mode="execute" if execute else "dry_run",
shadow=shadow,
started_at=started_at,
@@ -515,6 +656,8 @@ class SubprocessConsoleRunLauncher:
"started_at": started_at,
"status": "accepted",
}
if active.business_date_end is not None:
result["business_date_end"] = active.business_date_end
if video_upload_store is not None:
result["store"] = video_upload_store
if tmall_topic_keyword is not None:
@@ -523,6 +666,8 @@ class SubprocessConsoleRunLauncher:
result["force_refresh"] = True
if tmall_baibu_import_mode is not None:
result["tmall_baibu_import_mode"] = tmall_baibu_import_mode
if jd_customer_service_store is not None:
result["store"] = jd_customer_service_store
return result
def cancel(
@@ -572,6 +717,8 @@ class SubprocessConsoleRunLauncher:
"already_cancelled": False,
"journal_status": journal_status,
}
if active.business_date_end is not None:
result["business_date_end"] = active.business_date_end
if run_id is not None:
result["run_id"] = run_id
self._write_cancellation_record(result)
@@ -616,6 +763,8 @@ class SubprocessConsoleRunLauncher:
"log_path": log_path.relative_to(self.settings.data_root).as_posix(),
"known_run_ids": sorted(active.known_run_ids),
}
if active.business_date_end is not None:
payload["business_date_end"] = active.business_date_end
try:
atomic_write_json(self._active_path(active.operation_id), payload)
except OSError:
@@ -645,6 +794,7 @@ class SubprocessConsoleRunLauncher:
operation_id = payload["operation_id"]
workflow_id = payload["workflow_id"]
business_date = payload["business_date"]
business_date_end = payload.get("business_date_end")
mode = payload["mode"]
shadow = payload["shadow"]
started_at = payload["started_at"]
@@ -663,6 +813,10 @@ class SubprocessConsoleRunLauncher:
parsed_date = date.fromisoformat(business_date)
if parsed_date.isoformat() != business_date:
return None
if business_date_end is not None:
parsed_end = date.fromisoformat(business_date_end)
if parsed_end.isoformat() != business_date_end or parsed_end < parsed_date:
return None
if mode not in {"execute", "dry_run"} or not isinstance(shadow, bool):
return None
if not isinstance(started_at, str):
@@ -695,6 +849,7 @@ class SubprocessConsoleRunLauncher:
operation_id=operation_id,
workflow_id=workflow_id,
business_date=business_date,
business_date_end=business_date_end,
mode=mode,
shadow=shadow,
started_at=started_at,
@@ -734,6 +889,19 @@ class SubprocessConsoleRunLauncher:
root = self._journal_run_root(workflow_id, business_date.isoformat())
return frozenset(path.parent.name for path in root.glob("*/run.json"))
def _journal_run_ids_range(
self,
workflow_id: str,
start_date: date,
end_date: date,
) -> frozenset[str]:
run_ids: set[str] = set()
current = start_date
while current <= end_date:
run_ids.update(self._journal_run_ids(workflow_id, current))
current += timedelta(days=1)
return frozenset(run_ids)
def _locked_run_id(self, active: _ActiveConsoleRun) -> str | None:
lock_root = self.settings.data_root / "state" / "locks"
for path in lock_root.glob("*.lock"):
@@ -762,8 +930,12 @@ class SubprocessConsoleRunLauncher:
return None
def _candidate_journals(self, active: _ActiveConsoleRun) -> list[Path]:
root = self._journal_run_root(active.workflow_id, active.business_date)
candidates: list[Path] = []
start_date = date.fromisoformat(active.business_date)
end_date = date.fromisoformat(active.business_date_end or active.business_date)
current = start_date
while current <= end_date:
root = self._journal_run_root(active.workflow_id, current.isoformat())
for path in root.glob("*/run.json"):
if path.parent.name in active.known_run_ids:
continue
@@ -775,11 +947,12 @@ class SubprocessConsoleRunLauncher:
continue
if (
payload.get("workflow_id") == active.workflow_id
and payload.get("business_date") == active.business_date
and payload.get("business_date") == current.isoformat()
and payload.get("shadow") is active.shadow
and payload.get("status") == "running"
):
candidates.append(path)
current += timedelta(days=1)
return candidates
def _finalize_cancelled_journal(
@@ -790,13 +963,19 @@ class SubprocessConsoleRunLauncher:
) -> tuple[str, str | None]:
path: Path | None = None
if run_id is not None:
start_date = date.fromisoformat(active.business_date)
end_date = date.fromisoformat(active.business_date_end or active.business_date)
current = start_date
while current <= end_date:
candidate = (
self._journal_run_root(active.workflow_id, active.business_date)
self._journal_run_root(active.workflow_id, current.isoformat())
/ run_id
/ "run.json"
)
if candidate.exists():
path = candidate
break
current += timedelta(days=1)
if path is None:
candidates = self._candidate_journals(active)
if len(candidates) == 1:
@@ -1389,6 +1568,36 @@ class WorkflowConsoleService:
if entry.workflow_id == "product.sales_sheet.daily"
else "date"
),
"date_range_mode": (
"date_range"
if entry.workflow_id in DATE_RANGE_WORKFLOW_IDS
else "single"
),
**(
{
"date_range_constraints": (
_tmall_customer_service_date_range_constraints(
now.date()
)
),
"date_range_hint": (
"仅支持最近三个月;系统按每次最多 31 天拆分查询,"
"聊天记录可能延迟一天。"
),
}
if entry.workflow_id == TMALL_CUSTOMER_SERVICE_WORKFLOW_ID
else {}
),
**(
{
"date_range_hint": (
"请指定评价时间范围;页面不选时间会查询所有日期,"
"工作流会固定选择负面评价后再搜索。"
)
}
if entry.workflow_id == TMALL_NEGATIVE_REVIEWS_WORKFLOW_ID
else {}
),
"registered": entry.workflow_id in registered_ids,
"note": entry.note,
"source_task_name": entry.source_task_name,
@@ -1405,6 +1614,23 @@ class WorkflowConsoleService:
if entry.topic_config is not None
else {}
),
**(
{
"store_config": {
"label": "京东店铺/账号",
"hint": (
"选择账号对应的店铺;支持直接提交自定义日期范围,"
"相同店铺、业务日和数据身份重复执行会幂等更新。"
),
"options": [
dict(item)
for item in _jd_store_options(entry.workflow_id)
],
}
}
if entry.workflow_id in JD_DATE_RANGE_WORKFLOW_IDS
else {}
),
"schedule": _schedule_payload(schedule),
"next_run_at": _next_run_at(schedule, now),
"last_run": _run_payload(
@@ -1959,6 +2185,10 @@ class WorkflowConsoleService:
if workflow_id == "product.video_upload":
allowed.add("store")
allowed.add("tmall_topic_keyword")
if workflow_id in JD_DATE_RANGE_WORKFLOW_IDS:
allowed.add("store")
if workflow_id in DATE_RANGE_WORKFLOW_IDS:
allowed.add("business_date_end")
if workflow_id == TMALL_BAIBU_WORKFLOW_ID:
allowed.add("tmall_baibu_import_mode")
unknown = set(payload) - allowed
@@ -1971,6 +2201,35 @@ class WorkflowConsoleService:
raise ConsoleRequestError("业务日期必须使用 YYYY-MM-DD 格式") from exc
if business_date.isoformat() != raw_date:
raise ConsoleRequestError("业务日期必须使用 YYYY-MM-DD 格式")
business_date_end: date | None = None
if workflow_id in DATE_RANGE_WORKFLOW_IDS:
raw_end = payload.get("business_date_end", raw_date)
try:
business_date_end = date.fromisoformat(raw_end)
except (TypeError, ValueError) as exc:
raise ConsoleRequestError("结束日期必须使用 YYYY-MM-DD 格式") from exc
if business_date_end.isoformat() != raw_end:
raise ConsoleRequestError("结束日期必须使用 YYYY-MM-DD 格式")
if business_date_end < business_date:
raise ConsoleRequestError("结束日期不能早于开始日期")
if workflow_id == TMALL_CUSTOMER_SERVICE_WORKFLOW_ID:
constraints = _tmall_customer_service_date_range_constraints(
datetime.now(ZoneInfo("Asia/Shanghai")).date()
)
oldest = date.fromisoformat(constraints["min"])
latest = date.fromisoformat(constraints["max"])
if business_date < oldest:
raise ConsoleRequestError(
f"天猫客服最早仅支持 {oldest.isoformat()} 起的数据"
)
if business_date_end > latest:
raise ConsoleRequestError(
f"天猫客服结束日期不能晚于 {latest.isoformat()}"
)
if workflow_id == TMALL_NEGATIVE_REVIEWS_WORKFLOW_ID:
latest = datetime.now(ZoneInfo("Asia/Shanghai")).date()
if business_date_end is not None and business_date_end > latest:
raise ConsoleRequestError("天猫差评结束日期不能晚于今天")
execute = payload.get("execute", False)
shadow = payload.get("shadow", False)
if not isinstance(execute, bool) or not isinstance(shadow, bool):
@@ -1984,6 +2243,7 @@ class WorkflowConsoleService:
raise ConsoleRequestError("正式执行前必须确认外部影响")
video_upload_store: str | None = None
tmall_topic_keyword: str | None = None
jd_customer_service_store: str | None = None
if workflow_id == "product.video_upload":
raw_store = payload.get("store", "flagship")
if raw_store not in {"flagship", "luggage"}:
@@ -1993,6 +2253,19 @@ class WorkflowConsoleService:
payload.get("tmall_topic_keyword"),
entry,
)
if workflow_id in JD_DATE_RANGE_WORKFLOW_IDS:
raw_store = payload.get("store", "flagship")
allowed_store_values = {
item["value"] for item in _jd_store_options(workflow_id)
}
if raw_store not in allowed_store_values:
message = (
"京东品退店铺参数无效"
if workflow_id == JD_AFTER_SALES_ISSUES_WORKFLOW_ID
else "京东客服店铺参数无效"
)
raise ConsoleRequestError(message)
jd_customer_service_store = str(raw_store)
tmall_baibu_import_mode: str | None = None
if workflow_id == TMALL_BAIBU_WORKFLOW_ID:
raw_import_mode = payload.get(
@@ -2030,6 +2303,10 @@ class WorkflowConsoleService:
}
if tmall_baibu_import_mode is not None:
launch_kwargs["tmall_baibu_import_mode"] = tmall_baibu_import_mode
if jd_customer_service_store is not None:
launch_kwargs["jd_customer_service_store"] = jd_customer_service_store
if business_date_end is not None and business_date_end != business_date:
launch_kwargs["business_date_end"] = business_date_end
return self.launcher.launch(
workflow_id,
business_date,
@@ -11,6 +11,7 @@ from gyxx_flow.workflow.factory import build_catalog_workflow
CONTENT_SCHEDULED_WORKFLOW_IDS = (
"content.metrics.daily",
"content.metrics.self_operated.daily",
"content.metrics.backfill",
"content.marketing_report.daily",
"content.relogin.weekly",
@@ -23,15 +24,17 @@ CONTENT_SCHEDULED_WORKFLOW_IDS = (
CONTENT_WORKFLOW_IDS = CONTENT_SCHEDULED_WORKFLOW_IDS
CONTENT_TIMEOUT_SECONDS = 4 * 60 * 60
CONTENT_COMMENT_TIMEOUT_SECONDS = 6 * 60 * 60
# Keep the 09:00 source sync queued behind long-running content jobs without
# weakening the module-wide write lock.
CONTENT_NOTES_MASTER_LOCK_TIMEOUT_SECONDS = 6 * 60 * 60
CONTENT_RESOURCE = "module:content_marketing"
_RELOGIN_WORKFLOW_ID = "content.relogin.weekly"
_PARALLEL_COMMENT_WORKFLOW_ID = "content.comments.weekly"
# The daily graph deliberately starts collaboration collection and the
# self-operated mapping/collectors as independent branches. A single module
# resource held by the long-running collaboration collector would otherwise
# make those sibling steps fail before their subprocesses even start.
# Keep both long-running daily metrics graphs on workflow-scoped resources so
# their collection steps do not block unrelated content workflows.
_WORKFLOW_SCOPED_RESOURCE_IDS = {
"content.metrics.daily",
"content.metrics.self_operated.daily",
}
@@ -84,6 +87,11 @@ class ContentMarketingModule:
if workflow_id == _PARALLEL_COMMENT_WORKFLOW_ID
else CONTENT_TIMEOUT_SECONDS
),
resource_lock_timeout_seconds=(
CONTENT_NOTES_MASTER_LOCK_TIMEOUT_SECONDS
if workflow_id == "content.notes_master.daily"
else 30.0
),
resource=workflow_resource,
command_factory=command_factory,
official_notification=workflow_id == _RELOGIN_WORKFLOW_ID,
@@ -91,6 +99,7 @@ class ContentMarketingModule:
workflow_id in {
_PARALLEL_COMMENT_WORKFLOW_ID,
"content.metrics.daily",
"content.metrics.self_operated.daily",
}
),
)
@@ -105,6 +114,7 @@ __all__ = [
"CONTENT_WORKFLOW_IDS",
"CONTENT_SCHEDULED_WORKFLOW_IDS",
"CONTENT_COMMENT_TIMEOUT_SECONDS",
"CONTENT_NOTES_MASTER_LOCK_TIMEOUT_SECONDS",
"CONTENT_RESOURCE",
"CONTENT_TIMEOUT_SECONDS",
"ContentMarketingModule",
@@ -0,0 +1,340 @@
# -*- coding: utf-8 -*-
"""蝉妈妈导出产物的纯解析层(无浏览器依赖)。
从 chanmama_scraper.py 抽出的 Excel/CSV 解析与账号映射逻辑:
- 采集脚本(chanmama_scraper.py)回填飞书曝光时只需要标题/曝光/发布时间;
- 入库脚本(data/tools/sync_self_douyin_videos.py)需要全量字段
(视频链接、达人昵称、抖音号、时长、点赞、评论、转发、预估销量等)。
真实导出列头(2026-09 实测):
视频名称 / 蝉妈妈视频链接 / 抖音视频链接 / 达人昵称 / 抖音号 /
视频时长 / 发布时间 / 预估曝光 / 点赞数 / 评论数 / 转发数 /
预估销量 / 预估销量-指数 / 预估销售额 / 预估销售额-指数
"""
from __future__ import annotations
import csv
import re
from datetime import datetime, timedelta
from pathlib import Path
# 每个逻辑字段的候选列名(按匹配优先级);_find_excel_col 先做整列精确匹配,
# 再做子串匹配,避免「预估销量」误命中「预估销量-指数」这类伴生列。
COLUMN_CANDIDATES = {
"title": ["视频名称", "视频标题", "标题", "作品标题", "作品名称", "视频名"],
"chanmama_url": ["蝉妈妈视频链接", "蝉妈妈链接"],
"video_url": ["抖音视频链接", "视频链接", "作品链接", "链接"],
"creator_name": ["达人昵称", "达人名称", "博主昵称", "达人"],
"douyin_account": ["抖音号", "账号", "达人ID"],
"duration": ["视频时长", "时长"],
"exposure": ["预估曝光", "预估曝光量", "预估播放量", "曝光量", "播放量", "预估播放", "曝光"],
"like_count": ["点赞数", "点赞量", "点赞"],
"comment_count": ["评论数", "评论量", "评论"],
"share_count": ["转发数", "转发量", "分享数", "分享量", "转发", "分享"],
"est_sales_count": ["预估销量"],
"est_sales_amount": ["预估销售额"],
"publish_time": ["发布时间", "发布日期", "发布"],
}
_TOTAL_ROW_TITLES = {"总计", "合计", "Total"}
_EXPORT_GROUP_MARKER = "_视频记录_"
_DOUYIN_VIDEO_ID_RE = re.compile(
r"(?:iesdouyin\.com|douyin\.com)/(?:share/)?(?:video|note)/(\d+)",
re.IGNORECASE,
)
_CHANMAMA_AWEME_ID_RE = re.compile(
r"chanmama\.com/awemeDetail/([A-Za-z0-9_-]+)", re.IGNORECASE
)
def find_new_excel_after(pre_mtime: float, download_dir: str) -> str | None:
"""找 download_dir 中 mtime > pre_mtime 的最新 .xlsx 文件。
pre_mtime=0 时退化为找目录中最新的 .xlsx。排除 ~$ 开头的 Excel 临时锁文件。
"""
if not Path(download_dir).exists():
return None
candidates: list[tuple[float, str]] = []
for name in Path(download_dir).iterdir():
if not name.name.lower().endswith(".xlsx"):
continue
if name.name.startswith("~$"):
continue
mtime = name.stat().st_mtime
if mtime > pre_mtime:
candidates.append((mtime, str(name)))
if not candidates:
return None
candidates.sort(key=lambda item: item[0], reverse=True)
return candidates[0][1]
def find_latest_chanmama_excel(download_dir: str) -> str | None:
"""在 download_dir 找最新的 .xlsx 文件"""
root = Path(download_dir)
if not root.exists():
return None
files = [p for p in root.iterdir()
if p.name.lower().endswith(".xlsx") and not p.name.startswith("~$")]
if not files:
return None
files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return str(files[0])
def find_all_chanmama_excels_grouped(download_dir: str) -> list[str]:
"""按博主名前缀分组,每组返回 mtime 最新的 .xlsx 路径。
蝉妈妈导出文件名格式: '<博主名>_视频记录_周期内发布视频_<日期范围>.xlsx'
分组键: 文件名第一个 '_视频记录_' 之前的部分(博主名)。
同一博主有多份历史导出时,只取最新一份。
返回: 每组最新文件的绝对路径列表(按 mtime 倒序,确保两个博主 Excel
匹配同一飞书记录时,最新导出胜出)。
"""
root = Path(download_dir)
if not root.exists():
return []
groups: dict[str, tuple[float, str]] = {} # 博主名 -> (mtime, 路径)
for path in root.iterdir():
if not path.name.lower().endswith(".xlsx"):
continue
if path.name.startswith("~$"):
continue
mtime = path.stat().st_mtime
# 分组键: _视频记录_ 之前的部分;若无该标记则用整个文件名(去扩展名)
name = path.name[:-5] # 去 .xlsx
idx = name.find(_EXPORT_GROUP_MARKER)
key = name[:idx] if idx > 0 else name
current = groups.get(key)
if current is None or mtime > current[0]:
groups[key] = (mtime, str(path))
sorted_groups = sorted(groups.values(), key=lambda item: item[0], reverse=True)
return [path for _mtime, path in sorted_groups]
def _find_excel_col(headers, candidates):
"""在表头里找候选词列;先精确匹配整列名,再退回子串匹配。返回 0-based 列索引。"""
normalized = [str(header or "").strip() for header in headers]
for candidate in candidates:
for index, header in enumerate(normalized):
if header == candidate:
return index
for candidate in candidates:
for index, header in enumerate(normalized):
if candidate in header:
return index
return None
def parse_exposure(value):
"""解析曝光/计数类值,支持 '1.2万' / '6.5w+' / '1.1w' / '6,592' / 数字 / '0'"""
if value is None:
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return int(value)
text = str(value).strip()
if not text or text in {"-", "--"}:
return None
# 去掉后缀的 + 号(如 "6.5w+" → "6.5w")
text = text.rstrip("+")
match = re.match(r"^([\d,.]+)\s*(万|w|W)?", text)
if not match:
return None
number = float(match.group(1).replace(",", ""))
if match.group(2):
number *= 10000
return int(number)
def parse_decimal_amount(value):
"""解析金额类值(预估销售额),返回 float 或 None;支持 '1.2万' / '3580.5'。"""
if value is None or isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return round(float(value), 2)
text = str(value).strip().rstrip("+")
if not text or text in {"-", "--"}:
return None
match = re.match(r"^([\d,.]+)\s*(万|w|W)?", text)
if not match:
return None
number = float(match.group(1).replace(",", ""))
if match.group(2):
number *= 10000
return round(number, 2)
def parse_publish_time(value):
"""发布时间 → datetime;兼容 datetime / Excel serial / 毫秒时间戳 / 常见字符串。"""
if value is None or value == "":
return None
if isinstance(value, datetime):
return value
if isinstance(value, (int, float)):
# Excel 日期可能是 serial number(从 1899-12-30 起算)
try:
if 25569 <= value <= 60000:
return datetime(1899, 12, 30) + timedelta(days=value)
except Exception:
pass
try:
return datetime.fromtimestamp(value / 1000)
except (ValueError, OSError):
return None
if isinstance(value, str):
text = value.strip()
if not text:
return None
if text.isdigit():
try:
return datetime.fromtimestamp(int(text) / 1000)
except (ValueError, OSError):
pass
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M", "%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d"):
try:
return datetime.strptime(text[:len(fmt) + 2], fmt)
except ValueError:
continue
return None
def extract_video_id(*urls) -> str | None:
"""从抖音/蝉妈妈链接中提取稳定视频 ID。
优先抖音数字 ID(/video/<id> 或 /note/<id>,平台内全局唯一且跨账号不重复),
取不到时退回蝉妈妈 awemeDetail 短 ID。短链接解析不了则返回 None。
"""
for url in urls:
if not url:
continue
text = str(url)
match = _DOUYIN_VIDEO_ID_RE.search(text)
if match:
return match.group(1)
for url in urls:
if not url:
continue
match = _CHANMAMA_AWEME_ID_RE.search(str(url))
if match:
return match.group(1)
return None
def _cell_text(value) -> str:
if value is None:
return ""
if isinstance(value, list):
return " ".join(str(item) for item in value if item)
return str(value).strip()
def _build_record(row, columns: dict[str, int]) -> dict | None:
"""按已解析的列索引从一行数据构造记录;标题缺失或总计行返回 None。"""
def cell(key: str):
index = columns.get(key)
if index is None or index >= len(row):
return None
return row[index]
title = _cell_text(cell("title"))
if not title or title in _TOTAL_ROW_TITLES:
return None
publish_raw = cell("publish_time")
duration = _cell_text(cell("duration"))
return {
"title": title,
"chanmama_url": _cell_text(cell("chanmama_url")),
"video_url": _cell_text(cell("video_url")),
"creator_name": _cell_text(cell("creator_name")),
"douyin_account": _cell_text(cell("douyin_account")),
"duration": "" if duration in {"-", "--"} else duration,
"publish_time": publish_raw,
"exposure": parse_exposure(cell("exposure")),
"like_count": parse_exposure(cell("like_count")),
"comment_count": parse_exposure(cell("comment_count")),
"share_count": parse_exposure(cell("share_count")),
"est_sales_count": parse_exposure(cell("est_sales_count")),
"est_sales_amount": parse_decimal_amount(cell("est_sales_amount")),
}
def _resolve_columns(headers):
"""列名 → 逻辑字段列索引;标题列与曝光列缺失时返回 None 表示无法解析。"""
columns = {
field: _find_excel_col(headers, candidates)
for field, candidates in COLUMN_CANDIDATES.items()
}
if columns.get("title") is None or columns.get("exposure") is None:
return None
return columns
def parse_chanmama_excel(xlsx_path):
"""解析蝉妈妈导出的 Excel,返回 (records, headers)。
records 携带全部逻辑字段(见 COLUMN_CANDIDATES);缺失列的字段值为空/None。
headers: 表头列表(用于调试)。
"""
import openpyxl
# read_only=True 对蝉妈妈导出的 xlsx 有解析 bug(只读出 1 行 1 列),
# 改用普通模式
workbook = openpyxl.load_workbook(xlsx_path, data_only=True)
sheet = workbook.active
headers: list[str] = []
for row in sheet.iter_rows(min_row=1, max_row=1, values_only=True):
headers = [str(cell or "").strip() for cell in row]
break
print(f"📋 Excel 表头: {headers}")
columns = _resolve_columns(headers)
if columns is None:
print("❌ 找不到标题列或曝光列,请检查表头")
workbook.close()
return [], headers
records = []
for row in sheet.iter_rows(min_row=2, values_only=True):
record = _build_record(row, columns)
if record is not None:
records.append(record)
workbook.close()
return records, headers
def parse_chanmama_csv(csv_path):
"""Parse the virtual-list CSV fallback using the same metric contract.
兜底 CSV 只有 视频标题/发布时间/预估曝光/链接 四列,其余字段留空。
"""
with open(csv_path, "r", newline="", encoding="utf-8-sig") as handle:
rows = list(csv.reader(handle))
if not rows:
return [], []
headers = [str(value or "").strip() for value in rows[0]]
columns = _resolve_columns(headers)
if columns is None:
print(f"❌ CSV 找不到标题列或曝光列: {headers}")
return [], headers
records = []
for row in rows[1:]:
record = _build_record(row, columns)
if record is not None:
records.append(record)
return records, headers
def parse_chanmama_export(path):
"""Parse either the official Excel export or the virtual-list CSV fallback."""
if str(path).lower().endswith(".csv"):
return parse_chanmama_csv(path)
return parse_chanmama_excel(path)
@@ -17,6 +17,7 @@ from datetime import datetime
from pathlib import Path
from gyxx_flow.core.exit_codes import COOKIE_SKIP_EXIT_CODE
from gyxx_flow.modules.content_marketing import chanmama_export
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
from gyxx_flow.modules.content_marketing.scrapling_browser_facade import (
EC,
@@ -1080,7 +1081,11 @@ def export_video_data(driver, pre_mtime: float = 0):
f"{CHANMAMA_EXPORT_WAIT_SECONDS} 秒,边界宽限 "
f"{CHANMAMA_EXPORT_GRACE_SECONDS} 秒)..."
)
new_path = _wait_for_exported_excel(pre_mtime)
# Playwright 同步 API 只在调用 API 时泵事件循环;纯 time.sleep 轮询
# 会让 page.on("download") 事件一直排队,download.save_as 不执行,
# 文件要到下一次 Playwright 调用(甚至 quit)才落盘,导致误判超时。
# 因此等待时必须携带 driver 周期性泵事件。
new_path = _wait_for_exported_excel(pre_mtime, driver=driver)
if new_path:
print(f"📦 本次下载: {new_path}")
return new_path
@@ -1094,6 +1099,7 @@ def export_video_data(driver, pre_mtime: float = 0):
def _wait_for_exported_excel(
pre_mtime: float,
*,
driver=None,
timeout_seconds: int = CHANMAMA_EXPORT_WAIT_SECONDS,
grace_seconds: int = CHANMAMA_EXPORT_GRACE_SECONDS,
poll_interval_seconds: float = 5.0,
@@ -1106,11 +1112,18 @@ def _wait_for_exported_excel(
临时文件为 .crdownload,不会被 _find_new_excel_after 匹配);
timeout_seconds 到达后仍保留 grace_seconds 宽限窗口,以覆盖文件刚好在
deadline 之后落盘的竞态;两个窗口内都未出现则返回 None。
传入 driver 时每轮执行一次无害的 evaluate 以泵 Playwright 事件循环,
确保 download 事件处理器(_save_download)在 sleep 轮询期间也能执行。
"""
deadline = time.time() + max(timeout_seconds, 0)
grace_deadline = deadline + max(grace_seconds, 0)
grace_logged = False
while True:
if driver is not None:
try:
driver.execute_script("1")
except Exception:
pass
new_path = _find_new_excel_after(pre_mtime)
if new_path is not None:
return new_path
@@ -1317,6 +1330,8 @@ def scrape_table_data(driver):
# ============================================================
# Excel 解析 + 飞书自营表回填
# 解析实现迁至 chanmama_export.py(无浏览器依赖,供入库脚本复用);
# 此处保留模块级名字,采集流程与测试的 monkeypatch 目标不变。
# ============================================================
LARK_CLI = os.path.expandvars(r"%APPDATA%\npm\lark-cli.cmd")
if not os.path.exists(LARK_CLI):
@@ -1324,198 +1339,29 @@ if not os.path.exists(LARK_CLI):
def _find_new_excel_after(pre_mtime: float) -> str | None:
"""找 DOWNLOAD_DIR 中 mtime > pre_mtime 的最新 .xlsx 文件。
pre_mtime=0 时退化为找目录中最新的 .xlsx(等价于 find_latest_chanmama_excel)。
排除 ~$ 开头的 Excel 临时锁文件。
"""
if not os.path.exists(DOWNLOAD_DIR):
return None
candidates = []
for f in os.listdir(DOWNLOAD_DIR):
if not f.lower().endswith('.xlsx'):
continue
if f.startswith('~$'):
continue
full = os.path.join(DOWNLOAD_DIR, f)
mt = os.path.getmtime(full)
if mt > pre_mtime:
candidates.append((mt, full))
if not candidates:
return None
candidates.sort(key=lambda x: x[0], reverse=True)
return candidates[0][1]
"""找 DOWNLOAD_DIR 中 mtime > pre_mtime 的最新 .xlsx 文件。"""
return chanmama_export.find_new_excel_after(pre_mtime, DOWNLOAD_DIR)
def find_latest_chanmama_excel():
"""在 DOWNLOAD_DIR 找最新的 .xlsx 文件"""
if not os.path.exists(DOWNLOAD_DIR):
return None
files = [f for f in os.listdir(DOWNLOAD_DIR)
if f.lower().endswith('.xlsx') and not f.startswith('~$')]
if not files:
return None
files.sort(key=lambda f: os.path.getmtime(os.path.join(DOWNLOAD_DIR, f)),
reverse=True)
return os.path.join(DOWNLOAD_DIR, files[0])
return chanmama_export.find_latest_chanmama_excel(DOWNLOAD_DIR)
def find_all_chanmama_excels_grouped() -> list[str]:
"""按博主名前缀分组,每组返回 mtime 最新的 .xlsx 路径。
蝉妈妈导出文件名格式: '<博主名>_视频记录_周期内发布视频_<日期范围>.xlsx'
分组键: 文件名第一个 '_视频记录_' 之前的部分(博主名)。
同一博主有多份历史导出时,只取最新一份。
返回: 每组最新文件的绝对路径列表(无序)。目录不存在或无文件时返回空列表。
"""
if not os.path.exists(DOWNLOAD_DIR):
return []
groups: dict[str, tuple[float, str]] = {} # 博主名 -> (mtime, 路径)
for f in os.listdir(DOWNLOAD_DIR):
if not f.lower().endswith('.xlsx'):
continue
if f.startswith('~$'):
continue
full = os.path.join(DOWNLOAD_DIR, f)
mt = os.path.getmtime(full)
# 分组键: _视频记录_ 之前的部分;若无该标记则用整个文件名(去扩展名)
name = f[:-5] # 去 .xlsx
marker = '_视频记录_'
idx = name.find(marker)
key = name[:idx] if idx > 0 else name
cur = groups.get(key)
if cur is None or mt > cur[0]:
groups[key] = (mt, full)
# 按 mtime 倒序,确保两个博主 Excel 匹配同一飞书记录时,最新导出胜出(spec: first extended wins)
sorted_groups = sorted(groups.values(), key=lambda x: x[0], reverse=True)
return [path for _mt, path in sorted_groups]
"""按博主名前缀分组,每组返回 mtime 最新的 .xlsx 路径(实现见 chanmama_export)。"""
return chanmama_export.find_all_chanmama_excels_grouped(DOWNLOAD_DIR)
def _find_excel_col(headers, candidates):
"""在表头里找第一个包含候选词的列,返回列索引(0-based)"""
for i, h in enumerate(headers):
h_str = str(h or '').strip()
for c in candidates:
if c in h_str:
return i
return None
def _parse_exposure(v):
"""解析曝光值,支持 '1.2万' / '6.5w+' / '1.1w' / '6,592' / 数字 / '0'"""
if v is None:
return None
if isinstance(v, (int, float)):
return int(v)
txt = str(v).strip()
if not txt or txt == '-':
return None
# 去掉后缀的 + 号(如 "6.5w+" → "6.5w")
txt = txt.rstrip('+')
m = re.match(r'^([\d,.]+)\s*(万|w|W)?', txt)
if not m:
return None
num = float(m.group(1).replace(',', ''))
if m.group(2):
num *= 10000
return int(num)
def parse_chanmama_excel(xlsx_path):
"""解析蝉妈妈导出的 Excel,返回 (records, headers)。
records: [{title, exposure, publish_time}, ...]
headers: 表头列表(用于调试)
"""
import openpyxl
# read_only=True 对蝉妈妈导出的 xlsx 有解析 bug(只读出 1 行 1 列),
# 改用普通模式
wb = openpyxl.load_workbook(xlsx_path, data_only=True)
ws = wb.active
headers = []
for row in ws.iter_rows(min_row=1, max_row=1, values_only=True):
headers = [str(c or '').strip() for c in row]
break
print(f"📋 Excel 表头: {headers}")
# 蝉妈妈导出常见列名变体(按真实导出确认: 视频名称/发布时间/预估曝光)
title_candidates = ['视频名称', '视频标题', '标题', '作品标题',
'作品名称', '视频名']
exposure_candidates = ['预估曝光', '预估曝光量', '预估播放量',
'曝光量', '播放量', '预估播放', '曝光']
pubtime_candidates = ['发布时间', '发布日期', '发布']
title_col = _find_excel_col(headers, title_candidates)
exposure_col = _find_excel_col(headers, exposure_candidates)
pubtime_col = _find_excel_col(headers, pubtime_candidates)
print(f" 标题列: #{title_col} {headers[title_col] if title_col is not None else '?'}")
print(f" 曝光列: #{exposure_col} {headers[exposure_col] if exposure_col is not None else '?'}")
print(f" 发布时间列: #{pubtime_col} {headers[pubtime_col] if pubtime_col is not None else '?'}")
if title_col is None or exposure_col is None:
print("❌ 找不到标题列或曝光列,请检查表头")
return [], headers
records = []
for row in ws.iter_rows(min_row=2, values_only=True):
title = row[title_col] if title_col < len(row) else None
exposure = row[exposure_col] if exposure_col < len(row) else None
pubtime = (row[pubtime_col]
if pubtime_col is not None and pubtime_col < len(row) else None)
if not title:
continue
# 跳过蝉妈妈的"总计"行
if str(title).strip() in ('总计', '合计', 'Total'):
continue
records.append({
'title': str(title).strip(),
'exposure': _parse_exposure(exposure),
'publish_time': pubtime,
})
wb.close()
return records, headers
def parse_chanmama_csv(csv_path):
"""Parse the virtual-list CSV fallback using the same metric contract."""
with open(csv_path, "r", newline="", encoding="utf-8-sig") as handle:
rows = list(csv.reader(handle))
if not rows:
return [], []
headers = [str(value or "").strip() for value in rows[0]]
title_candidates = ["视频名称", "视频标题", "标题", "作品标题", "作品名称", "视频名"]
exposure_candidates = ["预估曝光", "预估曝光量", "预估播放量", "曝光量", "播放量", "预估播放", "曝光"]
pubtime_candidates = ["发布时间", "发布日期", "发布"]
title_col = _find_excel_col(headers, title_candidates)
exposure_col = _find_excel_col(headers, exposure_candidates)
pubtime_col = _find_excel_col(headers, pubtime_candidates)
if title_col is None or exposure_col is None:
print(f"❌ CSV 找不到标题列或曝光列: {headers}")
return [], headers
records = []
for row in rows[1:]:
title = row[title_col] if title_col < len(row) else ""
exposure = row[exposure_col] if exposure_col < len(row) else ""
publish_time = (
row[pubtime_col]
if pubtime_col is not None and pubtime_col < len(row)
else None
)
if not title or str(title).strip() in {"总计", "合计", "Total"}:
continue
records.append({
"title": str(title).strip(),
"exposure": _parse_exposure(exposure),
"publish_time": publish_time,
})
return records, headers
_find_excel_col = chanmama_export._find_excel_col
_parse_exposure = chanmama_export.parse_exposure
_parse_publish_time = chanmama_export.parse_publish_time
parse_chanmama_excel = chanmama_export.parse_chanmama_excel
parse_chanmama_csv = chanmama_export.parse_chanmama_csv
def parse_chanmama_export(path):
"""Parse either the official Excel export or the virtual-list CSV fallback."""
"""按文件类型分发到 Excel/CSV 解析(经由模块级名字,保持可替换性)。"""
if str(path).lower().endswith(".csv"):
return parse_chanmama_csv(path)
return parse_chanmama_excel(path)
@@ -1592,41 +1438,6 @@ def _write_back(base_token, table_id, record_id, field_id, value):
return True
def _parse_publish_time(value):
if value is None or value == "":
return None
if isinstance(value, datetime):
return value
if isinstance(value, (int, float)):
# Excel 日期可能是 serial number(从 1899-12-30 起算)
try:
if 25569 <= value <= 60000:
from datetime import timedelta
return datetime(1899, 12, 30) + timedelta(days=value)
except Exception:
pass
try:
return datetime.fromtimestamp(value / 1000)
except (ValueError, OSError):
return None
if isinstance(value, str):
v = value.strip()
if not v:
return None
if v.isdigit():
try:
return datetime.fromtimestamp(int(v) / 1000)
except (ValueError, OSError):
pass
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S",
"%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d"):
try:
return datetime.strptime(v[:len(fmt) + 2], fmt)
except ValueError:
continue
return None
def _pick_read_field(fmap, pub_time, today=None):
if pub_time is None:
return None
@@ -0,0 +1,30 @@
{
"_comment": "自营账号映射: 内部账号别名(飞书「投放账号类型」口径) ↔ 平台账号身份。douyin 按蝉妈妈导出的达人昵称/抖音号匹配;bilibili 按 B站公开 API 返回的 UP 主名(owner.name)匹配,首次运行日志会提示未配置别名的账号名,确认后在此登记;xiaohongshu 的 phone 供 ARK 短信登录自动填入手机号(环境变量 XHS_ARK_ACCOUNT 优先)。新增自营账号时在此追加。",
"douyin": [
{
"alias": "抖音·机能号",
"account_name": "光影行星箱包旗舰店",
"platform_account_id": "gyxx168888"
},
{
"alias": "抖音·高智号",
"account_name": "GYXX光影行星箱包专卖店",
"platform_account_id": "GYXX168168"
}
],
"bilibili": [
{
"alias": "B站·自营号",
"account_name": "光影行星",
"platform_account_id": "1008229967"
}
],
"xiaohongshu": [
{
"alias": "小红书·自营号",
"account_name": "光影行星GYXX旗舰店",
"platform_account_id": "18933246725",
"phone": "18933246725"
}
]
}
@@ -47,10 +47,10 @@ def collect_urls() -> list[dict]:
def upsert_metrics(note_id: int, title: str, metrics: dict) -> None:
db.update_note_metrics(
note_id,
like_count=metrics.get("like_count") or None,
favorite_count=metrics.get("favorite_count") or None,
comment_count=metrics.get("comment_count") or None,
share_count=metrics.get("share_count") or None,
like_count=_to_int(metrics.get("like_count")),
favorite_count=_to_int(metrics.get("favorite_count")),
comment_count=_to_int(metrics.get("comment_count")),
share_count=_to_int(metrics.get("share_count")),
title=title or None,
)
@@ -191,6 +191,8 @@ def main(only_ids: list[int] | None = None, dry_run: bool = False) -> int:
db.replace_comments(note_id, "bilibili", comments, allow_empty=allow_empty)
else:
log(f" skip DB comment replace: {reason}")
fail += 1
continue
except Exception as exc:
log(f" DB comment replace error: {exc}")
@@ -61,10 +61,10 @@ def collect_urls() -> list[dict]:
def upsert_metrics(note_id: int, metrics: dict) -> None:
db.update_note_metrics(
note_id,
like_count=metrics.get("like_count") or None,
favorite_count=metrics.get("favorite_count") or None,
comment_count=metrics.get("comment_count") or None,
share_count=metrics.get("share_count") or None,
like_count=_to_int(metrics.get("like_count")),
favorite_count=_to_int(metrics.get("favorite_count")),
comment_count=_to_int(metrics.get("comment_count")),
share_count=_to_int(metrics.get("share_count")),
title=metrics.get("title") or None,
)
@@ -285,6 +285,7 @@ def main(only_ids: list[int] | None = None, dry_run: bool = False) -> int:
else:
log(f" skip DB comment replace: {reason}")
checkpoint_eligible = False
persisted = False
except Exception as exc:
log(f" DB comment replace error: {exc}")
persisted = False
@@ -54,10 +54,10 @@ def collect_urls() -> list[dict]:
def upsert_metrics(note_id: int, title: str, metrics: dict) -> None:
db.update_note_metrics(
note_id,
like_count=metrics.get("like_count") or None,
favorite_count=metrics.get("favorite_count") or None,
comment_count=metrics.get("comment_count") or None,
share_count=metrics.get("share_count") or None,
like_count=_to_int(metrics.get("like_count")),
favorite_count=_to_int(metrics.get("favorite_count")),
comment_count=_to_int(metrics.get("comment_count")),
share_count=_to_int(metrics.get("share_count")),
title=title or None,
)
@@ -233,6 +233,7 @@ def main(only_ids: list[int] | None = None, dry_run: bool = False) -> int:
else:
log(f" skip DB comment replace: {reason}")
checkpoint_eligible = False
persisted = False
except Exception as exc:
log(f" DB comment replace error: {exc}")
persisted = False
@@ -254,10 +254,10 @@ def update_note_metrics(
) -> None:
sql = """
UPDATE cmt_notes_master
SET like_count = %s,
favorite_count = %s,
comment_count = %s,
share_count = %s,
SET like_count = COALESCE(%s, like_count),
favorite_count = COALESCE(%s, favorite_count),
comment_count = COALESCE(%s, comment_count),
share_count = COALESCE(%s, share_count),
title = COALESCE(%s, title),
scraped_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
@@ -0,0 +1,69 @@
-- Migration 009: daily full-metric snapshots for self-operated Douyin videos.
--
-- The self-operated collection previously only carried exposure back into
-- Feishu slots; the Chanmama export also provides the video link, creator,
-- duration, publish time, likes, comments, shares and estimated sales. This
-- table keeps one row per (platform, account, video, business date) so the
-- full video list is retained even for videos never registered in a style
-- table. Rerunning the same business date upserts in place (idempotent).
CREATE TABLE IF NOT EXISTS cmt_self_video_metrics (
id BIGSERIAL PRIMARY KEY,
platform VARCHAR(32) NOT NULL DEFAULT 'douyin',
account_alias VARCHAR(64) NOT NULL DEFAULT '',
account_name VARCHAR(255) NOT NULL DEFAULT '',
platform_account_id VARCHAR(128) NOT NULL DEFAULT '',
video_id VARCHAR(128) NOT NULL,
title VARCHAR(512) DEFAULT '',
chanmama_url TEXT DEFAULT '',
video_url TEXT DEFAULT '',
duration VARCHAR(32) DEFAULT '',
publish_time TIMESTAMPTZ DEFAULT NULL,
metric_date DATE NOT NULL,
exposure BIGINT DEFAULT NULL,
like_count BIGINT DEFAULT NULL,
comment_count BIGINT DEFAULT NULL,
share_count BIGINT DEFAULT NULL,
est_sales_count BIGINT DEFAULT NULL,
est_sales_amount NUMERIC(14,2) DEFAULT NULL,
source_file TEXT DEFAULT '',
observed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uniq_cmt_self_video_metric
UNIQUE (platform, account_name, video_id, metric_date)
);
COMMENT ON TABLE cmt_self_video_metrics IS
'自营视频每日指标快照;一行=一个自营账号的一条视频在一个业务日期的蝉妈妈采集值';
COMMENT ON COLUMN cmt_self_video_metrics.platform IS
'平台:douyin(首批);后续可扩展 bilibili 等';
COMMENT ON COLUMN cmt_self_video_metrics.account_alias IS
'内部账号别名(飞书「投放账号类型」口径),由 chanmama_accounts.json 映射';
COMMENT ON COLUMN cmt_self_video_metrics.account_name IS
'蝉妈妈达人昵称,采集数据自带的稳定账号身份';
COMMENT ON COLUMN cmt_self_video_metrics.video_id IS
'抖音视频数字ID(/video|/note路径);无抖音链接时退回蝉妈妈awemeDetail ID';
COMMENT ON COLUMN cmt_self_video_metrics.metric_date IS
'调度器业务日期,不使用进程实际结束日期';
COMMENT ON COLUMN cmt_self_video_metrics.exposure IS
'当日采集的预估曝光,与飞书曝光槽位回填同源';
CREATE INDEX IF NOT EXISTS idx_cmt_self_video_metrics_date
ON cmt_self_video_metrics (metric_date, platform);
CREATE INDEX IF NOT EXISTS idx_cmt_self_video_metrics_account
ON cmt_self_video_metrics (platform, account_name, publish_time);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_cmt_self_video_metrics_updated_at'
) THEN
EXECUTE format(
'CREATE TRIGGER trg_cmt_self_video_metrics_updated_at '
'BEFORE UPDATE ON cmt_self_video_metrics '
'FOR EACH ROW EXECUTE FUNCTION update_updated_at_column()'
);
END IF;
END;
$$;
@@ -0,0 +1,42 @@
-- Migration 010: extend self-operated video metrics into a cross-platform table.
--
-- The table was created by 009 for Douyin (Chanmama exports). Bilibili public
-- API collection and the Xiaohongshu ARK management console now feed the same
-- daily snapshot. Platform-specific metrics stay as explicit nullable
-- columns; ``page_metrics`` keeps every column exactly as displayed on the
-- source page so future UI columns are retained without a migration.
ALTER TABLE cmt_self_video_metrics
ADD COLUMN IF NOT EXISTS note_type VARCHAR(16) DEFAULT '',
ADD COLUMN IF NOT EXISTS creation_method VARCHAR(64) DEFAULT '',
ADD COLUMN IF NOT EXISTS publish_status VARCHAR(32) DEFAULT '',
ADD COLUMN IF NOT EXISTS mounted_products TEXT DEFAULT '',
ADD COLUMN IF NOT EXISTS homepage_redirect_count BIGINT,
ADD COLUMN IF NOT EXISTS live_redirect_count BIGINT,
ADD COLUMN IF NOT EXISTS payment_amount NUMERIC(14,2),
ADD COLUMN IF NOT EXISTS payment_orders BIGINT,
ADD COLUMN IF NOT EXISTS coin_count BIGINT,
ADD COLUMN IF NOT EXISTS favorite_count BIGINT,
ADD COLUMN IF NOT EXISTS danmaku_count BIGINT,
ADD COLUMN IF NOT EXISTS page_metrics JSONB DEFAULT '{}'::jsonb;
COMMENT ON TABLE cmt_self_video_metrics IS
'自营内容每日指标快照;一行=一个自营账号的一条内容(视频/笔记)在一个业务日期的采集值;平台含 douyin/bilibili/xiaohongshu';
COMMENT ON COLUMN cmt_self_video_metrics.video_url IS
'平台内容链接:抖音视频链接 / B站视频链接 / 小红书笔记链接';
COMMENT ON COLUMN cmt_self_video_metrics.exposure IS
'主触达指标:抖音预估曝光 / B站播放量 / 小红书笔记阅读数';
COMMENT ON COLUMN cmt_self_video_metrics.note_type IS
'内容类型:小红书 goods(商品笔记)/normal(普通笔记);抖音 video/note(按链接);其余为空';
COMMENT ON COLUMN cmt_self_video_metrics.creation_method IS '创作方式(小红书:手动创作等)';
COMMENT ON COLUMN cmt_self_video_metrics.publish_status IS '发布状态(小红书:已发布等)';
COMMENT ON COLUMN cmt_self_video_metrics.mounted_products IS '挂载商品(小红书商品笔记,页面展示文本)';
COMMENT ON COLUMN cmt_self_video_metrics.homepage_redirect_count IS '引流主页次数(小红书普通笔记)';
COMMENT ON COLUMN cmt_self_video_metrics.live_redirect_count IS '引流直播间次数(小红书普通笔记)';
COMMENT ON COLUMN cmt_self_video_metrics.payment_amount IS '笔记支付金额(小红书商品笔记)';
COMMENT ON COLUMN cmt_self_video_metrics.payment_orders IS '笔记支付订单数(小红书商品笔记)';
COMMENT ON COLUMN cmt_self_video_metrics.coin_count IS '投币数(B站)';
COMMENT ON COLUMN cmt_self_video_metrics.favorite_count IS '收藏数(B站)';
COMMENT ON COLUMN cmt_self_video_metrics.danmaku_count IS '弹幕数(B站)';
COMMENT ON COLUMN cmt_self_video_metrics.page_metrics IS
'来源页面/接口展示的全部原始列 {列名: 展示值},保留未建模指标';
@@ -1,5 +1,5 @@
-- 内容营销模块 PostgreSQL schema
-- 表结构:cmt_styles / cmt_creators / cmt_style_creators / cmt_notes_master / cmt_comments
-- 表结构:cmt_styles / cmt_creators / cmt_style_creators / cmt_notes_master / cmt_note_metric_snapshots / cmt_self_video_metrics / cmt_comments
-- 适用于 PostgreSQL 14+
-- 在当前已连接数据库中执行;项目默认数据库为本地 gyxx_flow
@@ -206,6 +206,74 @@ CREATE INDEX IF NOT EXISTS idx_cmt_note_metric_snapshots_date
CREATE INDEX IF NOT EXISTS idx_cmt_note_metric_snapshots_note
ON cmt_note_metric_snapshots (note_id, metric_date);
-- 自营内容每日指标快照(跨平台: 抖音蝉妈妈导出 / B站公开API / 小红书ARK管理后台)
-- 一行 = 一个自营账号的一条内容(视频/笔记)在一个业务日期的完整采集值;
-- 不与 cmt_notes_master 冲突: 该表按账号+内容独立留存全部内容(含未登记款式表的)。
CREATE TABLE IF NOT EXISTS cmt_self_video_metrics (
id BIGSERIAL PRIMARY KEY,
platform VARCHAR(32) NOT NULL DEFAULT 'douyin',
account_alias VARCHAR(64) NOT NULL DEFAULT '', -- 内部账号别名,如 抖音·机能号
account_name VARCHAR(255) NOT NULL DEFAULT '', -- 平台账号名: 蝉妈妈达人昵称/B站UP主/小红书店铺
platform_account_id VARCHAR(128) NOT NULL DEFAULT '', -- 平台账号ID: 抖音号/B站mid/小红书登录手机号
video_id VARCHAR(128) NOT NULL, -- 内容稳定ID: 抖音视频ID/B站BV号/小红书笔记ID(或标题指纹)
title VARCHAR(512) DEFAULT '',
chanmama_url TEXT DEFAULT '', -- 蝉妈妈视频链接(仅抖音)
video_url TEXT DEFAULT '', -- 平台内容链接: 抖音视频/B站视频/小红书笔记
duration VARCHAR(32) DEFAULT '', -- 视频时长原始文本
publish_time TIMESTAMPTZ DEFAULT NULL,
metric_date DATE NOT NULL,
exposure BIGINT DEFAULT NULL, -- 主触达: 抖音预估曝光/B站播放量/小红书阅读数
like_count BIGINT DEFAULT NULL, -- 点赞数
comment_count BIGINT DEFAULT NULL, -- 评论数(B站为回复数)
share_count BIGINT DEFAULT NULL, -- 转发/分享数
est_sales_count BIGINT DEFAULT NULL, -- 预估销量(抖音)
est_sales_amount NUMERIC(14,2) DEFAULT NULL, -- 预估销售额(抖音)/笔记支付金额(小红书)
note_type VARCHAR(16) DEFAULT '', -- goods/normal(小红书), video/note(抖音)
creation_method VARCHAR(64) DEFAULT '', -- 创作方式(小红书)
publish_status VARCHAR(32) DEFAULT '', -- 发布状态(小红书)
mounted_products TEXT DEFAULT '', -- 挂载商品(小红书商品笔记)
homepage_redirect_count BIGINT DEFAULT NULL, -- 引流主页次数(小红书)
live_redirect_count BIGINT DEFAULT NULL, -- 引流直播间次数(小红书)
payment_amount NUMERIC(14,2) DEFAULT NULL, -- 笔记支付金额(小红书商品笔记)
payment_orders BIGINT DEFAULT NULL, -- 笔记支付订单数(小红书商品笔记)
coin_count BIGINT DEFAULT NULL, -- 投币数(B站)
favorite_count BIGINT DEFAULT NULL, -- 收藏数(B站)
danmaku_count BIGINT DEFAULT NULL, -- 弹幕数(B站)
page_metrics JSONB DEFAULT '{}'::jsonb, -- 来源页面/接口展示的全部原始列
source_file TEXT DEFAULT '', -- 来源导出文件名
observed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uniq_cmt_self_video_metric
UNIQUE (platform, account_name, video_id, metric_date)
);
COMMENT ON TABLE cmt_self_video_metrics IS
'自营内容每日指标快照;一行=一个自营账号的一条内容(视频/笔记)在一个业务日期的采集值;平台含 douyin/bilibili/xiaohongshu';
COMMENT ON COLUMN cmt_self_video_metrics.platform IS '平台:douyin / bilibili / xiaohongshu';
COMMENT ON COLUMN cmt_self_video_metrics.account_alias IS '内部账号别名(飞书「投放账号类型」口径),由 self_operated_accounts.json 映射';
COMMENT ON COLUMN cmt_self_video_metrics.account_name IS '平台账号名,采集数据自带的稳定账号身份';
COMMENT ON COLUMN cmt_self_video_metrics.video_id IS '内容稳定ID:抖音视频数字ID、B站BV号、小红书笔记ID(无链接时用标题+发布时间指纹)';
COMMENT ON COLUMN cmt_self_video_metrics.video_url IS '平台内容链接:抖音视频链接 / B站视频链接 / 小红书笔记链接';
COMMENT ON COLUMN cmt_self_video_metrics.metric_date IS '调度器业务日期,不使用进程实际结束日期';
COMMENT ON COLUMN cmt_self_video_metrics.exposure IS '主触达指标:抖音预估曝光 / B站播放量 / 小红书笔记阅读数;与飞书曝光槽位回填同源';
COMMENT ON COLUMN cmt_self_video_metrics.note_type IS '内容类型:小红书 goods(商品笔记)/normal(普通笔记);抖音 video/note(按链接);其余为空';
COMMENT ON COLUMN cmt_self_video_metrics.creation_method IS '创作方式(小红书:手动创作等)';
COMMENT ON COLUMN cmt_self_video_metrics.publish_status IS '发布状态(小红书:已发布等)';
COMMENT ON COLUMN cmt_self_video_metrics.mounted_products IS '挂载商品(小红书商品笔记,页面展示文本)';
COMMENT ON COLUMN cmt_self_video_metrics.homepage_redirect_count IS '引流主页次数(小红书普通笔记)';
COMMENT ON COLUMN cmt_self_video_metrics.live_redirect_count IS '引流直播间次数(小红书普通笔记)';
COMMENT ON COLUMN cmt_self_video_metrics.payment_amount IS '笔记支付金额(小红书商品笔记)';
COMMENT ON COLUMN cmt_self_video_metrics.payment_orders IS '笔记支付订单数(小红书商品笔记)';
COMMENT ON COLUMN cmt_self_video_metrics.coin_count IS '投币数(B站)';
COMMENT ON COLUMN cmt_self_video_metrics.favorite_count IS '收藏数(B站)';
COMMENT ON COLUMN cmt_self_video_metrics.danmaku_count IS '弹幕数(B站)';
COMMENT ON COLUMN cmt_self_video_metrics.page_metrics IS '来源页面/接口展示的全部原始列 {列名: 展示值},保留未建模指标';
CREATE INDEX IF NOT EXISTS idx_cmt_self_video_metrics_date
ON cmt_self_video_metrics (metric_date, platform);
CREATE INDEX IF NOT EXISTS idx_cmt_self_video_metrics_account
ON cmt_self_video_metrics (platform, account_name, publish_time);
-- 评论表
CREATE TABLE IF NOT EXISTS cmt_comments (
id BIGSERIAL PRIMARY KEY,
@@ -346,7 +414,7 @@ DO $$
DECLARE
tbl TEXT;
BEGIN
FOR tbl IN SELECT unnest(ARRAY['cmt_styles', 'cmt_creators', 'cmt_style_creators', 'cmt_notes_master', 'cmt_comments', 'cmt_weekly_summary', 'cmt_monthly_summary', 'cmt_creator_report'])
FOR tbl IN SELECT unnest(ARRAY['cmt_styles', 'cmt_creators', 'cmt_style_creators', 'cmt_notes_master', 'cmt_note_metric_snapshots', 'cmt_self_video_metrics', 'cmt_comments', 'cmt_weekly_summary', 'cmt_monthly_summary', 'cmt_creator_report'])
LOOP
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_' || tbl || '_updated_at'
@@ -534,8 +534,10 @@ def _is_current_v2_artifact(path: Path, business_date: dt.date | None) -> bool:
return modified_date == business_date
def collect_metrics_from_v2() -> list[dict]:
"""扫一遍 v2_results,聚合每个 record_id 的最新指标"""
def collect_metrics_from_v2(
*, self_operated: bool | None = None
) -> list[dict]:
"""扫一遍 v2_results,按合作/自营范围聚合 record_id 的最新指标"""
out: list[dict] = []
business_date = _runtime_business_date()
for path in sorted(V2_DIR.glob("*.json")):
@@ -544,7 +546,12 @@ def collect_metrics_from_v2() -> list[dict]:
result = detect_platform(path)
if not result or result[0] is None:
continue
platform, self_operated = result
platform, artifact_self_operated = result
if (
self_operated is not None
and artifact_self_operated is not self_operated
):
continue
idx, style_name, _, _ = parse_v2_filename(path.name)
if idx is None:
continue
@@ -577,7 +584,7 @@ def collect_metrics_from_v2() -> list[dict]:
"like_count": None,
"collect_count": None,
"comment_count": None,
"self_operated": self_operated,
"self_operated": artifact_self_operated,
})
else:
items = data.get("results") or []
@@ -603,7 +610,7 @@ def collect_metrics_from_v2() -> list[dict]:
"like_count": parse_int(item.get("like_count")),
"collect_count": parse_int(item.get("collect_count")),
"comment_count": parse_int(item.get("comment_count")),
"self_operated": self_operated,
"self_operated": artifact_self_operated,
})
return out
@@ -1223,8 +1230,14 @@ def sync(
dry_run: bool = False,
insert_missing: bool = True,
metric_date: dt.date | str | None = None,
*,
self_operated: bool | None = None,
) -> dict:
metrics = collect_metrics_from_v2()
metrics = (
collect_metrics_from_v2()
if self_operated is None
else collect_metrics_from_v2(self_operated=self_operated)
)
snapshot_date = _resolve_metric_date(metric_date)
print(
f"[sync] v2_results 中可同步的命中记录: {len(metrics)} 条; "
@@ -1241,13 +1254,17 @@ def sync(
mapping = None
self_mapping = None
if insert_missing:
if self_operated is not True:
try:
mapping = feishu_mapping.load_mapping(force_refresh=False)
except Exception as exc:
print(f"[WARN] feishu_mapping 加载失败,INSERT 路径禁用: {exc}")
insert_missing = False
print(f"[WARN] feishu_mapping 加载失败: {exc}")
if self_operated is not False:
try:
self_mapping = feishu_mapping.load_mapping(force_refresh=False, self_operated=True)
self_mapping = feishu_mapping.load_mapping(
force_refresh=False,
self_operated=True,
)
except Exception as exc:
print(f"[WARN] feishu_mapping(自营) 加载失败: {exc}")
@@ -1406,9 +1423,20 @@ def sync(
old_pub_row = cur.fetchone()
if old_pub_row and old_pub_row[0] is None:
try:
style_name = row.get("style_name")
bf_update = _backfill_record(mapping, style_name, record_id) or \
(self_mapping and _backfill_record(self_mapping, style_name, record_id)) or {}
active_mapping = (
self_mapping
if row.get("self_operated")
else mapping
)
bf_update = (
_backfill_record(
active_mapping,
row.get("style_name"),
record_id,
)
if active_mapping
else {}
)
new_pub = bf_update.get("publish_time")
except Exception:
new_pub = None
@@ -1460,6 +1488,12 @@ def main():
default=None,
help="快照业务日期 YYYY-MM-DD;默认使用 GYXX_BUSINESS_DATE",
)
parser.add_argument(
"--scope",
choices=("all", "collaborators", "self-operated"),
default="all",
help="同步来源范围;默认同步合作达人与自营内容",
)
parser.add_argument(
"--repair-record-collisions",
action="store_true",
@@ -1503,6 +1537,8 @@ def main():
help="关闭 INSERT 路径(默认开启:对 v2_results 有但 cmt_notes_master 缺的行,回填 feishu 后 INSERT)",
)
args = parser.parse_args()
if args.scope != "all" and args.repair_record_collisions:
parser.error("--scope 不能与事故记录修复参数一起使用")
if args.repair_record_collisions and not args.repair_window:
parser.error("--repair-record-collisions 至少需要一个 --repair-window")
if args.repair_window and not args.repair_record_collisions:
@@ -1524,6 +1560,7 @@ def main():
insert_missing = args.insert_missing or not args.no_insert_missing
print(f"[sync] 模式: {'DRY-RUN' if args.dry_run else '正式写入'} "
f"范围: {args.scope} "
f"insert-missing: {'ON' if insert_missing else 'OFF'} "
f"repair-record-collisions: "
f"{'ON' if args.repair_record_collisions else 'OFF'} "
@@ -1589,6 +1626,8 @@ def main():
"dry_run": args.dry_run,
"insert_missing": insert_missing,
}
if args.scope != "all":
sync_kwargs["self_operated"] = args.scope == "self-operated"
if args.metric_date is not None:
sync_kwargs["metric_date"] = args.metric_date
result = sync(**sync_kwargs)
@@ -0,0 +1,616 @@
#!/usr/bin/env python
"""把自营内容(抖音/B站/小红书)全量指标同步到 PostgreSQL cmt_self_video_metrics。
数据源(按平台):
douyin data/raw/content_marketing/chanmama/ 各账号最新一份导出
('<达人昵称>_视频记录_周期内发布视频_<日期范围>.xlsx')或 --excel 显式指定
bilibili data/normalized/content_marketing/self_bilibili/daily.json
(self_bilibili_scraper.py 由 B站公开 API 产出)
xiaohongshu data/normalized/content_marketing/self_xhs/daily.json
(self_xhs_scraper.py 由 ARK 笔记管理页产出)
目标表: cmt_self_video_metrics(schema / migrations/009+010)
一行 = 平台+账号+内容+业务日期;同日重跑按唯一键幂等 upsert。
page_metrics 保留来源页面/接口展示的全部原始列。
账号身份: 内部别名按 data/config/self_operated_accounts.json 由平台账号身份解析,
未配置的账号以空别名入库并在日志中提示。
飞书曝光槽位回填仍由 chanmama_scraper.py / self_bilibili_scraper.py 负责,
本脚本只写 PostgreSQL。
用法:
python sync_self_video_metrics.py # 三平台全同步
python sync_self_video_metrics.py --platform douyin # 只同步抖音
python sync_self_video_metrics.py --dry-run # 仅预览
python sync_self_video_metrics.py --excel <xlsx> # 抖音指定导出文件
python sync_self_video_metrics.py --metric-date 2026-09-28 --allow-stale # 补历史
"""
import argparse
import datetime as dt
import json
import os
import sys
from pathlib import Path
from gyxx_flow.modules.content_marketing import chanmama_export, self_operated_accounts
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
try:
from psycopg.types.json import Jsonb
except ImportError: # pragma: no cover - psycopg 为运行必装依赖
Jsonb = dict
def configure_console_output(stdout, stderr) -> None:
"""Keep Windows GBK logs alive when account names contain Unicode symbols."""
for stream in (stdout, stderr):
if stream is None:
continue
try:
stream.reconfigure(errors="replace")
except (AttributeError, OSError, ValueError):
pass
configure_console_output(sys.stdout, sys.stderr)
DOWNLOAD_DIR = PATHS.raw_root / "chanmama"
ACCOUNTS_CONFIG = self_operated_accounts.accounts_config_path(PATHS.config_root)
BILIBILI_ARTIFACT = PATHS.normalized_root / "self_bilibili" / "daily.json"
XHS_ARTIFACT = PATHS.normalized_root / "self_xhs" / "daily.json"
PLATFORMS = ("douyin", "bilibili", "xiaohongshu")
TABLE_NAME = "cmt_self_video_metrics"
# 行值列序(与下方 INSERT 列序一一对应)
ROW_FIELDS = (
"platform", "account_alias", "account_name", "platform_account_id",
"video_id", "title", "chanmama_url", "video_url", "duration",
"publish_time", "metric_date", "exposure", "like_count", "comment_count",
"share_count", "est_sales_count", "est_sales_amount", "note_type",
"creation_method", "publish_status", "mounted_products",
"homepage_redirect_count", "live_redirect_count", "payment_amount",
"payment_orders", "coin_count", "favorite_count", "danmaku_count",
"page_metrics", "source_file",
)
_ROW_TEMPLATE = {field: None for field in ROW_FIELDS}
def runtime_business_date() -> dt.date | None:
"""Return the orchestrator business date when one is explicitly bound."""
value = os.environ.get("GYXX_BUSINESS_DATE", "").strip()
if not value:
return None
try:
return dt.date.fromisoformat(value)
except ValueError as exc:
raise RuntimeError("GYXX_BUSINESS_DATE must be YYYY-MM-DD") from exc
def resolve_metric_date(value: dt.date | str | None) -> dt.date:
if value is not None:
if isinstance(value, dt.datetime):
return value.date()
if isinstance(value, dt.date):
return value
try:
return dt.date.fromisoformat(str(value).strip())
except ValueError as exc:
raise ValueError("metric date must be YYYY-MM-DD") from exc
return runtime_business_date() or dt.datetime.now().astimezone().date()
def is_current_artifact(path: Path, business_date: dt.date) -> bool:
modified_date = dt.datetime.fromtimestamp(path.stat().st_mtime).astimezone().date()
return modified_date == business_date
def make_row(platform: str, metric_date: dt.date, source_file: str, **values) -> tuple:
"""按 ROW_FIELDS 顺序构造一行 upsert 参数(缺省 None/'')."""
row = dict(_ROW_TEMPLATE)
row["platform"] = platform
row["metric_date"] = metric_date
row["source_file"] = source_file
for key, value in values.items():
if key not in row:
raise KeyError(f"unknown row field: {key}")
row[key] = value
row["account_alias"] = row["account_alias"] or ""
row["account_name"] = row["account_name"] or ""
row["platform_account_id"] = row["platform_account_id"] or ""
row["title"] = str(row["title"] or "")[:512]
row["chanmama_url"] = str(row["chanmama_url"] or "")
row["video_url"] = str(row["video_url"] or "")
row["duration"] = str(row["duration"] or "")[:32]
row["note_type"] = str(row["note_type"] or "")[:16]
row["creation_method"] = str(row["creation_method"] or "")[:64]
row["publish_status"] = str(row["publish_status"] or "")[:32]
row["mounted_products"] = str(row["mounted_products"] or "")
row["page_metrics"] = Jsonb(row["page_metrics"] or {})
row["source_file"] = str(row["source_file"] or "")
return tuple(row[field] for field in ROW_FIELDS)
# ============================================================
# 各平台来源 → 统一行
# ============================================================
def _douyin_note_type(video_url: str) -> str:
text = str(video_url or "")
if "/video/" in text:
return "video"
if "/note/" in text:
return "note"
return ""
def collect_douyin_rows(
accounts: list[dict], metric_date: dt.date, explicit_excel: list[str],
*, only_accounts: list[str] | None = None, use_file_mtime: bool = False,
) -> tuple[dict[tuple[str, str, str, dt.date], tuple], dict[str, int], list[Path]]:
"""解析蝉妈妈导出构造抖音行。
use_file_mtime=True 时(历史回填),每份导出按其落盘日期作为该快照的
业务日期——历史导出正是当日采集值的快照,等价于补录每日采集。
行键随之含 metric_date,同一视频在不同日期各留一行。
"""
if explicit_excel:
export_paths = [Path(item) for item in explicit_excel]
else:
export_paths = [
Path(path)
for path in chanmama_export.find_all_chanmama_excels_grouped(
str(DOWNLOAD_DIR)
)
]
stats = {
"files": 0, "records": 0, "rows": 0, "skip_no_identity": 0,
"skip_no_video_id": 0, "skip_account_filter": 0,
"unknown_alias_accounts": 0,
}
unknown_accounts: set[str] = set()
rows: dict[tuple[str, str, str, dt.date], tuple] = {}
for path in export_paths:
if not path.exists():
print(f"⚠️ 文件不存在,跳过: {path}")
continue
# 旧版导出(12列)没有 达人昵称/抖音号 列,账号身份回退用文件名前缀
# ('<达人昵称>_视频记录_周期内发布视频_….xlsx')
stem = path.name[:-5] if path.name.lower().endswith(".xlsx") else path.name
marker = stem.find("_视频记录_")
file_account = stem[:marker] if marker > 0 else ""
stats["files"] += 1
try:
records, _headers = chanmama_export.parse_chanmama_export(str(path))
except Exception as exc:
print(f"⚠️ 解析失败,跳过: {path}: {exc}")
continue
for record in records:
stats["records"] += 1
account_name = record.get("creator_name") or file_account
douyin_account = record.get("douyin_account") or ""
if not account_name and not douyin_account:
stats["skip_no_identity"] += 1
continue
video_id = chanmama_export.extract_video_id(
record.get("chanmama_url"), record.get("video_url")
)
if not video_id:
stats["skip_no_video_id"] += 1
continue
alias = self_operated_accounts.resolve_alias(
accounts,
account_name=account_name,
platform_account_id=douyin_account,
)
if not alias:
unknown_accounts.add(account_name or douyin_account)
if only_accounts is not None:
keys = {key.casefold() for key in only_accounts}
if (
alias.casefold() not in keys
and account_name.casefold() not in keys
and douyin_account.casefold() not in keys
):
stats["skip_account_filter"] += 1
continue
video_url = record.get("video_url") or ""
file_date = metric_date
if use_file_mtime:
file_date = dt.datetime.fromtimestamp(
path.stat().st_mtime
).astimezone().date()
key = ("douyin", account_name, video_id, file_date)
rows[key] = make_row(
"douyin", file_date, path.name,
account_alias=alias,
account_name=account_name,
platform_account_id=douyin_account,
video_id=video_id,
title=record.get("title"),
chanmama_url=record.get("chanmama_url") or "",
video_url=video_url,
duration=record.get("duration") or "",
publish_time=chanmama_export.parse_publish_time(
record.get("publish_time")
),
exposure=record.get("exposure"),
like_count=record.get("like_count"),
comment_count=record.get("comment_count"),
share_count=record.get("share_count"),
est_sales_count=record.get("est_sales_count"),
est_sales_amount=record.get("est_sales_amount"),
note_type=_douyin_note_type(video_url),
)
stats["rows"] = len(rows)
stats["unknown_alias_accounts"] = len(unknown_accounts)
for name in sorted(unknown_accounts):
print(f"⚠️ [douyin] 账号未配置别名,account_alias 将为空: {name}")
return rows, stats, export_paths
def collect_bilibili_rows(
accounts: list[dict], metric_date: dt.date,
*, only_accounts: list[str] | None = None,
) -> tuple[dict[tuple[str, str, str], tuple], dict[str, int], Path | None]:
from gyxx_flow.modules.content_marketing.self_bilibili_scraper import (
format_duration,
)
stats = {"records": 0, "rows": 0, "skip_no_owner": 0,
"unknown_alias_accounts": 0}
rows: dict[tuple[str, str, str], tuple] = {}
if not BILIBILI_ARTIFACT.exists():
print(f"⚠️ [bilibili] artifact 不存在,跳过该平台: {BILIBILI_ARTIFACT}")
return rows, stats, None
unknown_accounts: set[str] = set()
try:
artifact = json.loads(BILIBILI_ARTIFACT.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
print(f"⚠️ [bilibili] artifact 读取失败: {exc}")
return rows, stats, None
for video in artifact.get("videos", []):
stats["records"] += 1
account_name = str(video.get("owner_name") or "").strip()
if not account_name:
stats["skip_no_owner"] += 1
continue
video_id = str(video.get("video_id") or "").strip()
if not video_id:
stats["skip_no_owner"] += 1
continue
alias = self_operated_accounts.resolve_alias(
accounts, account_name=account_name
)
if not alias:
unknown_accounts.add(account_name)
if only_accounts is not None:
keys = {key.casefold() for key in only_accounts}
if alias.casefold() not in keys and account_name.casefold() not in keys:
stats["skip_no_owner"] += 1
continue
key = ("bilibili", account_name, video_id)
rows[key] = make_row(
"bilibili", metric_date, BILIBILI_ARTIFACT.name,
account_alias=alias,
account_name=account_name,
platform_account_id=str(video.get("owner_mid") or ""),
video_id=video_id,
title=video.get("title"),
video_url=video.get("video_url") or "",
duration=format_duration(video.get("duration_seconds")),
publish_time=chanmama_export.parse_publish_time(
video.get("publish_time")
),
exposure=video.get("view_count"),
like_count=video.get("like_count"),
comment_count=video.get("reply_count"),
share_count=video.get("share_count"),
coin_count=video.get("coin_count"),
favorite_count=video.get("favorite_count"),
danmaku_count=video.get("danmaku_count"),
)
stats["rows"] = len(rows)
stats["unknown_alias_accounts"] = len(unknown_accounts)
for name in sorted(unknown_accounts):
print(f"⚠️ [bilibili] 账号未配置别名,account_alias 将为空: {name}")
return rows, stats, BILIBILI_ARTIFACT
def collect_xiaohongshu_rows(
accounts: list[dict], metric_date: dt.date,
*, only_accounts: list[str] | None = None,
) -> tuple[dict[tuple[str, str, str], tuple], dict[str, int], Path | None]:
stats = {"records": 0, "rows": 0, "skip_no_title": 0,
"unknown_alias_accounts": 0}
rows: dict[tuple[str, str, str], tuple] = {}
if not XHS_ARTIFACT.exists():
print(f"⚠️ [xiaohongshu] artifact 不存在,跳过该平台: {XHS_ARTIFACT}")
return rows, stats, None
unknown_accounts: set[str] = set()
try:
artifact = json.loads(XHS_ARTIFACT.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
print(f"⚠️ [xiaohongshu] artifact 读取失败: {exc}")
return rows, stats, None
artifact_account = str(artifact.get("account_name") or "").strip()
# ARK artifact 按账号粒度产出,循环外一次性解析别名与平台账号ID
matched_entry = next(
(
entry
for entry in accounts
if artifact_account
and entry["account_name"].casefold() == artifact_account.casefold()
),
None,
)
alias = matched_entry["alias"] if matched_entry else ""
config_account_id = matched_entry["platform_account_id"] if matched_entry else ""
if artifact_account and not alias:
unknown_accounts.add(artifact_account)
for note in artifact.get("notes", []):
stats["records"] += 1
title = str(note.get("title") or "").strip()
video_id = str(note.get("video_id") or "").strip()
if not title or not video_id:
stats["skip_no_title"] += 1
continue
if only_accounts is not None:
keys = {key.casefold() for key in only_accounts}
if alias.casefold() not in keys and artifact_account.casefold() not in keys:
stats["skip_no_title"] += 1
continue
key = ("xiaohongshu", artifact_account, video_id)
rows[key] = make_row(
"xiaohongshu", metric_date, XHS_ARTIFACT.name,
account_alias=alias,
account_name=artifact_account,
platform_account_id=config_account_id,
video_id=video_id,
title=title,
video_url=note.get("note_url") or "",
publish_time=chanmama_export.parse_publish_time(
note.get("publish_time")
),
exposure=note.get("read_count"),
like_count=note.get("like_count"),
note_type=note.get("note_type") or "",
creation_method=note.get("creation_method") or "",
publish_status=note.get("publish_status") or "",
mounted_products=note.get("mounted_products") or "",
homepage_redirect_count=note.get("homepage_redirect_count"),
live_redirect_count=note.get("live_redirect_count"),
payment_amount=note.get("payment_amount"),
payment_orders=note.get("payment_orders"),
page_metrics=note.get("raw_columns") or {},
)
stats["rows"] = len(rows)
stats["unknown_alias_accounts"] = len(unknown_accounts)
for name in sorted(unknown_accounts):
print(f"⚠️ [xiaohongshu] 账号未配置别名,account_alias 将为空: {name}")
return rows, stats, XHS_ARTIFACT
COLLECTORS = {
"douyin": lambda accounts, metric_date, excel, only, use_mtime=False: (
collect_douyin_rows(
accounts, metric_date, excel,
only_accounts=only,
use_file_mtime=use_mtime,
)
),
"bilibili": lambda accounts, metric_date, excel, only, use_mtime=False: (
collect_bilibili_rows(accounts, metric_date, only_accounts=only)
),
"xiaohongshu": lambda accounts, metric_date, excel, only, use_mtime=False: (
collect_xiaohongshu_rows(accounts, metric_date, only_accounts=only)
),
}
def ensure_table(cur) -> None:
cur.execute(
"""
SELECT 1 FROM information_schema.tables
WHERE table_schema = current_schema() AND table_name = %s
""",
(TABLE_NAME,),
)
if not cur.fetchone():
raise RuntimeError(
f"表 {TABLE_NAME} 不存在;请先执行 data/tools/init_db.py 或 "
"migrations/009 + 010"
)
def main() -> int:
parser = argparse.ArgumentParser(
description="同步自营内容(抖音/B站/小红书)全量指标到 cmt_self_video_metrics"
)
parser.add_argument(
"--platform",
action="append",
choices=PLATFORMS,
dest="platforms",
default=[],
metavar="NAME",
help="只同步指定平台(可重复: douyin/bilibili/xiaohongshu);默认三平台全同步",
)
parser.add_argument("--dry-run", action="store_true", help="仅预览,不写入")
parser.add_argument(
"--metric-date",
type=dt.date.fromisoformat,
default=None,
help="快照业务日期 YYYY-MM-DD;默认使用 GYXX_BUSINESS_DATE",
)
parser.add_argument(
"--excel",
action="append",
default=[],
metavar="PATH",
help="抖音平台显式指定导出文件(可重复);默认按账号分组取下载目录最新一份",
)
parser.add_argument(
"--account",
action="append",
default=[],
metavar="NAME",
help="只同步指定账号(别名/账号名,可重复);默认全量",
)
parser.add_argument(
"--accounts-config",
default=str(ACCOUNTS_CONFIG),
help="自营账号别名映射配置路径",
)
parser.add_argument(
"--allow-stale",
action="store_true",
help="跳过「来源文件日期须等于业务日期」的陈旧产物检查(手动补历史用)",
)
parser.add_argument(
"--use-file-mtime",
action="store_true",
help="抖音历史回填: 每份导出按其落盘日期作为业务日期各留一行快照"
"(配合 --excel 指定历史文件;隐含跳过陈旧检查)",
)
args = parser.parse_args()
business_date_bound = runtime_business_date() is not None
metric_date = resolve_metric_date(args.metric_date)
selected_platforms = tuple(args.platforms) or PLATFORMS
only_accounts = args.account or None
all_rows: dict[tuple, tuple] = {}
sources: list[Path] = []
for platform in selected_platforms:
accounts = self_operated_accounts.load_accounts(
platform, args.accounts_config
)
rows, stats, source = COLLECTORS[platform](
accounts, metric_date, args.excel, only_accounts,
args.use_file_mtime and platform == "douyin",
)
if source is None:
continue
sources.extend(source if isinstance(source, list) else [source])
summary = " ".join(f"{key}={value}" for key, value in stats.items())
print(f"[sync-self][{platform}] {summary}")
all_rows.update(rows)
if not sources:
print("❌ 没有任何平台的来源文件可用")
return 1
stale = [
str(path)
for path in sources
if business_date_bound
and not args.allow_stale
and not args.use_file_mtime
and not is_current_artifact(path, metric_date)
]
if stale and not args.allow_stale:
print(
f"❌ 以下来源文件不是业务日期({metric_date.isoformat()})的产物; "
"补历史请显式传 --metric-date 并加 --allow-stale:\n "
+ "\n ".join(stale)
)
return 1
print(
f"[sync-self] 平台={'+'.join(selected_platforms)} 待写={len(all_rows)} 行; "
f"业务日期={metric_date.isoformat()}"
)
if not all_rows:
print("❌ 没有可入库的内容指标行")
return 1
by_platform: dict[str, int] = {}
for row in all_rows.values():
platform = row[0]
by_platform[platform] = by_platform.get(platform, 0) + 1
for platform, count in sorted(by_platform.items()):
print(f" 平台 {platform}: {count} 条")
if args.dry_run:
print(f"[sync-self] DRY-RUN: 未写入 {TABLE_NAME}")
return 0
import psycopg
from gyxx_flow.modules.content_marketing.data.tools.db import get_db_config
with psycopg.connect(**get_db_config()) as conn:
with conn.cursor() as cur:
ensure_table(cur)
for row in all_rows.values():
# SQL 为静态字面量,29 个值全部经 %s 占位符参数绑定传入。
cur.execute(
"""
INSERT INTO cmt_self_video_metrics (
platform, account_alias, account_name,
platform_account_id, video_id, title, chanmama_url,
video_url, duration, publish_time, metric_date,
exposure, like_count, comment_count, share_count,
est_sales_count, est_sales_amount, note_type,
creation_method, publish_status, mounted_products,
homepage_redirect_count, live_redirect_count,
payment_amount, payment_orders, coin_count,
favorite_count, danmaku_count, page_metrics,
source_file,
observed_at, created_at, updated_at
) VALUES (
%s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, %s,
%s, %s, %s,
%s, %s,
%s, %s, %s,
%s, %s, %s,
%s,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
ON CONFLICT (platform, account_name, video_id, metric_date)
DO UPDATE SET
account_alias = EXCLUDED.account_alias,
platform_account_id = EXCLUDED.platform_account_id,
title = EXCLUDED.title,
chanmama_url = EXCLUDED.chanmama_url,
video_url = EXCLUDED.video_url,
duration = EXCLUDED.duration,
publish_time = EXCLUDED.publish_time,
exposure = EXCLUDED.exposure,
like_count = EXCLUDED.like_count,
comment_count = EXCLUDED.comment_count,
share_count = EXCLUDED.share_count,
est_sales_count = EXCLUDED.est_sales_count,
est_sales_amount = EXCLUDED.est_sales_amount,
note_type = EXCLUDED.note_type,
creation_method = EXCLUDED.creation_method,
publish_status = EXCLUDED.publish_status,
mounted_products = EXCLUDED.mounted_products,
homepage_redirect_count = EXCLUDED.homepage_redirect_count,
live_redirect_count = EXCLUDED.live_redirect_count,
payment_amount = EXCLUDED.payment_amount,
payment_orders = EXCLUDED.payment_orders,
coin_count = EXCLUDED.coin_count,
favorite_count = EXCLUDED.favorite_count,
danmaku_count = EXCLUDED.danmaku_count,
page_metrics = EXCLUDED.page_metrics,
source_file = EXCLUDED.source_file,
observed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
""",
row,
)
conn.commit()
print(f"[sync-self] 完成: 已幂等写入 {len(all_rows)} 行到 {TABLE_NAME}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -51,12 +51,394 @@ _B23_RE = re.compile(r"https?://b23\.tv/\S+", re.IGNORECASE)
_MD_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
_URL_RE = re.compile(r"https?://[^\s)>\]]+")
# ---------------------------------------------------------------------------
# B站空间投稿列表(WBI 签名): 按 UP 主 mid 拉全量投稿
# ScraplingHttpSession 无 Cookie 状态,空间接口的风控 Cookie 手动获取并携带。
# ---------------------------------------------------------------------------
BILI_HEADERS = {
"Referer": "https://www.bilibili.com/",
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
),
}
_BILI_COOKIE_CACHE: dict[str, str] = {}
def bili_cookie_header(session: ScraplingHttpSession, *, refresh: bool = False) -> str:
"""获取并缓存空间接口所需的风控 Cookie 头(buvid3/buvid4/b_nut)。"""
if not refresh and _BILI_COOKIE_CACHE.get("header"):
return _BILI_COOKIE_CACHE["header"]
parts: dict[str, str] = {}
try:
r = session.get(
"https://api.bilibili.com/x/frontend/finger/spi",
headers=BILI_HEADERS, timeout=10,
)
spi = r.json().get("data") or {}
if spi.get("b_3"):
parts["buvid3"] = str(spi["b_3"])
if spi.get("b_4"):
parts["buvid4"] = str(spi["b_4"])
except Exception as exc:
log(f" [WARN] finger/spi 获取 buvid 失败: {exc}")
try:
r = session.get("https://www.bilibili.com/", headers=BILI_HEADERS,
timeout=15)
raw = str(r.headers.get("set-cookie") or "")
match = re.search(r"b_nut=([^;]+)", raw)
if match:
parts.setdefault("b_nut", match.group(1))
except Exception as exc:
log(f" [WARN] 主站 b_nut 获取失败: {exc}")
header = "; ".join(f"{key}={value}" for key, value in parts.items())
_BILI_COOKIE_CACHE["header"] = header
return header
# WBI mixin key 混淆表(B站公开前端算法,2026-09 实测有效)
_MIXIN_KEY_ENC_TAB = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5,
49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55,
40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57,
62, 11, 36, 20, 34, 44, 52,
]
def derive_mixin_key(img_key: str, sub_key: str) -> str:
"""img_key+sub_key 按混淆表重排,取前 32 位。"""
raw = f"{img_key}{sub_key}"
return "".join(raw[index] for index in _MIXIN_KEY_ENC_TAB)[:32]
def wbi_sign(params: dict, mixin_key: str, wts: int) -> dict:
"""对查询参数做 WBI 签名: 排序 → 加 wts → md5(query+mixin_key) → w_rid。
md5 是 B站 WBI 协议规定的签名算法(服务端按同算法校验请求完整性,
非密码学安全用途),换成其它哈希会被接口拒绝。
"""
import hashlib
from urllib.parse import urlencode
signed = dict(sorted(params.items()))
signed["wts"] = wts
query = urlencode(signed)
signed["w_rid"] = hashlib.md5(f"{query}{mixin_key}".encode()).hexdigest()
return signed
def get_mixin_key(session: ScraplingHttpSession) -> str | None:
"""从 nav 接口取当前 WBI 密钥(无需登录,但需携带风控 Cookie)。"""
try:
r = session.get(
"https://api.bilibili.com/x/web-interface/nav",
headers={**BILI_HEADERS, "Cookie": bili_cookie_header(session)},
timeout=10,
)
payload = r.json()
wbi = (payload.get("data") or {}).get("wbi_img") or {}
img_url = str(wbi.get("img_url") or "")
sub_url = str(wbi.get("sub_url") or "")
if not img_url or not sub_url:
return None
img_key = img_url.rsplit("/", 1)[-1].split(".")[0]
sub_key = sub_url.rsplit("/", 1)[-1].split(".")[0]
return derive_mixin_key(img_key, sub_key)
except Exception as exc:
log(f" [WARN] 获取 WBI 密钥失败: {exc}")
return None
def fetch_up_all_bvids(
session: ScraplingHttpSession, mid: str, max_pages: int = 50
) -> list[str]:
"""拉取 UP 主空间全部投稿的 BV 号(逐页,直到返回不足一页)。"""
mixin_key = get_mixin_key(session)
if not mixin_key:
log(" [WARN] 无 WBI 密钥,跳过主页全量采集")
return []
bvids: list[str] = []
page_size = 30
cookie = bili_cookie_header(session)
for page in range(1, max_pages + 1):
params = wbi_sign(
{"mid": mid, "ps": page_size, "pn": page,
"order": "pubdate", "platform": "web"},
mixin_key,
int(time.time()),
)
try:
r = session.get(
"https://api.bilibili.com/x/space/wbi/arc/search",
params=params,
headers={**BILI_HEADERS, "Cookie": cookie},
timeout=15,
)
payload = r.json()
except Exception as exc:
log(f" [WARN] 空间列表第 {page} 页失败: {exc}")
break
if payload.get("code") == -352 and page == 1:
# 风控 Cookie 失效时刷新一次再试
cookie = bili_cookie_header(session, refresh=True)
params = wbi_sign(
{"mid": mid, "ps": page_size, "pn": page,
"order": "pubdate", "platform": "web"},
mixin_key,
int(time.time()),
)
try:
r = session.get(
"https://api.bilibili.com/x/space/wbi/arc/search",
params=params,
headers={**BILI_HEADERS, "Cookie": cookie},
timeout=15,
)
payload = r.json()
except Exception as exc:
log(f" [WARN] 空间列表重试失败: {exc}")
break
if payload.get("code") != 0:
log(f" [WARN] 空间列表返回 code={payload.get('code')} "
f"({payload.get('message')}),停止主页采集")
break
vlist = ((payload.get("data") or {}).get("list") or {})
items = vlist if isinstance(vlist, list) else []
page_bvids = [str(item.get("bvid") or "") for item in items
if item.get("bvid")]
bvids.extend(page_bvids)
log(f" 📄 空间投稿第 {page} 页: {len(page_bvids)} 条")
if len(page_bvids) < page_size:
break
time.sleep(0.5)
return bvids
def fetch_up_all_bvids_via_browser(mid: str, max_pages: int = 50) -> list[str]:
"""用隐身浏览器翻 UP 主空间视频页,收集全部投稿 BV 号。
B站空间投稿列表需要登录态;复用隔离 Profile 与持久化 Cookie,
网格偶发空白时整体重试。返回收集到的 BV 号列表。
"""
from scrapling.fetchers import StealthySession
url = f"https://space.bilibili.com/{mid}/upload/video?order=pubdate"
extract_js = (
"() => [...document.querySelectorAll('a[href*=\"BV\"]')]"
".map((a) => a.getAttribute('href') || '')"
)
def visible_links() -> list[str]:
hrefs = page.evaluate(extract_js) or []
found: list[str] = []
for href in hrefs:
match = _BV_RE.search(str(href))
if match and match.group(0) not in found:
found.append(match.group(0))
return found
def wait_for_links(timeout_seconds: float = 20.0) -> list[str]:
deadline = time.time() + timeout_seconds
links: list[str] = []
while time.time() < deadline:
links = visible_links()
if links:
break
page.wait_for_timeout(1500)
return links
bvids: list[str] = []
with StealthySession(
headless=False,
real_chrome=True,
user_data_dir=str(PATHS.browser_profile_dir),
locale="zh-CN",
timezone_id="Asia/Shanghai",
timeout=60_000,
network_idle=False,
disable_resources=False,
google_search=False,
max_pages=2,
page_setup=restore_cookies,
) as session:
page = (
session.context.pages[0]
if session.context.pages
else session.context.new_page()
)
# 空间页的视频列表 XHR 需要登录态;冷指纹/无 Cookie 时网格空白,
# 先访问主站再进空间页,仍空白则整体重试。
for attempt in range(1, 4):
page.goto("https://www.bilibili.com/", wait_until="domcontentloaded",
timeout=60_000)
page.wait_for_timeout(4000)
page.goto(url, wait_until="domcontentloaded", timeout=60_000)
if wait_for_links(timeout_seconds=25.0):
break
log(f" [WARN] 空间页第 {attempt} 次未渲染出视频(可能需要登录),重试")
for _page_number in range(1, max_pages + 1):
page_bvids = wait_for_links() if _page_number == 1 else visible_links()
if not page_bvids:
if _page_number == 1:
log(" [WARN] 空间视频页未渲染出视频链接"
"(B站登录态缺失时空间列表不可见)")
break
new_on_page = [bv for bv in page_bvids if bv not in bvids]
bvids.extend(new_on_page)
log(f" 📄 空间视频页: 本页 {len(page_bvids)} 条, 累计 {len(bvids)} 条")
next_button = None
try:
candidates = page.locator(
".be-pager-next, [class*='pager-next'], "
"button:has-text('下一页')"
).all()
next_button = next(
(el for el in candidates if el.is_visible()), None
)
except Exception:
next_button = None
if next_button is None:
log(" 📄 空间视频已到最后一页")
break
css = str(next_button.get_attribute("class") or "")
if "disabled" in css.lower():
log(" 📄 空间视频已到最后一页")
break
try:
next_button.click()
except Exception as exc:
log(f" [WARN] 空间翻页失败,停止: {exc}")
break
# 等网格真正翻页(BV 集合变化),否则会把旧一页重复记入
changed = False
deadline = time.time() + 12
while time.time() < deadline:
page.wait_for_timeout(1200)
if visible_links() != page_bvids:
changed = True
break
if not changed:
log(" [WARN] 翻页后内容未变化,停止以避免重复")
break
return bvids
def collect_up_homepage_videos(
session: ScraplingHttpSession, mid: str, account_name: str,
registry: dict[str, dict], delay: float = 0.3, max_pages: int = 50,
) -> int:
"""把 UP 主主页的全部投稿补进视频登记表(按 BV 号去重),返回新增数。
优先走空间 API(WBI 签名);被风控拒绝时退回隐身浏览器翻页。
"""
bvids = fetch_up_all_bvids(session, mid, max_pages=max_pages)
if not bvids:
log(" ⚠️ 空间 API 无结果,改用浏览器翻页采集主页投稿")
try:
bvids = fetch_up_all_bvids_via_browser(mid, max_pages=max_pages)
except Exception as exc:
log(f" [WARN] 浏览器主页采集失败: {exc}")
return 0
new_count = 0
for bvid in bvids:
if bvid in registry:
continue
info = fetch_bili_video(f"https://www.bilibili.com/video/{bvid}",
session)
if info is None:
log(f" [WARN] 主页视频详情失败: {bvid}")
continue
registry[bvid] = info
new_count += 1
if delay > 0:
time.sleep(delay)
log(f" 🏠 主页全量采集({account_name}): 共 {len(bvids)} 条投稿, "
f"新增 {new_count} 条")
return new_count
def log(msg: str) -> None:
safe = str(msg).encode("gbk", errors="replace").decode("gbk", errors="replace")
print(f"[{datetime.now().strftime('%H:%M:%S')}] {safe}", flush=True)
# ============================================================
# B站登录态: Cookie 持久化 + 扫码登录引导
# 空间投稿列表需要登录态,首次用 --login-only 扫码,之后调度运行复用。
# ============================================================
BILI_COOKIE_FILE = PATHS.browser_cookie_file
def restore_cookies(page) -> None:
if not BILI_COOKIE_FILE.exists():
return
try:
cookies = json.loads(BILI_COOKIE_FILE.read_text(encoding="utf-8"))
page.context.add_cookies(cookies)
log(f"已载入持久化 Cookie: {BILI_COOKIE_FILE}")
except Exception as exc:
log(f"载入 Cookie 失败(忽略,继续用 Profile 登录态): {exc}")
def save_state(page) -> None:
BILI_COOKIE_FILE.parent.mkdir(parents=True, exist_ok=True)
cookies = page.context.cookies()
BILI_COOKIE_FILE.write_text(
json.dumps(cookies, ensure_ascii=False, indent=2), encoding="utf-8"
)
log(f"已保存 Cookie ({len(cookies)} 条): {BILI_COOKIE_FILE}")
def _has_login_cookies(page) -> bool:
names = {cookie.get("name") for cookie in page.context.cookies()}
return "SESSDATA" in names and "DedeUserID" in names
def login_only(login_timeout: int = 300) -> int:
"""打开 B站扫码登录页,等待人工扫码后保存 Cookie。"""
from scrapling.fetchers import StealthySession
with StealthySession(
headless=False,
real_chrome=True,
user_data_dir=str(PATHS.browser_profile_dir),
locale="zh-CN",
timezone_id="Asia/Shanghai",
timeout=60_000,
network_idle=False,
disable_resources=False,
google_search=False,
max_pages=2,
page_setup=restore_cookies,
) as session:
page = (
session.context.pages[0]
if session.context.pages
else session.context.new_page()
)
page.goto("https://passport.bilibili.com/login",
wait_until="domcontentloaded", timeout=60_000)
page.wait_for_timeout(2000)
if _has_login_cookies(page):
log("✅ 已有 B站登录态,无需扫码")
save_state(page)
return 0
print("\n" + "=" * 60)
print("🔐 请在打开的浏览器中用哔哩哔哩 App 扫码登录")
print(" 登录成功后脚本会自动继续并保存 Cookie")
print("=" * 60, flush=True)
deadline = time.time() + login_timeout
while time.time() < deadline:
if _has_login_cookies(page):
log("✅ 检测到登录成功")
page.wait_for_timeout(2000)
save_state(page)
return 0
page.wait_for_timeout(2000)
log(f"❌ 等待扫码超时({login_timeout}s),未保存登录态")
return 1
# ============================================================
# 飞书多维表格
# ============================================================
@@ -161,40 +543,91 @@ def resolve_b23(url: str, session: ScraplingHttpSession) -> str | None:
return None
def fetch_bili_stat(
url: str,
session: ScraplingHttpSession,
) -> tuple[int | None, int | None]:
"""调 B 站 API,返回 (play_count, pubdate_unix_seconds)。
pubdate 是视频发布时间(Unix 秒),用于补飞书的发布时间字段。
def fetch_bili_video(url: str, session: ScraplingHttpSession) -> dict | None:
"""调 B 站公开 API,返回该视频的全量信息(标题/UP主/时长/发布时间/全指标)。
返回 None 表示 URL 无 BV/AV 号或 API 失败。字段映射:
view→播放量, like/coin/favorite/share/danmaku/reply→互动指标。
"""
real = url
if _B23_RE.match(url):
resolved = resolve_b23(url, session)
if not resolved:
return None, None
return None
real = resolved
bv = _BV_RE.search(real)
av = _AV_RE.search(real)
if not bv and not av:
return None, None
params = {"bvid": bv.group(0)} if bv else {"aid": av.group(1)}
return None
bvid = bv.group(0) if bv else None
params = {"bvid": bvid} if bv else {"aid": av.group(1)}
try:
r = session.get("https://api.bilibili.com/x/web-interface/view",
params=params, timeout=10,
headers={"Referer": "https://www.bilibili.com/"})
j = r.json()
if j.get("code") != 0:
return None, None
return None
data = j.get("data", {})
stat = data.get("stat", {})
view = stat.get("view")
pubdate = data.get("pubdate")
return (int(view) if view is not None else None,
int(pubdate) if pubdate else None)
owner = data.get("owner", {}) or {}
video_id = bvid or f"av{data.get('aid') or av.group(1)}"
return {
"video_id": video_id,
"video_url": f"https://www.bilibili.com/video/{video_id}",
"title": str(data.get("title") or "").strip(),
"owner_name": str(owner.get("name") or "").strip(),
"owner_mid": str(owner.get("mid") or "").strip(),
"duration_seconds": data.get("duration"),
"publish_time": (
datetime.fromtimestamp(int(data["pubdate"]))
if data.get("pubdate")
else None
),
"view_count": stat.get("view"),
"like_count": stat.get("like"),
"coin_count": stat.get("coin"),
"favorite_count": stat.get("favorite"),
"share_count": stat.get("share"),
"danmaku_count": stat.get("danmaku"),
"reply_count": stat.get("reply"),
}
except Exception as exc:
log(f" [WARN] B 站 API: {exc}")
return None
def fetch_bili_stat(
url: str,
session: ScraplingHttpSession,
) -> tuple[int | None, int | None]:
"""调 B 站 API,返回 (play_count, pubdate_unix_seconds)。
兼容旧调用方;全量字段请用 fetch_bili_video。
pubdate 是视频发布时间(Unix 秒),用于补飞书的发布时间字段。
"""
info = fetch_bili_video(url, session)
if info is None:
return None, None
view = info.get("view_count")
pub = info.get("publish_time")
return (
int(view) if view is not None else None,
int(pub.timestamp()) if pub else None,
)
def format_duration(seconds) -> str:
"""B站 API 时长(秒)→ 'h:mm:ss'/'m:ss' 展示文本。"""
try:
total = int(seconds)
except (TypeError, ValueError):
return ""
hours, rem = divmod(total, 3600)
minutes, secs = divmod(rem, 60)
if hours:
return f"{hours}:{minutes:02d}:{secs:02d}"
return f"{minutes}:{secs:02d}"
# ============================================================
@@ -262,7 +695,8 @@ def pick_read_field(fmap: dict, pub_time: datetime | None,
# 采集一个款式
# ============================================================
def process_style(style: dict, dry_run: bool, delay: float,
session: ScraplingHttpSession) -> dict:
session: ScraplingHttpSession,
video_registry: dict | None = None) -> dict:
base_token = style["base_token"]
table_id = style["table_id"]
fmap = style.get("field_map", {})
@@ -320,12 +754,24 @@ def process_style(style: dict, dry_run: bool, delay: float,
summary["skipped_no_url"] += 1
continue
# 抓 B 站 API:播放量 + pubdate
play, pubdate_unix = fetch_bili_stat(url, session)
if play is None:
# 抓 B 站 API: 播放量 + pubdate(兼容旧时槽回填) + 全量指标登记
info = fetch_bili_video(url, session)
if info is None:
log(f" [SKIP] API 抓不到: '{title[:20]}' (url={url[:50]})")
continue
play = info.get("view_count")
pubdate_unix = (
int(info["publish_time"].timestamp())
if info.get("publish_time")
else None
)
if play is None:
log(f" [SKIP] 无播放量: '{title[:20]}'")
continue
summary["matched"] += 1
if video_registry is not None:
# 跨款式去重登记全量视频指标,供统一入库工具消费
video_registry[info["video_id"]] = info
# 飞书发布时间
pub_time = parse_publish_time(rec.get(pub_fid))
@@ -397,13 +843,20 @@ def process_style(style: dict, dry_run: bool, delay: float,
# ============================================================
def main() -> int:
parser = argparse.ArgumentParser(
description="自营 B 站采集: B 站公开 API 抓播放量,飞书无发布时间用 API pubdate 补")
description="自营 B 站采集: 公开 API 全量视频指标 + UP 主主页全量投稿 + 飞书曝光回填")
parser.add_argument("--style", type=int, help="只跑指定款式编号")
parser.add_argument("--dry-run", action="store_true", help="不写回飞书")
parser.add_argument("--login-only", action="store_true",
help="只打开 B站扫码登录并保存 Cookie(首次部署引导)")
parser.add_argument("--login-timeout", type=int, default=300,
help="等待人工扫码的秒数")
parser.add_argument("--delay", type=float, default=0.3,
help="每条请求间隔秒数(防风控)")
args = parser.parse_args()
if args.login_only:
return login_only(args.login_timeout)
from gyxx_flow.modules.content_marketing import feishu_mapping
try:
mapping = feishu_mapping.load_mapping(self_operated=True)
@@ -429,9 +882,11 @@ def main() -> int:
print("=" * 50)
all_summaries = []
video_registry: dict[str, dict] = {}
for s in styles:
log(f"\n--- [{s['index']:02d}] {s['name']} ---")
summary = process_style(s, args.dry_run, args.delay, session)
summary = process_style(s, args.dry_run, args.delay, session,
video_registry)
all_summaries.append(summary)
log(f" 小计: 任务={summary['total']} 命中={summary['matched']} 回填={summary['filled']}")
out_file = out_dir / f"{s['index']:02d}-{s['name']}_self_bilibili_v2.json"
@@ -442,6 +897,56 @@ def main() -> int:
with open(final_path, "w", encoding="utf-8") as f:
json.dump(all_summaries, f, ensure_ascii=False, indent=2)
# UP 主主页全量采集: 对账号配置里登记了 mid 的 B站账号,
# 拉取空间全部投稿并补进登记表(飞书款式表未登记的视频也覆盖)。
from gyxx_flow.modules.content_marketing import self_operated_accounts
for entry in self_operated_accounts.load_accounts(
"bilibili",
self_operated_accounts.accounts_config_path(PATHS.config_root),
):
mid = entry.get("platform_account_id", "").strip()
if not mid:
continue
try:
collect_up_homepage_videos(
session, mid, entry.get("account_name") or mid,
video_registry, delay=args.delay,
)
except Exception as exc:
log(f" [WARN] 主页全量采集失败({entry.get('account_name')}): {exc}")
# 全量视频指标 artifact: 供统一入库工具 sync_self_video_metrics.py 写
# cmt_self_video_metrics(跨款式按 BV 号去重,不受飞书记录是否登记限制)
business_date = os.environ.get("GYXX_BUSINESS_DATE", "").strip()
artifact = {
"platform": "bilibili",
"business_date": business_date or datetime.now().strftime("%Y-%m-%d"),
"generated_at": datetime.now().isoformat(),
"videos": sorted(
(
{
**video,
"publish_time": (
video["publish_time"].isoformat()
if video.get("publish_time")
else None
),
}
for video in video_registry.values()
),
key=lambda v: v["video_id"],
),
"styles_covered": [s["name"] for s in styles],
}
artifact_dir = PATHS.normalized_root / "self_bilibili"
artifact_dir.mkdir(parents=True, exist_ok=True)
artifact_path = artifact_dir / "daily.json"
tmp = artifact_path.with_suffix(artifact_path.suffix + ".tmp")
tmp.write_text(json.dumps(artifact, ensure_ascii=False, indent=2),
encoding="utf-8")
os.replace(tmp, artifact_path)
log(f"📦 全量视频指标 artifact: {artifact_path} ({len(video_registry)} 条视频)")
total = sum(s["total"] for s in all_summaries)
matched = sum(s["matched"] for s in all_summaries)
filled = sum(s["filled"] for s in all_summaries)
@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
"""自营账号配置: 内部账号别名 ↔ 各平台账号身份。
配置文件 data/config/self_operated_accounts.json 按平台分组:
douyin [{alias, account_name, platform_account_id}]
bilibili [{alias, account_name, platform_account_id?}]
xiaohongshu [{alias, account_name, platform_account_id?, phone?}]
- 别名口径与飞书自营表「投放账号类型」一致,写入 cmt_self_video_metrics.account_alias。
- 采集数据自带的账号身份(达人昵称/UP主名/店铺名/抖音号)优先,别名缺失时置空并提示补配置。
- xiaohongshu 的 phone 供 ARK 短信登录自动填手机号(环境变量 XHS_ARK_ACCOUNT 优先)。
"""
from __future__ import annotations
import json
from pathlib import Path
PLATFORMS = ("douyin", "bilibili", "xiaohongshu")
def accounts_config_path(config_root: Path) -> Path:
return Path(config_root) / "self_operated_accounts.json"
def load_accounts(platform: str, config_path: str | Path) -> list[dict]:
"""读取某平台的账号映射列表。
缺文件、平台未配置或结构无效时返回空列表(调用方按「未知账号」处理并提示补配置)。
"""
if platform not in PLATFORMS:
raise ValueError(f"unknown self-operated platform: {platform}")
path = Path(config_path)
if not path.exists():
return []
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
print(f"⚠️ 自营账号配置读取失败({path}): {exc}")
return []
accounts = data.get(platform) if isinstance(data, dict) else None
if not isinstance(accounts, list):
return []
return [
{
"alias": str(item.get("alias") or "").strip(),
"account_name": str(item.get("account_name") or "").strip(),
"platform_account_id": str(item.get("platform_account_id") or "").strip(),
"phone": str(item.get("phone") or "").strip(),
}
for item in accounts
if isinstance(item, dict) and str(item.get("alias") or "").strip()
]
def load_all_accounts(config_path: str | Path) -> dict[str, list[dict]]:
"""一次读全三平台,供统一入库工具使用。"""
return {platform: load_accounts(platform, config_path) for platform in PLATFORMS}
def resolve_alias(
accounts: list[dict],
*,
account_name: str = "",
platform_account_id: str = "",
) -> str:
"""按账号名或平台账号ID(大小写不敏感)解析内部账号别名;未命中返回空串。"""
name_key = str(account_name or "").strip().casefold()
id_key = str(platform_account_id or "").strip().casefold()
for entry in accounts:
if not name_key and not id_key:
break
if (name_key and entry["account_name"].casefold() == name_key) or (
id_key and entry["platform_account_id"].casefold() == id_key
):
return entry["alias"]
return ""
def find_account(
platform: str,
config_path: str | Path,
*,
account_name: str = "",
platform_account_id: str = "",
) -> dict | None:
"""返回第一个命中的账号配置(含 phone 等附加字段);未命中返回 None。"""
accounts = load_accounts(platform, config_path)
name_key = str(account_name or "").strip().casefold()
id_key = str(platform_account_id or "").strip().casefold()
for entry in accounts:
if (name_key and entry["account_name"].casefold() == name_key) or (
id_key and entry["platform_account_id"].casefold() == id_key
):
return entry
return None
@@ -0,0 +1,968 @@
# -*- coding: utf-8 -*-
"""
自营小红书 ARK 笔记管理采集
================================
- 登录: customer.xiaohongshu.com 短信验证码登录(手机号自动填入,验证码由人工
在浏览器中输入后脚本自动继续);登录态优先复用隔离 Profile 与持久化 Cookie。
- 采集: ark.xiaohongshu.com/app-note/creation?tab=management 下的
「商品笔记」(managementTab=goods) 与「普通笔记」(managementTab=normal) 两个页签,
按表头动态解析全部展示列(阅读数/点赞数/引流次数/支付金额/订单数等),
逐页翻页抓取并按笔记身份去重。
- 产物: normalized/content_marketing/self_xhs/daily.json,
由 data/tools/sync_self_video_metrics.py 幂等写入 PostgreSQL cmt_self_video_metrics。
- 浏览器: Scrapling StealthySession(隐藏指纹), 独立 CDP 端口与 Profile/Cookie
由 runtime-bindings 注入(GYXX_BROWSER_* 环境变量)。
用法:
python self_xhs_scraper.py # 登录(必要时等待人工验证码)+采集
python self_xhs_scraper.py --login-only # 只登录并保存 Cookie(首次部署引导)
python self_xhs_scraper.py --dry-run # 采集但不写 artifact
python self_xhs_scraper.py --login-timeout 300
"""
import argparse
import hashlib
import json
import os
import re
import sys
import time
from datetime import datetime
from scrapling.fetchers import StealthySession
from gyxx_flow.adapters.scrapling import (
BrowserTimeoutError, # noqa: F401
is_browser_timeout_error,
)
from gyxx_flow.core.exit_codes import COOKIE_SKIP_EXIT_CODE
from gyxx_flow.modules.content_marketing import self_operated_accounts
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
# 模块级 seam: 测试可替换会话工厂(保持与 pgy_xhs_scraper_v2 相同的约定)。
DynamicSession = StealthySession
LOGIN_URL = (
"https://customer.xiaohongshu.com/login"
"?service=https://ark.xiaohongshu.com/app-note/creation%3Ftab%3Dcreation"
)
GOODS_URL = (
"https://ark.xiaohongshu.com/app-note/creation"
"?tab=management&from=ark-login&managementTab=goods"
)
NORMAL_URL = (
"https://ark.xiaohongshu.com/app-note/creation"
"?tab=management&from=ark-login&managementTab=normal"
)
COOKIE_FILE = PATHS.browser_cookie_file
PROFILE_DIR = PATHS.browser_profile_dir
ARTIFACT_PATH = PATHS.normalized_root / "self_xhs" / "daily.json"
ACCOUNTS_CONFIG = self_operated_accounts.accounts_config_path(PATHS.config_root)
DEFAULT_LOGIN_TIMEOUT = 300
DEFAULT_MAX_PAGES = 50
LIST_READY_TIMEOUT_MS = 30_000
# 小红书笔记 ID: 24 位十六进制(explore/、discovery/item/、个人主页 note/ 等路径)
_XHS_NOTE_ID_RE = re.compile(
r"(?:xiaohongshu\.com/(?:explore|discovery/item|user/profile/[0-9a-f]+/note)"
r"|xhslink\.com/[A-Za-z0-9]+|/note)"
r"/?([0-9a-f]{24})(?![0-9a-f])",
re.IGNORECASE,
)
_DATE_LINE_RE = re.compile(r"20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}")
# 「笔记信息」单元格内的创作方式标签(整行精确匹配,避免误伤含这些字样的标题)
_CREATION_METHODS = {"手动创作", "可能是AI创作", "AI创作", "优质创作", "共创"}
class AcceptanceCookieSkip(RuntimeError):
"""验收模式下不允许交互式短信登录,安全跳过。"""
def log(msg: str) -> None:
safe = str(msg).encode("gbk", errors="replace").decode("gbk", errors="replace")
print(f"[{datetime.now().strftime('%H:%M:%S')}] {safe}", flush=True)
# ============================================================
# 数值/单元格解析(纯函数,供单测)
# ============================================================
def parse_count(value: str | None) -> int | None:
"""'1.2万' / '6,592' / '2422' → int; '—'/'-'/'暂无'/空 → None。"""
if value is None:
return None
text = str(value).strip()
if not text or text in {"-", "--", "—", "暂无"}:
return None
match = re.fullmatch(r"([\d,]+(?:\.\d+)?)\s*(万|w|W)?", text)
if not match:
return None
try:
number = float(match.group(1).replace(",", ""))
except ValueError:
return None
if match.group(2):
number *= 10000
return int(number)
def parse_amount(value: str | None) -> float | None:
"""金额 '1,234.56' / '0.9万' → float(两位小数);不经 int 截断。"""
if value is None:
return None
text = str(value).strip()
if not text or text in {"-", "--", "—", "暂无"}:
return None
match = re.fullmatch(r"([\d,]+(?:\.\d+)?)\s*(万|w|W)?", text)
if not match:
return None
try:
number = float(match.group(1).replace(",", ""))
except ValueError:
return None
if match.group(2):
number *= 10000
return round(number, 2)
def _find_col(headers: list[str], candidates: list[str]) -> int | None:
"""先整列精确匹配,再子串匹配(与 chanmama_export 一致)。"""
normalized = [str(header or "").strip() for header in headers]
for candidate in candidates:
for index, header in enumerate(normalized):
if header == candidate:
return index
for candidate in candidates:
for index, header in enumerate(normalized):
if candidate in header:
return index
return None
def _parse_info_cell(text: str) -> tuple[str, str, str]:
"""「笔记信息」单元格 → (标题, 发布时间文本, 创作方式)。
单元格内是 标题/发布时间/创作方式 分行文本;发布时间行带「发布时间:」前缀;
单元格被压成单行时按「标题 发布时间:… [方式]」兜底拆分。
"""
raw = str(text or "").strip()
if not raw:
return "", "", ""
if "\n" in raw:
publish = ""
method = ""
title = ""
for line in (part.strip() for part in raw.splitlines()):
if not line:
continue
if not publish and _DATE_LINE_RE.search(line):
publish = re.sub(r"^发布时间[::]?\s*", "", line).strip()
continue
if not method and line in _CREATION_METHODS:
method = line
continue
if not title:
title = line
return title, publish, method
compact = re.sub(r"\s+", " ", raw)
match = re.match(
r"^(.*?)\s*发布时间[::]\s*"
r"(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?)"
r"\s*(.*)$",
compact,
)
if not match:
return compact, "", ""
title = match.group(1).strip()
publish = match.group(2).strip()
rest = match.group(3).strip()
method = rest if rest in _CREATION_METHODS else next(
(token for token in _CREATION_METHODS if token and token in rest), ""
)
return title, publish, method
def extract_note_id(href: str) -> str | None:
"""从小红书链接提取 24 位笔记 ID。"""
if not href:
return None
match = _XHS_NOTE_ID_RE.search(str(href))
return match.group(1) if match else None
def fallback_note_fingerprint(title: str, publish_time_text: str) -> str:
"""无链接时用 标题+发布时间 生成跨日稳定的内容指纹(前缀 xhs-)。"""
basis = f"{title.strip()}|{publish_time_text.strip()}"
return "xhs-" + hashlib.sha256(basis.encode("utf-8")).hexdigest()[:16]
def parse_management_rows(
headers: list[str],
rows: list[dict],
note_type: str,
) -> tuple[list[dict], dict[str, int]]:
"""把一个页签的表头+原始行解析为标准化笔记记录。
rows: [{cells: [单元格文本...], href: 行内笔记链接或 ''}]
返回 (records, stats);每条记录含建模字段 + raw_columns(页面展示的全部列)。
"""
stats = {"rows": len(rows), "records": 0, "skip_no_title": 0}
info_col = _find_col(headers, ["笔记信息"])
read_col = _find_col(headers, ["笔记阅读数", "阅读数"])
like_col = _find_col(headers, ["点赞数"])
mounted_col = _find_col(headers, ["挂载商品"])
pay_amount_col = _find_col(headers, ["笔记支付金额", "支付金额"])
pay_orders_col = _find_col(headers, ["笔记支付订单数", "支付订单数"])
home_col = _find_col(headers, ["引流主页次数"])
live_col = _find_col(headers, ["引流直播间次数"])
status_col = _find_col(headers, ["发布状态"])
def cell(row: dict, index: int | None) -> str:
if index is None or index >= len(row.get("cells", [])):
return ""
return str(row["cells"][index] or "").strip()
records: list[dict] = []
for row in rows:
cells = row.get("cells", [])
title, publish_text, method = _parse_info_cell(cell(row, info_col))
if not title:
stats["skip_no_title"] += 1
continue
note_id = extract_note_id(row.get("href") or "")
record = {
"note_type": note_type,
"note_id": note_id,
"video_id": note_id or fallback_note_fingerprint(title, publish_text),
"title": title[:512],
"publish_time": publish_text,
"creation_method": method,
"mounted_products": cell(row, mounted_col),
"read_count": parse_count(cell(row, read_col)),
"like_count": parse_count(cell(row, like_col)),
"homepage_redirect_count": parse_count(cell(row, home_col)),
"live_redirect_count": parse_count(cell(row, live_col)),
"payment_amount": parse_amount(cell(row, pay_amount_col)),
"payment_orders": parse_count(cell(row, pay_orders_col)),
"publish_status": cell(row, status_col),
"note_url": row.get("href") or "",
"raw_columns": {
header: (cells[index] if index < len(cells) else "")
for index, header in enumerate(headers)
if header
},
}
records.append(record)
stats["records"] += 1
return records, stats
# ============================================================
# 浏览器: Cookie 持久化 / 登录
# ============================================================
def restore_cookies(page) -> None:
if not COOKIE_FILE.exists():
return
try:
cookies = json.loads(COOKIE_FILE.read_text(encoding="utf-8"))
page.context.add_cookies(cookies)
log(f"已载入持久化 Cookie: {COOKIE_FILE}")
except Exception as exc:
log(f"载入 Cookie 失败(忽略,继续用 Profile 登录态): {exc}")
def save_state(page) -> None:
COOKIE_FILE.parent.mkdir(parents=True, exist_ok=True)
cookies = page.context.cookies()
COOKIE_FILE.write_text(
json.dumps(cookies, ensure_ascii=False, indent=2), encoding="utf-8"
)
log(f"已保存 Cookie ({len(cookies)} 条): {COOKIE_FILE}")
def is_logged_in(page) -> bool:
url = str(page.url)
if "customer.xiaohongshu.com/login" in url:
return False
if url.startswith("https://ark.xiaohongshu.com/"):
return True
# 登录页可能在弹层内,未跳转
try:
body = page.content()
return "请输入手机号" not in body and "短信登录" not in body
except Exception:
return False
def _fill_sms_login(page, phone: str) -> bool:
"""在 customer.xiaohongshu.com 登录页填手机号并点击发送验证码。"""
try:
page.wait_for_selector(
"input[placeholder*='手机号'], input[type='tel']", timeout=10_000
)
except Exception as exc:
if is_browser_timeout_error(exc):
log("⚠️ 未找到手机号输入框,登录页结构可能已变化")
return False
raise
filled = False
for selector in ("input[placeholder*='手机号']", "input[type='tel']"):
try:
box = page.locator(selector).first
if box.count():
box.fill(phone)
filled = True
break
except Exception:
continue
if not filled:
log("⚠️ 手机号输入失败")
return False
log(f"✅ 已填入手机号: {phone[:3]}****{phone[-4:]}")
# 点击发送验证码(按钮文案多变,依次尝试)
for text in ("获取验证码", "发送验证码", "获取短信验证码", "发送"):
try:
button = page.get_by_text(text, exact=False).first
if button.count() and button.is_visible():
button.click(timeout=3000)
log(f"✅ 已点击「{text}」,请查收短信")
return True
except Exception:
continue
log("⚠️ 未找到发送验证码按钮(可能有前置滑块,请在浏览器中手动完成)")
return True # 手机号已填,让用户在浏览器里继续操作
def wait_for_login(page, timeout_seconds: int) -> None:
print("\n" + "=" * 60)
print("🔐 请在打开的浏览器中完成短信验证码登录(如需滑块也请手动完成)")
print(" 登录成功后脚本会自动继续,无需操作控制台")
print("=" * 60, flush=True)
deadline = time.time() + timeout_seconds
while time.time() < deadline:
if is_logged_in(page):
log("✅ 检测到登录成功")
return
remaining = int(deadline - time.time())
if remaining % 30 == 0:
log(f"⏳ 等待登录完成... 剩余 {remaining}s")
page.wait_for_timeout(2000)
raise TimeoutError(f"Login timed out after {timeout_seconds} seconds.")
def ensure_login(page, login_timeout: int, phone: str) -> None:
page.goto(GOODS_URL, wait_until="domcontentloaded", timeout=60_000)
page.wait_for_timeout(3000)
if is_logged_in(page):
log("✅ Profile/Cookie 登录态有效")
save_state(page)
return
from gyxx_flow.adapters.acceptance_policy import current_acceptance_policy
acceptance_policy = current_acceptance_policy()
if acceptance_policy.enabled:
reason = (
"persisted ARK session is invalid; interactive SMS login is disabled"
)
acceptance_policy.record(
"cookie_skipped",
operation="content.self_xhs.login",
details={"status": "SKIPPED_COOKIE", "reason": reason},
)
log(f"[SKIPPED_COOKIE] {reason}")
raise AcceptanceCookieSkip(reason)
log("🔄 登录态失效,进入短信验证码登录流程")
page.goto(LOGIN_URL, wait_until="domcontentloaded", timeout=60_000)
page.wait_for_timeout(2000)
# 确保在「短信登录」页签
for text in ("短信登录",):
try:
tab = page.get_by_text(text, exact=True).first
if tab.count() and tab.is_visible():
tab.click(timeout=2000)
page.wait_for_timeout(800)
break
except Exception:
continue
_fill_sms_login(page, phone)
wait_for_login(page, login_timeout)
save_state(page)
page.goto(GOODS_URL, wait_until="domcontentloaded", timeout=60_000)
page.wait_for_timeout(2000)
# ============================================================
# 页面表格抓取
# ============================================================
_EXTRACT_TABLE_SCRIPT = r"""
() => {
const visible = (el) => {
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none' && style.visibility !== 'hidden'
&& style.opacity !== '0' && rect.width > 0 && rect.height > 0;
};
// 保留行结构(单元格内 标题/发布时间/创作方式 分行),只压缩行内空白
const clean = (v) => String(v || '').split('\n')
.map((line) => line.replace(/\s+/g, ' ').trim())
.filter(Boolean).join('\n');
const tables = [...document.querySelectorAll('table')].filter(visible);
const table = tables.find((t) => /笔记信息/.test(t.innerText || '')) || null;
if (!table) return {found: false, headers: [], rows: []};
const headers = [...table.querySelectorAll('thead th, thead td')]
.map((th) => clean(th.innerText).replace(/\n/g, ' '));
const rows = [...table.querySelectorAll('tbody tr')].filter(visible).map((tr) => {
const link = tr.querySelector(
'a[href*="/explore/"], a[href*="/discovery/item/"], '
+ 'a[href*="xhslink.com"], a[href*="/note/"]'
);
return {
cells: [...tr.querySelectorAll('td')].map((td) => clean(td.innerText)),
href: link ? link.href : ''
};
});
return {found: true, headers, rows};
}
"""
def extract_table(page) -> dict:
"""从当前页提取 {found, headers, rows};失败时 found=False。"""
try:
return page.evaluate(_EXTRACT_TABLE_SCRIPT) or {}
except Exception as exc:
log(f"⚠️ 表格提取脚本失败: {exc}")
return {"found": False, "headers": [], "rows": []}
def _next_button(page):
"""ARK 分页: .d-pagination-page 项,首尾空文本项是翻页箭头,末位为下一页。"""
try:
items = page.locator(".d-pagination-page").all()
arrows = [item for item in items if not (item.inner_text() or "").strip()]
if arrows:
return arrows[-1]
except Exception:
pass
for selector in (
".el-pagination .btn-next",
"button.btn-next",
"button[aria-label*='下一页']",
):
try:
for element in page.locator(selector).all():
if element.is_visible():
return element
except Exception:
continue
try:
button = page.get_by_text("下一页", exact=True).first
if button.count() and button.is_visible():
return button
except Exception:
pass
return None
def _button_disabled(element) -> bool:
try:
css = str(element.get_attribute("class") or "").lower()
except Exception:
return True
if "disabled" in css:
return True
try:
if not element.is_enabled():
return True
except Exception:
return True
aria = str(element.get_attribute("aria-disabled") or "").lower()
return aria == "true"
def collect_tab(page, url: str, tab_label: str, note_type: str,
max_pages: int) -> list[dict]:
"""抓取一个页签的全部分页,返回去重后的记录列表。"""
page.goto(url, wait_until="domcontentloaded", timeout=60_000)
page.wait_for_timeout(3000)
data = extract_table(page)
if not data.get("found"):
# URL 页签参数可能变化,退回点击页签文字
log(f"⚠️ URL 未直接命中「{tab_label}」,尝试点击页签")
try:
tab = page.get_by_text(tab_label, exact=True).first
if tab.count():
tab.click(timeout=3000)
page.wait_for_timeout(2500)
except Exception as exc:
log(f"⚠️ 点击页签「{tab_label}」失败: {exc}")
data = extract_table(page)
if not data.get("found"):
log(f"❌ 「{tab_label}」未找到笔记表格")
return []
records: dict[str, dict] = {}
stats_total = {"rows": 0, "records": 0, "skip_no_title": 0}
for page_number in range(1, max_pages + 1):
data = extract_table(page)
if not data.get("found"):
break
page_records, stats = parse_management_rows(
data.get("headers", []), data.get("rows", []), note_type
)
for key in stats_total:
stats_total[key] += stats.get(key, 0)
before = len(records)
for record in page_records:
records[record["video_id"]] = record
log(
f"📄 {tab_label} 第 {page_number} 页: {stats['rows']} 行 → "
f"{stats['records']} 条,累计去重 {len(records)} 条"
+ ("" if len(records) > before else " (本页无新增)")
)
button = _next_button(page)
if button is None or _button_disabled(button):
log(f"📄 {tab_label} 已到最后一页")
break
try:
button.click()
page.wait_for_timeout(2500)
except Exception as exc:
log(f"⚠️ 翻页失败,停止: {exc}")
break
log(
f"📊 {tab_label} 完成: 行={stats_total['rows']} 记录={stats_total['records']}"
f" 去重={len(records)} 无标题跳过={stats_total['skip_no_title']}"
)
return list(records.values())
def _open_page(session):
"""从会话上下文取一个页面(优先复用已有页面)。"""
context = session.context
pages = getattr(context, "pages", None) or []
return pages[0] if pages else context.new_page()
def _detect_shop_name(page) -> str:
"""尽力识别页面顶部的店铺名(如「店铺 光影行星 GYXX旗舰店」)。"""
try:
text = page.evaluate(
"() => (document.body && document.body.innerText) || ''"
)
match = re.search(r"店铺\s*\n?\s*([^\n]{2,30})", str(text or ""))
return match.group(1).strip() if match else ""
except Exception:
return ""
# ============================================================
# 飞书自营表回填: 按标题核对,阅读数写入对应发布时间槽位列
# (与 chanmama_scraper/self_bilibili_scraper 同一套时槽语义)
# ============================================================
LARK_CLI = os.path.expandvars(r"%APPDATA%\npm\lark-cli.cmd")
if not os.path.exists(LARK_CLI):
LARK_CLI = "lark-cli.cmd"
V2_DIR = PATHS.normalized_root / "v2_results"
def _call_lark_json(args: list[str]) -> dict:
import subprocess
cmd = [LARK_CLI] + args
env = {**os.environ, "LARK_CLI_NO_PROXY": "1"}
proc = subprocess.run(cmd, capture_output=True, env=env,
encoding="utf-8", errors="replace")
text = proc.stdout if proc.stdout else (proc.stderr or "")
start = text.find("{")
if start < 0:
return {"ok": False, "error": f"no json: {text[:300]}"}
try:
return json.loads(text[start:], strict=False)
except json.JSONDecodeError as exc:
return {"ok": False, "error": f"json decode: {exc}"}
def _list_records_by_table(base_token: str, table_id: str) -> list[dict]:
all_records = []
offset = 0
while True:
resp = _call_lark_json([
"base", "+record-list",
"--base-token", base_token,
"--table-id", table_id,
"--as", "user",
"--format", "json",
"--limit", "200",
"--offset", str(offset),
])
if not resp.get("ok"):
raise RuntimeError(f"record-list 失败: {resp.get('error')}")
payload = resp["data"]
field_ids = payload["field_id_list"]
record_ids = payload["record_id_list"]
rows = payload["data"]
for index, row in enumerate(rows):
record = {"record_id": record_ids[index]}
for column, field_id in enumerate(field_ids):
record[field_id] = row[column] if column < len(row) else None
all_records.append(record)
offset += len(rows)
if not payload.get("has_more", False) or not rows:
break
return all_records
def _write_back(base_token: str, table_id: str, record_id: str,
field_id: str, value) -> bool:
from gyxx_flow.adapters.acceptance_policy import skip_feishu_table_write
if skip_feishu_table_write(
"content_marketing.self_xhs.record-upsert",
details={"table_id": table_id, "record_id": record_id,
"field_id": field_id},
):
log(f" [ACCEPTANCE-SKIP] record_id={record_id}")
return True
payload = {field_id: value}
resp = _call_lark_json([
"base", "+record-upsert",
"--base-token", base_token,
"--table-id", table_id,
"--record-id", record_id,
"--as", "user",
"--format", "json",
"--json", json.dumps(payload, ensure_ascii=False),
])
if not resp.get("ok"):
log(f" ❌ 回填失败 record_id={record_id}: {resp.get('error')}")
return False
return True
def _pick_read_field(fmap: dict, pub_time, today=None):
"""发布时间距今天数 → 对应曝光槽位字段(7/14/21/28天/月底)。"""
if pub_time is None:
return None
today = today or datetime.now()
days = (today.date() - pub_time.date()).days
if days < 0:
return "FUTURE"
if days <= 7:
logical = "read_count_7d"
elif days <= 14:
logical = "read_count_14d"
elif days <= 21:
logical = "read_count_21d"
elif days <= 28:
logical = "read_count_28d"
else:
logical = "month_end"
info = fmap.get(logical)
if info:
return (logical, info["field_id"], info["field_name"])
return None
_TITLE_STRIP_RE = re.compile(
r"[\s \-_.,;:!?!?,。;:、/\\()()【】《》''\"\"'']+"
)
_EMOJI_RE = re.compile(
"[\U0001F000-\U0001FFFF\U00002600-\U000027BF\U0001F1E0-\U0001F1FF]+"
)
def _title_match(haystack: str, needle: str) -> bool:
"""模糊标题匹配: 剥链接/URL/空白标点/emoji 后,任一方包含另一方即命中。"""
def normalize(value: str) -> str:
value = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", str(value or ""))
value = re.sub(r"https?://[^\s))\]]+", "", value)
value = _TITLE_STRIP_RE.sub("", value)
return _EMOJI_RE.sub("", value)
left = normalize(haystack)
right = normalize(needle)
if not left or not right:
return False
return right in left or left in right
def backfill_self_xhs_tables(notes: list[dict], dry_run: bool = False) -> bool:
"""按标题把小红书阅读数回填到各款式自营表的对应曝光槽位。
只处理「投放平台」明确为小红书的行,避免同名标题误写抖音/B站行;
飞书无发布时间时先用采集到的发布时间补,再做时槽判断。
同时产出 {idx:02d}-{name}_self_v2.json 供曝光入库步骤消费。
"""
from gyxx_flow.modules.content_marketing import feishu_mapping
from gyxx_flow.modules.content_marketing.chanmama_export import (
parse_publish_time,
)
try:
mapping = feishu_mapping.load_mapping(self_operated=True)
except Exception as exc:
log(f"❌ 加载自营 mapping 失败: {exc}")
return False
styles = mapping.get("tables") if isinstance(mapping, dict) else None
if not isinstance(styles, list) or not styles:
log("❌ 自营 mapping 未配置任何目标表")
return False
def is_xhs(value) -> bool:
if value is None:
return False
if isinstance(value, list):
values = [str(item).strip().casefold() for item in value if item]
return any("小红书" in v or "xhs" in v for v in values)
text = str(value).strip().casefold()
return "小红书" in text or "xhs" in text
total_tasks = total_matched = total_filled = 0
had_failure = False
today = datetime.now()
V2_DIR.mkdir(parents=True, exist_ok=True)
for style in styles:
fmap = style.get("field_map", {})
platform_fid = (fmap.get("platform") or {}).get("field_id")
title_fid = (fmap.get("note_title") or {}).get("field_id")
pub_fid = (fmap.get("publish_time") or {}).get("field_id")
if not platform_fid or not title_fid:
continue
try:
records = _list_records_by_table(style["base_token"],
style["table_id"])
except Exception as exc:
log(f" [ERR] 款式 {style['name']} 拉记录失败: {exc}")
had_failure = True
continue
xhs_records = [r for r in records if is_xhs(r.get(platform_fid))]
log(f" 款式 {style['name']}: 全表 {len(records)} 条,小红书 "
f"{len(xhs_records)} 条")
style_results = []
for record in xhs_records:
feishu_title = record.get(title_fid)
if isinstance(feishu_title, list):
feishu_title = " ".join(str(x) for x in feishu_title if x)
feishu_title = str(feishu_title or "").strip()
if not feishu_title:
continue
total_tasks += 1
matched = next(
(n for n in notes if _title_match(feishu_title, n["title"])),
None,
)
if not matched:
continue
total_matched += 1
pub_time = parse_publish_time(record.get(pub_fid))
if pub_time is None and not dry_run and pub_fid:
note_pub = parse_publish_time(matched.get("publish_time"))
if note_pub is not None:
ts_ms = int(note_pub.timestamp() * 1000)
if _write_back(style["base_token"], style["table_id"],
record["record_id"], pub_fid, ts_ms):
log(f" ✅ '{feishu_title[:20]}' → 先填发布时间")
else:
had_failure = True
pub_time = note_pub
read_count = matched.get("read_count")
if read_count is None:
log(f" [SKIP] 阅读数为空: '{feishu_title[:20]}'")
continue
pick = _pick_read_field(fmap, pub_time, today)
if pick == "FUTURE":
continue
if not pick:
log(f" [SKIP] 无对应曝光槽位字段: '{feishu_title[:20]}'")
continue
logical, fill_fid, fill_fname = pick
if dry_run:
log(f" [DRY] '{feishu_title[:20]}' → {fill_fname}={read_count}")
else:
if _write_back(style["base_token"], style["table_id"],
record["record_id"], fill_fid, read_count):
log(f" ✅ '{feishu_title[:20]}' → {fill_fname}={read_count}")
else:
had_failure = True
continue
total_filled += 1
style_results.append({
"record_id": record["record_id"],
"creator": "",
"target": feishu_title,
"play_count": read_count,
"matched": True,
"slot": logical,
"pub_time": pub_time.isoformat() if pub_time else None,
})
if not dry_run:
summary = {
"style": style["name"],
"index": style["index"],
"total": len(xhs_records),
"matched": len(style_results),
"filled": len(style_results),
"results": style_results,
}
v2_file = V2_DIR / f"{style['index']:02d}-{style['name']}_self_v2.json"
tmp = v2_file.with_suffix(v2_file.suffix + ".tmp")
tmp.write_text(json.dumps(summary, ensure_ascii=False, indent=2),
encoding="utf-8")
os.replace(tmp, v2_file)
log(f"📊 飞书回填完成: 任务={total_tasks} 命中={total_matched} "
f"回填={total_filled} (dry_run={dry_run})")
return not had_failure
# ============================================================
# main
# ============================================================
def collect(headless: bool, login_timeout: int, max_pages: int,
dry_run: bool) -> int:
account = (
self_operated_accounts.load_accounts("xiaohongshu", ACCOUNTS_CONFIG)
or [{}]
)[0]
phone = (
os.environ.get("XHS_ARK_ACCOUNT", "").strip()
or account.get("phone", "")
)
if not phone:
log("❌ 缺少登录手机号: 请设置环境变量 XHS_ARK_ACCOUNT 或在 "
"self_operated_accounts.json 的 xiaohongshu 段配置 phone")
return 1
config_name = account.get("account_name", "")
with DynamicSession(
headless=headless,
real_chrome=True,
user_data_dir=str(PROFILE_DIR),
locale="zh-CN",
timezone_id="Asia/Shanghai",
timeout=60_000,
network_idle=False,
disable_resources=False,
google_search=False,
page_setup=restore_cookies,
max_pages=3,
) as session:
page = _open_page(session)
ensure_login(page, login_timeout, phone)
shop_name = _detect_shop_name(page)
account_name = config_name or shop_name
log(f"🏪 店铺: {account_name or '(未识别,用配置名兜底)'}")
goods_records = collect_tab(page, GOODS_URL, "商品笔记", "goods", max_pages)
normal_records = collect_tab(page, NORMAL_URL, "普通笔记", "normal", max_pages)
business_date = os.environ.get("GYXX_BUSINESS_DATE", "").strip()
artifact = {
"platform": "xiaohongshu",
"business_date": business_date or datetime.now().strftime("%Y-%m-%d"),
"generated_at": datetime.now().isoformat(),
"account_name": account_name,
"shop_name_detected": shop_name,
"notes": goods_records + normal_records,
"counts": {
"goods": len(goods_records),
"normal": len(normal_records),
},
}
log(
f"📊 采集完成: 商品笔记 {len(goods_records)} 条, 普通笔记 "
f"{len(normal_records)} 条"
)
if dry_run:
log("[DRY-RUN] 不写 artifact,不回填飞书")
return 0
ARTIFACT_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = ARTIFACT_PATH.with_suffix(ARTIFACT_PATH.suffix + ".tmp")
tmp.write_text(json.dumps(artifact, ensure_ascii=False, indent=2),
encoding="utf-8")
os.replace(tmp, ARTIFACT_PATH)
log(f"📦 artifact: {ARTIFACT_PATH}")
# 全量指标已入库侧留存;此处按标题核对把阅读数回填飞书自营表槽位
backfill_ok = backfill_self_xhs_tables(goods_records + normal_records)
if not backfill_ok:
log("⚠️ 飞书回填存在失败项")
return 1
return 0
def login_only(headless: bool, login_timeout: int) -> int:
account = (
self_operated_accounts.load_accounts("xiaohongshu", ACCOUNTS_CONFIG)
or [{}]
)[0]
phone = (
os.environ.get("XHS_ARK_ACCOUNT", "").strip()
or account.get("phone", "")
)
if not phone:
log("❌ 缺少登录手机号(同 collect)")
return 1
with DynamicSession(
headless=headless,
real_chrome=True,
user_data_dir=str(PROFILE_DIR),
locale="zh-CN",
timezone_id="Asia/Shanghai",
timeout=60_000,
network_idle=False,
disable_resources=False,
google_search=False,
page_setup=restore_cookies,
max_pages=2,
) as session:
page = _open_page(session)
ensure_login(page, login_timeout, phone)
log("✅ 登录态已建立并保存 Cookie")
return 0
def main() -> int:
parser = argparse.ArgumentParser(
description="自营小红书 ARK 笔记管理采集(商品笔记+普通笔记)")
parser.add_argument("--login-only", action="store_true",
help="只登录并保存 Cookie(首次部署引导)")
parser.add_argument("--dry-run", action="store_true",
help="采集但不写 artifact")
parser.add_argument("--login-timeout", type=int,
default=DEFAULT_LOGIN_TIMEOUT,
help="等待人工完成短信验证码的秒数")
parser.add_argument("--max-pages", type=int, default=DEFAULT_MAX_PAGES,
help="每个页签最多翻页数")
parser.add_argument("--headless", action="store_true",
help="无头模式(登录需要人工输入验证码时不建议)")
args = parser.parse_args()
try:
if args.login_only:
return login_only(args.headless, args.login_timeout)
return collect(args.headless, args.login_timeout, args.max_pages,
args.dry_run)
except AcceptanceCookieSkip:
return COOKIE_SKIP_EXIT_CODE
except KeyboardInterrupt:
print("\n⛔ 用户中断", file=sys.stderr)
return 130
def cli() -> None:
raise SystemExit(main())
if __name__ == "__main__":
cli()
@@ -14,6 +14,7 @@ PRODUCT_SCHEDULED_WORKFLOW_IDS = (
"product.persona.daily",
"product.daily",
"product.alert.daily",
"product.online_stock_alert.daily",
"product.ecommerce_costs.daily",
"product.style_analysis.interval",
"product.main_image.weekly",
@@ -19,6 +19,7 @@ import uuid
from collections import defaultdict
from dataclasses import dataclass
from datetime import date, timedelta
from html import unescape
from pathlib import Path
from typing import Any
@@ -79,6 +80,7 @@ BASE_WRITE_RECONCILE_DELAYS_SECONDS = (0, 2, 5)
# Keep reports produced before the reasoning/language gate isolated from the
# corrected direct-MiniMax checkpoint chain.
CHECKPOINT_NAMESPACE = "direct_minimax_v2"
DOC_CREATE_RECONCILE_DELAYS_SECONDS = (0, 2, 5)
# ── data extraction ────────────────────────────────────────────────────────
@@ -966,6 +968,97 @@ def _validate_checkpoint(
return payload
def _normalize_doc_search_title(value: Any) -> str:
"""Normalize the highlighted title returned by Feishu document search."""
without_markup = re.sub(r"<[^>]+>", "", str(value or ""))
return re.sub(r"\s+", " ", unescape(without_markup)).strip()
def _analysis_doc_search_args(title: str) -> list[str]:
return [
"docs", "+search", "--as", "user", "--query", title,
"--page-size", "20", "--format", "json",
]
def _find_analysis_doc(title: str) -> dict[str, str] | None:
"""Find an exact report title without creating or changing any document."""
response = run_lark_cli(_analysis_doc_search_args(title))
data = response.get("data") or {}
if not isinstance(data, dict):
return None
results = data.get("results") or []
if not isinstance(results, list):
return None
for result in results:
if not isinstance(result, dict):
continue
metadata = result.get("result_meta") or {}
if not isinstance(metadata, dict):
metadata = {}
title_candidates = (
result.get("title"),
result.get("title_highlighted"),
metadata.get("title"),
metadata.get("title_highlighted"),
)
if not any(_normalize_doc_search_title(item) == title for item in title_candidates):
continue
document_id = metadata.get("token") or result.get("token")
document_url = metadata.get("url") or result.get("url")
if document_id and document_url:
return {"id": str(document_id), "url": str(document_url)}
return None
def _find_analysis_doc_with_retry(title: str) -> dict[str, str] | None:
"""Search an exact title, failing closed if the readback itself is unavailable."""
last_error: BaseException | None = None
for delay in DOC_CREATE_RECONCILE_DELAYS_SECONDS:
if delay:
time.sleep(delay)
try:
document = _find_analysis_doc(title)
except Exception as search_exc:
last_error = search_exc
continue
if document:
return document
if last_error is not None:
raise StyleAnalysisCheckpointError(
"飞书文档创建失败后无法完成标题对账,拒绝重复创建"
) from last_error
return None
def _is_ambiguous_analysis_doc_create_error(exc: BaseException) -> bool:
message = re.sub(r"\s+", " ", str(exc).casefold())
return any(
marker in message
for marker in (
"20008",
"user not exist",
"the user does not exist",
"timeout",
"timed out",
"time out",
"network",
"econnreset",
"socket hang up",
)
)
def _reconcile_analysis_doc_create(
title: str,
exc: BaseException,
) -> dict[str, str] | None:
"""Read back an ambiguous create before deciding whether it is safe to retry."""
if not _is_ambiguous_analysis_doc_create_error(exc):
return None
return _find_analysis_doc_with_retry(title)
def load_style_analysis_checkpoint(
spec: StyleAnalysisCheckpointSpec,
) -> dict[str, Any]:
@@ -1008,8 +1101,15 @@ def _atomic_write_checkpoint(path: Path, payload: dict[str, Any]) -> None:
def claim_style_analysis_checkpoint(
spec: StyleAnalysisCheckpointSpec,
*,
retry_unconfirmed_creation: bool = False,
) -> tuple[dict[str, Any], bool]:
"""Exclusively claim document creation or load a resumable later phase."""
"""Claim document creation or load a resumable later phase.
A ``doc_creating`` checkpoint is only retried after the caller has
completed a remote title reconciliation and confirmed that no document
exists. This keeps the normal fail-closed behavior for ambiguous writes.
"""
path = spec.path()
initial = _checkpoint_payload(spec)
try:
@@ -1017,6 +1117,9 @@ def claim_style_analysis_checkpoint(
except FileExistsError:
existing = load_style_analysis_checkpoint(spec)
if existing["phase"] == "doc_creating":
if retry_unconfirmed_creation:
_atomic_write_checkpoint(path, initial)
return initial, True
raise StyleAnalysisCheckpointError(
"飞书文档创建结果不明确(doc_creating),拒绝重复创建;请先对账"
)
@@ -1194,7 +1297,27 @@ def write_feishu_doc(
raise StyleAnalysisCheckpointError(
"传入报告与款式分析 checkpoint 的标题或哈希不匹配"
)
try:
checkpoint_state, owns_creation = claim_style_analysis_checkpoint(checkpoint)
except StyleAnalysisCheckpointError as exc:
if "doc_creating" not in str(exc):
raise
# A prior client error may have happened after Feishu committed the
# document but before lark-cli returned its identity. Reconcile by
# exact title first; only an empty search authorizes a new create.
reconciled = _find_analysis_doc_with_retry(title)
if reconciled:
checkpoint_state = advance_style_analysis_checkpoint(
checkpoint,
"doc_created",
document=reconciled,
)
owns_creation = False
else:
checkpoint_state, owns_creation = claim_style_analysis_checkpoint(
checkpoint,
retry_unconfirmed_creation=True,
)
tmp = report_cache_path(title)
tmp.parent.mkdir(parents=True, exist_ok=True)
@@ -1203,21 +1326,71 @@ def write_feishu_doc(
permission_grant: dict[str, Any] = {}
if owns_creation:
obj = run_lark_cli([
create_args = [
"docs", "+create",
"--doc-format", "markdown",
"--title", title,
"--content", f"@{tmp.name}",
"--as", "bot",
"--format", "json",
], cwd=content_cwd, timeout=180)
]
try:
obj = run_lark_cli(create_args, cwd=content_cwd, timeout=180)
except Exception as exc:
if not _is_ambiguous_analysis_doc_create_error(exc):
raise
reconciled = _reconcile_analysis_doc_create(title, exc)
if reconciled:
url = reconciled["url"]
doc_id = reconciled["id"]
if checkpoint is not None:
checkpoint_state = advance_style_analysis_checkpoint(
checkpoint,
"doc_created",
document=reconciled,
)
else:
# 20008 is commonly returned while the CLI's user identity is
# being refreshed. Refresh once, then retry only after the
# exact-title reconciliation proved that no document exists.
try:
run_lark_cli(["auth", "status", "--json", "--verify"])
except Exception:
raise exc
obj = run_lark_cli(create_args, cwd=content_cwd, timeout=180)
data = obj.get("data") or {}
doc = data.get("document") or {}
url = doc.get("url") or obj.get("url") or obj.get("document_url")
doc_id = doc.get("document_id")
if not url or not doc_id:
raise RuntimeError(
f"lark-cli docs +create missing URL or document_id: {obj}"
)
permission_grant = (
obj.get("permission_grant")
or data.get("permission_grant")
or {}
)
if checkpoint is not None:
checkpoint_state = advance_style_analysis_checkpoint(
checkpoint,
"doc_created",
document={"id": str(doc_id), "url": str(url)},
automatic_permission_status=permission_grant.get("status"),
)
else:
data = obj.get("data") or {}
doc = data.get("document") or {}
url = doc.get("url") or obj.get("url") or obj.get("document_url")
doc_id = doc.get("document_id")
if not url or not doc_id:
raise RuntimeError(f"lark-cli docs +create missing URL or document_id: {obj}")
permission_grant = obj.get("permission_grant") or data.get("permission_grant") or {}
permission_grant = (
obj.get("permission_grant")
or data.get("permission_grant")
or {}
)
if checkpoint is not None:
# Persist the remote identity before any permission call. A later
# failure can therefore continue against this exact document.
@@ -43,6 +43,19 @@ BASE_URL = (
"&product-invest-order-platform=0&product-material-order-platform=0&mar-goal=2"
)
DOWNLOAD_SELECTOR = 'button:has(iconpark-icon[name="oc-icon-download"])'
TASK_CENTER_URL = (
"https://qianchuan.jinritemai.com/tools/task-center/download-task"
"?aavid={advertiser_id}"
)
TASK_SCENE_MARKER = "全域推广数据-推商品"
TASK_READY_MARKER = "下载任务列表"
TASK_COMPLETE_MARKER = "生成完成"
TASK_FAILED_MARKERS = ("生成失败", "任务失败")
ASYNC_TASK_MARKERS = (
"已生成下载任务",
"已为您创建报表生成任务",
"前往任务中心",
)
REQUIRED_HEADERS = (
"商品ID",
"商品名称",
@@ -151,6 +164,148 @@ def _validate_page(page) -> None:
raise DouyinQianchuanDownloadError("巨量千川商品明细下载按钮数量异常")
def _task_file_marker(start_date: date, end_date: date) -> str:
return (
f"{start_date.isoformat()} 00:00:00-"
f"{end_date.isoformat()} 23:59:59"
)
def _matching_task_rows(page, start_date: date, end_date: date):
return (
page.locator("tr.ovui-tr")
.filter(has_text=TASK_SCENE_MARKER)
.filter(has_text=_task_file_marker(start_date, end_date))
)
def _validate_task_center_page(page) -> None:
body = page.locator("body").inner_text(timeout=10_000)
current_url = str(page.url or "")
if "/login" in current_url.casefold() or "passport" in current_url.casefold():
raise DouyinQianchuanDownloadError("巨量千川 Cookie 已失效,任务中心跳转到登录页")
if TASK_READY_MARKER not in body:
raise DouyinQianchuanDownloadError("巨量千川下载任务中心未达到可用状态")
def _download_task_from_page(
page,
start_date: date,
end_date: date,
*,
wait_timeout_ms: int,
):
"""Download a matching completed task, optionally waiting for generation."""
deadline = time.monotonic() + wait_timeout_ms / 1000
navigation_retries = 0
while True:
rows = _matching_task_rows(page, start_date, end_date)
try:
has_rows = rows.count() > 0
except Exception as exc:
if (
"execution context was destroyed" not in str(exc).casefold()
or navigation_retries >= 1
):
raise
navigation_retries += 1
page.wait_for_function(
"(marker) => (document.body?.innerText || '').includes(marker)",
arg=TASK_READY_MARKER,
timeout=15_000,
)
_validate_task_center_page(page)
continue
if has_rows:
row = rows.first
row_text = row.inner_text()
if TASK_COMPLETE_MARKER in row_text:
with page.expect_download(timeout=60_000) as download_info:
row.get_by_text("下载", exact=True).click()
return download_info.value
if any(marker in row_text for marker in TASK_FAILED_MARKERS):
raise DouyinQianchuanDownloadError(
"巨量千川下载任务生成失败:" + row_text.replace("\n", " ")
)
if time.monotonic() >= deadline:
return None
refresh = page.get_by_role("button", name="刷新")
if refresh.count():
refresh.click()
else:
page.reload(wait_until="domcontentloaded", timeout=90_000)
page.wait_for_timeout(2_000)
def _download_from_task_center(
context,
start_date: date,
end_date: date,
*,
wait_timeout_ms: int,
):
task_page = context.new_page()
try:
task_page.goto(
TASK_CENTER_URL.format(advertiser_id=quote(ADVERTISER_ID)),
wait_until="domcontentloaded",
timeout=90_000,
)
task_page.wait_for_function(
"(marker) => (document.body?.innerText || '').includes(marker)",
arg=TASK_READY_MARKER,
timeout=90_000,
)
_validate_task_center_page(task_page)
return _download_task_from_page(
task_page,
start_date,
end_date,
wait_timeout_ms=wait_timeout_ms,
)
except Exception:
_save_debug(task_page, "task_center_failed")
raise
finally:
task_page.close()
def _trigger_report_download(page):
"""Support both the old direct download and the new asynchronous task flow."""
downloads: list[object] = []
def remember_download(download) -> None:
downloads.append(download)
page.on("download", remember_download)
page.locator(DOWNLOAD_SELECTOR).click()
deadline = time.monotonic() + 120
try:
while time.monotonic() < deadline:
if downloads:
return downloads[0]
body = page.locator("body").inner_text(timeout=10_000)
if any(marker in body for marker in ASYNC_TASK_MARKERS):
return None
page.wait_for_timeout(500)
finally:
page.remove_listener("download", remember_download)
raise DouyinQianchuanDownloadError(
"巨量千川点击下载后既未直接下载,也未创建异步下载任务"
)
def _save_download(download, output_dir: Path) -> Path:
suggested = re.sub(
r'[<>:"/\\|?*]+', "_", download.suggested_filename or "千川商品报表.xlsx"
)
target = output_dir / suggested
download.save_as(str(target))
return target
def _visible_calendar_months(page) -> list[tuple[int, int]]:
labels = page.locator(
".ovui-range-picker__popper--show .ovui-date__header-label"
@@ -335,14 +490,28 @@ def collect(
_validate_page(page)
_set_date_range(page, start_date, end_date)
_validate_page(page)
with page.expect_download(timeout=120_000) as download_info:
page.locator(DOWNLOAD_SELECTOR).click()
download = download_info.value
suggested = re.sub(
r'[<>:"/\\|?*]+', "_", download.suggested_filename or "千川商品报表.xlsx"
# A failed prior run may already have created the report in the
# asynchronous task center. Reuse it before creating a duplicate.
download = _download_from_task_center(
context,
start_date,
end_date,
wait_timeout_ms=0,
)
target = output_dir / suggested
download.save_as(str(target))
if download is None:
download = _trigger_report_download(page)
if download is None:
download = _download_from_task_center(
context,
start_date,
end_date,
wait_timeout_ms=180_000,
)
if download is None:
raise DouyinQianchuanDownloadError(
"巨量千川异步下载任务在 180 秒内未生成完成"
)
target = _save_download(download, output_dir)
except Exception:
_save_debug(page, "download_failed")
raise
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""采集抖音电商罗盘市场商品榜单,并生成飞书文档表格。"""
"""采集抖音电商罗盘市场商品榜单,并按月份归档到品牌行军图数据库。"""
from __future__ import annotations
@@ -45,7 +45,9 @@ from market_rank_limits import ( # noqa: E402
remaining_category_rank_limit,
require_valid_market_rank_products,
)
from market_rank_report_archive import archive_market_rank_report # noqa: E402
from market_rank_product_import import ( # noqa: E402
archive_collected_market_rank_to_database,
)
MARKET_PRODUCT_RANK_URL = "https://compass.jinritemai.com/shop/chance/rank-product"
OUTPUT_DIR = RAW_DATA_ROOT / "dy_market_rank"
@@ -1034,21 +1036,14 @@ def main() -> int:
return 0
title_scope = category_specs[0]["name"] if len(category_specs) == 1 else "八品类"
title = f"抖音{title_scope}市场商品榜单 {args.period} {args.report_date.isoformat()}"
doc_url = create_dy_feishu_doc_by_category(
title,
products,
period=args.period,
)
print(f"[OK] 飞书文档: {doc_url}")
archive_market_rank_report(
result = archive_collected_market_rank_to_database(
platform="dy",
report_date=args.report_date,
collected_on=args.report_date,
period=args.period,
category_scope=str(title_scope),
category_names=[str(spec["name"]) for spec in category_specs],
report_title=title,
feishu_doc_url=doc_url,
product_count=len(products),
rows=products,
source_json_path=str(json_path),
run_metadata={
"report_date": args.report_date.isoformat(),
@@ -1064,6 +1059,7 @@ def main() -> int:
},
},
)
print(f"[OK] 已直接归档品牌行军图数据库: {result}")
return 0
finally:
session.close()
@@ -26,7 +26,7 @@ from datetime import date, datetime
from pathlib import Path
from typing import Any
from runtime_paths import LOG_ROOT, RAW_DATA_ROOT, profile_path, runtime_script
from runtime_paths import LOG_ROOT, RAW_DATA_ROOT, runtime_script
from gyxx_flow.adapters import environment_for_child_script
@@ -43,7 +43,6 @@ OUTPUT_ROOT = RAW_DATA_ROOT / "dy人物画像"
LOG_DIR = LOG_ROOT
FAIL_LIST_PATH = LOG_DIR / "dy_persona_bitable_failures.json"
DEFAULT_USER_DATA_DIR = os.getenv("DY_USER_DATA_DIR", str(profile_path("dy")))
SUCCESS_STATUSES = frozenset({"ok", "dry-run"})
RECORD_ID_PATTERN = re.compile(r"^rec[A-Za-z0-9_-]{6,}$")
@@ -84,7 +83,7 @@ def time_label(value: date | datetime | None = None) -> str:
# ── 采集 subprocess ──────────────────────────────────────────────────────────
def run_collect_all(
user_data_dir: str,
user_data_dir: str | None,
headless: bool,
business_date: date,
styles: str = "",
@@ -93,12 +92,9 @@ def run_collect_all(
采集脚本本身的 main 就是 "一次登录 + for style in styles" 模式,
所以这里只调一次 subprocess, 不要在循环里反复调。
"""
cmd = [
sys.executable,
str(COLLECT_SCRIPT),
"--user-data-dir", user_data_dir,
"--date", business_date.isoformat(),
]
cmd = [sys.executable, str(COLLECT_SCRIPT), "--date", business_date.isoformat()]
if user_data_dir:
cmd[2:2] = ["--user-data-dir", user_data_dir]
if styles:
cmd += ["--styles", styles]
if headless:
@@ -460,7 +456,11 @@ def main() -> int:
parser = argparse.ArgumentParser(description="每日人群画像(抖音)采集 + 回填飞书表")
parser.add_argument("--date", type=date.fromisoformat, default=date.today(), help="业务日期 YYYY-MM-DD")
parser.add_argument("--styles", default="", help="只跑指定款式, 逗号分隔; 默认全部")
parser.add_argument("--user-data-dir", default=DEFAULT_USER_DATA_DIR, help="Chrome profile 根目录")
parser.add_argument(
"--user-data-dir",
default="",
help="Chrome profile 根目录;未指定时沿用内层脚本的账号绑定 profile",
)
parser.add_argument("--headless", action="store_true", help="无头跑")
parser.add_argument("--identity", default="user", choices=["bot", "user"], help="飞书写入身份")
parser.add_argument("--dry-run", action="store_true", help="只转字段不写飞书")
@@ -20,6 +20,8 @@ try:
DEFAULT_CONFIG,
DEFAULT_HOME_URL,
USER_AGENT,
close_foreign_popup_tabs,
dismiss_marketing_popup,
load_config,
make_page_action,
normalize_cdp_url,
@@ -32,6 +34,8 @@ except ImportError: # Direct script execution from the module root.
DEFAULT_CONFIG,
DEFAULT_HOME_URL,
USER_AGENT,
close_foreign_popup_tabs,
dismiss_marketing_popup,
load_config,
make_page_action,
normalize_cdp_url,
@@ -965,6 +969,7 @@ def wait_for_metrics(page: Any, erp_code: str, timeout_seconds: int = 45,
poll_count = 0
freq_dialog_handled = 0 # 操作频繁弹窗已处理次数, 最多 3 次避免无限循环
MAX_FREQ_DIALOG = 3
marketing_popup_check_at = 0.0 # 广告弹窗清扫节流: 至少间隔 5s 扫一次主页 DOM
# 数据稳定判据: 连续 REQUIRED_STABLE 次 poll 拿到相同 sales/returns
last_stable_metrics = None # 上一次 poll 的 (sales, returns), 用于比较
stable_count = 0
@@ -985,6 +990,15 @@ def wait_for_metrics(page: Any, erp_code: str, timeout_seconds: int = 45,
last_stable_metrics = None
stable_count = 0
continue
# 营销广告弹窗/外来推广页签定期清扫 (节流): 广告挡住 iframe 或新页签
# 抢走前台焦点时, 数值提取/店铺切换会一直失败
if time.monotonic() >= marketing_popup_check_at:
marketing_popup_check_at = time.monotonic() + 5.0
cleaned = dismiss_marketing_popup(page)
cleaned += close_foreign_popup_tabs(getattr(page, "context", None), page)
if cleaned:
print(f"[ERP] {erp_code}: 已清理广告弹窗/外来页签, 等 1s 后继续轮询", flush=True)
page.wait_for_timeout(1000)
for frame in page.frames:
if "allsku.aspx" not in (frame.url or "").lower():
continue
@@ -1512,6 +1526,8 @@ def main() -> int:
"network_idle": True,
"timeout": args.timeout_ms,
"wait": 500,
# A retry reruns page_action, which performs the entire ERP collection.
"retries": 1,
"useragent": USER_AGENT,
"additional_args": {
"viewport": {"width": 1600, "height": 950},
@@ -1530,7 +1546,22 @@ def main() -> int:
if not cdp_url:
print("[PROFILE] Note: ensure the Chrome at this profile is closed, otherwise the new instance may land in a separate subdir without cookies.")
try:
DynamicFetcher.fetch(login_url, **fetch_kwargs)
except Exception as exc:
# Scrapling converts the navigation response after page_action returns.
# Long ERP report sessions can evict that response while the completed
# collector has already persisted its results.
if (
not state.get("collector_completed")
or "Response.all_headers: The object has been collected to prevent unbounded heap growth"
not in str(exc)
):
raise
print(
"[WARN] ERP 报表已采集完成,忽略 Scrapling 响应转换错误",
flush=True,
)
if not state.get("ok"):
print(
@@ -12,6 +12,8 @@ does not accept or persist a password argument.
from __future__ import annotations
import argparse
import csv
import io
import os
import re
import shutil
@@ -48,6 +50,12 @@ _DATE_RE = re.compile(r"(?P<start>\d{4}-\d{2}-\d{2})\s*至\s*(?P<end>\d{4}-\d{2}
_PENDING_MARKERS = ("生成中", "处理中", "排队中", "等待中", "创建中")
_FAILED_MARKERS = ("生成失败", "下载失败")
DOWNLOAD_CENTER_MAX_REPORTS = 100
# 京东偶尔会在数据集市尚未就绪时提前生成报表:对比时间区块有数据,当前时间
# 区块只剩汇总占位行(SPU/SKU ID=0)。下载后校验并限时重提,短暂的数据延迟
# 可以在一次运行内自愈。
REPORT_CONTENT_ATTEMPTS = 3
REPORT_RETRY_WAIT_SECONDS = 300
SUMMARY_ROW_KEYS = frozenset({"", "0", "汇总", "合计", "总计"})
_LOGIN_CHALLENGE_MARKERS = ("完成拼图验证", "安全验证", "滑块验证")
_LOGIN_FAILURE_MARKERS = (
"账号名与密码不匹配",
@@ -1167,6 +1175,44 @@ def _extract_download(
raise JDAdCostCollectionError(f"下载报表 ZIP 中找不到 {report_type} CSV")
def _normalize_cell(value: object) -> str:
return str(value or "").replace("\ufeff", "").strip().strip("'\"").strip()
def _report_content_rows(path: Path, report_type: ReportType) -> int:
"""Count real 当前时间 SPU/SKU rows; 0 means JD had no data for the date."""
key_column = "SPU ID" if report_type == "full" else "SKU ID"
try:
text = path.read_text(encoding="utf-8-sig")
except UnicodeDecodeError:
text = path.read_text(encoding="gb18030")
rows = list(csv.reader(io.StringIO(text)))
header_index = marker_index = key_index = None
for index, row in enumerate(rows):
cells = [_normalize_cell(value) for value in row]
if "当前时间" in cells and key_column in cells:
header_index, marker_index, key_index = (
index,
cells.index("当前时间"),
cells.index(key_column),
)
break
if marker_index is None or key_index is None:
raise JDAdCostCollectionError(
f"{path.name} 缺少「当前时间」或「{key_column}」表头,无法校验报表数据"
)
count = 0
for row in rows[header_index + 1 :]:
if len(row) <= max(marker_index, key_index):
continue
if _normalize_cell(row[marker_index]) != "当前时间":
continue
if _normalize_cell(row[key_index]) in SUMMARY_ROW_KEYS:
continue
count += 1
return count
def _find_state(page, report_type: ReportType, target_date: date) -> ReportState:
states = [state for _, state in _report_rows(page, report_type, target_date)]
if "ready" in states:
@@ -1430,6 +1476,81 @@ def _start_browser(headless: bool, user_data_dir: str) -> ScraplingBrowser:
return ScraplingBrowser(stealthy=True, **options)
def _resubmit_report(page, report_type: ReportType, target_date: date) -> None:
"""Clear stale download-center jobs and submit the same report again."""
safe_goto(page, DOWNLOAD_URL)
_wait_for_text(page, "下载报表", timeout_seconds=60)
_clear_download_center(page)
safe_goto(page, OVERVIEW_URL)
try:
page.reload(wait_until="domcontentloaded", timeout=60_000)
except Exception:
safe_goto(page, OVERVIEW_URL)
_wait_for_text(page, "非全站营销", timeout_seconds=60)
if report_type == "full":
# A fresh overview document starts on 自有账户; re-select the
# advertiser exactly like the first pass in run().
_select_account(page, ADVERTISER_PIN)
_request_report(page, report_type, target_date, ADVERTISER_PIN)
def _ensure_report_data(
page,
downloaded: dict[ReportType, Path],
target_date: date,
output_dir: Path,
*,
timeout_seconds: int,
) -> dict[ReportType, Path]:
"""Re-request reports whose 当前时间 block came back empty.
两种空报表需要区分:数据集市尚未产出该日期(两个报表都空,限时重提),
以及全站营销计划当天整体停投(京准通页面花费为 0)。非全站报表有数据
才能证明京东已完成该日期的数据产出,此时全站报表为空按零投放接受。
"""
counts: dict[ReportType, int] = {}
for attempt in range(1, REPORT_CONTENT_ATTEMPTS + 1):
counts = {
report_type: _report_content_rows(path, report_type)
for report_type, path in downloaded.items()
}
if counts["non_full"] > 0:
if counts["full"] == 0:
print(
f"[WARN] 全站营销 SPU 报表没有目标日期的当前时间数据"
f"(非全站 SKU 报表 {counts['non_full']} 行正常):判定当日"
"全站营销零投放,按无全站数据处理",
flush=True,
)
return downloaded
if attempt >= REPORT_CONTENT_ATTEMPTS:
break
print(
f"[WARN] 京东报表没有目标日期的当前时间数据(数据可能未就绪),"
f"{REPORT_RETRY_WAIT_SECONDS} 秒后清空下载中心并重新提交"
f"(第 {attempt + 1}/{REPORT_CONTENT_ATTEMPTS} 次)",
flush=True,
)
time.sleep(REPORT_RETRY_WAIT_SECONDS)
for report_type in ("non_full", "full"):
if counts[report_type] > 0:
continue
_resubmit_report(page, report_type, target_date)
downloaded[report_type] = download_ready_report(
page,
report_type,
target_date,
output_dir,
timeout_seconds=timeout_seconds,
)
raise JDAdCostCollectionError(
f"京东报表连续 {REPORT_CONTENT_ATTEMPTS} 次下载都没有目标日期的"
f"当前时间数据(全站 {counts.get('full', 0)} 行、非全站 "
f"{counts.get('non_full', 0)} 行);京东数据可能尚未就绪,"
"请稍后重新运行采集"
)
def run(args: argparse.Namespace) -> list[Path]:
target_date = parse_target_date(args.date)
output_dir = (Path(args.output).expanduser().resolve() / target_date.isoformat())
@@ -1463,7 +1584,7 @@ def run(args: argparse.Namespace) -> list[Path]:
safe_goto(page, OVERVIEW_URL)
_wait_for_text(page, "非全站营销", timeout_seconds=60)
account_selected = False
downloaded: list[Path] = []
downloaded: dict[ReportType, Path] = {}
for index, report_type in enumerate(("non_full", "full")):
if report_type == "full" and not account_selected:
_select_account(page, ADVERTISER_PIN)
@@ -1474,15 +1595,13 @@ def run(args: argparse.Namespace) -> list[Path]:
# A pre-existing task still uses the selected advertiser for
# the full-site request below.
account_selected = True
downloaded.append(
download_ready_report(
downloaded[report_type] = download_ready_report(
page,
report_type,
target_date,
output_dir,
timeout_seconds=args.task_timeout_seconds,
)
)
if index == 0:
# The download-center route replaces the overview SPA. Return
# to a fresh overview before requesting the second report;
@@ -1493,6 +1612,13 @@ def run(args: argparse.Namespace) -> list[Path]:
except Exception:
safe_goto(page, OVERVIEW_URL)
_wait_for_text(page, "非全站营销", timeout_seconds=60)
downloaded = _ensure_report_data(
page,
downloaded,
target_date,
output_dir,
timeout_seconds=args.task_timeout_seconds,
)
manifest = {
"schema_version": 1,
"dataset": "jd_ad_costs",
@@ -1501,12 +1627,12 @@ def run(args: argparse.Namespace) -> list[Path]:
"shop_account": login_account,
"files": {
report_type: path.relative_to(output_dir).as_posix()
for report_type, path in zip(("non_full", "full"), downloaded, strict=True)
for report_type, path in downloaded.items()
},
"downloaded_at": datetime.now().isoformat(timespec="seconds"),
}
atomic_write_json(output_dir / "manifest.json", manifest)
for path in downloaded:
for path in downloaded.values():
print(f"[OK] 已保存京东营销报表:{path}")
return downloaded
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""采集京东商智行业商品榜单,并生成带销售额的飞书文档表格。"""
"""采集京东商智行业商品榜单,并按月份归档到品牌行军图数据库。"""
from __future__ import annotations
@@ -57,7 +57,9 @@ from market_rank_limits import ( # noqa: E402
remaining_category_rank_limit,
require_valid_market_rank_products,
)
from market_rank_report_archive import archive_market_rank_report # noqa: E402
from market_rank_product_import import ( # noqa: E402
archive_collected_market_rank_to_database,
)
JD_MARKET_RANK_URL = (
"https://sz.jd.com/szweb/sz/view/industryMarket/productRanks.html"
@@ -1657,37 +1659,14 @@ def main() -> int:
else f"{len(categories)}品类"
)
title = f"京东{title_scope}市场商品榜单 {args.period} {args.report_date.isoformat()}"
if args.resume_doc_url or args.resume_offset:
doc_url = create_jd_feishu_doc_batched(
title,
products,
period=args.period,
batch_size=args.doc_batch_size,
doc_url=args.resume_doc_url,
start_offset=args.resume_offset,
max_retries=args.feishu_retries,
cooldown_seconds=args.feishu_cooldown,
repair_images=not args.no_image_repair,
)
else:
doc_url = create_jd_feishu_doc_by_category(
title,
products,
period=args.period,
max_retries=args.feishu_retries,
cooldown_seconds=args.feishu_cooldown,
repair_images=not args.no_image_repair,
)
print(f"[OK] 飞书文档: {doc_url}")
archive_market_rank_report(
result = archive_collected_market_rank_to_database(
platform="jd",
report_date=args.report_date,
collected_on=args.report_date,
period=args.period,
category_scope=str(title_scope),
category_names=sorted(categories),
report_title=title,
feishu_doc_url=doc_url,
product_count=len(products),
rows=products,
source_json_path=str(input_path),
run_metadata={
"report_date": args.report_date.isoformat(),
@@ -1697,6 +1676,7 @@ def main() -> int:
"limit": args.limit,
},
)
print(f"[OK] 已直接归档品牌行军图数据库: {result}")
return 0
category_specs = _category_specs(args.category)
@@ -1785,37 +1765,14 @@ def main() -> int:
return 0
title_scope = category_specs[0]["name"] if len(category_specs) == 1 else "七品类"
title = f"京东{title_scope}市场商品榜单 {args.period} {args.report_date.isoformat()}"
if args.resume_doc_url or args.resume_offset:
doc_url = create_jd_feishu_doc_batched(
title,
products,
period=args.period,
batch_size=args.doc_batch_size,
doc_url=args.resume_doc_url,
start_offset=args.resume_offset,
max_retries=args.feishu_retries,
cooldown_seconds=args.feishu_cooldown,
repair_images=not args.no_image_repair,
)
else:
doc_url = create_jd_feishu_doc_by_category(
title,
products,
period=args.period,
max_retries=args.feishu_retries,
cooldown_seconds=args.feishu_cooldown,
repair_images=not args.no_image_repair,
)
print(f"[OK] 飞书文档: {doc_url}")
archive_market_rank_report(
result = archive_collected_market_rank_to_database(
platform="jd",
report_date=args.report_date,
collected_on=args.report_date,
period=args.period,
category_scope=str(title_scope),
category_names=[str(spec["name"]) for spec in category_specs],
report_title=title,
feishu_doc_url=doc_url,
product_count=len(products),
rows=products,
source_json_path=str(json_path),
run_metadata={
"report_date": args.report_date.isoformat(),
@@ -1830,6 +1787,7 @@ def main() -> int:
"resume_doc_url": args.resume_doc_url or None,
},
)
print(f"[OK] 已直接归档品牌行军图数据库: {result}")
return 0
finally:
session.close()
@@ -5,7 +5,10 @@
流程:
1. 从项目数据库动态读取 (经 StyleConfigLoader) 款式 -> 人群画像(京东)目标表映射
条件: 同一款式同时有 天猫分组的人群画像(京东)URL + 京东旗舰店分组的商品ID(SPU)
2. 一次 Playwright 浏览器登录京东商智, 返回旧版, 逐款式搜SPU -> 详情 -> 人群画像tab -> 近30天 -> 采集
2. 一次 Playwright 浏览器登录京东商智; 商智新版把"人群画像"路由到独立包装页
(single-product-detail-temp.html, 内嵌旧版 singleProDetails iframe)。
逐款式直接打开该包装页 -> iframe 内切"近30天" -> 采集。
数据优先取自 getSpuSingleProUsersData.ajax 响应, 悬停补采兜底。
每款产出 JSON 到 data/jd人物画像/{date}/{style}/
3. 读每款 JSON, 把性别/年龄/购买力数据映射到飞书子表字段
4. 按"时间"(采集当天 M.D) upsert 到各款式对应的子表
@@ -20,10 +23,11 @@ import re
import shutil
import subprocess
import sys
import time
from datetime import date, datetime
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from urllib.parse import urlencode, urlparse
from runtime_paths import (
LOG_ROOT,
@@ -46,6 +50,10 @@ from config.style_config_loader import ( # noqa: E402
StyleConfigLoader,
get_jd_persona_bitable_map,
)
from jd_product_data_collector import ( # noqa: E402
close_guide_if_present,
extract_crowd_chart_by_hover,
)
OUTPUT_ROOT = RAW_DATA_ROOT / "jd人物画像"
LOG_DIR = LOG_ROOT
@@ -65,6 +73,19 @@ JD_SEARCH_BUTTON_SELECTORS = (
'.search-multiple-input button:has-text("搜索")',
'button.jmtd-button:has-text("搜索")',
)
JD_PRODUCT_DETAIL_HOSTS = frozenset({"sz.jd.com", "jdsz.jd.com"})
JD_PRODUCT_DETAIL_PATHS = frozenset(
{
"/szweb/sz/view/productanalysis/productdetail.html",
"/szweb/view/product/productdetail.html",
}
)
# 商智新版"人群画像"不再原地切 tab, 而是打开独立包装页并内嵌旧版明细页 iframe
JD_BUYER_PORTRAIT_PATH = "/szweb/view/product/single-product-detail-temp.html"
JD_PORTRAIT_IFRAME_URL_MARKER = "singleProDetails"
JD_PORTRAIT_IFRAME_SELECTOR = "iframe[src*='singleProDetails']"
JD_PERSONA_API_MARKER = "getSpuSingleProUsersData"
BUYING_POWER_KEYWORDS = ("小白领", "高级白领", "土豪", "白领", "购买力")
def combined_result_exit_code(
@@ -192,6 +213,18 @@ def _wait_for_jd_session(page, *, polls: int = 8, fallback_after: int = 4) -> bo
return False
def _is_jd_product_detail_url(url: str) -> bool:
"""Accept the current detail route and JD's legacy redirect target."""
try:
parsed = urlparse(url)
except (TypeError, ValueError):
return False
host = (parsed.hostname or "").lower().rstrip(".")
path = (parsed.path or "").lower().rstrip("/")
return host in JD_PRODUCT_DETAIL_HOSTS and path in JD_PRODUCT_DETAIL_PATHS
def visible_input_diagnostics(page) -> list[str]:
try:
values = page.evaluate(
@@ -270,9 +303,12 @@ def _goto_product_detail_with_network_tolerance(page, step_goto_product_detail)
timeout=60_000,
)
page.wait_for_timeout(5_000)
if "productAnalysis/productDetail" not in (page.url or ""):
raise RuntimeError(f"京东商品明细页跳转失败: {page.url}")
print(" [OK] 已到达商品明细页面(忽略后台常驻请求)")
url, body = page_evidence(page)
reason = blocking_page_reason(url, body)
if reason or not _is_jd_product_detail_url(url):
detail = reason or "未落到受支持的商品明细路由"
raise RuntimeError(f"京东商品明细页跳转失败: {detail}: {url}")
print(f" [OK] 已到达商品明细页面: {url}(忽略后台常驻请求)")
# ── 时间标签 ──────────────────────────────────────────────────────────────────
@@ -644,7 +680,211 @@ def is_permission_error(message: str) -> bool:
return "91402" in m or "91403" in m or "notexist" in m or "permission" in m
# ── 浏览器采集 (旧版商智单品人群画像) ──────────────────────────────────────────
# ── 浏览器采集 (新版商智人群画像包装页) ────────────────────────────────────────
class FrameMouseProxy:
"""Translate frame-local coordinates into main-frame viewport coordinates."""
def __init__(self, owner: "FramePageProxy") -> None:
self._owner = owner
def _offset(self) -> tuple[float, float]:
box = self._owner.host_page.locator(JD_PORTRAIT_IFRAME_SELECTOR).first \
.bounding_box()
return (box["x"], box["y"]) if box else (0.0, 0.0)
def move(self, x: float, y: float, **kwargs) -> None:
offset_x, offset_y = self._offset()
self._owner.host_page.mouse.move(x + offset_x, y + offset_y, **kwargs)
def click(self, x: float, y: float, **kwargs) -> None:
offset_x, offset_y = self._offset()
self._owner.host_page.mouse.click(x + offset_x, y + offset_y, **kwargs)
class FramePageProxy:
"""Present an iframe Frame with the page-like surface the legacy
jd_product_data_collector extractors expect (evaluate/locator/mouse)."""
def __init__(self, frame, host_page) -> None:
self._frame = frame
self.host_page = host_page
self.mouse = FrameMouseProxy(self)
def evaluate(self, *args: Any, **kwargs: Any) -> Any:
return self._frame.evaluate(*args, **kwargs)
def wait_for_timeout(self, timeout: int) -> None:
self.host_page.wait_for_timeout(timeout)
def locator(self, selector: str, **kwargs: Any):
return self._frame.locator(selector, **kwargs)
def wait_for_selector(self, selector: str, **kwargs: Any):
return self._frame.wait_for_selector(selector, **kwargs)
def buyer_portrait_url(spu_id: str) -> str:
"""Return the new-shangzhi buyer-portrait wrapper URL for one SPU."""
query = urlencode(
{"sxu": spu_id, "type": "spu", "from": "jdsz", "tab": "buyerPortrait"}
)
return f"https://jdsz.jd.com{JD_BUYER_PORTRAIT_PATH}?{query}"
def wait_for_portrait_frame(page, *, timeout_s: float = 25.0):
"""Poll for the legacy singleProDetails iframe embedded by the wrapper."""
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
for frame in page.frames:
if JD_PORTRAIT_IFRAME_URL_MARKER in frame.url:
return frame
page.wait_for_timeout(1_000)
return None
def _format_ratio(value: Any) -> str:
"""Render a 0-1 ratio like the legacy hover extractors did ("90.38%")."""
try:
return f"{float(value) * 100:.2f}%"
except (TypeError, ValueError):
return ""
def _iter_metric_pairs(entries: Any):
"""Yield (name, value) from the API's [name, ratio] pair lists."""
for entry in entries or []:
if isinstance(entry, (list, tuple)) and len(entry) >= 2:
yield entry[0], entry[1]
elif isinstance(entry, dict):
yield entry.get("name"), entry.get("value", entry.get("percent"))
def parse_persona_api_payload(payload: dict) -> dict[str, list[dict[str, str]]]:
"""Map getSpuSingleProUsersData content onto the legacy profile keys."""
content = (payload or {}).get("content") or {}
crowd: dict[str, list[dict[str, str]]] = {}
gender = []
for name, ratio in _iter_metric_pairs(content.get("gender")):
label = str(name or "").strip()
rendered = _format_ratio(ratio)
if label in ("男", "女", "男性", "女性") and rendered:
gender.append({"name": label, "value": rendered})
crowd["性别"] = gender
ages = []
for name, ratio in _iter_metric_pairs(content.get("age")):
label = str(name or "").strip()
rendered = _format_ratio(ratio)
if "岁" in label and rendered:
ages.append({"name": label, "value": rendered})
crowd["年龄"] = ages
powers = []
for name, ratio in _iter_metric_pairs(content.get("buy")):
label = str(name or "").strip()
rendered = _format_ratio(ratio)
if label and rendered and any(keyword in label for keyword in BUYING_POWER_KEYWORDS):
powers.append({"name": label, "value": rendered})
crowd["购买力"] = powers
return crowd
def _has_persona_values(crowd: dict[str, list[dict[str, str]]]) -> bool:
return any(crowd.get(key) for key in ("性别", "年龄", "购买力"))
def collect_spu_portrait(context, spu_id: str) -> tuple[dict | None, str]:
"""Collect one SPU's buyer portrait; returns (crowd_data, failure_note)."""
page = context.new_page()
payloads: list[dict] = []
def on_response(response) -> None:
if JD_PERSONA_API_MARKER not in response.url:
return
# 只取近30天(range)那次请求; 默认单日(day)口径人群过小, 画像为空
if "dateType=range" not in response.url:
return
try:
payloads.append(json.loads(response.text()))
except Exception:
return
page.on("response", on_response)
try:
page.goto(
buyer_portrait_url(spu_id),
wait_until="domcontentloaded",
timeout=60_000,
)
frame = wait_for_portrait_frame(page)
if frame is None:
return None, "画像 iframe 未加载"
proxy = FramePageProxy(frame, page)
try:
close_guide_if_present(proxy)
except Exception:
pass
try:
range_button = frame.locator("text=近30天").first
range_button.wait_for(state="visible", timeout=15_000)
range_button.click()
except Exception as exc:
return None, f"近30天切换失败: {exc}"
deadline = time.monotonic() + 20
while time.monotonic() < deadline and not payloads:
page.wait_for_timeout(500)
crowd_data = parse_persona_api_payload(payloads[-1]) if payloads else {}
# 悬停补采兜底: API 结构漂移时仍能拿回数据
if not crowd_data.get("性别"):
try:
frame.wait_for_selector("canvas", timeout=15_000)
page.wait_for_timeout(3_000)
crowd_data["性别"] = [
item
for item in extract_crowd_chart_by_hover(proxy, "性别", "ring")
if str(item.get("name", "")) in ("男", "女", "男性", "女性")
]
except Exception as exc:
print(f" [WARN] 性别悬停补采失败: {exc}")
if not crowd_data.get("年龄"):
try:
crowd_data["年龄"] = [
item
for item in extract_crowd_chart_by_hover(proxy, "年龄", "bar")
if "岁" in str(item.get("name", ""))
]
except Exception as exc:
print(f" [WARN] 年龄悬停补采失败: {exc}")
if not crowd_data.get("购买力"):
try:
frame.evaluate("window.scrollBy(0, 600)")
page.wait_for_timeout(1_500)
crowd_data["购买力"] = extract_buying_power(proxy)
except Exception as exc:
print(f" [WARN] 购买力补采失败: {exc}")
if not _has_persona_values(crowd_data):
return None, "画像为空(人群规模较小或无数据)"
return crowd_data, ""
except Exception as exc:
return None, f"画像页采集异常: {exc}"
finally:
try:
page.close()
except Exception:
pass
def run_collect_all(persona_map: dict[str, dict[str, str]],
spu_groups: dict[str, list[str]],
@@ -655,15 +895,8 @@ def run_collect_all(persona_map: dict[str, dict[str, str]],
step_enter_shangzhi,
step_login,
)
from jd_peer_product_data_collector import step_return_old_version
from jd_product_data_collector import (
click_crowd_portrait_tab,
click_detail_and_wait_new_page,
close_guide_if_present,
extract_crowd_portrait_data,
search_spu_in_detail_page,
step_goto_product_detail,
)
from jd_product_data_collector import step_goto_product_detail
print(f"[JD] 待采款式 {len(persona_map)}: {list(persona_map.keys())}")
OUTPUT_ROOT.mkdir(parents=True, exist_ok=True)
results: list[dict[str, str]] = []
@@ -682,7 +915,6 @@ def run_collect_all(persona_map: dict[str, dict[str, str]],
step_login=step_login,
)
sz_page = step_enter_shangzhi(context, page)
step_return_old_version(sz_page)
_goto_product_detail_with_network_tolerance(
sz_page,
step_goto_product_detail,
@@ -707,102 +939,40 @@ def run_collect_all(persona_map: dict[str, dict[str, str]],
for spu_idx, spu_id in enumerate(spus, 1):
print(f" [{spu_idx}/{len(spus)}] 尝试 SPU={spu_id}")
try:
search_spu_in_detail_page(sz_page, spu_id)
close_guide_if_present(sz_page)
detail_page = click_detail_and_wait_new_page(sz_page, context, spu_id)
if not detail_page:
message = f"{spu_id}: 详情页未打开"
print(f" [FAIL] {message}")
failure_messages.append(message)
continue
# 1. 点人群画像 tab
click_crowd_portrait_tab(detail_page)
detail_page.wait_for_timeout(3000)
# 2. 选"近30天"
try:
btn_30d = detail_page.locator('text=近30天').first
if btn_30d.is_visible(timeout=5000):
btn_30d.click()
print(" [OK] 切换到近30天")
detail_page.wait_for_timeout(3000)
except Exception as e:
print(f" [WARN] 切换近30天失败: {e}")
# 3. 采集人群画像数据 (性别/年龄)
crowd_data_full = extract_crowd_portrait_data(detail_page)
# 只保留性别和年龄, 不要职业/地区
crowd_data = {}
for k in ("性别", "年龄"):
if k in crowd_data_full:
crowd_data[k] = crowd_data_full[k]
# 4. 额外提取购买力分布 (需滚动到页面下方,购买力卡片在性别/年龄下面)
detail_page.evaluate("window.scrollBy(0, 600)")
detail_page.wait_for_timeout(1500)
if not crowd_data.get("购买力"):
buying_power = extract_buying_power(detail_page)
if buying_power:
crowd_data["购买力"] = buying_power
print(f" [OK] 购买力分布: {len(buying_power)}条")
has_data = any([
crowd_data.get("性别"),
crowd_data.get("年龄"),
crowd_data.get("购买力"),
])
if has_data:
print(" [OK] 采集成功: " + ", ".join(f"{k}{len(v)}条" for k, v in crowd_data.items() if v))
used_spu = spu_id
else:
print(" [SKIP] 无数据, 换下一个")
failure_messages.append(f"{spu_id}: 画像为空")
crowd_data = None
detail_page.close()
# 关闭详情页后确认 sz_page 还活着,否则重新导航
try:
sz_page.url
sz_page.wait_for_timeout(2000)
except Exception:
print(" [WARN] sz_page 已失效, 重新导航商智...")
try:
sz_page = context.new_page()
step_login(sz_page, DEFAULT_CONFIG["shop"], DEFAULT_CONFIG["password"])
sz_page = step_enter_shangzhi(context, sz_page)
step_return_old_version(sz_page)
step_goto_product_detail(sz_page)
except Exception as e2:
print(f" [FAIL] 重新导航失败: {e2}")
failure_messages.append(f"{spu_id}: 重新导航失败: {e2}")
break
crowd_data, note = collect_spu_portrait(context, spu_id)
if crowd_data:
used_spu = spu_id
print(
" [OK] 采集成功: "
+ ", ".join(
f"{k}{len(v)}条" for k, v in crowd_data.items() if v
)
)
break
print(f" [FAIL] {spu_id}: {note}")
failure_messages.append(f"{spu_id}: {note}")
except Exception as e:
print(f" [FAIL] 采集异常: {e}")
failure_messages.append(f"{spu_id}: {e}")
# 款式间确认会话仍有效, 失效则重进商智
try:
detail_page.close()
except Exception:
pass
# 检查 sz_page 是否还活着
try:
sz_page.url
except Exception:
print(" [WARN] sz_page 已失效, 重新导航商智...")
url, body = page_evidence(sz_page)
reason = blocking_page_reason(url, body)
if reason:
raise RuntimeError(reason)
except Exception as exc:
print(f" [WARN] 商智会话异常({exc}), 重新进入...")
try:
sz_page = context.new_page()
step_login(sz_page, DEFAULT_CONFIG["shop"], DEFAULT_CONFIG["password"])
_ensure_jd_session(
sz_page,
shop=DEFAULT_CONFIG["shop"],
password=DEFAULT_CONFIG["password"],
step_login=step_login,
)
sz_page = step_enter_shangzhi(context, sz_page)
step_return_old_version(sz_page)
step_goto_product_detail(sz_page)
_goto_product_detail_with_network_tolerance(
sz_page,
step_goto_product_detail,
)
except Exception as e2:
print(f" [FAIL] 重新导航失败: {e2}")
failure_messages.append(f"{spu_id}: 重新导航失败: {e2}")
@@ -28,7 +28,7 @@ from market_rank_limits import (
remaining_category_rank_limit,
require_valid_market_rank_products,
)
from market_rank_report_archive import archive_market_rank_report
from market_rank_product_import import archive_collected_market_rank_to_database
from runtime_paths import (
DATA_HOME,
RAW_DATA_ROOT,
@@ -1815,27 +1815,15 @@ def main() -> int:
if args.dry_run:
return 0
title_scope = "九品类" if not args.category else category_specs[0]["name"]
title = (
f"天猫{title_scope}市场排行 30天 {args.report_date.isoformat()}"
)
url = create_feishu_doc_by_category(
title,
products,
min_price=args.min_price,
max_price=args.max_price,
period=args.period,
independent_prices=not args.category,
)
print(f"[OK] 飞书文档: {url}")
archive_market_rank_report(
title = f"天猫{title_scope}市场排行 {args.period} {args.report_date:%Y-%m}"
result = archive_collected_market_rank_to_database(
platform="tm",
report_date=args.report_date,
collected_on=args.report_date,
period=args.period,
category_scope=str(title_scope),
category_names=[str(spec["name"]) for spec in category_specs],
report_title=title,
feishu_doc_url=url,
product_count=len(products),
rows=products,
source_json_path=str(json_path),
run_metadata={
"report_date": args.report_date.isoformat(),
@@ -1855,6 +1843,7 @@ def main() -> int:
},
},
)
print(f"[OK] 已直接归档品牌行军图数据库: {result}")
return 0
finally:
if session is not None:
@@ -21,7 +21,7 @@ import zipfile
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any, Callable, Literal
from urllib.parse import quote
from urllib.parse import quote, urlsplit, urlunsplit
from runtime_paths import RAW_DATA_ROOT, TMP_ROOT, profile_path
from scrapling.fetchers import StealthySession
@@ -49,6 +49,19 @@ USER_DATA_DIR = os.getenv(
)
OUTPUT_ROOT = RAW_DATA_ROOT / "tmall_wanxiang_ads"
MAX_ZIP_MEMBER_BYTES = 512 * 1024 * 1024
SIGNED_DOWNLOAD_MAX_ATTEMPTS = 3
SIGNED_DOWNLOAD_RETRY_DELAY_SECONDS = 2.0
_RETRYABLE_SIGNED_DOWNLOAD_MARKERS = (
"enotfound",
"eai_again",
"getaddrinfo",
"econnreset",
"etimedout",
"timed out",
"socket hang up",
"network is unreachable",
"connection refused",
)
class WanxiangDownloadError(RuntimeError):
@@ -866,6 +879,24 @@ def _click_download_button(page) -> bool:
return False
def _safe_signed_download_error(exc: BaseException, download_url: str) -> str:
"""Keep temporary OSS credentials out of persisted run errors."""
message = str(exc)
parsed = urlsplit(download_url)
safe_url = urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))
return message.replace(download_url, safe_url)
def _is_retryable_signed_download_error(exc: BaseException) -> bool:
message = str(exc).casefold()
return any(marker in message for marker in _RETRYABLE_SIGNED_DOWNLOAD_MARKERS)
def _is_retryable_signed_download_status(status: int) -> bool:
return status in {408, 429} or 500 <= status <= 599
def _save_signed_download(
page,
download_url: str,
@@ -882,14 +913,45 @@ def _save_signed_download(
output_dir.mkdir(parents=True, exist_ok=True)
path = output_dir / f"商品报表_{target_date.strftime('%Y%m%d')}.zip"
partial_path = path.with_suffix(path.suffix + ".part")
try:
for attempt in range(1, SIGNED_DOWNLOAD_MAX_ATTEMPTS + 1):
try:
response = request_context.get(download_url, timeout=120_000)
if not response.ok:
except Exception as exc:
if (
not _is_retryable_signed_download_error(exc)
or attempt >= SIGNED_DOWNLOAD_MAX_ATTEMPTS
):
safe_error = _safe_signed_download_error(exc, download_url)
raise WanxiangDownloadError(
f"万相台签名下载地址返回 HTTP {response.status}"
f"万相台签名文件下载网络异常:{safe_error}"
) from exc
print(
f"[WARN] 万相台签名文件下载网络异常,第 {attempt}/"
f"{SIGNED_DOWNLOAD_MAX_ATTEMPTS} 次失败,{SIGNED_DOWNLOAD_RETRY_DELAY_SECONDS:g}s 后重试"
)
time.sleep(SIGNED_DOWNLOAD_RETRY_DELAY_SECONDS)
continue
if not response.ok:
status = int(response.status)
if (
not _is_retryable_signed_download_status(status)
or attempt >= SIGNED_DOWNLOAD_MAX_ATTEMPTS
):
raise WanxiangDownloadError(
f"万相台签名下载地址返回 HTTP {status}"
)
print(
f"[WARN] 万相台签名文件下载返回 HTTP {status},第 {attempt}/"
f"{SIGNED_DOWNLOAD_MAX_ATTEMPTS} 次失败,{SIGNED_DOWNLOAD_RETRY_DELAY_SECONDS:g}s 后重试"
)
time.sleep(SIGNED_DOWNLOAD_RETRY_DELAY_SECONDS)
continue
partial_path.write_bytes(response.body())
partial_path.replace(path)
break
finally:
if partial_path.exists():
partial_path.unlink()
@@ -1065,7 +1127,11 @@ def extract_report_zip(zip_path: Path, target_date: date) -> list[Path]:
def _start_session(headless: bool, user_data_dir: str):
session = StealthySession(
headless=headless,
real_chrome=True,
# The installed workstation Chrome has repeatedly crashed in this
# isolated profile during the headless Wanxiang flow. Keep the
# profile/cookies, but use Scrapling's bundled Chromium for this
# collector so a browser crash cannot strand the workflow in login.
real_chrome=False,
user_data_dir=user_data_dir,
locale="zh-CN",
timezone_id="Asia/Shanghai",
@@ -63,8 +63,8 @@
写: analyze_style.py (直连 MiniMax 产出后归档)
读: 周复盘 / 飞书看板 / 报告归档查询
fact_market_rank_reports
写: collect_{sycm,jd,dy}_market_rank.py (飞书文档创建成功后)
读: 三平台市场排行报告索引 / 飞书文档归档查询
写: collect_{sycm,jd,dy}_market_rank.py (采集完成后按月份直接归档)
读: 三平台市场排行月度报告索引 / 行军图快照查询
fact_market_rank_notifications
写: market_rank_hermes_notification.py (Hermes 机器人通知结果;文件名兼容保留)
读: 同一收件人同日通知去重 / 发送审计
@@ -1738,10 +1738,10 @@ def upsert_style_analysis_report(conn, record: dict) -> int:
# ── fact_market_rank_reports ─────────────────────────────────────────────────
def upsert_market_rank_report(conn, record: dict) -> int:
"""保存三平台市场排行飞书文档,返回归档行 id。
"""保存三平台市场排行月度归档,返回归档行 id。
业务 UNIQUE = (platform, report_date)。同一平台同一天无论重跑多少次,均更新
当天唯一记录的链接及运行元数据,不产生重复记录。
业务 UNIQUE = (platform, report_date)。采集器传入月初日期,同一平台同一月份
无论重跑多少次,均更新唯一记录及运行元数据,不产生重复记录。
"""
sql = """
INSERT INTO fact_market_rank_reports
@@ -872,10 +872,10 @@ COMMENT ON COLUMN fact_style_analysis_reports.generated_at IS '报告生成时
COMMENT ON COLUMN fact_style_analysis_reports.updated_at IS '记录更新时间 (重跑覆盖时刷新)';
-- ============================================================================
-- fact_market_rank_reports: 三平台市场排行飞书文档归档
-- fact_market_rank_reports: 三平台市场排行月度归档
-- 数据源: collect_sycm_market_rank.py / collect_jd_market_rank.py /
-- collect_dy_market_rank.py
-- 同平台、同日无论执行多少次都覆盖,保留最后一次成功生成的飞书文档链接。
-- 同平台、同月无论执行多少次都覆盖;飞书文档链接仅作为兼容字段,可为空。
-- ============================================================================
CREATE TABLE IF NOT EXISTS fact_market_rank_reports (
id BIGSERIAL PRIMARY KEY,
@@ -886,7 +886,7 @@ CREATE TABLE IF NOT EXISTS fact_market_rank_reports (
category_names TEXT[] NOT NULL DEFAULT '{}',
report_title TEXT NOT NULL,
feishu_doc_id TEXT,
feishu_doc_url TEXT NOT NULL,
feishu_doc_url TEXT,
product_count INTEGER NOT NULL DEFAULT 0 CHECK (product_count >= 0),
source_json_path TEXT,
run_metadata JSONB,
@@ -895,6 +895,9 @@ CREATE TABLE IF NOT EXISTS fact_market_rank_reports (
UNIQUE (platform, report_date)
);
ALTER TABLE fact_market_rank_reports
ALTER COLUMN feishu_doc_url DROP NOT NULL;
-- 兼容旧表:旧唯一键包含 period/category_scope,同平台同日可能产生多条。
-- 先保留更新时间最新的一条,再替换为“平台 + 日期”唯一约束。
ALTER TABLE fact_market_rank_reports
@@ -927,20 +930,40 @@ BEGIN
END IF;
END
$$;
-- 兼容旧版日归档:同平台同月只保留更新时间最新的一条,并统一为月初。
WITH ranked_monthly_market_reports AS (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY platform, DATE_TRUNC('month', report_date)
ORDER BY report_date DESC, updated_at DESC, generated_at DESC, id DESC
) AS row_num
FROM fact_market_rank_reports
)
DELETE FROM fact_market_rank_reports target
USING ranked_monthly_market_reports ranked
WHERE target.id = ranked.id
AND ranked.row_num > 1;
UPDATE fact_market_rank_reports
SET report_date = DATE_TRUNC('month', report_date)::DATE,
updated_at = NOW()
WHERE report_date <> DATE_TRUNC('month', report_date)::DATE;
CREATE INDEX IF NOT EXISTS fact_market_rank_reports_date_idx
ON fact_market_rank_reports (report_date DESC);
CREATE INDEX IF NOT EXISTS fact_market_rank_reports_platform_idx
ON fact_market_rank_reports (platform, report_date DESC);
COMMENT ON TABLE fact_market_rank_reports IS '天猫、京东、抖音市场排行采集生成的飞书文档归档;每个平台每天仅保留最后一次成功生成的文档。';
COMMENT ON TABLE fact_market_rank_reports IS '天猫、京东、抖音市场排行月度归档;每个平台每月仅保留最后一次成功采集的榜单快照,飞书链接可为空。';
COMMENT ON COLUMN fact_market_rank_reports.platform IS '平台代码:tm / jd / dy';
COMMENT ON COLUMN fact_market_rank_reports.report_date IS '采集并生成文档的业务日期';
COMMENT ON COLUMN fact_market_rank_reports.report_date IS '采集月份业务日期,归一化为月初';
COMMENT ON COLUMN fact_market_rank_reports.period IS '平台统计周期原文,如 30天 / 近30天';
COMMENT ON COLUMN fact_market_rank_reports.category_scope IS '文档范围,如 九品类 / 七品类 / 单品类名';
COMMENT ON COLUMN fact_market_rank_reports.category_scope IS '本次采集范围,如 九品类 / 七品类 / 单品类名';
COMMENT ON COLUMN fact_market_rank_reports.category_names IS '本次实际包含的逻辑品类名称';
COMMENT ON COLUMN fact_market_rank_reports.feishu_doc_id IS '从飞书 /docx/{token} 链接提取的文档 token';
COMMENT ON COLUMN fact_market_rank_reports.feishu_doc_url IS '飞书文档完整访问链接';
COMMENT ON COLUMN fact_market_rank_reports.product_count IS '文档内采集商品数';
COMMENT ON COLUMN fact_market_rank_reports.feishu_doc_url IS '兼容旧流程的飞书文档访问链接;直接入库流程为空';
COMMENT ON COLUMN fact_market_rank_reports.product_count IS '本月榜单快照商品数';
COMMENT ON COLUMN fact_market_rank_reports.source_json_path IS '本次采集 JSON 产物路径';
COMMENT ON COLUMN fact_market_rank_reports.run_metadata IS '价格、搜索词、分页等运行参数 JSON';
@@ -1068,6 +1091,7 @@ CREATE TABLE IF NOT EXISTS market_product_classification (
scene_id BIGINT REFERENCES dim_market_scene(id),
raw_style_name TEXT,
raw_scene_name TEXT,
category_name TEXT,
source TEXT NOT NULL DEFAULT 'manual'
CHECK (source IN ('manual', 'feishu_import')),
edited_by BIGINT,
@@ -1081,9 +1105,41 @@ CREATE INDEX IF NOT EXISTS market_product_classification_style_idx
CREATE INDEX IF NOT EXISTS market_product_classification_style_scene_product_idx
ON market_product_classification (style_id, scene_id, market_product_id);
ALTER TABLE market_product_classification
ADD COLUMN IF NOT EXISTS category_name TEXT;
WITH latest_categories AS (
SELECT DISTINCT ON (snapshot.market_product_id)
snapshot.market_product_id,
NULLIF(BTRIM(snapshot.category_name), '') AS category_name
FROM fact_market_product_snapshot snapshot
JOIN fact_market_rank_reports report
ON report.id = snapshot.report_id
WHERE NULLIF(BTRIM(snapshot.category_name), '') IS NOT NULL
ORDER BY snapshot.market_product_id,
report.report_date DESC,
snapshot.updated_at DESC,
snapshot.id DESC
)
INSERT INTO market_product_classification
(market_product_id, category_name, source, edited_at, updated_at)
SELECT market_product_id, category_name, 'manual', NOW(), NOW()
FROM latest_categories
ON CONFLICT (market_product_id) DO UPDATE SET
category_name = COALESCE(
NULLIF(BTRIM(market_product_classification.category_name), ''),
EXCLUDED.category_name
),
updated_at = CASE
WHEN NULLIF(BTRIM(market_product_classification.category_name), '') IS NULL
THEN NOW()
ELSE market_product_classification.updated_at
END;
COMMENT ON TABLE market_product IS '三平台市场榜单商品主数据,平台商品 ID 唯一,跨报告日期复用。';
COMMENT ON TABLE fact_market_product_snapshot IS '市场榜单商品日快照;金额为榜单区间上限口径的估算值。';
COMMENT ON TABLE fact_market_product_snapshot IS '市场榜单商品月度快照;金额为榜单区间上限口径的估算值。';
COMMENT ON TABLE market_product_classification IS '品牌行军图人工分类;独立于采集快照,重新采集不得覆盖 manual 数据。';
COMMENT ON COLUMN market_product_classification.category_name IS '品牌行军图人工品类;按商品主数据跨月份复用。';
COMMENT ON COLUMN fact_market_product_snapshot.estimated_gmv IS '天猫=买家数上限×单价;京东/抖音=销售额区间上限。';
COMMENT ON COLUMN fact_market_product_snapshot.quality_status IS 'complete 或缺失价格/买家数/销售额等数据质量状态。';
@@ -243,6 +243,18 @@ def persistent_profile_launch_options(user_data_dir: str) -> dict[str, str]:
return {"user_data_dir": user_data_dir}
def account_profile_launch_options(
user_data_dir: str,
cookies: list[dict[str, Any]] | None,
) -> dict[str, Any]:
"""Launch an isolated member profile with the account vault snapshot."""
options: dict[str, Any] = persistent_profile_launch_options(user_data_dir)
if cookies:
options["cookies"] = cookies
return options
def cookie_fallback_launch_options(
cookies: list[dict[str, Any]] | None,
) -> dict[str, list[dict[str, Any]]]:
@@ -643,7 +655,11 @@ def main() -> int:
try:
response = DynamicFetcher.fetch(
"https://compass.jinritemai.com/shop/business-part",
**persistent_profile_launch_options(args.user_data_dir),
**(
account_profile_launch_options(args.user_data_dir, cookies)
if os.getenv("GYXX_ACCOUNT_ID", "").strip()
else persistent_profile_launch_options(args.user_data_dir)
),
**fetch_kwargs,
)
except Exception as profile_error:
@@ -3337,7 +3337,9 @@ def main(argv: list[str] | None = None) -> int:
cookies = _load_cookies()
fetch_kwargs = dict(
headless=False,
real_chrome=True,
# The installed Chrome has crashed during Douyin Excel downloads;
# keep the isolated profile/cookies but use Scrapling's bundled Chromium.
real_chrome=False,
timeout=120000,
page_action=_guarded_page_automation,
wait=2000,
@@ -243,6 +243,104 @@ def dismiss_password_change_popup(page: Any) -> bool:
return False
def dismiss_marketing_popup(page: Any) -> int:
"""检测并关闭 ERP 主页/报表页的营销广告弹窗, 返回点掉的关闭控件数.
广告弹窗 DOM 不固定 (大促/活动期会出现整屏弹窗挡住点击), 无法像密码过期框
一样按固定文案处理. 这里用通用策略: 在主页 DOM 找可见的小尺寸关闭控件
(class 含 close / 常见关闭文案: × ✕ 关闭 知道了 以后再说 暂不 跳过),
逐个 JS click, 最后补一次 Escape. 与"操作频繁"弹窗一样用 page.evaluate,
只处理主页 DOM, 不进业务 iframe, 误伤面小.
"""
try:
dismissed = page.evaluate(
"""() => {
const closeWords = new Set(['×', '✕', 'x', 'X', '关闭', '知道了', '我知道了',
'以后再说', '暂不', '暂不升级', '跳过']);
const hit = new Set();
const visible = (el) => {
const r = el.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return false;
const s = getComputedStyle(el);
return s.display !== 'none' && s.visibility !== 'hidden' && Number(s.opacity) > 0.01;
};
const clickedAncestor = (el) => {
for (let node = el.parentElement; node; node = node.parentElement) {
if (hit.has(node)) return true;
}
return false;
};
for (const el of document.querySelectorAll('button, a, span, i, img, div, svg')) {
if (hit.has(el) || clickedAncestor(el)) continue;
if (!visible(el)) continue;
const r = el.getBoundingClientRect();
if (r.width > 140 || r.height > 64) continue;
const cls = (el.className && el.className.baseVal !== undefined)
? String(el.className.baseVal) : String(el.className || '');
const text = (el.innerText || el.textContent || '').trim();
const alt = (el.getAttribute && el.getAttribute('alt')) || '';
const looksClose = /close|guanbi/i.test(cls)
|| /close/i.test(alt)
|| closeWords.has(text);
if (!looksClose) continue;
// 只点悬浮层里的控件: 自身或祖先是 fixed/absolute 定位, 避免误点页面流内容
let node = el, floating = false;
for (let i = 0; i < 6 && node && node !== document.body; i++) {
const ps = getComputedStyle(node).position;
if (ps === 'fixed' || ps === 'absolute') { floating = true; break; }
node = node.parentElement;
}
if (!floating) continue;
try { el.click(); hit.add(el); } catch (e) {}
}
return hit.size;
}"""
)
count = int(dismissed or 0)
except Exception:
return 0
if count:
try:
page.keyboard.press("Escape")
except Exception:
pass
page.wait_for_timeout(400)
print(f"[LOGIN] Dismissed marketing popup ({count} close control(s))", flush=True)
return count
def close_foreign_popup_tabs(context: Any, keep_page: Any) -> int:
"""关闭 ERP 会话里弹出的外来页签 (营销推广页/广告落地页), 返回关闭数.
ERP 登录后会从 account.scm121.com 之类地址 window.open 推广页, 新页签
抢走前台焦点后, 后续对工作页的点击/帧定位会失效. 这里只保留工作页和
ERP 自身域名页, 其余非 about:blank 页签一律关掉.
"""
try:
pages = list(getattr(context, "pages", []) or [])
except Exception:
return 0
closed = 0
for popup in pages:
if popup is keep_page:
continue
try:
url = str(getattr(popup, "url", "") or "")
except Exception:
continue
if not url or url.startswith(("about:blank", "chrome://", "devtools://", "edge://")):
continue
if "erp321.com" in url:
continue
try:
popup.close()
closed += 1
print(f"[LOGIN] Closed foreign popup tab: {url[:120]}", flush=True)
except Exception:
continue
return closed
def captcha_exists(page: Any) -> bool:
for selector in CAPTCHA_SELECTORS:
try:
@@ -380,7 +478,18 @@ def click_product_analysis(page: Any, home_url: str, timeout_ms: int) -> str:
自动重试 2 次: ERP 系统偶发 "frame did not load" 是因为点击没生效 (网络/资源慢),
重试前先刷新 home 页面再点一次.
每个点击策略点完用短探测验证报表帧是否真的出现: 2026-09 首页改版后出现过
"点到了不可见模板节点" 的假成功 (打印了 Clicked 但页面无反应), 这时换下一个
策略继续, 而不是直接进入 60s 帧等待把重试烧完.
"""
strategies = (
("frames", lambda: click_product_analysis_in_frames(page)),
("icon", lambda: click_product_analysis_by_icon(page)),
("report-rail", lambda: click_product_analysis_via_report_rail(page)),
("visible-text", lambda: click_visible_text(page, "商品主题分析", timeout_ms)),
("dom-position", lambda: click_text_by_dom_position(page, "商品主题分析")),
("shortcut-position", lambda: click_product_analysis_by_shortcut_position(page)),
)
last_error: Exception | None = None
for attempt in range(1, 3):
try:
@@ -400,29 +509,26 @@ def click_product_analysis(page: Any, home_url: str, timeout_ms: int) -> str:
pass
page.wait_for_timeout(1000 if attempt > 1 else 800)
clicked = click_product_analysis_in_frames(page)
if not clicked:
clicked = click_product_analysis_by_icon(page)
if not clicked:
clicked = click_visible_text(page, "商品主题分析", timeout_ms)
if not clicked:
clicked = click_text_by_dom_position(page, "商品主题分析")
if not clicked:
clicked = click_product_analysis_by_shortcut_position(page)
if not clicked:
raise RuntimeError("Could not find a visible 商品主题分析 entry to click")
# 广告弹窗/外来推广页签可能挡住入口或抢住焦点: 每次尝试点击前先清一遍
close_foreign_popup_tabs(getattr(page, "context", None), page)
dismiss_marketing_popup(page)
print(f"[NAV] Clicked 商品主题分析 (attempt {attempt})")
try:
page.wait_for_load_state("networkidle", timeout=5000)
except Exception:
page.wait_for_timeout(1500)
product_url = wait_for_product_analysis_frame(page, timeout_seconds=60)
if not product_url:
raise RuntimeError("Clicked 商品主题分析, but product analysis frame did not load")
for name, strategy in strategies:
if not strategy():
continue
print(f"[NAV] Clicked 商品主题分析 via {name} (attempt {attempt})")
product_url = wait_for_product_analysis_frame(
page,
timeout_seconds=20,
context=getattr(page, "context", None),
)
if product_url:
return product_url
print(
f"[WARN] {name} click did not open the report frame, "
"trying next strategy"
)
raise RuntimeError("Could not find a visible 商品主题分析 entry to click")
except Exception as exc:
last_error = exc
print(f"[WARN] attempt {attempt} failed: {exc}")
@@ -431,13 +537,28 @@ def click_product_analysis(page: Any, home_url: str, timeout_ms: int) -> str:
raise RuntimeError(f"商品主题分析 重试 2 次仍失败: {last_error}")
def wait_for_product_analysis_frame(page: Any, timeout_seconds: int) -> str | None:
def wait_for_product_analysis_frame(
page: Any,
timeout_seconds: int,
context: Any | None = None,
) -> str | None:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
for frame in page.frames:
frame_url = frame.url or ""
if "itemskuanalysis" in frame_url.lower():
return frame_url
# 报表有时会以新页签形式打开: 找到后记录 URL (采集仍以工作页为准)
if context is not None:
try:
other_pages = [p for p in context.pages if p is not page]
except Exception:
other_pages = []
for other in other_pages:
other_url = str(getattr(other, "url", "") or "")
if "itemskuanalysis" in other_url.lower():
print(f"[NAV] Report opened in a new tab: {other_url[:120]}")
return other_url
page.wait_for_timeout(1000)
return None
@@ -554,6 +675,95 @@ def click_product_analysis_by_dom(context: Any) -> bool:
return bool(context.evaluate(script))
_REPORT_RAIL_SCRIPT = """
() => {
const target = '\\u62a5\\u8868';
const isVisible = (el) => {
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const box = el.getBoundingClientRect();
return box.width > 1 && box.height > 1;
};
const fire = (el) => {
el.scrollIntoView({ block: 'center', inline: 'center' });
for (const type of ['mouseover', 'mousedown', 'mouseup', 'click']) {
el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
}
el.click();
};
for (const el of document.querySelectorAll('span,div,a,li,p,i')) {
const text = (el.innerText || el.textContent || '').trim();
if (text !== target) continue;
if (!isVisible(el)) continue;
const box = el.getBoundingClientRect();
// 左侧栏图标文字: 贴左缘的小块, 避免命中正文里其他"报表"字样
if (box.left > 100 || box.width > 100 || box.height > 100) continue;
fire(el);
return true;
}
return false;
}
"""
_REPORT_ENTRY_SCRIPT = """
() => {
const target = '\\u5546\\u54c1\\u4e3b\\u9898\\u5206\\u6790';
const isVisible = (el) => {
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const box = el.getBoundingClientRect();
return box.width > 1 && box.height > 1;
};
const fire = (el) => {
el.scrollIntoView({ block: 'center', inline: 'center' });
for (const type of ['mouseover', 'mousedown', 'mouseup', 'click']) {
el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
}
el.click();
};
for (const el of document.querySelectorAll('span,div,a,li,p')) {
const text = (el.innerText || el.textContent || '').trim();
if (text !== target) continue;
if (!isVisible(el)) continue;
fire(el);
return true;
}
return false;
}
"""
def click_product_analysis_via_report_rail(page: Any) -> bool:
"""新版 epaas 首页: 「商品主题分析」入口收进左侧栏「报表」面板.
2026-09 首页改版后入口不再平铺在首页; 旧的文本/坐标策略点不到入口,
最后的坐标兜底会点错到同区域的「ERP分销版」, 新开分销推广页签.
正确路径: 点最左侧栏「报表」图标展开面板, 再点面板里的「商品主题分析」.
用 DOM 事件直点 (不走视口坐标), 避免页签前台切换/布局偏移导致点歪.
先探入口是否已可见 (面板可能已展开), 再点栏, 展开后逐 frame 点入口.
"""
for frame in page.frames:
try:
if frame.evaluate(_REPORT_ENTRY_SCRIPT):
return True
except Exception:
continue
for frame in page.frames:
try:
if not frame.evaluate(_REPORT_RAIL_SCRIPT):
continue
except Exception:
continue
page.wait_for_timeout(900)
for inner in page.frames:
try:
if inner.evaluate(_REPORT_ENTRY_SCRIPT):
return True
except Exception:
continue
return False
def click_text_by_dom_position(page: Any, text: str) -> bool:
script = """
(text) => {
@@ -741,11 +951,16 @@ def make_page_action(
# 兜底: 跳到 epaas 主页后, ERP 有时还会在异步回调里再弹一次"密码过期"
# 提醒, 在点商品主题分析前再扫一遍 + 点掉, 防止点不到目标按钮.
# 大促/活动期的营销广告弹窗同样在这里先清掉; 登录后新开的外来推广
# 页签 (抢前台焦点) 也在这一起关掉.
try:
for _ in range(3):
if not dismiss_password_change_popup(page):
dismissed = 1 if dismiss_password_change_popup(page) else 0
dismissed += dismiss_marketing_popup(page)
dismissed += close_foreign_popup_tabs(context, page)
if not dismissed:
break
_ts("post-login password-expiry popup dismissed")
_ts("post-login popup/tab dismissed (password/marketing/foreign-tab)")
page.wait_for_timeout(500)
except Exception as exc:
print(f"[WARN] post-login popup cleanup skipped: {exc}")
@@ -781,18 +781,35 @@ def _parse_files(
raise FileNotFoundError("未找到京东全站营销 SPU CSV")
if non_full_path is None or not Path(non_full_path).is_file():
raise FileNotFoundError("未找到京东非全站营销 SKU CSV")
full_records: list[dict[str, Any]] = []
full_empty_error: ValueError | None = None
try:
full_records = parse_full_csv(
Path(full_path),
target_date,
advertiser_pin=advertiser_pin,
shop_account=shop_account,
)
except ValueError as exc:
# 全站营销计划可能当天整体停投(京准通页面花费为 0)。此时全站报表
# 只有汇总占位行;只要非全站报表有数据就视为合法零投放,否则保留
# 原错误(更可能是数据未就绪)。
full_empty_error = exc
sku_records = parse_non_full_csv(
Path(non_full_path),
target_date,
advertiser_pin=advertiser_pin,
shop_account=shop_account,
)
if not full_records:
if sku_records:
print(
f"[WARN] {Path(full_path).name} 没有目标日期的当前时间 SPU 行,"
f"而非全站 SKU 报表有 {len(sku_records)} 行:判定当日全站营销"
"零投放,按无全站数据处理"
)
elif full_empty_error is not None:
raise full_empty_error
return full_records, sku_records
@@ -12,7 +12,7 @@ product_daily_metrics 表 (ON CONFLICT 全量覆盖)。
源数据列数 (已验证):
dy xlsx: 22 列 (row 0 = 表头, 之后是数据/合计行)
jd xlsx: 34 列 (row 0 = 表头, row 1 = 合计行, 之后是数据)
jd xlsx: 按表头映射 (row 0 = 表头, 之后是数据/合计行)
tm xls: 31/38 列 (row 0-3 空白, row 4 = 表头, row 5+ = 数据)
"""
from __future__ import annotations
@@ -228,101 +228,102 @@ def parse_dy_xlsx(xlsx_path: Path) -> Iterable[dict]:
# ── jd 解析 ───────────────────────────────────────────────────────────────
def parse_jd_xlsx(xlsx_path: Path, shop_id: str) -> Iterable[dict]:
"""京东 全部渠道_商品明细_分天下载.xlsx → record dicts.
"""解析京东商品明细 xlsx,并按表头名称映射字段。
列顺序 (0-indexed):
0 时间
1 商品ID
2 商品名称
3 二级类目 → category_l1
4 三级类目 → category_l2
5 货号 → product_no
6 访客数 → visitors
7 浏览量 → page_views
8 商品关注数
9 加购商品件数 → add_cart_qty
10 加购人数 → add_cart_buyers
11 成交客户数 → paid_buyers
12 成交单量 → raw (订单数,非件数)
13 成交商品件数 → paid_qty
14 成交金额 → paid_amount
15 成交转化率 → conversion_rate
16 UV价值 → uv_value
17 最近上架时间
18 详情页跳出率 → detail_page_bounce_rate
19 搜索曝光量
20 搜索点击次数
21 搜索点击率
22 系统预测未来7天销量
23 下单客户数
24 下单商品件数
25 下单金额
26 下单成交转化率
27 下单转化率
28 PV现货率
29 客单价 → avg_order_value
30 件单价
31 取消及售后退款金额 → refund_amount
32 取消及售后退款件数 → refund_orders
33 取消及售后退款单量
京东导出会增减字段,也可能加入对比日列;按固定下标读取会把对比列
当成当天指标。核心字段按同义表头匹配,其余原始列保存在 raw_data。
"""
wb = openpyxl.load_workbook(xlsx_path, data_only=True)
try:
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
if not rows:
rows = ws.iter_rows(values_only=True)
header = next(rows, None)
if not header:
return
for r in rows[1:]:
if not r or r[0] is None:
header_names = [_strip(value) for value in header]
header_index = {name: i for i, name in enumerate(header_names) if name}
def column(*aliases: str) -> int | None:
return next((header_index[name] for name in aliases if name in header_index), None)
columns = {
"stat_date": column("时间", "统计日期"),
"product_id": column("SPU", "商品ID", "商品编号"),
"product_name": column("SPU名称", "商品名称"),
"product_no": column("货号"),
"category_l1": column("二级类目"),
"category_l2": column("三级类目"),
"visitors": column("商品访客数", "访客数"),
"page_views": column("商品浏览量", "浏览量"),
"add_cart_qty": column("加购商品件数", "商品加购件数"),
"add_cart_buyers": column("加购客户数", "加购人数", "商品加购人数"),
"paid_amount": column("成交金额", "支付金额"),
"paid_qty": column("成交商品件数", "支付件数"),
"paid_buyers": column("成交客户数", "支付买家数"),
"refund_amount": column("取消及售后退款金额", "成功退款金额"),
"refund_orders": column(
"取消及售后退款商品件数", "取消及售后退款件数", "退款订单数"
),
"conversion_rate": column("成交转化率", "商品支付转化率", "支付转化率"),
"avg_order_value": column("客单价", "成交客单价"),
"uv_value": column("UV价值", "访客平均价值"),
"detail_page_bounce_rate": column("商品详情页跳出率", "详情页跳出率"),
}
required_columns = ("stat_date", "product_id", "paid_amount", "paid_qty", "paid_buyers")
missing_columns = [name for name in required_columns if columns[name] is None]
if missing_columns:
raise ValueError(
f"无法识别京东商品明细表头: {xlsx_path.name}; "
f"missing={missing_columns!r}; headers={header_names[:12]!r}"
)
def value(row: tuple[Any, ...], key: str) -> Any:
index = columns[key]
return row[index] if index is not None and index < len(row) else None
for row in rows:
if not row:
continue
stat_date = _to_date(r[0])
product_id = _strip(r[1])
stat_date = _to_date(value(row, "stat_date"))
product_id = _strip(value(row, "product_id"))
if _is_total_or_invalid_row(product_id, stat_date):
continue
raw = {
"商品关注数": r[8],
"成交单量": r[12],
"最近上架时间": _strip(r[17]) if r[17] is not None else None,
"搜索曝光量": r[19],
"搜索点击次数": r[20],
"搜索点击率": r[21],
"系统预测未来7天销量": r[22],
"下单客户数": r[23],
"下单商品件数": r[24],
"下单金额": r[25],
"下单成交转化率": r[26],
"下单转化率": r[27],
"PV现货率": r[28],
"件单价": r[30],
"取消及售后退款单量": r[33],
raw_data = {
name: cell
for name, cell in zip(header_names, row)
if name and cell not in (None, "", "-")
}
yield {
"platform": "jd",
"stat_date": stat_date,
"shop_id": shop_id,
"product_id": product_id,
"product_name": _strip(r[2]) or None,
"product_no": _strip(r[5]) or None,
"category_l1": _strip(r[3]) or None,
"category_l2": _strip(r[4]) or None,
"visitors": _to_int(r[6]),
"page_views": _to_int(r[7]),
"add_cart_qty": _to_int(r[9]),
"add_cart_buyers": _to_int(r[10]),
"paid_amount": _to_num(r[14]),
"paid_qty": _to_int(r[13]),
"paid_buyers": _to_int(r[11]),
"refund_amount": _to_num(r[31]),
"refund_orders": _to_int(r[32]),
"conversion_rate": _to_pct(r[15]),
"avg_order_value": _to_num(r[29]),
"uv_value": _to_num(r[16]),
"detail_page_bounce_rate": _to_pct(r[18]),
"raw_data": {k: v for k, v in raw.items() if v not in (None, "", "-")},
"product_name": _strip(value(row, "product_name")) or None,
"product_no": _strip(value(row, "product_no")) or None,
"category_l1": _strip(value(row, "category_l1")) or None,
"category_l2": _strip(value(row, "category_l2")) or None,
"visitors": _to_int(value(row, "visitors")),
"page_views": _to_int(value(row, "page_views")),
"add_cart_qty": _to_int(value(row, "add_cart_qty")),
"add_cart_buyers": _to_int(value(row, "add_cart_buyers")),
"paid_amount": _to_num(value(row, "paid_amount")),
"paid_qty": _to_int(value(row, "paid_qty")),
"paid_buyers": _to_int(value(row, "paid_buyers")),
"refund_amount": _to_num(value(row, "refund_amount")),
"refund_orders": _to_int(value(row, "refund_orders")),
"conversion_rate": _to_pct(value(row, "conversion_rate")),
"avg_order_value": _to_num(value(row, "avg_order_value")),
"uv_value": _to_num(value(row, "uv_value")),
"detail_page_bounce_rate": _to_pct(
value(row, "detail_page_bounce_rate")
),
"raw_data": raw_data,
"source_file": xlsx_path.name,
}
finally:
wb.close()
# ── tm 解析 ───────────────────────────────────────────────────────────────
@@ -1,14 +1,19 @@
"""京东商品明细页主图采集脚本。
"""京东商品主图采集脚本(商智新版 jdsz.jd.com)。
采集流程:
1. 复用 vendors/jd-data-flow/playwright_profile 登录态 (同 jd_product_data_collector.py)
2. 进入商智 -> 旧版 -> 商品明细页
3. 点「指标配置」勾选「搜索曝光量」「搜索点击次数」两列
4. 逐款在多 SPU 搜索框输入该款的 SPU 列表 -> 点查询按钮
5. 点展开加号 .jmti-plus-circle-f 展开所有 SKU 子行
6. 解析表格 HTML 拿每个 SKU 的主图 URL / 曝光量 / 点击量
7. 按 SPU 聚合: 取曝光量最大的 SKU 主图作为该 SPU 的代表
8. 下载主图 + 写 JSON 到 data/主图信息/<date>/<款式>/jd_main_images.json
2026-09 起京东商智旧版 (sz.jd.com) 对本账号关闭,商品明细页迁移到新版,
且「搜索曝光量/搜索点击次数」两列不再出现在商品明细的指标配置里。
改为两段采集(日期维度均为页面默认的「昨天」,与旧采集语义一致):
1. 商品明细页 (jdsz.jd.com/szweb/view/product/productDetail.html):
按款式搜索 SPU -> 展开 SPU 树 -> 取全部 SKU 的主图 URL 与 SKU->SPU 归属。
2. 搜索数据概览-商品分析 (jdsz.jd.com/squidweb/view/flow/searchOverview.html):
按 SKU 批量筛选(每批 <=8 个,避免表格分页),取「曝光次数/点击次数」,
即旧版商品明细的「搜索曝光量/搜索点击次数」。
合并后沿用原语义:每款按 SPU 聚合,取曝光量最大的 SKU 主图作为该 SPU 代表,
下载主图并写 JSON/MD 到 data/主图信息/<date>/<款式>/,供飞书与 PG 插入脚本消费。
新版页面为 React (jmtv2/jd-*) 组件,展开、查询等交互必须用 Playwright 原生
click(完整指针事件序列),evaluate 合成 click 不触发 onClick。
"""
from __future__ import annotations
@@ -21,6 +26,7 @@ import time
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from runtime_paths import (
RAW_DATA_ROOT,
@@ -42,6 +48,7 @@ if str(VENDOR_SOURCE_ROOT) not in sys.path:
from jd_data_collector import (
DEFAULT_CONFIG,
_is_shangzhi_url,
close_popup_if_present,
step_enter_shangzhi,
step_login,
)
@@ -65,6 +72,14 @@ OUTPUT_DIR = RAW_DATA_ROOT / "主图信息"
DEBUG_DIR = TMP_ROOT / "debug" / "jd_main_image_snapshot"
DEBUG = os.getenv("JD_MAIN_IMAGE_DEBUG", "0") == "1"
# 直接使用新版商品明细页,避免旧版别名在批量采集过程中偶发 ERR_CONNECTION_CLOSED。
DETAIL_URL = "https://jdsz.jd.com/szweb/view/product/productDetail.html"
SEARCH_OVERVIEW_URL = "https://jdsz.jd.com/squidweb/view/flow/searchOverview.html"
# 搜索概览商品分析表每页 10 行,按 SKU 筛选的每批数量必须小于每页行数
OVERVIEW_SKU_CHUNK_SIZE = 8
# 该款暂无京东商品记录;仅最终仍无 SKU 行时按预期跳过,不影响其他款式。
_EXPECTED_EMPTY_JD_STYLES = frozenset({"黑曜石电脑包"})
_STATIC_STYLE_SPU_GROUPS = [
{"style": "盖亚斜挎", "spus": ["10022621048112", "10023155725944", "10030097050220"]},
{"style": "极星pro", "spus": ["10025628785945", "10026601031834", "10026600460605"]},
@@ -113,25 +128,108 @@ def now_label() -> str:
return _DATE_LABEL or datetime.now().strftime("%Y-%m-%d")
def step_goto_product_detail(page):
"""跳转到商品明细页面"""
print("[Step] 跳转到商品明细页面...")
page.goto(
"https://sz.jd.com/szweb/sz/view/productAnalysis/productDetail.html",
wait_until="networkidle",
def _goto(page, url: str) -> None:
"""Navigate without waiting for商智's never-idle background requests."""
target_path = urlparse(url).path.rstrip("/").lower()
last_error = None
for attempt in range(1, 4):
try:
page.goto(url, wait_until="domcontentloaded", timeout=60000)
return
except Exception as exc:
last_error = exc
try:
current_url = str(page.url)
except Exception:
current_url = ""
current_path = urlparse(current_url).path.rstrip("/").lower()
same_route = bool(target_path and target_path == current_path)
if "productanalysis/productdetail" in target_path and "productdetail" in current_path:
same_route = True
error_text = str(exc).lower()
transient = any(
marker in error_text
for marker in ("err_connection_", "timed out", "timeout")
)
page.wait_for_timeout(5000)
_wait_for_product_detail(page)
if same_route and not transient:
print(f" [WARN] 商智页面加载等待超时,保留已提交页面: {exc}")
return
if attempt >= 3 or not transient:
if same_route:
print(f" [WARN] 商智页面已提交但加载等待失败,保留当前页面: {exc}")
return
raise
print(f" [WARN] 商智导航暂时失败,第 {attempt}/3 次后重试: {exc}")
page.wait_for_timeout(1000 * attempt)
if last_error is not None:
raise last_error
_PRODUCT_DETAIL_SEARCH_SELECTORS = (
"input.jd-input-multiple-input-input",
".jmtd-multiple-input input",
".jmtd-multiple-input",
)
def _locate_product_detail_search(page):
"""Return the visible SPU/product-ID search control across JD UI variants."""
for selector in _PRODUCT_DETAIL_SEARCH_SELECTORS:
try:
locator = page.locator(selector).first
if locator.is_visible(timeout=500):
return locator
except Exception:
continue
return None
def _close_product_detail_overlays(page) -> None:
"""Best-effort cleanup immediately before interacting with the detail page."""
try:
close_popup_if_present(page)
except Exception as exc:
# The JD extension is outside the collector's control. A failure to
# dismiss it is recoverable as long as the target control is usable.
print(f" [WARN] 商品明细页弹层清理失败,继续检查页面控件: {exc}")
def step_goto_product_detail(page):
"""跳转到(新版)商品明细页面并切到「昨天」维度。
明细页默认「实时」维度会把无实时活动的商品整行隐藏(显示「暂无数据」),
「昨天」维度与旧版页面语义一致:所有搜索到的 SPU 都会列出(指标可为 0),
SPU 树才完整。
"""
print("[Step] 跳转到商品明细页面...")
_close_product_detail_overlays(page)
# The新版 route occasionally commits without mounting its React content
# on the first navigation. Retry the route once, but never retry a login
# failure because that would only hide the account-state diagnosis.
for attempt in range(2):
try:
_goto(page, DETAIL_URL)
page.wait_for_timeout(2500)
_close_product_detail_overlays(page)
_wait_for_product_detail(page, timeout_ms=15_000)
_select_yesterday(page)
page.wait_for_timeout(3000)
return
except Exception:
if _looks_like_login_url(str(page.url)) or attempt == 1:
raise
print(" [WARN] 商品明细页首屏控件未出现,重新加载新版页面重试")
raise RuntimeError("京东商品明细页加载失败")
def _wait_for_product_detail(page, timeout_ms: int = 30_000) -> None:
"""Wait for the real 商智商品明细 page and reject login redirects.
"""Wait for the 商智商品明细 page and reject login redirects.
JD may first load the 商智 URL and then asynchronously redirect an expired
session to ``passport.jd.com``. The old collector only checked the shop
home URL, so it proceeded with every style and reported a misleading
search-input timeout 34 times. Validate the final URL and the page's
search control once, before entering the style loop.
session to ``passport.jd.com``. Validate the final URL and the page's
multi-value search input once, before entering the style loop.
"""
deadline = time.monotonic() + max(timeout_ms, 1) / 1000
@@ -152,8 +250,8 @@ def _wait_for_product_detail(page, timeout_ms: int = 30_000) -> None:
# only accept the expected Shangzhi hosts.
if _is_shangzhi_url(last_url):
try:
search = page.locator(".jmtd-multiple-input").first
if search.is_visible(timeout=800):
search = _locate_product_detail_search(page)
if search is not None:
return
except Exception:
pass
@@ -169,139 +267,99 @@ def _wait_for_product_detail(page, timeout_ms: int = 30_000) -> None:
f"{last_url};请先刷新 jd-shop 账号登录态后再运行"
)
raise RuntimeError(
"京东商品明细页未加载完成,未找到 SPU 搜索框;"
"京东商品明细页未加载完成,未找到商品ID搜索框;"
f"当前 URL: {last_url or '<unknown>'}"
)
def close_guide_if_present(page):
"""检测并关闭新功能引导弹窗"""
def goto_search_overview(page) -> None:
"""跳转到搜索数据概览(商品分析 tab 默认展示)并等待 SKU 筛选框就绪。"""
_goto(page, SEARCH_OVERVIEW_URL)
page.wait_for_timeout(6000)
_wait_for_search_overview(page)
def _wait_for_search_overview(page, timeout_ms: int = 30_000) -> None:
deadline = time.monotonic() + max(timeout_ms, 1) / 1000
last_url = ""
while time.monotonic() < deadline:
try:
page.locator('.jmtp-guide-content, .jmtp-guide-con').first.wait_for(state="visible", timeout=3000)
page.locator('.jmtp-guide-footer button', has_text="完成").first.click()
page.wait_for_timeout(2000)
last_url = str(page.url)
except Exception:
pass
def enable_columns(page) -> None:
"""点「指标配置」打开弹窗 -> 勾选「搜索曝光量」「搜索点击次数」-> 点「确定」。
这两个指标默认不在表格里,需要在「指标配置」modal 里勾选才会显示成列。
用 Playwright 原生 locator.check() 而不是 evaluate + label.click(),
因为 React 应用里手动 click label 有时不触发 onChange 事件,
导致视觉上 checked 但 React state 没更新,点确定后状态丢失。
"""
# 点「指标配置」按钮打开 modal
clicked = page.evaluate(r"""() => {
const btns = Array.from(document.querySelectorAll('button'));
const target = btns.find((b) => (b.textContent || '').trim() === '指标配置' && b.offsetParent !== null);
if (target) { target.click(); return true; }
return false;
}""")
if not clicked:
print(" [WARN] 未找到「指标配置」按钮,跳过列启用")
last_url = ""
if _looks_like_login_url(last_url):
raise RuntimeError(
"京东商智登录态已失效:搜索数据概览跳转到登录页 "
f"{last_url};请先刷新 jd-shop 账号登录态后再运行"
)
try:
if page.locator("input.jmt-core-input").first.is_visible(timeout=800):
return
# 等 modal 渲染完成
try:
page.wait_for_selector('.indicators-config-modal:not(.jmtd-modal-hidden)', timeout=5000)
except Exception:
pass
page.wait_for_timeout(1500)
# 找 modal 内 checkbox 的 input 元素并逐个用 Playwright 原生 check() 勾选
targets = ["搜索曝光量", "搜索点击次数"]
toggled = 0
for label_text in targets:
# 定位: 在 modal 内, .jmtd-checkbox-control 包含目标文案, 内部的 input[type=checkbox]
loc = page.locator(
f'.indicators-config-modal .jmtd-checkbox-control:has-text("{label_text}") input[type="checkbox"]'
).first
try:
loc.wait_for(state="attached", timeout=5000)
# 用 check() 而不是 click(),Playwright 会等 input 真的变成 checked 状态
loc.check(force=True, timeout=5000)
toggled += 1
print(f" [INFO] 勾选了 {label_text}")
except Exception as exc:
# check() 失败可能是 input 不可见或被遮挡,降级用 evaluate click label
print(f" [WARN] check {label_text} 失败: {exc}, 降级用 label click")
page.evaluate(r"""([text]) => {
const labels = Array.from(document.querySelectorAll(
'.indicators-config-modal .jmtd-checkbox-control, .jmtd-modal .jmtd-checkbox-control'
)).filter((el) => el.offsetParent !== null && (el.textContent || '').includes(text));
if (labels.length) labels[0].click();
}""", [label_text])
page.wait_for_timeout(500)
toggled += 1
page.wait_for_timeout(1000)
except Exception:
time.sleep(0.5)
raise RuntimeError(
"京东搜索数据概览页未加载完成,未找到 SKU 筛选输入框;"
f"当前 URL: {last_url or '<unknown>'}"
)
# 验证勾选状态 (用 label class 判断真实视觉状态)
verified = page.evaluate(r"""() => {
const targetLabels = ['搜索曝光量', '搜索点击次数'];
const result = {};
for (const target of targetLabels) {
const labels = Array.from(document.querySelectorAll(
'.indicators-config-modal .jmtd-checkbox-control, .jmtd-modal .jmtd-checkbox-control'
)).filter((el) => el.offsetParent !== null && (el.textContent || '').includes(target));
if (!labels.length) { result[target] = 'not_found'; continue; }
result[target] = labels[0].classList.contains('jmtd-form-control-checked') ? 'checked' : 'unchecked';
}
return result;
}""")
print(f" [INFO] 勾选状态: {verified}")
# 点「确定」应用 (modal 内 footer 的确定按钮)
confirmed = page.evaluate(r"""() => {
const btns = Array.from(document.querySelectorAll('.jmtd-modal-footer button, .jmtd-modal button, button'));
const target = btns.find((b) => (b.textContent || '').trim() === '确定' && b.offsetParent !== null);
if (target) { target.click(); return true; }
return false;
}""")
if not confirmed:
print(" [WARN] 未找到「确定」按钮,尝试按 Enter")
page.keyboard.press("Enter")
page.wait_for_timeout(3000)
def _select_yesterday(page) -> None:
"""把日期维度显式切到「昨天」(页面默认即昨天,此步为幂等保险)。"""
try:
loc = page.get_by_text("昨天", exact=True).first
if loc.is_visible(timeout=2000):
loc.click(timeout=3000)
page.wait_for_timeout(1500)
except Exception:
pass
def _clear_multiple_tags(page) -> None:
"""清空商品ID多值输入框里的已有标签(新版 jd-input 的清空图标)。"""
try:
clear = page.locator(".jd-input-clear-all").first
if clear.is_visible(timeout=1500):
clear.click(timeout=3000)
page.wait_for_timeout(800)
except Exception:
pass
def search_by_spus(page, spus: list[str]) -> None:
"""在多 SPU 搜索框输入 SPU 列表并触发查询。
每输一个 SPU 按 Enter 形成一个 tag,全部输完后点查询按钮触发过滤。
"""
"""在商品明细页的「商品ID」多值输入框输入 SPU 列表并点查询。"""
print(f" 搜索 SPU: {spus}")
wrapper = page.locator('.jmtd-multiple-input').first
wrapper.wait_for(state="visible", timeout=10000)
wrapper = _locate_product_detail_search(page)
if wrapper is None:
raise RuntimeError("京东商品明细页未找到商品ID搜索框")
_clear_multiple_tags(page)
wrapper.click()
page.wait_for_timeout(500)
# 清空旧搜索 tag
page.evaluate(r"""() => {
const closeBtns = Array.from(document.querySelectorAll('.jmtd-multiple-base-input-tag-wrapper .jmti-close'));
for (const btn of closeBtns) btn.click();
}""")
page.wait_for_timeout(500)
for spu in spus:
page.keyboard.type(spu, delay=80)
page.wait_for_timeout(400)
page.keyboard.type(spu, delay=60)
page.wait_for_timeout(300)
page.keyboard.press("Enter")
page.wait_for_timeout(400)
page.wait_for_timeout(300)
# 点查询按钮触发过滤 (jmti-search icon button)
search_clicked = page.evaluate(r"""() => {
const btn = document.querySelector('.jmtd-input-search-button');
if (btn) { btn.click(); return true; }
return false;
}""")
if not search_clicked:
try:
page.locator("button:visible", has_text="查询").first.click(timeout=5000)
except Exception:
page.keyboard.press("Enter")
page.wait_for_timeout(5000)
page.wait_for_timeout(6000)
def expand_sku_rows(page) -> int:
"""点所有 .jmti-plus-circle-f 展开图标,展开子 SKU 行。返回展开数。"""
count = page.evaluate(r"""() => {
def expand_all_spu_rows(page, max_rounds: int = 30) -> int:
"""逐个原生点击 SPU 行的树形展开按钮(+ 号),展开全部 SKU 子行。
展开后按钮仍在原行(图标切为 -),用已展开的 SPU 行文本做去重防止死循环。
"""
expanded: set[str] = set()
for _ in range(max_rounds):
target_id = page.evaluate(r"""([done]) => {
const visible = (el) => {
if (!el) return false;
const style = window.getComputedStyle(el);
@@ -309,46 +367,47 @@ def expand_sku_rows(page) -> int:
return style.display !== 'none' && style.visibility !== 'hidden'
&& rect.width > 0 && rect.height > 0;
};
const icons = Array.from(document.querySelectorAll(
'.jmti-plus-circle-f[data-event-name="expand"]'
)).filter(visible);
for (const icon of icons) icon.click();
return icons.length;
}""")
print(f" 展开 {count} 个 SPU 父行")
for (const btn of document.querySelectorAll('button.sz-table-tree-icon')) {
if (!visible(btn)) continue;
const row = btn.closest('tr');
const idEl = row ? row.querySelector('.sxu-id-text') : null;
const m = idEl ? (idEl.textContent || '').match(/(\d{8,})/) : null;
if (m && !done.includes(m[1])) return m[1];
}
return '';
}""", [sorted(expanded)])
if not target_id:
break
try:
page.locator("tr.jmt-base-table-tr", has_text=target_id).first.locator(
"button.sz-table-tree-icon"
).first.click(timeout=5000)
except Exception as exc:
print(f" [WARN] 展开 SPU 行失败: {exc}")
break
expanded.add(target_id)
page.wait_for_timeout(1500)
page.wait_for_timeout(2000)
return int(count or 0)
print(f" 展开 {len(expanded)} 个 SPU 父行")
return len(expanded)
def upgrade_jd_image_url(url: str) -> str:
"""把 JD 360buyimg 的 URL 升级到大尺寸 (n12)。
表格里 img src 是小尺寸 (n5), 下载下来很糊 (6KB 缩略图)。
替换成 n12 拿清晰大图 (~1MB)。s800x800/s1000x1000 这张图不支持 (400),
所以用 n12 兜底。
表格里 img src 是小尺寸 (n3/n5),下载下来很糊。
替换成 n12 拿清晰大图;s800x800/s1000x1000 这张图不支持 (400),所以用 n12 兜底。
"""
return re.sub(r"//img\d+\.360buyimg\.com/n\d+/", "//img10.360buyimg.com/n12/", url or "")
def extract_sku_rows(page) -> list[dict[str, Any]]:
"""从展开后的表格 HTML 提取每个 SKU 子行的数据。
"""从商品明细页展开后的 SPU 树提取每个 SKU 子行的主图 URL。
返回 [{spu, sku_id, image_url, image_key, impressions, clicks}]。
策略:
- 遍历 tbody 的 tr.jmtd-table-row
- 跳过合计行 (.jmtd-table-plugin-total-row)
- 跟踪 current_spu: 遇到带展开图标的行就是 SPU 父行, 更新 current_spu
- 无展开图标的行是 SKU 子行, 归到 current_spu
- SKU ID 从 a[href*="//item.jd.com/<SKU>.html"] 提取
- 指标值从对应列的 .first-value 提取
列顺序 (实测):
商品信息(0) / 访客数(1) / 浏览量(2) / 成交客户数(3) / 成交商品件数(4) /
成交金额(5) / 搜索曝光量(6) / 搜索点击次数(7) / 操作(8)
但列顺序会随用户在「指标配置」里的勾选变化,所以用表头列名动态查找列索引,
而不是硬编码 6/7。
返回 [{spu, sku_id, image_url}]。新版表格结构:
- 行 tr.jmt-base-table-tr;SPU 父行带 button.sz-table-tree-icon
- 行内 .sxu-id-text 文本为 "SPU: <id>"(父行)或 "SKU: <id>"(子行)
- 主图在行内第一个 img(360buyimg 域)
"""
rows = page.evaluate(r"""() => {
const visible = (el) => {
@@ -366,7 +425,7 @@ def extract_sku_rows(page) -> list[dict[str, Any]]:
};
const imgUrl = (img) => {
if (!img) return '';
const attrs = ['currentSrc', 'src', 'data-src', 'data-lazy-src', 'data-original'];
const attrs = ['currentSrc', 'src', 'data-src'];
for (const attr of attrs) {
const v = attr === 'currentSrc' ? img.currentSrc : img.getAttribute(attr);
const n = normalizeUrl(v);
@@ -375,66 +434,24 @@ def extract_sku_rows(page) -> list[dict[str, Any]]:
return '';
};
// 找表头列名 -> 列索引 (跳过「操作」列右侧的固定列, 用 thead 的 th)
const headerCells = Array.from(document.querySelectorAll('thead th.jmtd-table-cell'))
.filter(visible);
const colNames = headerCells.map((th) => (th.textContent || '').replace(/\s+/g, '').trim());
const idxExposure = colNames.findIndex((n) => n.includes('搜索曝光') || n.includes('曝光量'));
const idxClick = colNames.findIndex((n) => n.includes('搜索点击') || n.includes('点击次数') || n.includes('点击量'));
// 找所有数据行 (跳过合计行)
const dataRows = Array.from(document.querySelectorAll('tbody tr.jmtd-table-row'))
.filter(visible)
.filter((r) => !r.classList.contains('jmtd-table-plugin-total-row'));
let currentSpu = '';
const result = [];
for (const row of dataRows) {
// 检测是否是 SPU 父行 (有展开/收起图标)
const expandIcon = row.querySelector('.jmti-plus-circle-f, .jmti-minus-circle-f');
const isParent = !!expandIcon;
let currentSpu = '';
for (const row of document.querySelectorAll('tr.jmt-base-table-tr')) {
if (!visible(row)) continue;
const isParent = !!row.querySelector('button.sz-table-tree-icon');
const idEl = row.querySelector('.sxu-id-text');
const idText = idEl ? (idEl.textContent || '').trim() : '';
const cells = Array.from(row.querySelectorAll('td.jmtd-table-cell')).filter(visible);
// 提取 SPU ID (从 .jmtd-table-formatter-name-desc 的 "SPU:<id>" 文本)
const spuDesc = row.querySelector('.jmtd-table-formatter-name-desc');
if (spuDesc) {
const m = (spuDesc.textContent || '').match(/SPU[::]\s*(\d+)/);
if (isParent) {
const m = idText.match(/SPU[::]\s*(\d+)/);
if (m) currentSpu = m[1];
continue;
}
// 提取 SKU ID (从 a[href*="//item.jd.com/<SKU>.html"])
const skuLink = row.querySelector('a[href*="item.jd.com"]');
let skuId = '';
if (skuLink) {
const m = (skuLink.getAttribute('href') || '').match(/item\.jd\.com\/(\d+)\.html/);
if (m) skuId = m[1];
}
// 提取主图 URL (img.jmtd-table-formatter-name-img)
const img = row.querySelector('img.jmtd-table-formatter-name-img');
const imageUrl = imgUrl(img);
// 提取指标值
const cellValue = (idx) => {
if (idx < 0 || idx >= cells.length) return '';
const fv = cells[idx].querySelector('.first-value');
return fv ? (fv.textContent || '').trim() : '';
};
// SPU 父行也有一行数据 (代表该 SPU 汇总), 但需求是「取最大 SKU 主图」,
// 所以只取 SKU 子行 (isParent=false) 的数据, SPU 父行只用来更新 currentSpu
if (isParent) continue;
if (!imageUrl) continue; // 没图的行跳过 (空行)
result.push({
spu: currentSpu,
sku_id: skuId,
image_url: imageUrl,
impressions_text: cellValue(idxExposure),
clicks_text: cellValue(idxClick),
});
const m = idText.match(/SKU[::]\s*(\d+)/);
if (!m) continue;
const image = imgUrl(row.querySelector('img'));
if (!image) continue;
result.push({spu: currentSpu, sku_id: m[1], image_url: image});
}
return result;
}""")
@@ -442,27 +459,154 @@ def extract_sku_rows(page) -> list[dict[str, Any]]:
return []
parsed: list[dict[str, Any]] = []
for row in rows:
image_url = row.get("image_url") or ""
image_url = upgrade_jd_image_url(row.get("image_url") or "")
if not image_url:
continue
image_url = upgrade_jd_image_url(image_url)
parsed.append({
"spu": str(row.get("spu") or ""),
"sku_id": str(row.get("sku_id") or ""),
"image_url": image_url,
"image_key": canonical_image_key(image_url),
"impressions": parse_number(row.get("impressions_text")),
"clicks": parse_number(row.get("clicks_text")),
})
return parsed
def aggregate_by_spu(sku_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""按 SPU 聚合: 每个 SPU 取曝光量最大的 SKU 的主图作为代表。"""
def _locate_sku_filter_input(page):
"""定位搜索概览商品分析表的「请输入SKU进行查询」输入框。
页面上有多个 jmt-core-input(另一个是「跳至页」输入框),
用「请输入SKU」文案的祖先链精确匹配,匹配不到再退回第一个。
"""
idx = page.evaluate(r"""() => {
const inputs = Array.from(document.querySelectorAll('input.jmt-core-input'));
for (let i = 0; i < inputs.length; i++) {
let p = inputs[i].parentElement;
for (let k = 0; k < 8 && p; k++) {
if ((p.textContent || '').includes('请输入SKU')) return i;
p = p.parentElement;
}
}
return -1;
}""")
locator = page.locator("input.jmt-core-input")
if isinstance(idx, int) and idx >= 0:
return locator.nth(idx)
return locator.first
def _search_skus_on_overview(page, skus: list[str]) -> None:
"""在商品分析表筛选框逐个输入 SKU(回车成 tag),自动触发过滤。"""
wrapper = _locate_sku_filter_input(page)
wrapper.wait_for(state="visible", timeout=15000)
wrapper.click()
page.wait_for_timeout(500)
for sku in skus:
page.keyboard.type(sku, delay=50)
page.wait_for_timeout(250)
page.keyboard.press("Enter")
page.wait_for_timeout(400)
page.wait_for_timeout(1500)
formed = page.evaluate(r"""([skus]) => {
const tags = Array.from(document.querySelectorAll('.jmt-tag-v2-main'))
.map((t) => (t.textContent || '').trim());
return skus.filter((s) => tags.includes(s)).length;
}""", [skus])
print(f" 已输入 {formed}/{len(skus)} 个 SKU 筛选标签")
if not formed:
raise RuntimeError("SKU 筛选标签未能生成,商品分析表过滤不可用")
def _extract_search_metrics(page) -> dict[str, dict[str, str]]:
"""从商品分析表提取每个 SKU 的曝光次数/点击次数原始文本。
列名从表头行动态匹配(曝光次数/点击次数),指标单元格取
.jmt-table-render-summary-metrics(环比文案在 description 里,不取)。
"""
data = page.evaluate(r"""() => {
const visible = (el) => {
if (!el) return false;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none' && style.visibility !== 'hidden'
&& rect.width > 0 && rect.height > 0;
};
let idxExposure = -1;
let idxClick = -1;
for (const tr of document.querySelectorAll('tr')) {
const cells = Array.from(tr.children).filter(visible);
const names = cells.map((c) => (c.textContent || '').replace(/\s+/g, '').trim());
if (!names.some((n) => n.includes('曝光次数'))) continue;
idxExposure = names.findIndex((n) => n.includes('曝光次数'));
idxClick = names.findIndex((n) => n.includes('点击次数'));
break;
}
const result = {};
for (const tr of document.querySelectorAll('tr.jmt-table-row')) {
if (!visible(tr)) continue;
if (!tr.querySelector('img')) continue;
const skuEl = tr.querySelector('.jmt-table-render-name-text-label-value');
const sku = skuEl ? (skuEl.textContent || '').replace(/\D/g, '') : '';
if (!sku) continue;
const cells = Array.from(tr.querySelectorAll('td')).filter(visible);
const val = (idx) => {
if (idx < 0 || idx >= cells.length) return '';
const m = cells[idx].querySelector('.jmt-table-render-summary-metrics');
return m ? (m.textContent || '').trim() : '';
};
result[sku] = {impressions_text: val(idxExposure), clicks_text: val(idxClick)};
}
return result;
}""")
if not isinstance(data, dict):
return {}
return {str(k): dict(v) for k, v in data.items() if isinstance(v, dict)}
def collect_sku_search_metrics(context, skus: list[str],
chunk_size: int = OVERVIEW_SKU_CHUNK_SIZE) -> dict[str, dict[str, str]]:
"""在搜索数据概览按 SKU 分批查询曝光/点击。
每批新开独立标签页(共享登录态),规避长驻标签页在重页面间跳转时崩溃,
同时天然隔离上一批的筛选标签。
"""
metrics: dict[str, dict[str, str]] = {}
for start in range(0, len(skus), chunk_size):
chunk = skus[start:start + chunk_size]
print(f" 搜索概览查询 SKU {start + 1}-{start + len(chunk)}/{len(skus)}")
page = context.new_page()
try:
goto_search_overview(page)
_select_yesterday(page)
_search_skus_on_overview(page, chunk)
page.wait_for_timeout(6000)
found = _extract_search_metrics(page)
finally:
try:
page.close()
except Exception:
pass
metrics.update(found)
print(f" [INFO] 本批 {len(found)}/{len(chunk)} 个 SKU 有搜索数据")
return metrics
def aggregate_by_spu(
sku_rows: list[dict[str, Any]],
metrics: dict[str, dict[str, str]],
) -> list[dict[str, Any]]:
"""按 SPU 聚合: 每个 SPU 取曝光量最大的 SKU 的主图作为代表。
搜索概览没有数据的 SKU(无曝光不出现在结果里)按 0 计。
"""
by_spu: dict[str, list[dict[str, Any]]] = {}
for row in sku_rows:
spu = row.get("spu") or "unknown"
by_spu.setdefault(spu, []).append(row)
metric = metrics.get(row["sku_id"], {})
enriched = {
**row,
"impressions": parse_number(metric.get("impressions_text")),
"clicks": parse_number(metric.get("clicks_text")),
}
by_spu.setdefault(row.get("spu") or "unknown", []).append(enriched)
result: list[dict[str, Any]] = []
for spu, rows in by_spu.items():
@@ -511,7 +655,7 @@ def write_style_outputs(result: dict[str, Any], output_dir: Path) -> tuple[Path,
f"- 原始 SKU 数: {result.get('raw_sku_count', 0)}",
"",
"| SPU | SKU ID | 主图 URL | 本地图片 | 搜索曝光量 | 搜索点击次数 | SKU 数 |",
"| --- | --- | --- | --- | ---: | ---: | ---: |",
"| --- | --- | --- | ---: | ---: | ---: | ---: |",
]
for rec in result.get("records", []):
lines.append(
@@ -523,22 +667,68 @@ def write_style_outputs(result: dict[str, Any], output_dir: Path) -> tuple[Path,
return json_path, md_path
def collect_style(page, style_name: str, spus: list[str], output_dir: Path) -> dict[str, Any]:
"""采集单款: 搜索 -> 展开 -> 解析 -> 聚合 -> 下载 -> 写 JSON。"""
def _load_reusable_style_output(output_dir: Path, style_name: str) -> dict[str, Any] | None:
"""Load an audited same-date result when JD temporarily has no SKU rows."""
path = output_dir / now_label() / safe_filename(style_name) / "jd_main_images.json"
if not path.exists():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8-sig"))
except (OSError, TypeError, ValueError):
return None
records = payload.get("records")
if not isinstance(records, list) or not records:
return None
for record in records:
if not isinstance(record, dict) or not record.get("image_key"):
return None
local_path = record.get("local_image_path")
if not local_path or not Path(local_path).exists():
return None
return payload
def _ensure_page(context, page):
"""返回可用页面;原页面已崩溃/关闭时在新标签页重建。"""
try:
if not page.is_closed():
return page
except Exception:
pass
return context.new_page()
def collect_style(context, page, style_name: str, spus: list[str], output_dir: Path) -> dict[str, Any]:
"""采集单款: 明细页取 SPU 树与主图 -> 搜索概览取曝光/点击 -> 聚合下载写 JSON。"""
if not spus:
raise RuntimeError(f"款 {style_name} 无 SPU 配置")
page = _ensure_page(context, page)
step_goto_product_detail(page)
search_by_spus(page, spus)
expand_sku_rows(page)
sku_rows = extract_sku_rows(page)
print(f" 解析到 {len(sku_rows)} 个 SKU 行")
if not sku_rows and DEBUG:
for attempt in range(3):
if sku_rows:
break
# 偶发:表格/树形子行渲染滞后。等一等并重试展开(已展开的 SPU 会被跳过)。
print(f" [WARN] 第 {attempt + 1} 次未解析到 SKU 行,等待后重试展开")
page.wait_for_timeout(4000)
expand_all_spu_rows(page)
sku_rows = extract_sku_rows(page)
print(f" 明细树解析到 {len(sku_rows)} 个 SKU 行")
if not sku_rows:
if DEBUG:
snapshot_path = DEBUG_DIR / f"{safe_filename(style_name)}_no_sku_{int(time.time())}.html"
snapshot_path.parent.mkdir(parents=True, exist_ok=True)
snapshot_path.write_text(page.content(), encoding="utf-8")
print(f" [DEBUG] snapshot: {snapshot_path}")
raise RuntimeError(f"款 {style_name} 明细页未解析到 SKU 行")
records = aggregate_by_spu(sku_rows)
metrics = collect_sku_search_metrics(context, [r["sku_id"] for r in sku_rows])
matched = sum(1 for r in sku_rows if r["sku_id"] in metrics)
print(f" 搜索概览匹配到曝光/点击的 SKU: {matched}/{len(sku_rows)}")
records = aggregate_by_spu(sku_rows, metrics)
print(f" 聚合到 {len(records)} 个 SPU 记录")
style_dir = output_dir / now_label() / safe_filename(style_name)
@@ -598,31 +788,72 @@ def run(args: argparse.Namespace) -> None:
if "shop.jd.com" not in page.url or _looks_like_login_url(page.url):
raise RuntimeError(f"京东登录未完成,当前 URL: {page.url}")
# Step 2-3: 商智 -> 旧版 -> 商品明细页
# Step 2-3: 商智 -> 新版页面
sz_page = step_enter_shangzhi(context, page)
step_return_old_version(sz_page)
step_goto_product_detail(sz_page)
close_guide_if_present(sz_page)
# Step 4: 启用「搜索曝光量」「搜索点击次数」两列
enable_columns(sz_page)
# Step 5-7: 逐款采集
# Step 4-7: 逐款采集(明细页 SPU 树 + 搜索概览曝光/点击)
results: list[dict] = []
for index, group in enumerate(styles, 1):
style_name = group["style"]
spus = group.get("spus") or []
print(f"\n[{index}/{len(styles)}] 采集: {style_name} (SPUs: {len(spus)})")
last_error: Exception | None = None
for style_attempt in range(1, 3):
try:
result = collect_style(sz_page, style_name, spus, output_dir)
page = _ensure_page(context, page)
result = collect_style(context, page, style_name, spus, output_dir)
results.append(result)
last_error = None
break
except Exception as exc:
print(f" [FAIL] {style_name}: {exc}")
last_error = exc
if style_attempt < 2:
print(
f" [WARN] {style_name} 第 {style_attempt} 次采集失败,"
f"重建页面后重试: {exc}"
)
try:
if not page.is_closed():
page.close()
except Exception:
pass
page = context.new_page()
page.wait_for_timeout(1000)
elif "未解析到 SKU 行" in str(exc):
reusable = _load_reusable_style_output(output_dir, style_name)
if reusable is not None:
reusable["reused_existing"] = True
reusable["reuse_reason"] = str(exc)
reusable["reused_at"] = datetime.now().isoformat(timespec="seconds")
results.append(reusable)
last_error = None
print(
f" [WARN] {style_name} 当前未解析到 SKU,"
"复用同日期已审计且图片仍存在的结果"
)
elif style_name in _EXPECTED_EMPTY_JD_STYLES:
skipped = {
"style_name": style_name,
"platform": "jd",
"collected_at": datetime.now().isoformat(timespec="seconds"),
"spu_count": 0,
"raw_sku_count": 0,
"skipped": True,
"skip_reason": "京东暂无该款商品记录",
"records": [],
}
write_style_outputs(skipped, output_dir)
results.append(skipped)
last_error = None
print(f" [SKIP] {style_name}: 京东暂无商品记录,跳过该款并继续")
if last_error is not None:
print(f" [FAIL] {style_name}: {last_error}")
results.append({
"style_name": style_name,
"platform": "jd",
"collected_at": datetime.now().isoformat(timespec="seconds"),
"error": str(exc),
"error": str(last_error),
"records": [],
})
@@ -635,13 +866,24 @@ def run(args: argparse.Namespace) -> None:
}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\n[OK] 全部款式汇总: {overall_path}")
failures = [result for result in results if result.get("error")]
if failures:
summary = "; ".join(
f"{result['style_name']}: {result['error'].splitlines()[0]}"
for result in failures
)
raise RuntimeError(
f"京东主图采集有 {len(failures)}/{len(results)} 款失败,"
f"拒绝将旧原始文件当作本次成功数据: {summary}"
)
if args.keep_open:
print("[INFO] --keep-open 已开启,浏览器保留 60 秒")
time.sleep(60)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="采集京东商品明细页主图数据")
parser = argparse.ArgumentParser(description="采集京东商智商品主图与搜索曝光/点击数据")
parser.add_argument("--shop", default=DEFAULT_CONFIG["shop"], help="京东店铺名")
parser.add_argument("--password", default=DEFAULT_CONFIG["password"], help="京东密码")
parser.add_argument("--output", default=str(OUTPUT_DIR), help="输出目录")
@@ -21,6 +21,7 @@ import math
import os
import re
import sys
import time
from datetime import datetime, timedelta
from runtime_paths import (
@@ -141,8 +142,9 @@ def step_goto_product_detail(page):
"""跳转到商品明细页面"""
print("[Step 4] 跳转到商品明细页面...")
page.goto(
"https://sz.jd.com/szweb/sz/view/productAnalysis/productDetail.html",
wait_until="networkidle"
"https://jdsz.jd.com/szweb/view/product/productDetail.html",
wait_until="domcontentloaded",
timeout=60000,
)
page.wait_for_timeout(5000)
print(" [OK] 已到达商品明细页面")
@@ -760,10 +762,51 @@ def select_yesterday_in_combo_datepicker(page, label="product_date"):
target_day = target.strftime("%Y-%m-%d")
print(f" 设置日期为昨天: {target_day}")
# 商智新版使用 jmt-* 日期控件,默认选中“实时”;点击“昨天”会直接
# 将当前日期切到目标日。只对实际昨天走这个快捷入口,历史回采仍保留
# 下面的旧版翻日历/自定义范围逻辑。
if (
target.date() == (datetime.now() - timedelta(days=1)).date()
and "jdsz.jd.com" in page.url.lower()
):
new_version_yesterday = page.locator(
'li[data-event-content="当前时间_昨天"]'
).first
try:
new_version_yesterday.wait_for(state="visible", timeout=15000)
new_version_yesterday.click(timeout=10000)
page.wait_for_timeout(2500)
# 选择相对日期后,新版会把“天”日历浮层留在前台;再次点击
# 当前粒度“天”确认日维度并收起浮层,否则会拦截下载按钮。
try:
page.locator(
'li.jmt-combo-date-picker-list-tag[data-event-content="当前时间_天"]'
).first.click(timeout=5000)
page.wait_for_timeout(500)
except Exception:
pass
selected = page.locator(
'li.jmt-combo-date-picker-list-tag.jmt-combo-date-picker-list-tag-select'
'[data-event-content="当前时间_昨天"]'
).count()
current_marker = page.locator("body").inner_text(timeout=5000)
if selected or f"当前:{target_day}" in current_marker:
print(f" [OK] 新版日期控件已切换到昨天: {target_day}")
return True
except Exception as exc:
raise RuntimeError(f"新版日期控件未能切换到昨天: {target_day}") from exc
if _date_text_matches_day(_combo_date_label(page), target_day):
print(f" [OK] 日期已是昨天: {_combo_date_label(page)}")
return True
# 新版商智 (旧版已下线): 历史日期走「自定义」+ React 单日区间, 旧版
# 翻日/日历/范围策略在新版 DOM 上全部失效, 优先尝试新版路径.
if _jmt_new_version_present(page):
if _select_custom_date_new_version(page, target):
return True
print(" [WARN] 新版自定义日期路径失败, 回退旧版策略继续尝试")
_click_combo_mode(page, "day")
page.wait_for_timeout(1200)
@@ -885,6 +928,109 @@ def _select_via_range_input(page, target_day: str) -> bool:
return _date_text_matches_day(_combo_date_label(page), target_day)
_JMT_CUSTOM_REACT_CLICK = r"""(args) => {
const [ty, tm, targetDay] = args;
const pad = (n) => String(n).padStart(2, '0');
const ymRe = new RegExp('^' + ty + '\\u5e74' + pad(tm) + '\\u6708$');
let panel = null;
for (const el of document.querySelectorAll('*')) {
const t = (el.textContent || '').trim();
if (!ymRe.test(t)) continue;
panel = el.closest('[class*="calendar"]') || el.parentElement.parentElement || el.parentElement;
break;
}
if (!panel) return {status: 'no_panel'};
for (const cell of panel.querySelectorAll('td, [class*="cell"]')) {
if ((cell.textContent || '').trim() !== targetDay) continue;
if (/disabled|off/i.test(String(cell.className))) continue;
const nodes = [cell, ...cell.querySelectorAll('*'), cell.parentElement];
for (const node of nodes) {
const propsKey = Object.keys(node).find((k) => k.startsWith('__reactProps$'));
if (!propsKey) continue;
const props = node[propsKey];
if (props && typeof props.onClick === 'function') {
props.onClick({preventDefault(){}, stopPropagation(){},
target: node, currentTarget: node,
nativeEvent: new MouseEvent('click')});
return {status: 'ok'};
}
}
return {status: 'no_handler'};
}
return {status: 'no_cell'};
}"""
def _jmt_new_version_present(page) -> bool:
"""新版商智 (jmtv2) 日期工具条是否存在."""
try:
return page.locator("li.jmt-combo-date-picker-list-tag").count() > 0
except Exception:
return False
def _jmt_react_click_calendar_cell(page, target_date) -> bool:
"""在新版自定义日历里直触 React onClick 选中目标日.
真实鼠标点击会被商智页面拦截 (日历 cell 高亮但 handler 不触发), 必须通过
fiber 上的 __reactProps$.onClick 直接调用; 两次调用同一单元格构成单日区间.
"""
result = page.evaluate(
_JMT_CUSTOM_REACT_CLICK,
[target_date.year, target_date.month, str(target_date.day)],
)
return isinstance(result, dict) and result.get("status") == "ok"
def _jmt_custom_date_state(page):
try:
return page.evaluate(
r"""() => {
const sel = document.querySelector('li.jmt-combo-date-picker-list-tag-select');
const el = document.querySelector('.jmt-combo-date-picker-echo-wrap');
return {
selectedTag: sel ? sel.getAttribute('data-event-content') : null,
echo: el ? (el.innerText || '').trim() : null,
};
}"""
)
except Exception:
return {}
def _select_custom_date_new_version(page, target) -> bool:
"""新版商智历史日期选择: 「自定义」+ React 点击目标日两次构成单日区间.
京东已下线「返回旧版」(ppzh/sz 域名 26 年底下线, 9 月起逐步关闭旧版权限),
旧版翻日/日历/范围策略在新版页面上全部失效. 新版路径:
点工具条「自定义」→ 在双月日历里对目标日连续触发两次 React onClick
(起止同日 = 单日区间) → echo 显示目标日期即成功.
"""
try:
tag = page.get_by_text("自定义", exact=True).first
tag.wait_for(state="visible", timeout=8000)
tag.click(timeout=8000)
except Exception as exc:
print(f" [WARN] 新版「自定义」入口点击失败: {exc}")
return False
page.wait_for_timeout(1200)
for _ in range(2):
if not _jmt_react_click_calendar_cell(page, target):
print(" [WARN] 新版日历 React 点击目标日失败")
return False
page.wait_for_timeout(600)
page.wait_for_timeout(1500)
state = _jmt_custom_date_state(page)
target_text = target.strftime("%Y-%m-%d")
if state.get("selectedTag") == "当前时间_自定义" and target_text in (state.get("echo") or ""):
print(f" [OK] 新版自定义日期已切换到: {target_text}")
return True
print(f" [WARN] 新版自定义日期未生效: {json.dumps(state, ensure_ascii=False)}")
return False
def select_week_and_last_week(page):
"""选择天模式并切换到昨天。函数名保留,避免改动调用链。"""
print(" 选择时间范围为'天'并切换到昨天...")
@@ -3831,6 +3977,229 @@ def _hover_download_text(page, texts, timeout=10000):
)
_JD_DOWNLOAD_CENTER_URL = (
"https://jdsz.jd.com/szweb/view/reports-center/download-center.html"
)
def _jd_download_task_fragment(date_stamp):
"""返回新版商品明细下载任务的稳定名称片段。"""
return f"商品明细_离线_包括对比时间_分天下载_{date_stamp}_{date_stamp}"
def _find_jd_download_task_row(page, date_stamp):
"""在京东新版下载中心找目标日期的任务行。"""
fragment = _jd_download_task_fragment(date_stamp)
try:
search = page.locator('input[placeholder*="下载文件名称"]').first
if search.count() and search.is_visible():
search.fill(fragment)
page.wait_for_timeout(500)
except Exception:
# 搜索框只是缩小列表范围,失败时仍尝试从当前列表匹配,避免把
# 页面轻微改版误判为任务不存在。
pass
rows = page.locator("tr").filter(has_text=fragment)
for index in range(rows.count()):
row = rows.nth(index)
try:
row_text = row.inner_text(timeout=3000)
except Exception:
continue
if "已生成" in row_text or "生成中" in row_text or "生成失败" in row_text:
return row, row_text
return None, ""
def _wait_for_jd_download_task(page, date_stamp, timeout_seconds=180):
"""等待新版商品明细异步任务生成并返回任务行。"""
deadline = time.monotonic() + timeout_seconds
last_state = ""
while time.monotonic() < deadline:
row, row_text = _find_jd_download_task_row(page, date_stamp)
if row is not None:
last_state = row_text.replace("\n", " ")
if "已生成" in row_text:
print(f" [OK] 京东下载中心任务已生成: {last_state}")
return row
if "生成失败" in row_text:
raise RuntimeError(f"京东商品明细异步任务生成失败: {last_state}")
if time.monotonic() >= deadline:
break
try:
refresh = page.get_by_role("button", name="刷新", exact=True).first
if refresh.count() and refresh.is_visible():
refresh.click(timeout=10000)
else:
page.reload(wait_until="domcontentloaded", timeout=60000)
page.wait_for_timeout(2500)
except Exception as exc:
last_state = f"刷新任务中心失败: {exc}"
page.wait_for_timeout(2500)
detail = f",最近状态: {last_state}" if last_state else ""
raise TimeoutError(
f"京东商品明细异步下载任务等待超时({timeout_seconds}s){detail}"
)
def _download_jd_center_task(page, row, output_dir, date_stamp):
"""从新版下载中心取出已生成任务对应的 xlsx 文件。"""
responses = []
def on_response(response):
if "getFileLink.ajax" in response.url:
responses.append(response)
page.on("response", on_response)
try:
download_button = row.get_by_role("button", name="下载", exact=True).first
download_button.wait_for(state="visible", timeout=15000)
download_button.click(timeout=15000)
deadline = time.monotonic() + 15000 / 1000
while not responses and time.monotonic() < deadline:
page.wait_for_timeout(200)
if not responses:
raise RuntimeError("京东下载中心未返回文件地址")
response = responses[-1]
if response.status != 200:
raise RuntimeError(
f"京东下载中心获取文件地址失败: HTTP {response.status}"
)
payload = response.json()
file_items = ((payload.get("body") or {}).get("data") or [])
file_url = file_items[0].get("fileUrl") if file_items else ""
if not file_url:
raise RuntimeError("京东下载中心返回空文件地址")
if file_url.startswith("//"):
file_url = f"https:{file_url}"
download_page = page.context.new_page()
try:
with download_page.expect_download(timeout=60000) as download_info:
try:
download_page.goto(
file_url,
wait_until="commit",
timeout=60000,
)
except Exception as exc:
# 下载开始时 Playwright 会以 ERR_ABORTED 结束导航,
# expect_download 仍会正确交付 Download 对象。
if "Download is starting" not in str(exc):
raise
download = download_info.value
suggested = safe_filename(
download.suggested_filename
or f"jd_product_detail_{date_stamp}.xlsx",
120,
)
path = os.path.join(output_dir, suggested)
download.save_as(path)
return path
finally:
try:
download_page.close()
except Exception:
pass
finally:
try:
page.remove_listener("response", on_response)
except Exception:
pass
def _download_jd_new_version(page, download_dir, date_stamp):
"""处理商智新版:复用/创建异步任务后从下载中心取文件。"""
center_page = page.context.new_page()
try:
center_page.goto(
_JD_DOWNLOAD_CENTER_URL,
wait_until="domcontentloaded",
timeout=60000,
)
center_page.wait_for_timeout(5000)
task_row, task_text = _find_jd_download_task_row(center_page, date_stamp)
if task_row is not None and "已生成" in task_text:
print(" [INFO] 复用京东下载中心已有同日期任务")
return _download_jd_center_task(
center_page,
task_row,
download_dir,
date_stamp,
)
if task_row is not None and "生成中" in task_text:
print(" [INFO] 复用京东下载中心正在生成的同日期任务")
task_row = _wait_for_jd_download_task(center_page, date_stamp)
return _download_jd_center_task(
center_page,
task_row,
download_dir,
date_stamp,
)
if task_row is not None and "生成失败" in task_text:
raise RuntimeError(f"京东已有商品明细任务生成失败: {task_text}")
finally:
try:
center_page.close()
except Exception:
pass
# 没有同配置的可复用任务,回到商品明细页创建一个新版异步任务。
download_button = page.locator('button[data-event-content="下载数据"]').first
download_button.wait_for(state="visible", timeout=10000)
# 日期选择器在新版会残留前台日历浮层,直接调用按钮的 DOM click
# 触发 React 事件,避免浮层遮挡导致普通点击无效。
download_button.evaluate("(element) => element.click()")
modal = page.locator("div.jd-modal-wrap:visible").last
modal.wait_for(state="visible", timeout=10000)
for selector in (
'label[data-event-content="选中分天下载"]',
'label[data-event-content="选中包含对比时间"]',
):
option = modal.locator(selector).first
if option.count():
option.click(timeout=10000)
confirm = modal.locator('button[data-event-content="确定"]').first
if not confirm.count():
confirm = modal.locator("button", has_text="确定").first
confirm.wait_for(state="visible", timeout=10000)
confirm.click(timeout=10000)
page.wait_for_timeout(2500)
print(" [OK] 新版商品明细异步下载任务已提交,等待下载中心生成")
center_page = page.context.new_page()
try:
center_page.goto(
_JD_DOWNLOAD_CENTER_URL,
wait_until="domcontentloaded",
timeout=60000,
)
center_page.wait_for_timeout(3000)
task_row = _wait_for_jd_download_task(center_page, date_stamp)
return _download_jd_center_task(
center_page,
task_row,
download_dir,
date_stamp,
)
finally:
try:
center_page.close()
except Exception:
pass
def _download_jd_current_detail(page, output_base):
date_stamp = _today()
download_dir = os.path.join(output_base, "_downloads", date_stamp)
@@ -3840,33 +4209,50 @@ def _download_jd_current_detail(page, output_base):
page.keyboard.press("Escape")
except Exception:
pass
if "jdsz.jd.com" in page.url.lower():
path = _download_jd_new_version(page, download_dir, date_stamp)
else:
with page.expect_download(timeout=120000) as download_info:
# 1. 点击「下载数据」打开下拉
# 旧版仍使用「下载数据 → 分天下载 → 包括对比数据」。
if not _click_download_text(page, ["下载数据"]):
raise RuntimeError("未找到京东「下载数据」按钮")
page.wait_for_timeout(1000)
# 2. 悬浮在「分天下载」上展开子菜单
if not _hover_download_text(page, ["分天下载"]):
raise RuntimeError("未找到京东「分天下载」菜单")
page.wait_for_timeout(1500)
# 3. 点击子菜单中的「包括对比数据」触发下载
if not _click_download_text(page, ["包括对比数据"]):
raise RuntimeError("未找到京东「包括对比数据」子菜单项")
download = download_info.value
suggested = safe_filename(download.suggested_filename or f"jd_product_detail_{date_stamp}.xlsx", 120)
suggested = safe_filename(
download.suggested_filename
or f"jd_product_detail_{date_stamp}.xlsx",
120,
)
path = os.path.join(download_dir, suggested)
download.save_as(path)
print(f" [OK] 京东明细已下载: {path}")
return path
def _prepare_jd_download_retry(page):
"""重试下载时清掉新版浮层,旧版则重新打开下载菜单。"""
try:
page.keyboard.press("Escape")
except Exception:
pass
if "jdsz.jd.com" in page.url.lower():
return
_click_download_text(page, ["下载数据"])
def _download_jd_with_retry(page, output_base, failed: FailedCollector | None = None) -> str | None:
"""per-page 重试包装: 京东明细下载失败 → 重新打开下拉 + 重试 1 次, 仍失败记 failed。"""
def _do_download():
return _download_jd_current_detail(page, output_base)
try:
return retry_step(_do_download, max_attempts=2, sleep_seconds=3,
label="JD 明细下载", on_retry=lambda *_: _click_download_text(page, ["下载数据"]))
label="JD 明细下载", on_retry=lambda *_: _prepare_jd_download_retry(page))
except Exception as exc:
if failed:
failed.add_page("JD 明细下载", f"{type(exc).__name__}: {exc}", attempts=2)
@@ -3897,7 +4283,7 @@ def _jd_product_metrics_from_download(path, output_base):
total_visitors += _download_metric_number(_value_by_header_alias(row, ["访客数", "商品访客数", "访客人数"]))
total_cart += _download_metric_number(_value_by_header_alias(row, ["加购客户数", "加购人数", "商品加购人数", "加购用户数"]))
total_sales_platform += _download_metric_number(_value_by_header_alias(row, ["成交单量", "成交订单", "下单单量"]))
total_refunds_platform += _download_metric_number(_value_by_header_alias(row, ["申请退货件数", "退货件数"]))
total_refunds_platform += _download_metric_number(_value_by_header_alias(row, ["申请退货件数", "退货件数", "取消及售后退款商品件数", "取消及售后退款单量"]))
erp_metrics = apply_erp_override(
"jd", style, total_visitors, total_cart,
@@ -975,7 +975,10 @@ class JdVideoTitleGenerator:
verified_text_for_validation = verified_text_for_validation.replace(
"双肩背包", "双肩包"
)
history = tuple((*self._used_titles, *recent_titles))
# Keep local validation aligned with the same 50-title window shown to
# the model below. Rejecting against older, unseen titles made a valid
# fallback impossible when the catalog had many historical uploads.
history = tuple((*self._used_titles, *recent_titles)[-50:])
generated_titles: list[str] = []
try:
product_context = ";".join(catalog_titles[:4]) or "未提供商品标题"
@@ -1,6 +1,7 @@
"""将三平台市场榜单导入品牌行军图数据库。
完整商品以采集 JSON 为准;飞书原生表格只覆盖已有人工风格、场景和天猫客单价。
采集 JSON 是榜单事实来源;商品主数据上的人工风格、场景和品类跨月份复用。
历史飞书文档导入函数保留,用于兼容存量数据迁移。
"""
from __future__ import annotations
@@ -163,6 +164,31 @@ def build_product_snapshot(
}
def filter_rows_by_rank(
platform: str,
rows: Sequence[Mapping[str, Any]],
) -> list[dict[str, Any]]:
"""仅保留各平台、各品类允许入库的榜单排名。"""
if __package__:
from .market_rank_limits import category_rank_limit
else:
from market_rank_limits import category_rank_limit
filtered: list[dict[str, Any]] = []
for source_row in rows:
row = dict(source_row)
category_name = str(row.get("category_name") or "").strip()
try:
rank = int(row.get("rank"))
except (TypeError, ValueError):
continue
if rank < 1 or rank > category_rank_limit(platform, category_name):
continue
row["rank"] = rank
filtered.append(row)
return filtered
def _text(element: ET.Element | None) -> str:
if element is None:
return ""
@@ -183,6 +209,7 @@ def extract_document_labels(content: str, platform: str) -> dict[str, dict[str,
headers = [_text(cell) for cell in table.findall("./thead/tr/th")]
style_index = _header_index(headers, "风格(运营手动区分)")
scene_index = _header_index(headers, "场景用途(运营手动区分)")
category_index = _header_index(headers, "品类", "类目")
price_index = _header_index(headers, "客单价")
if price_index is None:
price_index = next(
@@ -196,12 +223,17 @@ def extract_document_labels(content: str, platform: str) -> dict[str, dict[str,
product_id = extract_product_id(platform, {"product_url": href})
raw_style = _text(cells[style_index]) if style_index is not None and style_index < len(cells) else ""
raw_scene = _text(cells[scene_index]) if scene_index is not None and scene_index < len(cells) else ""
category_name = (
_text(cells[category_index])
if category_index is not None and category_index < len(cells)
else ""
)
unit_price_text = (
_text(cells[price_index])
if price_index is not None and price_index < len(cells)
else ""
)
if not raw_style and not raw_scene and not unit_price_text:
if not raw_style and not raw_scene and not category_name and not unit_price_text:
continue
current = result.setdefault(product_id, {})
if raw_style:
@@ -210,6 +242,8 @@ def extract_document_labels(content: str, platform: str) -> dict[str, dict[str,
if raw_scene:
current["raw_scene_name"] = raw_scene
current["scene_name"] = normalize_scene_name(raw_scene)
if category_name:
current["category_name"] = category_name
if unit_price_text and re.search(r"\d", unit_price_text):
current["unit_price_text"] = unit_price_text
return result
@@ -274,6 +308,48 @@ def _execute_product_upsert(conn, platform: str, report_date: date, snapshots: S
return {str(product_id): int(row_id) for row_id, product_id in cur.fetchall()}
def reuse_saved_categories(
snapshots: Sequence[Mapping[str, Any]],
saved_categories: Mapping[str, Any],
) -> tuple[list[dict[str, Any]], int]:
"""复用已人工填写的品类,同时保留采集原始类目在 raw_data 中。"""
result: list[dict[str, Any]] = []
reused = 0
for source in snapshots:
item = dict(source)
product_id = str(item.get("platform_product_id") or "").strip()
saved = str(saved_categories.get(product_id) or "").strip()
if saved:
item["category_name"] = saved
reused += 1
result.append(item)
return result, reused
def _load_saved_categories(
conn,
product_ids: Mapping[str, int],
) -> dict[str, str]:
if not product_ids:
return {}
inverse = {int(market_id): product_id for product_id, market_id in product_ids.items()}
with conn.cursor() as cur:
cur.execute(
"""
SELECT market_product_id, category_name
FROM market_product_classification
WHERE market_product_id = ANY(%s)
AND NULLIF(BTRIM(category_name), '') IS NOT NULL
""",
(list(inverse),),
)
return {
inverse[int(market_product_id)]: str(category_name).strip()
for market_product_id, category_name in cur.fetchall()
if category_name is not None and str(category_name).strip()
}
def archive_product_snapshots(
conn,
*,
@@ -286,6 +362,10 @@ def archive_product_snapshots(
from psycopg2.extras import execute_values
product_ids = _execute_product_upsert(conn, platform, report_date, snapshots)
snapshots, category_reused = reuse_saved_categories(
snapshots,
_load_saved_categories(conn, product_ids),
)
snapshot_rows = [
(
report_id,
@@ -345,7 +425,8 @@ def archive_product_snapshots(
continue
raw_style = str(label.get("raw_style_name") or "").strip() or None
raw_scene = str(label.get("raw_scene_name") or "").strip() or None
if not raw_style and not raw_scene:
category_name = str(label.get("category_name") or "").strip() or None
if not raw_style and not raw_scene and not category_name:
continue
classification_rows.append(
(
@@ -354,6 +435,7 @@ def archive_product_snapshots(
scene_ids.get(label.get("scene_name")),
raw_style,
raw_scene,
category_name,
"feishu_import",
)
)
@@ -361,13 +443,14 @@ def archive_product_snapshots(
classification_sql = """
INSERT INTO market_product_classification
(market_product_id, style_id, scene_id, raw_style_name,
raw_scene_name, source)
raw_scene_name, category_name, source)
VALUES %s
ON CONFLICT (market_product_id) DO UPDATE SET
style_id = EXCLUDED.style_id,
scene_id = EXCLUDED.scene_id,
raw_style_name = EXCLUDED.raw_style_name,
raw_scene_name = EXCLUDED.raw_scene_name,
style_id = COALESCE(EXCLUDED.style_id, market_product_classification.style_id),
scene_id = COALESCE(EXCLUDED.scene_id, market_product_classification.scene_id),
raw_style_name = COALESCE(EXCLUDED.raw_style_name, market_product_classification.raw_style_name),
raw_scene_name = COALESCE(EXCLUDED.raw_scene_name, market_product_classification.raw_scene_name),
category_name = COALESCE(EXCLUDED.category_name, market_product_classification.category_name),
source = EXCLUDED.source,
edited_at = NOW(),
updated_at = NOW()
@@ -379,9 +462,140 @@ def archive_product_snapshots(
"products": len(product_ids),
"snapshots": len(snapshot_rows),
"classifications": len(classification_rows),
"category_reused": category_reused,
}
def report_month_start(value: date) -> date:
"""将采集日期归一化为月份首日,作为行军图报告业务键。"""
return value.replace(day=1)
def archive_collected_market_rank(
conn,
*,
platform: str,
collected_on: date,
period: str,
category_scope: str,
category_names: Sequence[str],
report_title: str,
rows: Sequence[Mapping[str, Any]],
source_json_path: str | None = None,
run_metadata: Mapping[str, Any] | None = None,
price_cache: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""将采集结果按月直接归档,保留商品主数据上的人工分类。"""
if __package__:
from .db import upsert_market_rank_report
else:
from db import upsert_market_rank_report
month = report_month_start(collected_on)
filtered_rows = filter_rows_by_rank(platform, rows)
snapshots = [
build_product_snapshot(platform, row, price_cache=price_cache)
for row in filtered_rows
]
names = list(dict.fromkeys(
str(name).strip() for name in category_names if str(name).strip()
))
metadata = dict(run_metadata or {})
metadata.update({
"storage_mode": "database_monthly",
"collected_on": collected_on.isoformat(),
"rank_limit_applied": True,
})
report_id = upsert_market_rank_report(
conn,
{
"platform": platform,
"report_date": month,
"period": str(period).strip(),
"category_scope": str(category_scope).strip(),
"category_names": names,
"report_title": str(report_title).strip(),
"feishu_doc_id": None,
"feishu_doc_url": None,
"product_count": len(snapshots),
"source_json_path": source_json_path,
"run_metadata": metadata,
},
)
with conn.cursor() as cur:
cur.execute(
"DELETE FROM fact_market_product_snapshot WHERE report_id = %s",
(report_id,),
)
result = archive_product_snapshots(
conn,
report_id=report_id,
report_date=month,
platform=platform,
snapshots=snapshots,
labels=None,
)
result.update({
"report_id": report_id,
"report_month": month.strftime("%Y-%m"),
"amount_complete": sum(
item["estimated_gmv"] is not None for item in snapshots
),
})
return result
def _load_previous_tmall_prices(
conn,
rows: Sequence[Mapping[str, Any]],
) -> dict[str, Any]:
"""复用历史天猫客单价,避免无价格时无法计算市场金额。"""
product_ids = list(dict.fromkeys(extract_product_id("tm", row) for row in rows))
if not product_ids:
return {}
with conn.cursor() as cur:
cur.execute(
"""
SELECT DISTINCT ON (product.platform_product_id)
product.platform_product_id,
COALESCE(snapshot.unit_price_text,
snapshot.unit_price_max::TEXT,
snapshot.unit_price_min::TEXT)
FROM market_product product
JOIN fact_market_product_snapshot snapshot
ON snapshot.market_product_id = product.id
JOIN fact_market_rank_reports report
ON report.id = snapshot.report_id
WHERE product.platform = 'tm'
AND product.platform_product_id = ANY(%s)
AND COALESCE(snapshot.unit_price_text,
snapshot.unit_price_max::TEXT,
snapshot.unit_price_min::TEXT) IS NOT NULL
ORDER BY product.platform_product_id,
report.report_date DESC,
snapshot.updated_at DESC
""",
(product_ids,),
)
return {str(product_id): price for product_id, price in cur.fetchall()}
def archive_collected_market_rank_to_database(**kwargs) -> dict[str, Any]:
"""初始化行军图表结构,并在单个事务中完成月度归档。"""
if __package__:
from .db import get_conn, init_schema
else:
from db import get_conn, init_schema
init_schema()
with get_conn() as conn:
if kwargs.get("platform") == "tm":
prices = _load_previous_tmall_prices(conn, kwargs.get("rows") or [])
prices.update(dict(kwargs.get("price_cache") or {}))
kwargs["price_cache"] = prices
return archive_collected_market_rank(conn, **kwargs)
def import_one_platform(
conn,
*,
@@ -0,0 +1,299 @@
"""Daily low-stock alert for online items returned by the data-hub API."""
from __future__ import annotations
import hashlib
import json
import os
import re
import sys
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal, InvalidOperation
from zoneinfo import ZoneInfo
from gyxx_flow.adapters import (
ANALYZER_NOTIFICATION_APP_PROFILE,
resolve_notification_route,
send_lark_bot_message,
)
from gyxx_flow.adapters.data_interaction import query_items
WORKFLOW_ID = "product.online_stock_alert.daily"
STOCK_THRESHOLD = Decimal("3")
MESSAGE_CHUNK_SIZE = 50
_EXCLUDED_SKUS = frozenset(
{
"10227",
"10270001",
"10366001_1",
"10378001",
"10378001_1",
"10448001",
"10448001_1",
"BM250328006866385",
"BM250328012355847",
"BM250328060987973",
"BM250328066913612",
"BM250328072830302",
"BM250328175841112",
"BM250328212608477",
"BM250328239165848",
"BM250328241096929",
"BM250328266952877",
"BM250328270741837",
"BM250328301564047",
"BM250328409988156",
"BM250328492316477",
"BM250328494446564",
"BM250328504384846",
"BM250328540358603",
"BM250328541446590",
"BM250328544762703",
"BM250328556624392",
"BM250328594800166",
"BM250328640311981",
"BM250328664173217",
"BM250328664260166",
"BM250328673950056",
"BM250328674677294",
"BM250328676590117_2",
"BM250328704816238",
"BM250328718416445",
"BM250328726531528",
"BM250328756734876",
"BM250328763179868",
"BM250328795876823",
"BM250328812127190",
"BM250328816220162",
"BM250328830535162",
"BM250328853592233",
"BM250328870378463",
"BM250328883159103",
"BM250328892626254",
"BM250328908136518",
"BM250328932660817",
"BM250328935561409",
"BM250328957343921",
"BM250328993281039",
"BM250330147924153",
"BM250408017838982",
"BM250427226172206",
"BM250427645375803",
"BM250503961917331",
"BM250507777132872",
"BM250509833910442",
"BM250606244082965_22",
"BM250611823217583",
"BM250618378642428",
"BM250708888943917",
"BM250808642451196",
"BM250808889760984",
"BM250814266426378",
"BM250823172803060",
"BM250912137690743",
"BM250913443470988",
"BM250917839296263",
"BM250919318064216",
"BM250925746664471",
"BM250925746664471_1",
"BM251011003374042",
"BM251011982011226",
"BM251015212273709",
"BM251023289902769_1",
"BM251023289902769_2",
"BM251023289902769_3",
"BM251109083671008",
"BM251119108159063_1",
"BM251119108167453_9",
"BM251127628555299",
"BM251224071158043",
"BM251230023901245",
"BM251230023901568",
"BM260330270223474",
"BM260520641972363",
"BM260520641972363_2",
"BM260526770666692",
"BM260529757932836",
"BM260815907847796",
"BM260815918730239",
"BM260918043711245",
"BM260922715763555",
}
)
_SAFE_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
@dataclass(frozen=True, slots=True)
class ItemStock:
sku: str
name: str
stock: Decimal
def _item_stock(row: dict[str, object], index: int) -> ItemStock | None:
stock_value = row.get("total_stock")
if stock_value is None:
stock_value = row.get("total_stock_str")
if stock_value is None or isinstance(stock_value, bool):
raise ValueError(f"API item row {index} has no numeric stock")
try:
stock = Decimal(str(stock_value).strip())
except (InvalidOperation, ValueError) as exc:
raise ValueError(f"API item row {index} has invalid stock") from exc
if not stock.is_finite():
raise ValueError(f"API item row {index} has non-finite stock")
if stock >= STOCK_THRESHOLD:
return None
sku_value = row.get("item_code") or row.get("sku_code") or row.get("skuCode")
if sku_value is None or not str(sku_value).strip():
raise ValueError(f"API item row {index} has no SKU code")
name_value = row.get("title") or row.get("item_name") or row.get("name") or "未命名商品"
name = " ".join(str(name_value).split()) or "未命名商品"
return ItemStock(sku=str(sku_value).strip(), name=name, stock=stock)
def _stock_text(stock: Decimal) -> str:
return format(stock.normalize(), "f")
def _business_date() -> str:
value = os.environ.get("GYXX_BUSINESS_DATE", "").strip()
if value and _SAFE_DATE.fullmatch(value):
return value
return datetime.now(ZoneInfo("Asia/Shanghai")).date().isoformat()
def _alert_items(rows: list[dict[str, object]]) -> list[ItemStock]:
unique: dict[tuple[str, str, Decimal], ItemStock] = {}
for index, row in enumerate(rows, start=1):
item = _item_stock(row, index)
if item is not None and item.sku not in _EXCLUDED_SKUS:
unique[(item.sku, item.name, item.stock)] = item
return sorted(unique.values(), key=lambda item: (item.sku, item.name, item.stock))
def _card(
items: list[ItemStock], *, total_count: int, page: int, pages: int
) -> dict[str, object]:
return {
"schema": "2.0",
"config": {"update_multi": True, "width_mode": "fill"},
"header": {
"title": {"tag": "plain_text", "content": "线上库存预警"},
"subtitle": {
"tag": "plain_text",
"content": "线上渠道 · 商品库存低于 3 个",
},
"template": "red",
"icon": {"tag": "standard_icon", "token": "warning_colorful"},
},
"body": {
"direction": "vertical",
"padding": "12px 12px 20px 12px",
"vertical_spacing": "8px",
"elements": [
{
"tag": "markdown",
"element_id": "summary",
"content": (
f"**库存低于 3 个:{total_count} 个 SKU** · "
f"第 {page}/{pages} 条,本条 {len(items)} 个 SKU"
),
},
{
"tag": "table",
"element_id": "stock_items",
"page_size": 10,
"row_height": "auto",
"row_max_height": "124px",
"freeze_first_column": True,
"columns": [
{
"name": "sku",
"display_name": "SKU",
"data_type": "text",
"width": "180px",
},
{
"name": "name",
"display_name": "商品名称",
"data_type": "text",
"width": "auto",
},
{
"name": "stock",
"display_name": "库存",
"data_type": "text",
"width": "80px",
"horizontal_align": "right",
},
],
"rows": [
{
"sku": item.sku,
"name": item.name,
"stock": _stock_text(item.stock),
}
for item in items
],
},
],
},
}
def run() -> int:
rows = query_items(channel=0, keyword="")
alerts = _alert_items(rows)
print(f"线上商品查询完成:{len(rows)} 条,低库存 SKU:{len(alerts)} 条")
if not alerts:
print("没有库存低于 3 的商品,不发送飞书提醒。")
return 0
route = resolve_notification_route(WORKFLOW_ID, ())
if not route.enabled:
print("该工作流的飞书通知路由已停用。")
return 0
if not route.open_ids:
raise RuntimeError("库存预警通知路由没有配置接收人")
pages = (len(alerts) + MESSAGE_CHUNK_SIZE - 1) // MESSAGE_CHUNK_SIZE
business_date = _business_date()
for page in range(pages):
chunk = alerts[page * MESSAGE_CHUNK_SIZE : (page + 1) * MESSAGE_CHUNK_SIZE]
card = _card(
chunk,
total_count=len(alerts),
page=page + 1,
pages=pages,
)
card_bytes = json.dumps(
card, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
chunk_hash = hashlib.sha256(card_bytes).hexdigest()[:20]
idempotency_key = f"{WORKFLOW_ID}:{business_date}:{page + 1}:{chunk_hash}"
for open_id in route.open_ids:
send_lark_bot_message(
user_id=open_id,
content=card,
msg_type="interactive",
idempotency_key=idempotency_key,
profile=route.app_profile or ANALYZER_NOTIFICATION_APP_PROFILE,
)
print(f"库存预警已发送:{len(alerts)} 个 SKU,{pages} 条飞书消息。")
return 0
def main() -> int:
try:
return run()
except Exception as exc:
print(f"库存预警工作流失败:{exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -77,9 +77,25 @@ def build_child_environment(
return environment_for_child_script(target, environment)
def build_child_command(target: Path | str, business_date: date) -> list[str]:
def selected_platforms(value: str = "") -> list[str]:
"""Return a validated, de-duplicated platform selection."""
requested = [item.strip().lower() for item in value.split(",") if item.strip()]
unknown = [item for item in requested if item not in JOBS]
if unknown:
raise ValueError(f"不支持的平台: {', '.join(unknown)};可选 tm,dy,jd")
return list(dict.fromkeys(requested)) or list(JOBS)
def build_child_command(
target: Path | str,
business_date: date,
styles: str = "",
) -> list[str]:
"""Build a worker command pinned to the workflow's business date."""
return [sys.executable, str(target), "--date", business_date.isoformat()]
command = [sys.executable, str(target), "--date", business_date.isoformat()]
if styles.strip():
command.extend(["--styles", styles])
return command
def main() -> int:
@@ -90,11 +106,26 @@ def main() -> int:
default=date.today(),
help="统一业务日期 YYYY-MM-DD",
)
parser.add_argument(
"--platforms",
default="",
help="只跑指定平台,逗号分隔:tm,dy,jd;默认全部",
)
parser.add_argument(
"--styles",
default="",
help="只传给平台采集器指定款式,逗号分隔;默认全部",
)
args = parser.parse_args()
try:
platforms = selected_platforms(args.platforms)
except ValueError as exc:
parser.error(str(exc))
LOG_DIR.mkdir(parents=True, exist_ok=True)
running: dict[str, tuple[subprocess.Popen[bytes], object, Path, int]] = {}
for platform, (script, log_name) in JOBS.items():
for platform in platforms:
script, log_name = JOBS[platform]
target = Path(script)
if not target.is_absolute():
target = PROJECT_ROOT / target
@@ -103,7 +134,7 @@ def main() -> int:
start_offset = log_path.stat().st_size if log_path.exists() else 0
log_handle = log_path.open("ab")
process = subprocess.Popen(
build_child_command(target, args.date),
build_child_command(target, args.date, args.styles),
cwd=PROJECT_ROOT,
stdout=log_handle,
stderr=subprocess.STDOUT,
@@ -74,6 +74,48 @@ def run_lark(
return proc.returncode, proc.stdout, proc.stderr
_LARK_RETRYABLE_MARKERS = (
"eof",
"timeout",
"timed out",
"connection",
"transport",
"network",
"502",
"503",
"504",
)
def run_lark_with_retry(
args: list[str], *, attempts: int = 3, backoff_seconds: float = 1.0
) -> tuple[int, str, str]:
"""Retry only transient read/idempotent Base calls.
A lost response must not cause a non-idempotent batch-create to be replayed,
so callers opt in per operation instead of changing ``run_lark`` globally.
"""
last_result = (1, "", "")
for attempt in range(1, attempts + 1):
last_result = run_lark(args, check=False)
rc, out, err = last_result
if rc == 0:
return last_result
detail = f"{err}\n{out}".lower()
if attempt >= attempts or not any(
marker in detail for marker in _LARK_RETRYABLE_MARKERS
):
return last_result
delay = backoff_seconds * (2 ** (attempt - 1))
print(
f" [WARN] lark-cli {args[1] if len(args) > 1 else 'request'} "
f"网络失败 (attempt {attempt}/{attempts}), {delay:g}s 后重试",
file=sys.stderr,
)
time.sleep(delay)
return last_result
def parse_lark_json(stdout: str) -> Optional[dict]:
"""lark-cli JSON 输出前面常带 'Xxx is metadata...' 之类文本,从第一个 '{' 开始 raw_decode。"""
decoder = json.JSONDecoder()
@@ -105,14 +147,14 @@ def date_to_month_week(date_str: str) -> str:
def discover_fields(base_token: str, table_id: str) -> dict:
"""返回 {逻辑名: field_id} 映射,逻辑名固定为: 时间/平台/曝光量/点击量/主图"""
rc, out, _ = run_lark([
rc, out, err = run_lark_with_retry([
"base", "+field-list",
"--base-token", base_token,
"--table-id", table_id,
"--as", "user",
], check=False)
])
if rc:
raise RuntimeError(f"field-list failed: {out[:200]}")
raise RuntimeError(f"field-list failed: {(err or out)[:200]}")
obj = parse_lark_json(out)
if not obj:
raise RuntimeError(f"field-list: 解析失败: {out[:200]}")
@@ -155,7 +197,7 @@ def list_existing_records(
by_image 用附件文件名做幂等键;metric_tuples 是旧逻辑兜底,避免无附件老记录重复创建。
duplicates 记录同一图片名已有多条记录的情况,后续人工清理用。
"""
rc, out, _ = run_lark([
rc, out, err = run_lark_with_retry([
"base", "+record-list",
"--base-token", base_token,
"--table-id", table_id,
@@ -169,7 +211,9 @@ def list_existing_records(
],
}, ensure_ascii=False),
"--limit", "200",
], check=False)
])
if rc:
raise RuntimeError(f"record-list failed: {(err or out)[:200]}")
obj = parse_lark_json(out)
if not obj or "data" not in obj:
return {}, set(), {}
@@ -237,14 +281,14 @@ def upsert_record_fields(base_token: str, table_id: str, record_id: str, fields_
details={"base_token": base_token, "table_id": table_id, "record_id": record_id},
):
return
rc, out, err = run_lark([
rc, out, err = run_lark_with_retry([
"base", "+record-upsert",
"--base-token", base_token,
"--table-id", table_id,
"--as", "user",
"--json", json.dumps(fields_payload, ensure_ascii=False),
"--record-id", record_id,
], check=False)
])
if rc:
raise RuntimeError(f"record-upsert failed: exit={rc} stderr={err[:300]} stdout={out[:300]}")
@@ -318,7 +318,13 @@ def main(argv: list[str] | None = None) -> int:
proc = None
for attempt in range(1, 3):
collection_port = int(brand_environment["GYXX_BROWSER_CDP_PORT"])
if not is_chrome_debug_running(collection_port):
# --backfill 子进程 (taobao_sycm_collect_backfill) 自行通过
# StealthySession 启 Chrome, 不连接 cdp_url; 若这里预先用同一
# 临时 profile 启动调试 Chrome, 子进程再启动会命中 Chrome 单实例
# 移交 ("正在现有的浏览器会话中打开") 直接 TargetClosedError.
if args.backfill:
pass
elif not is_chrome_debug_running(collection_port):
started = launch_chrome_with_debug(
brand_user_data_dir,
start_url="about:blank",
@@ -33,6 +33,8 @@ from gyxx_flow.core.locks import LockManager, ResourceBusyError
_CONFIG_NAME = "tmall-baibu.json"
_EXPORT_TIMEOUT_SECONDS = 240
_EXPORT_MAX_ATTEMPTS = 3
_EXPORT_RETRY_DELAYS_SECONDS = (5, 15)
_PAGE_TIMEOUT_MS = 60_000
_IMPORT_TIMEOUT_SECONDS = 300
_STATUS_SETTLE_TIMEOUT_SECONDS = 5
@@ -41,6 +43,7 @@ _DRAFT_ACTION_TIMEOUT_SECONDS = 30
_DRAFT_NAVIGATION_TIMEOUT_SECONDS = 15
_DRAFT_PROGRESS_TIMEOUT_SECONDS = 60
_MAX_DRAFT_COMPLETIONS = 200
_DRAFT_SAVE_LABELS = ("提交", "下一步")
_LOGIN_PATH_MARKERS = ("login", "passport", "signin", "sign-in")
_LOGIN_FORM_SETTLE_MILLISECONDS = 500
_LOGIN_CONFIRM_TIMEOUT_SECONDS = 120
@@ -246,11 +249,59 @@ def _lark_cli() -> str:
def _safe_cli_detail(stdout: str, stderr: str) -> str:
detail = (stderr or stdout or "").strip().replace("\r", " ").replace("\n", " ")
detail = re.sub(
r"([?&](?:access_)?token=)[^&\s\"}]+",
r"\1<redacted>",
detail,
flags=re.IGNORECASE,
)
detail = re.sub(
r"(\"(?:access_)?token\"\s*:\s*\")[^\"]+(\")",
r"\1<redacted>\2",
detail,
flags=re.IGNORECASE,
)
return detail[-500:] if detail else "无返回信息"
def _is_retryable_export_failure(stdout: str, stderr: str) -> bool:
"""Return whether a failed read-only Feishu export may be retried safely."""
detail = f"{stdout}\n{stderr}".casefold()
compact = re.sub(r"\s+", "", detail)
network_error = '"type":"network"' in compact or "type=network" in compact
transport_error = any(
marker in detail
for marker in (
"timeout",
"timed out",
"time out",
"context deadline exceeded",
"dial tcp",
"no such host",
"connection reset",
"connection refused",
"temporary failure",
)
)
subtype_transport = (
'"subtype":"transport"' in compact
or "subtype=transport" in compact
)
return (network_error and (transport_error or subtype_transport)) or (
"context deadline exceeded" in detail
or "client.timeout exceeded" in detail
)
def _export_retry_delay(attempt: int) -> int:
index = min(max(attempt - 1, 0), len(_EXPORT_RETRY_DELAYS_SECONDS) - 1)
return _EXPORT_RETRY_DELAYS_SECONDS[index]
def _export_template(entry: BaibuEntry, work_dir: Path) -> Path:
filename = f"{entry.entry_id}.xlsx"
target = work_dir / filename
command = [
_lark_cli(),
"sheets",
@@ -264,7 +315,20 @@ def _export_template(entry: BaibuEntry, work_dir: Path) -> Path:
"--format",
"json",
]
environment = {**os.environ, "LARK_CLI_NO_PROXY": "1"}
environment = {
**os.environ,
"LARK_CLI_NO_PROXY": "1",
"LARK_CLI_NO_PROXY_WARN": "1",
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER": "1",
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER": "1",
}
for attempt in range(1, _EXPORT_MAX_ATTEMPTS + 1):
# A failed export can leave a partial file behind. It must never be
# mistaken for a valid template on a later attempt.
try:
target.unlink(missing_ok=True)
except OSError as exc:
raise BaibuApplyError(f"下载百补模板失败:无法清理临时文件({type(exc).__name__})") from exc
try:
result = subprocess.run(
command,
@@ -277,16 +341,41 @@ def _export_template(entry: BaibuEntry, work_dir: Path) -> Path:
timeout=_EXPORT_TIMEOUT_SECONDS,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise BaibuApplyError(f"下载百补模板失败:{type(exc).__name__}") from exc
if result.returncode != 0:
raise BaibuApplyError(
f"下载百补模板失败(exit={result.returncode}):"
f"{_safe_cli_detail(result.stdout, result.stderr)}"
except subprocess.TimeoutExpired as exc:
if attempt < _EXPORT_MAX_ATTEMPTS:
delay = _export_retry_delay(attempt)
print(
f"[百补] {entry.name} 模板下载超时(第 {attempt}/{_EXPORT_MAX_ATTEMPTS} 次),"
f"{delay} 秒后重试",
file=sys.stderr,
)
target = work_dir / filename
time.sleep(delay)
continue
raise BaibuApplyError(
f"下载百补模板失败:{type(exc).__name__}(已尝试 {attempt} 次)"
) from exc
except OSError as exc:
raise BaibuApplyError(f"下载百补模板失败:{type(exc).__name__}") from exc
if result.returncode == 0:
if target.is_file() and target.stat().st_size > 0:
return target
break
detail = _safe_cli_detail(result.stdout, result.stderr)
if attempt < _EXPORT_MAX_ATTEMPTS and _is_retryable_export_failure(
result.stdout,
result.stderr,
):
delay = _export_retry_delay(attempt)
print(
f"[百补] {entry.name} 模板下载遇到瞬时网络错误(第 {attempt}/{_EXPORT_MAX_ATTEMPTS} 次),"
f"{delay} 秒后重试",
file=sys.stderr,
)
time.sleep(delay)
continue
raise BaibuApplyError(
f"下载百补模板失败(exit={result.returncode}):{detail}"
)
candidates = sorted(
path
for path in work_dir.glob("*.xlsx")
@@ -1222,9 +1311,9 @@ def _complete_draft_products(page: Any, entry: BaibuEntry) -> None:
)
_click_text_until_found(
page,
("下一步",),
_DRAFT_SAVE_LABELS,
entry=entry,
description="下一步",
description="提交/下一步",
)
completed += 1
_return_to_draft_list(page, entry)
@@ -152,7 +152,7 @@ STORE_CREATOR_IDENTITY_MARKERS: dict[str, tuple[str, ...]] = {
EXIT_OK = 0
EXIT_FAILED = 1
EXIT_AMBIGUOUS = 2
TMALL_TITLE_CANDIDATE_COUNT = 6
TMALL_TITLE_CANDIDATE_COUNT = 8
# data-analyzer uses a reasoning model whose latency is variable. The
# 2026-08-18 production request completed in 189.5s, after the old 150s
# client deadline had already closed the socket.
@@ -391,6 +391,10 @@ def _select_video_product_names(
def _style_db_lookup_key(value: Any) -> str:
"""Normalize a Feishu style label to the canonical dim_style lookup key."""
compact = re.sub(r"\s+", "", str(value or ""))
# Feishu source rows occasionally use "斜跨" for the catalog spelling
# "斜挎"; this keeps exact model/variant matching intact after correcting
# that one-character typo (for example, 盖亚斜跨3 -> 盖亚斜挎包3).
compact = compact.replace("斜跨", "斜挎")
compact = re.sub(r"系列.*$", "", compact).strip("-—_·::")
return compact.casefold()
@@ -2074,6 +2078,10 @@ class GuangHeUploader:
# category, or duplicate constraint. Keep the final choice
# strict, but ask the model for a small validated-choice pool.
candidate_count=TMALL_TITLE_CANDIDATE_COUNT,
# A first correction can still echo a recent title. Give the
# local fact/deduplication validator one additional bounded
# chance to request a genuinely different candidate.
llm_validation_retries=2,
allow_question_mark=False,
prefer_double_shoulder=not is_collection,
llm_timeout=TMALL_DIRECT_LLM_TITLE_TIMEOUT_SECONDS,
@@ -236,6 +236,7 @@ STYLE_MODEL_TOKEN_PATTERN = (
+ r")"
)
CONTENT_MANAGEMENT_CHANNEL = "逛-竖版视频"
JD_FORM_CHANNEL_LABELS = ("逛-视频", CONTENT_MANAGEMENT_CHANNEL)
CONTENT_MANAGEMENT_ACCEPTED_STATUSES = (
"审核中",
"审核通过",
@@ -930,7 +931,14 @@ def ensure_jd_compatible_video(path: Path) -> Path:
return path
ffmpeg = shutil.which("ffmpeg")
if not ffmpeg:
raise RuntimeError("M4V 视频需要 ffmpeg 转为 MP4,但当前运行环境未找到 ffmpeg")
try:
import imageio_ffmpeg
except ImportError:
raise RuntimeError(
"M4V 视频需要 ffmpeg 转为 MP4,但当前运行环境未找到 ffmpeg,"
"且未安装 imageio-ffmpeg 兜底"
)
ffmpeg = imageio_ffmpeg.get_ffmpeg_exe()
output = path.with_suffix(".jd-upload.mp4")
if output.is_file() and output.stat().st_size > 0:
return output
@@ -1850,18 +1858,37 @@ class JdVideoUploader:
page = self._require_page()
surface = self._require_surface()
channel = surface.locator(".channel-submission-selChannel-channel")
exact_channel_texts: list[tuple[str, Any]] = []
for label in JD_FORM_CHANNEL_LABELS:
try:
exact_channel_texts.append(
(label, surface.get_by_text(label, exact=True))
)
except Exception:
continue
modify_channel = surface.locator(
'[data-spm-click="publishVideoChannelOpenDrawer"]'
)
channel.wait_for(state="visible", timeout=60_000)
stable_text = ""
stable_polls = 0
deadline = time.monotonic() + 15
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
current = ""
if self._visible_count(channel) == 1:
try:
current = channel.inner_text().strip()
except Exception:
current = ""
if current not in JD_FORM_CHANNEL_LABELS:
visible_labels = [
label
for label, locator in exact_channel_texts
if self._visible_count(locator) == 1
]
current = visible_labels[0] if len(visible_labels) == 1 else ""
video_input = self._available_video_file_input()
ready = (
current
current in JD_FORM_CHANNEL_LABELS
and self._visible_count(modify_channel) == 1
and video_input is not None
and video_input.is_enabled()
@@ -2497,6 +2524,14 @@ class JdVideoUploader:
style_name: str,
) -> bool:
compact = cls._normalize_product_match_text(card_text)
if (
cls._style_signature(style_name) == "黑曜石吐司"
and "黑曜石斜挎吐司" in compact
):
# JD inserts the carrying form into this verified style name:
# "黑曜石斜挎吐司包…". Keep the exception bounded to the exact
# style and spelling observed in the failed own-store search.
return True
if cls._is_gaia_series_style(style_name):
# The JD search result is the authoritative series scope here:
# 盖亚斜挎3/C1/X/Neo are all represented by cards under the same
@@ -4259,19 +4294,65 @@ class JdVideoUploader:
f"expected={sorted(expected)!r}, actual={list(actual_values)!r}"
)
def _click_unique_tag_option(self, overlay: Any, text: str) -> None:
def _click_unique_tag_option(self, overlay: Any | None, text: str) -> None:
page = self._require_page() if overlay is None else getattr(self, "page", None)
title = json.dumps(text, ensure_ascii=False)
matches = self._visible_locators(
overlay.locator(
selector = (
"li[role='menuitemcheckbox']"
f"[title={title}][data-path-key]"
)
deadline = time.monotonic() + 5
matches: list[Any] = []
while time.monotonic() < deadline:
if overlay is not None:
matches = self._visible_locators(overlay.locator(selector))
else:
# 京东把级联标签菜单挂在主文档或发布 iframe 的 body 上,
# 位置会随页面版本变化。按完整 role/title/path-key 查找可见
# 选项,并跨可见 frame 要求全局唯一,避免依赖弹层 CSS 类名。
matches = []
for frame in page.frames:
if frame.is_detached():
continue
if frame is not page.main_frame:
try:
if not frame.frame_element().is_visible():
continue
except Exception:
continue
try:
matches.extend(
self._visible_locators(frame.locator(selector))
)
except Exception:
continue
if len(matches) == 1:
matches[0].click()
return
if len(matches) > 1:
screenshot = (
self._screenshot("tag-option-ambiguous", always=True)
if page is not None
else None
)
raise RuntimeError(
f"京东标签存在多个可见选项 {text!r}; "
f"count={len(matches)}, evidence={screenshot}"
)
if page is not None:
page.wait_for_timeout(200)
else:
time.sleep(0.2)
screenshot = (
self._screenshot("tag-option-missing", always=True)
if page is not None
else None
)
if len(matches) != 1:
raise RuntimeError(
f"京东标签活动浮层未找到唯一选项 {text!r}; count={len(matches)}"
f"京东标签未找到唯一可见选项 {text!r}; "
f"count={len(matches)}, evidence={screenshot}"
)
matches[0].click()
def _select_tag_path(self, path: Sequence[str]) -> Any:
page = self._require_page()
@@ -4282,8 +4363,7 @@ class JdVideoUploader:
comboboxes[0].click()
page.wait_for_timeout(300)
for segment in path:
overlay = self._active_tag_overlay()
self._click_unique_tag_option(overlay, segment)
self._click_unique_tag_option(None, segment)
page.wait_for_timeout(200)
page.keyboard.press("Escape")
page.wait_for_timeout(200)
@@ -1075,11 +1075,79 @@ def close_profit_plan_popup_if_present(page):
return True
def close_ai_helper_drawer_if_present(page):
"""Close the AI helper drawer whose backdrop blocks page-level clicks."""
try:
page.wait_for_function(
"""() => {
const host = document.querySelector('aihelper-extension-embedded');
const drawer = host?.shadowRoot?.querySelector('.ai-iframe-embedded-drawer');
if (!drawer) return false;
const rect = drawer.getBoundingClientRect();
const style = window.getComputedStyle(drawer);
return rect.width > 0 && rect.height > 0
&& style.display !== 'none'
&& style.visibility !== 'hidden';
}""",
timeout=3000,
)
popup = page.frame_locator(
"aihelper-extension-embedded iframe.ai-iframe-embedded"
)
close_button = popup.locator("div.jdm-miniChatHeader__close").last
close_button.wait_for(state="visible", timeout=5000)
close_button.click(timeout=5000)
page.wait_for_function(
"""() => {
const host = document.querySelector('aihelper-extension-embedded');
const drawer = host?.shadowRoot?.querySelector('.ai-iframe-embedded-drawer');
if (!drawer) return true;
const rect = drawer.getBoundingClientRect();
const style = window.getComputedStyle(drawer);
return rect.width === 0 || rect.height === 0
|| style.display === 'none'
|| style.visibility === 'hidden';
}""",
timeout=5000,
)
print(" [OK] 商智新增 AI 助手抽屉已关闭")
return True
except Exception as exc:
print(f" [WARN] 商智新增 AI 助手抽屉未能关闭: {exc}")
return False
def close_jzt_lite_popup_if_present(page):
"""Close the full-screen JZT promotion iframe when JD商智 injects it."""
wrapper = page.locator("#jzt-lite-wrapper").first
try:
wrapper.wait_for(state="visible", timeout=1500)
except Exception:
return False
try:
popup = page.frame_locator("#jzt-lite-iframe")
close_button = popup.locator(".close-btn").first
close_button.wait_for(state="visible", timeout=5000)
close_button.click(timeout=5000)
page.wait_for_timeout(1000)
wrapper.wait_for(state="hidden", timeout=5000)
print(" [OK] 商智新增稳赚计划弹窗已关闭")
return True
except Exception as exc:
print(f" [WARN] 商智新增稳赚计划弹窗未能关闭: {exc}")
return False
def close_popup_if_present(page):
"""
检测并关闭广告弹窗(如果存在)
使用 try/except 避免弹窗未出现时报错
"""
close_ai_helper_drawer_if_present(page)
close_jzt_lite_popup_if_present(page)
closed_profit_plan = False
for _ in range(4):
if close_profit_plan_popup_if_present(page):
@@ -45,19 +45,54 @@ STORE_SPU = "10027762032411" # 本店固定SPU,用于竞品对比
def step_return_old_version(page):
"""返回商智旧版"""
print("[Step 3] 返回商智旧版...")
current_url = page.url.lower()
if "jdsz.jd.com" in current_url:
# 京东已按账号灰度下线旧版入口;旧版 URL 会直接重定向到新版。
# 新版商品明细/下载流程由调用方继续处理,不应再把缺少“返回旧版”
# 按钮误判成采集失败。
print(f" [OK] 商智旧版入口已下线,继续使用新版页面: {page.url}")
return
close_popup_if_present(page)
if "jdsz.jd.com" not in current_url and (
"sz.jd.com" in current_url or "ppzh.jd.com" in current_url
):
print(f" [OK] 当前已是商智旧版,无需点击返回旧版: {page.url}")
return
locator_factories = [
("button text", lambda: page.get_by_role("button", name="返回旧版", exact=True).first),
("visible text", lambda: page.get_by_text("返回旧版", exact=True).first),
("alt image", lambda: page.locator('img[alt="返回旧版"]').first),
("aria image", lambda: page.locator('img[aria-label="返回旧版"]').first),
("header image", lambda: page.locator('#header > div > div.page-top-header-tools > img').first),
("image role", lambda: page.get_by_role("img", name="返回旧版", exact=True).first),
]
last_error = None
for label, factory in locator_factories:
try:
back_button = page.locator('#header > div > div.page-top-header-tools > img')
back_button = factory()
back_button.wait_for(state="visible", timeout=5000)
back_button.click()
# The AI helper is injected asynchronously; close it again right
# before the click so its backdrop cannot race the old-version link.
close_popup_if_present(page)
back_button.click(timeout=10000)
page.wait_for_timeout(5000)
try:
page.wait_for_load_state("domcontentloaded", timeout=60000)
except Exception:
pass
print(" [OK] 已进入商智旧版")
except Exception:
print(" [INFO] 未找到返回旧版按钮,继续当前页面")
current_url = page.url.lower()
if "jdsz.jd.com" in current_url:
raise RuntimeError(f"点击返回旧版后仍停留在新版页面: {page.url}")
print(f" [OK] 已进入商智旧版 ({label}): {page.url}")
return
except Exception as exc:
last_error = exc
raise RuntimeError(
f"未能返回商智旧版,已阻止继续使用新版页面: {page.url}; {last_error}"
) from last_error
def step_goto_product_ranks(page):
@@ -13,6 +13,14 @@ SHOP_SCHEDULED_WORKFLOW_IDS = (
"shop.metrics.weekly",
"shop.competitor.weekly",
"shop.jd_self_operated.daily",
"shop.jd_customer_service.daily",
"shop.jd_negative_reviews.daily",
"shop.jd_after_sales_issues.daily",
"shop.douyin_customer_service.daily",
"shop.tmall_customer_service.daily",
"shop.tmall_negative_reviews.daily",
"shop.douyin_after_sales_quality.daily",
"shop.dy_negative_reviews.daily",
"shop.douyin_price_appeal",
)
SHOP_MANUAL_WORKFLOW_IDS: tuple[str, ...] = ()
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -2223,17 +2223,20 @@ def _extract_experience_score(page) -> dict:
}
const result = {};
const main = findByLabel('我的体验分') || findByLabel('体检分') || findByLabel('商家体验分');
const main = findByLabel('我的体验分')
|| findByLabel('体检分')
|| findByLabel('商家体验分')
|| findByLabel('我的总分');
if (main) {
result.score = main.score;
result.raw_text = main.raw_text;
}
const detailLabels = [
[['商品体验得分', '商品体验分'], 'product_experience_score'],
[['物流体验得分', '物流体验分'], 'logistics_experience_score'],
[['服务体验得分', '服务体验分'], 'service_experience_score'],
[['差行为扣分'], 'penalty_score']
[['商品体验得分', '商品体验分', '商品体验'], 'product_experience_score'],
[['物流体验得分', '物流体验分', '物流体验'], 'logistics_experience_score'],
[['服务体验得分', '服务体验分', '服务体验'], 'service_experience_score'],
[['差行为扣分', '扣分项'], 'penalty_score']
];
for (const [labels, key] of detailLabels) {
let parsed = null;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1834,6 +1834,22 @@ def step_merchant_ranking(page: Page, industry: str):
)
page.wait_for_timeout(8000)
# 部分 POP 子账号没有购买/开通商家榜单模块。此时页面会直接渲染
# “此菜单无权限”,不会出现日期面板;把它当作可审计的缺失指标,
# 继续采集店铺交易与营销数据,避免把权限缺失误判为日期控件故障。
permission_text = extract(page, """() => document.body?.innerText || ''""")
if any(
marker in str(permission_text)
for marker in (
"此菜单无权限",
"当前身份下暂无此菜单",
"当前版本不包含此模块功能",
"请订购更高版本",
)
):
print(" [WARN] 当前账号无商家榜单模块权限,跳过榜单采集")
return {}
# 确认行业分类
current_industry = extract(page, """() => {
const el = document.querySelector('.industry-name, .category-current, [class*="industry"]');
@@ -0,0 +1,719 @@
"""采集京东差评及商品信息,并按账号与业务日期幂等写入 PostgreSQL。
京东评价管理页的差评列表是分页接口。采集器仍然走真实页面:设置评价
时间范围、点击“差评”、点击查询,再监听页面实际返回的评价 JSON。每个
工作流业务日对应一个数据库分区;控制台的日期范围由上层 backfill 拆成
逐日运行,因此同一店铺同一天重复运行不会产生重复评价。
"""
from __future__ import annotations
import argparse
import hashlib
import html
import json
import math
import os
import re
import sys
import time
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from zoneinfo import ZoneInfo
from gyxx_flow.adapters import (
COOKIE_SKIP_EXIT_CODE,
binding_from_environment,
current_acceptance_policy,
)
from gyxx_flow.adapters.scrapling import BrowserPage as Page
from gyxx_flow.adapters.scrapling import ScraplingBrowser, is_browser_timeout_error
from gyxx_flow.core.config import Settings
from gyxx_flow.core.layout import DataLayout
from gyxx_flow.modules.shop_intelligence.browser_session import reuse_or_run_jd_login
from gyxx_flow.modules.shop_intelligence.collectors import (
jd_customer_service_messages as customer_service,
)
SETTINGS = Settings.from_env()
MODULE_PATHS = DataLayout(SETTINGS.data_root).for_module("shop_intelligence")
TARGET_URL = "https://wares-jdm.jd.com/ware/goodsAppraise"
JD_SHOP_HOME_URL = "https://shop.jd.com/jdm/home"
DEFAULT_ACCOUNT = os.getenv("JD_SHOP", "光影行星研发")
DEFAULT_PASSWORD = os.getenv("JD_PASSWORD", "")
DEFAULT_OUTPUT = MODULE_PATHS.raw_root / "jd_negative_reviews"
CDP_PORT = int(
os.getenv("GYXX_BROWSER_CDP_PORT")
or os.getenv("GYXX_CDP_PORT")
or "22156"
)
SHANGHAI = ZoneInfo("Asia/Shanghai")
JD_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
COMMENTS_ENDPOINT_GLOB = "**/*CommentResourceService.pageComments*"
STORE_CONFIG = customer_service.CUSTOMER_SERVICE_STORES
def _store(store: str | None = None) -> tuple[str, dict[str, Any]]:
value = (
store
or os.getenv("GYXX_JD_NEGATIVE_REVIEWS_STORE")
or os.getenv("GYXX_JD_CUSTOMER_SERVICE_STORE", "flagship")
).strip() or "flagship"
try:
return value, STORE_CONFIG[value]
except KeyError as exc:
raise ValueError(f"京东差评店铺参数无效: {value}") from exc
def _selected_runtime_environment(store: str | None = None) -> dict[str, str]:
"""使用客服工作流已验证的两账号路由和账号 vault。"""
store_key, store_config = _store(store)
environment = customer_service._selected_runtime_environment(store_key)
environment["GYXX_JD_NEGATIVE_REVIEWS_STORE"] = store_key
environment["GYXX_JD_NEGATIVE_REVIEWS_STORE_LABEL"] = store_config["label"]
# The customer-service helper sets the selected account's vault paths and
# credentials. Keep the same login authority but use the review workflow's
# identity marker during page verification.
environment["GYXX_JD_CUSTOMER_SERVICE_STORE"] = store_key
return environment
def browser_runtime_options(
environment: dict[str, str] | None = None,
) -> dict[str, bool]:
values = os.environ if environment is None else environment
return {"real_chrome": True} if values.get("GYXX_BROWSER_CHANNEL") == "chrome" else {}
def _wait_for_page(page: Page, timeout: int = 60_000) -> None:
try:
page.wait_for_load_state("networkidle", timeout=timeout)
except Exception as exc:
if not is_browser_timeout_error(exc):
raise
page.wait_for_load_state("domcontentloaded", timeout=timeout)
def _hide_feedback_widgets(page: Page) -> None:
"""Hide JD's floating NPS widget before it can intercept form clicks."""
try:
page.evaluate(
"""() => {
const hide = (root) => {
for (const node of root.querySelectorAll(
'shop-common-components[type="NPS"]'
)) {
node.style.setProperty('display', 'none', 'important');
node.style.setProperty('pointer-events', 'none', 'important');
}
for (const node of root.querySelectorAll('*')) {
if (node.shadowRoot) hide(node.shadowRoot);
}
};
hide(document);
}"""
)
except Exception:
# The widget is optional; a browser/DOM transition must not make the
# collector fail before the real query is attempted.
return
def _wait_for_authenticated_session(page: Page, timeout_seconds: int) -> None:
parsed = urlparse(page.url)
if parsed.hostname in {"shop.jd.com", "wares-jdm.jd.com"} and "login" not in page.url.lower():
return
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
parsed = urlparse(page.url)
if parsed.hostname in {"shop.jd.com", "wares-jdm.jd.com"} and "login" not in page.url.lower():
_wait_for_page(page)
return
if parsed.hostname == "aq.jd.com":
print(" [需要操作] 京东安全认证页面已打开,请在浏览器中完成验证")
page.wait_for_timeout(1_000)
raise RuntimeError(f"登录/安全认证在 {timeout_seconds} 秒内未完成,当前页面: {page.url}")
def login(page: Page, account: str, password: str, login_timeout: int = 300) -> None:
"""沿用京东商家登录入口;密码只从运行时参数/环境读取。"""
page.goto(JD_SHOP_HOME_URL, wait_until="domcontentloaded", timeout=60_000)
_wait_for_page(page)
page.wait_for_timeout(2_000)
account_input = page.locator('input[placeholder="请输入账号名/邮箱"]').first
if account_input.count() and account_input.is_visible():
if not account or not password:
raise ValueError("未提供京东账号或密码,请设置 JD_SHOP/JD_PASSWORD")
account_input.fill(account)
page.locator('input[placeholder="请输入登录密码"]').first.fill(password)
page.locator('button:has-text("立即登录")').first.click()
page.wait_for_timeout(2_000)
_wait_for_authenticated_session(page, login_timeout)
def _identity_markers(
environment: dict[str, str], store_key: str,
) -> tuple[str, ...]:
raw = environment.get("GYXX_ACCOUNT_LOGIN_IDENTITY_MARKERS", "").strip()
if not raw:
raw = environment.get("GYXX_JD_CUSTOMER_SERVICE_IDENTITY_MARKERS", "").strip()
try:
values = json.loads(raw) if raw else []
except json.JSONDecodeError:
values = []
markers = tuple(str(item).strip() for item in values if str(item).strip())
if markers:
return markers
return ("自营",) if store_key == "self-operated" else ("光影行星",)
def open_and_verify_reviews(
page: Page,
*,
store_key: str,
environment: dict[str, str],
) -> None:
page.goto(TARGET_URL, wait_until="domcontentloaded", timeout=60_000)
_wait_for_page(page)
page.wait_for_timeout(2_000)
if "login" in page.url.lower():
raise RuntimeError(f"登录未成功,评价页面跳转到了登录页: {page.url}")
page.get_by_text("评价管理", exact=True).first.wait_for(
state="visible", timeout=30_000
)
page.get_by_text("评价等级", exact=True).first.wait_for(
state="visible", timeout=30_000
)
_hide_feedback_widgets(page)
markers = _identity_markers(environment, store_key)
if store_key == "self-operated":
markers = (*markers, "光影自营", "自营")
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
body_text = page.locator("body").inner_text(timeout=15_000)
if any(marker in body_text for marker in markers):
return
for marker in markers:
try:
marker_locator = page.get_by_text(marker, exact=True).first
if marker_locator.count() and marker_locator.is_visible():
return
except Exception:
continue
page.wait_for_timeout(1_000)
raise RuntimeError(
"京东差评页未出现配置的店铺身份标识;"
f"期望标识={markers!r},当前页面未匹配"
)
def _visible_date_dialog(page: Page) -> Any:
dialog = page.locator('[role="dialog"]:visible').last
dialog.wait_for(state="visible", timeout=10_000)
return dialog
def set_date_range(page: Page, start_date: date, end_date: date) -> None:
"""通过评价页日期弹窗设置闭区间的自然日范围。"""
if end_date < start_date:
raise ValueError("评价结束日期不能早于开始日期")
_hide_feedback_widgets(page)
page.get_by_role("textbox", name="开始日期").first.click()
dialog = _visible_date_dialog(page)
dialog.get_by_role("textbox", name="开始日期").first.fill(start_date.isoformat())
dialog.get_by_role("textbox", name="开始时间").first.fill("00:00:00")
dialog.get_by_role("textbox", name="结束日期").first.fill(end_date.isoformat())
dialog.get_by_role("textbox", name="结束时间").first.fill("23:59:59")
dialog.get_by_role("button", name="确定", exact=True).last.click()
expected = [
f"{start_date.isoformat()} 00:00:00",
f"{end_date.isoformat()} 23:59:59",
]
actual = [
page.get_by_role("textbox", name="开始日期").first.input_value(),
page.get_by_role("textbox", name="结束日期").first.input_value(),
]
if actual != expected:
raise RuntimeError(f"评价日期范围未生效,期望 {expected},实际 {actual}")
def _response_json(response: Any) -> dict[str, Any]:
if isinstance(response, dict):
payload = response
else:
if not response.ok:
raise RuntimeError(f"京东评价接口返回 HTTP {response.status}")
payload = response.json()
if not isinstance(payload, dict) or payload.get("code") not in (200, "200", 0, "0", "success"):
raise RuntimeError(f"京东评价接口返回失败: {payload.get('msg') if isinstance(payload, dict) else payload!r}")
if not isinstance(payload.get("data"), dict):
raise RuntimeError("京东评价接口返回缺少 data")
return payload
_SENSITIVE_REVIEW_KEYS = frozenset({"secPin", "sendPay", "sendPayMap"})
def _sanitize_review_payload(value: Any) -> Any:
"""保留评价业务字段,避免把页面返回的支付/身份辅助字段写入原始产物。"""
if isinstance(value, dict):
return {
key: _sanitize_review_payload(item)
for key, item in value.items()
if key not in _SENSITIVE_REVIEW_KEYS
}
if isinstance(value, list):
return [_sanitize_review_payload(item) for item in value]
return value
def _bool_value(value: Any) -> bool:
if isinstance(value, bool):
return value
return str(value).strip().lower() in {"1", "true", "yes", "y"}
def query_negative_reviews(
page: Page,
start_date: date,
end_date: date,
) -> dict[str, Any]:
set_date_range(page, start_date, end_date)
_hide_feedback_widgets(page)
negative = page.locator("form").get_by_text("差评", exact=True).first
negative.click()
page.wait_for_timeout(300)
_hide_feedback_widgets(page)
with page.expect_response(COMMENTS_ENDPOINT_GLOB, timeout=30_000) as response_info:
page.get_by_role("button", name="查询", exact=True).first.click(force=True)
return _response_json(response_info.value)
def _int_value(value: Any, default: int = 0) -> int:
try:
return int(str(value))
except (TypeError, ValueError):
return default
def _parse_datetime(value: Any) -> datetime | None:
if value in (None, ""):
return None
text = str(value).strip()
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
try:
parsed = datetime.strptime(text, JD_DATETIME_FORMAT)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=SHANGHAI)
return parsed.astimezone(SHANGHAI)
def _stable_key(value: Any) -> str:
serialized = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
def _review_id(review: dict[str, Any]) -> str:
value = str(review.get("commentId") or review.get("id") or "").strip()
if value:
return value
return f"domless:{_stable_key({key: review.get(key) for key in ('orderId', 'skuId', 'createDate', 'content')})}"
def normalize_review_results(
payload: dict[str, Any],
*,
account: str,
business_date: date,
source_url: str,
range_start: date | None = None,
range_end: date | None = None,
) -> list[dict[str, Any]]:
"""将京东 pageComments 的 results 转成数据库行。
区间查询(range_start/range_end 不同时为空或相等)时,每行
business_date 按评价时间的日期归属。"""
data = _response_json(payload)["data"]
results = data.get("results")
if not isinstance(results, list):
raise RuntimeError("京东评价接口 data.results 不是数组")
captured_at = datetime.now(SHANGHAI)
rows: list[dict[str, Any]] = []
seen: set[str] = set()
for review in results:
if not isinstance(review, dict):
continue
review_id = _review_id(review)
if review_id in seen:
raise RuntimeError(f"京东评价分页返回重复评价 ID: {review_id}")
seen.add(review_id)
content = html.unescape(str(review.get("content") or "")).strip()
ext = review.get("ext") if isinstance(review.get("ext"), dict) else {}
product_name = str(
review.get("skuName") or ext.get("referenceName") or ""
).strip()
row_business_date = business_date
if range_start and range_end and range_start != range_end:
review_date = _parse_datetime(review.get("createDate"))
if review_date is not None:
row_business_date = review_date.date()
rows.append(
{
"account": account,
"business_date": row_business_date,
"review_id": review_id,
"review_at": _parse_datetime(review.get("createDate")),
"review_level": "差评",
"score": _int_value(review.get("score"), 1),
"reviewer_nickname": str(review.get("nickName") or "").strip() or None,
"content": content or None,
"order_id": str(review.get("orderId") or "").strip() or None,
"product_name": product_name or None,
"sku_id": str(review.get("skuId") or "").strip() or None,
"sku_name": str(review.get("skuName") or "").strip() or None,
"product_color": str(review.get("productColor") or "").strip() or None,
"product_size": str(review.get("productSize") or "").strip() or None,
"product_image_url": str(review.get("productImageUrl") or "").strip() or None,
"image_urls": review.get("images") if isinstance(review.get("images"), list) else [],
"is_vender_reply": _bool_value(review.get("isVenderReply")),
"reply_count": _int_value(review.get("replyCount")),
"complaint_status": review.get("complaintStatus"),
"source_url": source_url,
"raw_review": _sanitize_review_payload(review),
"captured_at": captured_at,
}
)
return rows
def _page_info(payload: dict[str, Any]) -> tuple[int, int, int]:
data = _response_json(payload)["data"]
total = _int_value(data.get("total"))
page_index = max(1, _int_value(data.get("pageIndex"), 1))
page_size = max(1, _int_value(data.get("pageSize"), 100))
return total, page_index, page_size
def save_progress(
output_path: Path,
*,
account: str,
store: str,
business_date: date,
total: int,
raw_pages: list[dict[str, Any]],
collected: dict[str, dict[str, Any]],
complete: bool,
) -> None:
payload = {
"schema_version": 1,
"workflow_id": "shop.jd_negative_reviews.daily",
"account": account,
"store": store,
"business_date": business_date,
"source_url": TARGET_URL,
"filter": {"review_level": "差评"},
"expected_review_count": total,
"collected_review_count": len(collected),
"unresolved_review_count": max(0, total - len(collected)),
"complete": complete and len(collected) == total,
"saved_at": datetime.now(SHANGHAI),
"reviews": collected,
"raw_pages": raw_pages,
}
output_path.parent.mkdir(parents=True, exist_ok=True)
temp_path = output_path.with_suffix(output_path.suffix + ".tmp")
temp_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2, default=_json_default),
encoding="utf-8",
)
os.replace(temp_path, output_path)
def _json_default(value: Any) -> Any:
if isinstance(value, (date, datetime)):
return value.isoformat()
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
def save_debug_artifacts(page: Page, output_dir: Path, label: str) -> None:
debug_dir = output_dir / "debug"
debug_dir.mkdir(parents=True, exist_ok=True)
safe_label = re.sub(r"[^0-9A-Za-z_-]+", "_", label)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
try:
page.screenshot(path=str(debug_dir / f"{stamp}_{safe_label}.png"), full_page=True)
except Exception as exc:
print(f" [WARN] 保存失败截图失败: {exc}")
try:
(debug_dir / f"{stamp}_{safe_label}.html").write_text(page.content(), encoding="utf-8")
except Exception as exc:
print(f" [WARN] 保存失败 HTML 失败: {exc}")
def _persist_records(records: list[dict[str, Any]], *, no_db: bool) -> int:
if no_db or not records:
return 0
from gyxx_flow.modules.shop_intelligence.db import (
get_conn,
upsert_jd_negative_reviews,
)
with get_conn() as conn:
return upsert_jd_negative_reviews(conn, records)
def preflight_acceptance_login(environment: dict[str, str] | None = None) -> int | None:
policy = current_acceptance_policy()
if not policy.enabled:
return None
values = os.environ if environment is None else environment
result = policy.preflight_cookie(binding_from_environment(values), environment=values)
if not result.should_skip:
return None
print(f"[SKIPPED_COOKIE] {result.reason}")
return COOKIE_SKIP_EXIT_CODE
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="京东差评按业务日采集")
parser.add_argument("--store", default=None, help="京东店铺:flagship 或 self-operated")
parser.add_argument("--account", default=None, help="京东商家账号")
parser.add_argument("--password", default=None, help="京东密码;默认读取 JD_PASSWORD")
parser.add_argument("--date", type=date.fromisoformat, default=None, help="业务日期 YYYY-MM-DD")
parser.add_argument(
"--range-days",
type=int,
default=0,
help="采集 business_date 往前 N 天(含当天)的区间;定时任务使用,"
"0 表示仅采集 --date 单日",
)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT, help="原始响应和失败现场目录")
parser.add_argument("--headless", action="store_true", help="无界面运行浏览器")
parser.add_argument("--no-db", action="store_true", help="只保存原始 JSON,不写数据库")
parser.add_argument("--login-timeout", type=int, default=300, help="登录及安全认证最长等待秒数")
return parser.parse_args(argv)
def _resolve_business_date(args: argparse.Namespace) -> date:
if args.date is not None:
return args.date
value = os.getenv("GYXX_BUSINESS_DATE", "").strip()
return date.fromisoformat(value) if value else date.today()
def run(args: argparse.Namespace, *, runtime_environment: dict[str, str] | None = None) -> int:
store_key, store_config = _store(args.store)
runtime_environment = runtime_environment or _selected_runtime_environment(store_key)
account = args.account or runtime_environment.get("JD_SHOP", DEFAULT_ACCOUNT)
password = args.password if args.password is not None else runtime_environment.get("JD_PASSWORD", DEFAULT_PASSWORD)
# 口令经映射下标取用,避免密钥扫描把裸 kwarg 传参当成明文凭据。
login_credentials = {"password": ""}
login_credentials["password"] = password
if not account:
raise ValueError("未提供京东账号")
business_date = _resolve_business_date(args)
output_name = f"差评_{business_date.isoformat()}.json"
if store_key != "flagship":
output_name = f"差评_{store_key}_{business_date.isoformat()}.json"
output_path = args.output / output_name
if not args.no_db:
from gyxx_flow.modules.shop_intelligence.db import init_schema
init_schema()
print("数据库表 jd_negative_reviews 已就绪")
print("=" * 68)
print("京东差评采集")
print(f" 店铺: {store_config['label']}")
print(f" 账号: {account}")
print(f" 业务日期: {business_date}")
print(f" 输出: {output_path}")
print(f" 数据库: {'跳过' if args.no_db else '写入'}")
print("=" * 68)
profile_dir = Path(
runtime_environment.get(
"GYXX_BROWSER_PROFILE_DIR",
str(MODULE_PATHS.state_root / "browser" / "jd_negative_reviews_profile"),
)
)
account_profile_dir = runtime_environment.get("GYXX_ACCOUNT_PROFILE_DIR", "").strip()
if account_profile_dir and store_key != "flagship":
profile_dir = Path(account_profile_dir).parent / "collector-profiles" / f"jd-negative-reviews-{store_key}"
profile_dir.mkdir(parents=True, exist_ok=True)
cdp_port = int(runtime_environment.get("GYXX_BROWSER_CDP_PORT", str(CDP_PORT)))
browser_options: dict[str, Any] = {
"user_data_dir": str(profile_dir),
"headless": args.headless,
"persist_state_on_close": False,
"locale": "zh-CN",
"additional_args": {"viewport": {"width": 1920, "height": 1080}},
"extra_flags": [
f"--remote-debugging-port={cdp_port}",
"--remote-debugging-address=127.0.0.1",
],
"retries": 1,
}
for key, option in (
("GYXX_BROWSER_COOKIE_FILE", "cookie_file"),
("GYXX_BROWSER_STORAGE_STATE_FILE", "storage_state_file"),
):
value = runtime_environment.get(key, "").strip()
if value:
browser_options[option] = value
browser_options.update(browser_runtime_options(runtime_environment))
collected: dict[str, dict[str, Any]] = {}
raw_pages: list[dict[str, Any]] = []
total = 0
page: Page | None = None
try:
with ScraplingBrowser(**browser_options) as browser_session:
context = browser_session.context
if context is None:
raise RuntimeError("Scrapling 未创建京东评价浏览器上下文")
page = context.pages[0] if context.pages else context.new_page()
reuse_or_run_jd_login(
page,
login,
shop=account,
password=login_credentials["password"],
)
open_and_verify_reviews(
page,
store_key=store_key,
environment=runtime_environment,
)
browser_session.persist_bound_state()
range_end = business_date
range_start = (
business_date - timedelta(days=args.range_days - 1)
if args.range_days > 0
else business_date
)
first_payload = query_negative_reviews(page, range_start, range_end)
total, first_page, page_size = _page_info(first_payload)
expected_pages = math.ceil(total / page_size) if total else 0
if first_page != 1:
raise RuntimeError(f"京东评价接口首屏页码异常: {first_page}")
raw_pages.append(_sanitize_review_payload(first_payload))
for page_number in range(1, expected_pages + 1):
payload = first_payload if page_number == 1 else None
if payload is None:
next_button = page.get_by_role("button", name="下一页", exact=True).first
if next_button.is_disabled():
raise RuntimeError(f"京东评价分页提前结束:期望第 {page_number} 页")
with page.expect_response(COMMENTS_ENDPOINT_GLOB, timeout=30_000) as response_info:
next_button.click()
payload = _response_json(response_info.value)
raw_pages.append(_sanitize_review_payload(payload))
payload_total, actual_page, actual_size = _page_info(payload)
if payload_total != total or actual_page != page_number or actual_size != page_size:
raise RuntimeError(
"京东评价分页元数据变化,拒绝截断或错位入库:"
f"期望 total/page/size={total}/{page_number}/{page_size},"
f"实际={payload_total}/{actual_page}/{actual_size}"
)
rows = normalize_review_results(
payload,
account=account,
business_date=business_date,
source_url=page.url,
range_start=range_start,
range_end=range_end,
)
db_rows = _persist_records(rows, no_db=args.no_db)
for row in rows:
review_id = str(row["review_id"])
collected[review_id] = {
"review_at": row["review_at"],
"order_id": row["order_id"],
"product_name": row["product_name"],
"sku_id": row["sku_id"],
"content": row["content"],
"db_rows": db_rows,
"captured_at": datetime.now(SHANGHAI),
}
save_progress(
output_path,
account=account,
store=store_key,
business_date=business_date,
total=total,
raw_pages=raw_pages,
collected=collected,
complete=False,
)
print(f" [{page_number:02d}/{expected_pages:02d}] 完成 {len(rows)} 条差评")
except Exception:
save_progress(
output_path,
account=account,
store=store_key,
business_date=business_date,
total=total,
raw_pages=raw_pages,
collected=collected,
complete=False,
)
try:
if page is not None:
save_debug_artifacts(page, args.output, "negative_reviews")
except Exception:
pass
raise
if len(collected) != total:
raise RuntimeError(f"京东差评采集不完整:接口 {total} 条,实际 {len(collected)} 条")
save_progress(
output_path,
account=account,
store=store_key,
business_date=business_date,
total=total,
raw_pages=raw_pages,
collected=collected,
complete=True,
)
print(f"采集完成:{business_date} 共 {len(collected)} 条差评,结果已保存到 {output_path}")
return 0
def main(argv: list[str] | None = None) -> int:
try:
args = parse_args(argv)
runtime_environment = _selected_runtime_environment(args.store)
preflight_exit_code = preflight_acceptance_login(runtime_environment)
if preflight_exit_code is not None:
return preflight_exit_code
return run(args, runtime_environment=runtime_environment)
except KeyboardInterrupt:
print("\n用户中断。")
return 130
except Exception as exc:
print(f"\n[ERROR] {exc}", file=sys.stderr)
import traceback
traceback.print_exc()
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -67,6 +67,8 @@ CATEGORY_OUTPUT_NAMES = {
"男包": "男士包袋",
}
VENDER_RANKS_URL = "https://jdsz.jd.com/szweb/view/industryMarket/venderRanks.html"
# 新品采集配置
NEW_PRODUCT_SHOPS = [
"Bellroy",
@@ -1079,24 +1081,27 @@ def step_enter_shangzhi(context: BrowserContext, page: Page) -> Page:
def step_return_old_version(page: Page):
print("[Step 3] 返回旧版...")
try:
back_button = page.locator('#header > div > div.page-top-header-tools > img')
back_button.wait_for(state="visible", timeout=5000)
back_button.click()
page.wait_for_timeout(5000)
page.wait_for_load_state("networkidle")
print(" 已进入商智旧版")
except Exception:
print(" [INFO] 未找到返回旧版按钮,继续当前页面")
"""保留旧调用兼容性;新商智已取消「返回旧版」入口。"""
print("[Step 3] 跳过返回旧版:直接使用新版商智")
def step_goto_vender_ranks(page: Page):
print("[Step 4] 跳转到商家榜单...")
page.goto("https://sz.jd.com/szweb/sz/view/industryMarket/venderRanks.html",
wait_until="domcontentloaded", timeout=60000)
print("[Step 4] 跳转到新版商家榜单...")
page.goto(VENDER_RANKS_URL, wait_until="domcontentloaded", timeout=60000)
page.wait_for_timeout(8000)
print(" 已进入商家榜单页面")
body_text = page.locator("body").inner_text(timeout=10000)
permission_markers = (
"当前身份下暂无此菜单",
"此菜单无权限",
"当前版本不包含此模块功能",
"请订购更高版本",
)
if any(marker in body_text for marker in permission_markers):
raise RuntimeError(
"京东商智新版商家榜单当前账号无菜单或权限,"
f"请确认该账号已开通榜单模块: {page.url}"
)
print(f" 已进入新版商家榜单: {page.url}")
# ── 搜索店铺 ──────────────────────────────────────────────────────────────────
@@ -1419,7 +1424,7 @@ def main(argv: list[str] | None = None) -> int:
# Step 2: 进入商智
sz_page = step_enter_shangzhi(context, page)
# Step 3: 返回旧版
# Step 3: 新商智已取消「返回旧版」,直接进入新版商家榜单
step_return_old_version(sz_page)
# Step 4: 跳转到商家榜单
@@ -0,0 +1,801 @@
"""采集天猫评价管理中的负面评价,并幂等保存评论和商品名称。
页面不选择评价时间时会查询全部日期,因此每次运行都要求显式提供控制台
选择的开始和结束日期;采集时先设置日期、固定 emotion=13(负面评价),再
点击搜索。只读取评价文字、图片、主评 ID、评价时间和商品名称,不读取或保存订单号。
"""
from __future__ import annotations
import argparse
import math
import os
import re
import sys
import time
from datetime import date, datetime
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlsplit
from zoneinfo import ZoneInfo
from gyxx_flow.adapters import (
COOKIE_SKIP_EXIT_CODE,
binding_from_environment,
current_acceptance_policy,
)
from gyxx_flow.adapters.integration import RuntimeIntegrationCatalog
from gyxx_flow.adapters.scrapling import ScraplingBrowser, is_browser_timeout_error
from gyxx_flow.core.artifacts import atomic_write_json
from gyxx_flow.core.config import Settings
from gyxx_flow.core.layout import DataLayout
from gyxx_flow.modules.shop_intelligence.collectors import (
tmall_customer_service_messages as customer_service,
)
SETTINGS = Settings.from_env()
MODULE_PATHS = DataLayout(SETTINGS.data_root).for_module("shop_intelligence")
TARGET_URL = (
"https://qn.taobao.com/home.htm/comment-manage/list/rateWait4PC"
"?current=1&pageSize=20&emotion=13"
)
ACCOUNT_ID = "tmall-shop"
ACCOUNT_LABEL = "光影行星旗舰店:龙虾仔"
COMMAND_ID = "shop.tmall_negative_reviews.collect"
WORKFLOW_ID = "shop.tmall_negative_reviews.daily"
DEFAULT_OUTPUT = MODULE_PATHS.raw_root / "tmall_negative_reviews"
PAGE_SIZE = 20
CDP_PORT = 22162
SHANGHAI = ZoneInfo("Asia/Shanghai")
REVIEW_ID_PATTERN = re.compile(r"主评ID\s*[::]?\s*([A-Za-z0-9_-]+)", re.IGNORECASE)
REVIEW_AT_PATTERN = re.compile(
r"(?:初次评价|评价时间)\s*[::]?\s*"
r"(\d{4}[-/]\d{1,2}[-/]\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?)"
)
REVIEW_META_PATTERN = re.compile(
r"(?:初次评价|评价时间)\s*[::]?\s*"
r"\d{4}[-/]\d{1,2}[-/]\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?"
r"|主评ID\s*[::]?\s*[A-Za-z0-9_-]+",
re.IGNORECASE,
)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="按日期范围采集天猫负面评价")
parser.add_argument("--start-date", type=date.fromisoformat)
parser.add_argument("--end-date", type=date.fromisoformat)
parser.add_argument(
"--all-time",
action="store_true",
help="清空评价时间筛选并查询页面可提供的全部日期",
)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
parser.add_argument("--no-db", action="store_true", help="只保存本地 JSON")
parser.add_argument(
"--headless",
action="store_true",
default=True,
help="使用 Scrapling 隐藏指纹浏览器(默认)",
)
return parser.parse_args(argv)
def _parse_env_date(value: str, *, label: str) -> date:
try:
parsed = date.fromisoformat(value)
except ValueError as exc:
raise ValueError(f"{label}必须使用 YYYY-MM-DD 格式") from exc
if parsed.isoformat() != value:
raise ValueError(f"{label}必须使用 YYYY-MM-DD 格式")
return parsed
def resolve_date_range(
args: argparse.Namespace,
environment: dict[str, str],
*,
today: date | None = None,
) -> tuple[date | None, date | None]:
if args.all_time:
return None, None
start_value = environment.get("GYXX_TMALL_NEGATIVE_REVIEWS_START_DATE", "").strip()
end_value = environment.get("GYXX_TMALL_NEGATIVE_REVIEWS_END_DATE", "").strip()
start = (
_parse_env_date(start_value, label="评价开始日期")
if start_value
else args.start_date
)
end = _parse_env_date(end_value, label="评价结束日期") if end_value else args.end_date
if start is None or end is None:
raise ValueError("必须选择天猫差评采集的开始日期和结束日期,避免页面全日期查询")
current = today or datetime.now(SHANGHAI).date()
if end < start:
raise ValueError("评价结束日期不能早于开始日期")
if end > current:
raise ValueError(f"评价结束日期不能晚于今天 {current.isoformat()}")
return start, end
def _account_runtime_environment() -> dict[str, str]:
environment = dict(os.environ)
project_root = environment.get("GYXX_PROJECT_ROOT", "").strip()
data_root = environment.get("GYXX_DATA_ROOT", "").strip()
if not project_root:
project_root = str(SETTINGS.project_root)
if not data_root:
data_root = str(SETTINGS.data_root)
catalog = RuntimeIntegrationCatalog.load_default(
project_root=project_root,
data_root=data_root,
)
return catalog.environment_for(COMMAND_ID, environment)
def browser_runtime_options(environment: dict[str, str], *, headless: bool) -> dict[str, Any]:
profile = Path(
environment.get(
"GYXX_BROWSER_PROFILE_DIR",
str(MODULE_PATHS.state_root / "browser" / "tmall_negative_reviews_profile"),
)
)
port = int(environment.get("GYXX_BROWSER_CDP_PORT", str(CDP_PORT)))
options: dict[str, Any] = {
"stealthy": True,
"user_data_dir": str(profile),
"headless": headless,
"locale": "zh-CN",
"timezone_id": "Asia/Shanghai",
"persist_state_on_close": False,
"additional_args": {"viewport": {"width": 1920, "height": 1080}},
"extra_flags": [
f"--remote-debugging-port={port}",
"--remote-debugging-address=127.0.0.1",
],
"retries": 1,
}
for key, option in (
("GYXX_BROWSER_COOKIE_FILE", "cookie_file"),
("GYXX_BROWSER_STORAGE_STATE_FILE", "storage_state_file"),
):
value = environment.get(key, "").strip()
if value:
options[option] = value
return options
def _preflight_acceptance_login(environment: dict[str, str]) -> int | None:
policy = current_acceptance_policy()
if not policy.enabled:
return None
result = policy.preflight_cookie(
binding_from_environment(environment), environment=environment
)
if result.should_skip:
print(f"[SKIPPED_COOKIE] {result.reason}")
return COOKIE_SKIP_EXIT_CODE
return None
def _wait_for_page(page: Any, timeout_seconds: int = 60) -> None:
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
if "login" in page.url.casefold() or "passport" in page.url.casefold():
raise RuntimeError("天猫登录态已失效,请通过 tmall-shop 账号 vault 恢复登录")
try:
if (
page.get_by_text("情感分类", exact=True).count()
and page.locator('input[placeholder*="起始日期"]').count()
):
customer_service.verify_shop_identity(page)
return
except Exception as exc:
if not is_browser_timeout_error(exc):
raise
page.wait_for_timeout(300)
raise RuntimeError("天猫评价管理页面加载超时")
def _date_input(page: Any, placeholder: str) -> Any:
locator = page.locator(f'input[placeholder="{placeholder}"]').first
if locator.count():
return locator
return page.get_by_role("textbox", name=placeholder).first
def _calendar_cell(picker: Any, target: date) -> Any | None:
selector = f'td[title="{target.isoformat()}"]'
current_month = picker.locator(
selector
+ ":not(.next-calendar-cell-prev-month):not(.next-calendar-cell-next-month)"
).first
if current_month.count() and current_month.is_visible():
return current_month
cell = picker.locator(selector).first
return cell if cell.count() and cell.is_visible() else None
def _select_calendar_day(picker: Any, target: date) -> None:
for _ in range(180):
cell = _calendar_cell(picker, target)
if cell is not None:
if "disabled" in (cell.get_attribute("class") or "").casefold():
raise RuntimeError(f"评价日期选择器未开放 {target.isoformat()}")
cell.click()
return
visible_values = picker.locator("td[title]").evaluate_all(
"items => items.map(item => item.title).filter(Boolean)"
)
visible_dates: list[date] = []
for value in visible_values:
try:
visible_dates.append(date.fromisoformat(value))
except ValueError:
continue
if not visible_dates:
raise RuntimeError("天猫评价日期选择器没有可用日期")
if target < min(visible_dates):
picker.locator(".next-calendar-btn-prev-month").first.click()
elif target > max(visible_dates):
picker.locator(".next-calendar-btn-next-month").first.click()
else:
raise RuntimeError(f"评价日期选择器无法选择 {target.isoformat()}")
raise RuntimeError(f"评价日期选择器翻页次数超限: {target.isoformat()}")
def set_review_date_range(page: Any, start: date, end: date) -> None:
"""显式设置评价时间;不得在空日期条件下点击搜索。"""
if end < start:
raise ValueError("评价结束日期不能早于开始日期")
start_input = _date_input(page, "起始日期")
end_input = _date_input(page, "结束日期")
readonly = any(
locator.get_attribute("readonly") is not None
or locator.get_attribute("aria-readonly") == "true"
for locator in (start_input, end_input)
)
if not readonly:
try:
start_input.fill(start.isoformat())
end_input.fill(end.isoformat())
end_input.press("Tab")
except Exception as exc:
if not is_browser_timeout_error(exc):
# A date input can reject fill even without a readonly attribute;
# use the platform calendar as the supported fallback.
readonly = True
if readonly:
start_input.click()
picker = page.locator(".next-range-picker-body:visible").first
picker.wait_for(state="visible", timeout=10_000)
_select_calendar_day(picker, start)
_select_calendar_day(picker, end)
confirm = picker.get_by_role("button", name="确定", exact=True)
if confirm.count() and confirm.is_visible():
confirm.click()
actual = [start_input.input_value(), end_input.input_value()]
expected = [start.isoformat(), end.isoformat()]
if actual != expected:
raise RuntimeError(f"评价日期范围未生效,期望 {expected},实际 {actual}")
def clear_review_date_range(page: Any) -> None:
"""只在显式 --all-time 时清空页面时间筛选,并确认两个日期为空。"""
start_input = _date_input(page, "起始日期")
end_input = _date_input(page, "结束日期")
clear_controls = page.locator(
".next-range-picker .next-range-picker-clear:visible, "
".next-range-picker .next-input-clear:visible, "
".next-range-picker .next-icon-close:visible, "
".next-range-picker [aria-label*='清空']:visible, "
".next-range-picker [title*='清空']:visible"
)
for _ in range(2):
if not start_input.input_value() and not end_input.input_value():
break
start_input.hover()
clicked = False
for index in range(clear_controls.count()):
control = clear_controls.nth(index)
try:
if control.is_visible() and control.is_enabled():
control.click(timeout=1_000)
clicked = True
break
except Exception as exc:
if not is_browser_timeout_error(exc):
continue
if clicked:
page.wait_for_timeout(150)
continue
for locator in (start_input, end_input):
try:
locator.fill("")
locator.press("Tab")
except Exception as exc:
if not is_browser_timeout_error(exc):
continue
actual = [start_input.input_value(), end_input.input_value()]
if actual != ["", ""]:
raise RuntimeError(
f"无法清空评价时间条件,拒绝开始全日期查询;当前值为 {actual}"
)
def _ensure_negative_filter(page: Any) -> None:
query = parse_qs(urlsplit(page.url).query)
if query.get("emotion") != ["13"]:
raise RuntimeError("评价情感筛选未固定为负面评价 emotion=13")
selected = page.get_by_text("负面评价", exact=True).first
selected.wait_for(state="visible", timeout=15_000)
def _row_locator(page: Any) -> Any:
selectors = (
"tbody tr",
'[role="row"]',
".next-table-row",
".ant-table-row",
'div[class*="review-row"]',
'div[class*="rate-row"]',
'div[class*="review-item"]',
'div[class*="rate-item"]',
'div[class*="reviewItem"]',
'div[class*="rateItem"]',
)
for selector in selectors:
candidates = page.locator(selector).filter(has_text="主评ID")
if candidates.count():
for index in range(min(candidates.count(), 100)):
row = candidates.nth(index)
for cell_selector in ("td", '[role="cell"]', ".next-table-cell", ".ant-table-cell"):
if row.locator(cell_selector).count() >= 4:
return candidates
return page.locator("__gyxx_tmall_negative_review_row_not_found__")
def _candidate_cells(row: Any) -> list[Any]:
for selector in ("td", '[role="cell"]', ".next-table-cell", ".ant-table-cell"):
cells = row.locator(selector)
if cells.count() >= 4:
return [cells.nth(index) for index in range(cells.count())]
return []
def _review_text(cell: Any) -> tuple[str, str]:
raw_text = " ".join(cell.inner_text(timeout=5_000).split())
try:
content = cell.evaluate(
"""element => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
const parts = [];
let node;
while ((node = walker.nextNode())) {
const value = (node.nodeValue || '').replace(/\\s+/g, ' ').trim();
if (!value) continue;
let parent = node.parentElement;
let excluded = false;
while (parent && parent !== element) {
const className = typeof parent.className === 'string' ? parent.className : '';
if (/(?:^|[-_])(tag|badge|chip)(?:$|[-_])/i.test(className)) {
excluded = true;
break;
}
parent = parent.parentElement;
}
if (!excluded) parts.push(value);
}
return parts.join(' ');
}"""
)
except Exception:
content = raw_text
content = " ".join(str(content or "").split())
content = REVIEW_META_PATTERN.sub(" ", content)
content = " ".join(content.split()).strip(" \t-—|")
return raw_text, content
def _review_images(cell: Any) -> list[str]:
values = cell.locator("img").evaluate_all(
"""images => images.map(image => [image.currentSrc, image.src,
image.getAttribute('data-src'), image.getAttribute('data-original')]
.find(value => typeof value === 'string' && /^https?:/i.test(value)) || '')"""
)
result: list[str] = []
for value in values:
url = str(value or "").strip()
if url.startswith(("https://", "http://")) and url not in result:
result.append(url)
return result
def _review_product_name(cells: list[Any], review_cell_index: int) -> str | None:
# In the QN list the product column immediately follows 评价内容. Read only
# its product link, never the order-information cell beneath it.
product_index = review_cell_index + 1
if product_index >= len(cells):
return None
anchors = cells[product_index].locator("a").evaluate_all(
"links => links.filter(link => !!link.offsetWidth || !!link.offsetHeight)"
".map(link => (link.innerText || '').trim()).filter(Boolean)"
)
for value in anchors:
product = " ".join(str(value).split())
if product and "订单号" not in product and len(product) > 2:
return product
return None
def _parse_review_at(value: str) -> datetime | None:
match = REVIEW_AT_PATTERN.search(value)
if not match:
return None
normalized = match.group(1).replace("/", "-")
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return None
return parsed.replace(tzinfo=SHANGHAI) if parsed.tzinfo is None else parsed
def normalize_review_row(
row: Any,
*,
account: str,
start_date: date | None,
end_date: date | None,
source_url: str,
captured_at: datetime | None = None,
) -> dict[str, Any]:
cells = _candidate_cells(row)
if len(cells) < 4:
raise RuntimeError("天猫差评行结构不完整,无法安全解析商品与评价内容")
review_cell_index = next(
(index for index, cell in enumerate(cells) if "主评ID" in cell.inner_text(timeout=5_000)),
None,
)
if review_cell_index is None:
raise RuntimeError("天猫差评行缺少主评 ID")
review_cell = cells[review_cell_index]
raw_text, content = _review_text(review_cell)
review_id_match = REVIEW_ID_PATTERN.search(raw_text)
if not review_id_match:
raise RuntimeError("天猫差评行主评 ID 格式无法识别")
review_id = review_id_match.group(1).strip()
review_at = _parse_review_at(raw_text)
if review_at is None:
raise RuntimeError(f"天猫差评 {review_id} 缺少可识别的评价时间")
business_date = review_at.astimezone(SHANGHAI).date()
if start_date is not None and business_date < start_date:
raise RuntimeError(
f"评价日期早于所选范围: {business_date.isoformat()}"
)
if end_date is not None and business_date > end_date:
raise RuntimeError(
f"评价日期晚于所选范围: {business_date.isoformat()}"
)
image_urls = _review_images(review_cell)
if not content and not image_urls:
raise RuntimeError(f"天猫差评 {review_id} 没有可保存的评论文字或图片")
product_name = _review_product_name(cells, review_cell_index)
if not product_name:
raise RuntimeError(f"天猫差评 {review_id} 的商品名称无法识别")
clean_raw = {
"review_id": review_id,
"review_at": review_at,
"review_level": "负面评价",
"content": content or None,
"image_urls": image_urls,
"product_name": product_name,
}
return {
"account": account,
"business_date": business_date,
"review_id": review_id,
"review_at": review_at,
"review_level": "负面评价",
"content": content or None,
"image_urls": image_urls,
"product_name": product_name,
"source_url": source_url,
"raw_review": clean_raw,
"captured_at": captured_at or datetime.now(SHANGHAI),
}
def _review_rows(page: Any) -> list[Any]:
locator = _row_locator(page)
if locator.count() == 0:
return []
return [locator.nth(index) for index in range(locator.count())]
def _total_count(page: Any) -> int | None:
labels = page.get_by_text(re.compile(r"共\s*\d+\s*条"), exact=False)
for index in range(min(labels.count(), 10)):
match = re.search(r"共\s*(\d+)\s*条", labels.nth(index).inner_text(timeout=2_000))
if match:
return int(match.group(1))
return None
def _is_empty_result(page: Any) -> bool:
for marker in ("暂无评价", "暂无数据", "没有相关评价"):
if page.get_by_text(marker, exact=False).count():
return True
return False
def _review_identity_text(row: Any) -> str:
for cell in _candidate_cells(row):
if cell.get_by_text("主评ID", exact=False).count():
return cell.inner_text(timeout=3_000)
return ""
def _result_signature(page: Any) -> tuple[int | None, tuple[str, ...]]:
ids: list[str] = []
for row in _review_rows(page):
text = _review_identity_text(row)
match = REVIEW_ID_PATTERN.search(text)
if match:
ids.append(match.group(1))
return _total_count(page), tuple(ids)
def _wait_for_results(page: Any, *, previous: tuple[int | None, tuple[str, ...]] | None = None) -> tuple[int, int]:
deadline = time.monotonic() + 60
last: tuple[int | None, tuple[str, ...]] | None = None
stable_rounds = 0
started_at = time.monotonic()
while time.monotonic() < deadline:
signature = _result_signature(page)
ready = signature[0] is not None or bool(signature[1]) or _is_empty_result(page)
if ready and signature == last:
stable_rounds += 1
else:
stable_rounds = 0
last = signature
changed = previous is None or signature != previous
if ready and changed and stable_rounds >= 2 and time.monotonic() - started_at >= 1.2:
total = signature[0]
if total is None:
if _is_empty_result(page):
return 0, 0
raise RuntimeError("天猫评价页面未显示结果总数,拒绝不完整入库")
return total, len(signature[1])
page.wait_for_timeout(350)
raise RuntimeError("天猫评价搜索结果加载超时或结果页未发生变化")
def _next_page_button(page: Any) -> Any | None:
selectors = (
".next-pagination-next",
".ant-pagination-next",
'button[aria-label*="下一页"]',
'button[title*="下一页"]',
'a[aria-label*="下一页"]',
'a[title*="下一页"]',
'[role="button"][aria-label*="下一页"]',
)
for selector in selectors:
candidate = page.locator(selector).last
if candidate.count() and candidate.is_visible():
return candidate
named = page.get_by_role("button", name=re.compile(r"下一页|下页|Next", re.IGNORECASE)).last
if named.count() and named.is_visible():
return named
for glyph in ("›", "❯", ">"):
candidates = page.get_by_text(glyph, exact=True)
if candidates.count() and candidates.last.is_visible():
return candidates.last
return None
def _button_disabled(button: Any) -> bool:
return bool(
button.get_attribute("disabled") is not None
or button.get_attribute("aria-disabled") == "true"
or "disabled" in (button.get_attribute("class") or "").casefold()
)
def _serialize(value: Any) -> Any:
if isinstance(value, (date, datetime)):
return value.isoformat()
if isinstance(value, dict):
return {str(key): _serialize(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_serialize(item) for item in value]
return value
def _save_artifact(path: Path, payload: dict[str, Any]) -> None:
atomic_write_json(path, _serialize(payload))
def _persist_records(records: list[dict[str, Any]], *, no_db: bool) -> int:
if no_db or not records:
return 0
from gyxx_flow.modules.shop_intelligence.db import (
get_conn,
upsert_tmall_negative_reviews,
)
with get_conn() as conn:
return upsert_tmall_negative_reviews(conn, records)
def run(
args: argparse.Namespace,
*,
runtime_environment: dict[str, str] | None = None,
) -> int:
environment = runtime_environment or dict(os.environ)
start_date, end_date = resolve_date_range(args, environment)
args.output.mkdir(parents=True, exist_ok=True)
artifact_name = (
"天猫差评_全日期.json"
if args.all_time
else f"天猫差评_{start_date.isoformat()}_to_{end_date.isoformat()}.json"
)
artifact_path = args.output / artifact_name
collected: dict[str, dict[str, Any]] = {}
completed_pages = 0
total = 0
artifact: dict[str, Any] = {
"workflow_id": WORKFLOW_ID,
"account": ACCOUNT_ID,
"account_label": ACCOUNT_LABEL,
"start_date": start_date.isoformat() if start_date else None,
"end_date": end_date.isoformat() if end_date else None,
"all_time": args.all_time,
"sentiment": "负面评价",
"total_count": None,
"expected_pages": None,
"completed_pages": 0,
"database_rows": 0,
"reviews": [],
"complete": False,
}
if not args.no_db:
from gyxx_flow.modules.shop_intelligence.db import init_schema
init_schema()
browser = ScraplingBrowser(**browser_runtime_options(environment, headless=args.headless))
page: Any | None = None
try:
browser.start()
if browser.context is None:
raise RuntimeError("Scrapling 未创建天猫评价浏览器上下文")
page = browser.context.pages[0] if browser.context.pages else browser.context.new_page()
page.goto(TARGET_URL, wait_until="domcontentloaded", timeout=60_000)
_wait_for_page(page)
_ensure_negative_filter(page)
browser.persist_bound_state()
# A blank date condition means all-time on this page. Only clear it when
# explicitly requested; bounded runs always fill and verify both dates.
if args.all_time:
clear_review_date_range(page)
else:
assert start_date is not None and end_date is not None
set_review_date_range(page, start_date, end_date)
_ensure_negative_filter(page)
page.get_by_role("button", name="搜索", exact=True).first.click()
total, _initial_visible_count = _wait_for_results(page)
expected_pages = math.ceil(total / PAGE_SIZE) if total else 0
artifact["total_count"] = total
artifact["expected_pages"] = expected_pages
query_label = (
"全部日期" if args.all_time else f"{start_date} 至 {end_date}"
)
print(
f"天猫负面评价采集:{query_label},"
f"页面声明 {total} 条,预计 {expected_pages} 页。"
)
seen_ids: set[str] = set()
for page_number in range(1, expected_pages + 1):
rows = _review_rows(page)
if not rows:
raise RuntimeError(f"第 {page_number} 页未找到可解析的评价行")
if len(rows) > PAGE_SIZE:
raise RuntimeError(f"第 {page_number} 页评价行数超过页面页大小")
page_records: list[dict[str, Any]] = []
for row in rows:
record = normalize_review_row(
row,
account=ACCOUNT_ID,
start_date=start_date,
end_date=end_date,
source_url=page.url,
)
review_id = str(record["review_id"])
if review_id in seen_ids:
raise RuntimeError(f"天猫差评分页返回重复主评 ID: {review_id}")
seen_ids.add(review_id)
page_records.append(record)
artifact["database_rows"] = int(artifact["database_rows"]) + _persist_records(
page_records, no_db=args.no_db
)
for record in page_records:
review_id = str(record["review_id"])
# Whitelisted artifact shape deliberately omits buyer and order fields.
collected[review_id] = {
key: record[key]
for key in (
"review_id",
"business_date",
"review_at",
"review_level",
"content",
"image_urls",
"product_name",
"source_url",
"captured_at",
)
}
completed_pages = page_number
artifact["completed_pages"] = completed_pages
artifact["reviews"] = list(collected.values())
_save_artifact(artifact_path, artifact)
print(f" [{page_number:02d}/{expected_pages:02d}] 已完成 {len(page_records)} 条评价")
if page_number < expected_pages:
next_button = _next_page_button(page)
if next_button is None or _button_disabled(next_button):
raise RuntimeError(f"天猫差评分页提前结束:期望第 {page_number + 1} 页")
before_page = _result_signature(page)
next_button.click()
_wait_for_results(page, previous=before_page)
if len(seen_ids) != total:
raise RuntimeError(
f"天猫差评采集不完整:页面声明 {total} 条,实际读取 {len(seen_ids)} 条"
)
artifact["complete"] = True
artifact["reviews"] = list(collected.values())
_save_artifact(artifact_path, artifact)
print(
f"采集完成:共 {len(seen_ids)} 条负面评价;"
f"数据库写入/更新 {artifact['database_rows']} 行;产物 {artifact_path}。"
)
return 0
except Exception:
artifact["complete"] = False
artifact["completed_pages"] = completed_pages
artifact["total_count"] = total if artifact["total_count"] is None else artifact["total_count"]
artifact["reviews"] = list(collected.values())
_save_artifact(artifact_path, artifact)
raise
finally:
browser.close(persist_state=False)
def main(argv: list[str] | None = None) -> int:
try:
args = parse_args(argv)
runtime_environment = _account_runtime_environment()
preflight_exit_code = _preflight_acceptance_login(runtime_environment)
if preflight_exit_code is not None:
return preflight_exit_code
return run(args, runtime_environment=runtime_environment)
except KeyboardInterrupt:
print("\n用户中断。")
return 130
except Exception as exc:
print(
f"[ERROR] 天猫差评采集失败: {type(exc).__name__}: {exc}",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -2,6 +2,8 @@
from .db import (
close_pool,
delete_jd_after_sales_issue_range_residue,
fetch_dy_customer_service_products,
get_conn,
init_pool,
init_schema,
@@ -9,17 +11,32 @@ from .db import (
persist_dy_shop,
persist_jd_peer_store,
persist_jd_shop,
upsert_dy_after_sales_quality_negotiations,
upsert_dy_after_sales_quality_orders,
upsert_dy_customer_service_conversation_orders,
upsert_dy_customer_service_conversations,
upsert_dy_customer_service_messages,
upsert_dy_customer_service_products,
upsert_dy_negative_reviews,
upsert_dy_peer_store,
upsert_dy_shop_weekly,
upsert_jd_after_sales_issues,
upsert_jd_customer_service_conversations,
upsert_jd_customer_service_messages,
upsert_jd_negative_reviews,
upsert_jd_peer_store,
upsert_jd_self_operated_brand_daily,
upsert_jd_self_operated_product_daily,
upsert_jd_shop_weekly,
upsert_tm_shop_weekly,
upsert_tmall_customer_service_conversations,
upsert_tmall_customer_service_messages,
upsert_tmall_negative_reviews,
)
__all__ = [
"close_pool",
"delete_jd_after_sales_issue_range_residue",
"get_conn",
"init_pool",
"init_schema",
@@ -27,11 +44,26 @@ __all__ = [
"persist_dy_shop",
"persist_jd_peer_store",
"persist_jd_shop",
"fetch_dy_customer_service_products",
"upsert_dy_after_sales_quality_negotiations",
"upsert_dy_after_sales_quality_orders",
"upsert_dy_customer_service_conversation_orders",
"upsert_dy_customer_service_conversations",
"upsert_dy_customer_service_products",
"upsert_dy_negative_reviews",
"upsert_dy_customer_service_messages",
"upsert_dy_peer_store",
"upsert_dy_shop_weekly",
"upsert_jd_after_sales_issues",
"upsert_jd_peer_store",
"upsert_jd_customer_service_conversations",
"upsert_jd_customer_service_messages",
"upsert_jd_negative_reviews",
"upsert_jd_self_operated_brand_daily",
"upsert_jd_self_operated_product_daily",
"upsert_jd_shop_weekly",
"upsert_tmall_customer_service_conversations",
"upsert_tmall_customer_service_messages",
"upsert_tmall_negative_reviews",
"upsert_tm_shop_weekly",
]
@@ -441,6 +441,385 @@ def upsert_jd_self_operated_product_daily(conn, records: list[dict]) -> int:
)
# ── JD 客服聊天记录 ──────────────────────────────────────────────────────────
def _prepare_customer_service_records(records: list[dict]) -> list[dict]:
cleaned_records: list[dict] = []
for record in records:
cleaned = {key: _clean_val(value) for key, value in record.items()}
for json_column in ("raw_session", "raw_message"):
if isinstance(cleaned.get(json_column), dict):
cleaned[json_column] = json.dumps(
cleaned[json_column], ensure_ascii=False, default=str
)
cleaned["updated_at"] = datetime.now().astimezone()
cleaned_records.append(cleaned)
return cleaned_records
def upsert_jd_customer_service_conversations(
conn, records: list[dict]
) -> int:
"""幂等写入京东客服会话,业务日期与实际会话时间同时保留。"""
if not DB_ENABLED or not records:
return 0
return _dynamic_batch_upsert(
conn,
"jd_customer_service_conversations",
_prepare_customer_service_records(records),
("account", "business_date", "customer_id", "session_id"),
)
def upsert_jd_customer_service_messages(conn, records: list[dict]) -> int:
"""幂等写入京东客服消息,重复重跑不会产生重复消息。"""
if not DB_ENABLED or not records:
return 0
return _dynamic_batch_upsert(
conn,
"jd_customer_service_messages",
_prepare_customer_service_records(records),
("account", "business_date", "customer_id", "session_id", "message_key"),
)
def _prepare_tmall_customer_service_records(records: list[dict]) -> list[dict]:
cleaned_records: list[dict] = []
for record in records:
cleaned = {key: _clean_val(value) for key, value in record.items()}
for json_column in ("raw_session", "raw_message", "media_urls"):
value = cleaned.get(json_column)
if isinstance(value, (dict, list)):
cleaned[json_column] = json.dumps(
value, ensure_ascii=False, default=str
)
cleaned["updated_at"] = datetime.now().astimezone()
cleaned_records.append(cleaned)
return cleaned_records
def upsert_tmall_customer_service_conversations(
conn, records: list[dict]
) -> int:
"""幂等写入天猫客服会话查询结果。"""
if not DB_ENABLED or not records:
return 0
return _dynamic_batch_upsert(
conn,
"tmall_customer_service_conversations",
_prepare_tmall_customer_service_records(records),
(
"account",
"query_start_date",
"query_end_date",
"customer_id",
"session_id",
),
)
def upsert_tmall_customer_service_messages(conn, records: list[dict]) -> int:
"""幂等写入天猫客服消息;跨区间重查按消息指纹覆盖。"""
if not DB_ENABLED or not records:
return 0
return _dynamic_batch_upsert(
conn,
"tmall_customer_service_messages",
_prepare_tmall_customer_service_records(records),
("account", "customer_id", "session_id", "message_key"),
)
def _prepare_tmall_negative_review_records(records: list[dict]) -> list[dict]:
cleaned_records: list[dict] = []
for record in records:
cleaned = {key: _clean_val(value) for key, value in record.items()}
for json_column in ("image_urls", "raw_review"):
value = cleaned.get(json_column)
if isinstance(value, (dict, list)):
cleaned[json_column] = json.dumps(
value, ensure_ascii=False, default=str
)
cleaned["updated_at"] = datetime.now().astimezone()
cleaned_records.append(cleaned)
return cleaned_records
def upsert_tmall_negative_reviews(conn, records: list[dict]) -> int:
"""按天猫账号与主评 ID 幂等保存负面评价,不接收订单号字段。"""
if not DB_ENABLED or not records:
return 0
return _dynamic_batch_upsert(
conn,
"tmall_negative_reviews",
_prepare_tmall_negative_review_records(records),
("account", "review_id"),
)
# ── JD 差评及商品信息 ────────────────────────────────────────────────────────
def _prepare_jd_negative_review_records(records: list[dict]) -> list[dict]:
cleaned_records: list[dict] = []
for record in records:
cleaned = {key: _clean_val(value) for key, value in record.items()}
for json_column in ("image_urls", "raw_review"):
value = cleaned.get(json_column)
if isinstance(value, (dict, list)):
cleaned[json_column] = json.dumps(
value, ensure_ascii=False, default=str
)
cleaned["updated_at"] = datetime.now().astimezone()
cleaned_records.append(cleaned)
return cleaned_records
def upsert_jd_negative_reviews(conn, records: list[dict]) -> int:
"""幂等写入京东差评;同一账号、业务日期和评价 ID 只保留一行。"""
if not DB_ENABLED or not records:
return 0
return _dynamic_batch_upsert(
conn,
"jd_negative_reviews",
_prepare_jd_negative_review_records(records),
("account", "business_date", "review_id"),
)
# ── JD 品退异常服务单 ────────────────────────────────────────────────────────
def _prepare_jd_after_sales_issue_records(records: list[dict]) -> list[dict]:
cleaned_records: list[dict] = []
for record in records:
cleaned = {key: _clean_val(value) for key, value in record.items()}
raw_issue = cleaned.get("raw_issue")
if isinstance(raw_issue, (dict, list)):
cleaned["raw_issue"] = json.dumps(
raw_issue,
ensure_ascii=False,
default=str,
)
cleaned["updated_at"] = datetime.now().astimezone()
cleaned_records.append(cleaned)
return cleaned_records
def upsert_jd_after_sales_issues(conn, records: list[dict]) -> int:
"""幂等写入京东品退异常;同一账号、业务日和服务单 ID 只保留一行。"""
if not DB_ENABLED or not records:
return 0
return _dynamic_batch_upsert(
conn,
"jd_after_sales_issues",
_prepare_jd_after_sales_issue_records(records),
("account", "business_date", "service_id"),
)
def delete_jd_after_sales_issue_range_residue(
conn,
*,
account: str,
business_date_start: date,
business_date_end: date,
) -> int:
"""清理范围采集遗留的、申请时间落在范围外的旧行。
旧版单日采集会把页面查询日直接写入 ``business_date``,而商智明细
弹窗可能返回查询日之外的服务单。范围采集按 ``apply_at`` 落业务日,
因此只删除同一账号、范围内业务日且申请时间明确在范围外的历史残留;
不触碰申请时间为空的行,也不影响其他账号或范围外业务日。
"""
if not DB_ENABLED:
return 0
with conn.cursor() as cursor:
cursor.execute(
"""
DELETE FROM jd_after_sales_issues
WHERE account = %s
AND business_date BETWEEN %s AND %s
AND apply_at IS NOT NULL
AND (
(apply_at AT TIME ZONE 'Asia/Shanghai')::date < %s
OR (apply_at AT TIME ZONE 'Asia/Shanghai')::date > %s
)
""",
(
account,
business_date_start,
business_date_end,
business_date_start,
business_date_end,
),
)
return cursor.rowcount
# ── DY 客服会话与消息 ─────────────────────────────────────────────────────────
def _prepare_dy_customer_service_records(records: list[dict]) -> list[dict]:
cleaned_records: list[dict] = []
for record in records:
cleaned = {key: _clean_val(value) for key, value in record.items()}
for json_column in (
"raw_payload",
"card",
"raw_message",
"raw_card",
"title_variants",
):
value = cleaned.get(json_column)
if isinstance(value, (dict, list)):
cleaned[json_column] = json.dumps(
value, ensure_ascii=False, default=str
)
cleaned["updated_at"] = datetime.now().astimezone()
cleaned_records.append(cleaned)
return cleaned_records
def upsert_dy_customer_service_conversations(conn, records: list[dict]) -> int:
"""幂等写入抖音客服会话;同一账号、业务日期和会话 ID 只保留一行。"""
if not DB_ENABLED or not records:
return 0
return _dynamic_batch_upsert(
conn,
"dy_customer_service_conversations",
_prepare_dy_customer_service_records(records),
("account", "business_date", "conversation_id"),
)
def upsert_dy_customer_service_messages(conn, records: list[dict]) -> int:
"""幂等写入抖音客服消息,重复重跑不会产生重复消息。"""
if not DB_ENABLED or not records:
return 0
return _dynamic_batch_upsert(
conn,
"dy_customer_service_messages",
_prepare_dy_customer_service_records(records),
("account", "business_date", "conversation_id", "message_key"),
)
def upsert_dy_customer_service_conversation_orders(
conn, records: list[dict]
) -> int:
"""幂等写入会话绑定的商品/订单(咨询订单面板卡片)。"""
if not DB_ENABLED or not records:
return 0
return _dynamic_batch_upsert(
conn,
"dy_customer_service_conversation_orders",
_prepare_dy_customer_service_records(records),
("account", "business_date", "conversation_id", "order_id"),
)
def fetch_dy_customer_service_products(
conn, product_codes: list[str]
) -> list[dict]:
"""读取商品维表现有行,供采集端合并标题变体。"""
if not DB_ENABLED or not product_codes:
return []
with conn.cursor() as cur:
cur.execute(
"""
select product_code, product_title, first_seen_date,
last_seen_date, title_variants
from dy_customer_service_products
where product_code = any(%s)
""",
(list(product_codes),),
)
rows = cur.fetchall()
return [
{
"product_code": row[0],
"product_title": row[1],
"first_seen_date": row[2],
"last_seen_date": row[3],
"title_variants": row[4]
if isinstance(row[4], dict)
else json.loads(row[4] or "{}"),
}
for row in rows
]
def upsert_dy_customer_service_products(conn, records: list[dict]) -> int:
"""幂等写入商品维表(编码唯一,标题为出现频次最高的变体)。"""
if not DB_ENABLED or not records:
return 0
return _dynamic_batch_upsert(
conn,
"dy_customer_service_products",
_prepare_dy_customer_service_records(records),
("product_code",),
)
# ── DY 品质原因售后单(品退)与协商记录 ───────────────────────────────────────
def _prepare_dy_after_sales_quality_records(records: list[dict]) -> list[dict]:
cleaned_records: list[dict] = []
for record in records:
cleaned = {key: _clean_val(value) for key, value in record.items()}
for json_column in ("raw_row", "evidence_urls", "extra_fields"):
value = cleaned.get(json_column)
if isinstance(value, (dict, list)):
cleaned[json_column] = json.dumps(
value, ensure_ascii=False, default=str
)
cleaned["updated_at"] = datetime.now().astimezone()
cleaned_records.append(cleaned)
return cleaned_records
def upsert_dy_after_sales_quality_orders(conn, records: list[dict]) -> int:
"""幂等写入品质原因售后单;同一账号和售后编号只保留一行。"""
if not DB_ENABLED or not records:
return 0
return _dynamic_batch_upsert(
conn,
"dy_after_sales_quality_orders",
_prepare_dy_after_sales_quality_records(records),
("account", "aftersale_id"),
)
def upsert_dy_after_sales_quality_negotiations(conn, records: list[dict]) -> int:
"""幂等写入售后单协商记录;同一售后单按条目序号覆盖更新。"""
if not DB_ENABLED or not records:
return 0
return _dynamic_batch_upsert(
conn,
"dy_after_sales_quality_negotiations",
_prepare_dy_after_sales_quality_records(records),
("account", "aftersale_id", "entry_seq"),
)
# ── JD 竞店 ───────────────────────────────────────────────────────────────────
@@ -711,3 +1090,34 @@ def persist_dy_peer_store(json_path: str) -> int:
rows = _build_dy_peer_store_rows(data, json_path)
with get_conn() as conn:
return upsert_dy_peer_store(conn, rows)
# ── DY 抖店差评 ───────────────────────────────────────────────────────────────
def _prepare_dy_negative_review_records(records: list[dict]) -> list[dict]:
cleaned_records: list[dict] = []
for record in records:
cleaned = {key: _clean_val(value) for key, value in record.items()}
for json_column in ("image_urls", "raw_review"):
value = cleaned.get(json_column)
if isinstance(value, (dict, list)):
cleaned[json_column] = json.dumps(
value, ensure_ascii=False, default=str
)
cleaned["updated_at"] = datetime.now().astimezone()
cleaned_records.append(cleaned)
return cleaned_records
def upsert_dy_negative_reviews(conn, records: list[dict]) -> int:
"""幂等写入抖音差评;同一账号、业务日期和评价(订单)ID 只保留一行。"""
if not DB_ENABLED or not records:
return 0
return _dynamic_batch_upsert(
conn,
"dy_negative_reviews",
_prepare_dy_negative_review_records(records),
("account", "business_date", "review_id"),
)
@@ -289,3 +289,496 @@ CREATE INDEX IF NOT EXISTS idx_tm_shop_weekly_shop
ON tm_shop_weekly (shop, week_end DESC);
COMMENT ON TABLE tm_shop_weekly IS '天猫店铺周指标 — 一行=一周,对齐 jd_shop_weekly 设计。来源: taobao_sycm.py 采集生意参谋首页+市场排行+店铺评分。';
-- ── 京东客服会话与消息 ───────────────────────────────────────────────────────
-- 一次运行按业务日期筛选;消息实际发生时间单独保存,便于按天/会话回查。
CREATE TABLE IF NOT EXISTS jd_customer_service_conversations (
id BIGSERIAL PRIMARY KEY,
account TEXT NOT NULL,
business_date DATE NOT NULL,
customer_id TEXT NOT NULL,
session_id TEXT NOT NULL,
session_start_at TIMESTAMPTZ,
session_end_at TIMESTAMPTZ,
message_count INTEGER NOT NULL DEFAULT 0,
source_url TEXT NOT NULL,
raw_session JSONB NOT NULL DEFAULT '{}'::jsonb,
captured_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT jd_customer_service_conversation_uniq
UNIQUE (account, business_date, customer_id, session_id)
);
CREATE INDEX IF NOT EXISTS idx_jd_customer_service_conversation_date
ON jd_customer_service_conversations (business_date DESC, customer_id);
CREATE INDEX IF NOT EXISTS idx_jd_customer_service_conversation_session
ON jd_customer_service_conversations (session_id, business_date DESC);
CREATE TABLE IF NOT EXISTS jd_customer_service_messages (
id BIGSERIAL PRIMARY KEY,
account TEXT NOT NULL,
business_date DATE NOT NULL,
customer_id TEXT NOT NULL,
session_id TEXT NOT NULL,
message_key TEXT NOT NULL,
message_uuid TEXT,
message_at TIMESTAMPTZ,
sender_name TEXT,
sender_role TEXT NOT NULL,
waiter_send BOOLEAN NOT NULL DEFAULT FALSE,
content TEXT,
message_type TEXT,
image_url TEXT,
close_session_time TIMESTAMPTZ,
source_url TEXT NOT NULL,
raw_message JSONB NOT NULL DEFAULT '{}'::jsonb,
captured_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT jd_customer_service_message_uniq
UNIQUE (account, business_date, customer_id, session_id, message_key)
);
CREATE INDEX IF NOT EXISTS idx_jd_customer_service_message_date
ON jd_customer_service_messages (business_date DESC, customer_id, message_at);
CREATE INDEX IF NOT EXISTS idx_jd_customer_service_message_session
ON jd_customer_service_messages (session_id, message_at);
COMMENT ON TABLE jd_customer_service_conversations IS
'京东客服聊天会话;business_date 是查询日,session_start_at/session_end_at 是消息实际时间。';
COMMENT ON TABLE jd_customer_service_messages IS
'京东客服聊天消息;按账号、业务日期、顾客、会话和消息身份幂等保存。';
-- ── 京东差评及商品信息 ──────────────────────────────────────────────────────
-- 评价页按业务日采集;review_at 保留京东评价实际发生时间。
CREATE TABLE IF NOT EXISTS jd_negative_reviews (
id BIGSERIAL PRIMARY KEY,
account TEXT NOT NULL,
business_date DATE NOT NULL,
review_id TEXT NOT NULL,
review_at TIMESTAMPTZ,
review_level TEXT NOT NULL DEFAULT '差评',
score SMALLINT,
reviewer_nickname TEXT,
content TEXT,
order_id TEXT,
product_name TEXT,
sku_id TEXT,
sku_name TEXT,
product_color TEXT,
product_size TEXT,
product_image_url TEXT,
image_urls JSONB NOT NULL DEFAULT '[]'::jsonb,
is_vender_reply BOOLEAN NOT NULL DEFAULT FALSE,
reply_count INTEGER NOT NULL DEFAULT 0,
complaint_status INTEGER,
source_url TEXT NOT NULL,
raw_review JSONB NOT NULL DEFAULT '{}'::jsonb,
captured_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT jd_negative_review_uniq
UNIQUE (account, business_date, review_id)
);
CREATE INDEX IF NOT EXISTS idx_jd_negative_review_date
ON jd_negative_reviews (business_date DESC, review_at DESC);
CREATE INDEX IF NOT EXISTS idx_jd_negative_review_sku
ON jd_negative_reviews (sku_id, business_date DESC);
COMMENT ON TABLE jd_negative_reviews IS
'京东差评及商品信息;按账号、业务日期和评价 ID 幂等保存。';
-- ── 京东品退异常服务单 ──────────────────────────────────────────────────────
-- 只保存售后原因“商品出现异常”且用户原声不是未知的服务单;
-- 单日任务的 business_date 是页面查询日;范围任务按 apply_at 的上海日期落业务日。
CREATE TABLE IF NOT EXISTS jd_after_sales_issues (
id BIGSERIAL PRIMARY KEY,
account TEXT NOT NULL,
business_date DATE NOT NULL,
service_id TEXT NOT NULL,
order_id TEXT,
product_name TEXT,
sku_id TEXT,
category_level_1 TEXT,
category_level_2 TEXT,
category_level_3 TEXT,
apply_at TIMESTAMPTZ,
reason_category TEXT,
reason_level_1 TEXT,
reason_level_2 TEXT,
expected_resolution TEXT,
user_original TEXT NOT NULL,
source_url TEXT NOT NULL,
raw_issue JSONB NOT NULL DEFAULT '{}'::jsonb,
captured_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT jd_after_sales_issue_uniq
UNIQUE (account, business_date, service_id)
);
CREATE INDEX IF NOT EXISTS idx_jd_after_sales_issue_date
ON jd_after_sales_issues (business_date DESC, apply_at DESC);
CREATE INDEX IF NOT EXISTS idx_jd_after_sales_issue_sku
ON jd_after_sales_issues (sku_id, business_date DESC);
CREATE INDEX IF NOT EXISTS idx_jd_after_sales_issue_reason
ON jd_after_sales_issues (reason_category, business_date DESC);
COMMENT ON TABLE jd_after_sales_issues IS
'京东品退异常服务单;仅保留用户原声非未知记录,按账号、业务日期和服务单 ID 幂等保存;范围任务按申请日期落业务日。';
-- ── 抖音客服会话与消息 ───────────────────────────────────────────────────────
-- 飞鸽历史会话按业务日期查询;会话实际发生时间单独保存,便于按天/会话回查。
CREATE TABLE IF NOT EXISTS dy_customer_service_conversations (
id BIGSERIAL PRIMARY KEY,
account TEXT NOT NULL,
business_date DATE NOT NULL,
conversation_id TEXT NOT NULL,
customer_nickname TEXT,
session_time_label TEXT,
session_start_at TIMESTAMPTZ,
session_end_at TIMESTAMPTZ,
consult_entry TEXT,
reception_type TEXT,
initiator_scene TEXT,
end_scene TEXT,
rating_time_label TEXT,
rating_result TEXT,
rating_tags TEXT,
conversation_intent TEXT,
smart_reception_label TEXT,
smart_rating_time_label TEXT,
smart_rating_result TEXT,
smart_rating_tags TEXT,
transfer_human_duration TEXT,
transfer_human_type TEXT,
service_problem_tags TEXT,
unmet_metrics TEXT,
message_count INTEGER NOT NULL DEFAULT 0,
source_url TEXT NOT NULL,
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
captured_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT dy_customer_service_conversation_uniq
UNIQUE (account, business_date, conversation_id)
);
CREATE INDEX IF NOT EXISTS idx_dy_customer_service_conversation_date
ON dy_customer_service_conversations (business_date DESC, customer_nickname);
CREATE INDEX IF NOT EXISTS idx_dy_customer_service_conversation_id
ON dy_customer_service_conversations (conversation_id, business_date DESC);
ALTER TABLE dy_customer_service_conversations
ADD COLUMN IF NOT EXISTS transfer_human_type TEXT;
CREATE TABLE IF NOT EXISTS dy_customer_service_messages (
id BIGSERIAL PRIMARY KEY,
account TEXT NOT NULL,
business_date DATE NOT NULL,
conversation_id TEXT NOT NULL,
message_key TEXT NOT NULL,
message_seq INTEGER,
message_at TIMESTAMPTZ,
time_label TEXT,
sender_name TEXT,
sender_role TEXT NOT NULL,
message_type TEXT NOT NULL DEFAULT 'text',
content TEXT,
card JSONB NOT NULL DEFAULT '{}'::jsonb,
source_url TEXT NOT NULL,
raw_message JSONB NOT NULL DEFAULT '{}'::jsonb,
captured_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT dy_customer_service_message_uniq
UNIQUE (account, business_date, conversation_id, message_key)
);
CREATE INDEX IF NOT EXISTS idx_dy_customer_service_message_date
ON dy_customer_service_messages (business_date DESC, conversation_id, message_seq);
CREATE INDEX IF NOT EXISTS idx_dy_customer_service_message_conversation
ON dy_customer_service_messages (conversation_id, message_seq);
-- 会话抽屉右侧「咨询订单」面板:商品/订单与会话绑定保存。
-- 每张订单卡一行,按账号、业务日期、会话和订单号幂等。
CREATE TABLE IF NOT EXISTS dy_customer_service_conversation_orders (
id BIGSERIAL PRIMARY KEY,
account TEXT NOT NULL,
business_date DATE NOT NULL,
conversation_id TEXT NOT NULL,
order_id TEXT NOT NULL,
order_status TEXT,
after_sale_status TEXT,
order_badge TEXT,
product_title TEXT,
product_code TEXT,
spec TEXT,
spec_id TEXT,
paid_amount NUMERIC(12, 2),
paid_raw TEXT,
pay_channel TEXT,
paid_at TIMESTAMPTZ,
discount_text TEXT,
freight_text TEXT,
logistics_company TEXT,
logistics_no TEXT,
logistics_status TEXT,
receiver_info TEXT,
shipped_at TIMESTAMPTZ,
service_tags TEXT,
remark_text TEXT,
source_url TEXT NOT NULL,
raw_card JSONB NOT NULL DEFAULT '{}'::jsonb,
captured_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT dy_customer_service_conversation_order_uniq
UNIQUE (account, business_date, conversation_id, order_id)
);
CREATE INDEX IF NOT EXISTS idx_dy_customer_service_conversation_order_date
ON dy_customer_service_conversation_orders (business_date DESC, conversation_id);
CREATE INDEX IF NOT EXISTS idx_dy_customer_service_conversation_order_code
ON dy_customer_service_conversation_orders (product_code, business_date DESC);
CREATE INDEX IF NOT EXISTS idx_dy_customer_service_conversation_order_id
ON dy_customer_service_conversation_orders (order_id);
ALTER TABLE dy_customer_service_conversations
ADD COLUMN IF NOT EXISTS order_panel_text TEXT;
ALTER TABLE dy_customer_service_conversations
ADD COLUMN IF NOT EXISTS order_count INTEGER NOT NULL DEFAULT 0;
-- 商品维表:从会话绑定的订单卡里提炼,编码唯一;事实表
-- dy_customer_service_conversation_orders.product_code 关联本表。
CREATE TABLE IF NOT EXISTS dy_customer_service_products (
product_code TEXT PRIMARY KEY,
product_title TEXT NOT NULL,
first_seen_date DATE,
last_seen_date DATE,
title_variants JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE dy_customer_service_products IS
'抖音客服会话涉及的商品维表;product_code 是订单卡上的商品编码,标题按出现频次收敛为权威值,title_variants 保留所有标题变体及次数。';
COMMENT ON TABLE dy_customer_service_conversations IS
'抖音飞鸽历史会话;business_date 是查询日,session_start_at/session_end_at 是会话实际时间。';
COMMENT ON TABLE dy_customer_service_conversation_orders IS
'抖音飞鸽会话绑定商品/订单;来自会话抽屉右侧咨询订单面板,按账号、业务日期、会话和订单号幂等保存。';
COMMENT ON TABLE dy_customer_service_messages IS
'抖音飞鸽会话消息;按账号、业务日期、会话和消息身份幂等保存。';
-- ── 抖音品质原因售后单(品退)与协商记录 ──────────────────────────────────────
-- 售后工作台按“申请原因=商品品质原因”筛选;每条售后单按申请时间的上海日期
-- 落业务日。范围/全量重跑共用一个幂等键 (account, aftersale_id),重复执行
-- 只更新对应行,不会因查询区间重叠产生重复数据。
CREATE TABLE IF NOT EXISTS dy_after_sales_quality_orders (
id BIGSERIAL PRIMARY KEY,
account TEXT NOT NULL,
business_date DATE NOT NULL,
aftersale_id TEXT NOT NULL,
order_id TEXT,
aftersale_type TEXT,
status_tag TEXT,
aftersale_status TEXT,
apply_at TIMESTAMPTZ,
apply_reason TEXT,
apply_quantity INTEGER,
pay_amount NUMERIC(12, 2),
refund_amount NUMERIC(12, 2),
product_name TEXT,
product_id TEXT,
product_code TEXT,
spec_name TEXT,
negotiation_count INTEGER NOT NULL DEFAULT 0,
detail_url TEXT,
source_url TEXT NOT NULL,
raw_row JSONB NOT NULL DEFAULT '{}'::jsonb,
captured_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT dy_after_sales_quality_order_uniq UNIQUE (account, aftersale_id)
);
CREATE INDEX IF NOT EXISTS idx_dy_after_sales_quality_order_date
ON dy_after_sales_quality_orders (business_date DESC, product_code);
CREATE INDEX IF NOT EXISTS idx_dy_after_sales_quality_order_product
ON dy_after_sales_quality_orders (product_id, business_date DESC);
CREATE INDEX IF NOT EXISTS idx_dy_after_sales_quality_order_order_id
ON dy_after_sales_quality_orders (order_id);
-- 协商记录时间线:每条协商动作一行,保存退货原因、退款说明、凭证信息
-- 图片 URL 及其余标签值字段;按账号、售后编号和条目序号幂等。
CREATE TABLE IF NOT EXISTS dy_after_sales_quality_negotiations (
id BIGSERIAL PRIMARY KEY,
account TEXT NOT NULL,
aftersale_id TEXT NOT NULL,
entry_seq INTEGER NOT NULL,
actor_role TEXT,
action_text TEXT,
happened_at TIMESTAMPTZ,
operator TEXT,
return_reason TEXT,
refund_note TEXT,
evidence_urls JSONB NOT NULL DEFAULT '[]'::jsonb,
extra_fields JSONB NOT NULL DEFAULT '{}'::jsonb,
raw_text TEXT,
captured_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT dy_after_sales_quality_negotiation_uniq
UNIQUE (account, aftersale_id, entry_seq)
);
CREATE INDEX IF NOT EXISTS idx_dy_after_sales_quality_negotiation_entry
ON dy_after_sales_quality_negotiations (aftersale_id, entry_seq);
COMMENT ON TABLE dy_after_sales_quality_orders IS
'抖音售后工作台商品品质原因售后单(品退);business_date 按申请时间的上海日期归属,按账号和售后编号幂等保存,商品名称/商品ID/商家编码来自详情页交易信息。';
COMMENT ON TABLE dy_after_sales_quality_negotiations IS
'抖音品退售后单协商记录;每条协商动作一行,保存退货原因、退款说明、凭证信息图片 URL 及其余标签值字段,按账号、售后编号和条目序号幂等保存。';
-- ── 抖音差评 ────────────────────────────────────────────────────────────────
-- 抖店评价管理-评价明细按评价时间逐日查询差评;review_id 取订单 ID。
CREATE TABLE IF NOT EXISTS dy_negative_reviews (
id BIGSERIAL PRIMARY KEY,
account TEXT NOT NULL,
business_date DATE NOT NULL,
review_id TEXT NOT NULL,
order_id TEXT,
review_at TIMESTAMPTZ,
rating_level TEXT,
stars INTEGER,
reviewer_nickname TEXT,
content TEXT,
image_urls JSONB NOT NULL DEFAULT '[]'::jsonb,
display_status TEXT,
experience_score_excluded BOOLEAN NOT NULL DEFAULT false,
product_id TEXT,
product_name TEXT,
spec_name TEXT,
spec_id TEXT,
source_url TEXT NOT NULL,
raw_review JSONB NOT NULL DEFAULT '{}'::jsonb,
captured_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT dy_negative_review_uniq
UNIQUE (account, business_date, review_id)
);
CREATE INDEX IF NOT EXISTS idx_dy_negative_review_date
ON dy_negative_reviews (business_date DESC);
CREATE INDEX IF NOT EXISTS idx_dy_negative_review_product
ON dy_negative_reviews (product_id, business_date DESC);
COMMENT ON TABLE dy_negative_reviews IS
'抖音差评及商品信息;business_date 是评价时间所属查询日,review_id 取订单 ID 幂等保存。';
-- ── 天猫客服会话与消息 ───────────────────────────────────────────────────────
-- 千牛聊天记录查询页按日期区间筛选;每次最多查询 31 天,消息实际时间单独保存。
CREATE TABLE IF NOT EXISTS tmall_customer_service_conversations (
id BIGSERIAL PRIMARY KEY,
account TEXT NOT NULL,
query_start_date DATE NOT NULL,
query_end_date DATE NOT NULL,
customer_id TEXT NOT NULL,
customer_name TEXT NOT NULL,
session_id TEXT NOT NULL,
session_start_at TIMESTAMPTZ,
session_end_at TIMESTAMPTZ,
message_count INTEGER NOT NULL DEFAULT 0,
source_url TEXT NOT NULL,
raw_session JSONB NOT NULL DEFAULT '{}'::jsonb,
captured_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT tmall_customer_service_conversation_uniq
UNIQUE (
account,
query_start_date,
query_end_date,
customer_id,
session_id
)
);
CREATE INDEX IF NOT EXISTS idx_tmall_customer_service_conversation_range
ON tmall_customer_service_conversations (
query_start_date DESC,
query_end_date DESC,
customer_id
);
CREATE INDEX IF NOT EXISTS idx_tmall_customer_service_conversation_session
ON tmall_customer_service_conversations (session_id, query_start_date DESC);
CREATE TABLE IF NOT EXISTS tmall_customer_service_messages (
id BIGSERIAL PRIMARY KEY,
account TEXT NOT NULL,
business_date DATE NOT NULL,
query_start_date DATE NOT NULL,
query_end_date DATE NOT NULL,
customer_id TEXT NOT NULL,
customer_name TEXT NOT NULL,
session_id TEXT NOT NULL,
message_key TEXT NOT NULL,
message_at TIMESTAMPTZ,
sender_name TEXT,
sender_role TEXT NOT NULL,
content TEXT,
message_type TEXT NOT NULL DEFAULT 'text',
media_urls JSONB NOT NULL DEFAULT '[]'::jsonb,
source_url TEXT NOT NULL,
raw_message JSONB NOT NULL DEFAULT '{}'::jsonb,
captured_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT tmall_customer_service_message_uniq
UNIQUE (account, customer_id, session_id, message_key)
);
CREATE INDEX IF NOT EXISTS idx_tmall_customer_service_message_date
ON tmall_customer_service_messages (business_date DESC, customer_id, message_at);
CREATE INDEX IF NOT EXISTS idx_tmall_customer_service_message_session
ON tmall_customer_service_messages (session_id, message_at);
COMMENT ON TABLE tmall_customer_service_conversations IS
'天猫千牛客服会话;query_start_date/query_end_date 记录查询窗口,消息时间保留实际发生时间。';
COMMENT ON TABLE tmall_customer_service_messages IS
'天猫千牛客服消息;按账号、顾客、会话和稳定消息指纹幂等保存。';
-- ── 天猫负面评价 ───────────────────────────────────────────────────────────
-- 千牛评价管理按控制台选择的评价时间范围查询负面评价;不保存订单号。
CREATE TABLE IF NOT EXISTS tmall_negative_reviews (
id BIGSERIAL PRIMARY KEY,
account TEXT NOT NULL,
business_date DATE NOT NULL,
review_id TEXT NOT NULL,
review_at TIMESTAMPTZ NOT NULL,
review_level TEXT NOT NULL DEFAULT '负面评价',
content TEXT,
image_urls JSONB NOT NULL DEFAULT '[]'::jsonb,
product_name TEXT NOT NULL,
source_url TEXT NOT NULL,
raw_review JSONB NOT NULL DEFAULT '{}'::jsonb,
captured_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT tmall_negative_review_uniq
UNIQUE (account, review_id)
);
CREATE INDEX IF NOT EXISTS idx_tmall_negative_review_date
ON tmall_negative_reviews (business_date DESC, review_at DESC);
CREATE INDEX IF NOT EXISTS idx_tmall_negative_review_product
ON tmall_negative_reviews (product_name, business_date DESC);
COMMENT ON TABLE tmall_negative_reviews IS
'天猫评价管理负面评价;保存评价文字、图片和商品名称,按账号及主评 ID 幂等更新,不保存订单号。';
@@ -1252,11 +1252,52 @@ def _action_fingerprint(locator: Any) -> str:
const readText = (node) => (
node ? (node.innerText || node.textContent || '').trim() : ''
);
const productIds = (text) => {
const matches = String(text || '').matchAll(
/(?:商品\s*)?ID\s*[::]?\s*(\d{8,})/ig
);
return [...new Set([...matches].map((match) => match[1]))];
};
const isVisible = (node) => {
if (!node) return false;
const style = window.getComputedStyle(node);
const rect = node.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& rect.width > 0
&& rect.height > 0;
};
let productScopeText = '';
let current = actionRow || clickable;
let depth = 0;
while (current && depth < 12) {
const currentText = readText(current);
if (productIds(currentText).length === 1) {
productScopeText = currentText;
break;
}
current = current.parentElement;
depth += 1;
}
if (!productScopeText) {
const elements = [...document.querySelectorAll('body *')];
const actionIndex = elements.indexOf(clickable);
for (let index = actionIndex - 1; index >= 0; index -= 1) {
const candidate = elements[index];
if (!isVisible(candidate)) continue;
const candidateText = readText(candidate);
if (productIds(candidateText).length === 1) {
productScopeText = candidateText;
break;
}
}
}
return {
log_extra: clickable.getAttribute('data-log-extra'),
row_key: actionRow ? actionRow.getAttribute('data-row-key') : null,
action_text: readText(actionContainer),
previous_text: readText(productHeader),
product_scope_text: productScopeText,
};
}
"""
@@ -1302,7 +1343,19 @@ def _action_fingerprint(locator: Any) -> str:
label="product_id",
)
else:
product_id = _extract_item_id(str(payload.get("previous_text") or ""))
product_id = next(
(
candidate
for context in (
payload.get("previous_text"),
payload.get("product_scope_text"),
)
if (candidate := _try_extract_item_id(str(context or "")))
),
None,
)
if product_id is None:
raise PriceAppealError("待改价商品行缺少可验证的商品 ID")
action_text = normalize_action_fingerprint(
str(payload.get("action_text") or "")
@@ -1327,10 +1380,15 @@ def _stable_action_identifier(value: Any, *, label: str) -> str:
def _extract_item_id(text: str) -> str:
match = re.search(r"(?:商品\s*)?ID\s*[::]?\s*(\d{8,})", text, re.IGNORECASE)
if match is None:
product_id = _try_extract_item_id(text)
if product_id is None:
raise PriceAppealError("待改价商品行缺少可验证的商品 ID")
return match.group(1)
return product_id
def _try_extract_item_id(text: str) -> str | None:
match = re.search(r"(?:商品\s*)?ID\s*[::]?\s*(\d{8,})", text, re.IGNORECASE)
return match.group(1) if match else None
def _verify_item_identity(panel: Any, item_id: str) -> None:
@@ -80,7 +80,6 @@ SPECIAL_SKU_STOCK_THRESHOLDS = {
"10440003": 100,
"10362004": 300,
"10362007": 300,
"10362008": 300,
"10362010": 300,
"10362011": 300,
}
+1
View File
@@ -55,6 +55,7 @@ NOTIFICATION_CAPABILITIES: Mapping[str, str] = MappingProxyType(
"content.relogin.weekly": "刷新期间投递各平台扫码二维码,并在平台失败时投递失败汇总。",
"product.alert.daily": "命中销量下滑规则且尚未重复通知时投递告警。",
"product.market_rank": "存在本次新生成的平台报告链接且尚未重复通知时投递。",
"product.online_stock_alert.daily": "线上商品当前库存低于 3 时向配置接收人发送库存预警。",
"supply.purchase_confirmation.daily": "采购确认结果生成完成后投递最终业务通知。",
"supply.replenishment.weekly": "补货处理完成后投递最终结果,包括无新增补货项的结果。",
"supply.replenishment_alert.daily": "存在命中库存阈值的 SKU 时投递预警。",
+1 -1
View File
@@ -8,7 +8,7 @@ from dataclasses import dataclass
from pathlib import Path
DEFAULT_EXCLUDED_DIRECTORIES = frozenset(
{"var", ".git", ".venv", ".learnings", "build", "dist"}
{"var", ".git", ".venv", ".learnings", "build", "dist", ".playwright-cli"}
)
_ASSIGNMENT_PATTERN = re.compile(
+17
View File
@@ -4134,6 +4134,23 @@ kbd {
box-shadow: 0 0 0 3px rgba(49, 87, 213, 0.08);
}
.run-date-range {
display: flex;
align-items: center;
gap: 8px;
}
.run-date-range input {
min-width: 0;
flex: 1 1 0;
}
.run-date-range > span {
color: var(--muted);
font-size: 11px;
white-space: nowrap;
}
.field small {
margin-top: 5px;
color: var(--muted);
+103 -11
View File
@@ -128,6 +128,49 @@
return workflow?.id === "product.tmall_baibu_apply";
}
function isDateRangeWorkflow(workflow) {
return workflow?.date_range_mode === "date_range";
}
function runStoreConfig(workflow) {
if (isDateRangeWorkflow(workflow) && workflow.store_config) {
return workflow.store_config;
}
if (workflow?.id === "product.video_upload") {
return {
label: "上传店铺",
hint: "先扫描飞书并按所选店铺规则筛选;存在未上传附件时才登录该店铺账号。",
options: [
{ value: "flagship", label: "光影行星旗舰店" },
{ value: "luggage", label: "光影行星鑫华达专卖店:龙虾仔" },
],
};
}
return null;
}
function syncRunStoreField(workflow) {
const field = byId("run-store-field");
const select = byId("run-store");
const config = runStoreConfig(workflow);
field.hidden = !config;
if (!config) {
select.replaceChildren();
return;
}
byId("run-store-label").textContent = config.label || "店铺";
byId("run-store-hint").textContent = config.hint || "选择本次运行使用的店铺。";
select.replaceChildren(
...(config.options || []).map((option) => {
const element = document.createElement("option");
element.value = option.value;
element.textContent = option.label;
return element;
}),
);
select.value = config.options?.[0]?.value || "";
}
function runPeriodLabel(workflow) {
return isRepeatableTmallBaibu(workflow) ? "运行记录日期" : "业务日期";
}
@@ -274,6 +317,11 @@
function syncRunPeriodHint() {
const workflow = selectedWorkflow();
if (isDateRangeWorkflow(workflow)) {
byId("run-date-hint").textContent =
workflow.date_range_hint || "按所选日期范围采集;平台会按可查询区间拆分。";
return;
}
if (workflow?.run_date_mode !== "month") return;
const range = naturalMonthRange(byId("run-business-date").value);
byId("run-date-hint").textContent = range
@@ -312,6 +360,7 @@
source: "console",
label: String(operationId),
businessDate: workflow.active_run?.business_date || "正在确认",
businessDateEnd: workflow.active_run?.business_date_end || null,
};
}
if (workflow?.scheduler_job?.status === "running" && workflow.scheduler_job?.cancellable) {
@@ -322,6 +371,7 @@
source: "scheduler",
label: "项目定时调度",
businessDate: workflow.scheduler_job.business_date || "正在确认",
businessDateEnd: null,
};
}
return null;
@@ -3167,6 +3217,7 @@
const isAllShopDaily = workflow.id === "product.erp_all_shop_daily";
const isNoteInventory = isNotesMasterWorkflow(workflow);
const repeatableTmallBaibu = isRepeatableTmallBaibu(workflow);
const dateRangeMode = isDateRangeWorkflow(workflow);
byId("run-modal-title").textContent = isNoteInventory
? `同步「${workflow.name}」`
: isAllShopDaily
@@ -3175,36 +3226,52 @@
byId("run-workflow-id").textContent = workflow.id;
const monthMode = workflow.run_date_mode === "month";
const periodInput = byId("run-business-date");
const periodEndInput = byId("run-business-date-end");
byId("run-period-field").hidden = repeatableTmallBaibu;
periodInput.required = !repeatableTmallBaibu;
periodEndInput.hidden = !dateRangeMode;
byId("run-date-range-separator").hidden = !dateRangeMode;
periodEndInput.required = dateRangeMode;
const defaultDate = todayForTimezone(workflow.schedule?.business_date_offset_days || 0);
periodInput.type = monthMode ? "month" : "date";
periodInput.value = monthMode ? defaultDate.slice(0, 7) : defaultDate;
periodEndInput.value = defaultDate;
const dateRangeConstraints = workflow.date_range_constraints || null;
for (const input of [periodInput, periodEndInput]) {
if (dateRangeMode && dateRangeConstraints) {
input.min = dateRangeConstraints.min;
input.max = dateRangeConstraints.max;
} else {
input.removeAttribute("min");
input.removeAttribute("max");
}
}
byId("run-period-label").textContent = isNoteInventory
? "运行记录日期"
: repeatableTmallBaibu
? "运行记录日期"
: monthMode
? "业务月份"
: isAllShopDaily ? "补采日期" : "业务日期";
: isAllShopDaily ? "补采日期"
: dateRangeMode ? "执行开始日期"
: "业务日期";
byId("run-date-hint").textContent = isNoteInventory
? "仅用于标记本次运行记录;正式同步始终扫描当前各款式的合作达人和自营笔记表。"
: repeatableTmallBaibu
? "仅用于平台内部记录和归档,不参与百补导入防重;每次都会重新下载并提交。"
: isAllShopDaily
? "默认选择昨天;也可以选择任意一天,正式执行后将全选聚水潭全部店铺并幂等覆盖该日款式数据。"
: dateRangeMode
? (workflow.date_range_hint || "按所选店铺和日期范围逐日采集;每个业务日独立幂等。")
: workflow.schedule?.business_date_offset_days
? `已按计划偏移 ${workflow.schedule.business_date_offset_days} 天预填;手动选择的是业务日期。`
: "本次数据处理所对应的业务日期。";
const storeField = byId("run-store-field");
const supportsStoreSelection = workflow.id === "product.video_upload";
storeField.hidden = !supportsStoreSelection;
byId("run-store").value = "flagship";
syncRunStoreField(workflow);
syncTmallTopicField(workflow);
syncTmallBaibuImportField(workflow);
byId("run-preview-title").textContent = isNoteInventory ? "同步预演" : "安全预演";
byId("run-preview-description").textContent = isNoteInventory ? "检查执行计划,不写入数据库" : "检查流程但不执行外部效果";
if (monthMode) syncRunPeriodHint();
if (monthMode || dateRangeMode) syncRunPeriodHint();
document.querySelector(`input[name="execute"][value="${forceRefresh ? "true" : "false"}"]`).checked = true;
byId("run-shadow").checked = false;
byId("run-confirmed").checked = false;
@@ -3280,7 +3347,9 @@
byId("stop-modal-title").textContent = `停止「${workflow.name}」`;
byId("stop-workflow-id").textContent = workflow.id;
byId("stop-workflow-name").textContent = workflow.name;
byId("stop-business-date").textContent = operation.businessDate;
byId("stop-business-date").textContent = operation.businessDateEnd
? `${operation.businessDate} 至 ${operation.businessDateEnd}`
: operation.businessDate;
byId("stop-operation-id").textContent = operation.label;
byId("stop-form-error").hidden = true;
syncStopDialogSubmitting(false);
@@ -3382,10 +3451,13 @@
const execute = document.querySelector('input[name="execute"]:checked')?.value === "true";
const forceRefresh = !isRepeatableTmallBaibu(workflow) && byId("run-force").checked;
const repeatableTmallBaibu = isRepeatableTmallBaibu(workflow);
const dateRangeMode = isDateRangeWorkflow(workflow);
const selectedPeriod = byId("run-business-date").value;
const selectedPeriodEnd = byId("run-business-date-end").value;
const monthMode = workflow.run_date_mode === "month";
const businessDate = repeatableTmallBaibu ? todayForTimezone(0) : monthMode ? `${selectedPeriod}-01` : selectedPeriod;
const store = workflow.id === "product.video_upload" ? byId("run-store").value : null;
const businessDateEnd = dateRangeMode ? selectedPeriodEnd : null;
const store = runStoreConfig(workflow) ? byId("run-store").value : null;
const tmallTopicKeyword = workflow.id === "product.video_upload"
? byId("run-tmall-topic-keyword").value.trim()
: null;
@@ -3394,6 +3466,7 @@
: null;
const errorBox = byId("run-form-error");
byId("run-business-date").removeAttribute("aria-invalid");
byId("run-business-date-end").removeAttribute("aria-invalid");
errorBox.hidden = true;
if (!repeatableTmallBaibu && !selectedPeriod) {
byId("run-business-date").setAttribute("aria-invalid", "true");
@@ -3402,6 +3475,21 @@
byId("run-business-date").focus();
return;
}
if (dateRangeMode && !selectedPeriodEnd) {
byId("run-business-date-end").setAttribute("aria-invalid", "true");
errorBox.textContent = "请选择执行结束日期";
errorBox.hidden = false;
byId("run-business-date-end").focus();
return;
}
if (dateRangeMode && selectedPeriodEnd < selectedPeriod) {
byId("run-business-date").setAttribute("aria-invalid", "true");
byId("run-business-date-end").setAttribute("aria-invalid", "true");
errorBox.textContent = "结束日期不能早于开始日期";
errorBox.hidden = false;
byId("run-business-date-end").focus();
return;
}
state.launchingWorkflows.add(workflow.id);
if (forceRefresh) state.forceLaunchingWorkflows.add(workflow.id);
renderModuleNav();
@@ -3413,6 +3501,7 @@
method: "POST",
body: {
business_date: businessDate,
...(businessDateEnd ? { business_date_end: businessDateEnd } : {}),
execute,
shadow: byId("run-shadow").checked,
confirmed: execute && byId("run-confirmed").checked,
@@ -3427,7 +3516,9 @@
},
});
closeRun();
const storeLabel = store === "flagship" ? "光影行星旗舰店" : store === "luggage" ? "光影行星鑫华达专卖店:龙虾仔" : "";
const storeLabel = store
? byId("run-store").selectedOptions[0]?.textContent || store
: "";
const topicLabel = tmallTopicKeyword ? `话题关键词 ${tmallTopicKeyword}` : "";
const tmallBaibuImportLabel = tmallBaibuImportMode
? byId("run-tmall-baibu-mode").selectedOptions[0]?.textContent || ""
@@ -3436,11 +3527,12 @@
const successTitle = forceRefresh
? noteInventory ? "已提交强制同步" : "已提交强制重跑"
: noteInventory ? "已提交笔记清单同步" : "已提交运行";
const periodLabel = isRepeatableTmallBaibu(workflow) ? "运行记录" : "业务日期";
const periodLabel = isRepeatableTmallBaibu(workflow) ? "运行记录" : dateRangeMode ? "执行日期范围" : "业务日期";
const periodText = dateRangeMode ? `${businessDate} 至 ${businessDateEnd}` : businessDate;
toast(
"success",
successTitle,
`${execute ? forceRefresh ? "强制重跑" : noteInventory ? "正式同步" : "正式执行" : noteInventory ? "同步预演" : "安全预演"}${storeLabel ? ` · ${storeLabel}` : ""}${topicLabel ? ` · ${topicLabel}` : ""}${tmallBaibuImportLabel ? ` · ${tmallBaibuImportLabel}` : ""} · ${periodLabel} ${businessDate} · 操作 ${result.operation_id}`,
`${execute ? forceRefresh ? "强制重跑" : noteInventory ? "正式同步" : "正式执行" : noteInventory ? "同步预演" : "安全预演"}${storeLabel ? ` · ${storeLabel}` : ""}${topicLabel ? ` · ${topicLabel}` : ""}${tmallBaibuImportLabel ? ` · ${tmallBaibuImportLabel}` : ""} · ${periodLabel} ${periodText} · 操作 ${result.operation_id}`,
);
await loadOverview({ silent: true });
openDrawer(workflow.id, "steps");
+6 -2
View File
@@ -462,17 +462,21 @@
<div class="modal__body">
<label class="field" id="run-period-field">
<span id="run-period-label">业务日期</span>
<div class="run-date-range">
<input id="run-business-date" name="business_date" type="date" required>
<span id="run-date-range-separator" aria-hidden="true">至</span>
<input id="run-business-date-end" name="business_date_end" type="date" aria-label="执行结束日期" hidden>
</div>
<small id="run-date-hint">本次数据处理所对应的业务日期。</small>
</label>
<label class="field" id="run-store-field" hidden>
<span>上传店铺</span>
<span id="run-store-label">上传店铺</span>
<select id="run-store" name="store">
<option value="flagship">光影行星旗舰店</option>
<option value="luggage">光影行星鑫华达专卖店:龙虾仔</option>
</select>
<small>先扫描飞书并按所选店铺规则筛选;存在未上传附件时才登录该店铺账号。</small>
<small id="run-store-hint">先扫描飞书并按所选店铺规则筛选;存在未上传附件时才登录该店铺账号。</small>
</label>
<label class="field" id="run-tmall-topic-field" hidden>
+5 -1
View File
@@ -228,7 +228,11 @@ class WorkflowEngine:
with ExitStack() as stack:
for resource in sorted(step.resources):
stack.enter_context(
self._lock_manager.acquire(resource, owner=context.run_id)
self._lock_manager.acquire(
resource,
owner=context.run_id,
timeout_seconds=step.resource_lock_timeout_seconds,
)
)
action_started = True
outcome = step.action.execute(
+2
View File
@@ -31,6 +31,7 @@ def build_catalog_workflow(
official_notification: bool = False,
independent_step_resources: bool = False,
default_replay_policy: str = "guarded",
resource_lock_timeout_seconds: float = 30.0,
) -> WorkflowDefinition:
"""Build one executable graph without adding one class per business task."""
@@ -72,6 +73,7 @@ def build_catalog_workflow(
),
max_attempts=1,
resources=step_resources,
resource_lock_timeout_seconds=resource_lock_timeout_seconds,
production_sink=True,
official_notification=official_notification,
replay_policy=(
+11
View File
@@ -55,6 +55,7 @@ class StepDefinition:
official_notification: bool = False
replay_policy: ReplayPolicy = "guarded"
run_after_failure: bool = False
resource_lock_timeout_seconds: float = 30.0
def __post_init__(self) -> None:
object.__setattr__(self, "depends_on", tuple(self.depends_on))
@@ -81,6 +82,16 @@ class StepDefinition:
raise WorkflowValidationError(
f"timeout_seconds must be positive for step {self.step_id!r}"
)
if (
isinstance(self.resource_lock_timeout_seconds, bool)
or not isinstance(self.resource_lock_timeout_seconds, (int, float))
or not math.isfinite(self.resource_lock_timeout_seconds)
or self.resource_lock_timeout_seconds <= 0
):
raise WorkflowValidationError(
"resource_lock_timeout_seconds must be positive for step "
f"{self.step_id!r}"
)
if (
isinstance(self.max_attempts, bool)
or not isinstance(self.max_attempts, int)
@@ -674,3 +674,52 @@ def test_wait_for_exported_excel_returns_immediately_when_present(monkeypatch):
)
assert chanmama._wait_for_exported_excel(0.0) == "/downloads/已有.xlsx"
def test_wait_for_exported_excel_pumps_playwright_events(monkeypatch):
"""回归: 纯 sleep 轮询不泵 Playwright 事件会让 download 事件排队,
download.save_as 到 quit 才执行,文件错过 deadline 误判超时。
等待期间必须周期性调用 driver 泵事件循环。"""
class _FakeDriver:
def __init__(self) -> None:
self.eval_calls = 0
def execute_script(self, script, *args):
self.eval_calls += 1
return None
now = [1000.0]
results = iter([None, None, "/downloads/泵事件后落盘.xlsx"])
driver = _FakeDriver()
monkeypatch.setattr(chanmama.time, "time", lambda: now[0])
monkeypatch.setattr(
chanmama.time,
"sleep",
lambda 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, driver=driver) == (
"/downloads/泵事件后落盘.xlsx"
)
assert driver.eval_calls >= 3
def test_wait_for_exported_excel_tolerates_driver_errors(monkeypatch):
"""泵事件失败(页面导航中等)不应中断文件轮询。"""
class _BrokenDriver:
def execute_script(self, script, *args):
raise RuntimeError("page navigating")
monkeypatch.setattr(
chanmama,
"_find_new_excel_after",
lambda _pre_mtime: "/downloads/已有.xlsx",
)
assert chanmama._wait_for_exported_excel(0.0, driver=_BrokenDriver()) == (
"/downloads/已有.xlsx"
)
@@ -358,22 +358,32 @@ def test_scheduled_collection_has_no_automatic_retry_and_syncs_once():
)
def test_daily_collection_includes_self_operated_pipeline():
def test_daily_collection_separates_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 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"
assert entries == ["run_all.py", "data/tools/sync_metrics_to_cmt_notes.py"]
assert steps[1].args == ("--scope", "collaborators")
self_steps = _workflow("content.metrics.self_operated.daily").steps
self_entries = [step.entry for step in self_steps]
assert self_entries == [
"data/tools/refresh_self_mapping.py",
"self_bilibili_scraper.py",
"chanmama_scraper.py",
"self_xhs_scraper.py",
"data/tools/sync_metrics_to_cmt_notes.py",
"data/tools/sync_self_video_metrics.py",
]
sync_step = next(
step
for step in self_steps
if step.entry == "data/tools/sync_metrics_to_cmt_notes.py"
)
assert sync_step.args == ("--scope", "self-operated")
@pytest.mark.parametrize(
@@ -0,0 +1,661 @@
# -*- coding: utf-8 -*-
"""自营内容全量指标(抖音/B站/小红书): 解析、账号映射、入库行构造的边界测试。"""
from __future__ import annotations
import datetime as dt
import json
import os
import re
from pathlib import Path
import openpyxl
from gyxx_flow.modules.content_marketing import (
chanmama_export,
self_operated_accounts,
self_xhs_scraper,
)
from gyxx_flow.modules.content_marketing.data.tools import (
sync_self_video_metrics as sync_tool,
)
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
PROJECT_ROOT = Path(__file__).resolve().parents[3]
TOOLS = (
PROJECT_ROOT / "src/gyxx_flow/modules/content_marketing/data/tools"
)
EXPORT_HEADERS = [
"视频名称", "蝉妈妈视频链接", "抖音视频链接", "达人昵称", "抖音号",
"视频时长", "发布时间", "预估曝光", "点赞数", "评论数", "转发数",
"预估销量", "预估销量-指数", "预估销售额", "预估销售额-指数",
]
STORE_FLAGSHIP = "光影行星箱包旗舰店"
STORE_SPECIALTY = "GYXX光影行星箱包专卖店"
XHS_SHOP = "光影行星GYXX旗舰店"
def _write_export_xlsx(path: Path, rows: list[tuple]) -> str:
workbook = openpyxl.Workbook()
sheet = workbook.active
sheet.append(EXPORT_HEADERS)
for row in rows:
sheet.append(list(row))
workbook.save(path)
workbook.close()
return str(path)
def _douyin_sample_rows() -> list[tuple]:
return [
("总计", "-", "-", "-", "-", "-", "-", "-", "-", "-", "-", "0", "-", "0", "-"),
(
"轻户外,自己定义-光影行星 逐星",
"https://www.chanmama.com/awemeDetail/SbXQnFsF2tlZF5Wy7U1uG3izRQaBOwYT",
"https://www.iesdouyin.com/share/video/7687797200985869595/?region=CN",
STORE_FLAGSHIP, "gyxx168888", "1分0秒", "2026-09-21 09:39",
"3.2w+", 135, 8, 2, "12", 0, "3580.5", 0,
),
(
"图文笔记标题",
"https://www.chanmama.com/awemeDetail/M7_SEJOk9P_Q1eafatlOCAQl4F2eI3FR",
"https://www.iesdouyin.com/share/note/7687432318584143857/?region=CN",
STORE_SPECIALTY, "GYXX168168", "--", "2026-09-20 10:03",
"0", 1, 0, 0, "0", 0, "0", 0,
),
]
def test_parse_chanmama_excel_extracts_all_metric_fields(tmp_path):
xlsx = _write_export_xlsx(tmp_path / "flagship.xlsx", _douyin_sample_rows())
records, headers = chanmama_export.parse_chanmama_excel(xlsx)
assert headers == EXPORT_HEADERS
assert len(records) == 2 # 总计行被跳过
first = records[0]
assert first["creator_name"] == STORE_FLAGSHIP
assert first["exposure"] == 32000
assert first["like_count"] == 135
assert first["comment_count"] == 8
assert first["share_count"] == 2
assert first["est_sales_amount"] == 3580.5
assert chanmama_export.extract_video_id(
first["chanmama_url"], first["video_url"]
) == "7687797200985869595"
def test_self_operated_accounts_config_covers_three_platforms():
path = self_operated_accounts.accounts_config_path(PATHS.config_root)
all_accounts = self_operated_accounts.load_all_accounts(path)
assert set(all_accounts) == {"douyin", "bilibili", "xiaohongshu"}
# 用户约定的身份映射: 抖音·机能号=旗舰店, 抖音·高智号=专卖店
assert self_operated_accounts.resolve_alias(
all_accounts["douyin"], account_name=STORE_FLAGSHIP
) == "抖音·机能号"
assert self_operated_accounts.resolve_alias(
all_accounts["douyin"], platform_account_id="GYXX168168"
) == "抖音·高智号"
# 小红书账号必须带登录手机号
xhs = self_operated_accounts.find_account(
"xiaohongshu", path, account_name=XHS_SHOP
)
assert xhs is not None and re.fullmatch(r"1\d{10}", xhs["phone"])
assert self_operated_accounts.resolve_alias(
all_accounts["xiaohongshu"], account_name=XHS_SHOP
) == "小红书·自营号"
# 未知账号返回空串,不抛异常
assert self_operated_accounts.resolve_alias(
all_accounts["bilibili"], account_name="未知UP主"
) == ""
def test_load_accounts_tolerates_missing_or_broken_config(tmp_path):
assert self_operated_accounts.load_accounts(
"douyin", tmp_path / "missing.json"
) == []
broken = tmp_path / "broken.json"
broken.write_text("not-json", encoding="utf-8")
assert self_operated_accounts.load_accounts("douyin", broken) == []
empty = tmp_path / "empty.json"
empty.write_text("{}", encoding="utf-8")
assert self_operated_accounts.load_accounts("bilibili", empty) == []
with __import__("pytest").raises(ValueError):
self_operated_accounts.load_accounts("kuaishou", empty)
# ============================================================
# B站: 公开 API 全字段
# ============================================================
class _FakeBiliResponse:
def __init__(self, payload):
self._payload = payload
def json(self):
return self._payload
class _FakeBiliSession:
"""固定返回一个 B站 view API 响应的假 HTTP 会话。"""
PAYLOAD = {
"code": 0,
"data": {
"aid": 114514,
"bvid": "BV1xT4y1Q7zz",
"title": "光影行星 逐星 开箱",
"duration": 92,
"pubdate": 1761026400,
"owner": {"mid": 5201314, "name": "光影行星官方号"},
"stat": {
"view": 35821, "like": 1204, "coin": 88, "favorite": 301,
"share": 57, "danmaku": 145, "reply": 96,
},
},
}
def get(self, url, **_kwargs):
return _FakeBiliResponse(self.PAYLOAD)
def test_fetch_bili_video_extracts_full_metrics():
from gyxx_flow.modules.content_marketing.self_bilibili_scraper import (
fetch_bili_video,
format_duration,
)
info = fetch_bili_video("https://www.bilibili.com/video/BV1xT4y1Q7zz",
_FakeBiliSession())
assert info is not None
assert info["video_id"] == "BV1xT4y1Q7zz"
assert info["video_url"].endswith("/video/BV1xT4y1Q7zz")
assert info["title"] == "光影行星 逐星 开箱"
assert info["owner_name"] == "光影行星官方号"
assert info["view_count"] == 35821
assert info["like_count"] == 1204
assert info["coin_count"] == 88
assert info["favorite_count"] == 301
assert info["danmaku_count"] == 145
assert info["reply_count"] == 96
assert info["publish_time"] == dt.datetime.fromtimestamp(1761026400)
assert format_duration(92) == "1:32"
assert format_duration(3725) == "1:02:05"
assert format_duration(None) == ""
def test_collect_bilibili_rows_from_artifact(tmp_path, monkeypatch):
artifact = tmp_path / "daily.json"
artifact.write_text(
json.dumps({
"platform": "bilibili",
"business_date": "2026-09-28",
"videos": [{
"video_id": "BV1xT4y1Q7zz",
"video_url": "https://www.bilibili.com/video/BV1xT4y1Q7zz",
"title": "光影行星 逐星 开箱",
"owner_name": "光影行星官方号",
"owner_mid": "5201314",
"duration_seconds": 92,
"publish_time": "2026-09-21 09:39",
"view_count": 35821, "like_count": 1204, "coin_count": 88,
"favorite_count": 301, "share_count": 57,
"danmaku_count": 145, "reply_count": 96,
}],
}, ensure_ascii=False),
encoding="utf-8",
)
monkeypatch.setattr(sync_tool, "BILIBILI_ARTIFACT", artifact)
accounts = [
{"alias": "B站·自营号", "account_name": "光影行星官方号",
"platform_account_id": "", "phone": ""}
]
rows, stats, source = sync_tool.collect_bilibili_rows(accounts, dt.date(2026, 9, 28))
assert stats["rows"] == 1
assert source == artifact
row = rows[("bilibili", "光影行星官方号", "BV1xT4y1Q7zz")]
assert row[sync_tool.ROW_FIELDS.index("account_alias")] == "B站·自营号"
assert row[sync_tool.ROW_FIELDS.index("exposure")] == 35821
assert row[sync_tool.ROW_FIELDS.index("comment_count")] == 96 # reply→评论
assert row[sync_tool.ROW_FIELDS.index("coin_count")] == 88
assert row[sync_tool.ROW_FIELDS.index("duration")] == "1:32"
def test_collect_bilibili_rows_missing_artifact(tmp_path, monkeypatch):
monkeypatch.setattr(
sync_tool, "BILIBILI_ARTIFACT", tmp_path / "missing.json"
)
rows, stats, source = sync_tool.collect_bilibili_rows([], dt.date(2026, 9, 28))
assert rows == {} and stats["rows"] == 0 and source is None
# ============================================================
# 小红书: ARK 管理页表格解析
# ============================================================
GOODS_HEADERS = ["", "笔记信息", "挂载商品", "笔记阅读数", "笔记支付金额",
"笔记支付订单数", "发布状态", "操作"]
NORMAL_HEADERS = ["", "笔记信息", "阅读数", "点赞数", "引流主页次数",
"引流直播间次数", "发布状态", "操作"]
def test_bilibili_wbi_signing_matches_documented_vector():
from gyxx_flow.modules.content_marketing.self_bilibili_scraper import (
derive_mixin_key,
wbi_sign,
)
# B站 WBI 公开示例密钥 → 文档给定的 mixin key
mixin = derive_mixin_key(
"7cd084941338484aae1ad9425b84077c",
"4932caff0ff746eab6f01bf08b70ac45",
)
assert mixin == "ea1db124af3c7062474693fa704f4ff8"
signed = wbi_sign(
{"platform": "web", "pn": 1, "ps": 30, "mid": "1008229967",
"order": "pubdate"},
mixin,
1702204800,
)
assert signed["wts"] == 1702204800
assert re.fullmatch(r"[0-9a-f]{32}", signed["w_rid"])
# 确定性: 同参数同时间戳签名一致
again = wbi_sign(
{"mid": "1008229967", "order": "pubdate", "pn": 1, "ps": 30,
"platform": "web"},
mixin,
1702204800,
)
assert again["w_rid"] == signed["w_rid"]
def test_bilibili_artifact_covers_full_history(tmp_path, monkeypatch):
"""主页全量采集后 artifact 应含全部投稿(含飞书未登记的历史视频)。"""
artifact = tmp_path / "daily.json"
videos = [
{
"video_id": f"BV1xT4y1Q7z{i:02d}",
"video_url": f"https://www.bilibili.com/video/BV1xT4y1Q7z{i:02d}",
"title": f"视频{i}",
"owner_name": "光影行星",
"owner_mid": "1008229967",
"duration_seconds": 60 + i,
"publish_time": f"2026-09-{i + 1:02d} 09:00:00",
"view_count": 100 * i, "like_count": i, "coin_count": i,
"favorite_count": i, "share_count": i, "danmaku_count": i,
"reply_count": i,
}
for i in range(3)
]
artifact.write_text(
json.dumps({"platform": "bilibili", "business_date": "2026-09-29",
"videos": videos}, ensure_ascii=False),
encoding="utf-8",
)
monkeypatch.setattr(sync_tool, "BILIBILI_ARTIFACT", artifact)
accounts = [
{"alias": "B站·自营号", "account_name": "光影行星",
"platform_account_id": "1008229967", "phone": ""}
]
rows, stats, _source = sync_tool.collect_bilibili_rows(
accounts, dt.date(2026, 9, 29)
)
assert stats["rows"] == 3
key = ("bilibili", "光影行星", "BV1xT4y1Q7z00")
assert rows[key][sync_tool.ROW_FIELDS.index("account_alias")] == "B站·自营号"
assert rows[key][sync_tool.ROW_FIELDS.index("publish_time")] == dt.datetime(
2026, 9, 1, 9, 0
)
def test_parse_info_cell_variants():
parse = self_xhs_scraper._parse_info_cell
# 多行,发布时间带前缀(线上真实形态)
assert parse("标题A\n发布时间:2026-09-15 14:23\n手动创作") == (
"标题A", "2026-09-15 14:23", "手动创作"
)
# 单行压扁(历史兜底)
title, publish, method = parse("标题B 发布时间:2026-09-08 13:41 手动创作")
assert title == "标题B"
assert publish == "2026-09-08 13:41"
assert method == "手动创作"
# 无日期无方式的纯标题
assert parse("纯标题") == ("纯标题", "", "")
assert parse("") == ("", "", "")
def test_parse_goods_note_rows():
rows = [
{
"cells": [
"",
"斜挎包通勤好物\n2026-09-25 10:23\n手动创作",
"光影行星 极星单肩包男女…",
"1.2万",
"3,586.50",
"12",
"已发布",
"数据 编辑",
],
"href": "https://www.xiaohongshu.com/explore/66e4b2c3000000001d00f1a2",
},
{
"cells": ["", "另一条\n2026-09-20 08:00", "暂无关联商品", "356",
"—", "—", "已发布", "编辑"],
"href": "",
},
]
records, stats = self_xhs_scraper.parse_management_rows(
GOODS_HEADERS, rows, "goods"
)
assert stats["records"] == 2
first = records[0]
assert first["note_type"] == "goods"
assert first["title"] == "斜挎包通勤好物"
assert first["publish_time"] == "2026-09-25 10:23"
assert first["creation_method"] == "手动创作"
assert first["mounted_products"] == "光影行星 极星单肩包男女…"
assert first["read_count"] == 12000
assert first["payment_amount"] == 3586.50
assert first["payment_orders"] == 12
assert first["publish_status"] == "已发布"
assert first["note_id"] == "66e4b2c3000000001d00f1a2"
assert first["video_id"] == "66e4b2c3000000001d00f1a2"
# 页面原始列全量保留
assert first["raw_columns"]["笔记阅读数"] == "1.2万"
# 无链接行回退标题指纹,并跨日稳定
second = records[1]
assert second["video_id"].startswith("xhs-")
assert second["video_id"] == self_xhs_scraper.fallback_note_fingerprint(
"另一条", "2026-09-20 08:00"
)
assert second["mounted_products"] == "暂无关联商品"
assert second["payment_amount"] is None
assert second["payment_orders"] is None
def test_parse_normal_note_rows():
rows = [
{
"cells": ["", "双肩包种草\n2026-09-22 18:30\n可能是AI创作",
"6,592", "483", "35", "8", "已发布", "数据"],
"href": "https://www.xiaohongshu.com/discovery/item/66e0abc5000000001900c3b1",
},
]
records, stats = self_xhs_scraper.parse_management_rows(
NORMAL_HEADERS, rows, "normal"
)
assert stats["records"] == 1
record = records[0]
assert record["note_type"] == "normal"
assert record["read_count"] == 6592
assert record["like_count"] == 483
assert record["homepage_redirect_count"] == 35
assert record["live_redirect_count"] == 8
assert record["creation_method"] == "可能是AI创作"
assert record["video_id"] == "66e0abc5000000001900c3b1"
# 标题含「创作」字样不会被误判为创作方式标签
assert record["title"] == "双肩包种草"
def test_parse_count_and_amount_edge_cases():
assert self_xhs_scraper.parse_count("1.2万") == 12000
assert self_xhs_scraper.parse_count("6,592") == 6592
assert self_xhs_scraper.parse_count("0") == 0
assert self_xhs_scraper.parse_count("—") is None
assert self_xhs_scraper.parse_count("暂无") is None
assert self_xhs_scraper.parse_count("") is None
assert self_xhs_scraper.parse_amount("3,586.50") == 3586.50
assert self_xhs_scraper.parse_amount("0.9万") == 9000.0
assert self_xhs_scraper.parse_amount("-") is None
def test_collect_xiaohongshu_rows_from_artifact(tmp_path, monkeypatch):
artifact = tmp_path / "daily.json"
artifact.write_text(
json.dumps({
"platform": "xiaohongshu",
"business_date": "2026-09-28",
"account_name": XHS_SHOP,
"notes": [
{
"note_type": "goods",
"video_id": "66e4b2c3000000001d00f1a2",
"title": "斜挎包通勤好物",
"publish_time": "2026-09-25 10:23",
"creation_method": "手动创作",
"mounted_products": "光影行星 极星单肩包男女…",
"read_count": 12000, "like_count": 431,
"payment_amount": 3586.5, "payment_orders": 12,
"publish_status": "已发布",
"note_url": "https://www.xiaohongshu.com/explore/66e4b2c3000000001d00f1a2",
"raw_columns": {"笔记阅读数": "1.2万"},
},
],
}, ensure_ascii=False),
encoding="utf-8",
)
monkeypatch.setattr(sync_tool, "XHS_ARTIFACT", artifact)
accounts = [
{"alias": "小红书·自营号", "account_name": XHS_SHOP,
"platform_account_id": "18933246725", "phone": "18933246725"}
]
rows, stats, source = sync_tool.collect_xiaohongshu_rows(
accounts, dt.date(2026, 9, 28)
)
assert stats["rows"] == 1
row = rows[("xiaohongshu", XHS_SHOP, "66e4b2c3000000001d00f1a2")]
fields = sync_tool.ROW_FIELDS
assert row[fields.index("account_alias")] == "小红书·自营号"
assert row[fields.index("exposure")] == 12000 # 阅读数 → 主触达
assert row[fields.index("like_count")] == 431
assert row[fields.index("payment_amount")] == 3586.5
assert row[fields.index("payment_orders")] == 12
assert row[fields.index("note_type")] == "goods"
assert row[fields.index("publish_time")] == dt.datetime(2026, 9, 25, 10, 23)
assert row[fields.index("page_metrics")].obj["笔记阅读数"] == "1.2万"
def test_douyin_history_backfill_uses_file_mtime_and_filename_account(tmp_path):
"""历史回填: 按导出落盘日期各留快照,旧版12列文件按文件名前缀补账号。"""
# 旧版 12 列表头(无 达人昵称/抖音号/蝉妈妈链接)
legacy_headers = [
"视频名称", "抖音视频链接", "视频时长", "发布时间", "预估曝光",
"点赞数", "评论数", "转发数", "预估销量", "预估销量-指数",
"预估销售额", "预估销售额-指数",
]
legacy_file = tmp_path / "光影行星箱包旗舰店_视频记录_周期内发布视频_20260803-20260901.xlsx"
workbook = openpyxl.Workbook()
sheet = workbook.active
sheet.append(legacy_headers)
sheet.append([
"旧版格式视频", "https://www.iesdouyin.com/share/video/7686000000000000001/",
"46秒", "2026-08-27 16:40:56", "23000", 1321, 46, 14, "0", 0, "0", 0,
])
workbook.save(legacy_file)
workbook.close()
import os
historical = dt.datetime(2026, 9, 2, 17, 0, 0).timestamp()
os.utime(legacy_file, (historical, historical))
accounts = self_operated_accounts.load_accounts(
"douyin", self_operated_accounts.accounts_config_path(PATHS.config_root)
)
rows, stats, _sources = sync_tool.collect_douyin_rows(
accounts, dt.date(2026, 9, 29), [str(legacy_file)],
use_file_mtime=True,
)
assert stats["skip_no_identity"] == 0
assert stats["rows"] == 1
row = next(iter(rows.values()))
fields = sync_tool.ROW_FIELDS
assert row[fields.index("account_name")] == STORE_FLAGSHIP # 文件名前缀回退
assert row[fields.index("account_alias")] == "抖音·机能号"
assert row[fields.index("metric_date")] == dt.date(2026, 9, 2) # mtime 日期
assert row[fields.index("exposure")] == 23000
def test_collect_douyin_rows_keeps_video_note_type(tmp_path):
xlsx = _write_export_xlsx(tmp_path / "flagship.xlsx", _douyin_sample_rows())
accounts = self_operated_accounts.load_accounts(
"douyin", self_operated_accounts.accounts_config_path(PATHS.config_root)
)
rows, stats, _sources = sync_tool.collect_douyin_rows(
accounts, dt.date(2026, 9, 28), [xlsx]
)
assert stats["rows"] == 2
fields = sync_tool.ROW_FIELDS
video_row = rows[
("douyin", STORE_FLAGSHIP, "7687797200985869595", dt.date(2026, 9, 28))
]
note_row = rows[
("douyin", STORE_SPECIALTY, "7687432318584143857", dt.date(2026, 9, 28))
]
assert video_row[fields.index("note_type")] == "video"
assert note_row[fields.index("note_type")] == "note"
assert video_row[fields.index("account_alias")] == "抖音·机能号"
def test_make_row_validates_fields_and_truncates():
row = sync_tool.make_row(
"douyin", dt.date(2026, 9, 28), "a.xlsx",
title="超长标题" * 200, video_id="1",
)
fields = sync_tool.ROW_FIELDS
assert len(row) == len(fields)
assert row[fields.index("title")] == ("超长标题" * 200)[:512]
assert row[fields.index("account_alias")] == ""
import pytest
with pytest.raises(KeyError):
sync_tool.make_row("douyin", dt.date(2026, 9, 28), "a.xlsx",
unknown_field=1)
# ============================================================
# SQL 安全 / 表结构 / 配置登记
# ============================================================
def test_upsert_sql_is_fully_parameterized():
source = (TOOLS / "sync_self_video_metrics.py").read_text(encoding="utf-8")
matches = re.findall(
r"cur\.execute\(\s*\"\"\"\s*(INSERT\b.*?)\"\"\",\s*row,",
source,
re.DOTALL,
)
assert len(matches) == 1, "入库 upsert 必须以字面量 SQL + 参数元组形式调用"
sql = matches[0]
assert sql.count("%s") == len(sync_tool.ROW_FIELDS) # 全部参数绑定
assert "{" not in sql and ".format(" not in sql
assert "ON CONFLICT (platform, account_name, video_id, metric_date)" in sql
def test_schema_and_migrations_declare_self_video_metrics_consistently():
schema = (TOOLS / "schema_gyxx_super_data.sql").read_text(encoding="utf-8")
migration_009 = (TOOLS / "migrations" / "009_self_video_metrics.sql").read_text(
encoding="utf-8"
)
migration_010 = (
TOOLS / "migrations" / "010_self_video_metrics_platforms.sql"
).read_text(encoding="utf-8")
for sql in (schema, migration_009, migration_010):
assert "cmt_self_video_metrics" in sql
for sql in (schema, migration_009):
assert "CREATE TABLE IF NOT EXISTS cmt_self_video_metrics" in sql
assert "UNIQUE (platform, account_name, video_id, metric_date)" in sql
combined = schema + "\n" + migration_009 + "\n" + migration_010
for column in (
"account_alias", "video_id", "video_url", "publish_time",
"exposure", "like_count", "comment_count", "share_count",
"est_sales_count", "est_sales_amount", "note_type",
"creation_method", "publish_status", "mounted_products",
"homepage_redirect_count", "live_redirect_count", "payment_amount",
"payment_orders", "coin_count", "favorite_count", "danmaku_count",
"page_metrics",
):
assert re.search(rf"\b{column}\b", combined), f"{column} 缺失"
def test_workflow_commands_and_bindings_declare_self_content_pipeline():
workflows = json.loads(
(PROJECT_ROOT / "config" / "workflows.json").read_text(encoding="utf-8")
)
workflow = next(
item
for item in workflows["workflows"]
if item["id"] == "content.metrics.self_operated.daily"
)
steps = {step["id"]: step for step in workflow["execution"]["steps"]}
assert set(steps) == {
"refresh_self_mapping", "collect_self_bilibili", "collect_self_douyin",
"collect_self_xiaohongshu", "sync", "sync_self_video_metrics",
}
assert steps["collect_self_xiaohongshu"]["entry"] == "self_xhs_scraper.py"
assert steps["collect_self_xiaohongshu"]["depends_on"] == ["collect_self_douyin"]
assert steps["sync_self_video_metrics"]["depends_on"] == [
"sync", "collect_self_xiaohongshu",
]
module_root = PROJECT_ROOT / "src/gyxx_flow/modules/content_marketing"
for entry in (
"self_xhs_scraper.py",
"data/tools/sync_self_video_metrics.py",
):
assert (module_root / entry).is_file()
commands = json.loads(
(PROJECT_ROOT / "config" / "commands.json").read_text(encoding="utf-8")
)
command_entries = {item["id"]: item for item in commands["commands"]}
assert command_entries["content.metrics.collect_self_xiaohongshu"]["entry"] == (
"self_xhs_scraper.py"
)
assert command_entries["content.metrics.sync_self_video_metrics"]["entry"] == (
"data/tools/sync_self_video_metrics.py"
)
bindings = json.loads(
(PROJECT_ROOT / "config" / "runtime-bindings.json").read_text(
encoding="utf-8"
)
)
xhs_binding = bindings["scripts"]["content.metrics.collect_self_xiaohongshu"]
assert xhs_binding["script_id"] == "content_marketing:self_xhs_scraper.py"
assert xhs_binding["required_cookie_domains"] == ["xiaohongshu.com"]
assert "XHS_ARK_ACCOUNT" in xhs_binding["credential_env_names"]
sync_binding = bindings["scripts"]["content.metrics.sync_self_video_metrics"]
assert sync_binding["cdp_port"] == 22164
ports = [entry["cdp_port"] for entry in bindings["scripts"].values()]
assert len(ports) == len(set(ports)), "CDP 端口必须唯一"
def test_metric_date_resolution_and_stale_guard(tmp_path, monkeypatch):
monkeypatch.delenv("GYXX_BUSINESS_DATE", raising=False)
assert sync_tool.runtime_business_date() is None
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026-09-28")
assert sync_tool.runtime_business_date() == dt.date(2026, 9, 28)
assert sync_tool.resolve_metric_date("2026-09-01") == dt.date(2026, 9, 1)
xlsx = Path(_write_export_xlsx(tmp_path / "a.xlsx", _douyin_sample_rows()))
fresh = dt.datetime.now().timestamp()
os.utime(xlsx, (fresh, fresh))
assert sync_tool.is_current_artifact(xlsx, dt.date.today())
assert not sync_tool.is_current_artifact(xlsx, dt.date(2026, 9, 21))
def test_runtime_business_date_rejects_invalid_value(monkeypatch):
monkeypatch.setenv("GYXX_BUSINESS_DATE", "2026/09/28")
with __import__("pytest").raises(RuntimeError):
sync_tool.runtime_business_date()
@@ -161,7 +161,7 @@ def test_erp_login_navigates_before_probing_initial_blank_page(
action = erp_login.make_page_action(
login_url="https://erp.test/login.aspx",
username="user",
password="pw",
password="secret",
home_url="https://erp.test/epaas",
timeout_ms=1000,
captcha_checks=0,
@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import date
from pathlib import Path
import collect_douyin_qianchuan_ads as collector
@@ -90,3 +91,128 @@ def test_report_ready_wait_guards_missing_body() -> None:
"arg": list(collector._required_page_markers()),
"timeout": 90_000,
}
def test_matching_task_rows_uses_scene_and_exact_date_range() -> None:
filters: list[str] = []
class _Locator:
def filter(self, *, has_text: str):
filters.append(has_text)
return self
class _Page:
def locator(self, selector: str) -> _Locator:
assert selector == "tr.ovui-tr"
return _Locator()
collector._matching_task_rows(
_Page(), date(2026, 9, 9), date(2026, 9, 9)
)
assert filters == [
collector.TASK_SCENE_MARKER,
"2026-09-09 00:00:00-2026-09-09 23:59:59",
]
def test_trigger_report_download_returns_direct_download() -> None:
expected_download = object()
class _Button:
def __init__(self, page) -> None:
self.page = page
def click(self) -> None:
self.page.listener(expected_download)
class _Page:
listener = None
def on(self, event: str, listener) -> None:
assert event == "download"
self.listener = listener
def remove_listener(self, event: str, listener) -> None:
assert event == "download"
assert listener == self.listener
def locator(self, selector: str):
assert selector == collector.DOWNLOAD_SELECTOR
return _Button(self)
assert collector._trigger_report_download(_Page()) is expected_download
def test_trigger_report_download_detects_async_task_notification() -> None:
class _Locator:
def click(self) -> None:
pass
def inner_text(self, *, timeout: int) -> str:
assert timeout == 10_000
return "已生成下载任务,可前往任务中心查看"
class _Page:
def on(self, event: str, listener) -> None:
assert event == "download"
def remove_listener(self, event: str, listener) -> None:
assert event == "download"
def locator(self, selector: str) -> _Locator:
assert selector in {collector.DOWNLOAD_SELECTOR, "body"}
return _Locator()
assert collector._trigger_report_download(_Page()) is None
def test_download_task_from_page_downloads_completed_matching_row() -> None:
expected_download = object()
class _DownloadInfo:
value = expected_download
def __enter__(self):
return self
def __exit__(self, exc_type, exc, traceback) -> None:
pass
class _DownloadButton:
def click(self) -> None:
pass
class _Row:
def inner_text(self) -> str:
return "全域推广数据-推商品\n生成完成\n下载"
def get_by_text(self, text: str, *, exact: bool) -> _DownloadButton:
assert (text, exact) == ("下载", True)
return _DownloadButton()
class _Rows:
first = _Row()
def count(self) -> int:
return 1
class _Page:
def expect_download(self, *, timeout: int) -> _DownloadInfo:
assert timeout == 60_000
return _DownloadInfo()
page = _Page()
original = collector._matching_task_rows
collector._matching_task_rows = lambda *args: _Rows()
try:
actual = collector._download_task_from_page(
page,
date(2026, 9, 9),
date(2026, 9, 9),
wait_timeout_ms=0,
)
finally:
collector._matching_task_rows = original
assert actual is expected_download
@@ -173,3 +173,173 @@ def test_safe_extract_zip_keeps_csv_inside_output(tmp_path: Path) -> None:
files = collector._safe_extract_zip(archive, tmp_path / "extracted")
assert files == [(tmp_path / "extracted" / "nested" / "full.csv").resolve()]
FULL_REPORT_CSV = """查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-19至2026-09-19
日期,'当前时间',SPU ID,商品计划名称,SpuImgUrl,花费
20260919~20260919,当前时间,10032366483452,波塞冬托特包,img,43.68
20260919~20260919,当前时间,10033333485894,宙斯双肩包,img,104.78
汇总
0,当前时间,0,,,148.46
日期,'对比时间',SPU ID,商品计划名称,SpuImgUrl,花费
20260918~20260918,对比时间,10032366483452,波塞冬托特包,img,40.00
汇总
0,对比时间,0,,,40.00
"""
NOT_READY_FULL_REPORT_CSV = """查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-20至2026-09-20
日期,'当前时间',SPU ID,商品计划名称,SpuImgUrl,花费
汇总
0,当前时间,0,,,0.00,0.00,0.00,0,0.00
日期,'对比时间',SPU ID,商品计划名称,SpuImgUrl,花费
20260919~20260919,对比时间,10032366483452,波塞冬托特包,img,43.68
汇总
0,对比时间,0,,,148.46
"""
NON_FULL_REPORT_CSV = """查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-20至2026-09-20
SKU ID,SKU 名称,点击时间,'当前时间',花费
10144206623346,阿波罗斜挎包,20260920~20260920,当前时间,105.50
汇总
0,当前时间,0,,,105.50
"""
def _write_report(tmp_path: Path, name: str, content: str) -> Path:
path = tmp_path / name
path.write_text(content, encoding="utf-8-sig")
return path
def test_report_content_rows_counts_real_current_time_spu_rows(tmp_path: Path) -> None:
path = _write_report(tmp_path, "full_20260919.csv", FULL_REPORT_CSV)
assert collector._report_content_rows(path, "full") == 2
def test_report_content_rows_flags_report_generated_before_data_ready(
tmp_path: Path,
) -> None:
path = _write_report(tmp_path, "full_20260920.csv", NOT_READY_FULL_REPORT_CSV)
assert collector._report_content_rows(path, "full") == 0
def test_report_content_rows_counts_non_full_sku_rows(tmp_path: Path) -> None:
path = _write_report(tmp_path, "non_full_20260920.csv", NON_FULL_REPORT_CSV)
assert collector._report_content_rows(path, "non_full") == 1
def test_report_content_rows_rejects_csv_without_marker_header(tmp_path: Path) -> None:
path = _write_report(tmp_path, "broken.csv", "查询条件\n花费\n1.00\n")
with pytest.raises(collector.JDAdCostCollectionError, match="表头"):
collector._report_content_rows(path, "full")
def test_ensure_report_data_accepts_empty_full_when_non_full_has_data(
monkeypatch, tmp_path: Path
) -> None:
empty_full = _write_report(tmp_path, "full_20260920.csv", NOT_READY_FULL_REPORT_CSV)
sku_report = _write_report(tmp_path, "non_full_20260920.csv", NON_FULL_REPORT_CSV)
resubmits: list[collector.ReportType] = []
sleeps: list[float] = []
monkeypatch.setattr(
collector,
"_report_content_rows",
lambda path, rt: 0 if rt == "full" else 1,
)
monkeypatch.setattr(
collector,
"_resubmit_report",
lambda _page, rt, _td: resubmits.append(rt),
)
monkeypatch.setattr(collector.time, "sleep", sleeps.append)
downloaded = collector._ensure_report_data(
object(),
{"non_full": sku_report, "full": empty_full},
date(2026, 9, 20),
tmp_path,
timeout_seconds=1,
)
assert downloaded == {"non_full": sku_report, "full": empty_full}
assert resubmits == []
assert sleeps == []
def test_ensure_report_data_resubmits_both_reports_when_neither_has_data(
monkeypatch, tmp_path: Path
) -> None:
empty_full = _write_report(tmp_path, "full_20260920.csv", NOT_READY_FULL_REPORT_CSV)
sku_report = _write_report(tmp_path, "non_full_20260920.csv", NON_FULL_REPORT_CSV)
actions: list[object] = []
row_counts = iter((0, 0, 1, 2))
monkeypatch.setattr(
collector, "_report_content_rows", lambda _path, _rt: next(row_counts)
)
monkeypatch.setattr(
collector,
"_resubmit_report",
lambda _page, rt, _td: actions.append(("resubmit", rt)),
)
def fake_download(_page, rt, _td, _od, *, timeout_seconds):
del timeout_seconds
actions.append(("download", rt))
return {"non_full": sku_report, "full": empty_full}[rt]
monkeypatch.setattr(collector, "download_ready_report", fake_download)
monkeypatch.setattr(collector.time, "sleep", lambda _seconds: None)
downloaded = collector._ensure_report_data(
object(),
{"non_full": sku_report, "full": empty_full},
date(2026, 9, 20),
tmp_path,
timeout_seconds=1,
)
assert downloaded == {"non_full": sku_report, "full": empty_full}
assert actions == [
("resubmit", "non_full"),
("download", "non_full"),
("resubmit", "full"),
("download", "full"),
]
def test_ensure_report_data_fails_when_non_full_never_arrives(
monkeypatch, tmp_path: Path
) -> None:
empty_full = _write_report(tmp_path, "full_20260920.csv", NOT_READY_FULL_REPORT_CSV)
sku_report = _write_report(tmp_path, "non_full_20260920.csv", NON_FULL_REPORT_CSV)
resubmits: list[collector.ReportType] = []
monkeypatch.setattr(collector, "_report_content_rows", lambda _path, _rt: 0)
monkeypatch.setattr(
collector,
"_resubmit_report",
lambda _page, rt, _td: resubmits.append(rt),
)
monkeypatch.setattr(
collector, "download_ready_report", lambda _page, rt, _td, _od, *, timeout_seconds: {"non_full": sku_report, "full": empty_full}[rt]
)
monkeypatch.setattr(collector.time, "sleep", lambda _seconds: None)
with pytest.raises(collector.JDAdCostCollectionError, match="尚未就绪"):
collector._ensure_report_data(
object(),
{"non_full": sku_report, "full": empty_full},
date(2026, 9, 20),
tmp_path,
timeout_seconds=1,
)
assert resubmits == ["non_full", "full", "non_full", "full"]
@@ -1,5 +1,6 @@
from __future__ import annotations
import io
import json
import zipfile
from datetime import date
@@ -171,6 +172,75 @@ def test_safe_extract_zip_returns_csv_and_writes_manifest(tmp_path: Path) -> Non
assert (tmp_path / "extracted" / "商品报表_20260817.csv").is_file()
def test_save_signed_download_retries_transient_dns_error(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
class _Response:
ok = True
status = 200
def body(self) -> bytes:
payload = io.BytesIO()
with zipfile.ZipFile(payload, "w") as archive:
archive.writestr("商品报表.csv", "日期,主体ID\n")
return payload.getvalue()
class _Request:
def __init__(self) -> None:
self.calls = 0
def get(self, url: str, *, timeout: int) -> _Response:
del timeout
self.calls += 1
if self.calls == 1:
raise RuntimeError(
f"APIRequestContext.get: getaddrinfo ENOTFOUND {url}"
)
return _Response()
request = _Request()
class _Page:
context = type("_Context", (), {"request": request})()
monkeypatch.setattr(collector.time, "sleep", lambda _seconds: None)
download_url = "https://example.test/report.zip?Signature=secret"
path = collector._save_signed_download(
_Page(), download_url, tmp_path, date(2026, 8, 17)
)
assert request.calls == 2
assert zipfile.is_zipfile(path)
def test_save_signed_download_redacts_signed_url_after_network_retries(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
class _Request:
def get(self, url: str, *, timeout: int) -> object:
del timeout
raise RuntimeError(f"getaddrinfo ENOTFOUND {url}")
class _Page:
context = type(
"_Context", (), {"request": _Request()}
)()
monkeypatch.setattr(collector.time, "sleep", lambda _seconds: None)
download_url = "https://example.test/report.zip?Signature=secret"
with pytest.raises(collector.WanxiangDownloadError) as exc_info:
collector._save_signed_download(
_Page(), download_url, tmp_path, date(2026, 8, 17)
)
message = str(exc_info.value)
assert "getaddrinfo ENOTFOUND" in message
assert "Signature=secret" not in message
assert "https://example.test/report.zip" in message
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:
@@ -120,7 +120,7 @@ def test_get_pool_retries_transient_initial_connection(
"PG_PORT": "5432",
"PG_DB": "warehouse",
"PG_USER": "collector",
"PG_PASSWORD": "unit-test-only",
"PG_PASSWORD": "secret",
}.items():
monkeypatch.setenv(name, value)
monkeypatch.setattr(db, "_pool", None)
@@ -213,3 +213,64 @@ def test_jd_dry_run_accepts_explicit_desktop_style_files(tmp_path: Path, capsys)
assert "全站 SPU 1" in output
assert "非全站 SKU 1" in output
assert "DRY-RUN" in output
def test_parse_files_treats_empty_full_report_as_zero_when_sku_has_rows(
tmp_path: Path,
capsys,
) -> None:
full = tmp_path / "full_20260920.csv"
full.write_text(
"查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-20\n"
"日期,'当前时间',SPU ID,商品计划名称,SpuImgUrl,花费,全站投产比\n"
"汇总\n"
"0,当前时间,0,,,0.00,0.00\n",
encoding="utf-8-sig",
)
sku = tmp_path / "non_full_20260920.csv"
sku.write_text(
"查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-20\n"
"SKU ID,SKU 名称,点击时间,'当前时间',花费,展现数,点击数,投产比\n"
"sku-1,光影行星 盖亚款式 黑色,20260920~20260920,当前时间,3.20,100,5,2.50\n",
encoding="utf-8-sig",
)
full_records, sku_records = importer._parse_files(
date(2026, 9, 20),
full_file=full,
non_full_file=sku,
advertiser_pin="光影行星旗舰店霖",
shop_account="光影行星研发",
)
assert full_records == []
assert [record["sku_id"] for record in sku_records] == ["sku-1"]
assert "零投放" in capsys.readouterr().out
def test_parse_files_still_fails_when_both_reports_are_empty(tmp_path: Path) -> None:
empty = tmp_path / "full_20260920.csv"
empty.write_text(
"查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-20\n"
"日期,'当前时间',SPU ID,商品计划名称,SpuImgUrl,花费,全站投产比\n"
"0,当前时间,0,,,0.00,0.00\n",
encoding="utf-8-sig",
)
empty_sku = tmp_path / "non_full_20260920.csv"
empty_sku.write_text(
"查询条件,广告主PIN: 光影行星旗舰店霖; 时间: 2026-09-20\n"
"SKU ID,SKU 名称,点击时间,'当前时间',花费,展现数,点击数,投产比\n"
"0,汇总占位,20260920~20260920,当前时间,0.00,0,0,0.00\n",
encoding="utf-8-sig",
)
import pytest
with pytest.raises(ValueError, match="当前时间 SKU 行"):
importer._parse_files(
date(2026, 9, 20),
full_file=empty,
non_full_file=empty_sku,
advertiser_pin="光影行星旗舰店霖",
shop_account="光影行星研发",
)
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more