commit 90236eaf4c65aa0f75e5393734129eccb31bf0a1 Author: chenpei Date: Wed Mar 4 13:25:48 2026 +0800 Initial commit diff --git a/, b._is_market_prompt(t), b/, b._is_market_prompt(t), new file mode 100644 index 0000000..e69de29 diff --git "a/,out)\nPY" "b/,out)\nPY" new file mode 100644 index 0000000..e69de29 diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..f6a4775 Binary files /dev/null and b/.DS_Store differ diff --git a/AGENTS.remote.live.md b/AGENTS.remote.live.md new file mode 100644 index 0000000..18182bc --- /dev/null +++ b/AGENTS.remote.live.md @@ -0,0 +1,152 @@ +# AGENTS.md - OpenClaw Workspace Rules + +## 1) Core Behavior + +- Execute first, explain later. Do not throw generic blockers to the user. +- Before asking user for help, try at least 2 autonomous recoveries. +- If still blocked, only ask for concrete missing items (OTP/CAPTCHA/2FA/session expired). +- Do not fabricate data. Every conclusion must come from tool results. + +## 2) Routing Priority (Highest to Lowest) + +### A. Xiaohongshu Tasks (override all market routes) + +Trigger keywords: 小红书 / 红书 / xhs / 笔记 / 种草 / 舆情 + +Required sequence: +1. `exec: xhs-api health` +2. `exec: xhs-api login-status` +3. If not logged in: `exec: xhs-api login-qrcode /root/.openclaw/workspace/outputs/xhs-login-qrcode.png` +4. Then use: + - `xhs-api search ""` + - `xhs-api detail true` + +### B. Sales / Aftersale / Refund / Performance Tasks (SQL only) + +Trigger keywords: 销售 / 业绩 / 售后 / 退款 / 费比 / 店铺周报 / SKU结构 / 发货 / 工单 / 客诉 / 客服体验 / 用户反馈 / 话术分析 / 产品信息 / 商品ID / SKU编码 / 成本 / 材质 / 预览图 / 库存 / 缺货 / 补货 / 安全库存 + +Hard rules: +- Must route to `company-sales-db-analysis`. +- Do not use yuce website for sales/aftersale tasks. +- Do not use web_search/web_fetch for these tasks. +- Must run at least one `exec` data query before final response. +- Do not call `tts` unless user explicitly asks voice output. + +Mandatory first checks (once per turn): +1. `exec: company-db health` +2. Query by SQL templates in `/root/.openclaw/workspace/sqlserver/` + +Template mapping: +- 店铺周报 -> `shop_weekly_report.sql` +- SKU结构 -> `sku_structure_report.sql` +- 产品信息/库存预警(SKU/成本/材质/预览图/库存/发货件数/缺货天数) -> `exec: product-info "" --days 30 --limit 20` +- 全量SKU缺货预警明细 -> `exec: stock-risk-alerts --days 30 --limit 500` +- 全量SKU缺货预警推送飞书(结构化字段) -> `exec: stock-risk-to-feishu --days 30 --top 100` +- 商品ID查SKU清单 -> `exec: product-skus-by-id "<商品id>" --limit 1000` + +Product info output minimum fields: +- SKU编码, SKU名称, 成本价, 材质, 预览图地址, 当前库存, 日均发货件数, 预计缺货天数, 补货建议 +- 推荐补充:SPU/货号/分类/供应商/尺寸/重量/SKU状态 +- 若存在物流数据,追加 一区/二区/三区 运费汇总(近30天) +- 若存在售后数据,追加 售后总览 + 售后类型矩阵 + 售后原因TOP(近30天) +- 若涉及库存/补货,必须追加:近30天在售商品ID数量(按渠道)+ 商品ID对应店铺名/运营人 + 采购/工厂/仓库信息 +- 库存预警(强制): + - 必须输出 `estimated_stockout_days` 与 `replenishment_lead_days(=10)` + - 当 `estimated_stockout_days <= 10` 时,结论必须标记 `补货紧急` +- 退款原因/售后 -> `after_sale_360_report.sql` +- 费比异常 -> `fee_ratio_alert.sql` +- 物流时效 -> `logistics_sla_report.sql` +- 运费效率 -> `freight_efficiency_report.sql` +- 客服热点 -> `customer_service_hotspot_report.sql` +- 客服体验/话术分析(可指定时间段) -> `exec: customer-service-analysis --start --end [--shop "店铺关键词"] --limit 20` + - 输出需包含:客服能力总分与等级(S/A/B/C/D) +- 安装服务 -> `install_service_quality_report.sql` +- 链接目标覆盖 -> `link_target_coverage_report.sql` + +Aftersale special rule: +- Do not analyze only one table. +- Must prioritize reason dimensions from: + 1) `jst_goods_profit_kpi_items_afs` (undefined57/58/56) + 2) `pc_kfChatLog` (mainReason/secondReason/thirdReason) + 3) `fys_Ticketing_install` (工单维度) +- If reason fields are empty, explicitly mark as "源字段为空" and continue with available dimensions. + +Metric priority rule (very important): +- Prefer piece/order counts first: `发货件数`, `订单数`, `售后件数`, `工单量`. +- Amount metrics are secondary and optional. +- Unless user explicitly asks for 金额/费用/利润, never rank conclusions by amount first. + +Output format for sales/aftersale: +1. 件数与订单核心结论 +2. 原因结构(一级/二级/三级) +3. 趋势与异常 +4. 金额补充(可选) +5. 可执行建议 +6. 若为客服体验/话术任务,必须补充“对话案例 + 推荐回复话术” +7. 若为客服体验/话术任务,必须补充“客服能力评分(按客服名字输出四维度+总分+等级)” + +### C. Market Analysis Tasks (non-sales) + +Trigger keywords: 市场分析 / 行业分析 / 类目分析 / 竞品分析 / 趋势分析 + +Hard rules: +- Only allow domain `hq.yucekj.com` / `yucekj.com`. +- Use `exec` with `yuce-browser` only. +- Never use browser `search` action. +- Never use web_search/web_fetch for this route. +- If login/CAPTCHA blocks, ask user for that exact step only. + +### D. General Online Search Tasks + +Trigger keywords: 在线搜索 / 搜一下 / 查资料 / 最新消息 / 新闻 / 政策更新 + +Preferred execution: +1. First use `exec: web-search "" --count 8` (stable path; includes Brave+Bing fallback). +2. Optional: if user explicitly asks for Brave-only, use `web_search`. +3. Summarize with source links, and mark date/time context when content is time-sensitive. + +## 3) Security and Privacy + +- Do not expose raw data source endpoints in user-facing replies. +- Mask domain/IP/api endpoints in final messages when possible. +- Exception: when reporting Feishu push result, must show the actual Feishu table link (do not mask `*.feishu.cn`). +- Never leak secrets from env files. + +## 4) Reply Quality Rules + +- Send concise final answer; no long tool transcript dump. +- If task may take time, send one short processing hint first. +- Do not repeat the same result twice in one turn. +- If a request fails, include: what was attempted, what failed, and next actionable fix. + +## 5) Feishu Delivery Rules + +Trigger keywords: 推到飞书表格 / 写入飞书 / 同步飞书 / 发送飞书表格链接 + +Default target (pinned): +- default_link: https://acnuoqgha8fk.feishu.cn/wiki/RBSxwq8woiQDCUkRZfkcg7x8nMb?table=tblzuMy85WiRAUX5&view=vewuydm9EI +- default_app_token: SZ4ebCKU3a9OdfspetxcSeg1n4g +- default_table_id: tblzuMy85WiRAUX5 + +Required behavior: +- Never auto-push to Feishu unless the user explicitly asks in the current turn. +- If current turn does not contain Feishu push intent, do analysis only and return in chat. +- When user asks to push result to Feishu Bitable, run write action first (feishu_bitable_create_record or feishu_bitable_update_record). +- For stock-risk/inventory warning reports, do NOT write a long text blob into one cell; must push structured columns/rows (summary row + detail rows), preferably via `exec: stock-risk-to-feishu --days 30 --top 100`. +- In continuous chat, if user says "把上一个结果推到飞书", use the previous assistant result as write payload. +- If user does not provide a link in current turn, ALWAYS use the pinned default target above. +- Do NOT ask user for Feishu link again unless user explicitly asks to switch to another table. +- In final reply, always include: + 1) push status (success/failed) + 2) target sheet link (actual link used) + 3) written record_id (or updated count) +- If write partially fails, return succeeded records and failed reason list separately. +- If user explicitly asks for "只要结果", keep reasoning short but still include the sheet link and write status line. + +## 6) Workspace Paths + +- SQL templates: `/root/.openclaw/workspace/sqlserver/` +- SQL catalog: `/root/.openclaw/workspace/outputs/company-sales-db-catalog.md` +- Router skill: `/root/.openclaw/skills/web-task-router/SKILL.md` +- Tool guide: `/root/.openclaw/workspace/TOOLS.md` +- Site config: `/root/.openclaw/workspace/websites.yaml` diff --git a/README_SQL_TEMPLATES.md b/README_SQL_TEMPLATES.md new file mode 100644 index 0000000..09f8915 --- /dev/null +++ b/README_SQL_TEMPLATES.md @@ -0,0 +1,101 @@ +# 销售分析常用 SQL 模板包 + +数据源:`JSDataMiddlePlatform`(SQLServer) +执行方式:`company-db run-file ` + +默认分析口径: +- 主口径优先:`发货件数 / 售后件数 / 订单数` +- 金额口径:仅补充展示;除非明确要求,不作为主排序维度 + +## 模板列表 + +1. `shop_weekly_report.sql` + - 用途:店铺周报(平台/店铺销售、退款、毛利、高退款风险店铺) + - 默认时间:上一个自然周 + +2. `sku_structure_report.sql` + - 用途:SKU结构分析(类目结构、SKU Top、价格带结构) + - 默认时间:最近30天 + +3. `refund_reason_report.sql` + - 用途:退款原因分析(全局原因Top、平台内原因Top、店铺风险清单、类型矩阵) + - 默认时间:最近30天 + +4. `fee_ratio_alert.sql` + - 用途:费比异常预警(店铺级P1/P2/P3预警、全局日趋势) + - 默认时间:最近14天 + +5. `logistics_sla_report.sql` + - 用途:物流时效/SLA分析(支付到发货、发货到签收、延迟预警) + - 默认时间:最近30天 + +6. `freight_efficiency_report.sql` + - 用途:运费效率分析(单票成本、单位重量成本、省份/快递组合成本) + - 默认时间:最近30天 + +7. `install_service_quality_report.sql` + - 用途:安装服务质量(工单状态、服务商表现、待审核区域) + - 默认时间:最近90天 + +8. `customer_service_hotspot_report.sql` + - 用途:客服问题热点(问题Top、店铺问题热点、日趋势) + - 默认时间:最近30天 + +9. `link_target_coverage_report.sql` + - 用途:链接目标覆盖(在线链接、运营负载、类目/价格带分布) + - 默认时间:在线链接全量 + +10. `after_sale_360_report.sql` + - 用途:售后360分析(退款金额、一级/二级原因、类型矩阵、安装工单联动) + - 默认时间:上一个自然周 + +11. `product-info`(CLI辅助命令) + - 用途:按 SKU/SPU/关键词查询产品基础信息 + 库存缺货预警 + - 命令:`product-info "" --days 30 --limit 20` + - 输出: + - 结果集1:产品基础信息(SKU名称、成本、材质、预览图等) + - 结果集2:最近N天出货/售后件数 + - 结果集3:最近N天店铺分布(件数) + - 结果集4:最近N天一区/二区/三区运费汇总 + - 结果集5:最近N天省份运费TOP + - 结果集6:最近N天售后总览 + - 结果集7:最近N天售后类型/结果矩阵 + - 结果集8:最近N天售后原因TOP + - 结果集9:最近N天每日总发货件数(按SKU) + - 结果集10:库存风险预估(当前库存/日均发货/预计缺货天数/补货建议,补货安全线=10天) + - 结果集11:近30天在售商品ID数量(按店铺渠道) + - 结果集12:商品ID对应店铺名/渠道/运营人(通知清单) + - 结果集13:缺货风险通知清单(采购/工厂/仓库/店铺/运营) + +12. `product-skus-by-id`(CLI辅助命令) + - 用途:按商品ID查询该ID下所有SKU编码与SKU名称 + - 命令:`product-skus-by-id "<商品id>" --limit 1000` + +13. `customer_service_experience_report.sql` + - 用途:客服体验与话术分析(用户反馈问题结构、体验分、风险话术、日趋势) + - 默认时间:最近30天(可改模板顶部 `@start_date/@end_date`) + +14. `customer-service-analysis`(CLI辅助命令) + - 用途:按指定时间段做客服维度的用户反馈体验/话术分析 + - 命令:`customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20` + +15. `sku_stock_risk_report.sql` + - 用途:按SKU编码查询库存、近30天日均发货件数、预计缺货天数、补货建议(安全线10天) + - 默认时间:最近30天(模板顶部可改 `@lookback_days`) + - 参数:修改模板顶部 `@sku` 即可 + +## 运行示例 + +```bash +company-db run-file /root/.openclaw/workspace/sqlserver/shop_weekly_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/after_sale_360_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/sku_stock_risk_report.sql +product-info "CJ019317N1097V01" --days 30 --limit 20 +customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" +``` + +## 注意事项 + +- 所有模板默认过滤 `配件` 和 `特殊单` 标签。 +- 金额字段多为字符串,模板已使用 `TRY_CONVERT` 做安全转换。 +- 库存预警建议:`estimated_stockout_days <= 10` 视为补货紧急。 diff --git a/README_SQL_TEMPLATES.remote.live.md b/README_SQL_TEMPLATES.remote.live.md new file mode 100644 index 0000000..9a378d2 --- /dev/null +++ b/README_SQL_TEMPLATES.remote.live.md @@ -0,0 +1,113 @@ +# 销售分析常用 SQL 模板包 + +数据源:`JSDataMiddlePlatform`(SQLServer) +执行方式:`company-db run-file ` + +默认分析口径: +- 主口径优先:`发货件数 / 售后件数 / 订单数` +- 金额口径:仅补充展示;除非明确要求,不作为主排序维度 + +## 模板列表 + +1. `shop_weekly_report.sql` + - 用途:店铺周报(平台/店铺销售、退款、毛利、高退款风险店铺) + - 默认时间:上一个自然周 + +2. `sku_structure_report.sql` + - 用途:SKU结构分析(类目结构、SKU Top、价格带结构) + - 默认时间:最近30天 + +3. `refund_reason_report.sql` + - 用途:退款原因分析(全局原因Top、平台内原因Top、店铺风险清单、类型矩阵) + - 默认时间:最近30天 + +4. `fee_ratio_alert.sql` + - 用途:费比异常预警(店铺级P1/P2/P3预警、全局日趋势) + - 默认时间:最近14天 + +5. `logistics_sla_report.sql` + - 用途:物流时效/SLA分析(支付到发货、发货到签收、延迟预警) + - 默认时间:最近30天 + +6. `freight_efficiency_report.sql` + - 用途:运费效率分析(单票成本、单位重量成本、省份/快递组合成本) + - 默认时间:最近30天 + +7. `install_service_quality_report.sql` + - 用途:安装服务质量(工单状态、服务商表现、待审核区域) + - 默认时间:最近90天 + +8. `customer_service_hotspot_report.sql` + - 用途:客服问题热点(问题Top、店铺问题热点、日趋势) + - 默认时间:最近30天 + +9. `link_target_coverage_report.sql` + - 用途:链接目标覆盖(在线链接、运营负载、类目/价格带分布) + - 默认时间:在线链接全量 + +10. `after_sale_360_report.sql` + - 用途:售后360分析(退款金额、一级/二级原因、类型矩阵、安装工单联动) + - 默认时间:上一个自然周 + +11. `product-info`(CLI辅助命令) + - 用途:按 SKU/SPU/关键词查询产品基础信息 + 库存缺货预警 + - 命令:`product-info "" --days 30 --limit 20` + - 输出: + - 结果集1:产品基础信息(SKU名称、成本、材质、预览图等) + - 结果集2:最近N天出货/售后件数 + - 结果集3:最近N天店铺分布(件数) + - 结果集4:最近N天一区/二区/三区运费汇总 + - 结果集5:最近N天省份运费TOP + - 结果集6:最近N天售后总览 + - 结果集7:最近N天售后类型/结果矩阵 + - 结果集8:最近N天售后原因TOP + - 结果集9:最近N天每日总发货件数(按SKU) + - 结果集10:库存风险预估(当前库存/日均发货/预计缺货天数/补货建议,补货安全线=10天) + - 结果集11:近30天在售商品ID数量(按店铺渠道) + - 结果集12:商品ID对应店铺名/渠道/运营人(通知清单) + - 结果集13:缺货风险通知清单(采购/工厂/仓库/店铺/运营) + +12. `product-skus-by-id`(CLI辅助命令) + - 用途:按商品ID查询该ID下所有SKU编码与SKU名称 + - 命令:`product-skus-by-id "<商品id>" --limit 1000` + +13. `customer_service_experience_report.sql` + - 用途:客服体验与话术分析(用户反馈问题结构、体验分、风险话术、日趋势) + - 默认时间:最近30天(可改模板顶部 `@start_date/@end_date`) + +14. `customer-service-analysis`(CLI辅助命令) + - 用途:按指定时间段做客服维度的用户反馈体验/话术分析 + - 命令:`customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20` + +15. `sku_stock_risk_report.sql` + - 用途:按SKU编码查询库存、近30天日均发货件数、预计缺货天数、补货建议(安全线10天) + - 默认时间:最近30天(模板顶部可改 `@lookback_days`) + - 参数:修改模板顶部 `@sku` 即可 + +16. `stock-risk-alerts`(CLI辅助命令) + - 用途:查询近30天所有在售SKU的缺货预警明细(SKU编码维度) + - 命令:`stock-risk-alerts --days 30 --limit 500` + - 输出: + - 结果集1:全量在售SKU/风险SKU汇总 + - 结果集2:风险明细(预计缺货天数、补货建议、采购、工厂、仓库、运营、渠道/店铺) + - 常用参数: + - `--lead-days 10`(补货提前期) + - `--warn-days 15`(预警阈值) + - `--include-safe`(包含安全SKU) + +## 运行示例 + +```bash +company-db run-file /root/.openclaw/workspace/sqlserver/shop_weekly_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/after_sale_360_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/sku_stock_risk_report.sql +product-info "CJ019317N1097V01" --days 30 --limit 20 +stock-risk-alerts --days 30 --limit 500 +customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" +``` + +## 注意事项 + +- 所有模板默认过滤 `配件` 和 `特殊单` 标签。 +- 金额字段多为字符串,模板已使用 `TRY_CONVERT` 做安全转换。 +- 库存预警建议:`estimated_stockout_days <= 10` 视为补货紧急。 diff --git a/TOOLS.remote.live.md b/TOOLS.remote.live.md new file mode 100644 index 0000000..b58b609 --- /dev/null +++ b/TOOLS.remote.live.md @@ -0,0 +1,90 @@ +# TOOLS.md - Local Notes + +Skills define _how_ tools work. This file is for _your_ specifics — the stuff that's unique to your setup. + +## What Goes Here + +Things like: + +- Camera names and locations +- SSH hosts and aliases +- Preferred voices for TTS +- Speaker/room names +- Device nicknames +- Anything environment-specific + +## Examples + +```markdown +### Cameras + +- living-room → Main area, 180° wide angle +- front-door → Entrance, motion-triggered + +### SSH + +- home-server → 192.168.1.100, user: admin + +### TTS + +- Preferred voice: "Nova" (warm, slightly British) +- Default speaker: Kitchen HomePod +``` + +## Why Separate? + +Skills are shared. Your setup is yours. Keeping them apart means you can update skills without losing your notes, and share skills without leaking your infrastructure. + +--- + +Add whatever helps you do your job. This is your cheat sheet. + +## Web Routing Preset (Auto) + +- Site registry file: `/root/.openclaw/workspace/websites.yaml` +- First apply `routing_rules.hard_route_rules`, then fallback to `sites[].purposes/keywords`. +- Hard rule: if request mentions "市场分析/行业分析/类目分析/竞品分析/趋势分析", force `ecommerce-market-analysis`. +- Hard rule: if request mentions "销售数据/业绩/售后/退款/费比/店铺业绩", force `company-sales-db-analysis` (SQLServer), not web page scraping. +- In forced market-analysis route, do not start with `web_search`; go directly to `https://hq.yucekj.com` login flow. +- In forced sales/aftersale route, run `company-db` commands directly (`health`, `tables`, `columns`, `query`, `run-file`). +- If matched site has `login_required: true`, use env credentials first (`username_env/password_env`), then 1Password fallback. +- If task does not need login, use `public-search` or direct public site access. +- If OTP/CAPTCHA appears, pause and ask user in chat, then continue. +- Never operate outside `allowed_domains`. + +## Product Info Helper + +- Command: `product-info "" --days 30 --limit 20` +- Returns: + 1) Product master info (`cost_price`, `board_material`, `preview_pic_url`) + 2) Last N days shipped/aftersale counts + 3) Last N days shop distribution by shipped piece count + 4) Last N days daily shipped pieces by SKU + 5) Stock risk estimation (`available_qty`, `avg_daily_shipped_30d`, `estimated_stockout_days`, `suggest_replenish_qty_to_10d`) + 6) Last 30d sold product-id counts by shop channel + 7) Product-id to shop/operator mapping (for notification) + 8) Risk-only contact list (`purchaser_name`, `factory_name`, `warehouse_name`, `operator_name`) +- Data source: SQLServer `JSDataMiddlePlatform` +- Command: `product-skus-by-id "<商品id>" --limit 1000` +- Returns all SKU codes and SKU names under the given product ID (style_code/product_no/spu/shopproid). +- `product-info` now also returns aftersale insight for matched SKU: + - aftersale summary, type/result matrix, reason TOP (last N days) +- Command: `stock-risk-alerts --days 30 --limit 500` +- Purpose: Query all selling SKUs with potential stockout warning details. +- Output: + 1) summary counters (`sold_sku_count_30d`, `stock_risk_sku_count`, `urgent_sku_count`, `warning_sku_count`) + 2) detailed risk list with `sku_id`, `estimated_stockout_days`, `suggest_replenish_qty_to_lead_days`, `purchaser_name`, `factory_name`, `warehouse_name`, `operator_name` +- Command: `stock-risk-to-feishu --days 30 --top 100` +- Purpose: Push stock risk report to Feishu in structured rows (not one-cell long text). +- Output: + - summary record + detail records, with batch id and Feishu link + - columns include `SKU编码/SKU名称/风险等级/预计缺货天数/建议补货件数/采购/运营/工厂/仓库/渠道/店铺` + + +## Customer Service Experience Helper + +- Command: `customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20` +- Purpose: Analyze customer feedback experience + service wording quality in a date range. +- Output: overall quality, reason structure, shop comparison score, wording coverage, risk phrase hits, daily trend, dialogue cases, capability score by agent name (S/A/B/C/D). + +- Replenishment warning rule: when `estimated_stockout_days <= 10`, mark as urgent restock. diff --git a/__pycache__/openclaw-log-observercpython-313.pyc b/__pycache__/openclaw-log-observercpython-313.pyc new file mode 100644 index 0000000..c82e6b3 Binary files /dev/null and b/__pycache__/openclaw-log-observercpython-313.pyc differ diff --git a/bot.bridge.live.py b/bot.bridge.live.py new file mode 100755 index 0000000..924d4d0 --- /dev/null +++ b/bot.bridge.live.py @@ -0,0 +1,650 @@ +#!/usr/bin/env python3 +import argparse +import hashlib +import json +import logging +import os +import re +import subprocess +import time +import urllib.parse + +from dingtalk_stream import AckMessage, CallbackMessage, ChatbotHandler, ChatbotMessage +from dingtalk_stream import Credential, DingTalkStreamClient + + +MARKET_HINT_KEYWORDS = ( + "市场分析", + "市场数据分析", + "行业分析", + "类目分析", + "竞品分析", + "趋势分析", +) + +SALES_HINT_KEYWORDS = ( + "销售数据", + "销售报表", + "销售看板", + "公司销售", + "业绩报表", + "业绩数据", + "费比监控", +) + +SENSITIVE_SALES_KEYWORDS = ( + "销售", + "业绩", + "售后", + "退款", + "店铺", + "发货", + "工单", + "费比", +) + +EXPLICIT_AMOUNT_KEYWORDS = ( + "金额", + "费用", + "收入", + "利润", + "成本", + "客单价", + "gmv", + "销售额", + "退款额", + "毛利", +) + + +URL_RE = re.compile(r"https?://[^\s)\]>]+", re.IGNORECASE) +IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b") +DOMAIN_RE = re.compile(r"\b(?:[a-zA-Z0-9-]+\.)+(?:com|cn|net|ai|org|io)\b", re.IGNORECASE) +IMAGE_EXT_RE = re.compile(r"\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:$|[?#])", re.IGNORECASE) + + + +def bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +class OpenClawDingTalkBot(ChatbotHandler): + def __init__(self, logger: logging.Logger): + super().__init__() + self.logger = logger + self.openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw") + self.openclaw_timeout = int(os.getenv("OPENCLAW_TIMEOUT", "600")) + self.market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report") + self.market_brief_timeout = int(os.getenv("MARKET_BRIEF_TIMEOUT", "180")) + self.prefix = os.getenv("BOT_PREFIX", "/oc").strip() + self.require_prefix = bool_env("REQUIRE_PREFIX", False) + self.reply_max_chars = int(os.getenv("REPLY_MAX_CHARS", "3500")) + self.session_prefix = os.getenv("OPENCLAW_SESSION_PREFIX", "dingtalk") + self.hide_source_urls = bool_env("HIDE_SOURCE_URLS", True) + self.send_processing_hint = bool_env("SEND_PROCESSING_HINT", True) + self.dedupe_ttl_seconds = int(os.getenv("DEDUPE_TTL_SECONDS", "300")) + self.session_max_lines = int(os.getenv("OPENCLAW_SESSION_MAX_LINES", "120")) + self.sessions_dir = os.getenv("OPENCLAW_SESSIONS_DIR", "/root/.openclaw/agents/main/sessions") + self.unmask_url_hosts = { + h.strip().lower() + for h in os.getenv("UNMASK_URL_HOSTS", "img.alicdn.com,static.jiansun.vip").split(",") + if h.strip() + } + self._recent_event_ts = {} + self.processing_hint_text = os.getenv( + "PROCESSING_HINT_TEXT", + "已收到,正在处理你的需求,请稍等1-3分钟。", + ).strip() + self.reply_style = os.getenv("REPLY_STYLE", "action_card").strip().lower() + self.action_card_title = os.getenv("ACTION_CARD_TITLE", "OpenClaw 助理").strip() + self.action_card_tips = os.getenv("ACTION_CARD_TIPS", "").strip() + self.action_card_logo = os.getenv("ACTION_CARD_LOGO", "").strip() + self.action_card_btn_text = os.getenv("ACTION_CARD_BTN_TEXT", "").strip() + self.action_card_btn_url = os.getenv("ACTION_CARD_BTN_URL", "").strip() + + def _should_handle(self, msg: ChatbotMessage, text: str) -> bool: + if text.startswith(self.prefix): + return True + if msg.is_in_at_list: + return True + return not self.require_prefix + + def _normalize_prompt(self, text: str) -> str: + text = text.strip() + if self.prefix and text.startswith(self.prefix): + text = text[len(self.prefix):].strip() + text = re.sub(r"^@\S+\s*", "", text).strip() + return text + + def _session_id(self, msg: ChatbotMessage) -> str: + conversation_id = (msg.conversation_id or "default").replace(" ", "_") + base_session = f"{self.session_prefix}-{conversation_id}" + + session_file = os.path.join(self.sessions_dir, f"{base_session}.jsonl") + if self.session_max_lines > 0 and os.path.exists(session_file): + try: + with open(session_file, "rb") as fh: + line_count = sum(1 for _ in fh) + if line_count >= self.session_max_lines: + rotate_suffix = time.strftime("%Y%m%d%H%M") + rotated = f"{base_session}-{rotate_suffix}" + self.logger.info( + "session auto-rotated: base=%s lines=%s rotated=%s", + base_session, + line_count, + rotated, + ) + return rotated + except Exception: + self.logger.exception("failed to inspect session file: %s", session_file) + + return base_session + + def _event_key(self, callback: CallbackMessage, msg: ChatbotMessage, prompt: str) -> str: + if getattr(msg, "message_id", None): + return f"msg:{msg.message_id}" + + data = callback.data if isinstance(callback.data, dict) else {} + for key in ("msgId", "messageId", "eventId", "processQueryKey"): + value = data.get(key) + if value: + return f"data:{key}:{value}" + + headers = getattr(callback, "headers", {}) or {} + for key in ("messageId", "eventId", "processQueryKey"): + value = headers.get(key) + if value: + return f"hdr:{key}:{value}" + + raw = "|".join( + [ + str(getattr(msg, "conversation_id", "") or ""), + str(getattr(msg, "sender_id", "") or ""), + str(getattr(msg, "sender_staff_id", "") or ""), + str(getattr(msg, "create_at", "") or ""), + prompt[:200], + ] + ) + return "fp:" + hashlib.sha1(raw.encode("utf-8")).hexdigest() + + def _seen_or_mark_event(self, key: str) -> bool: + now = time.time() + expire_before = now - max(self.dedupe_ttl_seconds, 30) + + stale_keys = [k for k, ts in self._recent_event_ts.items() if ts < expire_before] + for stale_key in stale_keys: + self._recent_event_ts.pop(stale_key, None) + + if key in self._recent_event_ts: + return True + + self._recent_event_ts[key] = now + return False + + def _is_market_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt) + + if any(k in normalized for k in SALES_HINT_KEYWORDS): + return False + + if "电脑桌" in normalized: + return True + + if any(k in normalized for k in MARKET_HINT_KEYWORDS): + return True + + if "市场" in normalized and "分析" in normalized: + return True + + return False + + def _is_sales_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt).lower() + return any(k.lower() in normalized for k in SENSITIVE_SALES_KEYWORDS) + + def _explicit_amount_requested(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt).lower() + return any(k.lower() in normalized for k in EXPLICIT_AMOUNT_KEYWORDS) + + def _mask_sensitive_amounts(self, prompt: str, answer: str) -> str: + if not self._is_sales_prompt(prompt): + return answer + if self._explicit_amount_requested(prompt): + return answer + + filtered_lines = [] + removed = 0 + for line in answer.splitlines(): + if re.search(r"(金额|退款率|费比|收入|利润|成本|客单价|GMV|毛利|\d[\d,\.]*\s*(元|万元|万|亿))", line, re.IGNORECASE): + removed += 1 + continue + filtered_lines.append(line) + + cleaned = "\n".join(filtered_lines).strip() + if removed > 0: + note = "(已按配置自动隐藏金额类敏感指标,仅保留件数/订单维度。)" + if cleaned: + cleaned = f"{cleaned}\n\n{note}" + else: + cleaned = note + + return cleaned or "已按配置隐藏金额类敏感指标,请改问件数/订单/原因结构维度。" + + def _extract_market_subject(self, prompt: str) -> str: + normalized = prompt.strip() + + def clean_subject(raw: str) -> str: + v = raw.strip().replace("\u201c", "").replace("\u201d", "").strip("\"'") + v = re.sub(r"^(帮我|请|麻烦|给我|我想|想要|想看|想做|分析|查看|查询|关于)+", "", v) + v = re.sub(r"(的|情况|数据|分析)+$", "", v) + return v.strip() + + def pick(raw: str): + v = clean_subject(raw) + if len(v) < 2: + return None + if v in {"市场", "行业", "类目", "竞品", "趋势", "市场数据"}: + return None + return v + + if "电脑桌" in normalized: + return "电脑桌" + + m = re.search(r"[\"\u201c\u201d']([^\"\u201c\u201d']{2,30})[\"\u201c\u201d']", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search( + r"关于([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据|市场情况)", + normalized, + ) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search( + r"([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据)", + normalized, + ) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search(r"分析(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search(r"查(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + return "电脑桌" + + def _sanitize_answer(self, answer: str) -> str: + if not self.hide_source_urls: + return answer + + keep_map = {} + + def _keep_or_mask_url(match): + raw = match.group(0) + try: + host = (urllib.parse.urlparse(raw).hostname or "").lower() + except Exception: + host = "" + + if host and ( + host in self.unmask_url_hosts + or host == "feishu.cn" + or host.endswith(".feishu.cn") + ): + token = f"__KEEP_URL_{len(keep_map)}__" + keep_map[token] = raw + return token + + return "[内部数据源地址已脱敏]" + + out = URL_RE.sub(_keep_or_mask_url, answer) + out = IP_RE.sub("[内部地址已脱敏]", out) + out = DOMAIN_RE.sub("[内部域名已脱敏]", out) + + # 保底脱敏(防止某些格式未被正则覆盖) + for token in ("hq.yucekj.com", "yucekj.com", "218.93.9.141"): + out = out.replace(token, "[内部数据源已脱敏]") + + for token, raw in keep_map.items(): + out = out.replace(token, raw) + + return out + + def _run_market_brief(self, subject: str) -> str: + cmd = [self.market_brief_bin, subject] + self.logger.info("market-brief call: subject=%s", subject) + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.market_brief_timeout, + ) + output = (proc.stdout or "").strip() + if proc.returncode != 0: + err = (proc.stderr or output or "market brief error").strip() + return f"市场分析执行失败: {err[:500]}" + if not output: + return "市场分析执行完成,但未返回文本结果。" + return output + + def _run_openclaw(self, session_id: str, prompt: str) -> str: + cmd = [ + self.openclaw_bin, + "agent", + "--session-id", + session_id, + "--message", + prompt, + "--json", + "--timeout", + str(self.openclaw_timeout), + ] + self.logger.info("openclaw call: session=%s", session_id) + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.openclaw_timeout + 20, + ) + except subprocess.TimeoutExpired: + self.logger.error("openclaw call timeout: session=%s timeout=%ss", session_id, self.openclaw_timeout) + return f"OpenClaw 调用超时(>{self.openclaw_timeout}秒),请重试。" + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "openclaw error").strip() + return f"OpenClaw 调用失败: {err[:500]}" + + try: + raw_stdout = (proc.stdout or "").strip() + decoder = json.JSONDecoder() + data = None + parse_error = None + try: + data, _ = decoder.raw_decode(raw_stdout) + except Exception as exc: + parse_error = exc + idx = raw_stdout.find("{") + while idx != -1: + candidate = raw_stdout[idx:] + try: + data, _ = decoder.raw_decode(candidate) + break + except Exception: + idx = raw_stdout.find("{", idx + 1) + if data is None: + raise parse_error + + payloads = data.get("result", {}).get("payloads", []) + text_items = [p.get("text", "") for p in payloads if isinstance(p, dict)] + answer = "\n".join(t for t in text_items if t).strip() + if not answer: + answer = "任务已执行,但未返回文本结果。" + + if self._is_market_prompt(prompt) and ( + "无法直接从页面抓取" in answer + or "需要登录后的操作" in answer + or "工具版本不支持点击交互" in answer + ): + subject = self._extract_market_subject(prompt) + fallback = self._run_market_brief(subject) + return f"已切换为市场分析直连模式({subject}):\n\n{fallback}" + + return answer + except Exception as exc: + self.logger.exception("parse openclaw output failed") + raw = (proc.stdout or "").strip() + return f"结果解析失败: {exc}.\n原始输出: {raw[:500]}" + + def _is_image_url(self, raw_url: str) -> bool: + try: + parsed = urllib.parse.urlparse(raw_url) + except Exception: + return False + + host = (parsed.hostname or "").lower() + path = (parsed.path or "").lower() + if IMAGE_EXT_RE.search(path): + return True + if host.startswith("img.") or "image" in host: + return True + return False + + def _extract_image_urls(self, text: str, limit: int = 5) -> list: + urls = [] + seen = set() + for match in URL_RE.finditer(text or ""): + raw = match.group(0).rstrip(".,;!?") + if not self._is_image_url(raw): + continue + if raw in seen: + continue + seen.add(raw) + urls.append(raw) + if len(urls) >= limit: + break + return urls + + def _remove_image_urls(self, text: str, urls: list) -> str: + out = text + for u in urls: + out = out.replace(u, "") + out = re.sub(r"\n{3,}", "\n\n", out) + return out.strip() + + def _build_action_buttons(self) -> list: + if not self.action_card_btn_url: + return [] + return [ + { + "text": self.action_card_btn_text or "打开链接", + "url": self.action_card_btn_url, + "iosUrl": self.action_card_btn_url, + "color": "gray", + } + ] + + def _is_card_reply_ok(self, reply_obj) -> bool: + if reply_obj is None: + return False + card_instance_id = getattr(reply_obj, "card_instance_id", None) + if isinstance(card_instance_id, str): + return bool(card_instance_id.strip()) + return bool(card_instance_id) + + def _reply_message(self, msg: ChatbotMessage, text: str, prefer_card: bool = True) -> bool: + payload = (text or "").strip() or "任务已执行,但未返回文本结果。" + if prefer_card and self.reply_style == "action_card": + image_urls = self._extract_image_urls(payload, limit=5) + if image_urls: + slider = [(f"图片{i + 1}", u) for i, u in enumerate(image_urls)] + markdown = self._remove_image_urls(payload, image_urls) + if not markdown: + markdown = "已整理相关图片如下:" + try: + card = self.reply_carousel_card( + incoming_message=msg, + markdown=markdown, + image_slider=slider, + button_text=self.action_card_btn_text or "查看", + title=self.action_card_title, + logo=self.action_card_logo, + ) + if self._is_card_reply_ok(card): + return True + self.logger.warning("carousel card reply returned empty id, fallback to action card/text") + except Exception: + self.logger.exception("carousel card reply failed, fallback to action card") + + markdown = payload + if self.action_card_title: + markdown = f"### {self.action_card_title}\n\n{payload}" + try: + card = self.reply_markdown_button( + incoming_message=msg, + markdown=markdown, + button_list=self._build_action_buttons(), + tips=self.action_card_tips, + title=self.action_card_title, + logo=self.action_card_logo, + ) + if self._is_card_reply_ok(card): + return True + self.logger.warning("action card reply returned empty id, fallback to text") + except Exception: + self.logger.exception("action card reply failed, fallback to text") + resp = self.reply_text(payload, msg) + if resp is None: + self.logger.error("text reply failed") + return False + return True + + async def process(self, callback: CallbackMessage): + try: + msg = ChatbotMessage.from_dict(callback.data) + except Exception: + self.logger.exception("invalid callback payload") + return AckMessage.STATUS_BAD_REQUEST, "bad payload" + + text = "" + if msg.text and getattr(msg.text, "content", None): + text = msg.text.content.strip() + else: + text_list = msg.get_text_list() or [] + text = text_list[0].strip() if text_list else "" + + if not text: + return AckMessage.STATUS_OK, "ignored" + + if not self._should_handle(msg, text): + return AckMessage.STATUS_OK, "ignored" + + prompt = self._normalize_prompt(text) + if not prompt: + self._reply_message( + msg, + f"用法: {self.prefix} 你的问题\\n例如: {self.prefix} 查询亿家达旗舰店上周业绩", + prefer_card=False, + ) + return AckMessage.STATUS_OK, "ok" + + event_key = self._event_key(callback, msg, prompt) + if self._seen_or_mark_event(event_key): + self.logger.info( + "duplicate callback ignored: key=%s conversation=%s sender=%s", + event_key, + getattr(msg, "conversation_id", ""), + getattr(msg, "sender_id", ""), + ) + return AckMessage.STATUS_OK, "duplicate_ignored" + + if self.send_processing_hint: + hint = self.processing_hint_text or "已收到,正在处理,请稍等。" + try: + self.reply_text(hint, msg) + except Exception: + self.logger.exception("failed to send processing hint") + + session_id = self._session_id(msg) + if self._is_market_prompt(prompt): + subject = self._extract_market_subject(prompt) + answer = self._run_market_brief(subject) + else: + answer = self._run_openclaw(session_id, prompt) + + answer = self._sanitize_answer(answer) + answer = self._mask_sensitive_amounts(prompt, answer) + if len(answer) > self.reply_max_chars: + answer = answer[: self.reply_max_chars] + "\n...(已截断)" + + try: + sent = self._reply_message(msg, answer, prefer_card=True) + if sent: + self.logger.info("reply sent: session=%s chars=%s", session_id, len(answer)) + else: + self.logger.error("reply failed: session=%s chars=%s", session_id, len(answer)) + except Exception: + self.logger.exception("failed to send final reply: session=%s", session_id) + return AckMessage.STATUS_OK, "ok" + + +def build_client(logger: logging.Logger) -> DingTalkStreamClient: + client_id = os.getenv("DINGTALK_CLIENT_ID", "").strip() + client_secret = os.getenv("DINGTALK_CLIENT_SECRET", "").strip() + + if not client_id or not client_secret: + raise RuntimeError("DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET 未配置") + + credential = Credential(client_id, client_secret) + client = DingTalkStreamClient(credential, logger=logger) + bot = OpenClawDingTalkBot(logger) + client.register_callback_handler(ChatbotMessage.TOPIC, bot) + if bool_env("REGISTER_DELEGATE_TOPIC", False): + client.register_callback_handler(ChatbotMessage.DELEGATE_TOPIC, bot) + logger.info("delegate topic enabled") + else: + logger.info("delegate topic disabled (default)") + return client + + +def self_check(logger: logging.Logger) -> int: + required = ["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"] + missing = [k for k in required if not os.getenv(k)] + if missing: + logger.error("missing env: %s", ", ".join(missing)) + return 2 + + openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw") + proc = subprocess.run([openclaw_bin, "--version"], capture_output=True, text=True) + if proc.returncode != 0: + logger.error("openclaw check failed: %s", (proc.stderr or proc.stdout).strip()) + return 3 + + market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report") + if not os.path.exists(market_brief_bin): + logger.error("market brief bin not found: %s", market_brief_bin) + return 4 + + logger.info("self-check ok, openclaw=%s", (proc.stdout or "").strip()) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="OpenClaw DingTalk Bridge") + parser.add_argument("--check", action="store_true", help="run checks and exit") + args = parser.parse_args() + + logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + logger = logging.getLogger("openclaw-dingtalk-bridge") + + if args.check: + return self_check(logger) + + client = build_client(logger) + logger.info("bridge started, listening DingTalk stream") + client.start_forever() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bot.py.restore b/bot.py.restore new file mode 100755 index 0000000..c0b1f90 --- /dev/null +++ b/bot.py.restore @@ -0,0 +1,627 @@ +#!/usr/bin/env python3 +import argparse +import hashlib +import json +import logging +import os +import re +import subprocess +import time +import urllib.parse + +from dingtalk_stream import AckMessage, CallbackMessage, ChatbotHandler, ChatbotMessage +from dingtalk_stream import Credential, DingTalkStreamClient + + +MARKET_HINT_KEYWORDS = ( + "市场分析", + "市场数据分析", + "行业分析", + "类目分析", + "竞品分析", + "趋势分析", +) + +SALES_HINT_KEYWORDS = ( + "销售数据", + "销售报表", + "销售看板", + "公司销售", + "业绩报表", + "业绩数据", + "费比监控", +) + +SENSITIVE_SALES_KEYWORDS = ( + "销售", + "业绩", + "售后", + "退款", + "店铺", + "发货", + "工单", + "费比", +) + +EXPLICIT_AMOUNT_KEYWORDS = ( + "金额", + "费用", + "收入", + "利润", + "成本", + "客单价", + "gmv", + "销售额", + "退款额", + "毛利", +) + + +URL_RE = re.compile(r"https?://[^\s)\]>]+", re.IGNORECASE) +IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b") +DOMAIN_RE = re.compile(r"\b(?:[a-zA-Z0-9-]+\.)+(?:com|cn|net|ai|org|io)\b", re.IGNORECASE) +IMAGE_EXT_RE = re.compile(r"\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:$|[?#])", re.IGNORECASE) + + + +def bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +class OpenClawDingTalkBot(ChatbotHandler): + def __init__(self, logger: logging.Logger): + super().__init__() + self.logger = logger + self.openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw") + self.openclaw_timeout = int(os.getenv("OPENCLAW_TIMEOUT", "600")) + self.market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report") + self.market_brief_timeout = int(os.getenv("MARKET_BRIEF_TIMEOUT", "180")) + self.prefix = os.getenv("BOT_PREFIX", "/oc").strip() + self.require_prefix = bool_env("REQUIRE_PREFIX", False) + self.reply_max_chars = int(os.getenv("REPLY_MAX_CHARS", "3500")) + self.session_prefix = os.getenv("OPENCLAW_SESSION_PREFIX", "dingtalk") + self.hide_source_urls = bool_env("HIDE_SOURCE_URLS", True) + self.send_processing_hint = bool_env("SEND_PROCESSING_HINT", True) + self.dedupe_ttl_seconds = int(os.getenv("DEDUPE_TTL_SECONDS", "300")) + self.session_max_lines = int(os.getenv("OPENCLAW_SESSION_MAX_LINES", "120")) + self.sessions_dir = os.getenv("OPENCLAW_SESSIONS_DIR", "/root/.openclaw/agents/main/sessions") + self.unmask_url_hosts = { + h.strip().lower() + for h in os.getenv("UNMASK_URL_HOSTS", "img.alicdn.com,static.jiansun.vip").split(",") + if h.strip() + } + self._recent_event_ts = {} + self.processing_hint_text = os.getenv( + "PROCESSING_HINT_TEXT", + "已收到,正在处理你的需求,请稍等1-3分钟。", + ).strip() + self.reply_style = os.getenv("REPLY_STYLE", "action_card").strip().lower() + self.action_card_title = os.getenv("ACTION_CARD_TITLE", "OpenClaw 助理").strip() + self.action_card_tips = os.getenv("ACTION_CARD_TIPS", "").strip() + self.action_card_logo = os.getenv("ACTION_CARD_LOGO", "").strip() + self.action_card_btn_text = os.getenv("ACTION_CARD_BTN_TEXT", "").strip() + self.action_card_btn_url = os.getenv("ACTION_CARD_BTN_URL", "").strip() + + def _should_handle(self, msg: ChatbotMessage, text: str) -> bool: + if text.startswith(self.prefix): + return True + if msg.is_in_at_list: + return True + return not self.require_prefix + + def _normalize_prompt(self, text: str) -> str: + text = text.strip() + if self.prefix and text.startswith(self.prefix): + text = text[len(self.prefix):].strip() + text = re.sub(r"^@\S+\s*", "", text).strip() + return text + + def _session_id(self, msg: ChatbotMessage) -> str: + conversation_id = (msg.conversation_id or "default").replace(" ", "_") + base_session = f"{self.session_prefix}-{conversation_id}" + + session_file = os.path.join(self.sessions_dir, f"{base_session}.jsonl") + if self.session_max_lines > 0 and os.path.exists(session_file): + try: + with open(session_file, "rb") as fh: + line_count = sum(1 for _ in fh) + if line_count >= self.session_max_lines: + rotate_suffix = time.strftime("%Y%m%d%H%M") + rotated = f"{base_session}-{rotate_suffix}" + self.logger.info( + "session auto-rotated: base=%s lines=%s rotated=%s", + base_session, + line_count, + rotated, + ) + return rotated + except Exception: + self.logger.exception("failed to inspect session file: %s", session_file) + + return base_session + + def _event_key(self, callback: CallbackMessage, msg: ChatbotMessage, prompt: str) -> str: + if getattr(msg, "message_id", None): + return f"msg:{msg.message_id}" + + data = callback.data if isinstance(callback.data, dict) else {} + for key in ("msgId", "messageId", "eventId", "processQueryKey"): + value = data.get(key) + if value: + return f"data:{key}:{value}" + + headers = getattr(callback, "headers", {}) or {} + for key in ("messageId", "eventId", "processQueryKey"): + value = headers.get(key) + if value: + return f"hdr:{key}:{value}" + + raw = "|".join( + [ + str(getattr(msg, "conversation_id", "") or ""), + str(getattr(msg, "sender_id", "") or ""), + str(getattr(msg, "sender_staff_id", "") or ""), + str(getattr(msg, "create_at", "") or ""), + prompt[:200], + ] + ) + return "fp:" + hashlib.sha1(raw.encode("utf-8")).hexdigest() + + def _seen_or_mark_event(self, key: str) -> bool: + now = time.time() + expire_before = now - max(self.dedupe_ttl_seconds, 30) + + stale_keys = [k for k, ts in self._recent_event_ts.items() if ts < expire_before] + for stale_key in stale_keys: + self._recent_event_ts.pop(stale_key, None) + + if key in self._recent_event_ts: + return True + + self._recent_event_ts[key] = now + return False + + def _is_market_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt) + + if any(k in normalized for k in SALES_HINT_KEYWORDS): + return False + + if "电脑桌" in normalized: + return True + + if any(k in normalized for k in MARKET_HINT_KEYWORDS): + return True + + if "市场" in normalized and "分析" in normalized: + return True + + return False + + def _is_sales_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt).lower() + return any(k.lower() in normalized for k in SENSITIVE_SALES_KEYWORDS) + + def _explicit_amount_requested(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt).lower() + return any(k.lower() in normalized for k in EXPLICIT_AMOUNT_KEYWORDS) + + def _mask_sensitive_amounts(self, prompt: str, answer: str) -> str: + if not self._is_sales_prompt(prompt): + return answer + if self._explicit_amount_requested(prompt): + return answer + + filtered_lines = [] + removed = 0 + for line in answer.splitlines(): + if re.search(r"(金额|退款率|费比|收入|利润|成本|客单价|GMV|毛利|\d[\d,\.]*\s*(元|万元|万|亿))", line, re.IGNORECASE): + removed += 1 + continue + filtered_lines.append(line) + + cleaned = "\n".join(filtered_lines).strip() + if removed > 0: + note = "(已按配置自动隐藏金额类敏感指标,仅保留件数/订单维度。)" + if cleaned: + cleaned = f"{cleaned}\n\n{note}" + else: + cleaned = note + + return cleaned or "已按配置隐藏金额类敏感指标,请改问件数/订单/原因结构维度。" + + def _extract_market_subject(self, prompt: str) -> str: + normalized = prompt.strip() + + def clean_subject(raw: str) -> str: + v = raw.strip().replace("\u201c", "").replace("\u201d", "").strip("\"'") + v = re.sub(r"^(帮我|请|麻烦|给我|我想|想要|想看|想做|分析|查看|查询|关于)+", "", v) + v = re.sub(r"(的|情况|数据|分析)+$", "", v) + return v.strip() + + def pick(raw: str): + v = clean_subject(raw) + if len(v) < 2: + return None + if v in {"市场", "行业", "类目", "竞品", "趋势", "市场数据"}: + return None + return v + + if "电脑桌" in normalized: + return "电脑桌" + + m = re.search(r"[\"\u201c\u201d']([^\"\u201c\u201d']{2,30})[\"\u201c\u201d']", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search( + r"关于([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据|市场情况)", + normalized, + ) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search( + r"([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据)", + normalized, + ) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search(r"分析(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search(r"查(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + return "电脑桌" + + def _sanitize_answer(self, answer: str) -> str: + if not self.hide_source_urls: + return answer + + keep_map = {} + + def _keep_or_mask_url(match): + raw = match.group(0) + try: + host = (urllib.parse.urlparse(raw).hostname or "").lower() + except Exception: + host = "" + + if host and host in self.unmask_url_hosts: + token = f"__KEEP_URL_{len(keep_map)}__" + keep_map[token] = raw + return token + + return "[内部数据源地址已脱敏]" + + out = URL_RE.sub(_keep_or_mask_url, answer) + out = IP_RE.sub("[内部地址已脱敏]", out) + out = DOMAIN_RE.sub("[内部域名已脱敏]", out) + + # 保底脱敏(防止某些格式未被正则覆盖) + for token in ("hq.yucekj.com", "yucekj.com", "218.93.9.141"): + out = out.replace(token, "[内部数据源已脱敏]") + + for token, raw in keep_map.items(): + out = out.replace(token, raw) + + return out + + def _run_market_brief(self, subject: str) -> str: + cmd = [self.market_brief_bin, subject] + self.logger.info("market-brief call: subject=%s", subject) + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.market_brief_timeout, + ) + output = (proc.stdout or "").strip() + if proc.returncode != 0: + err = (proc.stderr or output or "market brief error").strip() + return f"市场分析执行失败: {err[:500]}" + if not output: + return "市场分析执行完成,但未返回文本结果。" + return output + + def _run_openclaw(self, session_id: str, prompt: str) -> str: + cmd = [ + self.openclaw_bin, + "agent", + "--session-id", + session_id, + "--message", + prompt, + "--json", + "--timeout", + str(self.openclaw_timeout), + ] + self.logger.info("openclaw call: session=%s", session_id) + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.openclaw_timeout + 20, + ) + except subprocess.TimeoutExpired: + self.logger.error("openclaw call timeout: session=%s timeout=%ss", session_id, self.openclaw_timeout) + return f"OpenClaw 调用超时(>{self.openclaw_timeout}秒),请重试。" + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "openclaw error").strip() + return f"OpenClaw 调用失败: {err[:500]}" + + try: + raw_stdout = (proc.stdout or "").strip() + decoder = json.JSONDecoder() + data = None + parse_error = None + try: + data, _ = decoder.raw_decode(raw_stdout) + except Exception as exc: + parse_error = exc + idx = raw_stdout.find("{") + while idx != -1: + candidate = raw_stdout[idx:] + try: + data, _ = decoder.raw_decode(candidate) + break + except Exception: + idx = raw_stdout.find("{", idx + 1) + if data is None: + raise parse_error + + payloads = data.get("result", {}).get("payloads", []) + text_items = [p.get("text", "") for p in payloads if isinstance(p, dict)] + answer = "\n".join(t for t in text_items if t).strip() + if not answer: + answer = "任务已执行,但未返回文本结果。" + + if self._is_market_prompt(prompt) and ( + "无法直接从页面抓取" in answer + or "需要登录后的操作" in answer + or "工具版本不支持点击交互" in answer + ): + subject = self._extract_market_subject(prompt) + fallback = self._run_market_brief(subject) + return f"已切换为市场分析直连模式({subject}):\n\n{fallback}" + + return answer + except Exception as exc: + self.logger.exception("parse openclaw output failed") + raw = (proc.stdout or "").strip() + return f"结果解析失败: {exc}.\n原始输出: {raw[:500]}" + + def _is_image_url(self, raw_url: str) -> bool: + try: + parsed = urllib.parse.urlparse(raw_url) + except Exception: + return False + + host = (parsed.hostname or "").lower() + path = (parsed.path or "").lower() + if IMAGE_EXT_RE.search(path): + return True + if host.startswith("img.") or "image" in host: + return True + return False + + def _extract_image_urls(self, text: str, limit: int = 5) -> list: + urls = [] + seen = set() + for match in URL_RE.finditer(text or ""): + raw = match.group(0).rstrip(".,;!?") + if not self._is_image_url(raw): + continue + if raw in seen: + continue + seen.add(raw) + urls.append(raw) + if len(urls) >= limit: + break + return urls + + def _remove_image_urls(self, text: str, urls: list) -> str: + out = text + for u in urls: + out = out.replace(u, "") + out = re.sub(r"\n{3,}", "\n\n", out) + return out.strip() + + def _build_action_buttons(self) -> list: + if not self.action_card_btn_url: + return [] + return [ + { + "text": self.action_card_btn_text or "打开链接", + "url": self.action_card_btn_url, + "iosUrl": self.action_card_btn_url, + "color": "gray", + } + ] + + def _reply_message(self, msg: ChatbotMessage, text: str, prefer_card: bool = True): + payload = (text or "").strip() or "任务已执行,但未返回文本结果。" + if prefer_card and self.reply_style == "action_card": + image_urls = self._extract_image_urls(payload, limit=5) + if image_urls: + slider = [(f"图片{i + 1}", u) for i, u in enumerate(image_urls)] + markdown = self._remove_image_urls(payload, image_urls) + if not markdown: + markdown = "已整理相关图片如下:" + try: + self.reply_carousel_card( + incoming_message=msg, + markdown=markdown, + image_slider=slider, + button_text=self.action_card_btn_text or "查看", + title=self.action_card_title, + logo=self.action_card_logo, + ) + return + except Exception: + self.logger.exception("carousel card reply failed, fallback to action card") + + markdown = payload + if self.action_card_title: + markdown = f"### {self.action_card_title}\n\n{payload}" + try: + self.reply_markdown_button( + incoming_message=msg, + markdown=markdown, + button_list=self._build_action_buttons(), + tips=self.action_card_tips, + title=self.action_card_title, + logo=self.action_card_logo, + ) + return + except Exception: + self.logger.exception("action card reply failed, fallback to text") + self.reply_text(payload, msg) + + async def process(self, callback: CallbackMessage): + try: + msg = ChatbotMessage.from_dict(callback.data) + except Exception: + self.logger.exception("invalid callback payload") + return AckMessage.STATUS_BAD_REQUEST, "bad payload" + + text = "" + if msg.text and getattr(msg.text, "content", None): + text = msg.text.content.strip() + else: + text_list = msg.get_text_list() or [] + text = text_list[0].strip() if text_list else "" + + if not text: + return AckMessage.STATUS_OK, "ignored" + + if not self._should_handle(msg, text): + return AckMessage.STATUS_OK, "ignored" + + prompt = self._normalize_prompt(text) + if not prompt: + self._reply_message( + msg, + f"用法: {self.prefix} 你的问题\\n例如: {self.prefix} 查询亿家达旗舰店上周业绩", + prefer_card=False, + ) + return AckMessage.STATUS_OK, "ok" + + event_key = self._event_key(callback, msg, prompt) + if self._seen_or_mark_event(event_key): + self.logger.info( + "duplicate callback ignored: key=%s conversation=%s sender=%s", + event_key, + getattr(msg, "conversation_id", ""), + getattr(msg, "sender_id", ""), + ) + return AckMessage.STATUS_OK, "duplicate_ignored" + + if self.send_processing_hint: + hint = self.processing_hint_text or "已收到,正在处理,请稍等。" + try: + self.reply_text(hint, msg) + except Exception: + self.logger.exception("failed to send processing hint") + + session_id = self._session_id(msg) + if self._is_market_prompt(prompt): + subject = self._extract_market_subject(prompt) + answer = self._run_market_brief(subject) + else: + answer = self._run_openclaw(session_id, prompt) + + answer = self._sanitize_answer(answer) + answer = self._mask_sensitive_amounts(prompt, answer) + if len(answer) > self.reply_max_chars: + answer = answer[: self.reply_max_chars] + "\n...(已截断)" + + try: + self._reply_message(msg, answer, prefer_card=True) + self.logger.info("reply sent: session=%s chars=%s", session_id, len(answer)) + except Exception: + self.logger.exception("failed to send final reply: session=%s", session_id) + return AckMessage.STATUS_OK, "ok" + + +def build_client(logger: logging.Logger) -> DingTalkStreamClient: + client_id = os.getenv("DINGTALK_CLIENT_ID", "").strip() + client_secret = os.getenv("DINGTALK_CLIENT_SECRET", "").strip() + + if not client_id or not client_secret: + raise RuntimeError("DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET 未配置") + + credential = Credential(client_id, client_secret) + client = DingTalkStreamClient(credential, logger=logger) + bot = OpenClawDingTalkBot(logger) + client.register_callback_handler(ChatbotMessage.TOPIC, bot) + if bool_env("REGISTER_DELEGATE_TOPIC", False): + client.register_callback_handler(ChatbotMessage.DELEGATE_TOPIC, bot) + logger.info("delegate topic enabled") + else: + logger.info("delegate topic disabled (default)") + return client + + +def self_check(logger: logging.Logger) -> int: + required = ["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"] + missing = [k for k in required if not os.getenv(k)] + if missing: + logger.error("missing env: %s", ", ".join(missing)) + return 2 + + openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw") + proc = subprocess.run([openclaw_bin, "--version"], capture_output=True, text=True) + if proc.returncode != 0: + logger.error("openclaw check failed: %s", (proc.stderr or proc.stdout).strip()) + return 3 + + market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report") + if not os.path.exists(market_brief_bin): + logger.error("market brief bin not found: %s", market_brief_bin) + return 4 + + logger.info("self-check ok, openclaw=%s", (proc.stdout or "").strip()) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="OpenClaw DingTalk Bridge") + parser.add_argument("--check", action="store_true", help="run checks and exit") + args = parser.parse_args() + + logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + logger = logging.getLogger("openclaw-dingtalk-bridge") + + if args.check: + return self_check(logger) + + client = build_client(logger) + logger.info("bridge started, listening DingTalk stream") + client.start_forever() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fix_agents_feishu_section.py b/fix_agents_feishu_section.py new file mode 100644 index 0000000..080d916 --- /dev/null +++ b/fix_agents_feishu_section.py @@ -0,0 +1,26 @@ +from pathlib import Path + +p = Path('/root/.openclaw/workspace/AGENTS.md') +text = p.read_text(encoding='utf-8') +start = text.find('## 5) Feishu Delivery Rules\n') +ws = text.find('## 5) Workspace Paths\n') +if start == -1 or ws == -1 or ws < start: + raise SystemExit('section markers not found') +new_section = '''## 5) Feishu Delivery Rules + +Trigger keywords: 推到飞书表格 / 写入飞书 / 同步飞书 / 发送飞书表格链接 + +Required behavior: +- When user asks to push result to Feishu Bitable, run write action first (feishu_bitable_create_record or feishu_bitable_update_record). +- In final reply, always include: + 1) push status (success/failed) + 2) target sheet link (the user-provided wiki/base URL) + 3) written record_id (or updated count) +- If write partially fails, return succeeded records and failed reason list separately. +- If user explicitly asks for "只要结果", keep reasoning short but still include the sheet link and write status line. + +''' +text = text[:start] + new_section + text[ws:] +text = text.replace('## 5) Workspace Paths\n', '## 6) Workspace Paths\n', 1) +p.write_text(text, encoding='utf-8') +print('fixed', p) diff --git a/fix_websites_stock_keywords.py b/fix_websites_stock_keywords.py new file mode 100644 index 0000000..7a92f6f --- /dev/null +++ b/fix_websites_stock_keywords.py @@ -0,0 +1,51 @@ +from pathlib import Path +p=Path('/root/.openclaw/workspace/websites.yaml') +lines=p.read_text(encoding='utf-8').splitlines() + +# Ensure hard route keywords contain inventory terms +hard_start=None +hard_end=None +for i,l in enumerate(lines): + if l.strip()=='- when_keywords:' and i>0 and 'hard_route_rules:' in lines[i-1]: + # first rule, skip + pass +for i,l in enumerate(lines): + if l.strip()=='- when_keywords:' and i+1 < len(lines) and lines[i+1].strip()=='- 销售数据': + hard_start=i + # find force_site_id line + for j in range(i+1,len(lines)): + if 'force_site_id: company-sales-db-analysis' in lines[j]: + hard_end=j + break + break +if hard_start is not None and hard_end is not None: + existing=set(l.strip().lstrip('-').strip() for l in lines[hard_start:hard_end] if l.strip().startswith('- ')) + need=['库存','缺货','补货','安全库存'] + insert_pos=hard_end + for kw in need: + if kw not in existing: + lines.insert(insert_pos, ' - '+kw) + insert_pos+=1 + +# Ensure site keywords contain inventory terms +site_kw_start=None +site_kw_end=None +for i,l in enumerate(lines): + if l.strip()=='keywords:' and i>0 and lines[i-1].strip()=='- 物流与安装协同分析': + site_kw_start=i + for j in range(i+1,len(lines)): + if lines[j].strip()=='login_required: false': + site_kw_end=j + break + break +if site_kw_start is not None and site_kw_end is not None: + existing=set(l.strip().lstrip('-').strip() for l in lines[site_kw_start:site_kw_end] if l.strip().startswith('- ')) + need=['库存','缺货','补货','安全库存'] + insert_pos=site_kw_end + for kw in need: + if kw not in existing: + lines.insert(insert_pos, ' - '+kw) + insert_pos+=1 + +p.write_text('\n'.join(lines)+'\n',encoding='utf-8') +print('updated',p) diff --git a/openclaw-caddy-local-root.crt b/openclaw-caddy-local-root.crt new file mode 100644 index 0000000..a5465c9 --- /dev/null +++ b/openclaw-caddy-local-root.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBpDCCAUmgAwIBAgIQANEkK1m3v+0fBES3/FsNCzAKBggqhkjOPQQDAjAwMS4w +LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI2IEVDQyBSb290MB4X +DTI2MDIwODAyMTExMloXDTM1MTIxODAyMTExMlowMDEuMCwGA1UEAxMlQ2FkZHkg +TG9jYWwgQXV0aG9yaXR5IC0gMjAyNiBFQ0MgUm9vdDBZMBMGByqGSM49AgEGCCqG +SM49AwEHA0IABA6AXOtkhlMavSBG9ZKAhVl6cqcjYfdSGZRNZweHSTGaWFUyp+2z +kcYEXLNFPL7M8TpDfSBOPKM1P4vIdfBjJhKjRTBDMA4GA1UdDwEB/wQEAwIBBjAS +BgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQWBBSaSmQ0hDEj0WAnSVCtZ80wsWii +WDAKBggqhkjOPQQDAgNJADBGAiEAyv1iBqcTKsMWTPYOjbGkeyYXCLIlIbEWznSx +VRKhL6gCIQDVW8oVdtANrvqtwpJNnVvOeXO43JeOT9+Qx56z2UZK6A== +-----END CERTIFICATE----- diff --git a/openclaw-log-observer b/openclaw-log-observer new file mode 100755 index 0000000..fde10fa --- /dev/null +++ b/openclaw-log-observer @@ -0,0 +1,893 @@ +#!/usr/bin/env python3 +import argparse +import datetime as dt +import hashlib +import json +import os +import re +import subprocess +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +DEFAULT_APP_TOKEN = "GC2Mb4TXwad8ZusStBecLNg8nCe" +DEFAULT_TABLE_LINK = "https://acnuoqgha8fk.feishu.cn/base/GC2Mb4TXwad8ZusStBecLNg8nCe" +OPENCLAW_CONFIG = "/root/.openclaw/openclaw.json" +SESSIONS_DIR = Path("/root/.openclaw/agents/main/sessions") +REPORT_DIR = Path("/var/log/openclaw/log-observer") + +REQUIRED_FIELDS = [ + "记录类型", + "批次ID", + "巡检时间", + "日志范围(分钟)", + "日志总行数", + "OpenClaw调用次数", + "成功回复次数", + "未完成会话数", + "错误总数", + "网络错误数", + "解析错误数", + "重复回调数", + "问题类型", + "会话ID", + "问题摘要", + "建议动作", + "自动回放状态", + "自动回放会话", + "自动回放摘要", + "状态", +] + +ISSUE_PATTERNS = { + "openclaw_call_failed": re.compile(r"OpenClaw 调用失败|openclaw call timeout|OpenClaw 调用超时", re.I), + "parse_failed": re.compile(r"结果解析失败|parse openclaw output failed", re.I), + "network_error": re.compile(r"network exception|keepalive ping timeout|timed out during opening handshake", re.I), + "reply_failed": re.compile(r"reply failed: session=|failed to send final reply", re.I), + "traceback": re.compile(r"Traceback \(most recent call last\)", re.I), +} + +DIALOG_PATTERNS = { + "dialog_tool_noise": re.compile( + r"NO TOOLBeenCalled|without a preceding text-to-speech|tts\s*Completed|结果解析失败|原始输出:", + re.I, + ), + "dialog_blocked_response": re.compile( + r"权限不足|未登录|无法直接从页面抓取|需要登录后的操作|工具版本不支持点击交互|device identity required|网关令牌缺失|需要配对", + re.I, + ), + "dialog_meta_leak": re.compile(r"\[plugins\]|Registered\s+\w+\s+tool|runId\"\s*:\s*\"", re.I), +} + +CALL_RE = re.compile(r"openclaw call: session=([^\s]+)") +REPLY_RE = re.compile(r"reply sent: session=([^\s]+)") +DUP_RE = re.compile(r"duplicate callback ignored", re.I) +TS_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}T[^\s]+)\s+") +SESSION_RE = re.compile(r"session=([^\s,]+)") + + +def _run(cmd: List[str], timeout: int = 120) -> subprocess.CompletedProcess: + return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + + +def _http_json(method: str, url: str, headers: Dict[str, str], payload: Dict = None) -> Dict: + data = None + req_headers = dict(headers) + if payload is not None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req_headers["Content-Type"] = "application/json; charset=utf-8" + + req = urllib.request.Request(url, data=data, headers=req_headers, method=method) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + raw = resp.read().decode("utf-8") + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="ignore") + raise RuntimeError(f"HTTP {e.code} {url}: {body[:1000]}") from e + except Exception as e: + raise RuntimeError(f"request failed {method} {url}: {e}") from e + + +def _load_feishu_app() -> Tuple[str, str]: + app_id = os.getenv("FEISHU_APP_ID", "").strip() + app_secret = os.getenv("FEISHU_APP_SECRET", "").strip() + if app_id and app_secret: + return app_id, app_secret + + cfg_path = Path(OPENCLAW_CONFIG) + if not cfg_path.exists(): + raise RuntimeError("missing feishu credentials (env and openclaw.json both unavailable)") + + cfg = json.loads(cfg_path.read_text(encoding="utf-8")) + ch = (cfg.get("channels") or {}).get("feishu") or {} + app_id = (ch.get("appId") or "").strip() + app_secret = (ch.get("appSecret") or "").strip() + if not app_id or not app_secret: + raise RuntimeError("feishu appId/appSecret is empty in openclaw.json") + return app_id, app_secret + + +def _tenant_token(app_id: str, app_secret: str) -> str: + url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" + res = _http_json("POST", url, headers={}, payload={"app_id": app_id, "app_secret": app_secret}) + if res.get("code") != 0: + raise RuntimeError(f"feishu auth failed: {res}") + token = res.get("tenant_access_token") + if not token: + raise RuntimeError("feishu auth succeeded but tenant_access_token missing") + return token + + +def _create_table(token: str, app_token: str, table_name: str) -> str: + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables" + res = _http_json( + "POST", + url, + headers={"Authorization": f"Bearer {token}"}, + payload={"table": {"name": table_name[:100]}}, + ) + if res.get("code") != 0: + raise RuntimeError(f"create table failed ({table_name}): {res}") + + data = res.get("data") or {} + table_id = data.get("table_id") or (data.get("table") or {}).get("table_id") or "" + if not table_id: + raise RuntimeError(f"create table succeeded but table_id missing: {res}") + return table_id + + +def _list_fields(token: str, app_token: str, table_id: str) -> Dict[str, str]: + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields?page_size=500" + res = _http_json("GET", url, headers={"Authorization": f"Bearer {token}"}) + if res.get("code") != 0: + raise RuntimeError(f"list fields failed: {res}") + out: Dict[str, str] = {} + for it in ((res.get("data") or {}).get("items") or []): + name = it.get("field_name") + fid = it.get("field_id") + if name and fid: + out[name] = fid + return out + + +def _create_field(token: str, app_token: str, table_id: str, field_name: str) -> None: + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields" + res = _http_json( + "POST", + url, + headers={"Authorization": f"Bearer {token}"}, + payload={"field_name": field_name, "type": 1}, + ) + if res.get("code") != 0: + raise RuntimeError(f"create field failed ({field_name}): {res}") + + +def _ensure_fields(token: str, app_token: str, table_id: str) -> None: + fields = _list_fields(token, app_token, table_id) + for name in REQUIRED_FIELDS: + if name not in fields: + _create_field(token, app_token, table_id, name) + time.sleep(0.3) + + +def _create_record(token: str, app_token: str, table_id: str, fields: Dict[str, str]) -> None: + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records" + res = _http_json( + "POST", + url, + headers={"Authorization": f"Bearer {token}"}, + payload={"fields": fields}, + ) + if res.get("code") != 0: + raise RuntimeError(f"create record failed: {res}") + + +def _build_table_link(base_link: str, app_token: str, table_id: str) -> str: + if not base_link: + return f"https://acnuoqgha8fk.feishu.cn/base/{app_token}?table={table_id}" + parsed = urllib.parse.urlsplit(base_link) + query_pairs = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True) + query_pairs = [(k, v) for (k, v) in query_pairs if k.lower() not in {"table", "view"}] + query_pairs.append(("table", table_id)) + query = urllib.parse.urlencode(query_pairs) + return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, parsed.path, query, parsed.fragment)) + + +def _extract_text_from_openclaw(stdout: str) -> str: + raw = (stdout or "").strip() + if not raw: + return "" + + decoder = json.JSONDecoder() + data = None + idx = raw.find("{") + while idx != -1: + try: + data, _ = decoder.raw_decode(raw[idx:]) + break + except Exception: + idx = raw.find("{", idx + 1) + + if not isinstance(data, dict): + return "" + payloads = ((data.get("result") or {}).get("payloads") or []) + text_items = [p.get("text", "") for p in payloads if isinstance(p, dict)] + return "\n".join(t for t in text_items if t).strip() + + +def _collect_logs(service: str, since_minutes: int) -> List[str]: + cmd = [ + "journalctl", + "-u", + service, + "--since", + f"{since_minutes} min ago", + "--no-pager", + "-o", + "short-iso", + ] + proc = _run(cmd) + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "journalctl failed").strip() + raise RuntimeError(f"collect logs failed: {err}") + return [line.rstrip("\n") for line in (proc.stdout or "").splitlines() if line.strip()] + + +def _extract_session(line: str) -> str: + for regex in (CALL_RE, REPLY_RE, SESSION_RE): + m = regex.search(line) + if m: + return m.group(1) + return "" + + +def _analyze_logs(lines: List[str]) -> Dict: + call_count = 0 + reply_count = 0 + duplicate_count = 0 + issue_counts = {k: 0 for k in ISSUE_PATTERNS} + calls_by_session: Dict[str, int] = {} + replies_by_session: Dict[str, int] = {} + issues: List[Dict] = [] + + for line in lines: + m = CALL_RE.search(line) + if m: + call_count += 1 + session = m.group(1) + calls_by_session[session] = calls_by_session.get(session, 0) + 1 + + m = REPLY_RE.search(line) + if m: + reply_count += 1 + session = m.group(1) + replies_by_session[session] = replies_by_session.get(session, 0) + 1 + + if DUP_RE.search(line): + duplicate_count += 1 + + for issue_type, patt in ISSUE_PATTERNS.items(): + if patt.search(line): + issue_counts[issue_type] += 1 + ts_match = TS_RE.match(line) + issues.append( + { + "type": issue_type, + "timestamp": ts_match.group(1) if ts_match else "", + "line": line[-600:], + "session": _extract_session(line), + } + ) + break + + unresolved_sessions = [] + for session, cnt in calls_by_session.items(): + rc = replies_by_session.get(session, 0) + if cnt > rc: + unresolved_sessions.append({"session": session, "pending_calls": cnt - rc}) + + unresolved_sessions = sorted(unresolved_sessions, key=lambda x: (-x["pending_calls"], x["session"])) + + return { + "line_count": len(lines), + "call_count": call_count, + "reply_count": reply_count, + "duplicate_count": duplicate_count, + "issue_counts": issue_counts, + "issues": issues, + "unresolved_sessions": unresolved_sessions, + } + + +def _collect_session_ids(lines: List[str]) -> List[str]: + seen = set() + out = [] + for line in lines: + sid = _extract_session(line) + if sid and sid not in seen: + seen.add(sid) + out.append(sid) + return out + + +def _parse_iso_utc(value: str) -> Optional[dt.datetime]: + if not value: + return None + try: + if value.endswith("Z"): + value = value[:-1] + "+00:00" + parsed = dt.datetime.fromisoformat(value) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=dt.timezone.utc) + return parsed.astimezone(dt.timezone.utc) + except Exception: + return None + + +def _extract_content_text(content) -> str: + if isinstance(content, str): + return content.strip() + if isinstance(content, list): + parts = [] + for item in content: + if not isinstance(item, dict): + continue + if item.get("type") != "text": + continue + text = item.get("text") + if text: + parts.append(str(text)) + return "\n".join(parts).strip() + return "" + + +def _normalize_text(text: str) -> str: + return re.sub(r"[\s,,。.!!??::~~`'\"\-_/\\]+", "", text or "").lower() + + +def _dialog_issue( + issue_type: str, + session_id: str, + timestamp: str, + user_text: str, + assistant_text: str, +) -> Dict: + user_short = (user_text or "").replace("\n", " ")[:120] + assistant_short = (assistant_text or "").replace("\n", " ")[:320] + line = f"user={user_short} | assistant={assistant_short}" + return { + "type": issue_type, + "timestamp": timestamp, + "line": line, + "session": session_id, + } + + +def _analyze_dialog_anomalies(lines: List[str], since_minutes: int) -> Dict: + session_ids = _collect_session_ids(lines) + cutoff = dt.datetime.now(dt.timezone.utc) - dt.timedelta(minutes=since_minutes) + + issue_counts = {k: 0 for k in DIALOG_PATTERNS} + issue_counts["dialog_repeat_reply"] = 0 + issue_counts["dialog_large_code_dump"] = 0 + issues: List[Dict] = [] + inspected = 0 + + for session_id in session_ids: + session_file = SESSIONS_DIR / f"{session_id}.jsonl" + if not session_file.exists() or not session_file.is_file(): + continue + + inspected += 1 + last_user_text = "" + last_assistant_norm = "" + + try: + with session_file.open("r", encoding="utf-8") as fh: + for raw in fh: + raw = raw.strip() + if not raw: + continue + try: + obj = json.loads(raw) + except Exception: + continue + + if obj.get("type") != "message": + continue + + msg = obj.get("message") or {} + role = (msg.get("role") or "").lower() + content_text = _extract_content_text(msg.get("content")) + if not content_text: + continue + + ts_raw = obj.get("timestamp") or "" + ts_dt = _parse_iso_utc(ts_raw) + if ts_dt and ts_dt < cutoff: + continue + ts_text = ts_raw or "" + + if role == "user": + last_user_text = content_text + continue + if role != "assistant": + continue + + normalized = _normalize_text(content_text) + for issue_type, patt in DIALOG_PATTERNS.items(): + if patt.search(content_text): + issue_counts[issue_type] += 1 + issues.append(_dialog_issue(issue_type, session_id, ts_text, last_user_text, content_text)) + break + + if normalized and last_assistant_norm and normalized == last_assistant_norm and len(normalized) >= 24: + issue_counts["dialog_repeat_reply"] += 1 + issues.append(_dialog_issue("dialog_repeat_reply", session_id, ts_text, last_user_text, content_text)) + last_assistant_norm = normalized + + if "```" in content_text: + code_line_count = sum(1 for ln in content_text.splitlines() if ln.strip()) + if code_line_count >= 24: + issue_counts["dialog_large_code_dump"] += 1 + issues.append(_dialog_issue("dialog_large_code_dump", session_id, ts_text, last_user_text, content_text)) + except Exception: + issues.append( + { + "type": "dialog_session_parse_failed", + "timestamp": dt.datetime.now().isoformat(), + "line": f"session file parse failed: {session_file}", + "session": session_id, + } + ) + + return { + "dialog_issue_counts": issue_counts, + "dialog_issues": issues, + "dialog_inspected_sessions": inspected, + } + + +def _last_user_prompt(session_id: str) -> str: + p = SESSIONS_DIR / f"{session_id}.jsonl" + if not p.exists() or p.stat().st_size == 0: + return "" + + prompt = "" + try: + with p.open("r", encoding="utf-8") as fh: + for raw in fh: + raw = raw.strip() + if not raw: + continue + try: + obj = json.loads(raw) + except Exception: + continue + if obj.get("type") != "message": + continue + message = obj.get("message") or {} + role = (message.get("role") or "").lower() + if role != "user": + continue + content = _extract_content_text(message.get("content")) + if content: + prompt = content + except Exception: + return "" + return prompt[:1200] + + +def _is_safe_readonly_prompt(prompt: str) -> bool: + if not prompt: + return False + lowered = prompt.lower() + dangerous = ["删除", "drop ", "truncate", "update ", "insert ", "修改", "推送", "发送", "写入", "支付", "转账"] + if any(k in lowered for k in dangerous): + return False + read_only = ["查询", "分析", "检查", "统计", "汇总", "搜索", "报表", "售后", "库存", "sku", "店铺"] + return any(k in lowered for k in read_only) + + +def _openclaw_replay(prompt: str, timeout: int = 240) -> Tuple[bool, str, str]: + replay_session = f"observer-replay-{int(time.time())}-{hashlib.sha1(prompt.encode('utf-8')).hexdigest()[:6]}" + cmd = [ + "/usr/bin/openclaw", + "agent", + "--session-id", + replay_session, + "--message", + prompt, + "--json", + "--timeout", + str(timeout), + ] + proc = _run(cmd, timeout=timeout + 20) + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "openclaw replay failed").strip() + return False, replay_session, err[:300] + + text = _extract_text_from_openclaw(proc.stdout or "") + if not text: + text = (proc.stdout or "")[:300] + ok = "调用失败" not in text and "超时" not in text and "解析失败" not in text + summary = text.replace("\n", " ")[:260] + return ok, replay_session, summary + + +def _total_issue_count(analysis: Dict) -> int: + base = sum((analysis.get("issue_counts") or {}).values()) + dialog = sum((analysis.get("dialog_issue_counts") or {}).values()) + return base + dialog + + +def _openclaw_observer_comment(analysis: Dict, top_lines: List[str], timeout: int = 180) -> str: + payload = { + "summary": { + "line_count": analysis["line_count"], + "call_count": analysis["call_count"], + "reply_count": analysis["reply_count"], + "duplicate_count": analysis["duplicate_count"], + "issue_counts": analysis["issue_counts"], + "dialog_issue_counts": analysis.get("dialog_issue_counts", {}), + "dialog_inspected_sessions": analysis.get("dialog_inspected_sessions", 0), + "unresolved_sessions": analysis["unresolved_sessions"][:10], + }, + "samples": top_lines[:20], + } + + prompt = ( + "你是日志巡检分身。根据以下 JSON 生成简短巡检结论,输出三段:" + "\n1) 主要问题(最多3条)" + "\n2) 对主机器人的修复建议(最多5条,必须可执行)" + "\n3) 优先级最高的一条修复动作" + "\n请重点覆盖对话返回异常(如无关输出、工具噪音、重复回复)。" + "\n请用中文、短句、不要废话。\n\n" + + json.dumps(payload, ensure_ascii=False) + ) + + cmd = [ + "/usr/bin/openclaw", + "agent", + "--session-id", + "log-observer-main", + "--message", + prompt, + "--json", + "--timeout", + str(timeout), + ] + proc = _run(cmd, timeout=timeout + 20) + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "openclaw observer failed").strip() + return f"分身分析失败: {err[:300]}" + text = _extract_text_from_openclaw(proc.stdout or "") + return text or "分身分析完成,但未返回文本。" + + +def _recommendation(issue_type: str) -> str: + mapping = { + "network_error": "检查网络抖动与 websocket keepalive,建议提高重连退避并增加心跳监控。", + "parse_failed": "统一 OpenClaw 输出解析入口,先剥离插件日志再解码 JSON。", + "openclaw_call_failed": "检查模型/API可用性与超时阈值,必要时降级到本地简答模式。", + "reply_failed": "检查 ActionCard 权限,失败时强制文本兜底避免无回复。", + "traceback": "排查异常栈并加防护,避免同类异常重复触发。", + "dialog_tool_noise": "在回复前增加输出清洗,拦截 tool 元信息/tts 噪音文本。", + "dialog_blocked_response": "对登录/权限失败加自动重试和明确下一步,不直接抛给用户。", + "dialog_meta_leak": "过滤插件注册/运行元数据,用户侧只保留结论。", + "dialog_repeat_reply": "对同会话相同答复加内容去重,避免重复发送。", + "dialog_large_code_dump": "非代码任务启用摘要模式,限制长代码块直接外发。", + "dialog_session_parse_failed": "检查 session 文件格式和异常记录,保证可解析。", + } + return mapping.get(issue_type, "检查对应日志并补充防护与重试策略。") + + +def _write_report(batch_id: str, since_minutes: int, analysis: Dict, observer_text: str, replay_rows: List[Dict], lines: List[str]) -> Path: + REPORT_DIR.mkdir(parents=True, exist_ok=True) + report_path = REPORT_DIR / f"{batch_id}.md" + + issues_top = analysis["issues"][:16] + unresolved_top = analysis["unresolved_sessions"][:12] + dialog_top = (analysis.get("dialog_issues") or [])[:16] + + out = [] + out.append(f"# 日志巡检报告 {batch_id}") + out.append("") + out.append(f"- 巡检时间: {dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + out.append(f"- 日志范围: 最近 {since_minutes} 分钟") + out.append(f"- 日志总行数: {analysis['line_count']}") + out.append(f"- OpenClaw调用/成功回复: {analysis['call_count']} / {analysis['reply_count']}") + out.append(f"- 未完成会话: {len(analysis['unresolved_sessions'])}") + out.append(f"- 错误总数: {_total_issue_count(analysis)}") + out.append(f"- 对话巡检会话数: {analysis.get('dialog_inspected_sessions', 0)}") + out.append(f"- 对话内容异常数: {sum((analysis.get('dialog_issue_counts') or {}).values())}") + out.append("") + out.append("## 分身结论") + out.append("") + out.append(observer_text.strip() or "无") + out.append("") + + out.append("## 问题明细") + out.append("") + if not issues_top: + out.append("- 无错误问题") + else: + for idx, row in enumerate(issues_top, start=1): + out.append(f"{idx}. [{row.get('type')}] session={row.get('session') or '-'}") + out.append(f" - 时间: {row.get('timestamp') or '-'}") + out.append(f" - 日志: {row.get('line')}") + out.append(f" - 建议: {_recommendation(row.get('type') or '')}") + + out.append("") + out.append("## 对话内容异常") + out.append("") + if not dialog_top: + out.append("- 无") + else: + for idx, row in enumerate(dialog_top, start=1): + out.append(f"{idx}. [{row.get('type')}] session={row.get('session') or '-'}") + out.append(f" - 时间: {row.get('timestamp') or '-'}") + out.append(f" - 内容: {row.get('line')}") + out.append(f" - 建议: {_recommendation(row.get('type') or '')}") + + out.append("") + out.append("## 未完成会话") + out.append("") + if not unresolved_top: + out.append("- 无") + else: + for row in unresolved_top: + out.append(f"- {row['session']}: pending_calls={row['pending_calls']}") + + out.append("") + out.append("## 自动回放") + out.append("") + if not replay_rows: + out.append("- 未执行") + else: + for row in replay_rows: + out.append(f"- {row['session']} -> {row['status']} ({row['replay_session']}) {row['summary']}") + + out.append("") + out.append("## 日志样本") + out.append("") + for line in lines[-40:]: + out.append(f"- {line[-800:]}") + + report_path.write_text("\n".join(out) + "\n", encoding="utf-8") + latest = REPORT_DIR / "latest.md" + latest.write_text(report_path.read_text(encoding="utf-8"), encoding="utf-8") + return report_path + + +def _push_to_feishu( + batch_id: str, + since_minutes: int, + analysis: Dict, + observer_text: str, + replay_rows: List[Dict], + app_token: str, + base_link: str, + shared_table: bool, +) -> Tuple[str, str]: + app_id, app_secret = _load_feishu_app() + token = _tenant_token(app_id, app_secret) + + table_name = f"日志巡检-{dt.datetime.now().strftime('%m%d-%H%M')}" + table_id = "" + if shared_table: + parsed = urllib.parse.urlsplit(base_link or "") + query = dict(urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)) + table_id = query.get("table", "").strip() + if not table_id: + raise RuntimeError("shared-table 模式下未提供 table_id") + else: + table_id = _create_table(token, app_token, table_name) + + _ensure_fields(token, app_token, table_id) + now_text = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + _create_record( + token, + app_token, + table_id, + { + "记录类型": "summary", + "批次ID": batch_id, + "巡检时间": now_text, + "日志范围(分钟)": str(since_minutes), + "日志总行数": str(analysis["line_count"]), + "OpenClaw调用次数": str(analysis["call_count"]), + "成功回复次数": str(analysis["reply_count"]), + "未完成会话数": str(len(analysis["unresolved_sessions"])), + "错误总数": str(_total_issue_count(analysis)), + "网络错误数": str(analysis["issue_counts"].get("network_error", 0)), + "解析错误数": str(analysis["issue_counts"].get("parse_failed", 0)), + "重复回调数": str(analysis["duplicate_count"]), + "问题类型": "巡检汇总", + "会话ID": "-", + "问题摘要": ( + observer_text[:5000] + + "\n\n" + + f"对话巡检会话数={analysis.get('dialog_inspected_sessions', 0)}, 对话内容异常数={sum((analysis.get('dialog_issue_counts') or {}).values())}" + )[:8000], + "建议动作": "按优先级逐条修复,修复后观察下一周期是否下降。", + "自动回放状态": "已执行" if replay_rows else "未执行", + "自动回放会话": str(len(replay_rows)), + "自动回放摘要": "; ".join([f"{r['session']}:{r['status']}" for r in replay_rows])[:8000], + "状态": "ok", + }, + ) + + for row in analysis["issues"][:40]: + _create_record( + token, + app_token, + table_id, + { + "记录类型": "issue", + "批次ID": batch_id, + "巡检时间": row.get("timestamp") or now_text, + "日志范围(分钟)": str(since_minutes), + "日志总行数": "", + "OpenClaw调用次数": "", + "成功回复次数": "", + "未完成会话数": "", + "错误总数": "", + "网络错误数": "", + "解析错误数": "", + "重复回调数": "", + "问题类型": row.get("type") or "", + "会话ID": row.get("session") or "", + "问题摘要": row.get("line", "")[:8000], + "建议动作": _recommendation(row.get("type") or ""), + "自动回放状态": "", + "自动回放会话": "", + "自动回放摘要": "", + "状态": "pending", + }, + ) + + for row in replay_rows[:20]: + _create_record( + token, + app_token, + table_id, + { + "记录类型": "replay", + "批次ID": batch_id, + "巡检时间": now_text, + "日志范围(分钟)": str(since_minutes), + "日志总行数": "", + "OpenClaw调用次数": "", + "成功回复次数": "", + "未完成会话数": "", + "错误总数": "", + "网络错误数": "", + "解析错误数": "", + "重复回调数": "", + "问题类型": "auto-replay", + "会话ID": row.get("session") or "", + "问题摘要": row.get("prompt", "")[:8000], + "建议动作": "若回放成功,检查主机器人同类问题是否已恢复。", + "自动回放状态": row.get("status", ""), + "自动回放会话": row.get("replay_session", ""), + "自动回放摘要": row.get("summary", "")[:8000], + "状态": "ok" if row.get("status") == "success" else "failed", + }, + ) + + table_link = _build_table_link(base_link, app_token, table_id) + return table_id, table_link + + +def main() -> int: + parser = argparse.ArgumentParser(description="OpenClaw log observer") + parser.add_argument("--service", default="openclaw-dingtalk-bridge.service") + parser.add_argument("--since-minutes", type=int, default=60) + parser.add_argument("--push-feishu", action="store_true") + parser.add_argument("--shared-table", action="store_true") + parser.add_argument("--app-token", default=DEFAULT_APP_TOKEN) + parser.add_argument("--table-link", default=DEFAULT_TABLE_LINK) + parser.add_argument("--replay-failures", action="store_true") + parser.add_argument("--replay-limit", type=int, default=2) + args = parser.parse_args() + + since_minutes = max(5, min(args.since_minutes, 24 * 60)) + batch_id = dt.datetime.now().strftime("%Y%m%d-%H%M%S") + + try: + lines = _collect_logs(args.service, since_minutes) + analysis = _analyze_logs(lines) + dialog_result = _analyze_dialog_anomalies(lines, since_minutes) + analysis.update(dialog_result) + analysis["issues"].extend(dialog_result.get("dialog_issues", [])) + except Exception as e: + print("STATUS=error") + print(f"ERROR={e}") + return 2 + + issue_lines = [row["line"] for row in analysis["issues"][:20]] + observer_text = _openclaw_observer_comment(analysis, issue_lines) + + replay_rows: List[Dict] = [] + if args.replay_failures: + for item in analysis["unresolved_sessions"][: max(0, args.replay_limit)]: + session_id = item["session"] + prompt = _last_user_prompt(session_id) + if not _is_safe_readonly_prompt(prompt): + replay_rows.append( + { + "session": session_id, + "prompt": prompt[:200], + "status": "skipped", + "replay_session": "", + "summary": "prompt为空或非只读任务,跳过", + } + ) + continue + ok, replay_session, summary = _openclaw_replay(prompt) + replay_rows.append( + { + "session": session_id, + "prompt": prompt[:500], + "status": "success" if ok else "failed", + "replay_session": replay_session, + "summary": summary, + } + ) + + report_path = _write_report(batch_id, since_minutes, analysis, observer_text, replay_rows, lines) + + table_id = "" + table_link = "" + if args.push_feishu: + try: + table_id, table_link = _push_to_feishu( + batch_id=batch_id, + since_minutes=since_minutes, + analysis=analysis, + observer_text=observer_text, + replay_rows=replay_rows, + app_token=args.app_token, + base_link=args.table_link, + shared_table=args.shared_table, + ) + except Exception as e: + print("STATUS=partial") + print(f"ERROR=push feishu failed: {e}") + print(f"BATCH_ID={batch_id}") + print(f"REPORT_PATH={report_path}") + print(f"LINE_COUNT={analysis['line_count']}") + print(f"ISSUE_COUNT={_total_issue_count(analysis)}") + print(f"UNRESOLVED_SESSIONS={len(analysis['unresolved_sessions'])}") + return 1 + + print("STATUS=ok") + print(f"BATCH_ID={batch_id}") + print(f"REPORT_PATH={report_path}") + print(f"LINE_COUNT={analysis['line_count']}") + print(f"CALL_COUNT={analysis['call_count']}") + print(f"REPLY_COUNT={analysis['reply_count']}") + print(f"ISSUE_COUNT={_total_issue_count(analysis)}") + print(f"NETWORK_ERRORS={analysis['issue_counts'].get('network_error', 0)}") + print(f"PARSE_ERRORS={analysis['issue_counts'].get('parse_failed', 0)}") + print(f"DUPLICATE_CALLBACKS={analysis['duplicate_count']}") + print(f"DIALOG_INSPECTED_SESSIONS={analysis.get('dialog_inspected_sessions', 0)}") + print(f"DIALOG_ANOMALIES={sum((analysis.get('dialog_issue_counts') or {}).values())}") + print(f"UNRESOLVED_SESSIONS={len(analysis['unresolved_sessions'])}") + print(f"REPLAY_ROWS={len(replay_rows)}") + if table_id: + print(f"TABLE_ID={table_id}") + if table_link: + print(f"TABLE_LINK={table_link}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/patch_bot_run_openclaw.py b/patch_bot_run_openclaw.py new file mode 100644 index 0000000..1edbc22 --- /dev/null +++ b/patch_bot_run_openclaw.py @@ -0,0 +1,83 @@ +from pathlib import Path + +path = Path('/opt/openclaw-dingtalk-bridge/bot.py') +text = path.read_text(encoding='utf-8') +start_marker = ' def _run_openclaw(self, session_id: str, prompt: str) -> str:\n' +end_marker = '\n def _is_image_url(self, raw_url: str) -> bool:\n' +start = text.find(start_marker) +end = text.find(end_marker, start) +if start == -1 or end == -1: + raise SystemExit('failed to locate _run_openclaw boundaries') + +replacement = ''' def _run_openclaw(self, session_id: str, prompt: str) -> str: + cmd = [ + self.openclaw_bin, + "agent", + "--session-id", + session_id, + "--message", + prompt, + "--json", + "--timeout", + str(self.openclaw_timeout), + ] + self.logger.info("openclaw call: session=%s", session_id) + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.openclaw_timeout + 20, + ) + except subprocess.TimeoutExpired: + self.logger.error("openclaw call timeout: session=%s timeout=%ss", session_id, self.openclaw_timeout) + return f"OpenClaw 调用超时(>{self.openclaw_timeout}秒),请重试。" + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "openclaw error").strip() + return f"OpenClaw 调用失败: {err[:500]}" + + try: + raw_stdout = (proc.stdout or "").strip() + decoder = json.JSONDecoder() + data = None + parse_error = None + try: + data, _ = decoder.raw_decode(raw_stdout) + except Exception as exc: + parse_error = exc + idx = raw_stdout.find("{") + while idx != -1: + candidate = raw_stdout[idx:] + try: + data, _ = decoder.raw_decode(candidate) + break + except Exception: + idx = raw_stdout.find("{", idx + 1) + if data is None: + raise parse_error + + payloads = data.get("result", {}).get("payloads", []) + text_items = [p.get("text", "") for p in payloads if isinstance(p, dict)] + answer = "\\n".join(t for t in text_items if t).strip() + if not answer: + answer = "任务已执行,但未返回文本结果。" + + if self._is_market_prompt(prompt) and ( + "无法直接从页面抓取" in answer + or "需要登录后的操作" in answer + or "工具版本不支持点击交互" in answer + ): + subject = self._extract_market_subject(prompt) + fallback = self._run_market_brief(subject) + return f"已切换为市场分析直连模式({subject}):\\n\\n{fallback}" + + return answer + except Exception as exc: + self.logger.exception("parse openclaw output failed") + raw = (proc.stdout or "").strip() + return f"结果解析失败: {exc}.\\n原始输出: {raw[:500]}" +''' + +new_text = text[:start] + replacement + text[end:] +path.write_text(new_text, encoding='utf-8') +print('patched', path) diff --git a/product-info b/product-info new file mode 100755 index 0000000..82f6174 --- /dev/null +++ b/product-info @@ -0,0 +1,821 @@ +#!/usr/bin/env python3 +import argparse +import subprocess +import sys + + +def _sql_escape(value: str) -> str: + return value.replace("'", "''") + + +def build_sql(keyword: str, days: int, limit: int) -> str: + kw = _sql_escape(keyword.strip()) + return f""" +USE [JSDataMiddlePlatform]; +SET NOCOUNT ON; + +DECLARE @kw nvarchar(100) = N'{kw}'; +DECLARE @days int = {days}; +DECLARE @limit int = {limit}; +DECLARE @start_time datetime2 = DATEADD(day, -@days, GETDATE()); +DECLARE @end_time datetime2 = GETDATE(); +DECLARE @has_exact bit = 0; + +IF EXISTS (SELECT 1 FROM dbo.jst_sku WITH (NOLOCK) WHERE sku_id = @kw) + SET @has_exact = 1; + +IF OBJECT_ID('tempdb..#matched_sku') IS NOT NULL + DROP TABLE #matched_sku; + +SELECT TOP (300) + s.sku_id, + s.i_id AS style_code, + s.other_1 AS spu, + s.name AS sku_name, + s.short_name, + s.properties_value, + s.color, + s.item_type, + s.category, + s.brand, + s.vc_name, + s.supplier_name, + s.supplier_sku_id, + s.sku_code, + s.sale_price, + s.cost_price, + s.purchase_price, + s.market_price, + s.weight, + s.l, + s.w, + s.h, + s.unit, + s.labels, + s.other_2 AS board_type, + s.other_10 AS board_material, + s.other_3 AS default_warehouse, + s.other_5 AS sku_status, + s.other_7 AS product_no, + s.pic AS preview_pic_url, + s.pic_big AS preview_pic_big_url, + s.created, + s.modified, + CASE + WHEN s.sku_id = @kw THEN 0 + WHEN s.i_id = @kw THEN 1 + WHEN s.other_1 = @kw THEN 2 + WHEN s.other_7 = @kw THEN 3 + WHEN s.sku_id LIKE @kw + '%' THEN 4 + WHEN s.name LIKE @kw + '%' THEN 5 + ELSE 9 + END AS match_rank +INTO #matched_sku +FROM dbo.jst_sku AS s WITH (NOLOCK) +WHERE ISNULL(NULLIF(@kw, ''), '') <> '' + AND ( + (@has_exact = 1 AND s.sku_id = @kw) + OR ( + @has_exact = 0 + AND ( + s.sku_id LIKE '%' + @kw + '%' + OR s.i_id LIKE '%' + @kw + '%' + OR s.other_1 LIKE '%' + @kw + '%' + OR s.name LIKE '%' + @kw + '%' + OR s.short_name LIKE '%' + @kw + '%' + OR s.other_7 LIKE '%' + @kw + '%' + ) + ) + ) +ORDER BY + CASE + WHEN s.sku_id = @kw THEN 0 + WHEN s.i_id = @kw THEN 1 + WHEN s.other_1 = @kw THEN 2 + WHEN s.other_7 = @kw THEN 3 + WHEN s.sku_id LIKE @kw + '%' THEN 4 + WHEN s.name LIKE @kw + '%' THEN 5 + ELSE 9 + END, + s.modified DESC; + +-- 1) 产品基础信息(含SKU名称/成本/材质/预览图 + 扩展字段) +SELECT TOP (@limit) + sku_id, + sku_name, + short_name, + style_code, + spu, + product_no, + sku_code, + properties_value, + color, + item_type, + category, + brand, + supplier_name, + supplier_sku_id, + cost_price, + purchase_price, + sale_price, + market_price, + board_type, + board_material, + default_warehouse, + sku_status, + labels, + weight, + l, + w, + h, + unit, + preview_pic_url, + preview_pic_big_url, + created, + modified +FROM #matched_sku +ORDER BY match_rank ASC, modified DESC; + +IF OBJECT_ID('tempdb..#afs') IS NOT NULL + DROP TABLE #afs; + +SELECT + NULLIF(a.undefined10, '') AS sku_id, + ISNULL(NULLIF(a.undefined36, ''), 'unknown') AS sku_name, + ISNULL(NULLIF(a.undefined8, ''), 'unknown') AS shop_name, + NULLIF(a.undefined2, '') AS order_id, + ISNULL(NULLIF(a.undefined56, ''), '未标注') AS afs_type, + ISNULL(NULLIF(a.undefined58, ''), '未标注') AS afs_result, + ISNULL(NULLIF(a.undefined57, ''), '') AS native_reason, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')) AS order_qty, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')) AS shipped_qty, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined37, '')) AS pre_ship_apply_qty, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined40, '')) AS pre_ship_return_qty, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined46, '')) AS post_ship_apply_qty, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined49, '')) AS post_ship_return_qty, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount +INTO #afs +FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK) +WHERE a.createdAt >= @start_time + AND a.createdAt < @end_time + AND ISNULL(NULLIF(a.undefined2, ''), '') <> '' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%' + AND NULLIF(a.undefined10, '') IN (SELECT sku_id FROM #matched_sku); + +IF OBJECT_ID('tempdb..#afs_orders') IS NOT NULL + DROP TABLE #afs_orders; + +SELECT DISTINCT order_id +INTO #afs_orders +FROM #afs +WHERE ISNULL(order_id, '') <> ''; + +IF OBJECT_ID('tempdb..#chat_latest') IS NOT NULL + DROP TABLE #chat_latest; + +WITH chat_rank AS ( + SELECT + NULLIF(c.orderNo, '') AS order_id, + ISNULL(NULLIF(c.mainReason, ''), '') AS main_reason, + ISNULL(NULLIF(c.secondReason, ''), '') AS second_reason, + ISNULL(NULLIF(c.thirdReason, ''), '') AS third_reason, + c.createdAt, + ROW_NUMBER() OVER ( + PARTITION BY NULLIF(c.orderNo, '') + ORDER BY c.createdAt DESC, c.id DESC + ) AS rn + FROM dbo.pc_kfChatLog AS c WITH (NOLOCK) + INNER JOIN #afs_orders AS o + ON o.order_id = NULLIF(c.orderNo, '') +) +SELECT + order_id, + main_reason, + second_reason, + third_reason +INTO #chat_latest +FROM chat_rank +WHERE rn = 1; + +IF OBJECT_ID('tempdb..#afs_enriched') IS NOT NULL + DROP TABLE #afs_enriched; + +SELECT + af.*, + CASE + WHEN ISNULL(NULLIF(af.native_reason, ''), '') <> '' THEN af.native_reason + WHEN ISNULL(NULLIF(cl.main_reason, ''), '') <> '' THEN cl.main_reason + ELSE '未标注' + END AS reason_l1, + CASE + WHEN ISNULL(NULLIF(cl.second_reason, ''), '') <> '' THEN cl.second_reason + ELSE '未标注' + END AS reason_l2, + CASE + WHEN ISNULL(NULLIF(cl.third_reason, ''), '') <> '' THEN cl.third_reason + ELSE '未标注' + END AS reason_l3, + CASE + WHEN ISNULL(NULLIF(af.native_reason, ''), '') <> '' THEN 'afs_reason' + WHEN ISNULL(NULLIF(cl.main_reason, ''), '') <> '' THEN 'chat_main_reason' + ELSE 'unknown' + END AS reason_source +INTO #afs_enriched +FROM #afs AS af +LEFT JOIN #chat_latest AS cl + ON cl.order_id = af.order_id; + +-- 2) 最近N天产品出货/售后件数(件数优先) +SELECT TOP (@limit) + ae.sku_id, + MAX(COALESCE(ms.sku_name, ae.sku_name)) AS sku_name, + COUNT(*) AS afs_record_count, + COUNT(DISTINCT ae.order_id) AS order_count, + SUM(ISNULL(ae.order_qty, 0)) AS order_qty_sum, + SUM(ISNULL(ae.shipped_qty, 0)) AS shipped_qty_sum, + SUM(CASE WHEN ISNULL(ae.refund_amount, 0) > 0 THEN 1 ELSE 0 END) AS refund_order_count +FROM #afs_enriched AS ae +LEFT JOIN #matched_sku AS ms + ON ms.sku_id = ae.sku_id +GROUP BY ae.sku_id +ORDER BY shipped_qty_sum DESC, order_count DESC; + +-- 3) 最近N天该产品的店铺分布(件数) +SELECT TOP (@limit) + ae.shop_name, + COUNT(DISTINCT ae.order_id) AS order_count, + SUM(ISNULL(ae.order_qty, 0)) AS order_qty_sum, + SUM(ISNULL(ae.shipped_qty, 0)) AS shipped_qty_sum, + SUM(CASE WHEN ISNULL(ae.refund_amount, 0) > 0 THEN 1 ELSE 0 END) AS refund_order_count +FROM #afs_enriched AS ae +GROUP BY ae.shop_name +ORDER BY shipped_qty_sum DESC, order_count DESC; + +IF OBJECT_ID('tempdb..#freight') IS NOT NULL + DROP TABLE #freight; + +SELECT + NULLIF(f.sku_id, '') AS sku_id, + NULLIF(f.so_id, '') AS so_id, + ISNULL(NULLIF(f.receiver_state, ''), '未知') AS receiver_state, + CASE + WHEN REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(NULLIF(f.receiver_state,''),'未知'),'省',''),'市',''),'壮族自治区',''),'回族自治区',''),'维吾尔自治区',''),'自治区',''),'特别行政区',''),'地区','') + IN ('上海','江苏','浙江','安徽') THEN '一区' + WHEN REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(NULLIF(f.receiver_state,''),'未知'),'省',''),'市',''),'壮族自治区',''),'回族自治区',''),'维吾尔自治区',''),'自治区',''),'特别行政区',''),'地区','') + IN ('北京','天津','河北','山东','河南','湖北','湖南','江西','福建','广东') THEN '二区' + WHEN REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(NULLIF(f.receiver_state,''),'未知'),'省',''),'市',''),'壮族自治区',''),'回族自治区',''),'维吾尔自治区',''),'自治区',''),'特别行政区',''),'地区','') + IN ('辽宁','吉林','黑龙江','山西','陕西','四川','重庆','贵州','云南','广西','海南','内蒙古','甘肃','宁夏','青海','新疆','西藏') THEN '三区' + ELSE '未分区' + END AS freight_zone, + ISNULL(NULLIF(f.expressName, ''), 'unknown') AS express_name, + TRY_CONVERT(decimal(18, 2), f.freightCost) AS freight_cost, + TRY_CONVERT(decimal(18, 2), f.weight) AS freight_weight, + f.send_date +INTO #freight +FROM dbo.fys_jstFreightCostDetail AS f WITH (NOLOCK) +WHERE f.send_date >= @start_time + AND f.send_date < @end_time + AND ISNULL(NULLIF(f.sku_id, ''), '') <> '' + AND NULLIF(f.sku_id, '') IN (SELECT sku_id FROM #matched_sku); + +-- 4) 最近N天一区/二区/三区运费(按SKU) +SELECT TOP (@limit) + fr.sku_id, + fr.freight_zone, + COUNT(*) AS freight_record_count, + COUNT(DISTINCT fr.so_id) AS order_count, + SUM(ISNULL(fr.freight_cost, 0)) AS freight_cost_sum, + AVG(ISNULL(fr.freight_cost, 0)) AS avg_freight_cost, + AVG(ISNULL(fr.freight_weight, 0)) AS avg_weight +FROM #freight AS fr +GROUP BY fr.sku_id, fr.freight_zone +ORDER BY + CASE fr.freight_zone WHEN '一区' THEN 1 WHEN '二区' THEN 2 WHEN '三区' THEN 3 ELSE 9 END, + order_count DESC; + +-- 5) 最近N天省份运费TOP(按SKU) +SELECT TOP (@limit) + fr.sku_id, + fr.receiver_state, + COUNT(DISTINCT fr.so_id) AS order_count, + SUM(ISNULL(fr.freight_cost, 0)) AS freight_cost_sum, + AVG(ISNULL(fr.freight_cost, 0)) AS avg_freight_cost, + AVG(ISNULL(fr.freight_weight, 0)) AS avg_weight +FROM #freight AS fr +GROUP BY fr.sku_id, fr.receiver_state +ORDER BY order_count DESC, freight_cost_sum DESC; + +-- 6) 最近N天售后总览(该SKU) +SELECT TOP (@limit) + ae.sku_id, + COUNT(*) AS afs_record_count, + COUNT(DISTINCT ae.order_id) AS afs_order_count, + SUM(ISNULL(ae.shipped_qty, 0)) AS shipped_qty_sum, + SUM(ISNULL(ae.pre_ship_apply_qty, 0) + ISNULL(ae.post_ship_apply_qty, 0)) AS apply_qty_sum, + SUM(ISNULL(ae.pre_ship_return_qty, 0) + ISNULL(ae.post_ship_return_qty, 0)) AS return_qty_sum, + SUM(CASE WHEN ISNULL(ae.refund_amount, 0) > 0 THEN 1 ELSE 0 END) AS refund_order_count, + SUM(ISNULL(ae.refund_amount, 0)) AS refund_amount_sum +FROM #afs_enriched AS ae +GROUP BY ae.sku_id +ORDER BY afs_record_count DESC; + +-- 7) 最近N天售后类型/结果矩阵(该SKU) +SELECT TOP (@limit) + ae.sku_id, + ae.afs_type, + ae.afs_result, + COUNT(*) AS afs_record_count, + COUNT(DISTINCT ae.order_id) AS order_count, + SUM(ISNULL(ae.shipped_qty, 0)) AS shipped_qty_sum, + SUM(CASE WHEN ISNULL(ae.refund_amount, 0) > 0 THEN 1 ELSE 0 END) AS refund_order_count +FROM #afs_enriched AS ae +GROUP BY ae.sku_id, ae.afs_type, ae.afs_result +ORDER BY afs_record_count DESC, order_count DESC; + +-- 8) 最近N天售后原因TOP(该SKU) +SELECT TOP (@limit) + ae.sku_id, + ae.reason_l1, + ae.reason_l2, + ae.reason_l3, + ae.reason_source, + COUNT(*) AS afs_record_count, + COUNT(DISTINCT ae.order_id) AS order_count, + SUM(ISNULL(ae.shipped_qty, 0)) AS shipped_qty_sum, + SUM(CASE WHEN ISNULL(ae.refund_amount, 0) > 0 THEN 1 ELSE 0 END) AS refund_order_count +FROM #afs_enriched AS ae +GROUP BY ae.sku_id, ae.reason_l1, ae.reason_l2, ae.reason_l3, ae.reason_source +ORDER BY afs_record_count DESC, order_count DESC; + +IF OBJECT_ID('tempdb..#inv_latest') IS NOT NULL + DROP TABLE #inv_latest; + +WITH inv_rank AS ( + SELECT + NULLIF(i.sku_id, '') AS sku_id, + TRY_CONVERT(decimal(18, 2), i.qty) AS stock_qty, + TRY_CONVERT(decimal(18, 2), i.order_lock) AS order_lock_qty, + TRY_CONVERT(decimal(18, 2), i.pick_lock) AS pick_lock_qty, + TRY_CONVERT(decimal(18, 2), i.lock_qty) AS lock_qty, + TRY_CONVERT(decimal(18, 2), i.virtual_qty) AS virtual_qty, + TRY_CONVERT(decimal(18, 2), i.defective_qty) AS defective_qty, + TRY_CONVERT(decimal(18, 2), i.purchase_qty) AS purchase_qty, + TRY_CONVERT(decimal(18, 2), i.return_qty) AS return_qty, + TRY_CONVERT(decimal(18, 2), i.in_qty) AS in_qty, + TRY_CONVERT(decimal(18, 2), i.min_qty) AS min_qty, + TRY_CONVERT(decimal(18, 2), i.max_qty) AS max_qty, + i.wms_co_id, + i.name AS inventory_note, + COALESCE(i.updateTime, i.modified, i.createTime) AS inventory_snapshot_time, + ROW_NUMBER() OVER ( + PARTITION BY NULLIF(i.sku_id, '') + ORDER BY COALESCE(i.updateTime, i.modified, i.createTime) DESC, i.Id DESC + ) AS rn + FROM dbo.jst_inventoryInfo AS i WITH (NOLOCK) + WHERE NULLIF(i.sku_id, '') IN (SELECT sku_id FROM #matched_sku) +) +SELECT + sku_id, + stock_qty, + order_lock_qty, + pick_lock_qty, + lock_qty, + virtual_qty, + defective_qty, + purchase_qty, + return_qty, + in_qty, + min_qty, + max_qty, + wms_co_id, + inventory_note, + inventory_snapshot_time, + CASE + WHEN ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0) < 0 + THEN CAST(0 AS decimal(18, 2)) + ELSE ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0) + END AS available_qty +INTO #inv_latest +FROM inv_rank +WHERE rn = 1; + +IF OBJECT_ID('tempdb..#ship_base') IS NOT NULL + DROP TABLE #ship_base; + +SELECT + NULLIF(a.undefined10, '') AS sku_id, + NULLIF(a.undefined2, '') AS order_id, + NULLIF(a.undefined12, '') AS product_id, + NULLIF(a.undefined11, '') AS style_code_from_afs, + NULLIF(a.undefined96, '') AS product_no_from_afs, + ISNULL(NULLIF(a.undefined8, ''), 'unknown') AS shop_name, + CASE + WHEN CHARINDEX('-', ISNULL(NULLIF(a.undefined8, ''), '')) > 0 + THEN LEFT(ISNULL(NULLIF(a.undefined8, ''), ''), CHARINDEX('-', ISNULL(NULLIF(a.undefined8, ''), '')) - 1) + ELSE ISNULL(NULLIF(a.undefined8, ''), '未知渠道') + END AS shop_channel, + ISNULL(NULLIF(a.undefined66, ''), '') AS ship_warehouse_name, + COALESCE( + TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120), + TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120), + TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 111), + TRY_CONVERT(datetime2, a.createdAt) + ) AS ship_dt, + COALESCE( + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')), + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')), + CAST(0 AS decimal(18, 2)) + ) AS shipped_qty +INTO #ship_base +FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK) +WHERE NULLIF(a.undefined10, '') IN (SELECT sku_id FROM #matched_sku) + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%'; + +IF OBJECT_ID('tempdb..#ship_daily') IS NOT NULL + DROP TABLE #ship_daily; + +SELECT + sb.sku_id, + CAST(sb.ship_dt AS date) AS ship_date, + COUNT(DISTINCT sb.order_id) AS order_count, + SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum +INTO #ship_daily +FROM #ship_base AS sb +WHERE sb.ship_dt >= @start_time + AND sb.ship_dt < @end_time +GROUP BY sb.sku_id, CAST(sb.ship_dt AS date); + +IF OBJECT_ID('tempdb..#ship_stats') IS NOT NULL + DROP TABLE #ship_stats; + +SELECT + sd.sku_id, + SUM(CASE WHEN sd.ship_date >= CAST(DATEADD(day, -7, @end_time) AS date) THEN ISNULL(sd.shipped_qty_sum, 0) ELSE 0 END) AS shipped_qty_7d, + SUM(CASE WHEN sd.ship_date >= CAST(DATEADD(day, -30, @end_time) AS date) THEN ISNULL(sd.shipped_qty_sum, 0) ELSE 0 END) AS shipped_qty_30d, + MAX(sd.ship_date) AS last_ship_date +INTO #ship_stats +FROM #ship_daily AS sd +GROUP BY sd.sku_id; + +IF OBJECT_ID('tempdb..#sku_owner') IS NOT NULL + DROP TABLE #sku_owner; + +SELECT + ms.sku_id, + COALESCE( + NULLIF(pc_exact.purchaser, ''), + NULLIF(pc_style.purchaser, ''), + NULLIF(pc_base.purchaser, ''), + '未分配' + ) AS purchaser_name, + COALESCE( + NULLIF(po_exact.oper, ''), + NULLIF(po_style.oper, ''), + NULLIF(po_base.oper, ''), + NULLIF(pc_exact.oper, ''), + NULLIF(pc_style.oper, ''), + NULLIF(pc_base.oper, ''), + '未分配' + ) AS spu_oper_name +INTO #sku_owner +FROM #matched_sku AS ms +OUTER APPLY ( + SELECT TOP 1 c.purchaser, c.oper + FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK) + WHERE NULLIF(c.spu, '') = NULLIF(ms.spu, '') + ORDER BY c.Id DESC +) AS pc_exact +OUTER APPLY ( + SELECT TOP 1 c.purchaser, c.oper + FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK) + WHERE NULLIF(c.spu, '') = NULLIF(ms.style_code, '') + ORDER BY c.Id DESC +) AS pc_style +OUTER APPLY ( + SELECT TOP 1 c.purchaser, c.oper + FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK) + WHERE NULLIF(c.spu, '') = CASE + WHEN CHARINDEX('K', ISNULL(ms.spu, '')) > 0 + THEN LEFT(ms.spu, CHARINDEX('K', ms.spu) - 1) + ELSE NULL + END + ORDER BY c.Id DESC +) AS pc_base +OUTER APPLY ( + SELECT TOP 1 o.oper + FROM dbo.op_operManage AS o WITH (NOLOCK) + WHERE NULLIF(o.spu, '') = NULLIF(ms.spu, '') + ORDER BY o.Id DESC +) AS po_exact +OUTER APPLY ( + SELECT TOP 1 o.oper + FROM dbo.op_operManage AS o WITH (NOLOCK) + WHERE NULLIF(o.spu, '') = NULLIF(ms.style_code, '') + ORDER BY o.Id DESC +) AS po_style +OUTER APPLY ( + SELECT TOP 1 o.oper + FROM dbo.op_operManage AS o WITH (NOLOCK) + WHERE NULLIF(o.spu, '') = CASE + WHEN CHARINDEX('K', ISNULL(ms.spu, '')) > 0 + THEN LEFT(ms.spu, CHARINDEX('K', ms.spu) - 1) + ELSE NULL + END + ORDER BY o.Id DESC +) AS po_base; + +IF OBJECT_ID('tempdb..#tm_oper') IS NOT NULL + DROP TABLE #tm_oper; + +WITH tm_rank AS ( + SELECT + NULLIF(t.shopproid, '') AS product_id, + ISNULL(NULLIF(t.shopname, ''), 'unknown') AS shop_name, + NULLIF(t.oper, '') AS oper_name, + NULLIF(t.dept, '') AS dept_name, + ROW_NUMBER() OVER ( + PARTITION BY NULLIF(t.shopproid, ''), ISNULL(NULLIF(t.shopname, ''), 'unknown') + ORDER BY t.Id DESC + ) AS rn + FROM dbo.op_TargetManagement AS t WITH (NOLOCK) + WHERE NULLIF(t.shopproid, '') IS NOT NULL +) +SELECT + product_id, + shop_name, + oper_name, + dept_name +INTO #tm_oper +FROM tm_rank +WHERE rn = 1; + +IF OBJECT_ID('tempdb..#tm_oper_pid') IS NOT NULL + DROP TABLE #tm_oper_pid; + +SELECT + product_id, + MAX(oper_name) AS oper_name, + MAX(dept_name) AS dept_name +INTO #tm_oper_pid +FROM #tm_oper +GROUP BY product_id; + +IF OBJECT_ID('tempdb..#product_id_contact') IS NOT NULL + DROP TABLE #product_id_contact; + +SELECT + sb.sku_id, + ms.sku_name, + ISNULL(NULLIF(sb.product_id, ''), '(未标注)') AS product_id, + sb.shop_channel, + sb.shop_name, + COALESCE( + NULLIF(tm_shop.oper_name, ''), + NULLIF(tm_pid.oper_name, ''), + NULLIF(ow.spu_oper_name, ''), + '未分配' + ) AS operator_name, + COALESCE(NULLIF(ow.purchaser_name, ''), '未分配') AS purchaser_name, + COALESCE(NULLIF(ms.supplier_name, ''), '未标注') AS factory_name, + COALESCE(NULLIF(sb.ship_warehouse_name, ''), NULLIF(ms.default_warehouse, ''), NULLIF(inv.inventory_note, ''), '未标注') AS warehouse_name, + COUNT(DISTINCT sb.order_id) AS order_count_30d, + SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d +INTO #product_id_contact +FROM #ship_base AS sb +INNER JOIN #matched_sku AS ms + ON ms.sku_id = sb.sku_id +LEFT JOIN #sku_owner AS ow + ON ow.sku_id = sb.sku_id +LEFT JOIN #tm_oper AS tm_shop + ON tm_shop.product_id = NULLIF(sb.product_id, '') + AND tm_shop.shop_name = sb.shop_name +LEFT JOIN #tm_oper_pid AS tm_pid + ON tm_pid.product_id = NULLIF(sb.product_id, '') +LEFT JOIN #inv_latest AS inv + ON inv.sku_id = sb.sku_id +WHERE sb.ship_dt >= @start_time + AND sb.ship_dt < @end_time + AND ISNULL(NULLIF(sb.product_id, ''), '') <> '' +GROUP BY + sb.sku_id, + ms.sku_name, + ISNULL(NULLIF(sb.product_id, ''), '(未标注)'), + sb.shop_channel, + sb.shop_name, + COALESCE( + NULLIF(tm_shop.oper_name, ''), + NULLIF(tm_pid.oper_name, ''), + NULLIF(ow.spu_oper_name, ''), + '未分配' + ), + COALESCE(NULLIF(ow.purchaser_name, ''), '未分配'), + COALESCE(NULLIF(ms.supplier_name, ''), '未标注'), + COALESCE(NULLIF(sb.ship_warehouse_name, ''), NULLIF(ms.default_warehouse, ''), NULLIF(inv.inventory_note, ''), '未标注'); + +IF OBJECT_ID('tempdb..#risk_eval') IS NOT NULL + DROP TABLE #risk_eval; + +SELECT + ms.sku_id, + ms.sku_name, + inv.stock_qty, + inv.order_lock_qty, + inv.pick_lock_qty, + inv.lock_qty, + inv.available_qty, + inv.virtual_qty, + inv.defective_qty, + inv.purchase_qty, + inv.return_qty, + inv.in_qty, + inv.min_qty, + inv.max_qty, + inv.wms_co_id, + inv.inventory_snapshot_time, + ss.last_ship_date, + CAST(ISNULL(ss.shipped_qty_7d, 0) / 7.0 AS decimal(18, 2)) AS avg_daily_shipped_7d, + CAST(ISNULL(ss.shipped_qty_30d, 0) / 30.0 AS decimal(18, 2)) AS avg_daily_shipped_30d, + CAST( + CASE + WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN NULL + ELSE inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0) + END AS decimal(18, 2) + ) AS estimated_stockout_days, + 10 AS replenishment_lead_days, + CAST( + CASE + WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN 0 + WHEN inv.available_qty >= (ISNULL(ss.shipped_qty_30d, 0) / 30.0) * 10 THEN 0 + ELSE ((ISNULL(ss.shipped_qty_30d, 0) / 30.0) * 10) - inv.available_qty + END AS decimal(18, 2) + ) AS suggest_replenish_qty_to_10d, + CASE + WHEN inv.sku_id IS NULL THEN '无库存快照' + WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN '近30天无发货' + WHEN inv.available_qty <= 0 THEN '缺货/可用库存<=0' + WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= 10 THEN '补货紧急(<=10天)' + WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= 15 THEN '补货预警(10-15天)' + ELSE '库存安全' + END AS stock_risk_level, + CASE + WHEN inv.sku_id IS NULL THEN 0 + WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN 0 + WHEN inv.available_qty <= 0 THEN 1 + WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= 15 THEN 1 + ELSE 0 + END AS has_stock_risk +INTO #risk_eval +FROM #matched_sku AS ms +LEFT JOIN #inv_latest AS inv + ON inv.sku_id = ms.sku_id +LEFT JOIN #ship_stats AS ss + ON ss.sku_id = ms.sku_id; + +-- 9) 最近N天每日总发货件数(按SKU) +SELECT TOP (@limit) + sd.sku_id, + ms.sku_name, + sd.ship_date, + sd.order_count, + sd.shipped_qty_sum +FROM #ship_daily AS sd +LEFT JOIN #matched_sku AS ms + ON ms.sku_id = sd.sku_id +ORDER BY sd.ship_date DESC, sd.shipped_qty_sum DESC; + +-- 10) 库存风险预估(按SKU,补货安全线=10天) +SELECT TOP (@limit) + re.sku_id, + re.sku_name, + re.stock_qty, + re.order_lock_qty, + re.pick_lock_qty, + re.lock_qty, + re.available_qty, + re.virtual_qty, + re.defective_qty, + re.purchase_qty, + re.return_qty, + re.in_qty, + re.min_qty, + re.max_qty, + re.wms_co_id, + re.inventory_snapshot_time, + re.last_ship_date, + re.avg_daily_shipped_7d, + re.avg_daily_shipped_30d, + re.estimated_stockout_days, + re.replenishment_lead_days, + re.suggest_replenish_qty_to_10d, + re.stock_risk_level +FROM #risk_eval AS re +ORDER BY + CASE + WHEN re.stock_qty IS NULL THEN 9 + WHEN re.avg_daily_shipped_30d <= 0 THEN 8 + WHEN re.available_qty <= 0 THEN 1 + WHEN re.estimated_stockout_days <= 10 THEN 2 + WHEN re.estimated_stockout_days <= 15 THEN 3 + ELSE 4 + END, + re.estimated_stockout_days ASC, + re.sku_id ASC; + +-- 11) 近30天在售商品ID数量(按店铺渠道) +SELECT TOP (@limit) + sb.sku_id, + ms.sku_name, + sb.shop_channel, + COUNT(DISTINCT sb.product_id) AS sold_product_id_count_30d, + COUNT(DISTINCT sb.shop_name) AS shop_count_30d, + COUNT(DISTINCT sb.order_id) AS order_count_30d, + SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d +FROM #ship_base AS sb +INNER JOIN #matched_sku AS ms + ON ms.sku_id = sb.sku_id +WHERE sb.ship_dt >= @start_time + AND sb.ship_dt < @end_time + AND ISNULL(NULLIF(sb.product_id, ''), '') <> '' +GROUP BY sb.sku_id, ms.sku_name, sb.shop_channel +ORDER BY sold_product_id_count_30d DESC, shipped_qty_sum_30d DESC, sb.shop_channel ASC; + +-- 12) 商品ID对应店铺/渠道/运营人(用于通知) +SELECT TOP (@limit) + pic.sku_id, + pic.sku_name, + pic.product_id, + pic.shop_channel, + pic.shop_name, + pic.operator_name, + pic.purchaser_name, + pic.factory_name, + pic.warehouse_name, + pic.order_count_30d, + pic.shipped_qty_sum_30d +FROM #product_id_contact AS pic +ORDER BY pic.shipped_qty_sum_30d DESC, pic.order_count_30d DESC, pic.product_id ASC; + +-- 13) 缺货风险通知清单(带采购/工厂/仓库/店铺/运营) +SELECT TOP (@limit) + re.sku_id, + re.sku_name, + re.stock_risk_level, + re.available_qty, + re.avg_daily_shipped_30d, + re.estimated_stockout_days, + re.suggest_replenish_qty_to_10d, + pic.product_id, + pic.shop_channel, + pic.shop_name, + pic.operator_name, + pic.purchaser_name, + pic.factory_name, + pic.warehouse_name, + pic.shipped_qty_sum_30d +FROM #risk_eval AS re +LEFT JOIN #product_id_contact AS pic + ON pic.sku_id = re.sku_id +WHERE re.has_stock_risk = 1 +ORDER BY + CASE + WHEN re.available_qty <= 0 THEN 1 + WHEN re.estimated_stockout_days <= 10 THEN 2 + WHEN re.estimated_stockout_days <= 15 THEN 3 + ELSE 9 + END, + re.estimated_stockout_days ASC, + pic.shipped_qty_sum_30d DESC; +""" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Company product info lookup (SQLServer)") + parser.add_argument("keyword", help="SKU/SPU/款式/产品名关键词") + parser.add_argument("--days", type=int, default=30, help="lookback window, default 30") + parser.add_argument("--limit", type=int, default=20, help="max rows per result set") + args = parser.parse_args() + + kw = args.keyword.strip() + if not kw: + print("keyword is required", file=sys.stderr) + return 2 + + days = max(1, min(args.days, 365)) + limit = max(1, min(args.limit, 100)) + + sql = build_sql(kw, days, limit) + cmd = ["company-db", "query", "--sql", sql] + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "company-db failed").strip() + print(err, file=sys.stderr) + return proc.returncode + + print(proc.stdout.rstrip()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/product_id_promo_weekly_report.sql b/product_id_promo_weekly_report.sql new file mode 100644 index 0000000..453f796 --- /dev/null +++ b/product_id_promo_weekly_report.sql @@ -0,0 +1,404 @@ +USE [JSDataMiddlePlatform]; +SET NOCOUNT ON; + +/* + Template: 商品ID推广费用周报(2026可用) + 目标: 费用 + 发货件数 + 缺货风险 + 负责人 + 默认窗口: 上一个自然周 [week_start, week_end) +*/ + +DECLARE @week_end datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()), 0) AS datetime2); +DECLARE @week_start datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()) - 1, 0) AS datetime2); +DECLARE @lookback_30d datetime2 = DATEADD(day, -30, @week_end); +DECLARE @lead_days int = 10; +DECLARE @warn_days int = 15; + +IF OBJECT_ID('tempdb..#base_week') IS NOT NULL + DROP TABLE #base_week; + +WITH src AS ( + SELECT + NULLIF(a.undefined2, '') AS order_id, + NULLIF(a.undefined12, '') AS product_id, + NULLIF(a.undefined10, '') AS sku_id, + ISNULL(NULLIF(a.undefined8, ''), 'unknown') AS shop_name, + CASE + WHEN CHARINDEX('-', ISNULL(NULLIF(a.undefined8, ''), '')) > 0 + THEN LEFT(ISNULL(NULLIF(a.undefined8, ''), ''), CHARINDEX('-', ISNULL(NULLIF(a.undefined8, ''), '')) - 1) + ELSE 'unknown' + END AS shop_channel, + COALESCE( + TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120), + TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120), + TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 111), + TRY_CONVERT(datetime2, a.createdAt) + ) AS ship_dt, + COALESCE( + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')), + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')), + CAST(0 AS decimal(18, 2)) + ) AS shipped_qty, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined97, '')) AS promo_fee_8001201, + ( + ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined108, '')), 0) + + ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined109, '')), 0) + + ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined110, '')), 0) + + ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined111, '')), 0) + + ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined112, '')), 0) + + ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined113, '')), 0) + + ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined114, '')), 0) + + ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined115, '')), 0) + + ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined116, '')), 0) + + ISNULL(TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined117, '')), 0) + ) AS commission_fee_sum, + ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels + FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK) +) +SELECT + order_id, + product_id, + sku_id, + shop_name, + shop_channel, + ship_dt, + shipped_qty, + ISNULL(promo_fee_8001201, 0) AS promo_fee_8001201, + ISNULL(commission_fee_sum, 0) AS commission_fee_sum +INTO #base_week +FROM src +WHERE ship_dt >= @week_start + AND ship_dt < @week_end + AND ISNULL(product_id, '') <> '' + AND ISNULL(sku_id, '') <> '' + AND order_labels NOT LIKE '%配件%' + AND order_labels NOT LIKE '%特殊单%'; + +IF OBJECT_ID('tempdb..#ship_30d') IS NOT NULL + DROP TABLE #ship_30d; + +WITH src_30d AS ( + SELECT + NULLIF(a.undefined12, '') AS product_id, + NULLIF(a.undefined10, '') AS sku_id, + ISNULL(NULLIF(a.undefined8, ''), 'unknown') AS shop_name, + COALESCE( + TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120), + TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120), + TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 111), + TRY_CONVERT(datetime2, a.createdAt) + ) AS ship_dt, + COALESCE( + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')), + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')), + CAST(0 AS decimal(18, 2)) + ) AS shipped_qty, + ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels + FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK) +) +SELECT + product_id, + sku_id, + shop_name, + SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_30d +INTO #ship_30d +FROM src_30d +WHERE ship_dt >= @lookback_30d + AND ship_dt < @week_end + AND ISNULL(product_id, '') <> '' + AND ISNULL(sku_id, '') <> '' + AND order_labels NOT LIKE '%配件%' + AND order_labels NOT LIKE '%特殊单%' +GROUP BY product_id, sku_id, shop_name; + +IF OBJECT_ID('tempdb..#inv_latest') IS NOT NULL + DROP TABLE #inv_latest; + +WITH inv_rank AS ( + SELECT + NULLIF(i.sku_id, '') AS sku_id, + TRY_CONVERT(decimal(18, 2), i.qty) AS stock_qty, + TRY_CONVERT(decimal(18, 2), i.order_lock) AS order_lock_qty, + TRY_CONVERT(decimal(18, 2), i.pick_lock) AS pick_lock_qty, + TRY_CONVERT(decimal(18, 2), i.lock_qty) AS lock_qty, + i.name AS inventory_note, + COALESCE(i.updateTime, i.modified, i.createTime) AS inventory_snapshot_time, + ROW_NUMBER() OVER ( + PARTITION BY NULLIF(i.sku_id, '') + ORDER BY COALESCE(i.updateTime, i.modified, i.createTime) DESC, i.Id DESC + ) AS rn + FROM dbo.jst_inventoryInfo AS i WITH (NOLOCK) + WHERE NULLIF(i.sku_id, '') IN (SELECT DISTINCT sku_id FROM #base_week) +) +SELECT + sku_id, + stock_qty, + order_lock_qty, + pick_lock_qty, + lock_qty, + inventory_note, + inventory_snapshot_time, + CASE + WHEN ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0) < 0 + THEN CAST(0 AS decimal(18, 2)) + ELSE ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0) + END AS available_qty +INTO #inv_latest +FROM inv_rank +WHERE rn = 1; + +IF OBJECT_ID('tempdb..#sku_risk') IS NOT NULL + DROP TABLE #sku_risk; + +SELECT + s.product_id, + s.shop_name, + s.sku_id, + s.shipped_qty_30d, + CAST(ISNULL(s.shipped_qty_30d, 0) / 30.0 AS decimal(18, 2)) AS avg_daily_shipped_30d, + inv.available_qty, + CAST( + CASE + WHEN ISNULL(s.shipped_qty_30d, 0) <= 0 THEN NULL + ELSE inv.available_qty / NULLIF(ISNULL(s.shipped_qty_30d, 0) / 30.0, 0) + END AS decimal(18, 2) + ) AS estimated_stockout_days, + CAST( + CASE + WHEN ISNULL(s.shipped_qty_30d, 0) <= 0 THEN 0 + WHEN inv.available_qty >= (ISNULL(s.shipped_qty_30d, 0) / 30.0) * @lead_days THEN 0 + ELSE ((ISNULL(s.shipped_qty_30d, 0) / 30.0) * @lead_days) - inv.available_qty + END AS decimal(18, 2) + ) AS suggest_replenish_qty_to_10d, + CASE + WHEN inv.sku_id IS NULL THEN '无库存快照' + WHEN ISNULL(s.shipped_qty_30d, 0) <= 0 THEN '近30天无发货' + WHEN inv.available_qty <= 0 THEN '缺货/可用库存<=0' + WHEN (inv.available_qty / NULLIF(ISNULL(s.shipped_qty_30d, 0) / 30.0, 0)) <= @lead_days THEN '补货紧急(<=10天)' + WHEN (inv.available_qty / NULLIF(ISNULL(s.shipped_qty_30d, 0) / 30.0, 0)) <= @warn_days THEN '补货预警(10-15天)' + ELSE '库存安全' + END AS stock_risk_level +INTO #sku_risk +FROM #ship_30d AS s +LEFT JOIN #inv_latest AS inv + ON inv.sku_id = s.sku_id; + +IF OBJECT_ID('tempdb..#owner_pick') IS NOT NULL + DROP TABLE #owner_pick; + +WITH owner_src AS ( + SELECT + bw.product_id, + bw.shop_name, + bw.sku_id, + SUM(ISNULL(bw.shipped_qty, 0)) AS week_shipped_qty, + NULLIF(js.other_1, '') AS spu, + NULLIF(js.i_id, '') AS style_code, + NULLIF(tm.oper, '') AS tm_oper, + NULLIF(opm.oper, '') AS opm_oper, + NULLIF(spu_cfg.oper, '') AS spu_cfg_oper, + NULLIF(spu_cfg.purchaser, '') AS purchaser + FROM #base_week AS bw + LEFT JOIN dbo.jst_sku AS js WITH (NOLOCK) + ON js.sku_id = bw.sku_id + LEFT JOIN dbo.op_TargetManagement AS tm WITH (NOLOCK) + ON NULLIF(tm.shopproid, '') = bw.product_id + AND ISNULL(NULLIF(tm.shopname, ''), '') = ISNULL(NULLIF(bw.shop_name, ''), '') + OUTER APPLY ( + SELECT TOP 1 o.oper + FROM dbo.op_operManage AS o WITH (NOLOCK) + WHERE NULLIF(o.spu, '') IN ( + NULLIF(js.other_1, ''), + NULLIF(js.i_id, ''), + CASE + WHEN CHARINDEX('K', ISNULL(js.other_1, '')) > 0 + THEN LEFT(js.other_1, CHARINDEX('K', js.other_1) - 1) + ELSE NULL + END + ) + ORDER BY o.Id DESC + ) AS opm + OUTER APPLY ( + SELECT TOP 1 c.oper, c.purchaser + FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK) + WHERE NULLIF(c.spu, '') IN ( + NULLIF(js.other_1, ''), + NULLIF(js.i_id, ''), + CASE + WHEN CHARINDEX('K', ISNULL(js.other_1, '')) > 0 + THEN LEFT(js.other_1, CHARINDEX('K', js.other_1) - 1) + ELSE NULL + END + ) + ORDER BY c.Id DESC + ) AS spu_cfg + GROUP BY + bw.product_id, + bw.shop_name, + bw.sku_id, + NULLIF(js.other_1, ''), + NULLIF(js.i_id, ''), + NULLIF(tm.oper, ''), + NULLIF(opm.oper, ''), + NULLIF(spu_cfg.oper, ''), + NULLIF(spu_cfg.purchaser, '') +), owner_rank AS ( + SELECT + product_id, + shop_name, + COALESCE(tm_oper, opm_oper, spu_cfg_oper, '未分配') AS operator_name, + COALESCE(purchaser, '未分配') AS purchaser_name, + ROW_NUMBER() OVER ( + PARTITION BY product_id, shop_name + ORDER BY week_shipped_qty DESC, sku_id ASC + ) AS rn + FROM owner_src +) +SELECT + product_id, + shop_name, + operator_name, + purchaser_name +INTO #owner_pick +FROM owner_rank +WHERE rn = 1; + +IF OBJECT_ID('tempdb..#risk_agg') IS NOT NULL + DROP TABLE #risk_agg; + +SELECT + product_id, + shop_name, + COUNT(DISTINCT CASE WHEN stock_risk_level LIKE '补货紧急%' OR stock_risk_level LIKE '缺货%' THEN sku_id END) AS urgent_risk_sku_count, + COUNT(DISTINCT CASE WHEN stock_risk_level LIKE '补货预警%' THEN sku_id END) AS warning_risk_sku_count, + MIN(estimated_stockout_days) AS min_estimated_stockout_days, + SUM(CASE WHEN stock_risk_level LIKE '补货紧急%' OR stock_risk_level LIKE '缺货%' THEN ISNULL(suggest_replenish_qty_to_10d, 0) ELSE 0 END) AS suggest_replenish_qty_10d +INTO #risk_agg +FROM #sku_risk +GROUP BY product_id, shop_name; + +IF OBJECT_ID('tempdb..#product_week') IS NOT NULL + DROP TABLE #product_week; + +SELECT + bw.product_id, + bw.shop_channel, + bw.shop_name, + COUNT(DISTINCT bw.sku_id) AS sku_count, + COUNT(DISTINCT bw.order_id) AS order_count, + SUM(ISNULL(bw.shipped_qty, 0)) AS shipped_qty_sum, + SUM(ISNULL(bw.promo_fee_8001201, 0)) AS promo_fee_sum, + SUM(ISNULL(bw.commission_fee_sum, 0)) AS commission_fee_sum, + SUM(ISNULL(bw.promo_fee_8001201, 0) + ISNULL(bw.commission_fee_sum, 0)) AS marketing_fee_sum, + CASE + WHEN SUM(ISNULL(bw.shipped_qty, 0)) <= 0 THEN NULL + ELSE (SUM(ISNULL(bw.promo_fee_8001201, 0) + ISNULL(bw.commission_fee_sum, 0)) / SUM(ISNULL(bw.shipped_qty, 0))) + END AS marketing_fee_per_piece, + ISNULL(ra.urgent_risk_sku_count, 0) AS urgent_risk_sku_count, + ISNULL(ra.warning_risk_sku_count, 0) AS warning_risk_sku_count, + ra.min_estimated_stockout_days, + ISNULL(ra.suggest_replenish_qty_10d, 0) AS suggest_replenish_qty_10d, + CASE + WHEN ISNULL(ra.urgent_risk_sku_count, 0) > 0 THEN '补货紧急' + WHEN ISNULL(ra.warning_risk_sku_count, 0) > 0 THEN '补货预警' + ELSE '库存安全/无快照' + END AS product_stock_risk_level, + ISNULL(op.purchaser_name, '未分配') AS purchaser_name, + ISNULL(op.operator_name, '未分配') AS operator_name +INTO #product_week +FROM #base_week AS bw +LEFT JOIN #risk_agg AS ra + ON ra.product_id = bw.product_id + AND ra.shop_name = bw.shop_name +LEFT JOIN #owner_pick AS op + ON op.product_id = bw.product_id + AND op.shop_name = bw.shop_name +GROUP BY + bw.product_id, + bw.shop_channel, + bw.shop_name, + ISNULL(ra.urgent_risk_sku_count, 0), + ISNULL(ra.warning_risk_sku_count, 0), + ra.min_estimated_stockout_days, + ISNULL(ra.suggest_replenish_qty_10d, 0), + CASE + WHEN ISNULL(ra.urgent_risk_sku_count, 0) > 0 THEN '补货紧急' + WHEN ISNULL(ra.warning_risk_sku_count, 0) > 0 THEN '补货预警' + ELSE '库存安全/无快照' + END, + ISNULL(op.purchaser_name, '未分配'), + ISNULL(op.operator_name, '未分配'); + +-- 1) 周汇总(商品ID维度) +SELECT + @week_start AS week_start, + @week_end AS week_end, + COUNT(*) AS product_shop_count, + COUNT(DISTINCT product_id) AS product_count, + COUNT(DISTINCT shop_name) AS shop_count, + SUM(ISNULL(order_count, 0)) AS order_count_sum, + SUM(ISNULL(shipped_qty_sum, 0)) AS shipped_qty_sum, + SUM(ISNULL(promo_fee_sum, 0)) AS promo_fee_sum, + SUM(ISNULL(commission_fee_sum, 0)) AS commission_fee_sum, + SUM(ISNULL(marketing_fee_sum, 0)) AS marketing_fee_sum, + CASE + WHEN SUM(ISNULL(shipped_qty_sum, 0)) <= 0 THEN NULL + ELSE SUM(ISNULL(marketing_fee_sum, 0)) / SUM(ISNULL(shipped_qty_sum, 0)) + END AS marketing_fee_per_piece_avg, + SUM(CASE WHEN product_stock_risk_level = '补货紧急' THEN 1 ELSE 0 END) AS urgent_product_count, + SUM(CASE WHEN product_stock_risk_level = '补货预警' THEN 1 ELSE 0 END) AS warning_product_count +FROM #product_week; + +-- 2) 商品ID周报TOP100(费用 + 发货件数 + 缺货风险 + 负责人) +SELECT TOP (100) + product_id, + shop_channel, + shop_name, + sku_count, + order_count, + shipped_qty_sum, + promo_fee_sum, + commission_fee_sum, + marketing_fee_sum, + marketing_fee_per_piece, + product_stock_risk_level, + min_estimated_stockout_days, + suggest_replenish_qty_10d, + urgent_risk_sku_count, + warning_risk_sku_count, + purchaser_name, + operator_name +FROM #product_week +ORDER BY marketing_fee_sum DESC, shipped_qty_sum DESC, product_id ASC; + +-- 3) 高风险商品ID清单(运营动作优先) +SELECT TOP (100) + product_id, + shop_channel, + shop_name, + shipped_qty_sum, + marketing_fee_sum, + product_stock_risk_level, + min_estimated_stockout_days, + suggest_replenish_qty_10d, + purchaser_name, + operator_name +FROM #product_week +WHERE product_stock_risk_level IN ('补货紧急', '补货预警') +ORDER BY + CASE product_stock_risk_level WHEN '补货紧急' THEN 1 ELSE 2 END, + min_estimated_stockout_days ASC, + marketing_fee_sum DESC; + +-- 4) 负责人缺失清单(数据治理) +SELECT TOP (100) + product_id, + shop_channel, + shop_name, + shipped_qty_sum, + marketing_fee_sum, + purchaser_name, + operator_name +FROM #product_week +WHERE purchaser_name = '未分配' + OR operator_name = '未分配' +ORDER BY marketing_fee_sum DESC, shipped_qty_sum DESC; diff --git a/remote_sync/.DS_Store b/remote_sync/.DS_Store new file mode 100644 index 0000000..1e2dcd6 Binary files /dev/null and b/remote_sync/.DS_Store differ diff --git a/remote_sync/AGENTS.md b/remote_sync/AGENTS.md new file mode 100644 index 0000000..fbe0e3e --- /dev/null +++ b/remote_sync/AGENTS.md @@ -0,0 +1,448 @@ +# AGENTS.md - Your Workspace + +This folder is home. Treat it that way. + +## Hard Output Rules + +## No Pass-Through Policy + +- Never throw unresolved problems back to the user with generic blockers like "无法访问"、"请提供平台"、"请先手动" as the first outcome. +- Before asking the user for help, perform at least 2 autonomous recovery attempts (tool retry, alternate page/module, or fallback command). +- If still blocked, report only concrete missing items (for example: OTP/CAPTCHA/2FA) and include the exact attempts already tried. +- For market/sales tasks, do not ask for platform access details; this environment is preconfigured and should be executed directly. + +## Sales Non-Compliance Guard + +- For sales requests, these replies are forbidden: "没有直接访问", "请提供平台", "公开渠道", "无法连接系统". +- Sales turn validity rule: at least one `exec` tool call must happen before final conclusion text. +- For sales requests, do not send interim progress text; execute tools silently and return final results only. + +## Critical Domain Lock + +- For any request containing market-analysis intent ("市场分析", "行业分析", "类目分析", "竞品分析", "趋势分析"), only use domain `hq.yucekj.com` (or `yucekj.com`). +- If the same request also includes company performance intent ("销售", "业绩", "售后", "退款", "费比", "店铺"), override to SQLServer route `company-sales-db-analysis` and do not open yuce. +- In these requests, treat any other domain as blocked and do not use it for navigation or fetching. +- Use browser tool only with valid actions (`open` or `navigate`) and always pass `targetUrl` set to `https://hq.yucekj.com` (or a yucekj subpage). +- Preferred execution path for market-analysis: use `exec` to run `yuce-browser open https://hq.yucekj.com` then `yuce-browser snapshot 60`. +- Never use browser action `search`. +- Never use `web_search` or `web_fetch` for market-analysis requests. +- `browser` and `nodes` tools are disabled; use `exec` with `yuce-browser` for yuce-only navigation. +- Do not produce market-analysis numbers from memory. Always collect evidence from yucekj pages first. +- Before writing any report file, complete at least one successful browser snapshot on `hq.yucekj.com` and base conclusions on that content. +- If login/data collection fails, ask user to assist (OTP/CAPTCHA/manual step) instead of fabricating results. +- If a tool request would hit another domain, abort that step and continue only with the yucekj domain. + +## Xiaohongshu MCP Route + +- For tasks mentioning "小红书"/"红书"/"xhs"/"笔记"/"种草"/"小红书数据", use local service `xiaohongshu-mcp` first. +- Xiaohongshu tasks override generic market-analysis routing; do not route these tasks to yuce or sales systems. +- Mandatory first actions for Xiaohongshu tasks: + 1) `exec`: `xhs-api health` + 2) `exec`: `xhs-api login-status` + 3) If not logged in: `exec`: `xhs-api login-qrcode /root/.openclaw/workspace/outputs/xhs-login-qrcode.png`, ask user to scan, then retry login status. +- Data collection path: + - Keyword retrieval: `xhs-api search ""` + - Feed detail: extract `id` + `xsecToken` from search result, then `xhs-api detail true` +- Never ask user for Xiaohongshu platform URL/access basics first; this environment is preconfigured for local xiaohongshu-mcp. +- Do not expose raw source addresses (domain/IP/API endpoint) in final user response. + +## Critical Sales Route (High Priority) + +- For any request containing sales intent keywords ("业绩", "销售", "店铺", "上周", "旗舰店", "费比", "售后", "退款"), immediately route to SQLServer data source `company-sales-db-analysis`. +- Do not ask generic clarification before attempting data collection. +- These sales/market routes are user-preapproved automation flows; do not ask for extra permission before running `exec` collection steps. +- Do not call `memory_search` or `memory_get` for sales tasks. +- Never call `tts` or `voice_call` for sales tasks unless user explicitly asks for voice output. +- For sales tasks, do at least one `exec` data-collection tool call before any explanatory long text. +- Do not say "没有权限/无法连接系统" for sales tasks; this environment is preconfigured with DB connection + credentials. +- For sales tasks, never set `elevated=true` in `exec` calls. +- For sales tasks, do not call custom scripts under `/root/.openclaw/workspace/scripts/` unless user explicitly asked and file existence was verified first. +- KPI priority for sales/aftersale outputs: `发货件数/发货订单数/售后件数` first, then optional amount metrics. +- Unless user explicitly asks for "金额/费用/收入/利润", avoid using amount as primary ranking dimension. +- Mandatory first actions for sales tasks: + 1) `exec`: `company-db health` + 2) `exec`: `company-db tables` + 3) `exec`: `company-db run-file /root/.openclaw/workspace/sqlserver/last_week_afs_report.sql` (or execute targeted SQL) +- Always read `/root/.openclaw/workspace/outputs/company-sales-db-catalog.md` before crafting complex sales/aftersale SQL. +- If a sales request arrives, run tools immediately and output only the final result summary. + +- For direct human chat, output final answer only; never output internal analysis, tool JSON, or pseudo code unless explicitly requested. +- Default language is Simplified Chinese. +- Keep answers short and practical; avoid templates and setup scripts unless user asks for them. +- For greetings, reply with one short sentence only. +- If message is exactly an internal relay line (for example `Agent-to-agent announce step`), reply with one short acknowledgment and wait. + + +## First Run + +If `BOOTSTRAP.md` exists, that's your birth certificate. Follow it, figure out who you are, then delete it. You won't need it again. + +## Every Session + +Before doing anything else: + +1. Read `SOUL.md` — this is who you are +2. Read `USER.md` — this is who you're helping +3. Read `memory/YYYY-MM-DD.md` (today + yesterday) for recent context +4. **If in MAIN SESSION** (direct chat with your human): Also read `MEMORY.md` + +Don't ask permission. Just do it. + +## Memory + +You wake up fresh each session. These files are your continuity: + +- **Daily notes:** `memory/YYYY-MM-DD.md` (create `memory/` if needed) — raw logs of what happened +- **Long-term:** `MEMORY.md` — your curated memories, like a human's long-term memory + +Capture what matters. Decisions, context, things to remember. Skip the secrets unless asked to keep them. + +### 🧠 MEMORY.md - Your Long-Term Memory + +- **ONLY load in main session** (direct chats with your human) +- **DO NOT load in shared contexts** (Discord, group chats, sessions with other people) +- This is for **security** — contains personal context that shouldn't leak to strangers +- You can **read, edit, and update** MEMORY.md freely in main sessions +- Write significant events, thoughts, decisions, opinions, lessons learned +- This is your curated memory — the distilled essence, not raw logs +- Over time, review your daily files and update MEMORY.md with what's worth keeping + +### 📝 Write It Down - No "Mental Notes"! + +- **Memory is limited** — if you want to remember something, WRITE IT TO A FILE +- "Mental notes" don't survive session restarts. Files do. +- When someone says "remember this" → update `memory/YYYY-MM-DD.md` or relevant file +- When you learn a lesson → update AGENTS.md, TOOLS.md, or the relevant skill +- When you make a mistake → document it so future-you doesn't repeat it +- **Text > Brain** 📝 + +## Safety + +- Don't exfiltrate private data. Ever. +- Don't run destructive commands without asking. +- `trash` > `rm` (recoverable beats gone forever) +- When in doubt, ask. + +## External vs Internal + +**Safe to do freely:** + +- Read files, explore, organize, learn +- Search the web, check calendars +- Work within this workspace + +**Ask first:** + +- Sending emails, tweets, public posts +- Anything that leaves the machine +- Anything you're uncertain about + +Preapproved exception: +- For `hq.yucekj.com` and SQLServer sales data tasks explicitly requested by the human, proceed directly with execution and report results. + +## Group Chats + +You have access to your human's stuff. That doesn't mean you _share_ their stuff. In groups, you're a participant — not their voice, not their proxy. Think before you speak. + +### 💬 Know When to Speak! + +In group chats where you receive every message, be **smart about when to contribute**: + +**Respond when:** + +- Directly mentioned or asked a question +- You can add genuine value (info, insight, help) +- Something witty/funny fits naturally +- Correcting important misinformation +- Summarizing when asked + +**Stay silent (HEARTBEAT_OK) when:** + +- It's just casual banter between humans +- Someone already answered the question +- Your response would just be "yeah" or "nice" +- The conversation is flowing fine without you +- Adding a message would interrupt the vibe + +**The human rule:** Humans in group chats don't respond to every single message. Neither should you. Quality > quantity. If you wouldn't send it in a real group chat with friends, don't send it. + +**Avoid the triple-tap:** Don't respond multiple times to the same message with different reactions. One thoughtful response beats three fragments. + +Participate, don't dominate. + +### 😊 React Like a Human! + +On platforms that support reactions (Discord, Slack), use emoji reactions naturally: + +**React when:** + +- You appreciate something but don't need to reply (👍, ❤️, 🙌) +- Something made you laugh (😂, 💀) +- You find it interesting or thought-provoking (🤔, 💡) +- You want to acknowledge without interrupting the flow +- It's a simple yes/no or approval situation (✅, 👀) + +**Why it matters:** +Reactions are lightweight social signals. Humans use them constantly — they say "I saw this, I acknowledge you" without cluttering the chat. You should too. + +**Don't overdo it:** One reaction per message max. Pick the one that fits best. + +## Tools + +Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (camera names, SSH details, voice preferences) in `TOOLS.md`. + +**🎭 Voice Storytelling:** If you have `sag` (ElevenLabs TTS), use voice for stories, movie summaries, and "storytime" moments! Way more engaging than walls of text. Surprise people with funny voices. + +**📝 Platform Formatting:** + +- **Discord/WhatsApp:** No markdown tables! Use bullet lists instead +- **Discord links:** Wrap multiple links in `<>` to suppress embeds: `` +- **WhatsApp:** No headers — use **bold** or CAPS for emphasis + +## 💓 Heartbeats - Be Proactive! + +When you receive a heartbeat poll (message matches the configured heartbeat prompt), don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively! + +Default heartbeat prompt: +`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.` + +You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn. + +### Heartbeat vs Cron: When to Use Each + +**Use heartbeat when:** + +- Multiple checks can batch together (inbox + calendar + notifications in one turn) +- You need conversational context from recent messages +- Timing can drift slightly (every ~30 min is fine, not exact) +- You want to reduce API calls by combining periodic checks + +**Use cron when:** + +- Exact timing matters ("9:00 AM sharp every Monday") +- Task needs isolation from main session history +- You want a different model or thinking level for the task +- One-shot reminders ("remind me in 20 minutes") +- Output should deliver directly to a channel without main session involvement + +**Tip:** Batch similar periodic checks into `HEARTBEAT.md` instead of creating multiple cron jobs. Use cron for precise schedules and standalone tasks. + +**Things to check (rotate through these, 2-4 times per day):** + +- **Emails** - Any urgent unread messages? +- **Calendar** - Upcoming events in next 24-48h? +- **Mentions** - Twitter/social notifications? +- **Weather** - Relevant if your human might go out? + +**Track your checks** in `memory/heartbeat-state.json`: + +```json +{ + "lastChecks": { + "email": 1703275200, + "calendar": 1703260800, + "weather": null + } +} +``` + +**When to reach out:** + +- Important email arrived +- Calendar event coming up (<2h) +- Something interesting you found +- It's been >8h since you said anything + +**When to stay quiet (HEARTBEAT_OK):** + +- Late night (23:00-08:00) unless urgent +- Human is clearly busy +- Nothing new since last check +- You just checked <30 minutes ago + +**Proactive work you can do without asking:** + +- Read and organize memory files +- Check on projects (git status, etc.) +- Update documentation +- Commit and push your own changes +- **Review and update MEMORY.md** (see below) + +### 🔄 Memory Maintenance (During Heartbeats) + +Periodically (every few days), use a heartbeat to: + +1. Read through recent `memory/YYYY-MM-DD.md` files +2. Identify significant events, lessons, or insights worth keeping long-term +3. Update `MEMORY.md` with distilled learnings +4. Remove outdated info from MEMORY.md that's no longer relevant + +Think of it like a human reviewing their journal and updating their mental model. Daily files are raw notes; MEMORY.md is curated wisdom. + +The goal: Be helpful without being annoying. Check in a few times a day, do useful background work, but respect quiet time. + +## Make It Yours + +This is a starting point. Add your own conventions, style, and rules as you figure out what works. + +## Clean Chat Mode + +- In direct human chats, answer the user request only and keep replies concise. +- Do not output internal reasoning or planning text. +- Ignore plain system relay lines like `Agent-to-agent announce step`; do not ask follow-up unless a real task is present. +- If a required argument is missing for a tool action, ask for that one missing field in one short sentence. + +## Search Fallback + +- When user asks for online search and `web_search` is unavailable (for example missing API key), use `browser` to open a search engine and summarize results instead of returning setup instructions. +- Return concise Chinese results first, then optional source links. + +## Market Analysis Hard Route + +- If user request mentions market analysis intent ("市场分析", "行业分析", "类目分析", "竞品分析", "趋势分析"), always route to `ecommerce-market-analysis` in `/root/.openclaw/workspace/websites.yaml`. +- For this route, do not start with `web_search` or generic search engines. +- Canonical market site is `https://hq.yucekj.com` (login entry: `https://hq.yucekj.com/#/login?redirectUrl=https%3A%2F%2Fhq.yucekj.com%2F%23%2F`). +- Start from login entry, then continue in the same site session for evidence collection. +- Use env credentials `SITE_ECOMMERCE_MARKET_ANALYSIS_USERNAME` and `SITE_ECOMMERCE_MARKET_ANALYSIS_PASSWORD` first. + +## Company Sales Hard Route + +- If user request mentions sales-data intent ("销售数据", "销售报表", "销售看板", "公司销售", "业绩报表", "费比监控", "业绩数据", "售后", "退款"), route to `company-sales-db-analysis` in `/root/.openclaw/workspace/websites.yaml`. +- For this route, do not use generic search first. Start with `exec`: `company-db health`, then run SQL query. +- Allowed data source is SQLServer tables listed in `/root/.openclaw/workspace/websites.yaml` -> `company-sales-db-analysis.allowed_tables`. +- For sales-data requests, never call `memory_search` or `memory_get`; go straight to DB execution. +- Use env credentials `COMPANY_DB_HOST/PORT/NAME/USER/PASSWORD` (loaded from `/root/.openclaw/credentials/company-sql.env`). +- If request contains both market-analysis and sales-data keywords, prioritize company sales DB route when the ask is company performance/aftersale metrics. + +## Install Media Hard Route + +- If user request mentions install-media intent ("安装图纸", "安装视频", "安装说明书", "图纸地址", "视频地址", "订单号查图纸"), route to `install-media-db-analysis` in `/root/.openclaw/workspace/websites.yaml`. +- Execution must be: order-id -> product_no (SQLServer) -> install-media links (MySQL), command: + - `install-media-lookup --order-id "<订单号>"` +- Keep both fields in output: + - `raw_path` (数据库原始路径) + - `media_url` (浏览器可访问地址) +- URL rule: `D:\\JianSunTuZhiFile\\...` -> `https://img.jiansuntuzhi.cn/...`. +- If MySQL is unavailable or no mapping hit, return explicit gap and next step; do not fabricate links. + +## Company Sales Task Router + +- For `company-sales-db-analysis`, use field catalog from `/root/.openclaw/workspace/outputs/company-sales-db-catalog.md`. +- Mandatory execution sequence: identify指标 -> map字段/表 -> run SQL -> check null/outlier -> output conclusions. +- For aftersale analysis requests (`售后分析`/`退款原因`/`退货原因`/`客诉原因`), always run: + `company-db run-file /root/.openclaw/workspace/sqlserver/after_sale_360_report.sql` +- Do not output "售后分析" using only `fys_Ticketing_install` work-order metrics. +- If a legacy path references `sqlserver/last_week_after_sale_analysis.sql`, treat it as alias of `after_sale_360_report.sql` and still output reason dimensions. +- Output order for sales/aftersale reports: + 1) 发货件数/售后件数/订单数 + 2) 结构占比与趋势 + 3) 金额类指标(仅补充,不作主排序,除非用户明确要求) +- Dimension routing defaults: + - fee ratio / margin / refund / expense / platform or shop performance -> `jst_goods_profit_kpi_items_afs` + `jst_order_express_details` + - SKUID/SPU/detail requests -> `jst_sku` + `op_TargetManagement` + `op_operManage` + - logistics/freight questions -> `fys_jstFreightCost` + `fys_jstFreightCostDetail` + - installation/aftersale process -> `fys_Ticketing_install` + `fys_Ticketing_install_item` + `pc_kfChatLog` + - retention decision (退回/补偿/放货) -> `product-retention-decision --depreciation-rate 0.20` +- For retention decision output, must include: + - `depreciation_rate_used` + `depreciation_loss_if_return` + - `option_return_net_profit` / `option_discount_net_profit_{low,high}` / `option_release_net_profit` + - `recommended_option` + `recommended_action` +- Evidence minimum for sales brief: + - at least 1 SQL result set with fields + values + - at least 1 statement of time window and filter conditions + - if values are `--` or empty, report as `未在当前筛选下取到` + +## Autonomous Page Planning (Market Analysis) + +Priority: This section overrides earlier conflicting market-analysis guidance. + +- For market-analysis requests, the agent must autonomously decide which yuce module/page to open based on requested metrics. +- Allowed domain is still strictly limited to `hq.yucekj.com` and `yucekj.com`. +- Execution path must use `exec` with `yuce-browser` only (no `cron`, no `web_search`, no `web_fetch`). +- Page-by-page data fields and button-expansion flow must follow: + - `/root/.openclaw/workspace/outputs/market-analysis-page-sop.md` +- Do not use guessed hash routes for modules (forbidden examples: `/#/insight`, `/#/leaf-category-analysis`, `/#/keyword-analysis`, `/#/trend-analysis`, `/#/demand-analysis`, `/#/keyword-layout`, `/#/brand-shop-hot`, `/#/price-segment`, `/#/surge-product`). +- For module access, use either homepage card clicks or known `?report-app=...` report URLs. +- If a page shows `很抱歉,您没有权限访问此页面`, retry once via homepage card click/known report URL before concluding permission is missing. + +### Metric -> Page Routing Map + +- If request includes market size / growth / trend -> prioritize `市场洞察`. +- If request includes category share / subcategory opportunity -> prioritize `叶子类目分析`. +- If request includes brand/shop/product competition -> prioritize `品牌店铺商品热榜` + `竞店布局分析`. +- If request includes user demand / keyword trend -> prioritize `产品需求分析` + `需求趋势分析` + `关键词布局分析`. +- If request includes pricing strategy / price band -> prioritize `价格段布局` + `品牌布局分析`. + +### Autonomous Execution Policy + +- First collect evidence pages, then output conclusions. +- Minimum evidence for each report: at least 2 snapshots from relevant modules + key request logs. +- If requested metric has no visible data on current account/plan, explicitly mark as "未在当前账号页面取到" (do not fabricate). +- If CAPTCHA/OTP appears, stop immediately and ask user for manual step. + +### Output Format (Mandatory) + +- Section 1: "已抓取证据" (page/module names + observed fields) +- Section 2: "分析结论" (only based on Section 1) +- Section 3: "缺失项与下一步" (what still needs manual click/filter or higher permissions) + +## Market Analysis Output Guardrails + +- For yuce market-analysis tasks, module names must be chosen only from this whitelist: + - 市场洞察 + - 叶子类目分析 + - 品牌店铺商品热榜 + - 飙升品榜单 + - 品牌布局分析 + - 竞店布局分析 + - 价格段布局 + - 产品需求分析 + - 需求趋势分析 + - 关键词布局分析 +- Do not invent module ids, dataset names, or pseudo data-source names (e.g. yuce_market_size). +- If not executed yet, output only "拟访问模块(白名单)" + "对应指标"; do not output any numeric conclusions. +- Do not edit files under `memory/` unless user explicitly asks to modify memory files. + +## Market Brief Mode (Conclusion + Suggestions) + +For market-analysis tasks, final output must be an analysis brief, not a tool plan. + +### Mandatory evidence gate + +- Before writing any conclusion, execute evidence collection with `exec` + `yuce-browser`. +- Minimum evidence: + - at least 2 snapshots from relevant yuce modules + - at least 1 network/request evidence line from `yuce-browser requests` +- If evidence is insufficient, output only missing items and ask user for one concrete next action. + +### Mandatory report structure + +Use exactly these sections in Chinese: + +1. `已抓取证据` + - List each evidence item with: module/page, metric field, observed value. +2. `核心结论` + - 3-5 concise conclusions, each mapped to evidence. +3. `可执行建议` + - 3-5 actionable suggestions with priority (`P1/P2/P3`). +4. `风险与缺口` + - Explicitly state unavailable metrics and why. +5. `下一步` + - The next 1-3 actions to improve certainty. + +### Anti-fabrication constraints + +- Do not output market numbers unless they appear in captured evidence. +- Any inference must be labeled as `推断`. +- If a metric is absent, write `未在当前页面取到`. + +## Market Brief Script Binding + +For market-analysis brief tasks, prefer this deterministic command first: + +- `exec`: `market-brief-report ` +- During execution, enforce SOP steps in `/root/.openclaw/workspace/outputs/market-analysis-page-sop.md`, + especially: after any date/filter change, click `查询` before collecting evidence. + +Then summarize its output without changing numeric values. +If script output already contains the required sections, reuse them directly. diff --git a/remote_sync/AGENTS.remote.md b/remote_sync/AGENTS.remote.md new file mode 100644 index 0000000..ba423cd --- /dev/null +++ b/remote_sync/AGENTS.remote.md @@ -0,0 +1,121 @@ +# AGENTS.md - OpenClaw Workspace Rules + +## 1) Core Behavior + +- Execute first, explain later. Do not throw generic blockers to the user. +- Before asking user for help, try at least 2 autonomous recoveries. +- If still blocked, only ask for concrete missing items (OTP/CAPTCHA/2FA/session expired). +- Do not fabricate data. Every conclusion must come from tool results. + +## 2) Routing Priority (Highest to Lowest) + +### A. Xiaohongshu Tasks (override all market routes) + +Trigger keywords: 小红书 / 红书 / xhs / 笔记 / 种草 / 舆情 + +Required sequence: +1. `exec: xhs-api health` +2. `exec: xhs-api login-status` +3. If not logged in: `exec: xhs-api login-qrcode /root/.openclaw/workspace/outputs/xhs-login-qrcode.png` +4. Then use: + - `xhs-api search ""` + - `xhs-api detail true` + +### B. Sales / Aftersale / Refund / Performance Tasks (SQL only) + +Trigger keywords: 销售 / 业绩 / 售后 / 退款 / 费比 / 店铺周报 / SKU结构 / 发货 / 工单 / 客诉 / 客服体验 / 用户反馈 / 话术分析 / 产品信息 / 商品ID / SKU编码 / 成本 / 材质 / 预览图 + +Hard rules: +- Must route to `company-sales-db-analysis`. +- Do not use yuce website for sales/aftersale tasks. +- Do not use web_search/web_fetch for these tasks. +- Must run at least one `exec` data query before final response. +- Do not call `tts` unless user explicitly asks voice output. + +Mandatory first checks (once per turn): +1. `exec: company-db health` +2. Query by SQL templates in `/root/.openclaw/workspace/sqlserver/` + +Template mapping: +- 店铺周报 -> `shop_weekly_report.sql` +- SKU结构 -> `sku_structure_report.sql` +- 产品信息(SKU/成本/材质/预览图) -> `exec: product-info "" --days 30 --limit 20` +- 商品ID查SKU清单 -> `exec: product-skus-by-id "<商品id>" --limit 1000` + +Product info output minimum fields: +- SKU编码, SKU名称, 成本价, 材质, 预览图地址 +- 推荐补充:SPU/货号/分类/供应商/尺寸/重量/SKU状态 +- 若存在物流数据,追加 一区/二区/三区 运费汇总(近30天) +- 若存在售后数据,追加 售后总览 + 售后类型矩阵 + 售后原因TOP(近30天) +- 退款原因/售后 -> `after_sale_360_report.sql` +- 费比异常 -> `fee_ratio_alert.sql` +- 物流时效 -> `logistics_sla_report.sql` +- 运费效率 -> `freight_efficiency_report.sql` +- 客服热点 -> `customer_service_hotspot_report.sql` +- 客服体验/话术分析(可指定时间段) -> `exec: customer-service-analysis --start --end [--shop "店铺关键词"] --limit 20` + - 输出需包含:客服能力总分与等级(S/A/B/C/D) +- 安装服务 -> `install_service_quality_report.sql` +- 链接目标覆盖 -> `link_target_coverage_report.sql` + +Aftersale special rule: +- Do not analyze only one table. +- Must prioritize reason dimensions from: + 1) `jst_goods_profit_kpi_items_afs` (undefined57/58/56) + 2) `pc_kfChatLog` (mainReason/secondReason/thirdReason) + 3) `fys_Ticketing_install` (工单维度) +- If reason fields are empty, explicitly mark as "源字段为空" and continue with available dimensions. + +Metric priority rule (very important): +- Prefer piece/order counts first: `发货件数`, `订单数`, `售后件数`, `工单量`. +- Amount metrics are secondary and optional. +- Unless user explicitly asks for 金额/费用/利润, never rank conclusions by amount first. + +Output format for sales/aftersale: +1. 件数与订单核心结论 +2. 原因结构(一级/二级/三级) +3. 趋势与异常 +4. 金额补充(可选) +5. 可执行建议 +6. 若为客服体验/话术任务,必须补充“对话案例 + 推荐回复话术” +7. 若为客服体验/话术任务,必须补充“客服能力评分(按客服名字输出四维度+总分+等级)” + +### C. Market Analysis Tasks (non-sales) + +Trigger keywords: 市场分析 / 行业分析 / 类目分析 / 竞品分析 / 趋势分析 + +Hard rules: +- Only allow domain `hq.yucekj.com` / `yucekj.com`. +- Use `exec` with `yuce-browser` only. +- Never use browser `search` action. +- Never use web_search/web_fetch for this route. +- If login/CAPTCHA blocks, ask user for that exact step only. + +### D. General Online Search Tasks + +Trigger keywords: 在线搜索 / 搜一下 / 查资料 / 最新消息 / 新闻 / 政策更新 + +Preferred execution: +1. First use `exec: web-search "" --count 8` (stable path; includes Brave+Bing fallback). +2. Optional: if user explicitly asks for Brave-only, use `web_search`. +3. Summarize with source links, and mark date/time context when content is time-sensitive. + +## 3) Security and Privacy + +- Do not expose raw data source endpoints in user-facing replies. +- Mask domain/IP/api endpoints in final messages when possible. +- Never leak secrets from env files. + +## 4) Reply Quality Rules + +- Send concise final answer; no long tool transcript dump. +- If task may take time, send one short processing hint first. +- Do not repeat the same result twice in one turn. +- If a request fails, include: what was attempted, what failed, and next actionable fix. + +## 5) Workspace Paths + +- SQL templates: `/root/.openclaw/workspace/sqlserver/` +- SQL catalog: `/root/.openclaw/workspace/outputs/company-sales-db-catalog.md` +- Router skill: `/root/.openclaw/skills/web-task-router/SKILL.md` +- Tool guide: `/root/.openclaw/workspace/TOOLS.md` +- Site config: `/root/.openclaw/workspace/websites.yaml` diff --git a/remote_sync/README_SQL_TEMPLATES.remote.md b/remote_sync/README_SQL_TEMPLATES.remote.md new file mode 100644 index 0000000..fd28b55 --- /dev/null +++ b/remote_sync/README_SQL_TEMPLATES.remote.md @@ -0,0 +1,123 @@ +# 销售分析常用 SQL 模板包 + +数据源:`JSDataMiddlePlatform`(SQLServer) +执行方式:`company-db run-file ` + +默认分析口径(已按最新要求): +- 主口径优先:`发货件数 / 售后件数 / 订单数` +- 金额口径:仅补充展示;除非明确要求,不作为主排序维度 + +## 模板列表 + +1. `shop_weekly_report.sql` + - 用途:店铺周报(平台/店铺销售、退款、毛利、高退款风险店铺) + - 默认时间:上一个自然周 + +2. `sku_structure_report.sql` + - 用途:SKU结构分析(类目结构、SKU Top、价格带结构) + - 默认时间:最近30天 + +3. `refund_reason_report.sql` + - 用途:退款原因分析(全局原因Top、平台内原因Top、店铺风险清单、类型矩阵) + - 默认时间:最近30天 + +4. `fee_ratio_alert.sql` + - 用途:费比异常预警(店铺级P1/P2/P3预警、全局日趋势) + - 默认时间:最近14天 + +5. `logistics_sla_report.sql` + - 用途:物流时效/SLA分析(支付到发货、发货到签收、延迟预警) + - 默认时间:最近30天 + +6. `freight_efficiency_report.sql` + - 用途:运费效率分析(单票成本、单位重量成本、省份/快递组合成本) + - 默认时间:最近30天 + +7. `install_service_quality_report.sql` + - 用途:安装服务质量(工单状态、服务商表现、待审核区域) + - 默认时间:最近90天 + +8. `customer_service_hotspot_report.sql` + - 用途:客服问题热点(问题Top、店铺问题热点、日趋势) + - 默认时间:最近30天 + +9. `link_target_coverage_report.sql` + - 用途:链接目标覆盖(在线链接、运营负载、类目/价格带分布) + - 默认时间:在线链接全量 + +10. `after_sale_360_report.sql` + - 用途:售后360分析(退款金额、一级/二级原因、类型矩阵、安装工单联动) + - 默认时间:上一个自然周 + +11. `product-info`(CLI辅助命令) + - 用途:按 SKU/SPU/关键词查询产品基础信息(成本、材质、预览图地址) + - 命令:`product-info "" --days 30 --limit 20` + - 输出: + - 结果集1:产品基础信息(含 `sku_name`、`cost_price`、`board_material`、`preview_pic_url`) + - 结果集2:最近N天出货/售后件数 + - 结果集3:最近N天店铺分布(件数) + - 结果集4:最近N天一区/二区/三区运费汇总 + - 结果集5:最近N天省份运费TOP + - 结果集6:最近N天售后总览(申请件数/退回件数/退款单数) + - 结果集7:最近N天售后类型/结果矩阵 + - 结果集8:最近N天售后原因TOP(含来源) + +12. `product-skus-by-id`(CLI辅助命令) + - 用途:按 商品ID 查询该ID下所有 SKU编码 与 SKU名称 + - 命令:`product-skus-by-id "<商品id>" --limit 1000` + - 输出: + - 结果集1:匹配汇总(SKU数量/SPU数量) + - 结果集2:SKU编码+SKU名称清单(含分类、成本、预览图) + +13. `customer_service_experience_report.sql` + - 用途:客服体验与话术分析(用户反馈问题结构、体验分、风险话术、日趋势) + - 默认时间:最近30天(可改模板顶部 `@start_date/@end_date`) + +14. `customer-service-analysis`(CLI辅助命令) + - 用途:按指定时间段做客服维度的用户反馈体验/话术分析 + - 命令:`customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20` + - 输出: + - 结果集1:时间段总览(会话量、空内容率、无对应分类率) + - 结果集2:一级/二级问题结构 TOP + - 结果集3:店铺体验分对比 + - 结果集4:话术覆盖率(问候/共情/承诺/收尾) + - 结果集5:风险话术命中 TOP + - 结果集6:每日体验趋势 + - 结果集7:风险对话案例 + 建议回复话术 + - 结果集8:客服能力评分(响应能力/满意度/退款效率/求助风险控制 + 总分等级) + - 结果集9:按客服名字评分(可识别优秀客服) + +### 客服能力评分口径(代理映射) + +- 参考维度:旺旺响应能力、旺旺满意度、退款处理体验、平台求助风险控制 +- 当前数据库可用字段:`pc_kfChatLog.content/mainReason/secondReason/thirdReason/reasonContent` +- 评分权重(总分100): + - 响应能力 30% + - 满意度 30% + - 退款效率 20% + - 求助风险控制 20% +- 等级:`S>=90`,`A>=80`,`B>=70`,`C>=60`,`D<60` +- 说明:该评分为“规则维度代理口径”,用于内部运营诊断,不等同平台官方最终分数。 + +## 运行示例 + +```bash +company-db run-file /root/.openclaw/workspace/sqlserver/shop_weekly_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/sku_structure_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/refund_reason_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/fee_ratio_alert.sql +company-db run-file /root/.openclaw/workspace/sqlserver/logistics_sla_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/freight_efficiency_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/install_service_quality_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/customer_service_hotspot_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/link_target_coverage_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/after_sale_360_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/customer_service_experience_report.sql +customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20 +``` + +## 注意事项 + +- 所有模板都默认过滤 `配件` 和 `特殊单` 标签。 +- 金额字段多为字符串,模板已使用 `TRY_CONVERT` 做安全转换。 +- 如需自定义时间,修改每个模板顶部变量即可。 diff --git a/remote_sync/TOOLS.md b/remote_sync/TOOLS.md new file mode 100644 index 0000000..0fd3dba --- /dev/null +++ b/remote_sync/TOOLS.md @@ -0,0 +1,53 @@ +# TOOLS.md - Local Notes + +Skills define _how_ tools work. This file is for _your_ specifics — the stuff that's unique to your setup. + +## What Goes Here + +Things like: + +- Camera names and locations +- SSH hosts and aliases +- Preferred voices for TTS +- Speaker/room names +- Device nicknames +- Anything environment-specific + +## Examples + +```markdown +### Cameras + +- living-room → Main area, 180° wide angle +- front-door → Entrance, motion-triggered + +### SSH + +- home-server → 192.168.1.100, user: admin + +### TTS + +- Preferred voice: "Nova" (warm, slightly British) +- Default speaker: Kitchen HomePod +``` + +## Why Separate? + +Skills are shared. Your setup is yours. Keeping them apart means you can update skills without losing your notes, and share skills without leaking your infrastructure. + +--- + +Add whatever helps you do your job. This is your cheat sheet. + +## Web Routing Preset (Auto) + +- Site registry file: `/root/.openclaw/workspace/websites.yaml` +- First apply `routing_rules.hard_route_rules`, then fallback to `sites[].purposes/keywords`. +- Hard rule: if request mentions "市场分析/行业分析/类目分析/竞品分析/趋势分析", force `ecommerce-market-analysis`. +- Hard rule: if request mentions "销售数据/业绩/售后/退款/费比/店铺业绩", force `company-sales-db-analysis` (SQLServer), not web page scraping. +- In forced market-analysis route, do not start with `web_search`; go directly to `https://hq.yucekj.com` login flow. +- In forced sales/aftersale route, run `company-db` commands directly (`health`, `tables`, `columns`, `query`, `run-file`). +- If matched site has `login_required: true`, use env credentials first (`username_env/password_env`), then 1Password fallback. +- If task does not need login, use `public-search` or direct public site access. +- If OTP/CAPTCHA appears, pause and ask user in chat, then continue. +- Never operate outside `allowed_domains`. diff --git a/remote_sync/TOOLS.remote.md b/remote_sync/TOOLS.remote.md new file mode 100644 index 0000000..1c64344 --- /dev/null +++ b/remote_sync/TOOLS.remote.md @@ -0,0 +1,73 @@ +# TOOLS.md - Local Notes + +Skills define _how_ tools work. This file is for _your_ specifics — the stuff that's unique to your setup. + +## What Goes Here + +Things like: + +- Camera names and locations +- SSH hosts and aliases +- Preferred voices for TTS +- Speaker/room names +- Device nicknames +- Anything environment-specific + +## Examples + +```markdown +### Cameras + +- living-room → Main area, 180° wide angle +- front-door → Entrance, motion-triggered + +### SSH + +- home-server → 192.168.1.100, user: admin + +### TTS + +- Preferred voice: "Nova" (warm, slightly British) +- Default speaker: Kitchen HomePod +``` + +## Why Separate? + +Skills are shared. Your setup is yours. Keeping them apart means you can update skills without losing your notes, and share skills without leaking your infrastructure. + +--- + +Add whatever helps you do your job. This is your cheat sheet. + +## Web Routing Preset (Auto) + +- Site registry file: `/root/.openclaw/workspace/websites.yaml` +- First apply `routing_rules.hard_route_rules`, then fallback to `sites[].purposes/keywords`. +- Hard rule: if request mentions "市场分析/行业分析/类目分析/竞品分析/趋势分析", force `ecommerce-market-analysis`. +- Hard rule: if request mentions "销售数据/业绩/售后/退款/费比/店铺业绩", force `company-sales-db-analysis` (SQLServer), not web page scraping. +- In forced market-analysis route, do not start with `web_search`; go directly to `https://hq.yucekj.com` login flow. +- In forced sales/aftersale route, run `company-db` commands directly (`health`, `tables`, `columns`, `query`, `run-file`). +- If matched site has `login_required: true`, use env credentials first (`username_env/password_env`), then 1Password fallback. +- If task does not need login, use `public-search` or direct public site access. +- If OTP/CAPTCHA appears, pause and ask user in chat, then continue. +- Never operate outside `allowed_domains`. + +## Product Info Helper + +- Command: `product-info "" --days 30 --limit 20` +- Returns: + 1) Product master info (`cost_price`, `board_material`, `preview_pic_url`) + 2) Last N days shipped/aftersale counts + 3) Last N days shop distribution by shipped piece count +- Data source: SQLServer `JSDataMiddlePlatform` +- Command: `product-skus-by-id "<商品id>" --limit 1000` +- Returns all SKU codes and SKU names under the given product ID (style_code/product_no/spu/shopproid). +- `product-info` now also returns aftersale insight for matched SKU: + - aftersale summary, type/result matrix, reason TOP (last N days) + + +## Customer Service Experience Helper + +- Command: `customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20` +- Purpose: Analyze customer feedback experience + service wording quality in a date range. +- Output: overall quality, reason structure, shop comparison score, wording coverage, risk phrase hits, daily trend, dialogue cases, capability score by agent name (S/A/B/C/D). diff --git a/remote_sync/__pycache__/bot.cpython-313.pyc b/remote_sync/__pycache__/bot.cpython-313.pyc new file mode 100644 index 0000000..376ed74 Binary files /dev/null and b/remote_sync/__pycache__/bot.cpython-313.pyc differ diff --git a/remote_sync/__pycache__/bot.current.cpython-313.pyc b/remote_sync/__pycache__/bot.current.cpython-313.pyc new file mode 100644 index 0000000..d459c97 Binary files /dev/null and b/remote_sync/__pycache__/bot.current.cpython-313.pyc differ diff --git a/remote_sync/__pycache__/bot.py.cpython-313.pyc b/remote_sync/__pycache__/bot.py.cpython-313.pyc new file mode 100644 index 0000000..eca6c78 Binary files /dev/null and b/remote_sync/__pycache__/bot.py.cpython-313.pyc differ diff --git a/remote_sync/__pycache__/install_media_lookup.cpython-313.pyc b/remote_sync/__pycache__/install_media_lookup.cpython-313.pyc new file mode 100644 index 0000000..5b629d6 Binary files /dev/null and b/remote_sync/__pycache__/install_media_lookup.cpython-313.pyc differ diff --git a/remote_sync/__pycache__/product-retention-decisioncpython-313.pyc b/remote_sync/__pycache__/product-retention-decisioncpython-313.pyc new file mode 100644 index 0000000..05e8504 Binary files /dev/null and b/remote_sync/__pycache__/product-retention-decisioncpython-313.pyc differ diff --git a/remote_sync/bot.current.py b/remote_sync/bot.current.py new file mode 100755 index 0000000..58344b7 --- /dev/null +++ b/remote_sync/bot.current.py @@ -0,0 +1,2319 @@ +#!/usr/bin/env python3 +import argparse +import asyncio +import base64 +import datetime as dt +import hashlib +import hmac +import io +import json +import logging +import mimetypes +import os +import re +import subprocess +import tempfile +import time +import urllib.parse +import uuid + +from dingtalk_stream import AckMessage, CallbackMessage, ChatbotHandler, ChatbotMessage +from dingtalk_stream import Credential, DingTalkStreamClient +import requests + +try: + from PIL import Image +except Exception: + Image = None + +try: + import boto3 + from botocore.config import Config as BotoConfig +except Exception: + boto3 = None + BotoConfig = None + + +MARKET_HINT_KEYWORDS = ( + "市场分析", + "市场数据分析", + "行业分析", + "类目分析", + "竞品分析", + "趋势分析", +) + +SALES_HINT_KEYWORDS = ( + "销售数据", + "销售报表", + "销售看板", + "公司销售", + "业绩报表", + "业绩数据", + "费比监控", +) + +SENSITIVE_SALES_KEYWORDS = ( + "销售", + "业绩", + "售后", + "退款", + "店铺", + "发货", + "工单", + "费比", +) + +EXPLICIT_AMOUNT_KEYWORDS = ( + "金额", + "费用", + "收入", + "利润", + "成本", + "客单价", + "gmv", + "销售额", + "退款额", + "毛利", +) + +INSTALL_MEDIA_HINT_KEYWORDS = ( + "安装图纸", + "安装视频", + "安装说明书", + "图纸地址", + "视频地址", + "图纸链接", + "视频链接", + "订单号查图纸", + "订单号查视频", +) + +INSTALL_MEDIA_STITCH_HINT_KEYWORDS = ( + "拼接", + "合并", + "一张图", + "整合图", + "汇总图", +) + +VIDEO_ANALYSIS_HINT_KEYWORDS = ( + "视频", + "video", + "短片", + "片段", +) + +VIDEO_ANALYSIS_ACTION_KEYWORDS = ( + "分析", + "解读", + "总结", + "提炼", + "复盘", + "讲解", + "评价", + "建议", + "看看", + "看下", + "说明", +) + +VIDEO_SCRIPT_HINT_KEYWORDS = ( + "脚本", + "分镜", + "复刻", + "仿拍", + "同款", + "类似视频", + "拍摄方案", + "镜头脚本", +) + +DALONGXIA_ANALYTICS_HINT_KEYWORDS = ( + "壹点需求", + "需求系统", + "任务进度总览", + "进度总览", + "数据分析总览", + "需求统计", + "设计师绩效", + "流程效率", + "预警", + "维度分析", + "主部门", + "designerperformance", + "dimensionanalysis", + "taskprogressoverview", + "designerdeduction", + "designerscore", + "primarydept", + "primarytask", + "analytics", +) + + +URL_RE = re.compile(r"https?://[^\s)\]>]+", re.IGNORECASE) +IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b") +DOMAIN_RE = re.compile(r"\b(?:[a-zA-Z0-9-]+\.)+(?:com|cn|net|ai|org|io)\b", re.IGNORECASE) +IMAGE_EXT_RE = re.compile(r"\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:$|[?#])", re.IGNORECASE) +VIDEO_EXT_RE = re.compile(r"\.(?:mp4|mov|m4v|webm|mkv|avi|flv|wmv|ts|m3u8)(?:$|[?#])", re.IGNORECASE) +ORDER_ID_RE = re.compile(r"\b\d{12,24}(?:-\d{2,4})?\b") +SMALLTALK_SET = { + "你好", + "您好", + "嗨", + "哈喽", + "在吗", + "在嘛", + "早上好", + "上午好", + "中午好", + "下午好", + "晚上好", + "hi", + "hello", +} + + + +def bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +class OpenClawDingTalkBot(ChatbotHandler): + def __init__(self, logger: logging.Logger): + super().__init__() + self.logger = logger + self.openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw") + self.openclaw_timeout = int(os.getenv("OPENCLAW_TIMEOUT", "600")) + self.market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report") + self.market_brief_timeout = int(os.getenv("MARKET_BRIEF_TIMEOUT", "180")) + self.install_media_bin = os.getenv("INSTALL_MEDIA_BIN", "/usr/local/bin/install-media-lookup") + self.install_media_timeout = int(os.getenv("INSTALL_MEDIA_TIMEOUT", "120")) + self.prefix = os.getenv("BOT_PREFIX", "/oc").strip() + self.require_prefix = bool_env("REQUIRE_PREFIX", False) + self.reply_max_chars = int(os.getenv("REPLY_MAX_CHARS", "3500")) + self.session_prefix = os.getenv("OPENCLAW_SESSION_PREFIX", "dingtalk") + self.hide_source_urls = bool_env("HIDE_SOURCE_URLS", True) + self.send_processing_hint = bool_env("SEND_PROCESSING_HINT", True) + self.dedupe_ttl_seconds = int(os.getenv("DEDUPE_TTL_SECONDS", "300")) + self.session_max_lines = int(os.getenv("OPENCLAW_SESSION_MAX_LINES", "120")) + self.sessions_dir = os.getenv("OPENCLAW_SESSIONS_DIR", "/root/.openclaw/agents/main/sessions") + self.unmask_url_hosts = { + h.strip().lower() + for h in os.getenv( + "UNMASK_URL_HOSTS", + "img.alicdn.com,static.jiansun.vip,img.jiansuntuzhi.cn,v.youku.com", + ).split(",") + if h.strip() + } + self._recent_event_ts = {} + self.processing_hint_text = os.getenv( + "PROCESSING_HINT_TEXT", + "已收到,正在处理你的需求,请稍等1-3分钟。", + ).strip() + self.reply_style = os.getenv("REPLY_STYLE", "action_card").strip().lower() + self.action_card_title = os.getenv("ACTION_CARD_TITLE", "OpenClaw 助理").strip() + self.action_card_tips = os.getenv("ACTION_CARD_TIPS", "").strip() + self.action_card_logo = os.getenv("ACTION_CARD_LOGO", "").strip() + self.action_card_btn_text = os.getenv("ACTION_CARD_BTN_TEXT", "").strip() + self.action_card_btn_url = os.getenv("ACTION_CARD_BTN_URL", "").strip() + self.smalltalk_reply_text = os.getenv( + "SMALLTALK_REPLY_TEXT", + "你好,我在。请直接告诉我你的任务目标(例如:查询某店上周售后原因TOP5并给出改进建议)。", + ).strip() + self.max_parallel_jobs = max(1, min(int(os.getenv("BOT_MAX_PARALLEL_JOBS", "4")), 16)) + self._work_semaphore = asyncio.Semaphore(self.max_parallel_jobs) + self._conversation_locks = {} + self.card_disable_seconds = max(300, min(int(os.getenv("CARD_DISABLE_SECONDS", "43200")), 86400)) + self.card_disabled_until = 0.0 + self.install_media_stitch_enabled = bool_env("INSTALL_MEDIA_STITCH_ENABLED", True) + self.install_media_stitch_min_images = max(2, min(int(os.getenv("INSTALL_MEDIA_STITCH_MIN_IMAGES", "2")), 10)) + self.install_media_stitch_max_images = max( + self.install_media_stitch_min_images, + min(int(os.getenv("INSTALL_MEDIA_STITCH_MAX_IMAGES", "20")), 50), + ) + self.install_media_fetch_timeout = max(3, min(int(os.getenv("INSTALL_MEDIA_FETCH_TIMEOUT", "12")), 60)) + self.install_media_max_width = max(720, min(int(os.getenv("INSTALL_MEDIA_MAX_WIDTH", "1600")), 4000)) + self.s3_endpoint = ( + os.getenv("INSTALL_MEDIA_S3_ENDPOINT", "").strip() + or os.getenv("S3_ENDPOINT", "").strip() + ) + self.s3_bucket = ( + os.getenv("INSTALL_MEDIA_S3_BUCKET", "").strip() + or os.getenv("S3_BUCKET", "").strip() + ) + self.s3_region = ( + os.getenv("INSTALL_MEDIA_S3_REGION", "").strip() + or os.getenv("S3_REGION", "us-east-1").strip() + ) + self.s3_access_key = ( + os.getenv("INSTALL_MEDIA_S3_ACCESS_KEY_ID", "").strip() + or os.getenv("S3_ACCESS_KEY_ID", "").strip() + ) + self.s3_secret_key = ( + os.getenv("INSTALL_MEDIA_S3_SECRET_ACCESS_KEY", "").strip() + or os.getenv("S3_SECRET_ACCESS_KEY", "").strip() + ) + self.s3_public_domain = ( + os.getenv("INSTALL_MEDIA_S3_PUBLIC_DOMAIN", "").strip() + or os.getenv("S3_PUBLIC_DOMAIN", "").strip() + ) + self.s3_key_prefix = ( + os.getenv("INSTALL_MEDIA_S3_FILE_PATH", "").strip() + or os.getenv("S3_FILE_PATH", "").strip() + ) + self.s3_acl = ( + os.getenv("INSTALL_MEDIA_S3_ACL", "").strip() + or os.getenv("S3_ACL", "public-read").strip() + ) + self.s3_path_style = bool_env("INSTALL_MEDIA_S3_PATH_STYLE", bool_env("S3_PATH_STYLE", True)) + if self.s3_key_prefix and not self.s3_key_prefix.endswith("/"): + self.s3_key_prefix += "/" + self.image_analysis_enabled = bool_env("IMAGE_ANALYSIS_ENABLED", True) + self.image_analysis_model = os.getenv("IMAGE_ANALYSIS_MODEL", "qwen2.5vl:3b").strip() or "qwen2.5vl:3b" + self.image_analysis_timeout = max(10, min(int(os.getenv("IMAGE_ANALYSIS_TIMEOUT", "90")), 300)) + self.image_max_images = max(1, min(int(os.getenv("IMAGE_MAX_IMAGES", "3")), 8)) + self.image_download_timeout = max(3, min(int(os.getenv("IMAGE_DOWNLOAD_TIMEOUT", "20")), 60)) + self.image_max_bytes = max(262144, min(int(os.getenv("IMAGE_MAX_BYTES", "10485760")), 31457280)) + self.image_upload_to_s3 = bool_env("IMAGE_UPLOAD_TO_S3", True) + self.image_vision_output_max_chars = max(300, min(int(os.getenv("IMAGE_VISION_OUTPUT_MAX_CHARS", "2200")), 6000)) + self.video_analysis_enabled = bool_env("VIDEO_ANALYSIS_ENABLED", True) + self.video_analysis_model = os.getenv("VIDEO_ANALYSIS_MODEL", "qwen3.5-plus").strip() or "qwen3.5-plus" + self.video_analysis_base_url = ( + os.getenv("VIDEO_ANALYSIS_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions").strip() + or "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions" + ) + self.video_analysis_timeout = max(15, min(int(os.getenv("VIDEO_ANALYSIS_TIMEOUT", "360")), 900)) + self.video_analysis_max_tokens = max(256, min(int(os.getenv("VIDEO_ANALYSIS_MAX_TOKENS", "1800")), 8192)) + self.video_analysis_max_urls = max(1, min(int(os.getenv("VIDEO_ANALYSIS_MAX_URLS", "2")), 3)) + + def _should_handle(self, msg: ChatbotMessage, text: str) -> bool: + if text.startswith(self.prefix): + return True + if msg.is_in_at_list: + return True + return not self.require_prefix + + def _normalize_prompt(self, text: str) -> str: + text = text.strip() + if self.prefix and text.startswith(self.prefix): + text = text[len(self.prefix):].strip() + text = re.sub(r"^@\S+\s*", "", text).strip() + return text + + def _is_smalltalk_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"[\s,,。.!!??~~]+", "", (prompt or "").strip().lower()) + if not normalized: + return False + if normalized in SMALLTALK_SET: + return True + if normalized.startswith(("你好", "您好", "hi", "hello")) and len(normalized) <= 6: + return True + return False + + def _safe_session_component(self, raw: str) -> str: + original = (raw or "default").strip().replace(" ", "_") + safe = re.sub(r"[^A-Za-z0-9_-]+", "-", original) + safe = re.sub(r"-{2,}", "-", safe).strip("-") + if not safe: + safe = "default" + if safe != original: + digest = hashlib.sha1(original.encode("utf-8")).hexdigest()[:8] + safe = f"{safe}-{digest}" + return safe[:96] + + def _session_id(self, msg: ChatbotMessage) -> str: + raw_conversation_id = str(msg.conversation_id or "default") + safe_conversation_id = self._safe_session_component(raw_conversation_id) + base_session = f"{self.session_prefix}-{safe_conversation_id}" + + session_file = os.path.join(self.sessions_dir, f"{base_session}.jsonl") + if self.session_max_lines > 0 and os.path.exists(session_file): + try: + with open(session_file, "rb") as fh: + line_count = sum(1 for _ in fh) + if line_count >= self.session_max_lines: + rotate_suffix = time.strftime("%Y%m%d%H%M") + rotated = f"{base_session}-{rotate_suffix}" + self.logger.info( + "session auto-rotated: base=%s lines=%s rotated=%s", + base_session, + line_count, + rotated, + ) + return rotated + except Exception: + self.logger.exception("failed to inspect session file: %s", session_file) + + return base_session + + def _event_key(self, callback: CallbackMessage, msg: ChatbotMessage, prompt: str) -> str: + if getattr(msg, "message_id", None): + return f"msg:{msg.message_id}" + + data = callback.data if isinstance(callback.data, dict) else {} + for key in ("msgId", "messageId", "eventId", "processQueryKey"): + value = data.get(key) + if value: + return f"data:{key}:{value}" + + headers = getattr(callback, "headers", {}) or {} + for key in ("messageId", "eventId", "processQueryKey"): + value = headers.get(key) + if value: + return f"hdr:{key}:{value}" + + raw = "|".join( + [ + str(getattr(msg, "conversation_id", "") or ""), + str(getattr(msg, "sender_id", "") or ""), + str(getattr(msg, "sender_staff_id", "") or ""), + str(getattr(msg, "create_at", "") or ""), + prompt[:200], + ] + ) + return "fp:" + hashlib.sha1(raw.encode("utf-8")).hexdigest() + + def _seen_or_mark_event(self, key: str) -> bool: + now = time.time() + expire_before = now - max(self.dedupe_ttl_seconds, 30) + + stale_keys = [k for k, ts in self._recent_event_ts.items() if ts < expire_before] + for stale_key in stale_keys: + self._recent_event_ts.pop(stale_key, None) + + if key in self._recent_event_ts: + return True + + self._recent_event_ts[key] = now + return False + + def _is_market_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt) + + if any(k in normalized for k in SALES_HINT_KEYWORDS): + return False + + if "电脑桌" in normalized: + return True + + if any(k in normalized for k in MARKET_HINT_KEYWORDS): + return True + + if "市场" in normalized and any( + k in normalized for k in ("搜索", "飙升词", "热词", "需求供给比", "价格带", "客单价", "洞察", "趋势") + ): + return True + + if "市场" in normalized and "分析" in normalized: + return True + + return False + + def _is_install_media_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt) + if not normalized: + return False + if any(k in normalized for k in INSTALL_MEDIA_HINT_KEYWORDS): + return True + if "安装" in normalized and ("图纸" in normalized or "视频" in normalized): + return True + return False + + def _is_install_media_stitch_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt) + if not normalized: + return False + return any(k in normalized for k in INSTALL_MEDIA_STITCH_HINT_KEYWORDS) + + def _is_video_url(self, raw_url: str) -> bool: + try: + parsed = urllib.parse.urlparse(raw_url) + except Exception: + return False + + if parsed.scheme not in {"http", "https"}: + return False + + host = (parsed.hostname or "").lower() + target = f"{parsed.path or ''}?{parsed.query or ''}" + if VIDEO_EXT_RE.search(target): + return True + + video_hosts = ( + "douyin.com", + "iesdouyin.com", + "bilibili.com", + "youku.com", + "qq.com", + "ixigua.com", + "kuaishou.com", + "mgtv.com", + "1905.com", + ) + return any(host == h or host.endswith(f".{h}") for h in video_hosts) + + def _extract_video_urls(self, text: str, limit: int = 2) -> list: + urls = [] + seen = set() + for match in URL_RE.finditer(text or ""): + raw = match.group(0).rstrip(".,;!?") + if not self._is_video_url(raw): + continue + if raw in seen: + continue + seen.add(raw) + urls.append(raw) + if len(urls) >= limit: + break + return urls + + def _is_video_analysis_prompt(self, prompt: str) -> bool: + if self._is_install_media_prompt(prompt): + return False + + normalized = re.sub(r"\s+", "", (prompt or "")).lower() + if not normalized: + return False + + video_urls = self._extract_video_urls(prompt, limit=1) + if not video_urls: + return False + + if any(k in normalized for k in VIDEO_ANALYSIS_HINT_KEYWORDS): + return True + if any(k in normalized for k in VIDEO_ANALYSIS_ACTION_KEYWORDS): + return True + return len(normalized) <= 120 + + def _needs_video_script(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", (prompt or "")).lower() + if not normalized: + return False + return any(k in normalized for k in VIDEO_SCRIPT_HINT_KEYWORDS) + + def _is_dalongxia_analytics_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", (prompt or "")).lower() + if not normalized: + return False + if any(k in normalized for k in DALONGXIA_ANALYTICS_HINT_KEYWORDS): + return True + if "任务进度" in normalized and ("总览" in normalized or "overview" in normalized): + return True + if "设计师绩效" in normalized or "流程效率" in normalized: + return True + if "primary" in normalized and ("dept" in normalized or "task" in normalized): + return True + return False + + def _parse_analytics_mode(self, prompt: str) -> str: + normalized = re.sub(r"\s+", "", (prompt or "")).lower() + compact = re.sub(r"[^a-z0-9\u4e00-\u9fa5]+", "", normalized) + + mode_aliases = ( + ("primarytaskbranddistribution", "primary_task_brand_distribution"), + ("primarytaskflowefficiency", "primary_task_flow_efficiency"), + ("primarydeptstatistics", "primary_dept_statistics"), + ("primarydeptuserranking", "primary_dept_user_ranking"), + ("primarydeptranking", "primary_dept_ranking"), + ("primarytask", "primary_task_flow_efficiency"), + ("primarydept", "primary_dept_ranking"), + ("designerdeductiondetails", "designer_deduction"), + ("designerscoredetails", "designer_score"), + ("designerdeduction", "designer_deduction"), + ("designerscore", "designer_score"), + ("taskprogressoverview", "task_progress_overview"), + ("taskprogress", "task_progress_overview"), + ("progress", "task_progress_overview"), + ("demandstatistics", "demand_statistics"), + ("designerperformance", "designer_performance"), + ("processefficiency", "process_efficiency"), + ("dimensionanalysis", "dimension_analysis"), + ("overview", "overview"), + ("warnings", "warnings"), + ) + for token, mode in mode_aliases: + if token in compact: + return mode + + if "主部门" in normalized and "人员" in normalized and "排名" in normalized: + return "primary_dept_user_ranking" + if "主部门" in normalized and "品牌" in normalized and "分布" in normalized: + return "primary_task_brand_distribution" + if "主部门" in normalized and "任务流程" in normalized and "效率" in normalized: + return "primary_task_flow_efficiency" + if "主部门" in normalized and "统计" in normalized: + return "primary_dept_statistics" + if "主部门" in normalized and "排名" in normalized: + return "primary_dept_ranking" + if "设计师" in normalized and ("扣分明细" in normalized or "扣分详情" in normalized): + return "designer_deduction" + if "设计师" in normalized and ("评分明细" in normalized or "得分明细" in normalized): + return "designer_score" + if "维度" in normalized and "分析" in normalized: + return "dimension_analysis" + if "流程效率" in normalized: + return "process_efficiency" + if "设计师绩效" in normalized: + return "designer_performance" + if "需求统计" in normalized: + return "demand_statistics" + if "预警" in normalized: + return "warnings" + if "任务进度" in normalized and ("总览" in normalized or "overview" in normalized): + return "task_progress_overview" + if "数据分析" in normalized and "总览" in normalized: + return "overview" + return "overview" + + def _parse_analytics_date_range(self, prompt: str) -> str: + normalized = re.sub(r"\s+", "", (prompt or "")).lower() + if any(k in normalized for k in ("all", "全部", "全量", "所有时间")): + return "all" + if any(k in normalized for k in ("quarter", "季度", "q1", "q2", "q3", "q4")): + return "quarter" + if any(k in normalized for k in ("week", "本周", "上周", "近7天", "最近7天", "7天")): + return "week" + return "month" + + def _parse_analytics_month(self, prompt: str) -> str: + text = prompt or "" + m = re.search(r"(20\d{2})[年/-](\d{1,2})", text) + if not m: + m = re.search(r"(20\d{2})(\d{2})", text) + if m: + year = int(m.group(1)) + month = int(m.group(2)) + if 1 <= month <= 12: + return f"{year:04d}-{month:02d}" + now = time.localtime() + return f"{now.tm_year:04d}-{now.tm_mon:02d}" + + def _parse_analytics_ids(self, prompt: str) -> tuple: + text = prompt or "" + dept_id = "" + user_id = "" + + m = re.search(r"(?:部门ID|deptId|dept_id)[::\s]*([0-9]{1,20})", text, re.IGNORECASE) + if m: + dept_id = m.group(1) + m = re.search(r"(?:用户ID|设计师ID|成员ID|userId|user_id)[::\s]*([0-9]{1,20})", text, re.IGNORECASE) + if m: + user_id = m.group(1) + return dept_id, user_id + + def _normalize_analytics_datetime(self, raw: str, end_of_day: bool = False) -> str: + text = (raw or "").strip() + if not text: + return "" + text = text.replace("/", "-").replace(" ", "T") + if re.match(r"^\d{4}-\d{1,2}-\d{1,2}$", text): + suffix = "23:59:59" if end_of_day else "00:00:00" + return f"{text}T{suffix}" + if re.match(r"^\d{4}-\d{1,2}-\d{1,2}T\d{1,2}$", text): + return f"{text}:00:00" + if re.match(r"^\d{4}-\d{1,2}-\d{1,2}T\d{1,2}:\d{1,2}$", text): + return f"{text}:00" + if re.match(r"^\d{4}-\d{1,2}-\d{1,2}T\d{1,2}:\d{1,2}:\d{1,2}$", text): + return text + return "" + + def _parse_analytics_time_window(self, prompt: str, month: str) -> tuple: + text = prompt or "" + start_time = "" + end_time = "" + + range_match = re.search( + r"(20\d{2}[/-]\d{1,2}[/-]\d{1,2})\s*(?:到|至|~|-)\s*(20\d{2}[/-]\d{1,2}[/-]\d{1,2})", + text, + ) + if range_match: + start_time = self._normalize_analytics_datetime(range_match.group(1), end_of_day=False) + end_time = self._normalize_analytics_datetime(range_match.group(2), end_of_day=True) + + if not start_time: + m = re.search(r"(?:startTime|start_time|开始时间)[::=\s]*([0-9/:\- T]{8,25})", text, re.IGNORECASE) + if m: + start_time = self._normalize_analytics_datetime(m.group(1), end_of_day=False) + if not end_time: + m = re.search(r"(?:endTime|end_time|结束时间)[::=\s]*([0-9/:\- T]{8,25})", text, re.IGNORECASE) + if m: + end_time = self._normalize_analytics_datetime(m.group(1), end_of_day=True) + + if start_time and end_time: + return start_time, end_time + + month_match = re.match(r"^(20\d{2})-(\d{2})$", month or "") + if month_match: + year = int(month_match.group(1)) + mon = int(month_match.group(2)) + first = dt.datetime(year, mon, 1, 0, 0, 0) + if mon == 12: + next_first = dt.datetime(year + 1, 1, 1, 0, 0, 0) + else: + next_first = dt.datetime(year, mon + 1, 1, 0, 0, 0) + last = next_first - dt.timedelta(seconds=1) + return first.strftime("%Y-%m-%dT%H:%M:%S"), last.strftime("%Y-%m-%dT%H:%M:%S") + + now = dt.datetime.now() + first = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + last = now.replace(hour=23, minute=59, second=59, microsecond=0) + return first.strftime("%Y-%m-%dT%H:%M:%S"), last.strftime("%Y-%m-%dT%H:%M:%S") + + def _parse_include_task_progress(self, prompt: str) -> bool: + return bool( + re.search( + r"(含|包含|带上|带|include)\s*任务进度|includeTaskProgress\s*[:=]\s*true", + prompt or "", + re.IGNORECASE, + ) + ) + + def _build_dalongxia_analytics_request(self, mode: str, app_key: str, app_secret: str, params: dict) -> tuple: + timestamp = str(int(time.time() * 1000)) + date_range = params.get("date_range", "month") + month = params.get("month", "") + dept_id = str(params.get("dept_id", "") or "") + user_id = str(params.get("user_id", "") or "") + include_task_progress = bool(params.get("include_task_progress", False)) + include_str = str(include_task_progress).lower() + start_time = params.get("start_time", "") + end_time = params.get("end_time", "") + + if mode == "task_progress_overview": + action = "analytics_task_progress_overview" + payload = "|".join([action, app_key, date_range, month, dept_id, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "dateRange": date_range, + "month": month, + } + if dept_id: + body["deptId"] = dept_id + return "/api/open/analytics/task-progress/overview", "壹点需求系统:任务进度总览", body + + if mode == "demand_statistics": + action = "analytics_demand_statistics" + payload = "|".join([action, app_key, date_range, month, dept_id, user_id, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "dateRange": date_range, + "month": month, + } + if dept_id: + body["deptId"] = dept_id + if user_id: + body["userId"] = user_id + return "/api/open/analytics/demand-statistics", "壹点需求系统:需求统计", body + + if mode == "designer_performance": + action = "analytics_designer_performance" + payload = "|".join([action, app_key, month, dept_id, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "month": month, + } + if dept_id: + body["deptId"] = dept_id + return "/api/open/analytics/designer-performance", "壹点需求系统:设计师绩效", body + + if mode == "process_efficiency": + action = "analytics_process_efficiency" + payload = "|".join([action, app_key, month, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "month": month, + } + return "/api/open/analytics/process-efficiency", "壹点需求系统:流程效率", body + + if mode == "warnings": + action = "analytics_warnings" + payload = "|".join([action, app_key, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + } + return "/api/open/analytics/warnings", "壹点需求系统:预警信息", body + + if mode == "dimension_analysis": + action = "analytics_dimension_analysis" + payload = "|".join([action, app_key, date_range, month, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "dateRange": date_range, + "month": month, + } + return "/api/open/analytics/dimension-analysis", "壹点需求系统:维度分析", body + + if mode == "designer_deduction": + action = "analytics_designer_deduction_details" + detail_user_id = user_id or os.getenv("ANALYTICS_DEFAULT_USER_ID", "38").strip() + payload = "|".join([action, app_key, str(detail_user_id), month, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "userId": str(detail_user_id), + "month": month, + } + return "/api/open/analytics/designer-deduction-details", "壹点需求系统:设计师扣分明细", body + + if mode == "designer_score": + action = "analytics_designer_score_details" + detail_user_id = user_id or os.getenv("ANALYTICS_DEFAULT_USER_ID", "38").strip() + payload = "|".join([action, app_key, str(detail_user_id), month, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "userId": str(detail_user_id), + "month": month, + } + return "/api/open/analytics/designer-score-details", "壹点需求系统:设计师得分明细", body + + if mode == "primary_dept_statistics": + action = "analytics_primary_dept_statistics" + payload = "|".join([action, app_key, start_time, end_time, dept_id, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "startTime": start_time, + "endTime": end_time, + } + if dept_id: + body["deptId"] = dept_id + return "/api/open/analytics/primary-dept/statistics", "壹点需求系统:主部门统计", body + + if mode == "primary_dept_ranking": + action = "analytics_primary_dept_ranking" + payload = "|".join([action, app_key, start_time, end_time, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "startTime": start_time, + "endTime": end_time, + } + return "/api/open/analytics/primary-dept/ranking", "壹点需求系统:主部门排名", body + + if mode == "primary_task_flow_efficiency": + action = "analytics_primary_task_flow_efficiency" + payload = "|".join([action, app_key, start_time, end_time, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "startTime": start_time, + "endTime": end_time, + } + return "/api/open/analytics/primary-task/flow-efficiency", "壹点需求系统:主部门任务流转效率", body + + if mode == "primary_task_brand_distribution": + action = "analytics_primary_task_brand_distribution" + payload = "|".join([action, app_key, start_time, end_time, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "startTime": start_time, + "endTime": end_time, + } + return "/api/open/analytics/primary-task/brand-distribution", "壹点需求系统:主部门任务品牌分布", body + + if mode == "primary_dept_user_ranking": + action = "analytics_primary_dept_user_ranking" + payload = "|".join([action, app_key, start_time, end_time, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "startTime": start_time, + "endTime": end_time, + } + return "/api/open/analytics/primary-dept/user-ranking", "壹点需求系统:主部门成员排名", body + + action = "analytics_overview" + payload = "|".join([action, app_key, date_range, month, dept_id, user_id, include_str, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "dateRange": date_range, + "month": month, + "includeTaskProgress": include_task_progress, + } + if dept_id: + body["deptId"] = dept_id + if user_id: + body["userId"] = user_id + return "/api/open/analytics/overview", "壹点需求系统:数据分析总览", body + + def _format_dalongxia_analytics_result(self, mode: str, title: str, body: dict, data: dict) -> str: + def _f(v): + if v is None: + return "-" + if isinstance(v, float): + s = f"{v:.2f}" + return s.rstrip("0").rstrip(".") + return str(v) + + def _is_num(v): + return isinstance(v, (int, float)) + + def _sum_num(items, key): + total = 0 + found = False + for it in items: + v = it.get(key) if isinstance(it, dict) else None + if _is_num(v): + total += v + found = True + return total if found else None + + query_params = [] + for key in ("dateRange", "month", "deptId", "userId", "includeTaskProgress", "startTime", "endTime"): + if key in body and body[key] not in ("", None): + query_params.append(f"{key}={body[key]}") + + lines = [title] + if query_params: + lines.append(f"- 查询参数: {', '.join(query_params)}") + if isinstance(data, dict): + if data.get("time"): + lines.append(f"- 统计时间: {data.get('time')}") + if data.get("traceId"): + lines.append(f"- traceId: {data.get('traceId')}") + + root = (data or {}).get("data") if isinstance(data, dict) else None + if root is None: + root = data + + if mode == "task_progress_overview" and isinstance(root, dict): + metrics = root.get("metrics") or {} + status = root.get("statusDistribution") or {} + trend = (root.get("trend") or [])[:5] + lines.append( + "- 核心指标: " + f"总任务={_f(metrics.get('totalTasks'))},完成={_f(metrics.get('completedTasks'))}," + f"处理中={_f(metrics.get('inProgressTasks'))},逾期={_f(metrics.get('overdueTasks'))}," + f"完成率={_f(metrics.get('completionRate'))}%" + ) + if status: + parts = [f"{k}={_f(v)}" for k, v in status.items() if _is_num(v)] + if parts: + lines.append(f"- 状态分布: {','.join(parts[:8])}") + if trend: + lines.append("- 趋势(最近5个点):") + for t in trend: + lines.append( + f" - {_f(t.get('date'))}: 新建={_f(t.get('createdCount'))},完成={_f(t.get('completedCount'))}" + ) + return "\n".join(lines) + + if mode == "designer_performance" and isinstance(root, dict): + items = root.get("designerStats") or [] + if not isinstance(items, list): + items = [] + items = sorted( + [it for it in items if isinstance(it, dict)], + key=lambda x: (x.get("completedCount") or 0, x.get("receivedCount") or 0), + reverse=True, + ) + total_received = _sum_num(items, "receivedCount") + total_completed = _sum_num(items, "completedCount") + lines.append( + "- 汇总: " + f"设计师数={len(items)},接收任务={_f(total_received)},完成任务={_f(total_completed)}" + ) + lines.append("- TOP设计师(按完成量):") + for idx, it in enumerate(items[:10], 1): + lines.append( + f" {idx}. {_f(it.get('realName') or it.get('userName'))}|{_f(it.get('deptName'))}|" + f"完成={_f(it.get('completedCount'))}|准时率={_f(it.get('onTimeRate'))}%|完成率={_f(it.get('completionRate'))}%" + ) + return "\n".join(lines) + + if mode == "dimension_analysis" and isinstance(root, dict): + items = root.get("categoryAnalysis") or [] + if not isinstance(items, list): + items = [] + items = sorted( + [it for it in items if isinstance(it, dict)], + key=lambda x: (x.get("totalTasks") or 0), + reverse=True, + ) + lines.append(f"- 类目数: {len(items)}") + lines.append("- TOP类目(按任务量):") + for idx, it in enumerate(items[:10], 1): + lines.append( + f" {idx}. {_f(it.get('categoryName'))}|任务={_f(it.get('totalTasks'))}|" + f"完成={_f(it.get('completedTasks'))}|进行中={_f(it.get('inProgressTasks'))}|完成率={_f(it.get('completionRate'))}%" + ) + return "\n".join(lines) + + if mode == "overview" and isinstance(root, dict): + module_keys = list(root.keys()) + lines.append(f"- 模块: {', '.join(module_keys[:8]) if module_keys else '-'}") + demand = root.get("demandStatistics") or {} + user_stats = demand.get("userStats") if isinstance(demand, dict) else [] + if isinstance(user_stats, list) and user_stats: + lines.append(f"- 需求统计: 用户数={len(user_stats)}") + top = sorted( + [it for it in user_stats if isinstance(it, dict)], + key=lambda x: (x.get("totalDemands") or 0), + reverse=True, + )[:5] + lines.append("- 需求量TOP5:") + for idx, it in enumerate(top, 1): + lines.append( + f" {idx}. {_f(it.get('realName') or it.get('userName'))}|{_f(it.get('deptName'))}|" + f"总需求={_f(it.get('totalDemands'))}|完成={_f(it.get('completedDemands'))}" + ) + task_progress = root.get("taskProgressOverview") or root.get("taskProgress") or {} + if isinstance(task_progress, dict) and task_progress.get("metrics"): + m = task_progress.get("metrics") or {} + lines.append( + "- 任务进度: " + f"总任务={_f(m.get('totalTasks'))},完成={_f(m.get('completedTasks'))},逾期={_f(m.get('overdueTasks'))}" + ) + return "\n".join(lines) + + if mode in { + "primary_dept_ranking", + "primary_dept_statistics", + "primary_task_flow_efficiency", + "primary_task_brand_distribution", + "primary_dept_user_ranking", + }: + items = root if isinstance(root, list) else [] + if not items and isinstance(root, dict): + for key in ("items", "list", "rows", "data"): + if isinstance(root.get(key), list): + items = root.get(key) + break + items = [it for it in items if isinstance(it, dict)] + lines.append(f"- 条数: {len(items)}") + lines.append("- TOP10:") + for idx, it in enumerate(items[:10], 1): + name = it.get("deptName") or it.get("userName") or it.get("brandName") or it.get("name") + task_count = it.get("taskCount") or it.get("totalTasks") or it.get("count") + rate = it.get("approvalRate") or it.get("completionRate") or it.get("onTimeRate") + extra = it.get("avgResponseHours") or it.get("avgCycleHours") + part = f" {idx}. {_f(name)}|任务={_f(task_count)}" + if rate is not None: + part += f"|比率={_f(rate)}%" + if extra is not None: + part += f"|时长={_f(extra)}h" + lines.append(part) + return "\n".join(lines) + + if mode == "warnings": + items = root if isinstance(root, list) else [] + if isinstance(root, dict): + for key in ("warnings", "items", "list", "rows"): + if isinstance(root.get(key), list): + items = root.get(key) + break + if isinstance(items, list): + lines.append(f"- 预警条数: {len(items)}") + for idx, it in enumerate(items[:10], 1): + if isinstance(it, dict): + msg = it.get("message") or it.get("warnMsg") or it.get("content") or it.get("title") + lines.append(f" {idx}. {_f(msg)}") + else: + lines.append(f" {idx}. {_f(it)}") + return "\n".join(lines) + + if mode in {"designer_deduction", "designer_score"}: + items = root if isinstance(root, list) else [] + if isinstance(root, dict): + for key in ("details", "items", "list", "rows", "data"): + if isinstance(root.get(key), list): + items = root.get(key) + break + items = [it for it in items if isinstance(it, dict)] + lines.append(f"- 记录数: {len(items)}") + lines.append("- 前10条:") + for idx, it in enumerate(items[:10], 1): + lines.append( + f" {idx}. 时间={_f(it.get('time') or it.get('createTime') or it.get('date'))}|" + f"分值={_f(it.get('score') or it.get('deductionScore') or it.get('value'))}|" + f"原因={_f(it.get('reason') or it.get('remark') or it.get('content'))}" + ) + return "\n".join(lines) + + # fallback + fallback = json.dumps(data, ensure_ascii=False, indent=2) + if len(fallback) > 1800: + fallback = fallback[:1800] + "\n...(已截断)" + lines.append("- 结果预览:") + lines.append(fallback) + return "\n".join(lines) + + def _build_open_api_sign(self, secret: str, payload: str) -> str: + return hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest() + + def _post_open_api_json(self, base_url: str, path: str, body: dict) -> dict: + url = f"{base_url.rstrip('/')}{path}" + timeout = max(10, min(int(os.getenv("OPEN_API_TIMEOUT", "60")), 300)) + last_err = None + + def _parse_response(resp): + content_type = (resp.headers.get("Content-Type") or "").lower() + if "json" not in content_type: + text = (resp.text or "").strip() + raise RuntimeError(f"HTTP {resp.status_code} 非JSON响应: {text[:300]}") + data = resp.json() + if not resp.ok: + code = data.get("code", "unknown") + msg = data.get("message", data) + raise RuntimeError(f"HTTP {resp.status_code} / code={code} / msg={msg}") + if data.get("code") != 200: + raise RuntimeError(f"code={data.get('code')} / msg={data.get('message')}") + return data.get("data") + + try: + resp = requests.post(url, json=body, timeout=timeout) + return _parse_response(resp) + except Exception as exc: + last_err = exc + self.logger.warning("open api request attempt1 failed: %s", exc) + + try: + with requests.Session() as session: + session.trust_env = False + resp = session.post(url, json=body, timeout=timeout) + return _parse_response(resp) + except Exception as exc: + last_err = exc + self.logger.warning("open api request attempt2 failed: %s", exc) + + raise last_err or RuntimeError("open api request failed") + + def _run_dalongxia_analytics(self, prompt: str) -> str: + base_url = ( + os.getenv("OPEN_API_BASE_URL", "").strip() + or os.getenv("OPEN_API_BASE", "").strip() + or "https://qd.yydd.ai" + ) + app_key = os.getenv("OPEN_API_APP_KEY", "").strip() + app_secret = os.getenv("OPEN_API_APP_SECRET", "").strip() + if not app_key or not app_secret: + return "壹点需求系统查询失败:缺少 OPEN_API_APP_KEY / OPEN_API_APP_SECRET。" + + mode = self._parse_analytics_mode(prompt) + date_range = self._parse_analytics_date_range(prompt) + month = self._parse_analytics_month(prompt) + dept_id, user_id = self._parse_analytics_ids(prompt) + include_task_progress = self._parse_include_task_progress(prompt) + start_time, end_time = self._parse_analytics_time_window(prompt, month) + try: + path, title, body = self._build_dalongxia_analytics_request( + mode=mode, + app_key=app_key, + app_secret=app_secret, + params={ + "date_range": date_range, + "month": month, + "dept_id": dept_id, + "user_id": user_id, + "include_task_progress": include_task_progress, + "start_time": start_time, + "end_time": end_time, + }, + ) + data = self._post_open_api_json(base_url, path, body) + wrapped = data if isinstance(data, dict) and ("data" in data or "traceId" in data or "time" in data) else {"data": data} + return self._format_dalongxia_analytics_result(mode=mode, title=title, body=body, data=wrapped) + except Exception as exc: + return f"壹点需求系统查询失败: {exc}" + + def _is_sales_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt).lower() + return any(k.lower() in normalized for k in SENSITIVE_SALES_KEYWORDS) + + def _explicit_amount_requested(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt).lower() + return any(k.lower() in normalized for k in EXPLICIT_AMOUNT_KEYWORDS) + + def _mask_sensitive_amounts(self, prompt: str, answer: str) -> str: + if not self._is_sales_prompt(prompt): + return answer + if self._explicit_amount_requested(prompt): + return answer + + filtered_lines = [] + removed = 0 + for line in answer.splitlines(): + if re.search(r"(金额|退款率|费比|收入|利润|成本|客单价|GMV|毛利|\d[\d,\.]*\s*(元|万元|万|亿))", line, re.IGNORECASE): + removed += 1 + continue + filtered_lines.append(line) + + cleaned = "\n".join(filtered_lines).strip() + if removed > 0: + note = "(已按配置自动隐藏金额类敏感指标,仅保留件数/订单维度。)" + if cleaned: + cleaned = f"{cleaned}\n\n{note}" + else: + cleaned = note + + return cleaned or "已按配置隐藏金额类敏感指标,请改问件数/订单/原因结构维度。" + + def _extract_market_subject(self, prompt: str) -> str: + normalized = prompt.strip() + + def clean_subject(raw: str) -> str: + v = raw.strip().replace("\u201c", "").replace("\u201d", "").strip("\"'") + v = re.sub(r"\s+", "", v) + v = re.sub(r"^(帮我|请|麻烦|给我|我想|想要|想看|想做|分析|查看|查询|关于|整理一份|整理|输出|生成)+", "", v) + v = re.sub(r"^(近(?:[一二三四五六七八九十两半0-9]+)(?:天|周|个月|月|年)|过去(?:[一二三四五六七八九十两半0-9]+)(?:天|周|个月|月|年)|近30天|近7天|近15天|近一个月|近三个月|近半年|近一年)+", "", v) + v = re.sub(r"(的|情况|数据|分析)+$", "", v) + return v.strip() + + def pick(raw: str): + v = clean_subject(raw) + if len(v) < 2: + return None + if v in {"市场", "行业", "类目", "竞品", "趋势", "市场数据", "关键词", "搜索"}: + return None + return v + + if "电脑桌" in normalized: + return "电脑桌" + + m = re.search(r"[\"\u201c\u201d']([^\"\u201c\u201d']{2,30})[\"\u201c\u201d']", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search( + r"关于([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据|市场情况)", + normalized, + ) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search( + r"([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据)", + normalized, + ) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search(r"([\u4e00-\u9fa5A-Za-z0-9]{2,20})类目", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search(r"([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:市场|市场搜索|关键词|飙升词|热词)", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search(r"分析(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search(r"查(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + return "市场主题" + + def _extract_order_id(self, prompt: str) -> str: + text = prompt.strip() + for pattern in ( + r"订单号[::\s]*([A-Za-z0-9-]{8,40})", + r"订单编号[::\s]*([A-Za-z0-9-]{8,40})", + r"订单[::\s]*([A-Za-z0-9-]{8,40})", + ): + m = re.search(pattern, text, re.IGNORECASE) + if m: + return m.group(1).strip() + + m2 = ORDER_ID_RE.search(text) + if m2: + return m2.group(0).strip() + return "" + + def _extract_product_no(self, prompt: str) -> str: + text = prompt.strip() + for pattern in ( + r"(?:产品货号|货号|商品货号|SKU编码|SKUID|sku编码|skuid|sku)[::\s]*([A-Za-z0-9_-]{5,40})", + ): + m = re.search(pattern, text, re.IGNORECASE) + if m: + return m.group(1).strip() + m2 = re.search(r"\b[A-Za-z]{1,4}\d{4,}[A-Za-z0-9_-]{2,}\b", text) + if m2: + return m2.group(0).strip() + return "" + + def _is_code_prompt(self, prompt: str) -> bool: + normalized = (prompt or "").lower() + hints = ( + "代码", + "sql", + "python", + "java", + "javascript", + "脚本", + "bash", + "shell", + "命令", + "函数", + "class", + "yaml", + "json", + "正则", + ) + return any(h in normalized for h in hints) + + def _clean_dialog_noise(self, answer: str) -> str: + if not answer: + return answer + + cleaned = [] + for raw in answer.splitlines(): + line = raw.strip() + lower = line.lower() + if not line: + cleaned.append(raw) + continue + + if line.startswith("[plugins]"): + continue + if "registered" in lower and "tool" in lower: + continue + if "no toolbeencalled" in lower: + continue + if "without a preceding text-to-speech" in lower: + continue + if line.startswith("原始输出:"): + continue + if line.startswith("{") and "\"runId\"" in line: + continue + + cleaned.append(raw) + + out = "\n".join(cleaned).strip() + return out or answer + + def _trim_noncode_blocks(self, prompt: str, answer: str) -> str: + if not answer or self._is_code_prompt(prompt): + return answer + + pattern = re.compile(r"```[\s\S]*?```", re.MULTILINE) + + def _repl(match): + block = match.group(0) + lines = [ln for ln in block.splitlines() if ln.strip()] + if len(lines) <= 12: + return block + return "```text\n[代码块已省略,如需完整代码请明确说明]\n```" + + return pattern.sub(_repl, answer) + + def _disable_card_temporarily(self, reason: str) -> None: + self.card_disabled_until = time.time() + self.card_disable_seconds + self.logger.warning("card reply disabled for %ss, reason=%s", self.card_disable_seconds, reason) + + def _extract_incoming_download_codes(self, msg: ChatbotMessage) -> list: + try: + raw_codes = msg.get_image_list() or [] + except Exception: + self.logger.exception("failed to read incoming image list") + return [] + + seen = set() + codes = [] + for raw in raw_codes: + code = str(raw or "").strip() + if not code or code in seen: + continue + seen.add(code) + codes.append(code) + if len(codes) >= self.image_max_images: + break + return codes + + def _guess_image_ext(self, content_type: str, source_url: str) -> str: + ctype = (content_type or "").split(";")[0].strip().lower() + ext = "" + if ctype: + ext = mimetypes.guess_extension(ctype) or "" + if not ext: + path = urllib.parse.urlparse(source_url or "").path + _, path_ext = os.path.splitext(path) + ext = path_ext + ext = (ext or ".jpg").lower() + if ext.startswith("."): + ext = ext[1:] + if ext == "jpeg": + return "jpg" + if ext not in {"jpg", "png", "webp", "gif", "bmp"}: + return "jpg" + return ext + + def _upload_chat_image_bytes(self, image_bytes: bytes, ext: str, conversation_id: str) -> str: + if not self.image_upload_to_s3: + return "" + if boto3 is None or BotoConfig is None: + return "" + if not (self.s3_bucket and self.s3_access_key and self.s3_secret_key): + return "" + + safe_conv = self._safe_session_component(str(conversation_id or "chat")) + stamp = time.strftime("%Y%m%d%H%M%S") + filename = f"{safe_conv}_{stamp}_{uuid.uuid4().hex[:8]}.{ext}" + object_key = f"{self.s3_key_prefix}chat-images/{filename}" + content_type = f"image/{'jpeg' if ext == 'jpg' else ext}" + with tempfile.TemporaryDirectory(prefix="chat_image_") as tmpdir: + local_path = os.path.join(tmpdir, filename) + with open(local_path, "wb") as fh: + fh.write(image_bytes) + self._upload_to_s3(local_path, object_key, content_type) + return self._build_public_s3_url(object_key) + + def _analyze_image_with_local_vlm(self, image_bytes: bytes, user_prompt: str, image_index: int) -> str: + if not self.image_analysis_enabled: + return "" + payload = { + "model": self.image_analysis_model, + "stream": False, + "messages": [ + { + "role": "user", + "content": ( + "你现在只做OCR和画面客观描述,不做任何分析和建议。\n" + "请输出格式:\n" + "OCR_TEXT_BEGIN\n" + "<逐行文字,尽量保留原样,包含中文/英文/数字/LOGO字母>\n" + "OCR_TEXT_END\n" + "SCENE_DESC_BEGIN\n" + "<画面客观描述:人物/物体/场景/颜色/布局;若无法判断写 UNKNOWN_SCENE>\n" + "SCENE_DESC_END\n" + "规则:\n" + "- 只能输出OCR和画面描述,不要解释;\n" + "- 不确定字符用 ? 标记;\n" + "- 完全无文字时输出 NO_TEXT;\n" + "- 画面描述不得给建议、结论或推断。\n" + f"图片序号:{image_index}\n" + f"用户问题:{user_prompt or '请识别并分析图片'}" + ), + "images": [base64.b64encode(image_bytes).decode("ascii")], + } + ], + } + try: + resp = requests.post( + "http://127.0.0.1:11434/api/chat", + json=payload, + timeout=self.image_analysis_timeout, + ) + resp.raise_for_status() + data = resp.json() + text = str((data.get("message") or {}).get("content", "")).strip() + if not text: + return "" + return text[: self.image_vision_output_max_chars] + except Exception as exc: + self.logger.warning("image vlm analysis failed image=%s err=%s", image_index, exc) + return "" + + def _build_image_context_from_dingtalk(self, msg: ChatbotMessage, prompt: str, download_codes: list) -> dict: + blocks = [] + uploaded_urls = [] + warnings = [] + used_images = 0 + + for idx, code in enumerate(download_codes, 1): + try: + download_url = self.get_image_download_url(code) + if not download_url: + warnings.append(f"图片{idx}下载地址获取失败") + continue + + resp = requests.get(download_url, timeout=self.image_download_timeout) + resp.raise_for_status() + image_bytes = resp.content or b"" + if not image_bytes: + warnings.append(f"图片{idx}为空内容") + continue + if len(image_bytes) > self.image_max_bytes: + warnings.append(f"图片{idx}过大,已跳过") + continue + + ext = self._guess_image_ext(resp.headers.get("Content-Type", ""), download_url) + s3_url = "" + try: + s3_url = self._upload_chat_image_bytes(image_bytes, ext, str(getattr(msg, "conversation_id", ""))) + except Exception as exc: + self.logger.warning("upload incoming image failed idx=%s err=%s", idx, exc) + if s3_url: + uploaded_urls.append(s3_url) + + vision = self._analyze_image_with_local_vlm(image_bytes, prompt, idx) + if not vision: + warnings.append(f"图片{idx}识别失败") + continue + + item_lines = [f"图片{idx}感知结果(OCR+场景):"] + item_lines.append(vision) + blocks.append("\n".join(item_lines)) + used_images += 1 + except Exception as exc: + warnings.append(f"图片{idx}处理异常: {exc}") + + result = { + "used_images": used_images, + "context_text": "\n\n".join(blocks).strip(), + "uploaded_urls": uploaded_urls, + "warnings": warnings, + } + self.logger.info( + "image context built: incoming=%s used=%s uploaded=%s warnings=%s", + len(download_codes), + used_images, + len(uploaded_urls), + len(warnings), + ) + return result + + def _merge_prompt_with_image_context(self, prompt: str, image_context: str) -> str: + base = (prompt or "").strip() or "请识别并分析我上传的图片内容。" + return ( + f"{base}\n\n" + "【图片OCR与场景结果(本地模型)】\n" + f"{image_context}\n\n" + "请仅基于以上OCR文本和场景描述做理解、分析和建议。\n" + "注意:图片已完成本地识别,不要再尝试访问图片URL,也不要调用browser/image工具。" + ).strip() + + def _sanitize_answer(self, prompt: str, answer: str) -> str: + if self._is_market_prompt(prompt) or self._is_install_media_prompt(prompt): + return answer + + if not self.hide_source_urls: + return answer + + keep_map = {} + + def _keep_or_mask_url(match): + raw = match.group(0) + try: + host = (urllib.parse.urlparse(raw).hostname or "").lower() + except Exception: + host = "" + + if host and ( + host in self.unmask_url_hosts + or host == "feishu.cn" + or host.endswith(".feishu.cn") + ): + token = f"__KEEP_URL_{len(keep_map)}__" + keep_map[token] = raw + return token + + return "[内部数据源地址已脱敏]" + + out = URL_RE.sub(_keep_or_mask_url, answer) + out = IP_RE.sub("[内部地址已脱敏]", out) + out = DOMAIN_RE.sub("[内部域名已脱敏]", out) + + # 保底脱敏(防止某些格式未被正则覆盖) + for token in ("hq.yucekj.com", "yucekj.com", "218.93.9.141"): + out = out.replace(token, "[内部数据源已脱敏]") + + for token, raw in keep_map.items(): + out = out.replace(token, raw) + + return out + + def _run_market_brief(self, subject: str) -> str: + cmd = [self.market_brief_bin, subject] + self.logger.info("market-brief call: subject=%s", subject) + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.market_brief_timeout, + ) + output = (proc.stdout or "").strip() + if proc.returncode != 0: + err = (proc.stderr or output or "market brief error").strip() + return f"市场分析执行失败: {err[:500]}" + if not output: + return "市场分析执行完成,但未返回文本结果。" + return output + + def _parse_install_media_output(self, output: str) -> dict: + parsed = {"status": "", "mappings": [], "media": [], "warnings": []} + current_set = "" + in_warnings = False + for raw in (output or "").splitlines(): + line = raw.strip() + if not line: + continue + if line.startswith("STATUS:"): + parsed["status"] = line.split(":", 1)[1].strip() + in_warnings = False + continue + if line.startswith("NAME:"): + current_set = line.split(":", 1)[1].strip() + in_warnings = False + continue + if line == "WARNINGS:": + in_warnings = True + current_set = "" + continue + if in_warnings and line.startswith("- "): + parsed["warnings"].append(line[2:].strip()) + continue + if not line.startswith("{"): + continue + try: + obj = json.loads(line) + except Exception: + continue + if current_set == "order_to_product_mapping": + parsed["mappings"].append(obj) + elif current_set == "install_media_links": + parsed["media"].append(obj) + return parsed + + def _run_install_media_lookup(self, prompt: str) -> str: + order_id = self._extract_order_id(prompt) + product_no = self._extract_product_no(prompt) + if not order_id and not product_no: + return "请提供订单号(或产品货号),我将按“订单号 -> 货号 -> 安装图纸/视频库”流程查询。" + + cmd = [self.install_media_bin] + if order_id: + cmd += ["--order-id", order_id] + if product_no: + cmd += ["--sku-id", product_no] + cmd += ["--mysql-schema", "jiansunblueprint", "--limit", "200"] + + self.logger.info("install-media call: order_id=%s product_no=%s", order_id or "-", product_no or "-") + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.install_media_timeout, + ) + except subprocess.TimeoutExpired: + return f"安装资源查询超时(>{self.install_media_timeout}秒),请重试。" + + raw_output = (proc.stdout or "").strip() + if proc.returncode != 0 and not raw_output: + err = (proc.stderr or "install media lookup error").strip() + return f"安装资源查询失败: {err[:500]}" + + parsed = self._parse_install_media_output(raw_output) + mappings = parsed.get("mappings", []) + media_rows = parsed.get("media", []) + warnings = parsed.get("warnings", []) + + product_nos = sorted( + { + str(x.get("product_no", "")).strip() + for x in mappings + if str(x.get("product_no", "")).strip() and str(x.get("product_no", "")).strip() != "(none)" + } + ) + + drawing_rows = [x for x in media_rows if str(x.get("media_type", "")).lower() == "drawing"] + video_rows = [x for x in media_rows if str(x.get("media_type", "")).lower() == "video"] + stitch_required = self.install_media_stitch_enabled or self._is_install_media_stitch_prompt(prompt) + stitch_url = "" + stitch_note = "" + if stitch_required and drawing_rows: + drawing_urls = [] + seen_urls = set() + for row in drawing_rows: + url = str(row.get("media_url", "")).strip() + if not url or url in seen_urls: + continue + seen_urls.add(url) + drawing_urls.append(url) + if len(drawing_urls) >= self.install_media_stitch_min_images: + product_hint = ( + (product_nos[0] if product_nos else "") + or product_no + or order_id + or "unknown" + ) + stitch_url, stitch_note = self._stitch_and_upload_install_media( + drawing_urls=drawing_urls[: self.install_media_stitch_max_images], + product_hint=product_hint, + ) + + lines = ["安装资源查询结果"] + if order_id: + lines.append(f"- 订单号: {order_id}") + if product_nos: + lines.append(f"- 命中货号: {', '.join(product_nos[:8])}") + elif product_no: + lines.append(f"- 输入货号: {product_no}") + + lines.append(f"- 图纸: {len(drawing_rows)} 条,视频: {len(video_rows)} 条") + if stitch_url: + lines.append(f"- 拼接图: {stitch_url}") + elif stitch_note and self._is_install_media_stitch_prompt(prompt): + lines.append(f"- 拼接图: {stitch_note}") + + display_rows = media_rows + if stitch_url: + # 用户要求仅返回拼接图,不再返回原始多条图纸链接;视频链接保留 + display_rows = [x for x in media_rows if str(x.get("media_type", "")).lower() != "drawing"] + + if display_rows: + lines.append("- 资源链接(前20条):") + for i, row in enumerate(display_rows[:20], 1): + mtype = str(row.get("media_type", "")).lower() + tag = "图纸" if mtype == "drawing" else ("视频" if mtype == "video" else "资源") + url = str(row.get("media_url", "")).strip() + if url: + lines.append(f"{i}. [{tag}] {url}") + if len(display_rows) > 20: + lines.append(f"- 其余 {len(display_rows) - 20} 条已省略。") + else: + if stitch_url: + lines.append("- 原始图纸链接已按配置隐藏。") + else: + lines.append("- 未查到安装图纸/视频链接。") + + if warnings: + lines.append("- 警告:") + for w in warnings[:3]: + lines.append(f" - {w}") + + return "\n".join(lines) + + def _run_video_analysis(self, prompt: str) -> str: + if not self.video_analysis_enabled: + return "视频分析功能未启用。" + + video_urls = self._extract_video_urls(prompt, limit=self.video_analysis_max_urls) + if not video_urls: + return "请提供可访问的视频链接(http/https),我再开始分析。" + + api_key = ( + os.getenv("DASHSCOPE_API_KEY", "").strip() + or os.getenv("BAILIAN_API_KEY", "").strip() + or os.getenv("QWEN_API_KEY", "").strip() + ) + if not api_key: + return "视频分析未配置API Key,请设置 DASHSCOPE_API_KEY 后重试。" + + need_script = self._needs_video_script(prompt) + user_goal = self._remove_image_urls(prompt, video_urls).strip() + if not user_goal: + if need_script: + user_goal = "请深度拆解该视频,并输出可直接复刻的分镜脚本和拍摄执行方案。" + else: + user_goal = "请分析该视频的核心内容、关键信息和可执行建议。" + elif "视频" not in user_goal and "video" not in user_goal.lower(): + user_goal = f"{user_goal}\n请围绕视频内容输出结论。" + + content_items = [{"type": "video_url", "video_url": {"url": video_urls[0]}}] + content_items.append({"type": "text", "text": user_goal}) + + system_prompt = ( + "你是视频内容分析助手。请基于视频画面和字幕内容输出结构化结论,避免臆测。" + ) + if need_script: + system_prompt = ( + "你是资深短视频导演与编剧。请基于视频内容做可执行复刻拆解。\n" + "输出必须包含:\n" + "1) 视频定位与核心卖点(1-2句)\n" + "2) 时间轴分镜脚本表(时间段/镜头景别/画面内容/人物动作/机位与运镜/字幕或口播/BGM与音效)\n" + "3) 拍摄执行清单(场景、道具、服装、灯光、设备)\n" + "4) 剪辑脚本(节奏点、转场、卡点、字幕样式)\n" + "5) 复刻风险与规避建议(至少3条)\n" + "6) 可直接给拍摄团队的最终脚本(按步骤)\n" + "要求:具体、可落地、少空话。" + ) + + payload = { + "model": self.video_analysis_model, + "messages": [ + { + "role": "system", + "content": system_prompt, + }, + { + "role": "user", + "content": content_items, + }, + ], + "temperature": 0.2, + "max_tokens": self.video_analysis_max_tokens, + } + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + self.logger.info("video-analysis call: model=%s url_count=%s", self.video_analysis_model, len(video_urls)) + try: + resp = requests.post( + self.video_analysis_base_url, + headers=headers, + json=payload, + timeout=self.video_analysis_timeout, + ) + except Exception as exc: + self.logger.error("video-analysis request failed: %s", exc) + return f"视频分析调用失败:{exc}" + + text = "" + if resp.status_code >= 400: + try: + err_data = resp.json() + err_msg = ( + err_data.get("error", {}).get("message") + or err_data.get("message") + or resp.text + ) + except Exception: + err_msg = resp.text + return f"视频分析失败(HTTP {resp.status_code}):{str(err_msg).strip()[:300]}" + + try: + data = resp.json() + choices = data.get("choices") or [] + message = (choices[0] or {}).get("message", {}) if choices else {} + content = message.get("content", "") + if isinstance(content, list): + chunks = [] + for item in content: + if isinstance(item, dict): + t = str(item.get("text", "")).strip() + if t: + chunks.append(t) + text = "\n".join(chunks).strip() + else: + text = str(content or "").strip() + except Exception as exc: + self.logger.exception("video-analysis parse failed") + return f"视频分析返回解析失败:{exc}" + + if not text: + return "视频分析完成,但未返回有效文本结果。" + + if len(video_urls) > 1: + return f"{text}\n\n(已按配置先分析第1个视频链接;如需逐条分析请分别发送。)" + return text + + def _stitch_and_upload_install_media(self, drawing_urls: list, product_hint: str) -> tuple: + if Image is None: + return "", "未执行(缺少 Pillow 依赖)" + if boto3 is None or BotoConfig is None: + return "", "未执行(缺少 boto3 依赖)" + if not (self.s3_bucket and self.s3_access_key and self.s3_secret_key): + return "", "未执行(S3 配置不完整)" + + images = [] + try: + for url in drawing_urls: + try: + resp = requests.get(url, timeout=self.install_media_fetch_timeout) + resp.raise_for_status() + with Image.open(io.BytesIO(resp.content)) as img: + images.append(img.convert("RGB")) + except Exception as exc: + self.logger.warning("install-media image download failed: %s %s", url, exc) + + if len(images) < self.install_media_stitch_min_images: + return "", "未执行(可下载图纸不足)" + + target_width = min(max(img.width for img in images), self.install_media_max_width) + resized_images = [] + total_height = 0 + for img in images: + if img.width != target_width: + new_height = max(1, int(img.height * target_width / img.width)) + if hasattr(Image, "Resampling"): + resized = img.resize((target_width, new_height), Image.Resampling.LANCZOS) + else: + resized = img.resize((target_width, new_height), Image.LANCZOS) + else: + resized = img.copy() + resized_images.append(resized) + total_height += resized.height + + stitched = Image.new("RGB", (target_width, total_height), (255, 255, 255)) + y = 0 + for img in resized_images: + stitched.paste(img, (0, y)) + y += img.height + + safe_product = re.sub(r"[^A-Za-z0-9_-]+", "-", (product_hint or "unknown")).strip("-")[:40] or "unknown" + stamp = time.strftime("%Y%m%d%H%M%S") + filename = f"{safe_product}_{stamp}_{uuid.uuid4().hex[:8]}.jpg" + object_key = f"{self.s3_key_prefix}install-media/{filename}" + + with tempfile.TemporaryDirectory(prefix="install_media_") as tmpdir: + local_path = os.path.join(tmpdir, filename) + stitched.save(local_path, format="JPEG", quality=88, optimize=True) + self._upload_to_s3(local_path, object_key, "image/jpeg") + + return self._build_public_s3_url(object_key), "" + except Exception as exc: + self.logger.exception("install-media stitch/upload failed") + return "", f"未执行({exc})" + finally: + for img in images: + try: + img.close() + except Exception: + pass + + def _upload_to_s3(self, file_path: str, object_key: str, content_type: str) -> None: + endpoint = self.s3_endpoint or None + config = BotoConfig(s3={"addressing_style": "path" if self.s3_path_style else "virtual"}) + client = boto3.client( + "s3", + endpoint_url=endpoint, + region_name=self.s3_region, + aws_access_key_id=self.s3_access_key, + aws_secret_access_key=self.s3_secret_key, + config=config, + ) + with open(file_path, "rb") as fh: + kwargs = { + "Bucket": self.s3_bucket, + "Key": object_key, + "Body": fh, + "ContentType": content_type, + } + if self.s3_acl: + kwargs["ACL"] = self.s3_acl + client.put_object(**kwargs) + + def _build_public_s3_url(self, object_key: str) -> str: + safe_key = urllib.parse.quote(object_key, safe="/-_.~") + if self.s3_public_domain: + return f"{self.s3_public_domain.rstrip('/')}/{safe_key}" + if self.s3_endpoint: + base = self.s3_endpoint.rstrip("/") + if self.s3_path_style: + return f"{base}/{self.s3_bucket}/{safe_key}" + return f"{base}/{safe_key}" + return safe_key + + def _run_openclaw(self, session_id: str, prompt: str) -> str: + cmd = [ + self.openclaw_bin, + "agent", + "--session-id", + session_id, + "--message", + prompt, + "--json", + "--timeout", + str(self.openclaw_timeout), + ] + self.logger.info("openclaw call: session=%s", session_id) + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.openclaw_timeout + 20, + ) + except subprocess.TimeoutExpired: + self.logger.error("openclaw call timeout: session=%s timeout=%ss", session_id, self.openclaw_timeout) + return f"OpenClaw 调用超时(>{self.openclaw_timeout}秒),请重试。" + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "openclaw error").strip() + return f"OpenClaw 调用失败: {err[:500]}" + + try: + raw_stdout = (proc.stdout or "").strip() + decoder = json.JSONDecoder() + data = None + parse_error = None + try: + data, _ = decoder.raw_decode(raw_stdout) + except Exception as exc: + parse_error = exc + idx = raw_stdout.find("{") + while idx != -1: + candidate = raw_stdout[idx:] + try: + data, _ = decoder.raw_decode(candidate) + break + except Exception: + idx = raw_stdout.find("{", idx + 1) + if data is None: + raise parse_error + + payloads = data.get("result", {}).get("payloads", []) + text_items = [p.get("text", "") for p in payloads if isinstance(p, dict)] + answer = "\n".join(t for t in text_items if t).strip() + if not answer: + answer = "任务已执行,但未返回文本结果。" + + market_fallback_hints = ( + "无法直接从页面抓取", + "需要登录后的操作", + "工具版本不支持点击交互", + "无法正常访问该系统", + "您没有权限访问此页面", + "尚未登录", + "请先前往登录页登录", + "device identity required", + "控制用户界面需要HTTPS", + ) + if self._is_market_prompt(prompt) and any(h in answer for h in market_fallback_hints): + subject = self._extract_market_subject(prompt) + fallback = self._run_market_brief(subject) + return f"已切换为市场分析直连模式({subject}):\n\n{fallback}" + + return answer + except Exception as exc: + self.logger.exception("parse openclaw output failed") + raw = (proc.stdout or "").strip() + return f"结果解析失败: {exc}.\n原始输出: {raw[:500]}" + + def _is_image_url(self, raw_url: str) -> bool: + try: + parsed = urllib.parse.urlparse(raw_url) + except Exception: + return False + + host = (parsed.hostname or "").lower() + path = (parsed.path or "").lower() + if IMAGE_EXT_RE.search(path): + return True + if host.startswith("img.") or "image" in host: + return True + return False + + def _extract_image_urls(self, text: str, limit: int = 5) -> list: + urls = [] + seen = set() + for match in URL_RE.finditer(text or ""): + raw = match.group(0).rstrip(".,;!?") + if not self._is_image_url(raw): + continue + if raw in seen: + continue + seen.add(raw) + urls.append(raw) + if len(urls) >= limit: + break + return urls + + def _remove_image_urls(self, text: str, urls: list) -> str: + out = text + for u in urls: + out = out.replace(u, "") + out = re.sub(r"\n{3,}", "\n\n", out) + return out.strip() + + def _build_action_buttons(self) -> list: + if not self.action_card_btn_url: + return [] + return [ + { + "text": self.action_card_btn_text or "打开链接", + "url": self.action_card_btn_url, + "iosUrl": self.action_card_btn_url, + "color": "gray", + } + ] + + def _is_card_reply_ok(self, reply_obj) -> bool: + if reply_obj is None: + return False + card_instance_id = getattr(reply_obj, "card_instance_id", None) + if isinstance(card_instance_id, str): + return bool(card_instance_id.strip()) + return bool(card_instance_id) + + def _reply_message(self, msg: ChatbotMessage, text: str, prefer_card: bool = True) -> bool: + payload = (text or "").strip() or "任务已执行,但未返回文本结果。" + + if self.reply_style != "action_card": + prefer_card = False + if self.card_disabled_until > time.time(): + prefer_card = False + + if prefer_card: + image_urls = self._extract_image_urls(payload, limit=5) + if image_urls: + slider = [(f"图片{i + 1}", u) for i, u in enumerate(image_urls)] + markdown = self._remove_image_urls(payload, image_urls) + if not markdown: + markdown = "已整理相关图片如下:" + try: + card = self.reply_carousel_card( + incoming_message=msg, + markdown=markdown, + image_slider=slider, + button_text=self.action_card_btn_text or "查看", + title=self.action_card_title, + logo=self.action_card_logo, + ) + if self._is_card_reply_ok(card): + return True + self.logger.warning("carousel card reply returned empty id, fallback to action card/text") + self._disable_card_temporarily("carousel_card_empty_id") + except Exception as exc: + self.logger.exception("carousel card reply failed, fallback to action card") + if "Card.Instance.Write" in str(exc) or "AccessDenied" in str(exc): + self._disable_card_temporarily("card_permission_denied") + + markdown = payload + if self.action_card_title: + markdown = f"### {self.action_card_title}\n\n{payload}" + try: + card = self.reply_markdown_button( + incoming_message=msg, + markdown=markdown, + button_list=self._build_action_buttons(), + tips=self.action_card_tips, + title=self.action_card_title, + logo=self.action_card_logo, + ) + if self._is_card_reply_ok(card): + return True + self.logger.warning("action card reply returned empty id, fallback to text") + self._disable_card_temporarily("action_card_empty_id") + except Exception as exc: + self.logger.exception("action card reply failed, fallback to text") + if "Card.Instance.Write" in str(exc) or "AccessDenied" in str(exc): + self._disable_card_temporarily("card_permission_denied") + + resp = self.reply_text(payload, msg) + if resp is None: + self.logger.error("text reply failed") + return False + return True + + async def process(self, callback: CallbackMessage): + try: + msg = ChatbotMessage.from_dict(callback.data) + except Exception: + self.logger.exception("invalid callback payload") + return AckMessage.STATUS_BAD_REQUEST, "bad payload" + + text = "" + if msg.text and getattr(msg.text, "content", None): + text = msg.text.content.strip() + else: + text_list = msg.get_text_list() or [] + text = text_list[0].strip() if text_list else "" + + incoming_image_codes = self._extract_incoming_download_codes(msg) + + if not text and not incoming_image_codes: + return AckMessage.STATUS_OK, "ignored" + + if not self._should_handle(msg, text): + return AckMessage.STATUS_OK, "ignored" + + prompt = self._normalize_prompt(text) + if not prompt: + if incoming_image_codes: + prompt = "请识别并分析我上传的图片内容,输出结论和建议。" + else: + self._reply_message( + msg, + f"用法: {self.prefix} 你的问题\n例如: {self.prefix} 查询亿家达旗舰店上周业绩", + prefer_card=False, + ) + return AckMessage.STATUS_OK, "ok" + + event_key = self._event_key(callback, msg, prompt) + if self._seen_or_mark_event(event_key): + self.logger.info( + "duplicate callback ignored: key=%s conversation=%s sender=%s", + event_key, + getattr(msg, "conversation_id", ""), + getattr(msg, "sender_id", ""), + ) + return AckMessage.STATUS_OK, "duplicate_ignored" + + if self._is_smalltalk_prompt(prompt): + try: + self._reply_message(msg, self.smalltalk_reply_text, prefer_card=False) + except Exception: + self.logger.exception("failed to send smalltalk reply") + return AckMessage.STATUS_OK, "smalltalk_ok" + + if self.send_processing_hint: + hint = self.processing_hint_text or "已收到,正在处理,请稍等。" + try: + self.reply_text(hint, msg) + except Exception: + self.logger.exception("failed to send processing hint") + + conversation_key = re.sub(r"[^A-Za-z0-9_-]+", "-", str(getattr(msg, "conversation_id", "") or "default")) + conv_lock = self._conversation_locks.setdefault(conversation_key, asyncio.Lock()) + + async with conv_lock: + session_id = self._session_id(msg) + image_context_result = {"used_images": 0, "context_text": "", "uploaded_urls": [], "warnings": []} + prompt_for_exec = prompt + try: + async with self._work_semaphore: + self.logger.info( + "worker acquired: session=%s conversation=%s max_parallel=%s", + session_id, + conversation_key, + self.max_parallel_jobs, + ) + if incoming_image_codes and not self._is_install_media_prompt(prompt): + image_context_result = await asyncio.to_thread( + self._build_image_context_from_dingtalk, + msg, + prompt, + incoming_image_codes, + ) + context_text = image_context_result.get("context_text", "") + if context_text: + prompt_for_exec = self._merge_prompt_with_image_context(prompt, context_text) + if self._is_install_media_prompt(prompt): + answer = await asyncio.to_thread(self._run_install_media_lookup, prompt) + elif self._is_dalongxia_analytics_prompt(prompt): + answer = await asyncio.to_thread(self._run_dalongxia_analytics, prompt) + elif self._is_video_analysis_prompt(prompt): + answer = await asyncio.to_thread(self._run_video_analysis, prompt) + elif self._is_market_prompt(prompt): + subject = self._extract_market_subject(prompt) + answer = await asyncio.to_thread(self._run_market_brief, subject) + else: + answer = await asyncio.to_thread(self._run_openclaw, session_id, prompt_for_exec) + except Exception: + self.logger.exception("worker execution failed: session=%s", session_id) + answer = "系统执行异常,请稍后重试。" + + if incoming_image_codes and image_context_result.get("used_images", 0) == 0: + warnings = image_context_result.get("warnings", []) + warn_text = ";".join(warnings[:2]) if warnings else "图片下载或识别失败" + answer = f"已收到图片,但未完成识别:{warn_text}。请重发清晰图片后再试。" + elif image_context_result.get("warnings"): + answer = f"{answer}\n\n(图片处理提示:{';'.join(image_context_result['warnings'][:2])})" + + answer = self._clean_dialog_noise(answer) + answer = self._trim_noncode_blocks(prompt, answer) + answer = self._sanitize_answer(prompt, answer) + answer = self._mask_sensitive_amounts(prompt, answer) + if len(answer) > self.reply_max_chars: + answer = answer[: self.reply_max_chars] + "\n...(已截断)" + + try: + prefer_card = not self._is_install_media_prompt(prompt) + sent = self._reply_message(msg, answer, prefer_card=prefer_card) + if sent: + self.logger.info("reply sent: session=%s chars=%s", session_id, len(answer)) + else: + self.logger.error("reply failed: session=%s chars=%s", session_id, len(answer)) + except Exception: + self.logger.exception("failed to send final reply: session=%s", session_id) + return AckMessage.STATUS_OK, "ok" + + +def build_client(logger: logging.Logger) -> DingTalkStreamClient: + client_id = os.getenv("DINGTALK_CLIENT_ID", "").strip() + client_secret = os.getenv("DINGTALK_CLIENT_SECRET", "").strip() + + if not client_id or not client_secret: + raise RuntimeError("DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET 未配置") + + credential = Credential(client_id, client_secret) + client = DingTalkStreamClient(credential, logger=logger) + bot = OpenClawDingTalkBot(logger) + client.register_callback_handler(ChatbotMessage.TOPIC, bot) + if bool_env("REGISTER_DELEGATE_TOPIC", False): + client.register_callback_handler(ChatbotMessage.DELEGATE_TOPIC, bot) + logger.info("delegate topic enabled") + else: + logger.info("delegate topic disabled (default)") + return client + + +def self_check(logger: logging.Logger) -> int: + required = ["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"] + missing = [k for k in required if not os.getenv(k)] + if missing: + logger.error("missing env: %s", ", ".join(missing)) + return 2 + + openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw") + proc = subprocess.run([openclaw_bin, "--version"], capture_output=True, text=True) + if proc.returncode != 0: + logger.error("openclaw check failed: %s", (proc.stderr or proc.stdout).strip()) + return 3 + + market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report") + if not os.path.exists(market_brief_bin): + logger.error("market brief bin not found: %s", market_brief_bin) + return 4 + + install_media_bin = os.getenv("INSTALL_MEDIA_BIN", "/usr/local/bin/install-media-lookup") + if not os.path.exists(install_media_bin): + logger.error("install media bin not found: %s", install_media_bin) + return 5 + + logger.info("self-check ok, openclaw=%s", (proc.stdout or "").strip()) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="OpenClaw DingTalk Bridge") + parser.add_argument("--check", action="store_true", help="run checks and exit") + args = parser.parse_args() + + logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + logger = logging.getLogger("openclaw-dingtalk-bridge") + + if args.check: + return self_check(logger) + + client = build_client(logger) + logger.info("bridge started, listening DingTalk stream") + client.start_forever() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/remote_sync/bot.py b/remote_sync/bot.py new file mode 100755 index 0000000..5684e97 --- /dev/null +++ b/remote_sync/bot.py @@ -0,0 +1,2389 @@ +#!/usr/bin/env python3 +import argparse +import asyncio +import base64 +import datetime as dt +import hashlib +import hmac +import io +import json +import logging +import mimetypes +import os +import re +import subprocess +import tempfile +import time +import urllib.parse +import uuid + +from dingtalk_stream import AckMessage, CallbackMessage, ChatbotHandler, ChatbotMessage +from dingtalk_stream import Credential, DingTalkStreamClient +import requests + +try: + from PIL import Image +except Exception: + Image = None + +try: + import boto3 + from botocore.config import Config as BotoConfig +except Exception: + boto3 = None + BotoConfig = None + + +MARKET_HINT_KEYWORDS = ( + "市场分析", + "市场数据分析", + "行业分析", + "类目分析", + "竞品分析", + "趋势分析", +) + +SALES_HINT_KEYWORDS = ( + "销售数据", + "销售报表", + "销售看板", + "公司销售", + "业绩报表", + "业绩数据", + "费比监控", +) + +SENSITIVE_SALES_KEYWORDS = ( + "销售", + "业绩", + "售后", + "退款", + "店铺", + "发货", + "工单", + "费比", +) + +EXPLICIT_AMOUNT_KEYWORDS = ( + "金额", + "费用", + "收入", + "利润", + "成本", + "客单价", + "gmv", + "销售额", + "退款额", + "毛利", +) + +INSTALL_MEDIA_HINT_KEYWORDS = ( + "安装图纸", + "安装视频", + "安装说明书", + "图纸地址", + "视频地址", + "图纸链接", + "视频链接", + "订单号查图纸", + "订单号查视频", +) + +INSTALL_MEDIA_STITCH_HINT_KEYWORDS = ( + "拼接", + "合并", + "一张图", + "整合图", + "汇总图", +) + +VIDEO_ANALYSIS_HINT_KEYWORDS = ( + "视频", + "video", + "短片", + "片段", +) + +VIDEO_ANALYSIS_ACTION_KEYWORDS = ( + "分析", + "解读", + "总结", + "提炼", + "复盘", + "讲解", + "评价", + "建议", + "看看", + "看下", + "说明", +) + +VIDEO_SCRIPT_HINT_KEYWORDS = ( + "脚本", + "分镜", + "复刻", + "仿拍", + "同款", + "类似视频", + "拍摄方案", + "镜头脚本", +) + +DALONGXIA_ANALYTICS_HINT_KEYWORDS = ( + "壹点需求", + "需求系统", + "任务进度总览", + "进度总览", + "数据分析总览", + "需求统计", + "设计师绩效", + "流程效率", + "预警", + "维度分析", + "主部门", + "designerperformance", + "dimensionanalysis", + "taskprogressoverview", + "designerdeduction", + "designerscore", + "primarydept", + "primarytask", + "analytics", +) + + +URL_RE = re.compile( + r"https?://[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+", + re.IGNORECASE, +) +IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b") +DOMAIN_RE = re.compile(r"\b(?:[a-zA-Z0-9-]+\.)+(?:com|cn|net|ai|org|io)\b", re.IGNORECASE) +IMAGE_EXT_RE = re.compile(r"\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:$|[?#])", re.IGNORECASE) +VIDEO_EXT_RE = re.compile(r"\.(?:mp4|mov|m4v|webm|mkv|avi|flv|wmv|ts|m3u8)(?:$|[?#])", re.IGNORECASE) +ORDER_ID_RE = re.compile(r"\b\d{12,24}(?:-\d{2,4})?\b") +SMALLTALK_SET = { + "你好", + "您好", + "嗨", + "哈喽", + "在吗", + "在嘛", + "早上好", + "上午好", + "中午好", + "下午好", + "晚上好", + "hi", + "hello", +} + + + +def bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +class OpenClawDingTalkBot(ChatbotHandler): + def __init__(self, logger: logging.Logger): + super().__init__() + self.logger = logger + self.openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw") + self.openclaw_timeout = int(os.getenv("OPENCLAW_TIMEOUT", "600")) + self.market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report") + self.market_brief_timeout = int(os.getenv("MARKET_BRIEF_TIMEOUT", "180")) + self.install_media_bin = os.getenv("INSTALL_MEDIA_BIN", "/usr/local/bin/install-media-lookup") + self.install_media_timeout = int(os.getenv("INSTALL_MEDIA_TIMEOUT", "120")) + self.prefix = os.getenv("BOT_PREFIX", "/oc").strip() + self.require_prefix = bool_env("REQUIRE_PREFIX", False) + self.reply_max_chars = int(os.getenv("REPLY_MAX_CHARS", "3500")) + self.session_prefix = os.getenv("OPENCLAW_SESSION_PREFIX", "dingtalk") + self.hide_source_urls = bool_env("HIDE_SOURCE_URLS", True) + self.send_processing_hint = bool_env("SEND_PROCESSING_HINT", True) + self.dedupe_ttl_seconds = int(os.getenv("DEDUPE_TTL_SECONDS", "300")) + self.session_max_lines = int(os.getenv("OPENCLAW_SESSION_MAX_LINES", "120")) + self.sessions_dir = os.getenv("OPENCLAW_SESSIONS_DIR", "/root/.openclaw/agents/main/sessions") + self.unmask_url_hosts = { + h.strip().lower() + for h in os.getenv( + "UNMASK_URL_HOSTS", + "img.alicdn.com,static.jiansun.vip,img.jiansuntuzhi.cn,v.youku.com", + ).split(",") + if h.strip() + } + self._recent_event_ts = {} + self.processing_hint_text = os.getenv( + "PROCESSING_HINT_TEXT", + "已收到,正在处理你的需求,请稍等1-3分钟。", + ).strip() + self.reply_style = os.getenv("REPLY_STYLE", "action_card").strip().lower() + self.action_card_title = os.getenv("ACTION_CARD_TITLE", "OpenClaw 助理").strip() + self.action_card_tips = os.getenv("ACTION_CARD_TIPS", "").strip() + self.action_card_logo = os.getenv("ACTION_CARD_LOGO", "").strip() + self.action_card_btn_text = os.getenv("ACTION_CARD_BTN_TEXT", "").strip() + self.action_card_btn_url = os.getenv("ACTION_CARD_BTN_URL", "").strip() + self.smalltalk_reply_text = os.getenv( + "SMALLTALK_REPLY_TEXT", + "你好,我在。请直接告诉我你的任务目标(例如:查询某店上周售后原因TOP5并给出改进建议)。", + ).strip() + self.max_parallel_jobs = max(1, min(int(os.getenv("BOT_MAX_PARALLEL_JOBS", "4")), 16)) + self._work_semaphore = asyncio.Semaphore(self.max_parallel_jobs) + self._conversation_locks = {} + self.card_disable_seconds = max(300, min(int(os.getenv("CARD_DISABLE_SECONDS", "43200")), 86400)) + self.card_disabled_until = 0.0 + self.install_media_stitch_enabled = bool_env("INSTALL_MEDIA_STITCH_ENABLED", True) + self.install_media_stitch_min_images = max(2, min(int(os.getenv("INSTALL_MEDIA_STITCH_MIN_IMAGES", "2")), 10)) + self.install_media_stitch_max_images = max( + self.install_media_stitch_min_images, + min(int(os.getenv("INSTALL_MEDIA_STITCH_MAX_IMAGES", "20")), 50), + ) + self.install_media_fetch_timeout = max(3, min(int(os.getenv("INSTALL_MEDIA_FETCH_TIMEOUT", "12")), 60)) + self.install_media_max_width = max(720, min(int(os.getenv("INSTALL_MEDIA_MAX_WIDTH", "1600")), 4000)) + self.s3_endpoint = ( + os.getenv("INSTALL_MEDIA_S3_ENDPOINT", "").strip() + or os.getenv("S3_ENDPOINT", "").strip() + ) + self.s3_bucket = ( + os.getenv("INSTALL_MEDIA_S3_BUCKET", "").strip() + or os.getenv("S3_BUCKET", "").strip() + ) + self.s3_region = ( + os.getenv("INSTALL_MEDIA_S3_REGION", "").strip() + or os.getenv("S3_REGION", "us-east-1").strip() + ) + self.s3_access_key = ( + os.getenv("INSTALL_MEDIA_S3_ACCESS_KEY_ID", "").strip() + or os.getenv("S3_ACCESS_KEY_ID", "").strip() + ) + self.s3_secret_key = ( + os.getenv("INSTALL_MEDIA_S3_SECRET_ACCESS_KEY", "").strip() + or os.getenv("S3_SECRET_ACCESS_KEY", "").strip() + ) + self.s3_public_domain = ( + os.getenv("INSTALL_MEDIA_S3_PUBLIC_DOMAIN", "").strip() + or os.getenv("S3_PUBLIC_DOMAIN", "").strip() + ) + self.s3_key_prefix = ( + os.getenv("INSTALL_MEDIA_S3_FILE_PATH", "").strip() + or os.getenv("S3_FILE_PATH", "").strip() + ) + self.s3_acl = ( + os.getenv("INSTALL_MEDIA_S3_ACL", "").strip() + or os.getenv("S3_ACL", "public-read").strip() + ) + self.s3_path_style = bool_env("INSTALL_MEDIA_S3_PATH_STYLE", bool_env("S3_PATH_STYLE", True)) + if self.s3_key_prefix and not self.s3_key_prefix.endswith("/"): + self.s3_key_prefix += "/" + self.image_analysis_enabled = bool_env("IMAGE_ANALYSIS_ENABLED", True) + self.image_analysis_model = os.getenv("IMAGE_ANALYSIS_MODEL", "qwen2.5vl:3b").strip() or "qwen2.5vl:3b" + self.image_analysis_timeout = max(10, min(int(os.getenv("IMAGE_ANALYSIS_TIMEOUT", "90")), 300)) + self.image_max_images = max(1, min(int(os.getenv("IMAGE_MAX_IMAGES", "3")), 8)) + self.image_download_timeout = max(3, min(int(os.getenv("IMAGE_DOWNLOAD_TIMEOUT", "20")), 60)) + self.image_max_bytes = max(262144, min(int(os.getenv("IMAGE_MAX_BYTES", "10485760")), 31457280)) + self.image_upload_to_s3 = bool_env("IMAGE_UPLOAD_TO_S3", True) + self.image_vision_output_max_chars = max(300, min(int(os.getenv("IMAGE_VISION_OUTPUT_MAX_CHARS", "2200")), 6000)) + self.video_analysis_enabled = bool_env("VIDEO_ANALYSIS_ENABLED", True) + self.video_analysis_model = os.getenv("VIDEO_ANALYSIS_MODEL", "qwen-vl-plus").strip() or "qwen-vl-plus" + self.video_analysis_fallback_model = ( + os.getenv("VIDEO_ANALYSIS_FALLBACK_MODEL", "qwen-vl-plus").strip() or "qwen-vl-plus" + ) + self.video_analysis_base_url = ( + os.getenv("VIDEO_ANALYSIS_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions").strip() + or "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions" + ) + self.video_analysis_timeout = max(15, min(int(os.getenv("VIDEO_ANALYSIS_TIMEOUT", "360")), 900)) + self.video_analysis_max_tokens = max(256, min(int(os.getenv("VIDEO_ANALYSIS_MAX_TOKENS", "1800")), 8192)) + self.video_analysis_max_urls = max(1, min(int(os.getenv("VIDEO_ANALYSIS_MAX_URLS", "2")), 3)) + + def _should_handle(self, msg: ChatbotMessage, text: str) -> bool: + if text.startswith(self.prefix): + return True + if msg.is_in_at_list: + return True + return not self.require_prefix + + def _normalize_prompt(self, text: str) -> str: + text = text.strip() + if self.prefix and text.startswith(self.prefix): + text = text[len(self.prefix):].strip() + text = re.sub(r"^@\S+\s*", "", text).strip() + return text + + def _is_smalltalk_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"[\s,,。.!!??~~]+", "", (prompt or "").strip().lower()) + if not normalized: + return False + if normalized in SMALLTALK_SET: + return True + if normalized.startswith(("你好", "您好", "hi", "hello")) and len(normalized) <= 6: + return True + return False + + def _safe_session_component(self, raw: str) -> str: + original = (raw or "default").strip().replace(" ", "_") + safe = re.sub(r"[^A-Za-z0-9_-]+", "-", original) + safe = re.sub(r"-{2,}", "-", safe).strip("-") + if not safe: + safe = "default" + if safe != original: + digest = hashlib.sha1(original.encode("utf-8")).hexdigest()[:8] + safe = f"{safe}-{digest}" + return safe[:96] + + def _session_id(self, msg: ChatbotMessage) -> str: + raw_conversation_id = str(msg.conversation_id or "default") + safe_conversation_id = self._safe_session_component(raw_conversation_id) + base_session = f"{self.session_prefix}-{safe_conversation_id}" + + session_file = os.path.join(self.sessions_dir, f"{base_session}.jsonl") + if self.session_max_lines > 0 and os.path.exists(session_file): + try: + with open(session_file, "rb") as fh: + line_count = sum(1 for _ in fh) + if line_count >= self.session_max_lines: + rotate_suffix = time.strftime("%Y%m%d%H%M") + rotated = f"{base_session}-{rotate_suffix}" + self.logger.info( + "session auto-rotated: base=%s lines=%s rotated=%s", + base_session, + line_count, + rotated, + ) + return rotated + except Exception: + self.logger.exception("failed to inspect session file: %s", session_file) + + return base_session + + def _event_key(self, callback: CallbackMessage, msg: ChatbotMessage, prompt: str) -> str: + if getattr(msg, "message_id", None): + return f"msg:{msg.message_id}" + + data = callback.data if isinstance(callback.data, dict) else {} + for key in ("msgId", "messageId", "eventId", "processQueryKey"): + value = data.get(key) + if value: + return f"data:{key}:{value}" + + headers = getattr(callback, "headers", {}) or {} + for key in ("messageId", "eventId", "processQueryKey"): + value = headers.get(key) + if value: + return f"hdr:{key}:{value}" + + raw = "|".join( + [ + str(getattr(msg, "conversation_id", "") or ""), + str(getattr(msg, "sender_id", "") or ""), + str(getattr(msg, "sender_staff_id", "") or ""), + str(getattr(msg, "create_at", "") or ""), + prompt[:200], + ] + ) + return "fp:" + hashlib.sha1(raw.encode("utf-8")).hexdigest() + + def _seen_or_mark_event(self, key: str) -> bool: + now = time.time() + expire_before = now - max(self.dedupe_ttl_seconds, 30) + + stale_keys = [k for k, ts in self._recent_event_ts.items() if ts < expire_before] + for stale_key in stale_keys: + self._recent_event_ts.pop(stale_key, None) + + if key in self._recent_event_ts: + return True + + self._recent_event_ts[key] = now + return False + + def _is_market_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt) + + if any(k in normalized for k in SALES_HINT_KEYWORDS): + return False + + if "电脑桌" in normalized: + return True + + if any(k in normalized for k in MARKET_HINT_KEYWORDS): + return True + + if "市场" in normalized and any( + k in normalized for k in ("搜索", "飙升词", "热词", "需求供给比", "价格带", "客单价", "洞察", "趋势") + ): + return True + + if "市场" in normalized and "分析" in normalized: + return True + + return False + + def _is_install_media_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt) + if not normalized: + return False + if any(k in normalized for k in INSTALL_MEDIA_HINT_KEYWORDS): + return True + if "安装" in normalized and ("图纸" in normalized or "视频" in normalized): + return True + return False + + def _is_install_media_stitch_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt) + if not normalized: + return False + return any(k in normalized for k in INSTALL_MEDIA_STITCH_HINT_KEYWORDS) + + def _is_video_url(self, raw_url: str) -> bool: + try: + parsed = urllib.parse.urlparse(raw_url) + except Exception: + return False + + if parsed.scheme not in {"http", "https"}: + return False + + host = (parsed.hostname or "").lower() + target = f"{parsed.path or ''}?{parsed.query or ''}" + if VIDEO_EXT_RE.search(target): + return True + + video_hosts = ( + "douyin.com", + "iesdouyin.com", + "bilibili.com", + "youku.com", + "qq.com", + "weixin.qq.com", + "ixigua.com", + "kuaishou.com", + "mgtv.com", + "1905.com", + "xiaohongshu.com", + "xhslink.com", + "tiktok.com", + "youtube.com", + "youtu.be", + ) + return any(host == h or host.endswith(f".{h}") for h in video_hosts) + + def _extract_video_urls(self, text: str, limit: int = 2) -> list: + urls = [] + seen = set() + for match in URL_RE.finditer(text or ""): + raw = match.group(0).rstrip(".,;!?") + if not self._is_video_url(raw): + continue + if raw in seen: + continue + seen.add(raw) + urls.append(raw) + if len(urls) >= limit: + break + return urls + + def _extract_any_urls(self, text: str, limit: int = 5) -> list: + urls = [] + seen = set() + for match in URL_RE.finditer(text or ""): + raw = match.group(0).rstrip(".,;!?") + if raw in seen: + continue + seen.add(raw) + urls.append(raw) + if len(urls) >= limit: + break + return urls + + def _is_video_analysis_prompt(self, prompt: str) -> bool: + if self._is_install_media_prompt(prompt): + return False + + normalized = re.sub(r"\s+", "", (prompt or "")).lower() + if not normalized: + return False + + has_video_word = ("视频" in normalized) or ("video" in normalized) + has_video_intent = any(k in normalized for k in VIDEO_ANALYSIS_HINT_KEYWORDS) or any( + k in normalized for k in VIDEO_ANALYSIS_ACTION_KEYWORDS + ) + if has_video_word and has_video_intent: + return True + video_urls = self._extract_video_urls(prompt, limit=1) + if not video_urls: + return False + if has_video_intent: + return True + return len(normalized) <= 120 + + def _needs_video_script(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", (prompt or "")).lower() + if not normalized: + return False + return any(k in normalized for k in VIDEO_SCRIPT_HINT_KEYWORDS) + + def _is_dalongxia_analytics_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", (prompt or "")).lower() + if not normalized: + return False + if any(k in normalized for k in DALONGXIA_ANALYTICS_HINT_KEYWORDS): + return True + if "任务进度" in normalized and ("总览" in normalized or "overview" in normalized): + return True + if "设计师绩效" in normalized or "流程效率" in normalized: + return True + if "primary" in normalized and ("dept" in normalized or "task" in normalized): + return True + return False + + def _parse_analytics_mode(self, prompt: str) -> str: + normalized = re.sub(r"\s+", "", (prompt or "")).lower() + compact = re.sub(r"[^a-z0-9\u4e00-\u9fa5]+", "", normalized) + + mode_aliases = ( + ("primarytaskbranddistribution", "primary_task_brand_distribution"), + ("primarytaskflowefficiency", "primary_task_flow_efficiency"), + ("primarydeptstatistics", "primary_dept_statistics"), + ("primarydeptuserranking", "primary_dept_user_ranking"), + ("primarydeptranking", "primary_dept_ranking"), + ("primarytask", "primary_task_flow_efficiency"), + ("primarydept", "primary_dept_ranking"), + ("designerdeductiondetails", "designer_deduction"), + ("designerscoredetails", "designer_score"), + ("designerdeduction", "designer_deduction"), + ("designerscore", "designer_score"), + ("taskprogressoverview", "task_progress_overview"), + ("taskprogress", "task_progress_overview"), + ("progress", "task_progress_overview"), + ("demandstatistics", "demand_statistics"), + ("designerperformance", "designer_performance"), + ("processefficiency", "process_efficiency"), + ("dimensionanalysis", "dimension_analysis"), + ("overview", "overview"), + ("warnings", "warnings"), + ) + for token, mode in mode_aliases: + if token in compact: + return mode + + if "主部门" in normalized and "人员" in normalized and "排名" in normalized: + return "primary_dept_user_ranking" + if "主部门" in normalized and "品牌" in normalized and "分布" in normalized: + return "primary_task_brand_distribution" + if "主部门" in normalized and "任务流程" in normalized and "效率" in normalized: + return "primary_task_flow_efficiency" + if "主部门" in normalized and "统计" in normalized: + return "primary_dept_statistics" + if "主部门" in normalized and "排名" in normalized: + return "primary_dept_ranking" + if "设计师" in normalized and ("扣分明细" in normalized or "扣分详情" in normalized): + return "designer_deduction" + if "设计师" in normalized and ("评分明细" in normalized or "得分明细" in normalized): + return "designer_score" + if "维度" in normalized and "分析" in normalized: + return "dimension_analysis" + if "流程效率" in normalized: + return "process_efficiency" + if "设计师绩效" in normalized: + return "designer_performance" + if "需求统计" in normalized: + return "demand_statistics" + if "预警" in normalized: + return "warnings" + if "任务进度" in normalized and ("总览" in normalized or "overview" in normalized): + return "task_progress_overview" + if "数据分析" in normalized and "总览" in normalized: + return "overview" + return "overview" + + def _parse_analytics_date_range(self, prompt: str) -> str: + normalized = re.sub(r"\s+", "", (prompt or "")).lower() + if any(k in normalized for k in ("all", "全部", "全量", "所有时间")): + return "all" + if any(k in normalized for k in ("quarter", "季度", "q1", "q2", "q3", "q4")): + return "quarter" + if any(k in normalized for k in ("week", "本周", "上周", "近7天", "最近7天", "7天")): + return "week" + return "month" + + def _parse_analytics_month(self, prompt: str) -> str: + text = prompt or "" + m = re.search(r"(20\d{2})[年/-](\d{1,2})", text) + if not m: + m = re.search(r"(20\d{2})(\d{2})", text) + if m: + year = int(m.group(1)) + month = int(m.group(2)) + if 1 <= month <= 12: + return f"{year:04d}-{month:02d}" + now = time.localtime() + return f"{now.tm_year:04d}-{now.tm_mon:02d}" + + def _parse_analytics_ids(self, prompt: str) -> tuple: + text = prompt or "" + dept_id = "" + user_id = "" + + m = re.search(r"(?:部门ID|deptId|dept_id)[::\s]*([0-9]{1,20})", text, re.IGNORECASE) + if m: + dept_id = m.group(1) + m = re.search(r"(?:用户ID|设计师ID|成员ID|userId|user_id)[::\s]*([0-9]{1,20})", text, re.IGNORECASE) + if m: + user_id = m.group(1) + return dept_id, user_id + + def _normalize_analytics_datetime(self, raw: str, end_of_day: bool = False) -> str: + text = (raw or "").strip() + if not text: + return "" + text = text.replace("/", "-").replace(" ", "T") + if re.match(r"^\d{4}-\d{1,2}-\d{1,2}$", text): + suffix = "23:59:59" if end_of_day else "00:00:00" + return f"{text}T{suffix}" + if re.match(r"^\d{4}-\d{1,2}-\d{1,2}T\d{1,2}$", text): + return f"{text}:00:00" + if re.match(r"^\d{4}-\d{1,2}-\d{1,2}T\d{1,2}:\d{1,2}$", text): + return f"{text}:00" + if re.match(r"^\d{4}-\d{1,2}-\d{1,2}T\d{1,2}:\d{1,2}:\d{1,2}$", text): + return text + return "" + + def _parse_analytics_time_window(self, prompt: str, month: str) -> tuple: + text = prompt or "" + start_time = "" + end_time = "" + + range_match = re.search( + r"(20\d{2}[/-]\d{1,2}[/-]\d{1,2})\s*(?:到|至|~|-)\s*(20\d{2}[/-]\d{1,2}[/-]\d{1,2})", + text, + ) + if range_match: + start_time = self._normalize_analytics_datetime(range_match.group(1), end_of_day=False) + end_time = self._normalize_analytics_datetime(range_match.group(2), end_of_day=True) + + if not start_time: + m = re.search(r"(?:startTime|start_time|开始时间)[::=\s]*([0-9/:\- T]{8,25})", text, re.IGNORECASE) + if m: + start_time = self._normalize_analytics_datetime(m.group(1), end_of_day=False) + if not end_time: + m = re.search(r"(?:endTime|end_time|结束时间)[::=\s]*([0-9/:\- T]{8,25})", text, re.IGNORECASE) + if m: + end_time = self._normalize_analytics_datetime(m.group(1), end_of_day=True) + + if start_time and end_time: + return start_time, end_time + + month_match = re.match(r"^(20\d{2})-(\d{2})$", month or "") + if month_match: + year = int(month_match.group(1)) + mon = int(month_match.group(2)) + first = dt.datetime(year, mon, 1, 0, 0, 0) + if mon == 12: + next_first = dt.datetime(year + 1, 1, 1, 0, 0, 0) + else: + next_first = dt.datetime(year, mon + 1, 1, 0, 0, 0) + last = next_first - dt.timedelta(seconds=1) + return first.strftime("%Y-%m-%dT%H:%M:%S"), last.strftime("%Y-%m-%dT%H:%M:%S") + + now = dt.datetime.now() + first = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + last = now.replace(hour=23, minute=59, second=59, microsecond=0) + return first.strftime("%Y-%m-%dT%H:%M:%S"), last.strftime("%Y-%m-%dT%H:%M:%S") + + def _parse_include_task_progress(self, prompt: str) -> bool: + return bool( + re.search( + r"(含|包含|带上|带|include)\s*任务进度|includeTaskProgress\s*[:=]\s*true", + prompt or "", + re.IGNORECASE, + ) + ) + + def _build_dalongxia_analytics_request(self, mode: str, app_key: str, app_secret: str, params: dict) -> tuple: + timestamp = str(int(time.time() * 1000)) + date_range = params.get("date_range", "month") + month = params.get("month", "") + dept_id = str(params.get("dept_id", "") or "") + user_id = str(params.get("user_id", "") or "") + include_task_progress = bool(params.get("include_task_progress", False)) + include_str = str(include_task_progress).lower() + start_time = params.get("start_time", "") + end_time = params.get("end_time", "") + + if mode == "task_progress_overview": + action = "analytics_task_progress_overview" + payload = "|".join([action, app_key, date_range, month, dept_id, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "dateRange": date_range, + "month": month, + } + if dept_id: + body["deptId"] = dept_id + return "/api/open/analytics/task-progress/overview", "壹点需求系统:任务进度总览", body + + if mode == "demand_statistics": + action = "analytics_demand_statistics" + payload = "|".join([action, app_key, date_range, month, dept_id, user_id, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "dateRange": date_range, + "month": month, + } + if dept_id: + body["deptId"] = dept_id + if user_id: + body["userId"] = user_id + return "/api/open/analytics/demand-statistics", "壹点需求系统:需求统计", body + + if mode == "designer_performance": + action = "analytics_designer_performance" + payload = "|".join([action, app_key, month, dept_id, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "month": month, + } + if dept_id: + body["deptId"] = dept_id + return "/api/open/analytics/designer-performance", "壹点需求系统:设计师绩效", body + + if mode == "process_efficiency": + action = "analytics_process_efficiency" + payload = "|".join([action, app_key, month, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "month": month, + } + return "/api/open/analytics/process-efficiency", "壹点需求系统:流程效率", body + + if mode == "warnings": + action = "analytics_warnings" + payload = "|".join([action, app_key, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + } + return "/api/open/analytics/warnings", "壹点需求系统:预警信息", body + + if mode == "dimension_analysis": + action = "analytics_dimension_analysis" + payload = "|".join([action, app_key, date_range, month, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "dateRange": date_range, + "month": month, + } + return "/api/open/analytics/dimension-analysis", "壹点需求系统:维度分析", body + + if mode == "designer_deduction": + action = "analytics_designer_deduction_details" + detail_user_id = user_id or os.getenv("ANALYTICS_DEFAULT_USER_ID", "38").strip() + payload = "|".join([action, app_key, str(detail_user_id), month, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "userId": str(detail_user_id), + "month": month, + } + return "/api/open/analytics/designer-deduction-details", "壹点需求系统:设计师扣分明细", body + + if mode == "designer_score": + action = "analytics_designer_score_details" + detail_user_id = user_id or os.getenv("ANALYTICS_DEFAULT_USER_ID", "38").strip() + payload = "|".join([action, app_key, str(detail_user_id), month, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "userId": str(detail_user_id), + "month": month, + } + return "/api/open/analytics/designer-score-details", "壹点需求系统:设计师得分明细", body + + if mode == "primary_dept_statistics": + action = "analytics_primary_dept_statistics" + payload = "|".join([action, app_key, start_time, end_time, dept_id, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "startTime": start_time, + "endTime": end_time, + } + if dept_id: + body["deptId"] = dept_id + return "/api/open/analytics/primary-dept/statistics", "壹点需求系统:主部门统计", body + + if mode == "primary_dept_ranking": + action = "analytics_primary_dept_ranking" + payload = "|".join([action, app_key, start_time, end_time, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "startTime": start_time, + "endTime": end_time, + } + return "/api/open/analytics/primary-dept/ranking", "壹点需求系统:主部门排名", body + + if mode == "primary_task_flow_efficiency": + action = "analytics_primary_task_flow_efficiency" + payload = "|".join([action, app_key, start_time, end_time, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "startTime": start_time, + "endTime": end_time, + } + return "/api/open/analytics/primary-task/flow-efficiency", "壹点需求系统:主部门任务流转效率", body + + if mode == "primary_task_brand_distribution": + action = "analytics_primary_task_brand_distribution" + payload = "|".join([action, app_key, start_time, end_time, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "startTime": start_time, + "endTime": end_time, + } + return "/api/open/analytics/primary-task/brand-distribution", "壹点需求系统:主部门任务品牌分布", body + + if mode == "primary_dept_user_ranking": + action = "analytics_primary_dept_user_ranking" + payload = "|".join([action, app_key, start_time, end_time, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "startTime": start_time, + "endTime": end_time, + } + return "/api/open/analytics/primary-dept/user-ranking", "壹点需求系统:主部门成员排名", body + + action = "analytics_overview" + payload = "|".join([action, app_key, date_range, month, dept_id, user_id, include_str, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "dateRange": date_range, + "month": month, + "includeTaskProgress": include_task_progress, + } + if dept_id: + body["deptId"] = dept_id + if user_id: + body["userId"] = user_id + return "/api/open/analytics/overview", "壹点需求系统:数据分析总览", body + + def _format_dalongxia_analytics_result(self, mode: str, title: str, body: dict, data: dict) -> str: + def _f(v): + if v is None: + return "-" + if isinstance(v, float): + s = f"{v:.2f}" + return s.rstrip("0").rstrip(".") + return str(v) + + def _is_num(v): + return isinstance(v, (int, float)) + + def _sum_num(items, key): + total = 0 + found = False + for it in items: + v = it.get(key) if isinstance(it, dict) else None + if _is_num(v): + total += v + found = True + return total if found else None + + query_params = [] + for key in ("dateRange", "month", "deptId", "userId", "includeTaskProgress", "startTime", "endTime"): + if key in body and body[key] not in ("", None): + query_params.append(f"{key}={body[key]}") + + lines = [title] + if query_params: + lines.append(f"- 查询参数: {', '.join(query_params)}") + if isinstance(data, dict): + if data.get("time"): + lines.append(f"- 统计时间: {data.get('time')}") + if data.get("traceId"): + lines.append(f"- traceId: {data.get('traceId')}") + + root = (data or {}).get("data") if isinstance(data, dict) else None + if root is None: + root = data + + if mode == "task_progress_overview" and isinstance(root, dict): + metrics = root.get("metrics") or {} + status = root.get("statusDistribution") or {} + trend = (root.get("trend") or [])[:5] + lines.append( + "- 核心指标: " + f"总任务={_f(metrics.get('totalTasks'))},完成={_f(metrics.get('completedTasks'))}," + f"处理中={_f(metrics.get('inProgressTasks'))},逾期={_f(metrics.get('overdueTasks'))}," + f"完成率={_f(metrics.get('completionRate'))}%" + ) + if status: + parts = [f"{k}={_f(v)}" for k, v in status.items() if _is_num(v)] + if parts: + lines.append(f"- 状态分布: {','.join(parts[:8])}") + if trend: + lines.append("- 趋势(最近5个点):") + for t in trend: + lines.append( + f" - {_f(t.get('date'))}: 新建={_f(t.get('createdCount'))},完成={_f(t.get('completedCount'))}" + ) + return "\n".join(lines) + + if mode == "designer_performance" and isinstance(root, dict): + items = root.get("designerStats") or [] + if not isinstance(items, list): + items = [] + items = sorted( + [it for it in items if isinstance(it, dict)], + key=lambda x: (x.get("completedCount") or 0, x.get("receivedCount") or 0), + reverse=True, + ) + total_received = _sum_num(items, "receivedCount") + total_completed = _sum_num(items, "completedCount") + total_score = _sum_num(items, "totalScore") + total_net_score = _sum_num(items, "netScore") + lines.append( + "- 汇总: " + f"设计师数={len(items)},接收任务={_f(total_received)},完成任务={_f(total_completed)}," + f"总积分={_f(total_score)},净积分={_f(total_net_score)}" + ) + lines.append("- TOP设计师(按完成量,含每人积分):") + for idx, it in enumerate(items[:10], 1): + total_score_item = it.get("totalScore") + net_score_item = it.get("netScore") + point_value = net_score_item if _is_num(net_score_item) else total_score_item + lines.append( + f" {idx}. {_f(it.get('realName') or it.get('userName'))}|{_f(it.get('deptName'))}|" + f"完成={_f(it.get('completedCount'))}|积分={_f(point_value)}|" + f"扣分={_f(it.get('deductionScore'))}|目标={_f(it.get('targetScore'))}|" + f"准时率={_f(it.get('onTimeRate'))}%|完成率={_f(it.get('completionRate'))}%" + ) + return "\n".join(lines) + + if mode == "dimension_analysis" and isinstance(root, dict): + items = root.get("categoryAnalysis") or [] + if not isinstance(items, list): + items = [] + items = sorted( + [it for it in items if isinstance(it, dict)], + key=lambda x: (x.get("totalTasks") or 0), + reverse=True, + ) + lines.append(f"- 类目数: {len(items)}") + lines.append("- TOP类目(按任务量):") + for idx, it in enumerate(items[:10], 1): + lines.append( + f" {idx}. {_f(it.get('categoryName'))}|任务={_f(it.get('totalTasks'))}|" + f"完成={_f(it.get('completedTasks'))}|进行中={_f(it.get('inProgressTasks'))}|完成率={_f(it.get('completionRate'))}%" + ) + return "\n".join(lines) + + if mode == "overview" and isinstance(root, dict): + module_keys = list(root.keys()) + lines.append(f"- 模块: {', '.join(module_keys[:8]) if module_keys else '-'}") + demand = root.get("demandStatistics") or {} + user_stats = demand.get("userStats") if isinstance(demand, dict) else [] + if isinstance(user_stats, list) and user_stats: + lines.append(f"- 需求统计: 用户数={len(user_stats)}") + top = sorted( + [it for it in user_stats if isinstance(it, dict)], + key=lambda x: (x.get("totalDemands") or 0), + reverse=True, + )[:5] + lines.append("- 需求量TOP5:") + for idx, it in enumerate(top, 1): + lines.append( + f" {idx}. {_f(it.get('realName') or it.get('userName'))}|{_f(it.get('deptName'))}|" + f"总需求={_f(it.get('totalDemands'))}|完成={_f(it.get('completedDemands'))}" + ) + task_progress = root.get("taskProgressOverview") or root.get("taskProgress") or {} + if isinstance(task_progress, dict) and task_progress.get("metrics"): + m = task_progress.get("metrics") or {} + lines.append( + "- 任务进度: " + f"总任务={_f(m.get('totalTasks'))},完成={_f(m.get('completedTasks'))},逾期={_f(m.get('overdueTasks'))}" + ) + return "\n".join(lines) + + if mode in { + "primary_dept_ranking", + "primary_dept_statistics", + "primary_task_flow_efficiency", + "primary_task_brand_distribution", + "primary_dept_user_ranking", + }: + items = root if isinstance(root, list) else [] + if not items and isinstance(root, dict): + for key in ("items", "list", "rows", "data"): + if isinstance(root.get(key), list): + items = root.get(key) + break + items = [it for it in items if isinstance(it, dict)] + lines.append(f"- 条数: {len(items)}") + lines.append("- TOP10:") + for idx, it in enumerate(items[:10], 1): + name = it.get("deptName") or it.get("userName") or it.get("brandName") or it.get("name") + task_count = it.get("taskCount") or it.get("totalTasks") or it.get("count") + rate = it.get("approvalRate") or it.get("completionRate") or it.get("onTimeRate") + extra = it.get("avgResponseHours") or it.get("avgCycleHours") + part = f" {idx}. {_f(name)}|任务={_f(task_count)}" + if rate is not None: + part += f"|比率={_f(rate)}%" + if extra is not None: + part += f"|时长={_f(extra)}h" + lines.append(part) + return "\n".join(lines) + + if mode == "warnings": + items = root if isinstance(root, list) else [] + if isinstance(root, dict): + for key in ("warnings", "items", "list", "rows"): + if isinstance(root.get(key), list): + items = root.get(key) + break + if isinstance(items, list): + lines.append(f"- 预警条数: {len(items)}") + for idx, it in enumerate(items[:10], 1): + if isinstance(it, dict): + msg = it.get("message") or it.get("warnMsg") or it.get("content") or it.get("title") + lines.append(f" {idx}. {_f(msg)}") + else: + lines.append(f" {idx}. {_f(it)}") + return "\n".join(lines) + + if mode in {"designer_deduction", "designer_score"}: + items = root if isinstance(root, list) else [] + if isinstance(root, dict): + for key in ("details", "items", "list", "rows", "data"): + if isinstance(root.get(key), list): + items = root.get(key) + break + items = [it for it in items if isinstance(it, dict)] + lines.append(f"- 记录数: {len(items)}") + lines.append("- 前10条:") + for idx, it in enumerate(items[:10], 1): + lines.append( + f" {idx}. 时间={_f(it.get('time') or it.get('createTime') or it.get('date'))}|" + f"分值={_f(it.get('score') or it.get('deductionScore') or it.get('value'))}|" + f"原因={_f(it.get('reason') or it.get('remark') or it.get('content'))}" + ) + return "\n".join(lines) + + # fallback + fallback = json.dumps(data, ensure_ascii=False, indent=2) + if len(fallback) > 1800: + fallback = fallback[:1800] + "\n...(已截断)" + lines.append("- 结果预览:") + lines.append(fallback) + return "\n".join(lines) + + def _build_open_api_sign(self, secret: str, payload: str) -> str: + return hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest() + + def _post_open_api_json(self, base_url: str, path: str, body: dict) -> dict: + url = f"{base_url.rstrip('/')}{path}" + timeout = max(10, min(int(os.getenv("OPEN_API_TIMEOUT", "60")), 300)) + last_err = None + + def _parse_response(resp): + content_type = (resp.headers.get("Content-Type") or "").lower() + if "json" not in content_type: + text = (resp.text or "").strip() + raise RuntimeError(f"HTTP {resp.status_code} 非JSON响应: {text[:300]}") + data = resp.json() + if not resp.ok: + code = data.get("code", "unknown") + msg = data.get("message", data) + raise RuntimeError(f"HTTP {resp.status_code} / code={code} / msg={msg}") + if data.get("code") != 200: + raise RuntimeError(f"code={data.get('code')} / msg={data.get('message')}") + return data.get("data") + + try: + resp = requests.post(url, json=body, timeout=timeout) + return _parse_response(resp) + except Exception as exc: + last_err = exc + self.logger.warning("open api request attempt1 failed: %s", exc) + + try: + with requests.Session() as session: + session.trust_env = False + resp = session.post(url, json=body, timeout=timeout) + return _parse_response(resp) + except Exception as exc: + last_err = exc + self.logger.warning("open api request attempt2 failed: %s", exc) + + raise last_err or RuntimeError("open api request failed") + + def _run_dalongxia_analytics(self, prompt: str) -> str: + base_url = ( + os.getenv("OPEN_API_BASE_URL", "").strip() + or os.getenv("OPEN_API_BASE", "").strip() + or "https://qd.yydd.ai" + ) + app_key = os.getenv("OPEN_API_APP_KEY", "").strip() + app_secret = os.getenv("OPEN_API_APP_SECRET", "").strip() + if not app_key or not app_secret: + return "壹点需求系统查询失败:缺少 OPEN_API_APP_KEY / OPEN_API_APP_SECRET。" + + mode = self._parse_analytics_mode(prompt) + date_range = self._parse_analytics_date_range(prompt) + month = self._parse_analytics_month(prompt) + dept_id, user_id = self._parse_analytics_ids(prompt) + include_task_progress = self._parse_include_task_progress(prompt) + start_time, end_time = self._parse_analytics_time_window(prompt, month) + try: + path, title, body = self._build_dalongxia_analytics_request( + mode=mode, + app_key=app_key, + app_secret=app_secret, + params={ + "date_range": date_range, + "month": month, + "dept_id": dept_id, + "user_id": user_id, + "include_task_progress": include_task_progress, + "start_time": start_time, + "end_time": end_time, + }, + ) + data = self._post_open_api_json(base_url, path, body) + wrapped = data if isinstance(data, dict) and ("data" in data or "traceId" in data or "time" in data) else {"data": data} + return self._format_dalongxia_analytics_result(mode=mode, title=title, body=body, data=wrapped) + except Exception as exc: + return f"壹点需求系统查询失败: {exc}" + + def _is_sales_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt).lower() + return any(k.lower() in normalized for k in SENSITIVE_SALES_KEYWORDS) + + def _explicit_amount_requested(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt).lower() + return any(k.lower() in normalized for k in EXPLICIT_AMOUNT_KEYWORDS) + + def _mask_sensitive_amounts(self, prompt: str, answer: str) -> str: + if not self._is_sales_prompt(prompt): + return answer + if self._explicit_amount_requested(prompt): + return answer + + filtered_lines = [] + removed = 0 + for line in answer.splitlines(): + if re.search(r"(金额|退款率|费比|收入|利润|成本|客单价|GMV|毛利|\d[\d,\.]*\s*(元|万元|万|亿))", line, re.IGNORECASE): + removed += 1 + continue + filtered_lines.append(line) + + cleaned = "\n".join(filtered_lines).strip() + if removed > 0: + note = "(已按配置自动隐藏金额类敏感指标,仅保留件数/订单维度。)" + if cleaned: + cleaned = f"{cleaned}\n\n{note}" + else: + cleaned = note + + return cleaned or "已按配置隐藏金额类敏感指标,请改问件数/订单/原因结构维度。" + + def _extract_market_subject(self, prompt: str) -> str: + normalized = prompt.strip() + + def clean_subject(raw: str) -> str: + v = raw.strip().replace("\u201c", "").replace("\u201d", "").strip("\"'") + v = re.sub(r"\s+", "", v) + v = re.sub(r"^(帮我|请|麻烦|给我|我想|想要|想看|想做|分析|查看|查询|关于|整理一份|整理|输出|生成)+", "", v) + v = re.sub(r"^(近(?:[一二三四五六七八九十两半0-9]+)(?:天|周|个月|月|年)|过去(?:[一二三四五六七八九十两半0-9]+)(?:天|周|个月|月|年)|近30天|近7天|近15天|近一个月|近三个月|近半年|近一年)+", "", v) + v = re.sub(r"(的|情况|数据|分析)+$", "", v) + return v.strip() + + def pick(raw: str): + v = clean_subject(raw) + if len(v) < 2: + return None + if v in {"市场", "行业", "类目", "竞品", "趋势", "市场数据", "关键词", "搜索"}: + return None + return v + + if "电脑桌" in normalized: + return "电脑桌" + + m = re.search(r"[\"\u201c\u201d']([^\"\u201c\u201d']{2,30})[\"\u201c\u201d']", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search( + r"关于([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据|市场情况)", + normalized, + ) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search( + r"([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据)", + normalized, + ) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search(r"([\u4e00-\u9fa5A-Za-z0-9]{2,20})类目", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search(r"([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:市场|市场搜索|关键词|飙升词|热词)", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search(r"分析(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search(r"查(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + return "市场主题" + + def _extract_order_id(self, prompt: str) -> str: + text = prompt.strip() + for pattern in ( + r"订单号[::\s]*([A-Za-z0-9-]{8,40})", + r"订单编号[::\s]*([A-Za-z0-9-]{8,40})", + r"订单[::\s]*([A-Za-z0-9-]{8,40})", + ): + m = re.search(pattern, text, re.IGNORECASE) + if m: + return m.group(1).strip() + + m2 = ORDER_ID_RE.search(text) + if m2: + return m2.group(0).strip() + return "" + + def _extract_product_no(self, prompt: str) -> str: + text = prompt.strip() + for pattern in ( + r"(?:产品货号|货号|商品货号|SKU编码|SKUID|sku编码|skuid|sku)[::\s]*([A-Za-z0-9_-]{5,40})", + ): + m = re.search(pattern, text, re.IGNORECASE) + if m: + return m.group(1).strip() + m2 = re.search(r"\b[A-Za-z]{1,4}\d{4,}[A-Za-z0-9_-]{2,}\b", text) + if m2: + return m2.group(0).strip() + return "" + + def _is_code_prompt(self, prompt: str) -> bool: + normalized = (prompt or "").lower() + hints = ( + "代码", + "sql", + "python", + "java", + "javascript", + "脚本", + "bash", + "shell", + "命令", + "函数", + "class", + "yaml", + "json", + "正则", + ) + return any(h in normalized for h in hints) + + def _clean_dialog_noise(self, answer: str) -> str: + if not answer: + return answer + + cleaned = [] + for raw in answer.splitlines(): + line = raw.strip() + lower = line.lower() + if not line: + cleaned.append(raw) + continue + + if line.startswith("[plugins]"): + continue + if "registered" in lower and "tool" in lower: + continue + if "no toolbeencalled" in lower: + continue + if "without a preceding text-to-speech" in lower: + continue + if line.startswith("原始输出:"): + continue + if line.startswith("{") and "\"runId\"" in line: + continue + + cleaned.append(raw) + + out = "\n".join(cleaned).strip() + return out or answer + + def _trim_noncode_blocks(self, prompt: str, answer: str) -> str: + if not answer or self._is_code_prompt(prompt): + return answer + + pattern = re.compile(r"```[\s\S]*?```", re.MULTILINE) + + def _repl(match): + block = match.group(0) + lines = [ln for ln in block.splitlines() if ln.strip()] + if len(lines) <= 12: + return block + return "```text\n[代码块已省略,如需完整代码请明确说明]\n```" + + return pattern.sub(_repl, answer) + + def _disable_card_temporarily(self, reason: str) -> None: + self.card_disabled_until = time.time() + self.card_disable_seconds + self.logger.warning("card reply disabled for %ss, reason=%s", self.card_disable_seconds, reason) + + def _extract_incoming_download_codes(self, msg: ChatbotMessage) -> list: + try: + raw_codes = msg.get_image_list() or [] + except Exception: + self.logger.exception("failed to read incoming image list") + return [] + + seen = set() + codes = [] + for raw in raw_codes: + code = str(raw or "").strip() + if not code or code in seen: + continue + seen.add(code) + codes.append(code) + if len(codes) >= self.image_max_images: + break + return codes + + def _guess_image_ext(self, content_type: str, source_url: str) -> str: + ctype = (content_type or "").split(";")[0].strip().lower() + ext = "" + if ctype: + ext = mimetypes.guess_extension(ctype) or "" + if not ext: + path = urllib.parse.urlparse(source_url or "").path + _, path_ext = os.path.splitext(path) + ext = path_ext + ext = (ext or ".jpg").lower() + if ext.startswith("."): + ext = ext[1:] + if ext == "jpeg": + return "jpg" + if ext not in {"jpg", "png", "webp", "gif", "bmp"}: + return "jpg" + return ext + + def _upload_chat_image_bytes(self, image_bytes: bytes, ext: str, conversation_id: str) -> str: + if not self.image_upload_to_s3: + return "" + if boto3 is None or BotoConfig is None: + return "" + if not (self.s3_bucket and self.s3_access_key and self.s3_secret_key): + return "" + + safe_conv = self._safe_session_component(str(conversation_id or "chat")) + stamp = time.strftime("%Y%m%d%H%M%S") + filename = f"{safe_conv}_{stamp}_{uuid.uuid4().hex[:8]}.{ext}" + object_key = f"{self.s3_key_prefix}chat-images/{filename}" + content_type = f"image/{'jpeg' if ext == 'jpg' else ext}" + with tempfile.TemporaryDirectory(prefix="chat_image_") as tmpdir: + local_path = os.path.join(tmpdir, filename) + with open(local_path, "wb") as fh: + fh.write(image_bytes) + self._upload_to_s3(local_path, object_key, content_type) + return self._build_public_s3_url(object_key) + + def _analyze_image_with_local_vlm(self, image_bytes: bytes, user_prompt: str, image_index: int) -> str: + if not self.image_analysis_enabled: + return "" + payload = { + "model": self.image_analysis_model, + "stream": False, + "messages": [ + { + "role": "user", + "content": ( + "你现在只做OCR和画面客观描述,不做任何分析和建议。\n" + "请输出格式:\n" + "OCR_TEXT_BEGIN\n" + "<逐行文字,尽量保留原样,包含中文/英文/数字/LOGO字母>\n" + "OCR_TEXT_END\n" + "SCENE_DESC_BEGIN\n" + "<画面客观描述:人物/物体/场景/颜色/布局;若无法判断写 UNKNOWN_SCENE>\n" + "SCENE_DESC_END\n" + "规则:\n" + "- 只能输出OCR和画面描述,不要解释;\n" + "- 不确定字符用 ? 标记;\n" + "- 完全无文字时输出 NO_TEXT;\n" + "- 画面描述不得给建议、结论或推断。\n" + f"图片序号:{image_index}\n" + f"用户问题:{user_prompt or '请识别并分析图片'}" + ), + "images": [base64.b64encode(image_bytes).decode("ascii")], + } + ], + } + try: + resp = requests.post( + "http://127.0.0.1:11434/api/chat", + json=payload, + timeout=self.image_analysis_timeout, + ) + resp.raise_for_status() + data = resp.json() + text = str((data.get("message") or {}).get("content", "")).strip() + if not text: + return "" + return text[: self.image_vision_output_max_chars] + except Exception as exc: + self.logger.warning("image vlm analysis failed image=%s err=%s", image_index, exc) + return "" + + def _build_image_context_from_dingtalk(self, msg: ChatbotMessage, prompt: str, download_codes: list) -> dict: + blocks = [] + uploaded_urls = [] + warnings = [] + used_images = 0 + + for idx, code in enumerate(download_codes, 1): + try: + download_url = self.get_image_download_url(code) + if not download_url: + warnings.append(f"图片{idx}下载地址获取失败") + continue + + resp = requests.get(download_url, timeout=self.image_download_timeout) + resp.raise_for_status() + image_bytes = resp.content or b"" + if not image_bytes: + warnings.append(f"图片{idx}为空内容") + continue + if len(image_bytes) > self.image_max_bytes: + warnings.append(f"图片{idx}过大,已跳过") + continue + + ext = self._guess_image_ext(resp.headers.get("Content-Type", ""), download_url) + s3_url = "" + try: + s3_url = self._upload_chat_image_bytes(image_bytes, ext, str(getattr(msg, "conversation_id", ""))) + except Exception as exc: + self.logger.warning("upload incoming image failed idx=%s err=%s", idx, exc) + if s3_url: + uploaded_urls.append(s3_url) + + vision = self._analyze_image_with_local_vlm(image_bytes, prompt, idx) + if not vision: + warnings.append(f"图片{idx}识别失败") + continue + + item_lines = [f"图片{idx}感知结果(OCR+场景):"] + item_lines.append(vision) + blocks.append("\n".join(item_lines)) + used_images += 1 + except Exception as exc: + warnings.append(f"图片{idx}处理异常: {exc}") + + result = { + "used_images": used_images, + "context_text": "\n\n".join(blocks).strip(), + "uploaded_urls": uploaded_urls, + "warnings": warnings, + } + self.logger.info( + "image context built: incoming=%s used=%s uploaded=%s warnings=%s", + len(download_codes), + used_images, + len(uploaded_urls), + len(warnings), + ) + return result + + def _merge_prompt_with_image_context(self, prompt: str, image_context: str) -> str: + base = (prompt or "").strip() or "请识别并分析我上传的图片内容。" + return ( + f"{base}\n\n" + "【图片OCR与场景结果(本地模型)】\n" + f"{image_context}\n\n" + "请仅基于以上OCR文本和场景描述做理解、分析和建议。\n" + "注意:图片已完成本地识别,不要再尝试访问图片URL,也不要调用browser/image工具。" + ).strip() + + def _sanitize_answer(self, prompt: str, answer: str) -> str: + if self._is_market_prompt(prompt) or self._is_install_media_prompt(prompt): + return answer + + if not self.hide_source_urls: + return answer + + keep_map = {} + + def _keep_or_mask_url(match): + raw = match.group(0) + try: + host = (urllib.parse.urlparse(raw).hostname or "").lower() + except Exception: + host = "" + + if host and ( + host in self.unmask_url_hosts + or host == "feishu.cn" + or host.endswith(".feishu.cn") + ): + token = f"__KEEP_URL_{len(keep_map)}__" + keep_map[token] = raw + return token + + return "[内部数据源地址已脱敏]" + + out = URL_RE.sub(_keep_or_mask_url, answer) + out = IP_RE.sub("[内部地址已脱敏]", out) + out = DOMAIN_RE.sub("[内部域名已脱敏]", out) + + # 保底脱敏(防止某些格式未被正则覆盖) + for token in ("hq.yucekj.com", "yucekj.com", "218.93.9.141"): + out = out.replace(token, "[内部数据源已脱敏]") + + for token, raw in keep_map.items(): + out = out.replace(token, raw) + + return out + + def _run_market_brief(self, subject: str) -> str: + cmd = [self.market_brief_bin, subject] + self.logger.info("market-brief call: subject=%s", subject) + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.market_brief_timeout, + ) + output = (proc.stdout or "").strip() + if proc.returncode != 0: + err = (proc.stderr or output or "market brief error").strip() + return f"市场分析执行失败: {err[:500]}" + if not output: + return "市场分析执行完成,但未返回文本结果。" + return output + + def _parse_install_media_output(self, output: str) -> dict: + parsed = {"status": "", "mappings": [], "media": [], "warnings": []} + current_set = "" + in_warnings = False + for raw in (output or "").splitlines(): + line = raw.strip() + if not line: + continue + if line.startswith("STATUS:"): + parsed["status"] = line.split(":", 1)[1].strip() + in_warnings = False + continue + if line.startswith("NAME:"): + current_set = line.split(":", 1)[1].strip() + in_warnings = False + continue + if line == "WARNINGS:": + in_warnings = True + current_set = "" + continue + if in_warnings and line.startswith("- "): + parsed["warnings"].append(line[2:].strip()) + continue + if not line.startswith("{"): + continue + try: + obj = json.loads(line) + except Exception: + continue + if current_set == "order_to_product_mapping": + parsed["mappings"].append(obj) + elif current_set == "install_media_links": + parsed["media"].append(obj) + return parsed + + def _run_install_media_lookup(self, prompt: str) -> str: + order_id = self._extract_order_id(prompt) + product_no = self._extract_product_no(prompt) + if not order_id and not product_no: + return "请提供订单号(或产品货号),我将按“订单号 -> 货号 -> 安装图纸/视频库”流程查询。" + + cmd = [self.install_media_bin] + if order_id: + cmd += ["--order-id", order_id] + if product_no: + cmd += ["--sku-id", product_no] + cmd += ["--mysql-schema", "jiansunblueprint", "--limit", "200"] + + self.logger.info("install-media call: order_id=%s product_no=%s", order_id or "-", product_no or "-") + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.install_media_timeout, + ) + except subprocess.TimeoutExpired: + return f"安装资源查询超时(>{self.install_media_timeout}秒),请重试。" + + raw_output = (proc.stdout or "").strip() + if proc.returncode != 0 and not raw_output: + err = (proc.stderr or "install media lookup error").strip() + return f"安装资源查询失败: {err[:500]}" + + parsed = self._parse_install_media_output(raw_output) + mappings = parsed.get("mappings", []) + media_rows = parsed.get("media", []) + warnings = parsed.get("warnings", []) + + product_nos = sorted( + { + str(x.get("product_no", "")).strip() + for x in mappings + if str(x.get("product_no", "")).strip() and str(x.get("product_no", "")).strip() != "(none)" + } + ) + + drawing_rows = [x for x in media_rows if str(x.get("media_type", "")).lower() == "drawing"] + video_rows = [x for x in media_rows if str(x.get("media_type", "")).lower() == "video"] + stitch_required = self.install_media_stitch_enabled or self._is_install_media_stitch_prompt(prompt) + stitch_url = "" + stitch_note = "" + if stitch_required and drawing_rows: + drawing_urls = [] + seen_urls = set() + for row in drawing_rows: + url = str(row.get("media_url", "")).strip() + if not url or url in seen_urls: + continue + seen_urls.add(url) + drawing_urls.append(url) + if len(drawing_urls) >= self.install_media_stitch_min_images: + product_hint = ( + (product_nos[0] if product_nos else "") + or product_no + or order_id + or "unknown" + ) + stitch_url, stitch_note = self._stitch_and_upload_install_media( + drawing_urls=drawing_urls[: self.install_media_stitch_max_images], + product_hint=product_hint, + ) + + lines = ["安装资源查询结果"] + if order_id: + lines.append(f"- 订单号: {order_id}") + if product_nos: + lines.append(f"- 命中货号: {', '.join(product_nos[:8])}") + elif product_no: + lines.append(f"- 输入货号: {product_no}") + + lines.append(f"- 图纸: {len(drawing_rows)} 条,视频: {len(video_rows)} 条") + if stitch_url: + lines.append(f"- 拼接图: {stitch_url}") + elif stitch_note and self._is_install_media_stitch_prompt(prompt): + lines.append(f"- 拼接图: {stitch_note}") + + display_rows = media_rows + if stitch_url: + # 用户要求仅返回拼接图,不再返回原始多条图纸链接;视频链接保留 + display_rows = [x for x in media_rows if str(x.get("media_type", "")).lower() != "drawing"] + + if display_rows: + lines.append("- 资源链接(前20条):") + for i, row in enumerate(display_rows[:20], 1): + mtype = str(row.get("media_type", "")).lower() + tag = "图纸" if mtype == "drawing" else ("视频" if mtype == "video" else "资源") + url = str(row.get("media_url", "")).strip() + if url: + lines.append(f"{i}. [{tag}] {url}") + if len(display_rows) > 20: + lines.append(f"- 其余 {len(display_rows) - 20} 条已省略。") + else: + if stitch_url: + lines.append("- 原始图纸链接已按配置隐藏。") + else: + lines.append("- 未查到安装图纸/视频链接。") + + if warnings: + lines.append("- 警告:") + for w in warnings[:3]: + lines.append(f" - {w}") + + return "\n".join(lines) + + def _run_video_analysis(self, prompt: str) -> str: + if not self.video_analysis_enabled: + return "视频分析功能未启用。" + + video_urls = self._extract_video_urls(prompt, limit=self.video_analysis_max_urls) + if not video_urls: + any_urls = self._extract_any_urls(prompt, limit=self.video_analysis_max_urls) + if any_urls: + video_urls = any_urls + self.logger.warning("video-analysis fallback to raw url(s): %s", ",".join(video_urls)) + else: + return "请提供可访问的视频链接(http/https),我再开始分析。" + + api_key = ( + os.getenv("DASHSCOPE_API_KEY", "").strip() + or os.getenv("BAILIAN_API_KEY", "").strip() + or os.getenv("QWEN_API_KEY", "").strip() + ) + if not api_key: + return "视频分析未配置API Key,请设置 DASHSCOPE_API_KEY 后重试。" + + model_name = (self.video_analysis_model or "").strip() + lower_model = model_name.lower() + if ( + ("vl" not in lower_model and "omni" not in lower_model) + and self.video_analysis_fallback_model + and self.video_analysis_fallback_model != model_name + ): + self.logger.warning( + "video-analysis model may not support video input, auto fallback: %s -> %s", + model_name, + self.video_analysis_fallback_model, + ) + model_name = self.video_analysis_fallback_model + + need_script = self._needs_video_script(prompt) + user_goal = self._remove_image_urls(prompt, video_urls).strip() + if not user_goal: + if need_script: + user_goal = "请深度拆解该视频,并输出可直接复刻的分镜脚本和拍摄执行方案。" + else: + user_goal = "请分析该视频的核心内容、关键信息和可执行建议。" + elif "视频" not in user_goal and "video" not in user_goal.lower(): + user_goal = f"{user_goal}\n请围绕视频内容输出结论。" + + content_items = [{"type": "video_url", "video_url": {"url": video_urls[0]}}] + content_items.append({"type": "text", "text": user_goal}) + + system_prompt = ( + "你是视频内容分析助手。请基于视频画面和字幕内容输出结构化结论,避免臆测。" + ) + if need_script: + system_prompt = ( + "你是资深短视频导演与编剧。请基于视频内容做可执行复刻拆解。\n" + "输出必须包含:\n" + "1) 视频定位与核心卖点(1-2句)\n" + "2) 时间轴分镜脚本表(时间段/镜头景别/画面内容/人物动作/机位与运镜/字幕或口播/BGM与音效)\n" + "3) 拍摄执行清单(场景、道具、服装、灯光、设备)\n" + "4) 剪辑脚本(节奏点、转场、卡点、字幕样式)\n" + "5) 复刻风险与规避建议(至少3条)\n" + "6) 可直接给拍摄团队的最终脚本(按步骤)\n" + "要求:具体、可落地、少空话。" + ) + + payload = { + "model": model_name, + "messages": [ + { + "role": "system", + "content": system_prompt, + }, + { + "role": "user", + "content": content_items, + }, + ], + "temperature": 0.2, + "max_tokens": self.video_analysis_max_tokens, + } + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + self.logger.info("video-analysis call: model=%s url_count=%s urls=%s", model_name, len(video_urls), ",".join(video_urls)) + try: + resp = requests.post( + self.video_analysis_base_url, + headers=headers, + json=payload, + timeout=self.video_analysis_timeout, + ) + except Exception as exc: + self.logger.error("video-analysis request failed: %s", exc) + return f"视频分析调用失败:{exc}" + + text = "" + if resp.status_code >= 400: + try: + err_data = resp.json() + err_msg = ( + err_data.get("error", {}).get("message") + or err_data.get("message") + or resp.text + ) + except Exception: + err_msg = resp.text + self.logger.error( + "video-analysis http error: status=%s model=%s body=%s", + resp.status_code, + model_name, + (str(err_msg) or "")[:800], + ) + hint = "" + em = (str(err_msg) or "").lower() + if ( + "video" in em + and ("not support" in em or "invalid" in em or "unsupported" in em) + and "vl" not in model_name.lower() + ): + hint = "(当前模型可能不支持视频输入,建议改为 qwen-vl-plus / qwen-vl-max)" + return f"视频分析失败(HTTP {resp.status_code}):{str(err_msg).strip()[:300]}{hint}" + + try: + data = resp.json() + choices = data.get("choices") or [] + message = (choices[0] or {}).get("message", {}) if choices else {} + content = message.get("content", "") + if isinstance(content, list): + chunks = [] + for item in content: + if isinstance(item, dict): + t = str(item.get("text", "")).strip() + if t: + chunks.append(t) + text = "\n".join(chunks).strip() + else: + text = str(content or "").strip() + except Exception as exc: + self.logger.exception("video-analysis parse failed") + self.logger.error("video-analysis raw body: %s", (resp.text or "")[:1000]) + return f"视频分析返回解析失败:{exc}" + + if not text: + return "视频分析完成,但未返回有效文本结果。" + + if len(video_urls) > 1: + return f"{text}\n\n(已按配置先分析第1个视频链接;如需逐条分析请分别发送。)" + return text + + def _stitch_and_upload_install_media(self, drawing_urls: list, product_hint: str) -> tuple: + if Image is None: + return "", "未执行(缺少 Pillow 依赖)" + if boto3 is None or BotoConfig is None: + return "", "未执行(缺少 boto3 依赖)" + if not (self.s3_bucket and self.s3_access_key and self.s3_secret_key): + return "", "未执行(S3 配置不完整)" + + images = [] + try: + for url in drawing_urls: + try: + resp = requests.get(url, timeout=self.install_media_fetch_timeout) + resp.raise_for_status() + with Image.open(io.BytesIO(resp.content)) as img: + images.append(img.convert("RGB")) + except Exception as exc: + self.logger.warning("install-media image download failed: %s %s", url, exc) + + if len(images) < self.install_media_stitch_min_images: + return "", "未执行(可下载图纸不足)" + + target_width = min(max(img.width for img in images), self.install_media_max_width) + resized_images = [] + total_height = 0 + for img in images: + if img.width != target_width: + new_height = max(1, int(img.height * target_width / img.width)) + if hasattr(Image, "Resampling"): + resized = img.resize((target_width, new_height), Image.Resampling.LANCZOS) + else: + resized = img.resize((target_width, new_height), Image.LANCZOS) + else: + resized = img.copy() + resized_images.append(resized) + total_height += resized.height + + stitched = Image.new("RGB", (target_width, total_height), (255, 255, 255)) + y = 0 + for img in resized_images: + stitched.paste(img, (0, y)) + y += img.height + + safe_product = re.sub(r"[^A-Za-z0-9_-]+", "-", (product_hint or "unknown")).strip("-")[:40] or "unknown" + stamp = time.strftime("%Y%m%d%H%M%S") + filename = f"{safe_product}_{stamp}_{uuid.uuid4().hex[:8]}.jpg" + object_key = f"{self.s3_key_prefix}install-media/{filename}" + + with tempfile.TemporaryDirectory(prefix="install_media_") as tmpdir: + local_path = os.path.join(tmpdir, filename) + stitched.save(local_path, format="JPEG", quality=88, optimize=True) + self._upload_to_s3(local_path, object_key, "image/jpeg") + + return self._build_public_s3_url(object_key), "" + except Exception as exc: + self.logger.exception("install-media stitch/upload failed") + return "", f"未执行({exc})" + finally: + for img in images: + try: + img.close() + except Exception: + pass + + def _upload_to_s3(self, file_path: str, object_key: str, content_type: str) -> None: + endpoint = self.s3_endpoint or None + config = BotoConfig(s3={"addressing_style": "path" if self.s3_path_style else "virtual"}) + client = boto3.client( + "s3", + endpoint_url=endpoint, + region_name=self.s3_region, + aws_access_key_id=self.s3_access_key, + aws_secret_access_key=self.s3_secret_key, + config=config, + ) + with open(file_path, "rb") as fh: + kwargs = { + "Bucket": self.s3_bucket, + "Key": object_key, + "Body": fh, + "ContentType": content_type, + } + if self.s3_acl: + kwargs["ACL"] = self.s3_acl + client.put_object(**kwargs) + + def _build_public_s3_url(self, object_key: str) -> str: + safe_key = urllib.parse.quote(object_key, safe="/-_.~") + if self.s3_public_domain: + return f"{self.s3_public_domain.rstrip('/')}/{safe_key}" + if self.s3_endpoint: + base = self.s3_endpoint.rstrip("/") + if self.s3_path_style: + return f"{base}/{self.s3_bucket}/{safe_key}" + return f"{base}/{safe_key}" + return safe_key + + def _run_openclaw(self, session_id: str, prompt: str) -> str: + cmd = [ + self.openclaw_bin, + "agent", + "--session-id", + session_id, + "--message", + prompt, + "--json", + "--timeout", + str(self.openclaw_timeout), + ] + self.logger.info("openclaw call: session=%s", session_id) + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.openclaw_timeout + 20, + ) + except subprocess.TimeoutExpired: + self.logger.error("openclaw call timeout: session=%s timeout=%ss", session_id, self.openclaw_timeout) + return f"OpenClaw 调用超时(>{self.openclaw_timeout}秒),请重试。" + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "openclaw error").strip() + return f"OpenClaw 调用失败: {err[:500]}" + + try: + raw_stdout = (proc.stdout or "").strip() + decoder = json.JSONDecoder() + data = None + parse_error = None + try: + data, _ = decoder.raw_decode(raw_stdout) + except Exception as exc: + parse_error = exc + idx = raw_stdout.find("{") + while idx != -1: + candidate = raw_stdout[idx:] + try: + data, _ = decoder.raw_decode(candidate) + break + except Exception: + idx = raw_stdout.find("{", idx + 1) + if data is None: + raise parse_error + + payloads = data.get("result", {}).get("payloads", []) + text_items = [p.get("text", "") for p in payloads if isinstance(p, dict)] + answer = "\n".join(t for t in text_items if t).strip() + if not answer: + answer = "任务已执行,但未返回文本结果。" + + market_fallback_hints = ( + "无法直接从页面抓取", + "需要登录后的操作", + "工具版本不支持点击交互", + "无法正常访问该系统", + "您没有权限访问此页面", + "尚未登录", + "请先前往登录页登录", + "device identity required", + "控制用户界面需要HTTPS", + ) + if self._is_market_prompt(prompt) and any(h in answer for h in market_fallback_hints): + subject = self._extract_market_subject(prompt) + fallback = self._run_market_brief(subject) + return f"已切换为市场分析直连模式({subject}):\n\n{fallback}" + + return answer + except Exception as exc: + self.logger.exception("parse openclaw output failed") + raw = (proc.stdout or "").strip() + return f"结果解析失败: {exc}.\n原始输出: {raw[:500]}" + + def _is_image_url(self, raw_url: str) -> bool: + try: + parsed = urllib.parse.urlparse(raw_url) + except Exception: + return False + + host = (parsed.hostname or "").lower() + path = (parsed.path or "").lower() + if IMAGE_EXT_RE.search(path): + return True + if host.startswith("img.") or "image" in host: + return True + return False + + def _extract_image_urls(self, text: str, limit: int = 5) -> list: + urls = [] + seen = set() + for match in URL_RE.finditer(text or ""): + raw = match.group(0).rstrip(".,;!?") + if not self._is_image_url(raw): + continue + if raw in seen: + continue + seen.add(raw) + urls.append(raw) + if len(urls) >= limit: + break + return urls + + def _remove_image_urls(self, text: str, urls: list) -> str: + out = text + for u in urls: + out = out.replace(u, "") + out = re.sub(r"\n{3,}", "\n\n", out) + return out.strip() + + def _build_action_buttons(self) -> list: + if not self.action_card_btn_url: + return [] + return [ + { + "text": self.action_card_btn_text or "打开链接", + "url": self.action_card_btn_url, + "iosUrl": self.action_card_btn_url, + "color": "gray", + } + ] + + def _is_card_reply_ok(self, reply_obj) -> bool: + if reply_obj is None: + return False + card_instance_id = getattr(reply_obj, "card_instance_id", None) + if isinstance(card_instance_id, str): + return bool(card_instance_id.strip()) + return bool(card_instance_id) + + def _reply_message(self, msg: ChatbotMessage, text: str, prefer_card: bool = True) -> bool: + payload = (text or "").strip() or "任务已执行,但未返回文本结果。" + + if self.reply_style != "action_card": + prefer_card = False + if self.card_disabled_until > time.time(): + prefer_card = False + + if prefer_card: + image_urls = self._extract_image_urls(payload, limit=5) + if image_urls: + slider = [(f"图片{i + 1}", u) for i, u in enumerate(image_urls)] + markdown = self._remove_image_urls(payload, image_urls) + if not markdown: + markdown = "已整理相关图片如下:" + try: + card = self.reply_carousel_card( + incoming_message=msg, + markdown=markdown, + image_slider=slider, + button_text=self.action_card_btn_text or "查看", + title=self.action_card_title, + logo=self.action_card_logo, + ) + if self._is_card_reply_ok(card): + return True + self.logger.warning("carousel card reply returned empty id, fallback to action card/text") + self._disable_card_temporarily("carousel_card_empty_id") + except Exception as exc: + self.logger.exception("carousel card reply failed, fallback to action card") + if "Card.Instance.Write" in str(exc) or "AccessDenied" in str(exc): + self._disable_card_temporarily("card_permission_denied") + + markdown = payload + if self.action_card_title: + markdown = f"### {self.action_card_title}\n\n{payload}" + try: + card = self.reply_markdown_button( + incoming_message=msg, + markdown=markdown, + button_list=self._build_action_buttons(), + tips=self.action_card_tips, + title=self.action_card_title, + logo=self.action_card_logo, + ) + if self._is_card_reply_ok(card): + return True + self.logger.warning("action card reply returned empty id, fallback to text") + self._disable_card_temporarily("action_card_empty_id") + except Exception as exc: + self.logger.exception("action card reply failed, fallback to text") + if "Card.Instance.Write" in str(exc) or "AccessDenied" in str(exc): + self._disable_card_temporarily("card_permission_denied") + + resp = self.reply_text(payload, msg) + if resp is None: + self.logger.error("text reply failed") + return False + return True + + async def process(self, callback: CallbackMessage): + try: + msg = ChatbotMessage.from_dict(callback.data) + except Exception: + self.logger.exception("invalid callback payload") + return AckMessage.STATUS_BAD_REQUEST, "bad payload" + + text = "" + if msg.text and getattr(msg.text, "content", None): + text = msg.text.content.strip() + else: + text_list = msg.get_text_list() or [] + text = text_list[0].strip() if text_list else "" + + incoming_image_codes = self._extract_incoming_download_codes(msg) + + if not text and not incoming_image_codes: + return AckMessage.STATUS_OK, "ignored" + + if not self._should_handle(msg, text): + return AckMessage.STATUS_OK, "ignored" + + prompt = self._normalize_prompt(text) + if not prompt: + if incoming_image_codes: + prompt = "请识别并分析我上传的图片内容,输出结论和建议。" + else: + self._reply_message( + msg, + f"用法: {self.prefix} 你的问题\n例如: {self.prefix} 查询亿家达旗舰店上周业绩", + prefer_card=False, + ) + return AckMessage.STATUS_OK, "ok" + + event_key = self._event_key(callback, msg, prompt) + if self._seen_or_mark_event(event_key): + self.logger.info( + "duplicate callback ignored: key=%s conversation=%s sender=%s", + event_key, + getattr(msg, "conversation_id", ""), + getattr(msg, "sender_id", ""), + ) + return AckMessage.STATUS_OK, "duplicate_ignored" + + if self._is_smalltalk_prompt(prompt): + try: + self._reply_message(msg, self.smalltalk_reply_text, prefer_card=False) + except Exception: + self.logger.exception("failed to send smalltalk reply") + return AckMessage.STATUS_OK, "smalltalk_ok" + + if self.send_processing_hint: + hint = self.processing_hint_text or "已收到,正在处理,请稍等。" + try: + self.reply_text(hint, msg) + except Exception: + self.logger.exception("failed to send processing hint") + + conversation_key = re.sub(r"[^A-Za-z0-9_-]+", "-", str(getattr(msg, "conversation_id", "") or "default")) + conv_lock = self._conversation_locks.setdefault(conversation_key, asyncio.Lock()) + + async with conv_lock: + session_id = self._session_id(msg) + image_context_result = {"used_images": 0, "context_text": "", "uploaded_urls": [], "warnings": []} + prompt_for_exec = prompt + try: + async with self._work_semaphore: + self.logger.info( + "worker acquired: session=%s conversation=%s max_parallel=%s", + session_id, + conversation_key, + self.max_parallel_jobs, + ) + if incoming_image_codes and not self._is_install_media_prompt(prompt): + image_context_result = await asyncio.to_thread( + self._build_image_context_from_dingtalk, + msg, + prompt, + incoming_image_codes, + ) + context_text = image_context_result.get("context_text", "") + if context_text: + prompt_for_exec = self._merge_prompt_with_image_context(prompt, context_text) + if self._is_install_media_prompt(prompt): + answer = await asyncio.to_thread(self._run_install_media_lookup, prompt) + elif self._is_dalongxia_analytics_prompt(prompt): + answer = await asyncio.to_thread(self._run_dalongxia_analytics, prompt) + elif self._is_video_analysis_prompt(prompt): + answer = await asyncio.to_thread(self._run_video_analysis, prompt) + elif self._is_market_prompt(prompt): + subject = self._extract_market_subject(prompt) + answer = await asyncio.to_thread(self._run_market_brief, subject) + else: + answer = await asyncio.to_thread(self._run_openclaw, session_id, prompt_for_exec) + except Exception: + self.logger.exception("worker execution failed: session=%s", session_id) + answer = "系统执行异常,请稍后重试。" + + if incoming_image_codes and image_context_result.get("used_images", 0) == 0: + warnings = image_context_result.get("warnings", []) + warn_text = ";".join(warnings[:2]) if warnings else "图片下载或识别失败" + answer = f"已收到图片,但未完成识别:{warn_text}。请重发清晰图片后再试。" + elif image_context_result.get("warnings"): + answer = f"{answer}\n\n(图片处理提示:{';'.join(image_context_result['warnings'][:2])})" + + answer = self._clean_dialog_noise(answer) + answer = self._trim_noncode_blocks(prompt, answer) + answer = self._sanitize_answer(prompt, answer) + answer = self._mask_sensitive_amounts(prompt, answer) + if len(answer) > self.reply_max_chars: + answer = answer[: self.reply_max_chars] + "\n...(已截断)" + + try: + prefer_card = not self._is_install_media_prompt(prompt) + sent = self._reply_message(msg, answer, prefer_card=prefer_card) + if sent: + self.logger.info("reply sent: session=%s chars=%s", session_id, len(answer)) + else: + self.logger.error("reply failed: session=%s chars=%s", session_id, len(answer)) + except Exception: + self.logger.exception("failed to send final reply: session=%s", session_id) + return AckMessage.STATUS_OK, "ok" + + +def build_client(logger: logging.Logger) -> DingTalkStreamClient: + client_id = os.getenv("DINGTALK_CLIENT_ID", "").strip() + client_secret = os.getenv("DINGTALK_CLIENT_SECRET", "").strip() + + if not client_id or not client_secret: + raise RuntimeError("DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET 未配置") + + credential = Credential(client_id, client_secret) + client = DingTalkStreamClient(credential, logger=logger) + bot = OpenClawDingTalkBot(logger) + client.register_callback_handler(ChatbotMessage.TOPIC, bot) + if bool_env("REGISTER_DELEGATE_TOPIC", False): + client.register_callback_handler(ChatbotMessage.DELEGATE_TOPIC, bot) + logger.info("delegate topic enabled") + else: + logger.info("delegate topic disabled (default)") + return client + + +def self_check(logger: logging.Logger) -> int: + required = ["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"] + missing = [k for k in required if not os.getenv(k)] + if missing: + logger.error("missing env: %s", ", ".join(missing)) + return 2 + + openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw") + proc = subprocess.run([openclaw_bin, "--version"], capture_output=True, text=True) + if proc.returncode != 0: + logger.error("openclaw check failed: %s", (proc.stderr or proc.stdout).strip()) + return 3 + + market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report") + if not os.path.exists(market_brief_bin): + logger.error("market brief bin not found: %s", market_brief_bin) + return 4 + + install_media_bin = os.getenv("INSTALL_MEDIA_BIN", "/usr/local/bin/install-media-lookup") + if not os.path.exists(install_media_bin): + logger.error("install media bin not found: %s", install_media_bin) + return 5 + + logger.info("self-check ok, openclaw=%s", (proc.stdout or "").strip()) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="OpenClaw DingTalk Bridge") + parser.add_argument("--check", action="store_true", help="run checks and exit") + args = parser.parse_args() + + logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + logger = logging.getLogger("openclaw-dingtalk-bridge") + + if args.check: + return self_check(logger) + + client = build_client(logger) + logger.info("bridge started, listening DingTalk stream") + client.start_forever() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/remote_sync/bot.py.minimax-work b/remote_sync/bot.py.minimax-work new file mode 100755 index 0000000..58344b7 --- /dev/null +++ b/remote_sync/bot.py.minimax-work @@ -0,0 +1,2319 @@ +#!/usr/bin/env python3 +import argparse +import asyncio +import base64 +import datetime as dt +import hashlib +import hmac +import io +import json +import logging +import mimetypes +import os +import re +import subprocess +import tempfile +import time +import urllib.parse +import uuid + +from dingtalk_stream import AckMessage, CallbackMessage, ChatbotHandler, ChatbotMessage +from dingtalk_stream import Credential, DingTalkStreamClient +import requests + +try: + from PIL import Image +except Exception: + Image = None + +try: + import boto3 + from botocore.config import Config as BotoConfig +except Exception: + boto3 = None + BotoConfig = None + + +MARKET_HINT_KEYWORDS = ( + "市场分析", + "市场数据分析", + "行业分析", + "类目分析", + "竞品分析", + "趋势分析", +) + +SALES_HINT_KEYWORDS = ( + "销售数据", + "销售报表", + "销售看板", + "公司销售", + "业绩报表", + "业绩数据", + "费比监控", +) + +SENSITIVE_SALES_KEYWORDS = ( + "销售", + "业绩", + "售后", + "退款", + "店铺", + "发货", + "工单", + "费比", +) + +EXPLICIT_AMOUNT_KEYWORDS = ( + "金额", + "费用", + "收入", + "利润", + "成本", + "客单价", + "gmv", + "销售额", + "退款额", + "毛利", +) + +INSTALL_MEDIA_HINT_KEYWORDS = ( + "安装图纸", + "安装视频", + "安装说明书", + "图纸地址", + "视频地址", + "图纸链接", + "视频链接", + "订单号查图纸", + "订单号查视频", +) + +INSTALL_MEDIA_STITCH_HINT_KEYWORDS = ( + "拼接", + "合并", + "一张图", + "整合图", + "汇总图", +) + +VIDEO_ANALYSIS_HINT_KEYWORDS = ( + "视频", + "video", + "短片", + "片段", +) + +VIDEO_ANALYSIS_ACTION_KEYWORDS = ( + "分析", + "解读", + "总结", + "提炼", + "复盘", + "讲解", + "评价", + "建议", + "看看", + "看下", + "说明", +) + +VIDEO_SCRIPT_HINT_KEYWORDS = ( + "脚本", + "分镜", + "复刻", + "仿拍", + "同款", + "类似视频", + "拍摄方案", + "镜头脚本", +) + +DALONGXIA_ANALYTICS_HINT_KEYWORDS = ( + "壹点需求", + "需求系统", + "任务进度总览", + "进度总览", + "数据分析总览", + "需求统计", + "设计师绩效", + "流程效率", + "预警", + "维度分析", + "主部门", + "designerperformance", + "dimensionanalysis", + "taskprogressoverview", + "designerdeduction", + "designerscore", + "primarydept", + "primarytask", + "analytics", +) + + +URL_RE = re.compile(r"https?://[^\s)\]>]+", re.IGNORECASE) +IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b") +DOMAIN_RE = re.compile(r"\b(?:[a-zA-Z0-9-]+\.)+(?:com|cn|net|ai|org|io)\b", re.IGNORECASE) +IMAGE_EXT_RE = re.compile(r"\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:$|[?#])", re.IGNORECASE) +VIDEO_EXT_RE = re.compile(r"\.(?:mp4|mov|m4v|webm|mkv|avi|flv|wmv|ts|m3u8)(?:$|[?#])", re.IGNORECASE) +ORDER_ID_RE = re.compile(r"\b\d{12,24}(?:-\d{2,4})?\b") +SMALLTALK_SET = { + "你好", + "您好", + "嗨", + "哈喽", + "在吗", + "在嘛", + "早上好", + "上午好", + "中午好", + "下午好", + "晚上好", + "hi", + "hello", +} + + + +def bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +class OpenClawDingTalkBot(ChatbotHandler): + def __init__(self, logger: logging.Logger): + super().__init__() + self.logger = logger + self.openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw") + self.openclaw_timeout = int(os.getenv("OPENCLAW_TIMEOUT", "600")) + self.market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report") + self.market_brief_timeout = int(os.getenv("MARKET_BRIEF_TIMEOUT", "180")) + self.install_media_bin = os.getenv("INSTALL_MEDIA_BIN", "/usr/local/bin/install-media-lookup") + self.install_media_timeout = int(os.getenv("INSTALL_MEDIA_TIMEOUT", "120")) + self.prefix = os.getenv("BOT_PREFIX", "/oc").strip() + self.require_prefix = bool_env("REQUIRE_PREFIX", False) + self.reply_max_chars = int(os.getenv("REPLY_MAX_CHARS", "3500")) + self.session_prefix = os.getenv("OPENCLAW_SESSION_PREFIX", "dingtalk") + self.hide_source_urls = bool_env("HIDE_SOURCE_URLS", True) + self.send_processing_hint = bool_env("SEND_PROCESSING_HINT", True) + self.dedupe_ttl_seconds = int(os.getenv("DEDUPE_TTL_SECONDS", "300")) + self.session_max_lines = int(os.getenv("OPENCLAW_SESSION_MAX_LINES", "120")) + self.sessions_dir = os.getenv("OPENCLAW_SESSIONS_DIR", "/root/.openclaw/agents/main/sessions") + self.unmask_url_hosts = { + h.strip().lower() + for h in os.getenv( + "UNMASK_URL_HOSTS", + "img.alicdn.com,static.jiansun.vip,img.jiansuntuzhi.cn,v.youku.com", + ).split(",") + if h.strip() + } + self._recent_event_ts = {} + self.processing_hint_text = os.getenv( + "PROCESSING_HINT_TEXT", + "已收到,正在处理你的需求,请稍等1-3分钟。", + ).strip() + self.reply_style = os.getenv("REPLY_STYLE", "action_card").strip().lower() + self.action_card_title = os.getenv("ACTION_CARD_TITLE", "OpenClaw 助理").strip() + self.action_card_tips = os.getenv("ACTION_CARD_TIPS", "").strip() + self.action_card_logo = os.getenv("ACTION_CARD_LOGO", "").strip() + self.action_card_btn_text = os.getenv("ACTION_CARD_BTN_TEXT", "").strip() + self.action_card_btn_url = os.getenv("ACTION_CARD_BTN_URL", "").strip() + self.smalltalk_reply_text = os.getenv( + "SMALLTALK_REPLY_TEXT", + "你好,我在。请直接告诉我你的任务目标(例如:查询某店上周售后原因TOP5并给出改进建议)。", + ).strip() + self.max_parallel_jobs = max(1, min(int(os.getenv("BOT_MAX_PARALLEL_JOBS", "4")), 16)) + self._work_semaphore = asyncio.Semaphore(self.max_parallel_jobs) + self._conversation_locks = {} + self.card_disable_seconds = max(300, min(int(os.getenv("CARD_DISABLE_SECONDS", "43200")), 86400)) + self.card_disabled_until = 0.0 + self.install_media_stitch_enabled = bool_env("INSTALL_MEDIA_STITCH_ENABLED", True) + self.install_media_stitch_min_images = max(2, min(int(os.getenv("INSTALL_MEDIA_STITCH_MIN_IMAGES", "2")), 10)) + self.install_media_stitch_max_images = max( + self.install_media_stitch_min_images, + min(int(os.getenv("INSTALL_MEDIA_STITCH_MAX_IMAGES", "20")), 50), + ) + self.install_media_fetch_timeout = max(3, min(int(os.getenv("INSTALL_MEDIA_FETCH_TIMEOUT", "12")), 60)) + self.install_media_max_width = max(720, min(int(os.getenv("INSTALL_MEDIA_MAX_WIDTH", "1600")), 4000)) + self.s3_endpoint = ( + os.getenv("INSTALL_MEDIA_S3_ENDPOINT", "").strip() + or os.getenv("S3_ENDPOINT", "").strip() + ) + self.s3_bucket = ( + os.getenv("INSTALL_MEDIA_S3_BUCKET", "").strip() + or os.getenv("S3_BUCKET", "").strip() + ) + self.s3_region = ( + os.getenv("INSTALL_MEDIA_S3_REGION", "").strip() + or os.getenv("S3_REGION", "us-east-1").strip() + ) + self.s3_access_key = ( + os.getenv("INSTALL_MEDIA_S3_ACCESS_KEY_ID", "").strip() + or os.getenv("S3_ACCESS_KEY_ID", "").strip() + ) + self.s3_secret_key = ( + os.getenv("INSTALL_MEDIA_S3_SECRET_ACCESS_KEY", "").strip() + or os.getenv("S3_SECRET_ACCESS_KEY", "").strip() + ) + self.s3_public_domain = ( + os.getenv("INSTALL_MEDIA_S3_PUBLIC_DOMAIN", "").strip() + or os.getenv("S3_PUBLIC_DOMAIN", "").strip() + ) + self.s3_key_prefix = ( + os.getenv("INSTALL_MEDIA_S3_FILE_PATH", "").strip() + or os.getenv("S3_FILE_PATH", "").strip() + ) + self.s3_acl = ( + os.getenv("INSTALL_MEDIA_S3_ACL", "").strip() + or os.getenv("S3_ACL", "public-read").strip() + ) + self.s3_path_style = bool_env("INSTALL_MEDIA_S3_PATH_STYLE", bool_env("S3_PATH_STYLE", True)) + if self.s3_key_prefix and not self.s3_key_prefix.endswith("/"): + self.s3_key_prefix += "/" + self.image_analysis_enabled = bool_env("IMAGE_ANALYSIS_ENABLED", True) + self.image_analysis_model = os.getenv("IMAGE_ANALYSIS_MODEL", "qwen2.5vl:3b").strip() or "qwen2.5vl:3b" + self.image_analysis_timeout = max(10, min(int(os.getenv("IMAGE_ANALYSIS_TIMEOUT", "90")), 300)) + self.image_max_images = max(1, min(int(os.getenv("IMAGE_MAX_IMAGES", "3")), 8)) + self.image_download_timeout = max(3, min(int(os.getenv("IMAGE_DOWNLOAD_TIMEOUT", "20")), 60)) + self.image_max_bytes = max(262144, min(int(os.getenv("IMAGE_MAX_BYTES", "10485760")), 31457280)) + self.image_upload_to_s3 = bool_env("IMAGE_UPLOAD_TO_S3", True) + self.image_vision_output_max_chars = max(300, min(int(os.getenv("IMAGE_VISION_OUTPUT_MAX_CHARS", "2200")), 6000)) + self.video_analysis_enabled = bool_env("VIDEO_ANALYSIS_ENABLED", True) + self.video_analysis_model = os.getenv("VIDEO_ANALYSIS_MODEL", "qwen3.5-plus").strip() or "qwen3.5-plus" + self.video_analysis_base_url = ( + os.getenv("VIDEO_ANALYSIS_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions").strip() + or "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions" + ) + self.video_analysis_timeout = max(15, min(int(os.getenv("VIDEO_ANALYSIS_TIMEOUT", "360")), 900)) + self.video_analysis_max_tokens = max(256, min(int(os.getenv("VIDEO_ANALYSIS_MAX_TOKENS", "1800")), 8192)) + self.video_analysis_max_urls = max(1, min(int(os.getenv("VIDEO_ANALYSIS_MAX_URLS", "2")), 3)) + + def _should_handle(self, msg: ChatbotMessage, text: str) -> bool: + if text.startswith(self.prefix): + return True + if msg.is_in_at_list: + return True + return not self.require_prefix + + def _normalize_prompt(self, text: str) -> str: + text = text.strip() + if self.prefix and text.startswith(self.prefix): + text = text[len(self.prefix):].strip() + text = re.sub(r"^@\S+\s*", "", text).strip() + return text + + def _is_smalltalk_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"[\s,,。.!!??~~]+", "", (prompt or "").strip().lower()) + if not normalized: + return False + if normalized in SMALLTALK_SET: + return True + if normalized.startswith(("你好", "您好", "hi", "hello")) and len(normalized) <= 6: + return True + return False + + def _safe_session_component(self, raw: str) -> str: + original = (raw or "default").strip().replace(" ", "_") + safe = re.sub(r"[^A-Za-z0-9_-]+", "-", original) + safe = re.sub(r"-{2,}", "-", safe).strip("-") + if not safe: + safe = "default" + if safe != original: + digest = hashlib.sha1(original.encode("utf-8")).hexdigest()[:8] + safe = f"{safe}-{digest}" + return safe[:96] + + def _session_id(self, msg: ChatbotMessage) -> str: + raw_conversation_id = str(msg.conversation_id or "default") + safe_conversation_id = self._safe_session_component(raw_conversation_id) + base_session = f"{self.session_prefix}-{safe_conversation_id}" + + session_file = os.path.join(self.sessions_dir, f"{base_session}.jsonl") + if self.session_max_lines > 0 and os.path.exists(session_file): + try: + with open(session_file, "rb") as fh: + line_count = sum(1 for _ in fh) + if line_count >= self.session_max_lines: + rotate_suffix = time.strftime("%Y%m%d%H%M") + rotated = f"{base_session}-{rotate_suffix}" + self.logger.info( + "session auto-rotated: base=%s lines=%s rotated=%s", + base_session, + line_count, + rotated, + ) + return rotated + except Exception: + self.logger.exception("failed to inspect session file: %s", session_file) + + return base_session + + def _event_key(self, callback: CallbackMessage, msg: ChatbotMessage, prompt: str) -> str: + if getattr(msg, "message_id", None): + return f"msg:{msg.message_id}" + + data = callback.data if isinstance(callback.data, dict) else {} + for key in ("msgId", "messageId", "eventId", "processQueryKey"): + value = data.get(key) + if value: + return f"data:{key}:{value}" + + headers = getattr(callback, "headers", {}) or {} + for key in ("messageId", "eventId", "processQueryKey"): + value = headers.get(key) + if value: + return f"hdr:{key}:{value}" + + raw = "|".join( + [ + str(getattr(msg, "conversation_id", "") or ""), + str(getattr(msg, "sender_id", "") or ""), + str(getattr(msg, "sender_staff_id", "") or ""), + str(getattr(msg, "create_at", "") or ""), + prompt[:200], + ] + ) + return "fp:" + hashlib.sha1(raw.encode("utf-8")).hexdigest() + + def _seen_or_mark_event(self, key: str) -> bool: + now = time.time() + expire_before = now - max(self.dedupe_ttl_seconds, 30) + + stale_keys = [k for k, ts in self._recent_event_ts.items() if ts < expire_before] + for stale_key in stale_keys: + self._recent_event_ts.pop(stale_key, None) + + if key in self._recent_event_ts: + return True + + self._recent_event_ts[key] = now + return False + + def _is_market_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt) + + if any(k in normalized for k in SALES_HINT_KEYWORDS): + return False + + if "电脑桌" in normalized: + return True + + if any(k in normalized for k in MARKET_HINT_KEYWORDS): + return True + + if "市场" in normalized and any( + k in normalized for k in ("搜索", "飙升词", "热词", "需求供给比", "价格带", "客单价", "洞察", "趋势") + ): + return True + + if "市场" in normalized and "分析" in normalized: + return True + + return False + + def _is_install_media_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt) + if not normalized: + return False + if any(k in normalized for k in INSTALL_MEDIA_HINT_KEYWORDS): + return True + if "安装" in normalized and ("图纸" in normalized or "视频" in normalized): + return True + return False + + def _is_install_media_stitch_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt) + if not normalized: + return False + return any(k in normalized for k in INSTALL_MEDIA_STITCH_HINT_KEYWORDS) + + def _is_video_url(self, raw_url: str) -> bool: + try: + parsed = urllib.parse.urlparse(raw_url) + except Exception: + return False + + if parsed.scheme not in {"http", "https"}: + return False + + host = (parsed.hostname or "").lower() + target = f"{parsed.path or ''}?{parsed.query or ''}" + if VIDEO_EXT_RE.search(target): + return True + + video_hosts = ( + "douyin.com", + "iesdouyin.com", + "bilibili.com", + "youku.com", + "qq.com", + "ixigua.com", + "kuaishou.com", + "mgtv.com", + "1905.com", + ) + return any(host == h or host.endswith(f".{h}") for h in video_hosts) + + def _extract_video_urls(self, text: str, limit: int = 2) -> list: + urls = [] + seen = set() + for match in URL_RE.finditer(text or ""): + raw = match.group(0).rstrip(".,;!?") + if not self._is_video_url(raw): + continue + if raw in seen: + continue + seen.add(raw) + urls.append(raw) + if len(urls) >= limit: + break + return urls + + def _is_video_analysis_prompt(self, prompt: str) -> bool: + if self._is_install_media_prompt(prompt): + return False + + normalized = re.sub(r"\s+", "", (prompt or "")).lower() + if not normalized: + return False + + video_urls = self._extract_video_urls(prompt, limit=1) + if not video_urls: + return False + + if any(k in normalized for k in VIDEO_ANALYSIS_HINT_KEYWORDS): + return True + if any(k in normalized for k in VIDEO_ANALYSIS_ACTION_KEYWORDS): + return True + return len(normalized) <= 120 + + def _needs_video_script(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", (prompt or "")).lower() + if not normalized: + return False + return any(k in normalized for k in VIDEO_SCRIPT_HINT_KEYWORDS) + + def _is_dalongxia_analytics_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", (prompt or "")).lower() + if not normalized: + return False + if any(k in normalized for k in DALONGXIA_ANALYTICS_HINT_KEYWORDS): + return True + if "任务进度" in normalized and ("总览" in normalized or "overview" in normalized): + return True + if "设计师绩效" in normalized or "流程效率" in normalized: + return True + if "primary" in normalized and ("dept" in normalized or "task" in normalized): + return True + return False + + def _parse_analytics_mode(self, prompt: str) -> str: + normalized = re.sub(r"\s+", "", (prompt or "")).lower() + compact = re.sub(r"[^a-z0-9\u4e00-\u9fa5]+", "", normalized) + + mode_aliases = ( + ("primarytaskbranddistribution", "primary_task_brand_distribution"), + ("primarytaskflowefficiency", "primary_task_flow_efficiency"), + ("primarydeptstatistics", "primary_dept_statistics"), + ("primarydeptuserranking", "primary_dept_user_ranking"), + ("primarydeptranking", "primary_dept_ranking"), + ("primarytask", "primary_task_flow_efficiency"), + ("primarydept", "primary_dept_ranking"), + ("designerdeductiondetails", "designer_deduction"), + ("designerscoredetails", "designer_score"), + ("designerdeduction", "designer_deduction"), + ("designerscore", "designer_score"), + ("taskprogressoverview", "task_progress_overview"), + ("taskprogress", "task_progress_overview"), + ("progress", "task_progress_overview"), + ("demandstatistics", "demand_statistics"), + ("designerperformance", "designer_performance"), + ("processefficiency", "process_efficiency"), + ("dimensionanalysis", "dimension_analysis"), + ("overview", "overview"), + ("warnings", "warnings"), + ) + for token, mode in mode_aliases: + if token in compact: + return mode + + if "主部门" in normalized and "人员" in normalized and "排名" in normalized: + return "primary_dept_user_ranking" + if "主部门" in normalized and "品牌" in normalized and "分布" in normalized: + return "primary_task_brand_distribution" + if "主部门" in normalized and "任务流程" in normalized and "效率" in normalized: + return "primary_task_flow_efficiency" + if "主部门" in normalized and "统计" in normalized: + return "primary_dept_statistics" + if "主部门" in normalized and "排名" in normalized: + return "primary_dept_ranking" + if "设计师" in normalized and ("扣分明细" in normalized or "扣分详情" in normalized): + return "designer_deduction" + if "设计师" in normalized and ("评分明细" in normalized or "得分明细" in normalized): + return "designer_score" + if "维度" in normalized and "分析" in normalized: + return "dimension_analysis" + if "流程效率" in normalized: + return "process_efficiency" + if "设计师绩效" in normalized: + return "designer_performance" + if "需求统计" in normalized: + return "demand_statistics" + if "预警" in normalized: + return "warnings" + if "任务进度" in normalized and ("总览" in normalized or "overview" in normalized): + return "task_progress_overview" + if "数据分析" in normalized and "总览" in normalized: + return "overview" + return "overview" + + def _parse_analytics_date_range(self, prompt: str) -> str: + normalized = re.sub(r"\s+", "", (prompt or "")).lower() + if any(k in normalized for k in ("all", "全部", "全量", "所有时间")): + return "all" + if any(k in normalized for k in ("quarter", "季度", "q1", "q2", "q3", "q4")): + return "quarter" + if any(k in normalized for k in ("week", "本周", "上周", "近7天", "最近7天", "7天")): + return "week" + return "month" + + def _parse_analytics_month(self, prompt: str) -> str: + text = prompt or "" + m = re.search(r"(20\d{2})[年/-](\d{1,2})", text) + if not m: + m = re.search(r"(20\d{2})(\d{2})", text) + if m: + year = int(m.group(1)) + month = int(m.group(2)) + if 1 <= month <= 12: + return f"{year:04d}-{month:02d}" + now = time.localtime() + return f"{now.tm_year:04d}-{now.tm_mon:02d}" + + def _parse_analytics_ids(self, prompt: str) -> tuple: + text = prompt or "" + dept_id = "" + user_id = "" + + m = re.search(r"(?:部门ID|deptId|dept_id)[::\s]*([0-9]{1,20})", text, re.IGNORECASE) + if m: + dept_id = m.group(1) + m = re.search(r"(?:用户ID|设计师ID|成员ID|userId|user_id)[::\s]*([0-9]{1,20})", text, re.IGNORECASE) + if m: + user_id = m.group(1) + return dept_id, user_id + + def _normalize_analytics_datetime(self, raw: str, end_of_day: bool = False) -> str: + text = (raw or "").strip() + if not text: + return "" + text = text.replace("/", "-").replace(" ", "T") + if re.match(r"^\d{4}-\d{1,2}-\d{1,2}$", text): + suffix = "23:59:59" if end_of_day else "00:00:00" + return f"{text}T{suffix}" + if re.match(r"^\d{4}-\d{1,2}-\d{1,2}T\d{1,2}$", text): + return f"{text}:00:00" + if re.match(r"^\d{4}-\d{1,2}-\d{1,2}T\d{1,2}:\d{1,2}$", text): + return f"{text}:00" + if re.match(r"^\d{4}-\d{1,2}-\d{1,2}T\d{1,2}:\d{1,2}:\d{1,2}$", text): + return text + return "" + + def _parse_analytics_time_window(self, prompt: str, month: str) -> tuple: + text = prompt or "" + start_time = "" + end_time = "" + + range_match = re.search( + r"(20\d{2}[/-]\d{1,2}[/-]\d{1,2})\s*(?:到|至|~|-)\s*(20\d{2}[/-]\d{1,2}[/-]\d{1,2})", + text, + ) + if range_match: + start_time = self._normalize_analytics_datetime(range_match.group(1), end_of_day=False) + end_time = self._normalize_analytics_datetime(range_match.group(2), end_of_day=True) + + if not start_time: + m = re.search(r"(?:startTime|start_time|开始时间)[::=\s]*([0-9/:\- T]{8,25})", text, re.IGNORECASE) + if m: + start_time = self._normalize_analytics_datetime(m.group(1), end_of_day=False) + if not end_time: + m = re.search(r"(?:endTime|end_time|结束时间)[::=\s]*([0-9/:\- T]{8,25})", text, re.IGNORECASE) + if m: + end_time = self._normalize_analytics_datetime(m.group(1), end_of_day=True) + + if start_time and end_time: + return start_time, end_time + + month_match = re.match(r"^(20\d{2})-(\d{2})$", month or "") + if month_match: + year = int(month_match.group(1)) + mon = int(month_match.group(2)) + first = dt.datetime(year, mon, 1, 0, 0, 0) + if mon == 12: + next_first = dt.datetime(year + 1, 1, 1, 0, 0, 0) + else: + next_first = dt.datetime(year, mon + 1, 1, 0, 0, 0) + last = next_first - dt.timedelta(seconds=1) + return first.strftime("%Y-%m-%dT%H:%M:%S"), last.strftime("%Y-%m-%dT%H:%M:%S") + + now = dt.datetime.now() + first = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + last = now.replace(hour=23, minute=59, second=59, microsecond=0) + return first.strftime("%Y-%m-%dT%H:%M:%S"), last.strftime("%Y-%m-%dT%H:%M:%S") + + def _parse_include_task_progress(self, prompt: str) -> bool: + return bool( + re.search( + r"(含|包含|带上|带|include)\s*任务进度|includeTaskProgress\s*[:=]\s*true", + prompt or "", + re.IGNORECASE, + ) + ) + + def _build_dalongxia_analytics_request(self, mode: str, app_key: str, app_secret: str, params: dict) -> tuple: + timestamp = str(int(time.time() * 1000)) + date_range = params.get("date_range", "month") + month = params.get("month", "") + dept_id = str(params.get("dept_id", "") or "") + user_id = str(params.get("user_id", "") or "") + include_task_progress = bool(params.get("include_task_progress", False)) + include_str = str(include_task_progress).lower() + start_time = params.get("start_time", "") + end_time = params.get("end_time", "") + + if mode == "task_progress_overview": + action = "analytics_task_progress_overview" + payload = "|".join([action, app_key, date_range, month, dept_id, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "dateRange": date_range, + "month": month, + } + if dept_id: + body["deptId"] = dept_id + return "/api/open/analytics/task-progress/overview", "壹点需求系统:任务进度总览", body + + if mode == "demand_statistics": + action = "analytics_demand_statistics" + payload = "|".join([action, app_key, date_range, month, dept_id, user_id, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "dateRange": date_range, + "month": month, + } + if dept_id: + body["deptId"] = dept_id + if user_id: + body["userId"] = user_id + return "/api/open/analytics/demand-statistics", "壹点需求系统:需求统计", body + + if mode == "designer_performance": + action = "analytics_designer_performance" + payload = "|".join([action, app_key, month, dept_id, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "month": month, + } + if dept_id: + body["deptId"] = dept_id + return "/api/open/analytics/designer-performance", "壹点需求系统:设计师绩效", body + + if mode == "process_efficiency": + action = "analytics_process_efficiency" + payload = "|".join([action, app_key, month, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "month": month, + } + return "/api/open/analytics/process-efficiency", "壹点需求系统:流程效率", body + + if mode == "warnings": + action = "analytics_warnings" + payload = "|".join([action, app_key, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + } + return "/api/open/analytics/warnings", "壹点需求系统:预警信息", body + + if mode == "dimension_analysis": + action = "analytics_dimension_analysis" + payload = "|".join([action, app_key, date_range, month, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "dateRange": date_range, + "month": month, + } + return "/api/open/analytics/dimension-analysis", "壹点需求系统:维度分析", body + + if mode == "designer_deduction": + action = "analytics_designer_deduction_details" + detail_user_id = user_id or os.getenv("ANALYTICS_DEFAULT_USER_ID", "38").strip() + payload = "|".join([action, app_key, str(detail_user_id), month, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "userId": str(detail_user_id), + "month": month, + } + return "/api/open/analytics/designer-deduction-details", "壹点需求系统:设计师扣分明细", body + + if mode == "designer_score": + action = "analytics_designer_score_details" + detail_user_id = user_id or os.getenv("ANALYTICS_DEFAULT_USER_ID", "38").strip() + payload = "|".join([action, app_key, str(detail_user_id), month, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "userId": str(detail_user_id), + "month": month, + } + return "/api/open/analytics/designer-score-details", "壹点需求系统:设计师得分明细", body + + if mode == "primary_dept_statistics": + action = "analytics_primary_dept_statistics" + payload = "|".join([action, app_key, start_time, end_time, dept_id, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "startTime": start_time, + "endTime": end_time, + } + if dept_id: + body["deptId"] = dept_id + return "/api/open/analytics/primary-dept/statistics", "壹点需求系统:主部门统计", body + + if mode == "primary_dept_ranking": + action = "analytics_primary_dept_ranking" + payload = "|".join([action, app_key, start_time, end_time, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "startTime": start_time, + "endTime": end_time, + } + return "/api/open/analytics/primary-dept/ranking", "壹点需求系统:主部门排名", body + + if mode == "primary_task_flow_efficiency": + action = "analytics_primary_task_flow_efficiency" + payload = "|".join([action, app_key, start_time, end_time, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "startTime": start_time, + "endTime": end_time, + } + return "/api/open/analytics/primary-task/flow-efficiency", "壹点需求系统:主部门任务流转效率", body + + if mode == "primary_task_brand_distribution": + action = "analytics_primary_task_brand_distribution" + payload = "|".join([action, app_key, start_time, end_time, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "startTime": start_time, + "endTime": end_time, + } + return "/api/open/analytics/primary-task/brand-distribution", "壹点需求系统:主部门任务品牌分布", body + + if mode == "primary_dept_user_ranking": + action = "analytics_primary_dept_user_ranking" + payload = "|".join([action, app_key, start_time, end_time, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "startTime": start_time, + "endTime": end_time, + } + return "/api/open/analytics/primary-dept/user-ranking", "壹点需求系统:主部门成员排名", body + + action = "analytics_overview" + payload = "|".join([action, app_key, date_range, month, dept_id, user_id, include_str, timestamp]) + body = { + "appKey": app_key, + "timestamp": timestamp, + "sign": self._build_open_api_sign(app_secret, payload), + "dateRange": date_range, + "month": month, + "includeTaskProgress": include_task_progress, + } + if dept_id: + body["deptId"] = dept_id + if user_id: + body["userId"] = user_id + return "/api/open/analytics/overview", "壹点需求系统:数据分析总览", body + + def _format_dalongxia_analytics_result(self, mode: str, title: str, body: dict, data: dict) -> str: + def _f(v): + if v is None: + return "-" + if isinstance(v, float): + s = f"{v:.2f}" + return s.rstrip("0").rstrip(".") + return str(v) + + def _is_num(v): + return isinstance(v, (int, float)) + + def _sum_num(items, key): + total = 0 + found = False + for it in items: + v = it.get(key) if isinstance(it, dict) else None + if _is_num(v): + total += v + found = True + return total if found else None + + query_params = [] + for key in ("dateRange", "month", "deptId", "userId", "includeTaskProgress", "startTime", "endTime"): + if key in body and body[key] not in ("", None): + query_params.append(f"{key}={body[key]}") + + lines = [title] + if query_params: + lines.append(f"- 查询参数: {', '.join(query_params)}") + if isinstance(data, dict): + if data.get("time"): + lines.append(f"- 统计时间: {data.get('time')}") + if data.get("traceId"): + lines.append(f"- traceId: {data.get('traceId')}") + + root = (data or {}).get("data") if isinstance(data, dict) else None + if root is None: + root = data + + if mode == "task_progress_overview" and isinstance(root, dict): + metrics = root.get("metrics") or {} + status = root.get("statusDistribution") or {} + trend = (root.get("trend") or [])[:5] + lines.append( + "- 核心指标: " + f"总任务={_f(metrics.get('totalTasks'))},完成={_f(metrics.get('completedTasks'))}," + f"处理中={_f(metrics.get('inProgressTasks'))},逾期={_f(metrics.get('overdueTasks'))}," + f"完成率={_f(metrics.get('completionRate'))}%" + ) + if status: + parts = [f"{k}={_f(v)}" for k, v in status.items() if _is_num(v)] + if parts: + lines.append(f"- 状态分布: {','.join(parts[:8])}") + if trend: + lines.append("- 趋势(最近5个点):") + for t in trend: + lines.append( + f" - {_f(t.get('date'))}: 新建={_f(t.get('createdCount'))},完成={_f(t.get('completedCount'))}" + ) + return "\n".join(lines) + + if mode == "designer_performance" and isinstance(root, dict): + items = root.get("designerStats") or [] + if not isinstance(items, list): + items = [] + items = sorted( + [it for it in items if isinstance(it, dict)], + key=lambda x: (x.get("completedCount") or 0, x.get("receivedCount") or 0), + reverse=True, + ) + total_received = _sum_num(items, "receivedCount") + total_completed = _sum_num(items, "completedCount") + lines.append( + "- 汇总: " + f"设计师数={len(items)},接收任务={_f(total_received)},完成任务={_f(total_completed)}" + ) + lines.append("- TOP设计师(按完成量):") + for idx, it in enumerate(items[:10], 1): + lines.append( + f" {idx}. {_f(it.get('realName') or it.get('userName'))}|{_f(it.get('deptName'))}|" + f"完成={_f(it.get('completedCount'))}|准时率={_f(it.get('onTimeRate'))}%|完成率={_f(it.get('completionRate'))}%" + ) + return "\n".join(lines) + + if mode == "dimension_analysis" and isinstance(root, dict): + items = root.get("categoryAnalysis") or [] + if not isinstance(items, list): + items = [] + items = sorted( + [it for it in items if isinstance(it, dict)], + key=lambda x: (x.get("totalTasks") or 0), + reverse=True, + ) + lines.append(f"- 类目数: {len(items)}") + lines.append("- TOP类目(按任务量):") + for idx, it in enumerate(items[:10], 1): + lines.append( + f" {idx}. {_f(it.get('categoryName'))}|任务={_f(it.get('totalTasks'))}|" + f"完成={_f(it.get('completedTasks'))}|进行中={_f(it.get('inProgressTasks'))}|完成率={_f(it.get('completionRate'))}%" + ) + return "\n".join(lines) + + if mode == "overview" and isinstance(root, dict): + module_keys = list(root.keys()) + lines.append(f"- 模块: {', '.join(module_keys[:8]) if module_keys else '-'}") + demand = root.get("demandStatistics") or {} + user_stats = demand.get("userStats") if isinstance(demand, dict) else [] + if isinstance(user_stats, list) and user_stats: + lines.append(f"- 需求统计: 用户数={len(user_stats)}") + top = sorted( + [it for it in user_stats if isinstance(it, dict)], + key=lambda x: (x.get("totalDemands") or 0), + reverse=True, + )[:5] + lines.append("- 需求量TOP5:") + for idx, it in enumerate(top, 1): + lines.append( + f" {idx}. {_f(it.get('realName') or it.get('userName'))}|{_f(it.get('deptName'))}|" + f"总需求={_f(it.get('totalDemands'))}|完成={_f(it.get('completedDemands'))}" + ) + task_progress = root.get("taskProgressOverview") or root.get("taskProgress") or {} + if isinstance(task_progress, dict) and task_progress.get("metrics"): + m = task_progress.get("metrics") or {} + lines.append( + "- 任务进度: " + f"总任务={_f(m.get('totalTasks'))},完成={_f(m.get('completedTasks'))},逾期={_f(m.get('overdueTasks'))}" + ) + return "\n".join(lines) + + if mode in { + "primary_dept_ranking", + "primary_dept_statistics", + "primary_task_flow_efficiency", + "primary_task_brand_distribution", + "primary_dept_user_ranking", + }: + items = root if isinstance(root, list) else [] + if not items and isinstance(root, dict): + for key in ("items", "list", "rows", "data"): + if isinstance(root.get(key), list): + items = root.get(key) + break + items = [it for it in items if isinstance(it, dict)] + lines.append(f"- 条数: {len(items)}") + lines.append("- TOP10:") + for idx, it in enumerate(items[:10], 1): + name = it.get("deptName") or it.get("userName") or it.get("brandName") or it.get("name") + task_count = it.get("taskCount") or it.get("totalTasks") or it.get("count") + rate = it.get("approvalRate") or it.get("completionRate") or it.get("onTimeRate") + extra = it.get("avgResponseHours") or it.get("avgCycleHours") + part = f" {idx}. {_f(name)}|任务={_f(task_count)}" + if rate is not None: + part += f"|比率={_f(rate)}%" + if extra is not None: + part += f"|时长={_f(extra)}h" + lines.append(part) + return "\n".join(lines) + + if mode == "warnings": + items = root if isinstance(root, list) else [] + if isinstance(root, dict): + for key in ("warnings", "items", "list", "rows"): + if isinstance(root.get(key), list): + items = root.get(key) + break + if isinstance(items, list): + lines.append(f"- 预警条数: {len(items)}") + for idx, it in enumerate(items[:10], 1): + if isinstance(it, dict): + msg = it.get("message") or it.get("warnMsg") or it.get("content") or it.get("title") + lines.append(f" {idx}. {_f(msg)}") + else: + lines.append(f" {idx}. {_f(it)}") + return "\n".join(lines) + + if mode in {"designer_deduction", "designer_score"}: + items = root if isinstance(root, list) else [] + if isinstance(root, dict): + for key in ("details", "items", "list", "rows", "data"): + if isinstance(root.get(key), list): + items = root.get(key) + break + items = [it for it in items if isinstance(it, dict)] + lines.append(f"- 记录数: {len(items)}") + lines.append("- 前10条:") + for idx, it in enumerate(items[:10], 1): + lines.append( + f" {idx}. 时间={_f(it.get('time') or it.get('createTime') or it.get('date'))}|" + f"分值={_f(it.get('score') or it.get('deductionScore') or it.get('value'))}|" + f"原因={_f(it.get('reason') or it.get('remark') or it.get('content'))}" + ) + return "\n".join(lines) + + # fallback + fallback = json.dumps(data, ensure_ascii=False, indent=2) + if len(fallback) > 1800: + fallback = fallback[:1800] + "\n...(已截断)" + lines.append("- 结果预览:") + lines.append(fallback) + return "\n".join(lines) + + def _build_open_api_sign(self, secret: str, payload: str) -> str: + return hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest() + + def _post_open_api_json(self, base_url: str, path: str, body: dict) -> dict: + url = f"{base_url.rstrip('/')}{path}" + timeout = max(10, min(int(os.getenv("OPEN_API_TIMEOUT", "60")), 300)) + last_err = None + + def _parse_response(resp): + content_type = (resp.headers.get("Content-Type") or "").lower() + if "json" not in content_type: + text = (resp.text or "").strip() + raise RuntimeError(f"HTTP {resp.status_code} 非JSON响应: {text[:300]}") + data = resp.json() + if not resp.ok: + code = data.get("code", "unknown") + msg = data.get("message", data) + raise RuntimeError(f"HTTP {resp.status_code} / code={code} / msg={msg}") + if data.get("code") != 200: + raise RuntimeError(f"code={data.get('code')} / msg={data.get('message')}") + return data.get("data") + + try: + resp = requests.post(url, json=body, timeout=timeout) + return _parse_response(resp) + except Exception as exc: + last_err = exc + self.logger.warning("open api request attempt1 failed: %s", exc) + + try: + with requests.Session() as session: + session.trust_env = False + resp = session.post(url, json=body, timeout=timeout) + return _parse_response(resp) + except Exception as exc: + last_err = exc + self.logger.warning("open api request attempt2 failed: %s", exc) + + raise last_err or RuntimeError("open api request failed") + + def _run_dalongxia_analytics(self, prompt: str) -> str: + base_url = ( + os.getenv("OPEN_API_BASE_URL", "").strip() + or os.getenv("OPEN_API_BASE", "").strip() + or "https://qd.yydd.ai" + ) + app_key = os.getenv("OPEN_API_APP_KEY", "").strip() + app_secret = os.getenv("OPEN_API_APP_SECRET", "").strip() + if not app_key or not app_secret: + return "壹点需求系统查询失败:缺少 OPEN_API_APP_KEY / OPEN_API_APP_SECRET。" + + mode = self._parse_analytics_mode(prompt) + date_range = self._parse_analytics_date_range(prompt) + month = self._parse_analytics_month(prompt) + dept_id, user_id = self._parse_analytics_ids(prompt) + include_task_progress = self._parse_include_task_progress(prompt) + start_time, end_time = self._parse_analytics_time_window(prompt, month) + try: + path, title, body = self._build_dalongxia_analytics_request( + mode=mode, + app_key=app_key, + app_secret=app_secret, + params={ + "date_range": date_range, + "month": month, + "dept_id": dept_id, + "user_id": user_id, + "include_task_progress": include_task_progress, + "start_time": start_time, + "end_time": end_time, + }, + ) + data = self._post_open_api_json(base_url, path, body) + wrapped = data if isinstance(data, dict) and ("data" in data or "traceId" in data or "time" in data) else {"data": data} + return self._format_dalongxia_analytics_result(mode=mode, title=title, body=body, data=wrapped) + except Exception as exc: + return f"壹点需求系统查询失败: {exc}" + + def _is_sales_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt).lower() + return any(k.lower() in normalized for k in SENSITIVE_SALES_KEYWORDS) + + def _explicit_amount_requested(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt).lower() + return any(k.lower() in normalized for k in EXPLICIT_AMOUNT_KEYWORDS) + + def _mask_sensitive_amounts(self, prompt: str, answer: str) -> str: + if not self._is_sales_prompt(prompt): + return answer + if self._explicit_amount_requested(prompt): + return answer + + filtered_lines = [] + removed = 0 + for line in answer.splitlines(): + if re.search(r"(金额|退款率|费比|收入|利润|成本|客单价|GMV|毛利|\d[\d,\.]*\s*(元|万元|万|亿))", line, re.IGNORECASE): + removed += 1 + continue + filtered_lines.append(line) + + cleaned = "\n".join(filtered_lines).strip() + if removed > 0: + note = "(已按配置自动隐藏金额类敏感指标,仅保留件数/订单维度。)" + if cleaned: + cleaned = f"{cleaned}\n\n{note}" + else: + cleaned = note + + return cleaned or "已按配置隐藏金额类敏感指标,请改问件数/订单/原因结构维度。" + + def _extract_market_subject(self, prompt: str) -> str: + normalized = prompt.strip() + + def clean_subject(raw: str) -> str: + v = raw.strip().replace("\u201c", "").replace("\u201d", "").strip("\"'") + v = re.sub(r"\s+", "", v) + v = re.sub(r"^(帮我|请|麻烦|给我|我想|想要|想看|想做|分析|查看|查询|关于|整理一份|整理|输出|生成)+", "", v) + v = re.sub(r"^(近(?:[一二三四五六七八九十两半0-9]+)(?:天|周|个月|月|年)|过去(?:[一二三四五六七八九十两半0-9]+)(?:天|周|个月|月|年)|近30天|近7天|近15天|近一个月|近三个月|近半年|近一年)+", "", v) + v = re.sub(r"(的|情况|数据|分析)+$", "", v) + return v.strip() + + def pick(raw: str): + v = clean_subject(raw) + if len(v) < 2: + return None + if v in {"市场", "行业", "类目", "竞品", "趋势", "市场数据", "关键词", "搜索"}: + return None + return v + + if "电脑桌" in normalized: + return "电脑桌" + + m = re.search(r"[\"\u201c\u201d']([^\"\u201c\u201d']{2,30})[\"\u201c\u201d']", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search( + r"关于([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据|市场情况)", + normalized, + ) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search( + r"([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据)", + normalized, + ) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search(r"([\u4e00-\u9fa5A-Za-z0-9]{2,20})类目", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search(r"([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:市场|市场搜索|关键词|飙升词|热词)", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search(r"分析(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search(r"查(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + return "市场主题" + + def _extract_order_id(self, prompt: str) -> str: + text = prompt.strip() + for pattern in ( + r"订单号[::\s]*([A-Za-z0-9-]{8,40})", + r"订单编号[::\s]*([A-Za-z0-9-]{8,40})", + r"订单[::\s]*([A-Za-z0-9-]{8,40})", + ): + m = re.search(pattern, text, re.IGNORECASE) + if m: + return m.group(1).strip() + + m2 = ORDER_ID_RE.search(text) + if m2: + return m2.group(0).strip() + return "" + + def _extract_product_no(self, prompt: str) -> str: + text = prompt.strip() + for pattern in ( + r"(?:产品货号|货号|商品货号|SKU编码|SKUID|sku编码|skuid|sku)[::\s]*([A-Za-z0-9_-]{5,40})", + ): + m = re.search(pattern, text, re.IGNORECASE) + if m: + return m.group(1).strip() + m2 = re.search(r"\b[A-Za-z]{1,4}\d{4,}[A-Za-z0-9_-]{2,}\b", text) + if m2: + return m2.group(0).strip() + return "" + + def _is_code_prompt(self, prompt: str) -> bool: + normalized = (prompt or "").lower() + hints = ( + "代码", + "sql", + "python", + "java", + "javascript", + "脚本", + "bash", + "shell", + "命令", + "函数", + "class", + "yaml", + "json", + "正则", + ) + return any(h in normalized for h in hints) + + def _clean_dialog_noise(self, answer: str) -> str: + if not answer: + return answer + + cleaned = [] + for raw in answer.splitlines(): + line = raw.strip() + lower = line.lower() + if not line: + cleaned.append(raw) + continue + + if line.startswith("[plugins]"): + continue + if "registered" in lower and "tool" in lower: + continue + if "no toolbeencalled" in lower: + continue + if "without a preceding text-to-speech" in lower: + continue + if line.startswith("原始输出:"): + continue + if line.startswith("{") and "\"runId\"" in line: + continue + + cleaned.append(raw) + + out = "\n".join(cleaned).strip() + return out or answer + + def _trim_noncode_blocks(self, prompt: str, answer: str) -> str: + if not answer or self._is_code_prompt(prompt): + return answer + + pattern = re.compile(r"```[\s\S]*?```", re.MULTILINE) + + def _repl(match): + block = match.group(0) + lines = [ln for ln in block.splitlines() if ln.strip()] + if len(lines) <= 12: + return block + return "```text\n[代码块已省略,如需完整代码请明确说明]\n```" + + return pattern.sub(_repl, answer) + + def _disable_card_temporarily(self, reason: str) -> None: + self.card_disabled_until = time.time() + self.card_disable_seconds + self.logger.warning("card reply disabled for %ss, reason=%s", self.card_disable_seconds, reason) + + def _extract_incoming_download_codes(self, msg: ChatbotMessage) -> list: + try: + raw_codes = msg.get_image_list() or [] + except Exception: + self.logger.exception("failed to read incoming image list") + return [] + + seen = set() + codes = [] + for raw in raw_codes: + code = str(raw or "").strip() + if not code or code in seen: + continue + seen.add(code) + codes.append(code) + if len(codes) >= self.image_max_images: + break + return codes + + def _guess_image_ext(self, content_type: str, source_url: str) -> str: + ctype = (content_type or "").split(";")[0].strip().lower() + ext = "" + if ctype: + ext = mimetypes.guess_extension(ctype) or "" + if not ext: + path = urllib.parse.urlparse(source_url or "").path + _, path_ext = os.path.splitext(path) + ext = path_ext + ext = (ext or ".jpg").lower() + if ext.startswith("."): + ext = ext[1:] + if ext == "jpeg": + return "jpg" + if ext not in {"jpg", "png", "webp", "gif", "bmp"}: + return "jpg" + return ext + + def _upload_chat_image_bytes(self, image_bytes: bytes, ext: str, conversation_id: str) -> str: + if not self.image_upload_to_s3: + return "" + if boto3 is None or BotoConfig is None: + return "" + if not (self.s3_bucket and self.s3_access_key and self.s3_secret_key): + return "" + + safe_conv = self._safe_session_component(str(conversation_id or "chat")) + stamp = time.strftime("%Y%m%d%H%M%S") + filename = f"{safe_conv}_{stamp}_{uuid.uuid4().hex[:8]}.{ext}" + object_key = f"{self.s3_key_prefix}chat-images/{filename}" + content_type = f"image/{'jpeg' if ext == 'jpg' else ext}" + with tempfile.TemporaryDirectory(prefix="chat_image_") as tmpdir: + local_path = os.path.join(tmpdir, filename) + with open(local_path, "wb") as fh: + fh.write(image_bytes) + self._upload_to_s3(local_path, object_key, content_type) + return self._build_public_s3_url(object_key) + + def _analyze_image_with_local_vlm(self, image_bytes: bytes, user_prompt: str, image_index: int) -> str: + if not self.image_analysis_enabled: + return "" + payload = { + "model": self.image_analysis_model, + "stream": False, + "messages": [ + { + "role": "user", + "content": ( + "你现在只做OCR和画面客观描述,不做任何分析和建议。\n" + "请输出格式:\n" + "OCR_TEXT_BEGIN\n" + "<逐行文字,尽量保留原样,包含中文/英文/数字/LOGO字母>\n" + "OCR_TEXT_END\n" + "SCENE_DESC_BEGIN\n" + "<画面客观描述:人物/物体/场景/颜色/布局;若无法判断写 UNKNOWN_SCENE>\n" + "SCENE_DESC_END\n" + "规则:\n" + "- 只能输出OCR和画面描述,不要解释;\n" + "- 不确定字符用 ? 标记;\n" + "- 完全无文字时输出 NO_TEXT;\n" + "- 画面描述不得给建议、结论或推断。\n" + f"图片序号:{image_index}\n" + f"用户问题:{user_prompt or '请识别并分析图片'}" + ), + "images": [base64.b64encode(image_bytes).decode("ascii")], + } + ], + } + try: + resp = requests.post( + "http://127.0.0.1:11434/api/chat", + json=payload, + timeout=self.image_analysis_timeout, + ) + resp.raise_for_status() + data = resp.json() + text = str((data.get("message") or {}).get("content", "")).strip() + if not text: + return "" + return text[: self.image_vision_output_max_chars] + except Exception as exc: + self.logger.warning("image vlm analysis failed image=%s err=%s", image_index, exc) + return "" + + def _build_image_context_from_dingtalk(self, msg: ChatbotMessage, prompt: str, download_codes: list) -> dict: + blocks = [] + uploaded_urls = [] + warnings = [] + used_images = 0 + + for idx, code in enumerate(download_codes, 1): + try: + download_url = self.get_image_download_url(code) + if not download_url: + warnings.append(f"图片{idx}下载地址获取失败") + continue + + resp = requests.get(download_url, timeout=self.image_download_timeout) + resp.raise_for_status() + image_bytes = resp.content or b"" + if not image_bytes: + warnings.append(f"图片{idx}为空内容") + continue + if len(image_bytes) > self.image_max_bytes: + warnings.append(f"图片{idx}过大,已跳过") + continue + + ext = self._guess_image_ext(resp.headers.get("Content-Type", ""), download_url) + s3_url = "" + try: + s3_url = self._upload_chat_image_bytes(image_bytes, ext, str(getattr(msg, "conversation_id", ""))) + except Exception as exc: + self.logger.warning("upload incoming image failed idx=%s err=%s", idx, exc) + if s3_url: + uploaded_urls.append(s3_url) + + vision = self._analyze_image_with_local_vlm(image_bytes, prompt, idx) + if not vision: + warnings.append(f"图片{idx}识别失败") + continue + + item_lines = [f"图片{idx}感知结果(OCR+场景):"] + item_lines.append(vision) + blocks.append("\n".join(item_lines)) + used_images += 1 + except Exception as exc: + warnings.append(f"图片{idx}处理异常: {exc}") + + result = { + "used_images": used_images, + "context_text": "\n\n".join(blocks).strip(), + "uploaded_urls": uploaded_urls, + "warnings": warnings, + } + self.logger.info( + "image context built: incoming=%s used=%s uploaded=%s warnings=%s", + len(download_codes), + used_images, + len(uploaded_urls), + len(warnings), + ) + return result + + def _merge_prompt_with_image_context(self, prompt: str, image_context: str) -> str: + base = (prompt or "").strip() or "请识别并分析我上传的图片内容。" + return ( + f"{base}\n\n" + "【图片OCR与场景结果(本地模型)】\n" + f"{image_context}\n\n" + "请仅基于以上OCR文本和场景描述做理解、分析和建议。\n" + "注意:图片已完成本地识别,不要再尝试访问图片URL,也不要调用browser/image工具。" + ).strip() + + def _sanitize_answer(self, prompt: str, answer: str) -> str: + if self._is_market_prompt(prompt) or self._is_install_media_prompt(prompt): + return answer + + if not self.hide_source_urls: + return answer + + keep_map = {} + + def _keep_or_mask_url(match): + raw = match.group(0) + try: + host = (urllib.parse.urlparse(raw).hostname or "").lower() + except Exception: + host = "" + + if host and ( + host in self.unmask_url_hosts + or host == "feishu.cn" + or host.endswith(".feishu.cn") + ): + token = f"__KEEP_URL_{len(keep_map)}__" + keep_map[token] = raw + return token + + return "[内部数据源地址已脱敏]" + + out = URL_RE.sub(_keep_or_mask_url, answer) + out = IP_RE.sub("[内部地址已脱敏]", out) + out = DOMAIN_RE.sub("[内部域名已脱敏]", out) + + # 保底脱敏(防止某些格式未被正则覆盖) + for token in ("hq.yucekj.com", "yucekj.com", "218.93.9.141"): + out = out.replace(token, "[内部数据源已脱敏]") + + for token, raw in keep_map.items(): + out = out.replace(token, raw) + + return out + + def _run_market_brief(self, subject: str) -> str: + cmd = [self.market_brief_bin, subject] + self.logger.info("market-brief call: subject=%s", subject) + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.market_brief_timeout, + ) + output = (proc.stdout or "").strip() + if proc.returncode != 0: + err = (proc.stderr or output or "market brief error").strip() + return f"市场分析执行失败: {err[:500]}" + if not output: + return "市场分析执行完成,但未返回文本结果。" + return output + + def _parse_install_media_output(self, output: str) -> dict: + parsed = {"status": "", "mappings": [], "media": [], "warnings": []} + current_set = "" + in_warnings = False + for raw in (output or "").splitlines(): + line = raw.strip() + if not line: + continue + if line.startswith("STATUS:"): + parsed["status"] = line.split(":", 1)[1].strip() + in_warnings = False + continue + if line.startswith("NAME:"): + current_set = line.split(":", 1)[1].strip() + in_warnings = False + continue + if line == "WARNINGS:": + in_warnings = True + current_set = "" + continue + if in_warnings and line.startswith("- "): + parsed["warnings"].append(line[2:].strip()) + continue + if not line.startswith("{"): + continue + try: + obj = json.loads(line) + except Exception: + continue + if current_set == "order_to_product_mapping": + parsed["mappings"].append(obj) + elif current_set == "install_media_links": + parsed["media"].append(obj) + return parsed + + def _run_install_media_lookup(self, prompt: str) -> str: + order_id = self._extract_order_id(prompt) + product_no = self._extract_product_no(prompt) + if not order_id and not product_no: + return "请提供订单号(或产品货号),我将按“订单号 -> 货号 -> 安装图纸/视频库”流程查询。" + + cmd = [self.install_media_bin] + if order_id: + cmd += ["--order-id", order_id] + if product_no: + cmd += ["--sku-id", product_no] + cmd += ["--mysql-schema", "jiansunblueprint", "--limit", "200"] + + self.logger.info("install-media call: order_id=%s product_no=%s", order_id or "-", product_no or "-") + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.install_media_timeout, + ) + except subprocess.TimeoutExpired: + return f"安装资源查询超时(>{self.install_media_timeout}秒),请重试。" + + raw_output = (proc.stdout or "").strip() + if proc.returncode != 0 and not raw_output: + err = (proc.stderr or "install media lookup error").strip() + return f"安装资源查询失败: {err[:500]}" + + parsed = self._parse_install_media_output(raw_output) + mappings = parsed.get("mappings", []) + media_rows = parsed.get("media", []) + warnings = parsed.get("warnings", []) + + product_nos = sorted( + { + str(x.get("product_no", "")).strip() + for x in mappings + if str(x.get("product_no", "")).strip() and str(x.get("product_no", "")).strip() != "(none)" + } + ) + + drawing_rows = [x for x in media_rows if str(x.get("media_type", "")).lower() == "drawing"] + video_rows = [x for x in media_rows if str(x.get("media_type", "")).lower() == "video"] + stitch_required = self.install_media_stitch_enabled or self._is_install_media_stitch_prompt(prompt) + stitch_url = "" + stitch_note = "" + if stitch_required and drawing_rows: + drawing_urls = [] + seen_urls = set() + for row in drawing_rows: + url = str(row.get("media_url", "")).strip() + if not url or url in seen_urls: + continue + seen_urls.add(url) + drawing_urls.append(url) + if len(drawing_urls) >= self.install_media_stitch_min_images: + product_hint = ( + (product_nos[0] if product_nos else "") + or product_no + or order_id + or "unknown" + ) + stitch_url, stitch_note = self._stitch_and_upload_install_media( + drawing_urls=drawing_urls[: self.install_media_stitch_max_images], + product_hint=product_hint, + ) + + lines = ["安装资源查询结果"] + if order_id: + lines.append(f"- 订单号: {order_id}") + if product_nos: + lines.append(f"- 命中货号: {', '.join(product_nos[:8])}") + elif product_no: + lines.append(f"- 输入货号: {product_no}") + + lines.append(f"- 图纸: {len(drawing_rows)} 条,视频: {len(video_rows)} 条") + if stitch_url: + lines.append(f"- 拼接图: {stitch_url}") + elif stitch_note and self._is_install_media_stitch_prompt(prompt): + lines.append(f"- 拼接图: {stitch_note}") + + display_rows = media_rows + if stitch_url: + # 用户要求仅返回拼接图,不再返回原始多条图纸链接;视频链接保留 + display_rows = [x for x in media_rows if str(x.get("media_type", "")).lower() != "drawing"] + + if display_rows: + lines.append("- 资源链接(前20条):") + for i, row in enumerate(display_rows[:20], 1): + mtype = str(row.get("media_type", "")).lower() + tag = "图纸" if mtype == "drawing" else ("视频" if mtype == "video" else "资源") + url = str(row.get("media_url", "")).strip() + if url: + lines.append(f"{i}. [{tag}] {url}") + if len(display_rows) > 20: + lines.append(f"- 其余 {len(display_rows) - 20} 条已省略。") + else: + if stitch_url: + lines.append("- 原始图纸链接已按配置隐藏。") + else: + lines.append("- 未查到安装图纸/视频链接。") + + if warnings: + lines.append("- 警告:") + for w in warnings[:3]: + lines.append(f" - {w}") + + return "\n".join(lines) + + def _run_video_analysis(self, prompt: str) -> str: + if not self.video_analysis_enabled: + return "视频分析功能未启用。" + + video_urls = self._extract_video_urls(prompt, limit=self.video_analysis_max_urls) + if not video_urls: + return "请提供可访问的视频链接(http/https),我再开始分析。" + + api_key = ( + os.getenv("DASHSCOPE_API_KEY", "").strip() + or os.getenv("BAILIAN_API_KEY", "").strip() + or os.getenv("QWEN_API_KEY", "").strip() + ) + if not api_key: + return "视频分析未配置API Key,请设置 DASHSCOPE_API_KEY 后重试。" + + need_script = self._needs_video_script(prompt) + user_goal = self._remove_image_urls(prompt, video_urls).strip() + if not user_goal: + if need_script: + user_goal = "请深度拆解该视频,并输出可直接复刻的分镜脚本和拍摄执行方案。" + else: + user_goal = "请分析该视频的核心内容、关键信息和可执行建议。" + elif "视频" not in user_goal and "video" not in user_goal.lower(): + user_goal = f"{user_goal}\n请围绕视频内容输出结论。" + + content_items = [{"type": "video_url", "video_url": {"url": video_urls[0]}}] + content_items.append({"type": "text", "text": user_goal}) + + system_prompt = ( + "你是视频内容分析助手。请基于视频画面和字幕内容输出结构化结论,避免臆测。" + ) + if need_script: + system_prompt = ( + "你是资深短视频导演与编剧。请基于视频内容做可执行复刻拆解。\n" + "输出必须包含:\n" + "1) 视频定位与核心卖点(1-2句)\n" + "2) 时间轴分镜脚本表(时间段/镜头景别/画面内容/人物动作/机位与运镜/字幕或口播/BGM与音效)\n" + "3) 拍摄执行清单(场景、道具、服装、灯光、设备)\n" + "4) 剪辑脚本(节奏点、转场、卡点、字幕样式)\n" + "5) 复刻风险与规避建议(至少3条)\n" + "6) 可直接给拍摄团队的最终脚本(按步骤)\n" + "要求:具体、可落地、少空话。" + ) + + payload = { + "model": self.video_analysis_model, + "messages": [ + { + "role": "system", + "content": system_prompt, + }, + { + "role": "user", + "content": content_items, + }, + ], + "temperature": 0.2, + "max_tokens": self.video_analysis_max_tokens, + } + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + self.logger.info("video-analysis call: model=%s url_count=%s", self.video_analysis_model, len(video_urls)) + try: + resp = requests.post( + self.video_analysis_base_url, + headers=headers, + json=payload, + timeout=self.video_analysis_timeout, + ) + except Exception as exc: + self.logger.error("video-analysis request failed: %s", exc) + return f"视频分析调用失败:{exc}" + + text = "" + if resp.status_code >= 400: + try: + err_data = resp.json() + err_msg = ( + err_data.get("error", {}).get("message") + or err_data.get("message") + or resp.text + ) + except Exception: + err_msg = resp.text + return f"视频分析失败(HTTP {resp.status_code}):{str(err_msg).strip()[:300]}" + + try: + data = resp.json() + choices = data.get("choices") or [] + message = (choices[0] or {}).get("message", {}) if choices else {} + content = message.get("content", "") + if isinstance(content, list): + chunks = [] + for item in content: + if isinstance(item, dict): + t = str(item.get("text", "")).strip() + if t: + chunks.append(t) + text = "\n".join(chunks).strip() + else: + text = str(content or "").strip() + except Exception as exc: + self.logger.exception("video-analysis parse failed") + return f"视频分析返回解析失败:{exc}" + + if not text: + return "视频分析完成,但未返回有效文本结果。" + + if len(video_urls) > 1: + return f"{text}\n\n(已按配置先分析第1个视频链接;如需逐条分析请分别发送。)" + return text + + def _stitch_and_upload_install_media(self, drawing_urls: list, product_hint: str) -> tuple: + if Image is None: + return "", "未执行(缺少 Pillow 依赖)" + if boto3 is None or BotoConfig is None: + return "", "未执行(缺少 boto3 依赖)" + if not (self.s3_bucket and self.s3_access_key and self.s3_secret_key): + return "", "未执行(S3 配置不完整)" + + images = [] + try: + for url in drawing_urls: + try: + resp = requests.get(url, timeout=self.install_media_fetch_timeout) + resp.raise_for_status() + with Image.open(io.BytesIO(resp.content)) as img: + images.append(img.convert("RGB")) + except Exception as exc: + self.logger.warning("install-media image download failed: %s %s", url, exc) + + if len(images) < self.install_media_stitch_min_images: + return "", "未执行(可下载图纸不足)" + + target_width = min(max(img.width for img in images), self.install_media_max_width) + resized_images = [] + total_height = 0 + for img in images: + if img.width != target_width: + new_height = max(1, int(img.height * target_width / img.width)) + if hasattr(Image, "Resampling"): + resized = img.resize((target_width, new_height), Image.Resampling.LANCZOS) + else: + resized = img.resize((target_width, new_height), Image.LANCZOS) + else: + resized = img.copy() + resized_images.append(resized) + total_height += resized.height + + stitched = Image.new("RGB", (target_width, total_height), (255, 255, 255)) + y = 0 + for img in resized_images: + stitched.paste(img, (0, y)) + y += img.height + + safe_product = re.sub(r"[^A-Za-z0-9_-]+", "-", (product_hint or "unknown")).strip("-")[:40] or "unknown" + stamp = time.strftime("%Y%m%d%H%M%S") + filename = f"{safe_product}_{stamp}_{uuid.uuid4().hex[:8]}.jpg" + object_key = f"{self.s3_key_prefix}install-media/{filename}" + + with tempfile.TemporaryDirectory(prefix="install_media_") as tmpdir: + local_path = os.path.join(tmpdir, filename) + stitched.save(local_path, format="JPEG", quality=88, optimize=True) + self._upload_to_s3(local_path, object_key, "image/jpeg") + + return self._build_public_s3_url(object_key), "" + except Exception as exc: + self.logger.exception("install-media stitch/upload failed") + return "", f"未执行({exc})" + finally: + for img in images: + try: + img.close() + except Exception: + pass + + def _upload_to_s3(self, file_path: str, object_key: str, content_type: str) -> None: + endpoint = self.s3_endpoint or None + config = BotoConfig(s3={"addressing_style": "path" if self.s3_path_style else "virtual"}) + client = boto3.client( + "s3", + endpoint_url=endpoint, + region_name=self.s3_region, + aws_access_key_id=self.s3_access_key, + aws_secret_access_key=self.s3_secret_key, + config=config, + ) + with open(file_path, "rb") as fh: + kwargs = { + "Bucket": self.s3_bucket, + "Key": object_key, + "Body": fh, + "ContentType": content_type, + } + if self.s3_acl: + kwargs["ACL"] = self.s3_acl + client.put_object(**kwargs) + + def _build_public_s3_url(self, object_key: str) -> str: + safe_key = urllib.parse.quote(object_key, safe="/-_.~") + if self.s3_public_domain: + return f"{self.s3_public_domain.rstrip('/')}/{safe_key}" + if self.s3_endpoint: + base = self.s3_endpoint.rstrip("/") + if self.s3_path_style: + return f"{base}/{self.s3_bucket}/{safe_key}" + return f"{base}/{safe_key}" + return safe_key + + def _run_openclaw(self, session_id: str, prompt: str) -> str: + cmd = [ + self.openclaw_bin, + "agent", + "--session-id", + session_id, + "--message", + prompt, + "--json", + "--timeout", + str(self.openclaw_timeout), + ] + self.logger.info("openclaw call: session=%s", session_id) + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.openclaw_timeout + 20, + ) + except subprocess.TimeoutExpired: + self.logger.error("openclaw call timeout: session=%s timeout=%ss", session_id, self.openclaw_timeout) + return f"OpenClaw 调用超时(>{self.openclaw_timeout}秒),请重试。" + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "openclaw error").strip() + return f"OpenClaw 调用失败: {err[:500]}" + + try: + raw_stdout = (proc.stdout or "").strip() + decoder = json.JSONDecoder() + data = None + parse_error = None + try: + data, _ = decoder.raw_decode(raw_stdout) + except Exception as exc: + parse_error = exc + idx = raw_stdout.find("{") + while idx != -1: + candidate = raw_stdout[idx:] + try: + data, _ = decoder.raw_decode(candidate) + break + except Exception: + idx = raw_stdout.find("{", idx + 1) + if data is None: + raise parse_error + + payloads = data.get("result", {}).get("payloads", []) + text_items = [p.get("text", "") for p in payloads if isinstance(p, dict)] + answer = "\n".join(t for t in text_items if t).strip() + if not answer: + answer = "任务已执行,但未返回文本结果。" + + market_fallback_hints = ( + "无法直接从页面抓取", + "需要登录后的操作", + "工具版本不支持点击交互", + "无法正常访问该系统", + "您没有权限访问此页面", + "尚未登录", + "请先前往登录页登录", + "device identity required", + "控制用户界面需要HTTPS", + ) + if self._is_market_prompt(prompt) and any(h in answer for h in market_fallback_hints): + subject = self._extract_market_subject(prompt) + fallback = self._run_market_brief(subject) + return f"已切换为市场分析直连模式({subject}):\n\n{fallback}" + + return answer + except Exception as exc: + self.logger.exception("parse openclaw output failed") + raw = (proc.stdout or "").strip() + return f"结果解析失败: {exc}.\n原始输出: {raw[:500]}" + + def _is_image_url(self, raw_url: str) -> bool: + try: + parsed = urllib.parse.urlparse(raw_url) + except Exception: + return False + + host = (parsed.hostname or "").lower() + path = (parsed.path or "").lower() + if IMAGE_EXT_RE.search(path): + return True + if host.startswith("img.") or "image" in host: + return True + return False + + def _extract_image_urls(self, text: str, limit: int = 5) -> list: + urls = [] + seen = set() + for match in URL_RE.finditer(text or ""): + raw = match.group(0).rstrip(".,;!?") + if not self._is_image_url(raw): + continue + if raw in seen: + continue + seen.add(raw) + urls.append(raw) + if len(urls) >= limit: + break + return urls + + def _remove_image_urls(self, text: str, urls: list) -> str: + out = text + for u in urls: + out = out.replace(u, "") + out = re.sub(r"\n{3,}", "\n\n", out) + return out.strip() + + def _build_action_buttons(self) -> list: + if not self.action_card_btn_url: + return [] + return [ + { + "text": self.action_card_btn_text or "打开链接", + "url": self.action_card_btn_url, + "iosUrl": self.action_card_btn_url, + "color": "gray", + } + ] + + def _is_card_reply_ok(self, reply_obj) -> bool: + if reply_obj is None: + return False + card_instance_id = getattr(reply_obj, "card_instance_id", None) + if isinstance(card_instance_id, str): + return bool(card_instance_id.strip()) + return bool(card_instance_id) + + def _reply_message(self, msg: ChatbotMessage, text: str, prefer_card: bool = True) -> bool: + payload = (text or "").strip() or "任务已执行,但未返回文本结果。" + + if self.reply_style != "action_card": + prefer_card = False + if self.card_disabled_until > time.time(): + prefer_card = False + + if prefer_card: + image_urls = self._extract_image_urls(payload, limit=5) + if image_urls: + slider = [(f"图片{i + 1}", u) for i, u in enumerate(image_urls)] + markdown = self._remove_image_urls(payload, image_urls) + if not markdown: + markdown = "已整理相关图片如下:" + try: + card = self.reply_carousel_card( + incoming_message=msg, + markdown=markdown, + image_slider=slider, + button_text=self.action_card_btn_text or "查看", + title=self.action_card_title, + logo=self.action_card_logo, + ) + if self._is_card_reply_ok(card): + return True + self.logger.warning("carousel card reply returned empty id, fallback to action card/text") + self._disable_card_temporarily("carousel_card_empty_id") + except Exception as exc: + self.logger.exception("carousel card reply failed, fallback to action card") + if "Card.Instance.Write" in str(exc) or "AccessDenied" in str(exc): + self._disable_card_temporarily("card_permission_denied") + + markdown = payload + if self.action_card_title: + markdown = f"### {self.action_card_title}\n\n{payload}" + try: + card = self.reply_markdown_button( + incoming_message=msg, + markdown=markdown, + button_list=self._build_action_buttons(), + tips=self.action_card_tips, + title=self.action_card_title, + logo=self.action_card_logo, + ) + if self._is_card_reply_ok(card): + return True + self.logger.warning("action card reply returned empty id, fallback to text") + self._disable_card_temporarily("action_card_empty_id") + except Exception as exc: + self.logger.exception("action card reply failed, fallback to text") + if "Card.Instance.Write" in str(exc) or "AccessDenied" in str(exc): + self._disable_card_temporarily("card_permission_denied") + + resp = self.reply_text(payload, msg) + if resp is None: + self.logger.error("text reply failed") + return False + return True + + async def process(self, callback: CallbackMessage): + try: + msg = ChatbotMessage.from_dict(callback.data) + except Exception: + self.logger.exception("invalid callback payload") + return AckMessage.STATUS_BAD_REQUEST, "bad payload" + + text = "" + if msg.text and getattr(msg.text, "content", None): + text = msg.text.content.strip() + else: + text_list = msg.get_text_list() or [] + text = text_list[0].strip() if text_list else "" + + incoming_image_codes = self._extract_incoming_download_codes(msg) + + if not text and not incoming_image_codes: + return AckMessage.STATUS_OK, "ignored" + + if not self._should_handle(msg, text): + return AckMessage.STATUS_OK, "ignored" + + prompt = self._normalize_prompt(text) + if not prompt: + if incoming_image_codes: + prompt = "请识别并分析我上传的图片内容,输出结论和建议。" + else: + self._reply_message( + msg, + f"用法: {self.prefix} 你的问题\n例如: {self.prefix} 查询亿家达旗舰店上周业绩", + prefer_card=False, + ) + return AckMessage.STATUS_OK, "ok" + + event_key = self._event_key(callback, msg, prompt) + if self._seen_or_mark_event(event_key): + self.logger.info( + "duplicate callback ignored: key=%s conversation=%s sender=%s", + event_key, + getattr(msg, "conversation_id", ""), + getattr(msg, "sender_id", ""), + ) + return AckMessage.STATUS_OK, "duplicate_ignored" + + if self._is_smalltalk_prompt(prompt): + try: + self._reply_message(msg, self.smalltalk_reply_text, prefer_card=False) + except Exception: + self.logger.exception("failed to send smalltalk reply") + return AckMessage.STATUS_OK, "smalltalk_ok" + + if self.send_processing_hint: + hint = self.processing_hint_text or "已收到,正在处理,请稍等。" + try: + self.reply_text(hint, msg) + except Exception: + self.logger.exception("failed to send processing hint") + + conversation_key = re.sub(r"[^A-Za-z0-9_-]+", "-", str(getattr(msg, "conversation_id", "") or "default")) + conv_lock = self._conversation_locks.setdefault(conversation_key, asyncio.Lock()) + + async with conv_lock: + session_id = self._session_id(msg) + image_context_result = {"used_images": 0, "context_text": "", "uploaded_urls": [], "warnings": []} + prompt_for_exec = prompt + try: + async with self._work_semaphore: + self.logger.info( + "worker acquired: session=%s conversation=%s max_parallel=%s", + session_id, + conversation_key, + self.max_parallel_jobs, + ) + if incoming_image_codes and not self._is_install_media_prompt(prompt): + image_context_result = await asyncio.to_thread( + self._build_image_context_from_dingtalk, + msg, + prompt, + incoming_image_codes, + ) + context_text = image_context_result.get("context_text", "") + if context_text: + prompt_for_exec = self._merge_prompt_with_image_context(prompt, context_text) + if self._is_install_media_prompt(prompt): + answer = await asyncio.to_thread(self._run_install_media_lookup, prompt) + elif self._is_dalongxia_analytics_prompt(prompt): + answer = await asyncio.to_thread(self._run_dalongxia_analytics, prompt) + elif self._is_video_analysis_prompt(prompt): + answer = await asyncio.to_thread(self._run_video_analysis, prompt) + elif self._is_market_prompt(prompt): + subject = self._extract_market_subject(prompt) + answer = await asyncio.to_thread(self._run_market_brief, subject) + else: + answer = await asyncio.to_thread(self._run_openclaw, session_id, prompt_for_exec) + except Exception: + self.logger.exception("worker execution failed: session=%s", session_id) + answer = "系统执行异常,请稍后重试。" + + if incoming_image_codes and image_context_result.get("used_images", 0) == 0: + warnings = image_context_result.get("warnings", []) + warn_text = ";".join(warnings[:2]) if warnings else "图片下载或识别失败" + answer = f"已收到图片,但未完成识别:{warn_text}。请重发清晰图片后再试。" + elif image_context_result.get("warnings"): + answer = f"{answer}\n\n(图片处理提示:{';'.join(image_context_result['warnings'][:2])})" + + answer = self._clean_dialog_noise(answer) + answer = self._trim_noncode_blocks(prompt, answer) + answer = self._sanitize_answer(prompt, answer) + answer = self._mask_sensitive_amounts(prompt, answer) + if len(answer) > self.reply_max_chars: + answer = answer[: self.reply_max_chars] + "\n...(已截断)" + + try: + prefer_card = not self._is_install_media_prompt(prompt) + sent = self._reply_message(msg, answer, prefer_card=prefer_card) + if sent: + self.logger.info("reply sent: session=%s chars=%s", session_id, len(answer)) + else: + self.logger.error("reply failed: session=%s chars=%s", session_id, len(answer)) + except Exception: + self.logger.exception("failed to send final reply: session=%s", session_id) + return AckMessage.STATUS_OK, "ok" + + +def build_client(logger: logging.Logger) -> DingTalkStreamClient: + client_id = os.getenv("DINGTALK_CLIENT_ID", "").strip() + client_secret = os.getenv("DINGTALK_CLIENT_SECRET", "").strip() + + if not client_id or not client_secret: + raise RuntimeError("DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET 未配置") + + credential = Credential(client_id, client_secret) + client = DingTalkStreamClient(credential, logger=logger) + bot = OpenClawDingTalkBot(logger) + client.register_callback_handler(ChatbotMessage.TOPIC, bot) + if bool_env("REGISTER_DELEGATE_TOPIC", False): + client.register_callback_handler(ChatbotMessage.DELEGATE_TOPIC, bot) + logger.info("delegate topic enabled") + else: + logger.info("delegate topic disabled (default)") + return client + + +def self_check(logger: logging.Logger) -> int: + required = ["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"] + missing = [k for k in required if not os.getenv(k)] + if missing: + logger.error("missing env: %s", ", ".join(missing)) + return 2 + + openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw") + proc = subprocess.run([openclaw_bin, "--version"], capture_output=True, text=True) + if proc.returncode != 0: + logger.error("openclaw check failed: %s", (proc.stderr or proc.stdout).strip()) + return 3 + + market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report") + if not os.path.exists(market_brief_bin): + logger.error("market brief bin not found: %s", market_brief_bin) + return 4 + + install_media_bin = os.getenv("INSTALL_MEDIA_BIN", "/usr/local/bin/install-media-lookup") + if not os.path.exists(install_media_bin): + logger.error("install media bin not found: %s", install_media_bin) + return 5 + + logger.info("self-check ok, openclaw=%s", (proc.stdout or "").strip()) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="OpenClaw DingTalk Bridge") + parser.add_argument("--check", action="store_true", help="run checks and exit") + args = parser.parse_args() + + logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + logger = logging.getLogger("openclaw-dingtalk-bridge") + + if args.check: + return self_check(logger) + + client = build_client(logger) + logger.info("bridge started, listening DingTalk stream") + client.start_forever() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/remote_sync/bot.remote.py b/remote_sync/bot.remote.py new file mode 100755 index 0000000..60ea5b5 --- /dev/null +++ b/remote_sync/bot.remote.py @@ -0,0 +1,628 @@ +#!/usr/bin/env python3 +import argparse +import hashlib +import json +import logging +import os +import re +import subprocess +import time +import urllib.parse + +from dingtalk_stream import AckMessage, CallbackMessage, ChatbotHandler, ChatbotMessage +from dingtalk_stream import Credential, DingTalkStreamClient + + +MARKET_HINT_KEYWORDS = ( + "市场分析", + "市场数据分析", + "行业分析", + "类目分析", + "竞品分析", + "趋势分析", +) + +SALES_HINT_KEYWORDS = ( + "销售数据", + "销售报表", + "销售看板", + "公司销售", + "业绩报表", + "业绩数据", + "费比监控", +) + +SENSITIVE_SALES_KEYWORDS = ( + "销售", + "业绩", + "售后", + "退款", + "店铺", + "发货", + "工单", + "费比", +) + +EXPLICIT_AMOUNT_KEYWORDS = ( + "金额", + "费用", + "收入", + "利润", + "成本", + "客单价", + "gmv", + "销售额", + "退款额", + "毛利", +) + + +URL_RE = re.compile(r"https?://[^\s)\]>]+", re.IGNORECASE) +IP_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b") +DOMAIN_RE = re.compile(r"\b(?:[a-zA-Z0-9-]+\.)+(?:com|cn|net|ai|org|io)\b", re.IGNORECASE) +IMAGE_EXT_RE = re.compile(r"\.(?:png|jpg|jpeg|gif|webp|bmp|svg)(?:$|[?#])", re.IGNORECASE) + + + +def bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +class OpenClawDingTalkBot(ChatbotHandler): + def __init__(self, logger: logging.Logger): + super().__init__() + self.logger = logger + self.openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw") + self.openclaw_timeout = int(os.getenv("OPENCLAW_TIMEOUT", "600")) + self.market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report") + self.market_brief_timeout = int(os.getenv("MARKET_BRIEF_TIMEOUT", "180")) + self.prefix = os.getenv("BOT_PREFIX", "/oc").strip() + self.require_prefix = bool_env("REQUIRE_PREFIX", False) + self.reply_max_chars = int(os.getenv("REPLY_MAX_CHARS", "3500")) + self.session_prefix = os.getenv("OPENCLAW_SESSION_PREFIX", "dingtalk") + self.hide_source_urls = bool_env("HIDE_SOURCE_URLS", True) + self.send_processing_hint = bool_env("SEND_PROCESSING_HINT", True) + self.dedupe_ttl_seconds = int(os.getenv("DEDUPE_TTL_SECONDS", "300")) + self.session_max_lines = int(os.getenv("OPENCLAW_SESSION_MAX_LINES", "120")) + self.sessions_dir = os.getenv("OPENCLAW_SESSIONS_DIR", "/root/.openclaw/agents/main/sessions") + self.unmask_url_hosts = { + h.strip().lower() + for h in os.getenv("UNMASK_URL_HOSTS", "img.alicdn.com,static.jiansun.vip").split(",") + if h.strip() + } + self._recent_event_ts = {} + self.processing_hint_text = os.getenv( + "PROCESSING_HINT_TEXT", + "已收到,正在处理你的需求,请稍等1-3分钟。", + ).strip() + self.reply_style = os.getenv("REPLY_STYLE", "action_card").strip().lower() + self.action_card_title = os.getenv("ACTION_CARD_TITLE", "OpenClaw 助理").strip() + self.action_card_tips = os.getenv("ACTION_CARD_TIPS", "").strip() + self.action_card_logo = os.getenv("ACTION_CARD_LOGO", "").strip() + self.action_card_btn_text = os.getenv("ACTION_CARD_BTN_TEXT", "").strip() + self.action_card_btn_url = os.getenv("ACTION_CARD_BTN_URL", "").strip() + + def _should_handle(self, msg: ChatbotMessage, text: str) -> bool: + if text.startswith(self.prefix): + return True + if msg.is_in_at_list: + return True + return not self.require_prefix + + def _normalize_prompt(self, text: str) -> str: + text = text.strip() + if self.prefix and text.startswith(self.prefix): + text = text[len(self.prefix):].strip() + text = re.sub(r"^@\S+\s*", "", text).strip() + return text + + def _session_id(self, msg: ChatbotMessage) -> str: + conversation_id = (msg.conversation_id or "default").replace(" ", "_") + base_session = f"{self.session_prefix}-{conversation_id}" + + session_file = os.path.join(self.sessions_dir, f"{base_session}.jsonl") + if self.session_max_lines > 0 and os.path.exists(session_file): + try: + with open(session_file, "rb") as fh: + line_count = sum(1 for _ in fh) + if line_count >= self.session_max_lines: + rotate_suffix = time.strftime("%Y%m%d%H%M") + rotated = f"{base_session}-{rotate_suffix}" + self.logger.info( + "session auto-rotated: base=%s lines=%s rotated=%s", + base_session, + line_count, + rotated, + ) + return rotated + except Exception: + self.logger.exception("failed to inspect session file: %s", session_file) + + return base_session + + def _event_key(self, callback: CallbackMessage, msg: ChatbotMessage, prompt: str) -> str: + if getattr(msg, "message_id", None): + return f"msg:{msg.message_id}" + + data = callback.data if isinstance(callback.data, dict) else {} + for key in ("msgId", "messageId", "eventId", "processQueryKey"): + value = data.get(key) + if value: + return f"data:{key}:{value}" + + headers = getattr(callback, "headers", {}) or {} + for key in ("messageId", "eventId", "processQueryKey"): + value = headers.get(key) + if value: + return f"hdr:{key}:{value}" + + raw = "|".join( + [ + str(getattr(msg, "conversation_id", "") or ""), + str(getattr(msg, "sender_id", "") or ""), + str(getattr(msg, "sender_staff_id", "") or ""), + str(getattr(msg, "create_at", "") or ""), + prompt[:200], + ] + ) + return "fp:" + hashlib.sha1(raw.encode("utf-8")).hexdigest() + + def _seen_or_mark_event(self, key: str) -> bool: + now = time.time() + expire_before = now - max(self.dedupe_ttl_seconds, 30) + + stale_keys = [k for k, ts in self._recent_event_ts.items() if ts < expire_before] + for stale_key in stale_keys: + self._recent_event_ts.pop(stale_key, None) + + if key in self._recent_event_ts: + return True + + self._recent_event_ts[key] = now + return False + + def _is_market_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt) + + if any(k in normalized for k in SALES_HINT_KEYWORDS): + return False + + if "电脑桌" in normalized: + return True + + if any(k in normalized for k in MARKET_HINT_KEYWORDS): + return True + + if "市场" in normalized and "分析" in normalized: + return True + + return False + + def _is_sales_prompt(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt).lower() + return any(k.lower() in normalized for k in SENSITIVE_SALES_KEYWORDS) + + def _explicit_amount_requested(self, prompt: str) -> bool: + normalized = re.sub(r"\s+", "", prompt).lower() + return any(k.lower() in normalized for k in EXPLICIT_AMOUNT_KEYWORDS) + + def _mask_sensitive_amounts(self, prompt: str, answer: str) -> str: + if not self._is_sales_prompt(prompt): + return answer + if self._explicit_amount_requested(prompt): + return answer + + filtered_lines = [] + removed = 0 + for line in answer.splitlines(): + if re.search(r"(金额|退款率|费比|收入|利润|成本|客单价|GMV|毛利|\d[\d,\.]*\s*(元|万元|万|亿))", line, re.IGNORECASE): + removed += 1 + continue + filtered_lines.append(line) + + cleaned = "\n".join(filtered_lines).strip() + if removed > 0: + note = "(已按配置自动隐藏金额类敏感指标,仅保留件数/订单维度。)" + if cleaned: + cleaned = f"{cleaned}\n\n{note}" + else: + cleaned = note + + return cleaned or "已按配置隐藏金额类敏感指标,请改问件数/订单/原因结构维度。" + + def _extract_market_subject(self, prompt: str) -> str: + normalized = prompt.strip() + + def clean_subject(raw: str) -> str: + v = raw.strip().replace("\u201c", "").replace("\u201d", "").strip("\"'") + v = re.sub(r"^(帮我|请|麻烦|给我|我想|想要|想看|想做|分析|查看|查询|关于)+", "", v) + v = re.sub(r"(的|情况|数据|分析)+$", "", v) + return v.strip() + + def pick(raw: str): + v = clean_subject(raw) + if len(v) < 2: + return None + if v in {"市场", "行业", "类目", "竞品", "趋势", "市场数据"}: + return None + return v + + if "电脑桌" in normalized: + return "电脑桌" + + m = re.search(r"[\"\u201c\u201d']([^\"\u201c\u201d']{2,30})[\"\u201c\u201d']", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search( + r"关于([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据|市场情况)", + normalized, + ) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search( + r"([\u4e00-\u9fa5A-Za-z0-9]{2,20})(?:的)?(?:市场数据分析|市场分析|行业分析|类目分析|竞品分析|趋势分析|市场数据)", + normalized, + ) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search(r"分析(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + m = re.search(r"查(?:一下|下)?([\u4e00-\u9fa5A-Za-z0-9]{2,20})", normalized) + if m: + v = pick(m.group(1)) + if v: + return v + + return "电脑桌" + + def _sanitize_answer(self, answer: str) -> str: + if not self.hide_source_urls: + return answer + + keep_map = {} + + def _keep_or_mask_url(match): + raw = match.group(0) + try: + host = (urllib.parse.urlparse(raw).hostname or "").lower() + except Exception: + host = "" + + if host and host in self.unmask_url_hosts: + token = f"__KEEP_URL_{len(keep_map)}__" + keep_map[token] = raw + return token + + return "[内部数据源地址已脱敏]" + + out = URL_RE.sub(_keep_or_mask_url, answer) + out = IP_RE.sub("[内部地址已脱敏]", out) + out = DOMAIN_RE.sub("[内部域名已脱敏]", out) + + # 保底脱敏(防止某些格式未被正则覆盖) + for token in ("hq.yucekj.com", "yucekj.com", "218.93.9.141"): + out = out.replace(token, "[内部数据源已脱敏]") + + for token, raw in keep_map.items(): + out = out.replace(token, raw) + + return out + + def _run_market_brief(self, subject: str) -> str: + cmd = [self.market_brief_bin, subject] + self.logger.info("market-brief call: subject=%s", subject) + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.market_brief_timeout, + ) + output = (proc.stdout or "").strip() + if proc.returncode != 0: + err = (proc.stderr or output or "market brief error").strip() + return f"市场分析执行失败: {err[:500]}" + if not output: + return "市场分析执行完成,但未返回文本结果。" + return output + + def _run_openclaw(self, session_id: str, prompt: str) -> str: + cmd = [ + self.openclaw_bin, + "agent", + "--session-id", + session_id, + "--message", + prompt, + "--json", + "--timeout", + str(self.openclaw_timeout), + ] + self.logger.info("openclaw call: session=%s", session_id) + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.openclaw_timeout + 20, + ) + except subprocess.TimeoutExpired: + self.logger.error("openclaw call timeout: session=%s timeout=%ss", session_id, self.openclaw_timeout) + return f"OpenClaw 调用超时(>{self.openclaw_timeout}秒),请重试。" + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "openclaw error").strip() + return f"OpenClaw 调用失败: {err[:500]}" + + try: + data = json.loads(proc.stdout) + payloads = data.get("result", {}).get("payloads", []) + text_items = [p.get("text", "") for p in payloads if isinstance(p, dict)] + answer = "\n".join(t for t in text_items if t).strip() + if not answer: + answer = "任务已执行,但未返回文本结果。" + + if self._is_market_prompt(prompt) and ( + "无法直接从页面抓取" in answer + or "需要登录后的操作" in answer + or "工具版本不支持点击交互" in answer + ): + subject = self._extract_market_subject(prompt) + fallback = self._run_market_brief(subject) + return f"已切换为市场分析直连模式({subject}):\n\n{fallback}" + + return answer + except Exception as exc: + self.logger.exception("parse openclaw output failed") + raw = (proc.stdout or "").strip() + return f"结果解析失败: {exc}.\n原始输出: {raw[:500]}" + + def _is_image_url(self, raw_url: str) -> bool: + try: + parsed = urllib.parse.urlparse(raw_url) + except Exception: + return False + + host = (parsed.hostname or "").lower() + path = (parsed.path or "").lower() + if IMAGE_EXT_RE.search(path): + return True + if host.startswith("img.") or "image" in host: + return True + return False + + def _extract_image_urls(self, text: str, limit: int = 5) -> list: + urls = [] + seen = set() + for match in URL_RE.finditer(text or ""): + raw = match.group(0).rstrip(".,;!?") + if not self._is_image_url(raw): + continue + if raw in seen: + continue + seen.add(raw) + urls.append(raw) + if len(urls) >= limit: + break + return urls + + def _remove_image_urls(self, text: str, urls: list) -> str: + out = text + for u in urls: + out = out.replace(u, "") + out = re.sub(r"\n{3,}", "\n\n", out) + return out.strip() + + def _build_action_buttons(self) -> list: + if not self.action_card_btn_url: + return [] + return [ + { + "text": self.action_card_btn_text or "打开链接", + "url": self.action_card_btn_url, + "iosUrl": self.action_card_btn_url, + "color": "gray", + } + ] + + def _is_card_reply_ok(self, reply_obj) -> bool: + if reply_obj is None: + return False + card_instance_id = getattr(reply_obj, "card_instance_id", None) + if isinstance(card_instance_id, str): + return bool(card_instance_id.strip()) + return bool(card_instance_id) + + def _reply_message(self, msg: ChatbotMessage, text: str, prefer_card: bool = True) -> bool: + payload = (text or "").strip() or "任务已执行,但未返回文本结果。" + if prefer_card and self.reply_style == "action_card": + image_urls = self._extract_image_urls(payload, limit=5) + if image_urls: + slider = [(f"图片{i + 1}", u) for i, u in enumerate(image_urls)] + markdown = self._remove_image_urls(payload, image_urls) + if not markdown: + markdown = "已整理相关图片如下:" + try: + card = self.reply_carousel_card( + incoming_message=msg, + markdown=markdown, + image_slider=slider, + button_text=self.action_card_btn_text or "查看", + title=self.action_card_title, + logo=self.action_card_logo, + ) + if self._is_card_reply_ok(card): + return True + self.logger.warning("carousel card reply returned empty id, fallback to action card/text") + except Exception: + self.logger.exception("carousel card reply failed, fallback to action card") + + markdown = payload + if self.action_card_title: + markdown = f"### {self.action_card_title}\n\n{payload}" + try: + card = self.reply_markdown_button( + incoming_message=msg, + markdown=markdown, + button_list=self._build_action_buttons(), + tips=self.action_card_tips, + title=self.action_card_title, + logo=self.action_card_logo, + ) + if self._is_card_reply_ok(card): + return True + self.logger.warning("action card reply returned empty id, fallback to text") + except Exception: + self.logger.exception("action card reply failed, fallback to text") + resp = self.reply_text(payload, msg) + if resp is None: + self.logger.error("text reply failed") + return False + return True + + async def process(self, callback: CallbackMessage): + try: + msg = ChatbotMessage.from_dict(callback.data) + except Exception: + self.logger.exception("invalid callback payload") + return AckMessage.STATUS_BAD_REQUEST, "bad payload" + + text = "" + if msg.text and getattr(msg.text, "content", None): + text = msg.text.content.strip() + else: + text_list = msg.get_text_list() or [] + text = text_list[0].strip() if text_list else "" + + if not text: + return AckMessage.STATUS_OK, "ignored" + + if not self._should_handle(msg, text): + return AckMessage.STATUS_OK, "ignored" + + prompt = self._normalize_prompt(text) + if not prompt: + self._reply_message( + msg, + f"用法: {self.prefix} 你的问题\\n例如: {self.prefix} 查询亿家达旗舰店上周业绩", + prefer_card=False, + ) + return AckMessage.STATUS_OK, "ok" + + event_key = self._event_key(callback, msg, prompt) + if self._seen_or_mark_event(event_key): + self.logger.info( + "duplicate callback ignored: key=%s conversation=%s sender=%s", + event_key, + getattr(msg, "conversation_id", ""), + getattr(msg, "sender_id", ""), + ) + return AckMessage.STATUS_OK, "duplicate_ignored" + + if self.send_processing_hint: + hint = self.processing_hint_text or "已收到,正在处理,请稍等。" + try: + self.reply_text(hint, msg) + except Exception: + self.logger.exception("failed to send processing hint") + + session_id = self._session_id(msg) + if self._is_market_prompt(prompt): + subject = self._extract_market_subject(prompt) + answer = self._run_market_brief(subject) + else: + answer = self._run_openclaw(session_id, prompt) + + answer = self._sanitize_answer(answer) + answer = self._mask_sensitive_amounts(prompt, answer) + if len(answer) > self.reply_max_chars: + answer = answer[: self.reply_max_chars] + "\n...(已截断)" + + try: + sent = self._reply_message(msg, answer, prefer_card=True) + if sent: + self.logger.info("reply sent: session=%s chars=%s", session_id, len(answer)) + else: + self.logger.error("reply failed: session=%s chars=%s", session_id, len(answer)) + except Exception: + self.logger.exception("failed to send final reply: session=%s", session_id) + return AckMessage.STATUS_OK, "ok" + + +def build_client(logger: logging.Logger) -> DingTalkStreamClient: + client_id = os.getenv("DINGTALK_CLIENT_ID", "").strip() + client_secret = os.getenv("DINGTALK_CLIENT_SECRET", "").strip() + + if not client_id or not client_secret: + raise RuntimeError("DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET 未配置") + + credential = Credential(client_id, client_secret) + client = DingTalkStreamClient(credential, logger=logger) + bot = OpenClawDingTalkBot(logger) + client.register_callback_handler(ChatbotMessage.TOPIC, bot) + if bool_env("REGISTER_DELEGATE_TOPIC", False): + client.register_callback_handler(ChatbotMessage.DELEGATE_TOPIC, bot) + logger.info("delegate topic enabled") + else: + logger.info("delegate topic disabled (default)") + return client + + +def self_check(logger: logging.Logger) -> int: + required = ["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"] + missing = [k for k in required if not os.getenv(k)] + if missing: + logger.error("missing env: %s", ", ".join(missing)) + return 2 + + openclaw_bin = os.getenv("OPENCLAW_BIN", "/usr/bin/openclaw") + proc = subprocess.run([openclaw_bin, "--version"], capture_output=True, text=True) + if proc.returncode != 0: + logger.error("openclaw check failed: %s", (proc.stderr or proc.stdout).strip()) + return 3 + + market_brief_bin = os.getenv("MARKET_BRIEF_BIN", "/usr/local/bin/market-brief-report") + if not os.path.exists(market_brief_bin): + logger.error("market brief bin not found: %s", market_brief_bin) + return 4 + + logger.info("self-check ok, openclaw=%s", (proc.stdout or "").strip()) + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="OpenClaw DingTalk Bridge") + parser.add_argument("--check", action="store_true", help="run checks and exit") + args = parser.parse_args() + + logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + logger = logging.getLogger("openclaw-dingtalk-bridge") + + if args.check: + return self_check(logger) + + client = build_client(logger) + logger.info("bridge started, listening DingTalk stream") + client.start_forever() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/remote_sync/bridge_journal_today.log b/remote_sync/bridge_journal_today.log new file mode 100644 index 0000000..02506df --- /dev/null +++ b/remote_sync/bridge_journal_today.log @@ -0,0 +1,193 @@ +2026-02-09T20:17:22+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T20:17:22+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:17:22,784 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream +2026-02-09T20:17:22+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:17:22,784 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-09T20:17:22+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:17:22,915 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '49b2dabf-05b1-11f1-bebd-26ca3a8714ef'} +2026-02-09T20:23:38+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:23:38,701 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A== +2026-02-09T20:24:53+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:24:53,306 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A== +2026-02-09T20:27:26+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:27:26,274 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received +2026-02-09T20:27:36+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:27:36,283 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-09T20:27:36+08:00 dao08-System-Product-Name python[319179]: 2026-02-09 20:27:36,453 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'b7654b04-05b2-11f1-b05d-329bedae46d2'} +2026-02-09T20:29:53+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge... +2026-02-09T20:29:53+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully. +2026-02-09T20:29:53+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T20:29:53+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 9.584s CPU time. +2026-02-09T20:29:53+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T20:29:53+08:00 dao08-System-Product-Name python[322046]: 2026-02-09 20:29:53,319 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream +2026-02-09T20:29:53+08:00 dao08-System-Product-Name python[322046]: 2026-02-09 20:29:53,319 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-09T20:29:53+08:00 dao08-System-Product-Name python[322046]: 2026-02-09 20:29:53,456 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '090e3062-05b3-11f1-b7a2-faf2d53e39a8'} +2026-02-09T20:30:12+08:00 dao08-System-Product-Name python[322046]: 2026-02-09 20:30:12,030 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A== +2026-02-09T20:31:53+08:00 dao08-System-Product-Name python[322046]: 2026-02-09 20:31:53,570 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A== +2026-02-09T20:33:12+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge... +2026-02-09T20:33:12+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully. +2026-02-09T20:33:12+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T20:33:12+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 10.324s CPU time. +2026-02-09T20:33:12+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T20:33:12+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:33:12,253 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream +2026-02-09T20:33:12+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:33:12,253 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-09T20:33:12+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:33:12,412 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '7fa31af1-05b3-11f1-89fb-2eaa794b294e'} +2026-02-09T20:36:22+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:36:22,895 INFO openclaw-dingtalk-bridge: market-brief call: subject=继续分析价格段布局和需求 +2026-02-09T20:39:18+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:39:18,580 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A== +2026-02-09T20:40:29+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:40:29,129 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A== +2026-02-09T20:41:20+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:41:20,866 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A== +2026-02-09T20:42:17+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:42:17,055 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received +2026-02-09T20:42:17+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:42:17,055 ERROR openclaw-dingtalk-bridge: error processing message: sent 1011 (internal error) keepalive ping timeout; no close frame received +2026-02-09T20:42:27+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:42:27,065 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-09T20:42:27+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 20:42:27,235 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'ca5546e8-05b4-11f1-8bff-5215a8ef9827'} +2026-02-09T21:26:25+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 21:26:25,497 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A== +2026-02-09T21:28:57+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 21:28:57,028 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A== +2026-02-09T21:29:18+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 21:29:18,153 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received +2026-02-09T21:29:28+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 21:29:28,163 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-09T21:29:28+08:00 dao08-System-Product-Name python[323746]: 2026-02-09 21:29:28,309 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '5bd4cb3b-05bb-11f1-80c0-7e88154b8801'} +2026-02-09T21:34:41+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge... +2026-02-09T21:34:41+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully. +2026-02-09T21:34:41+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T21:34:41+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 1min 11.189s CPU time, 424.5M memory peak, 0B memory swap peak. +2026-02-09T21:34:41+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T21:34:41+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:34:41,954 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream +2026-02-09T21:34:41+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:34:41,954 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-09T21:34:42+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:34:42,109 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '16df03a3-05bc-11f1-bebd-26ca3a8714ef'} +2026-02-09T21:38:33+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:38:33,276 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A== +2026-02-09T21:40:42+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:40:42,339 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A== +2026-02-09T21:41:51+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:41:51,118 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidJBUfPLXWbkHAtA+jtnES9Y0ny4H4d4FlgagPuGo0iiM= +2026-02-09T21:43:54+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:43:54,848 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A== +2026-02-09T21:46:45+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:46:45,649 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidJBUfPLXWbkHAtA+jtnES9Y0ny4H4d4FlgagPuGo0iiM= +2026-02-09T21:47:28+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:47:28,052 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A== +2026-02-09T21:47:37+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:47:37,331 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received +2026-02-09T21:47:47+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:47:47,340 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-09T21:47:47+08:00 dao08-System-Product-Name python[343044]: 2026-02-09 21:47:47,475 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'eafc88cb-05bd-11f1-b05d-329bedae46d2'} +2026-02-09T21:55:55+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge... +2026-02-09T21:55:55+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully. +2026-02-09T21:55:55+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T21:55:55+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 29.883s CPU time. +2026-02-09T21:55:55+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T21:55:55+08:00 dao08-System-Product-Name python[350397]: 2026-02-09 21:55:55,930 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream +2026-02-09T21:55:55+08:00 dao08-System-Product-Name python[350397]: 2026-02-09 21:55:55,931 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-09T21:55:56+08:00 dao08-System-Product-Name python[350397]: 2026-02-09 21:55:56,063 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '0e354371-05bf-11f1-8bc5-6647947ab531'} +2026-02-09T21:58:46+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge... +2026-02-09T21:58:46+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully. +2026-02-09T21:58:46+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T21:58:46+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T21:58:46+08:00 dao08-System-Product-Name python[351635]: 2026-02-09 21:58:46,878 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream +2026-02-09T21:58:46+08:00 dao08-System-Product-Name python[351635]: 2026-02-09 21:58:46,879 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-09T21:58:47+08:00 dao08-System-Product-Name python[351635]: 2026-02-09 21:58:47,100 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '74253344-05bf-11f1-8bff-5215a8ef9827'} +2026-02-09T22:00:49+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge... +2026-02-09T22:00:49+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully. +2026-02-09T22:00:49+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T22:00:49+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T22:00:49+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:00:49,277 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream +2026-02-09T22:00:49+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:00:49,277 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-09T22:00:49+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:00:49,415 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'bd0e66b6-05bf-11f1-bee8-0eb3b764c0bb'} +2026-02-09T22:02:29+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:02:29,164 INFO openclaw-dingtalk-bridge: market-brief call: subject=书架 +2026-02-09T22:05:13+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:05:13,915 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A== +2026-02-09T22:12:39+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:12:39,286 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A== +2026-02-09T22:16:49+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:16:49,128 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received +2026-02-09T22:16:59+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:16:59,138 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-09T22:16:59+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:16:59,278 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'ff24b313-05c1-11f1-80c0-7e88154b8801'} +2026-02-09T22:52:43+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:52:43,125 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A== +2026-02-09T22:54:53+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:54:53,519 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A== +2026-02-09T22:55:20+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:55:20,159 INFO openclaw-dingtalk-bridge: market-brief call: subject=基于以上 +2026-02-09T22:56:04+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:56:04,918 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received +2026-02-09T22:56:04+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:56:04,918 ERROR openclaw-dingtalk-bridge: error processing message: sent 1011 (internal error) keepalive ping timeout; no close frame received +2026-02-09T22:56:14+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:56:14,928 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-09T22:56:15+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:56:15,069 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '7b4d9192-05c7-11f1-8d8a-de809d1c6511'} +2026-02-09T22:58:08+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 22:58:08,695 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidJBUfPLXWbkHAtA+jtnES9Y0ny4H4d4FlgagPuGo0iiM= +2026-02-09T23:01:22+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 23:01:22,116 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A== +2026-02-09T23:05:11+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 23:05:11,545 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A== +2026-02-09T23:06:21+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 23:06:21,414 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A== +2026-02-09T23:06:42+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 23:06:42,222 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received +2026-02-09T23:06:52+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 23:06:52,233 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-09T23:06:52+08:00 dao08-System-Product-Name python[352345]: 2026-02-09 23:06:52,374 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'f72b29ad-05c8-11f1-884b-5a20030ba585'} +2026-02-09T23:08:17+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge... +2026-02-09T23:08:17+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully. +2026-02-09T23:08:17+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T23:08:17+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 2min 24.875s CPU time, 430.4M memory peak, 0B memory swap peak. +2026-02-09T23:08:17+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T23:08:17+08:00 dao08-System-Product-Name python[363276]: 2026-02-09 23:08:17,824 INFO openclaw-dingtalk-bridge: delegate topic disabled (default) +2026-02-09T23:08:17+08:00 dao08-System-Product-Name python[363276]: 2026-02-09 23:08:17,824 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream +2026-02-09T23:08:17+08:00 dao08-System-Product-Name python[363276]: 2026-02-09 23:08:17,825 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-09T23:08:17+08:00 dao08-System-Product-Name python[363276]: 2026-02-09 23:08:17,988 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '2a32a932-05c9-11f1-884b-5a20030ba585'} +2026-02-09T23:08:51+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge... +2026-02-09T23:08:51+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully. +2026-02-09T23:08:51+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T23:08:51+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T23:08:51+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:08:51,756 INFO openclaw-dingtalk-bridge: delegate topic disabled (default) +2026-02-09T23:08:51+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:08:51,756 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream +2026-02-09T23:08:51+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:08:51,756 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-09T23:08:51+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:08:51,914 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '3e6bc465-05c9-11f1-8d8a-de809d1c6511'} +2026-02-09T23:12:47+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:12:47,364 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A== +2026-02-09T23:17:27+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:17:27,896 INFO openclaw-dingtalk-bridge: duplicate callback ignored: key=msg:msgh7C3eoXlIiu8R6WVGDYXqA== conversation=cidZg4TLAufUGG9Axjg1sTY2A== sender=$:LWCP_v1:$vVLMdk3EzcbM0AbHu+Gt5Q== +2026-02-09T23:17:28+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:17:28,064 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A== +2026-02-09T23:17:37+08:00 dao08-System-Product-Name python[363512]: 2026-02-09 23:17:37,266 INFO openclaw-dingtalk-bridge: duplicate callback ignored: key=msg:msgmmZbADzAr1YhyOavIO6amw== conversation=cidZg4TLAufUGG9Axjg1sTY2A== sender=$:LWCP_v1:$vVLMdk3EzcbM0AbHu+Gt5Q== +2026-02-09T23:19:18+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge... +2026-02-09T23:19:18+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully. +2026-02-09T23:19:18+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T23:19:18+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 9.544s CPU time, 414.7M memory peak, 0B memory swap peak. +2026-02-09T23:19:18+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T23:19:19+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:19:19,139 INFO openclaw-dingtalk-bridge: delegate topic disabled (default) +2026-02-09T23:19:19+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:19:19,139 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream +2026-02-09T23:19:19+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:19:19,140 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-09T23:19:19+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:19:19,274 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'b45b2986-05ca-11f1-bebd-26ca3a8714ef'} +2026-02-09T23:21:25+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:21:25,894 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A== +2026-02-09T23:24:56+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:24:56,356 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A== +2026-02-09T23:26:21+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:26:21,744 INFO openclaw-dingtalk-bridge: duplicate callback ignored: key=msg:msgc8yPTi4RlHrE+NDnT673hw== conversation=cidPY1SARJgTkO29Bvb6F351A== sender=$:LWCP_v1:$vVLMdk3EzcbM0AbHu+Gt5Q== +2026-02-09T23:31:51+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:31:51,202 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A== +2026-02-09T23:38:36+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:38:36,958 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidZg4TLAufUGG9Axjg1sTY2A== +2026-02-09T23:38:59+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:38:59,471 ERROR openclaw-dingtalk-bridge: [start] network exception, error=sent 1011 (internal error) keepalive ping timeout; no close frame received +2026-02-09T23:39:09+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:39:09,473 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-09T23:39:09+08:00 dao08-System-Product-Name python[365057]: 2026-02-09 23:39:09,609 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': '79d969eb-05cd-11f1-8d8a-de809d1c6511'} +2026-02-09T23:40:57+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge... +2026-02-09T23:40:57+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully. +2026-02-09T23:40:57+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T23:40:57+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 19.556s CPU time, 433.0M memory peak, 0B memory swap peak. +2026-02-09T23:40:57+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T23:40:58+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:40:58,053 INFO openclaw-dingtalk-bridge: delegate topic disabled (default) +2026-02-09T23:40:58+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:40:58,053 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream +2026-02-09T23:40:58+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:40:58,053 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-09T23:40:58+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:40:58,214 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'ba9578cf-05cd-11f1-8bc5-6647947ab531'} +2026-02-09T23:42:42+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:42:42,320 INFO openclaw-dingtalk-bridge: session auto-rotated: base=dingtalk-cidPY1SARJgTkO29Bvb6F351A== lines=296 rotated=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602092342 +2026-02-09T23:42:42+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:42:42,320 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602092342 +2026-02-09T23:45:00+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:45:00,359 INFO openclaw-dingtalk-bridge: reply sent: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602092342 chars=938 +2026-02-09T23:45:00+08:00 dao08-System-Product-Name python[369315]: 2026-02-09 23:45:00,359 INFO openclaw-dingtalk-bridge: duplicate callback ignored: key=msg:msgK5zYDsuFOjCcFfmwOMWvXQ== conversation=cidPY1SARJgTkO29Bvb6F351A== sender=$:LWCP_v1:$vVLMdk3EzcbM0AbHu+Gt5Q== +2026-02-09T23:48:47+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge... +2026-02-09T23:48:47+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully. +2026-02-09T23:48:47+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T23:48:47+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 4.965s CPU time, 2.5M memory peak, 0B memory swap peak. +2026-02-09T23:48:47+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-09T23:48:47+08:00 dao08-System-Product-Name python[371808]: 2026-02-09 23:48:47,336 INFO openclaw-dingtalk-bridge: delegate topic disabled (default) +2026-02-09T23:48:47+08:00 dao08-System-Product-Name python[371808]: 2026-02-09 23:48:47,336 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream +2026-02-09T23:48:47+08:00 dao08-System-Product-Name python[371808]: 2026-02-09 23:48:47,336 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-09T23:48:47+08:00 dao08-System-Product-Name python[371808]: 2026-02-09 23:48:47,506 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'd24bc157-05ce-11f1-80c0-7e88154b8801'} +2026-02-10T00:41:03+08:00 dao08-System-Product-Name python[371808]: 2026-02-10 00:41:03,674 INFO openclaw-dingtalk-bridge: session auto-rotated: base=dingtalk-cidPY1SARJgTkO29Bvb6F351A== lines=296 rotated=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602100041 +2026-02-10T00:41:03+08:00 dao08-System-Product-Name python[371808]: 2026-02-10 00:41:03,674 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602100041 +2026-02-10T00:42:19+08:00 dao08-System-Product-Name python[371808]: 2026-02-10 00:42:19,519 INFO openclaw-dingtalk-bridge: reply sent: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602100041 chars=1197 +2026-02-10T00:42:19+08:00 dao08-System-Product-Name python[371808]: 2026-02-10 00:42:19,520 INFO openclaw-dingtalk-bridge: duplicate callback ignored: key=msg:msgviGW4x1TkcCrGfqVg4kyHA== conversation=cidPY1SARJgTkO29Bvb6F351A== sender=$:LWCP_v1:$vVLMdk3EzcbM0AbHu+Gt5Q== +2026-02-10T00:45:49+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge... +2026-02-10T00:45:49+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully. +2026-02-10T00:45:49+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-10T00:45:49+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 5.107s CPU time. +2026-02-10T00:45:49+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-10T00:45:50+08:00 dao08-System-Product-Name python[380335]: 2026-02-10 00:45:50,094 INFO openclaw-dingtalk-bridge: delegate topic disabled (default) +2026-02-10T00:45:50+08:00 dao08-System-Product-Name python[380335]: 2026-02-10 00:45:50,094 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream +2026-02-10T00:45:50+08:00 dao08-System-Product-Name python[380335]: 2026-02-10 00:45:50,094 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-10T00:45:50+08:00 dao08-System-Product-Name python[380335]: 2026-02-10 00:45:50,227 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'ca672ec1-05d6-11f1-80c0-7e88154b8801'} +2026-02-10T00:52:36+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge... +2026-02-10T00:52:36+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully. +2026-02-10T00:52:36+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-10T00:52:36+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-10T00:52:36+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:52:36,913 INFO openclaw-dingtalk-bridge: delegate topic disabled (default) +2026-02-10T00:52:36+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:52:36,913 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream +2026-02-10T00:52:36+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:52:36,914 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-10T00:52:37+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:52:37,075 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'bce5bf3b-05d7-11f1-8bc5-6647947ab531'} +2026-02-10T00:53:13+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:53:13,622 INFO openclaw-dingtalk-bridge: session auto-rotated: base=dingtalk-cidPY1SARJgTkO29Bvb6F351A== lines=296 rotated=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602100053 +2026-02-10T00:53:13+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:53:13,622 INFO openclaw-dingtalk-bridge: openclaw call: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602100053 +2026-02-10T00:53:38+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:53:38,789 ERROR dingtalk_stream.card_replier: CardReplier.create_and_send_card failed, create card instance failed, error=403 Client Error: Forbidden for url: https://api.dingtalk.com/v1.0/card/instances, response.text={"code":"Forbidden.AccessDenied.AccessTokenPermissionDenied","requestid":"25E39354-5AEE-70E0-BEB3-AB85E5BCC784","message":"应用尚未开通所需的权限:[Card.Instance.Write],点击链接申请并开通即可:https://open-dev.dingtalk.com/appscope/apply?content=ding3oaumxwg7sol5stc%23Card.Instance.Write","accessdenieddetail":{"requiredScopes":["Card.Instance.Write"]}} +2026-02-10T00:53:38+08:00 dao08-System-Product-Name python[381276]: 2026-02-10 00:53:38,790 INFO openclaw-dingtalk-bridge: reply sent: session=dingtalk-cidPY1SARJgTkO29Bvb6F351A==-202602100053 chars=847 +2026-02-10T00:59:35+08:00 dao08-System-Product-Name systemd[1]: Stopping openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge... +2026-02-10T00:59:35+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Deactivated successfully. +2026-02-10T00:59:35+08:00 dao08-System-Product-Name systemd[1]: Stopped openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-10T00:59:35+08:00 dao08-System-Product-Name systemd[1]: openclaw-dingtalk-bridge.service: Consumed 5.037s CPU time. +2026-02-10T00:59:35+08:00 dao08-System-Product-Name systemd[1]: Started openclaw-dingtalk-bridge.service - OpenClaw DingTalk Bridge. +2026-02-10T00:59:35+08:00 dao08-System-Product-Name python[382195]: 2026-02-10 00:59:35,206 INFO openclaw-dingtalk-bridge: delegate topic disabled (default) +2026-02-10T00:59:35+08:00 dao08-System-Product-Name python[382195]: 2026-02-10 00:59:35,206 INFO openclaw-dingtalk-bridge: bridge started, listening DingTalk stream +2026-02-10T00:59:35+08:00 dao08-System-Product-Name python[382195]: 2026-02-10 00:59:35,206 INFO openclaw-dingtalk-bridge: open connection, url=https://api.dingtalk.com/v1.0/gateway/connections/open +2026-02-10T00:59:35+08:00 dao08-System-Product-Name python[382195]: 2026-02-10 00:59:35,367 INFO openclaw-dingtalk-bridge: endpoint is {'endpoint': 'wss://wss-open-connection-union.dingtalk.com:443/connect', 'ticket': 'b6382dab-05d8-11f1-bebd-26ca3a8714ef'} diff --git a/remote_sync/company-db b/remote_sync/company-db new file mode 100644 index 0000000..5dd7a7b --- /dev/null +++ b/remote_sync/company-db @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +VENV="/root/.openclaw/.venv-db" +TOOL="/root/.openclaw/workspace/sqlserver/company_db_tool.py" + +if [ ! -x "$VENV/bin/python" ]; then + echo "missing python runtime: $VENV/bin/python" >&2 + exit 1 +fi + +if [ ! -f "$TOOL" ]; then + echo "missing tool script: $TOOL" >&2 + exit 1 +fi + +exec "$VENV/bin/python" "$TOOL" "$@" diff --git a/remote_sync/company_db_tool.py b/remote_sync/company_db_tool.py new file mode 100644 index 0000000..353147f --- /dev/null +++ b/remote_sync/company_db_tool.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +import argparse +import datetime as dt +import json +import os +import pathlib +import sys +from typing import Dict, List, Tuple + +import pytds + +BUSINESS_TABLES = [ + "jst_goods_profit_kpi_items_afs", + "jst_sku", + "fys_shopInfo", + "jst_order_express_details", + "fys_SkuDataLibrary_SpuConfig", + "op_operManage", + "op_TargetManagement", + "fys_Ticketing_install", + "fys_Ticketing_install_item", + "fys_jstFreightCost", + "fys_jstFreightCostDetail", + "pc_kfChatLog", +] + +ENV_FILE = "/root/.openclaw/credentials/company-sql.env" + + +def load_env_file(path: str) -> None: + p = pathlib.Path(path) + if not p.exists(): + return + for raw in p.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, val = line.split("=", 1) + key = key.strip() + val = val.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = val + + +def conn_params() -> Dict[str, object]: + load_env_file(ENV_FILE) + return { + "server": os.environ.get("COMPANY_DB_HOST", "218.93.191.16"), + "port": int(os.environ.get("COMPANY_DB_PORT", "1433")), + "database": os.environ.get("COMPANY_DB_NAME", "JSDataMiddlePlatform"), + "user": os.environ.get("COMPANY_DB_USER", "nanting"), + "password": os.environ.get("COMPANY_DB_PASSWORD", ""), + "login_timeout": 15, + "timeout": 300, + } + + +def get_conn(): + params = conn_params() + if not params["password"]: + raise RuntimeError("COMPANY_DB_PASSWORD is empty") + return pytds.connect(**params) + + +def _fmt(value): + if isinstance(value, dt.datetime): + return value.isoformat(sep=" ") + if isinstance(value, (dt.date, dt.time)): + return value.isoformat() + return value + + +def print_rows(columns: List[str], rows: List[Tuple[object, ...]], truncated: bool = False) -> None: + print("COLUMNS:", ", ".join(columns)) + if not rows: + print("ROWS: 0") + return + if truncated: + print(f"ROWS_SHOWN: {len(rows)} (truncated)") + else: + print(f"ROWS: {len(rows)}") + for row in rows: + obj = {columns[i]: _fmt(row[i]) for i in range(len(columns))} + print(json.dumps(obj, ensure_ascii=False, default=str)) + + +def split_go_batches(sql_text: str) -> List[str]: + batches: List[str] = [] + current: List[str] = [] + for line in sql_text.splitlines(): + if line.strip().upper() == "GO": + block = "\n".join(current).strip() + if block: + batches.append(block) + current = [] + else: + current.append(line) + block = "\n".join(current).strip() + if block: + batches.append(block) + return batches + + +def fetch_with_limit(cursor, row_limit: int) -> Tuple[List[Tuple[object, ...]], bool]: + if row_limit <= 0: + return cursor.fetchall(), False + + rows: List[Tuple[object, ...]] = [] + truncated = False + while len(rows) <= row_limit: + batch = cursor.fetchmany(256) + if not batch: + break + rows.extend(batch) + if len(rows) > row_limit: + rows = rows[:row_limit] + truncated = True + break + return rows, truncated + + +def run_sql(sql_text: str, row_limit: int) -> int: + batches = split_go_batches(sql_text) + with get_conn() as conn: + cur = conn.cursor() + set_index = 0 + for batch in batches: + cur.execute(batch) + while True: + if cur.description: + set_index += 1 + cols = [c[0] for c in cur.description] + rows, truncated = fetch_with_limit(cur, row_limit) + print(f"RESULT_SET {set_index}") + print_rows(cols, rows, truncated=truncated) + if not cur.nextset(): + break + cur.close() + return set_index + + +def cmd_health(_args: argparse.Namespace) -> int: + with get_conn() as conn: + cur = conn.cursor() + cur.execute("SELECT @@VERSION AS version, DB_NAME() AS db_name, GETDATE() AS server_time") + cols = [c[0] for c in cur.description] + row = cur.fetchone() + data = {cols[i]: _fmt(row[i]) for i in range(len(cols))} + print(json.dumps(data, ensure_ascii=False, default=str, indent=2)) + cur.close() + return 0 + + +def cmd_tables(_args: argparse.Namespace) -> int: + names = ", ".join([f"'{t}'" for t in BUSINESS_TABLES]) + sql = f""" + SELECT c.TABLE_NAME, COUNT(*) AS column_count + FROM INFORMATION_SCHEMA.COLUMNS c + WHERE c.TABLE_SCHEMA='dbo' + AND c.TABLE_NAME IN ({names}) + GROUP BY c.TABLE_NAME + ORDER BY c.TABLE_NAME + """ + with get_conn() as conn: + cur = conn.cursor() + cur.execute(sql) + rows = cur.fetchall() + cols = [c[0] for c in cur.description] + print_rows(cols, rows) + cur.close() + return 0 + + +def cmd_columns(args: argparse.Namespace) -> int: + table = args.table + sql = f""" + SELECT + COLUMN_NAME, + DATA_TYPE, + CHARACTER_MAXIMUM_LENGTH, + NUMERIC_PRECISION, + NUMERIC_SCALE, + IS_NULLABLE, + ORDINAL_POSITION + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA='dbo' AND TABLE_NAME='{table}' + ORDER BY ORDINAL_POSITION + """ + with get_conn() as conn: + cur = conn.cursor() + cur.execute(sql) + rows = cur.fetchall() + cols = [c[0] for c in cur.description] + print_rows(cols, rows) + cur.close() + return 0 + + +def _collect_schema() -> Dict[str, List[Tuple[object, ...]]]: + names = ", ".join([f"'{t}'" for t in BUSINESS_TABLES]) + sql = f""" + SELECT + TABLE_NAME, + COLUMN_NAME, + DATA_TYPE, + CHARACTER_MAXIMUM_LENGTH, + NUMERIC_PRECISION, + NUMERIC_SCALE, + IS_NULLABLE, + ORDINAL_POSITION + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA='dbo' + AND TABLE_NAME IN ({names}) + ORDER BY TABLE_NAME, ORDINAL_POSITION + """ + with get_conn() as conn: + cur = conn.cursor() + cur.execute(sql) + rows = cur.fetchall() + cur.close() + by_table: Dict[str, List[Tuple[object, ...]]] = {t: [] for t in BUSINESS_TABLES} + for row in rows: + by_table[str(row[0])].append(row) + return by_table + + +def cmd_catalog(args: argparse.Namespace) -> int: + by_table = _collect_schema() + now = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + out = pathlib.Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + + lines: List[str] = [] + lines.append("# 公司销售/售后数据库字段与维度目录") + lines.append("") + lines.append(f"> 自动生成时间: {now}") + lines.append("> 数据源: SQLServer / JSDataMiddlePlatform / dbo") + lines.append("> 适用范围: 公司销售数据、售后数据、店铺运营、物流运费、安装工单、客服问题") + lines.append("") + lines.append("## 一、强制路由规则(给AI)") + lines.append("") + lines.append("- 凡是涉及 `销售/业绩/售后/退款/费比/店铺业绩` 的需求,必须走数据库查询,禁止走旧业绩网页。") + lines.append("- 先查字段目录再写 SQL:`/root/.openclaw/workspace/outputs/company-sales-db-catalog.md`") + lines.append("- 优先复用周报模板:`/root/.openclaw/workspace/sqlserver/last_week_afs_report.sql`") + lines.append("- 输出结论必须标注:`时间范围 + 使用表 + 关键字段 + 过滤条件`。") + lines.append("") + lines.append("## 二、可查询业务维度(语义层)") + lines.append("") + dimension_rows = [ + ("订单维度", "订单号、子订单号、内部订单ID、订单状态、售后状态", "jst_order_express_details, jst_goods_profit_kpi_items_afs"), + ("商品维度", "SKU、SPU、货号、商品名称、规格、类目、品牌", "jst_sku, op_TargetManagement, op_operManage, fys_SkuDataLibrary_SpuConfig"), + ("店铺维度", "店铺名称、平台、店铺编码、负责人、渠道类型", "fys_shopInfo, jst_order_express_details, jst_goods_profit_kpi_items_afs"), + ("销售金额维度", "商品金额、付款金额、实发金额、成本、毛利相关费用", "jst_goods_profit_kpi_items_afs, jst_sku"), + ("售后维度", "售后类型、售后原因、退款/退货金额、售后标签", "jst_goods_profit_kpi_items_afs, pc_kfChatLog"), + ("物流维度", "快递公司、快递单号、物流状态、运费、重量、收货地区", "jst_order_express_details, fys_jstFreightCost, fys_jstFreightCostDetail"), + ("安装工单维度", "工单状态、安装费用、结算金额、服务商", "fys_Ticketing_install, fys_Ticketing_install_item"), + ("人员维度", "运营、设计师、采购、美工、店长、客服", "op_TargetManagement, op_operManage, fys_shopInfo, fys_SkuDataLibrary_SpuConfig"), + ("时间维度", "创建时间、支付时间、发货时间、签收时间、订单完成时间", "全表通用时间字段(createdAt/createTime/pay_time/send_time 等)"), + ("客服问题维度", "聊天主次原因、问题内容、订单关联", "pc_kfChatLog"), + ] + lines.append("| 维度 | 可查询内容 | 主要来源表 |") + lines.append("|---|---|---|") + for row in dimension_rows: + lines.append(f"| {row[0]} | {row[1]} | {row[2]} |") + lines.append("") + lines.append("## 三、核心关联键(Join Key)") + lines.append("") + lines.append("- `order_id`:`so_id / undefined2 / orderNo / OrderNum / Sourceno`") + lines.append("- `inner_order_id`:`inner_order_id / undefined1 / o_id`") + lines.append("- `sku_id`:`sku_id / undefined10 / barCode`") + lines.append("- `spu`:`spu / other_1 / undefined96`") + lines.append("- `shop_name`:`shop_name / shopName / undefined8 / shopname`") + lines.append("") + lines.append("## 四、全表字段清单(真实表头)") + lines.append("") + + total_cols = 0 + for table in BUSINESS_TABLES: + rows = by_table.get(table, []) + total_cols += len(rows) + lines.append(f"### {table}({len(rows)} 列)") + lines.append("") + lines.append("| 序号 | 字段名 | 类型 | 长度/精度 | 可空 |") + lines.append("|---:|---|---|---|---|") + for r in rows: + _, col, dtype, clen, prec, scale, nullable, ord_pos = r + if clen is not None: + length = str(clen) + elif prec is not None: + if scale is not None: + length = f"{prec},{scale}" + else: + length = str(prec) + else: + length = "-" + lines.append(f"| {ord_pos} | {col} | {dtype} | {length} | {nullable} |") + lines.append("") + + lines.append("## 五、统计") + lines.append("") + lines.append(f"- 业务表数量:{len(BUSINESS_TABLES)}") + lines.append(f"- 字段总数:{total_cols}") + lines.append("- 建议:高频分析优先在订单售后主表 `jst_goods_profit_kpi_items_afs` 先聚合,再按需关联其他表,避免超时。") + + out.write_text("\n".join(lines), encoding="utf-8") + print(str(out)) + return 0 + + +def cmd_query(args: argparse.Namespace) -> int: + sql_text = None + + if args.file: + sql_text = pathlib.Path(args.file).read_text(encoding="utf-8") + elif args.sql: + sql_text = args.sql + elif args.sql_positional: + sql_text = " ".join(args.sql_positional).strip() + + if not sql_text: + raise SystemExit("query requires --sql / --file / or inline SQL text") + + set_count = run_sql(sql_text, args.limit) + print(f"TOTAL_RESULT_SETS: {set_count}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="company-db", description="Company SQLServer helper") + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser("health") + sub.add_parser("tables") + + p_cols = sub.add_parser("columns") + p_cols.add_argument("table") + + p_query = sub.add_parser("query") + p_query.add_argument("--sql", help="SQL text") + p_query.add_argument("--file", help="SQL file path") + p_query.add_argument("--limit", type=int, default=200, help="row limit via SET ROWCOUNT") + p_query.add_argument("sql_positional", nargs="*", help="inline SQL text, e.g. company-db query SELECT 1") + + p_run = sub.add_parser("run-file") + p_run.add_argument("file", help="SQL file path") + p_run.add_argument("--limit", type=int, default=0, help="max rows shown per result set (0 means no limit)") + + p_cat = sub.add_parser("catalog") + p_cat.add_argument("--out", default="/root/.openclaw/workspace/outputs/company-sales-db-catalog.md") + + return p + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + if args.cmd == "health": + return cmd_health(args) + if args.cmd == "tables": + return cmd_tables(args) + if args.cmd == "columns": + return cmd_columns(args) + if args.cmd == "query": + return cmd_query(args) + if args.cmd == "run-file": + args.sql = None + args.file = args.file + return cmd_query(args) + if args.cmd == "catalog": + return cmd_catalog(args) + + parser.print_help() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/remote_sync/customer-service-analysis b/remote_sync/customer-service-analysis new file mode 100755 index 0000000..03d92b0 --- /dev/null +++ b/remote_sync/customer-service-analysis @@ -0,0 +1,1344 @@ +#!/usr/bin/env python3 +import argparse +import ast +import datetime as dt +import json +import re +import subprocess +import sys +from collections import defaultdict +from typing import Dict, List, Tuple + +BASE_BIN = "/usr/local/bin/customer-service-analysis.base" + +GREETING_KEYWORDS = ("欢迎", "您好", "亲,", "亲,", "亲~") +EMPATHY_KEYWORDS = ("抱歉", "不好意思", "理解您", "理解你的", "辛苦了") +COMMITMENT_KEYWORDS = ("马上", "尽快", "给您处理", "为您处理", "帮您处理", "我来帮您") +CLOSING_KEYWORDS = ("感谢", "祝您", "辛苦", "生活愉快", "欢迎下次") +RISK_KEYWORDS = ("不知道", "没办法", "无法处理", "不清楚", "等不了", "太慢") + +AFTERSALE_ROLE_KEYWORDS = ( + "退货", + "退款", + "售后", + "安装", + "工单", + "物流", + "补发", + "换货", + "维修", + "催退款", + "催取件", + "拒收", + "赔付", +) + + +def _parse_date(value: str) -> dt.date: + try: + return dt.datetime.strptime(value, "%Y-%m-%d").date() + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid date '{value}', expected YYYY-MM-DD") from exc + + +def _sql_escape(value: str) -> str: + return value.replace("'", "''") + + +def _fmt(v): + if v is None: + return "" + if isinstance(v, float): + return round(v, 2) + return v + + +def _safe_pct(num: float, den: float): + if den <= 0: + return None + return round((num / den) * 100.0, 2) + + +def _score_grade(score: float) -> str: + if score >= 90: + return "S" + if score >= 80: + return "A" + if score >= 70: + return "B" + if score >= 60: + return "C" + return "D" + + +def _contains_any(text: str, keywords: Tuple[str, ...]) -> bool: + return any(k in text for k in keywords) + + +def _split_result_sets(output_text: str) -> Tuple[Dict[int, List[str]], int]: + blocks: Dict[int, List[str]] = {} + current_idx = None + current_lines: List[str] = [] + total_sets = 0 + + for line in (output_text or "").splitlines(): + if line.startswith("TOTAL_RESULT_SETS:"): + try: + total_sets = int(line.split(":", 1)[1].strip()) + except Exception: + total_sets = 0 + continue + if line.startswith("RESULT_SET "): + if current_idx is not None: + blocks[current_idx] = current_lines + try: + current_idx = int(line.split()[1]) + except Exception: + current_idx = None + current_lines = [line] if current_idx is not None else [] + continue + if current_idx is not None: + current_lines.append(line) + + if current_idx is not None: + blocks[current_idx] = current_lines + + return blocks, total_sets + + +def _extract_json_rows(output: str) -> list: + rows = [] + for line in output.splitlines(): + line = line.strip() + if not line.startswith("{") or not line.endswith("}"): + continue + try: + rows.append(json.loads(line)) + except json.JSONDecodeError: + continue + return rows + + +def _run_company_query(sql_text: str, limit: int = 100000) -> List[Dict]: + proc = subprocess.run( + ["company-db", "query", "--limit", str(limit), "--sql", sql_text], + capture_output=True, + text=True, + ) + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "company-db failed").strip() + raise RuntimeError(err) + return _extract_json_rows(proc.stdout or "") + + +def _print_result_set(index: int, columns: List[str], rows: List[Dict]) -> None: + print(f"RESULT_SET {index}") + print("COLUMNS: " + ", ".join(columns)) + print(f"ROWS: {len(rows)}") + for row in rows: + obj = {c: row.get(c) for c in columns} + print(json.dumps(obj, ensure_ascii=False)) + + +def _clean_user_label(label: str) -> str: + return (label or "").replace("\ue7f6", "").strip() + + +def _extract_agent_name(user_label: str) -> str | None: + label = _clean_user_label(user_label) + if "-->" in label: + return None + if ":" not in label: + return None + + prefix, name = label.rsplit(":", 1) + prefix = prefix.strip() + name = name.strip() + if not prefix or not name: + return None + if len(name) > 40: + return None + if "店" not in prefix and "客服" not in prefix: + return None + return name + + +def _parse_dialog_entries(content_text: str) -> list[tuple[str, str]]: + text = (content_text or "").strip() + if not text or text == "[]": + return [] + + entries: list[tuple[str, str]] = [] + try: + data = ast.literal_eval(text) + if isinstance(data, list): + for item in data: + if isinstance(item, dict): + entries.append((str(item.get("user", "")), str(item.get("message", "")))) + if entries: + return entries + except Exception: + pass + + pattern = re.compile(r"'user':\s*'([^']*)'.*?'message':\s*'([^']*)'", re.S) + for user, message in pattern.findall(text): + entries.append((user, message)) + return entries + + +def _classify_role(main_reason: str, second_reason: str, third_reason: str) -> str: + text = f"{main_reason or ''}|{second_reason or ''}|{third_reason or ''}" + if _contains_any(text, AFTERSALE_ROLE_KEYWORDS): + return "售后" + return "售前" + + +def _timeliness_score(avg_submit_to_kf_hours): + if avg_submit_to_kf_hours is None: + return 60.0 + h = float(avg_submit_to_kf_hours) + if h <= 4: + return 100.0 + if h <= 12: + return 90.0 + if h <= 24: + return 80.0 + if h <= 48: + return 70.0 + if h <= 72: + return 60.0 + return 40.0 + + +def _to_float(value): + if value is None: + return None + if isinstance(value, (int, float)): + return float(value) + text = str(value).strip().replace(',', '') + if text == '' or text.lower() in ('none', 'null', 'nan'): + return None + if text.endswith('%'): + text = text[:-1] + try: + return float(text) + except Exception: + return None + + +def _to_int(value): + v = _to_float(value) + if v is None: + return 0 + return int(round(v)) + + +def _to_pct(value): + v = _to_float(value) + if v is None: + return None + if v <= 1.2: + v = v * 100.0 + return max(0.0, min(100.0, v)) + + +def _normalize_shop_name(name: str) -> str: + t = (name or '').strip().lower() + t = t.replace(' ', '').replace(' ', '') + for p in ('天猫-', '淘宝-', '京东-', '京喜-', '拼多多-', '抖音-', '阿里巴巴-', '唯品会-', '得物-', '快手-'): + if t.startswith(p): + t = t[len(p):] + t = t.replace('-', '').replace('_', '') + return t + + +def _normalize_agent_name(name: str) -> str: + t = (name or '').strip().lower() + t = t.replace(' ', '').replace(' ', '') + t = re.sub(r'^\d+号', '', t) + t = re.sub(r'(师傅|客服|专员|老师)$', '', t) + return t + + +def _normalize_role_text(value: str) -> str: + t = (value or '').strip() + if '售后' in t: + return '售后' + if '售前' in t: + return '售前' + return '' + + +def _expand_agent_aliases(name: str) -> List[str]: + raw = (name or '').strip() + if not raw: + return [] + + raw_candidates = {raw} + for sep in (':', ':'): + if sep in raw: + parts = [x.strip() for x in raw.split(sep) if x and x.strip()] + if parts: + raw_candidates.add(parts[-1]) + + norm_set = set() + for item in raw_candidates: + cleaned = re.sub(r'[((].*?[))]', '', item).strip() + if not cleaned: + continue + + normalized = _normalize_agent_name(cleaned) + if not normalized: + continue + + norm_set.add(normalized) + norm_set.add(re.sub(r'\d+$', '', normalized)) + + no_prefix = re.sub(r'^搭配师', '', normalized) + norm_set.add(no_prefix) + norm_set.add(re.sub(r'\d+$', '', no_prefix)) + + norm_set.discard('') + return sorted(norm_set) + + +def _parse_month_key(month_text: str) -> int: + if not month_text: + return 0 + s = str(month_text) + m = re.search(r'(20\d{2})-(\d{1,2})', s) + if m: + y = int(m.group(1)) + mm = int(m.group(2)) + if 1 <= mm <= 12: + return y * 100 + mm + m = re.search(r'(\d{2,4})年(\d{1,2})月', s) + if m: + y = int(m.group(1)) + if y < 100: + y += 2000 + mm = int(m.group(2)) + if 1 <= mm <= 12: + return y * 100 + mm + return 0 + + +def _build_external_conversion_sql(shop: str) -> str: + shop_escaped = _sql_escape(shop.strip()) + return f""" +USE [JSDataMiddlePlatform]; +SET NOCOUNT ON; +DECLARE @shop nvarchar(100) = N'{shop_escaped}'; +SELECT + [月份] AS month_text, + [店铺名称] AS shop_name, + [客服昵称] AS agent_name, + [询单人数] AS inquiry_count_text, + [当日或次日付款人数] AS paid_next_count_text, + [最终付款人数] AS paid_final_count_text, + [询单->次日付款转化率] AS conv_next_text, + [询单->最终付款转化率] AS conv_final_text +FROM dbo.cs_conversion_rate WITH (NOLOCK) +WHERE (@shop = N'' OR [店铺名称] LIKE N'%' + @shop + N'%'); +""" + + +def _build_external_satisfaction_sql(shop: str) -> str: + shop_escaped = _sql_escape(shop.strip()) + return f""" +USE [JSDataMiddlePlatform]; +SET NOCOUNT ON; +DECLARE @shop nvarchar(100) = N'{shop_escaped}'; +SELECT + [月份] AS month_text, + [店铺名称] AS shop_name, + [客服昵称] AS agent_name, + [聊天人次(原咨询人次)] AS chat_count_text, + [评价展示量] AS eval_show_text, + [评价参与量] AS eval_join_text, + [客户满意率] AS customer_satisfaction_text, + [服务满意度评分] AS service_score_text +FROM dbo.cs_satisfaction WITH (NOLOCK) +WHERE (@shop = N'' OR [店铺名称] LIKE N'%' + @shop + N'%'); +""" + + +def _build_external_roster_sql(shop: str) -> str: + shop_escaped = _sql_escape(shop.strip()) + return f""" +USE [JSDataMiddlePlatform]; +SET NOCOUNT ON; +DECLARE @shop nvarchar(100) = N'{shop_escaped}'; +SELECT + [店铺] AS shop_name, + [岗位] AS position_role, + [账号名] AS account_name, + [赤兔展示名] AS display_name, + [花名] AS nick_name, + [姓名] AS real_name +FROM dbo.cs_roster WITH (NOLOCK) +WHERE (@shop = N'' OR [店铺] LIKE N'%' + @shop + N'%'); +""" + + +def _roster_entry_score(entry: Dict) -> Tuple[int, int]: + return ( + 1 if entry.get('position_role') else 0, + len(entry.get('alias_norms') or []), + ) + + +def _load_cs_roster(shop: str) -> Dict: + rows = _run_company_query(_build_external_roster_sql(shop), limit=100000) + by_shop_alias: Dict[Tuple[str, str], Dict] = {} + by_alias: Dict[str, Dict] = {} + + for r in rows: + shop_name = str(r.get('shop_name') or '') + position_role = _normalize_role_text(str(r.get('position_role') or '')) + account_name = str(r.get('account_name') or '').strip() + display_name = str(r.get('display_name') or '').strip() + nick_name = str(r.get('nick_name') or '').strip() + real_name = str(r.get('real_name') or '').strip() + + alias_norms = set() + for raw in (account_name, display_name, nick_name, real_name): + for alias in _expand_agent_aliases(raw): + alias_norms.add(alias) + + if not alias_norms: + continue + + entry = { + 'shop_name': shop_name, + 'shop_norm': _normalize_shop_name(shop_name), + 'position_role': position_role, + 'account_name': account_name, + 'display_name': display_name, + 'nick_name': nick_name, + 'real_name': real_name, + 'alias_norms': sorted(alias_norms), + } + + for alias in alias_norms: + k = (entry['shop_norm'], alias) + cur = by_shop_alias.get(k) + if cur is None or _roster_entry_score(entry) > _roster_entry_score(cur): + by_shop_alias[k] = entry + + cur_alias = by_alias.get(alias) + if cur_alias is None or _roster_entry_score(entry) > _roster_entry_score(cur_alias): + by_alias[alias] = entry + + return { + 'by_shop_alias': by_shop_alias, + 'by_alias': by_alias, + } + + +def _pick_roster_entry(roster_index: Dict, shop_name: str, agent_name: str) -> Dict: + if not roster_index: + return {} + + ns = _normalize_shop_name(shop_name) + alias_list = _expand_agent_aliases(agent_name) + by_shop_alias = roster_index.get('by_shop_alias') or {} + by_alias = roster_index.get('by_alias') or {} + + for alias in alias_list: + hit = by_shop_alias.get((ns, alias)) + if hit: + return hit + + fuzzy_candidates = [] + for alias in alias_list: + for (shop_norm, alias_norm), hit in by_shop_alias.items(): + if alias_norm != alias: + continue + if shop_norm == ns or (shop_norm and ns and (shop_norm in ns or ns in shop_norm)): + fuzzy_candidates.append(hit) + + if fuzzy_candidates: + fuzzy_candidates.sort(key=_roster_entry_score, reverse=True) + return fuzzy_candidates[0] + + for alias in alias_list: + hit = by_alias.get(alias) + if hit: + return hit + + return {} + + +def _load_external_cs_metrics(shop: str) -> Dict[Tuple[str, str], Dict]: + conv_rows = _run_company_query(_build_external_conversion_sql(shop), limit=200000) + sat_rows = _run_company_query(_build_external_satisfaction_sql(shop), limit=200000) + + conv_best: Dict[Tuple[str, str], Dict] = {} + sat_best: Dict[Tuple[str, str], Dict] = {} + + for r in conv_rows: + key = (_normalize_shop_name(str(r.get('shop_name') or '')), _normalize_agent_name(str(r.get('agent_name') or ''))) + if not key[0] or not key[1]: + continue + mk = _parse_month_key(r.get('month_text')) + cur = conv_best.get(key) + inquiry_count = _to_int(r.get('inquiry_count_text')) + if cur is None or mk > cur['month_key'] or (mk == cur['month_key'] and inquiry_count > cur.get('inquiry_count', 0)): + conv_best[key] = { + 'month_key': mk, + 'month_text': str(r.get('month_text') or ''), + 'shop_name': str(r.get('shop_name') or ''), + 'agent_name': str(r.get('agent_name') or ''), + 'inquiry_count': inquiry_count, + 'paid_next_count': _to_int(r.get('paid_next_count_text')), + 'paid_final_count': _to_int(r.get('paid_final_count_text')), + 'conv_next_pct': _to_pct(r.get('conv_next_text')), + 'conv_final_pct': _to_pct(r.get('conv_final_text')), + } + + for r in sat_rows: + key = (_normalize_shop_name(str(r.get('shop_name') or '')), _normalize_agent_name(str(r.get('agent_name') or ''))) + if not key[0] or not key[1]: + continue + mk = _parse_month_key(r.get('month_text')) + cur = sat_best.get(key) + eval_join = _to_int(r.get('eval_join_text')) + if cur is None or mk > cur['month_key'] or (mk == cur['month_key'] and eval_join > cur.get('eval_join_count', 0)): + sat_best[key] = { + 'month_key': mk, + 'month_text': str(r.get('month_text') or ''), + 'shop_name': str(r.get('shop_name') or ''), + 'agent_name': str(r.get('agent_name') or ''), + 'chat_count': _to_int(r.get('chat_count_text')), + 'eval_show_count': _to_int(r.get('eval_show_text')), + 'eval_join_count': eval_join, + 'customer_satisfaction_pct': _to_pct(r.get('customer_satisfaction_text')), + 'service_score_5': _to_float(r.get('service_score_text')), + } + + out: Dict[Tuple[str, str], Dict] = {} + all_keys = set(conv_best.keys()) | set(sat_best.keys()) + for k in all_keys: + c = conv_best.get(k, {}) + s = sat_best.get(k, {}) + out[k] = { + 'shop_norm': k[0], + 'agent_norm': k[1], + 'month_key': max(c.get('month_key', 0), s.get('month_key', 0)), + 'month_text': c.get('month_text') or s.get('month_text') or '', + 'shop_name': c.get('shop_name') or s.get('shop_name') or '', + 'agent_name': c.get('agent_name') or s.get('agent_name') or '', + 'inquiry_count': c.get('inquiry_count', 0), + 'paid_next_count': c.get('paid_next_count', 0), + 'paid_final_count': c.get('paid_final_count', 0), + 'conv_next_pct': c.get('conv_next_pct'), + 'conv_final_pct': c.get('conv_final_pct'), + 'chat_count': s.get('chat_count', 0), + 'eval_show_count': s.get('eval_show_count', 0), + 'eval_join_count': s.get('eval_join_count', 0), + 'customer_satisfaction_pct': s.get('customer_satisfaction_pct'), + 'service_score_5': s.get('service_score_5'), + } + return out + + +def _pick_external_metric( + external: Dict[Tuple[str, str], Dict], + shop_name: str, + agent_name: str, + agent_alias_norms=None, +) -> Dict: + ns = _normalize_shop_name(shop_name) + alias_list = [] + if agent_alias_norms: + alias_list.extend([str(x) for x in agent_alias_norms if x]) + + na = _normalize_agent_name(agent_name) + if na: + alias_list.append(na) + + seen = set() + dedup_aliases = [] + for alias in alias_list: + if alias in seen: + continue + seen.add(alias) + dedup_aliases.append(alias) + + for alias in dedup_aliases: + direct = external.get((ns, alias)) + if direct: + return direct + + candidates = [ + v for (s, a), v in external.items() + if a in seen and (s == ns or (s and ns and (s in ns or ns in s))) + ] + if not candidates: + candidates = [v for (s, a), v in external.items() if a in seen] + + if not candidates: + return {} + + candidates.sort(key=lambda x: (x.get('month_key', 0), x.get('inquiry_count', 0), x.get('eval_join_count', 0)), reverse=True) + return candidates[0] + + +def _build_agent_raw_sql(start_date: dt.date, end_date: dt.date, shop: str, max_rows: int) -> str: + shop_escaped = _sql_escape(shop.strip()) + return f""" +USE [JSDataMiddlePlatform]; +SET NOCOUNT ON; + +DECLARE @start_time datetime2 = CAST('{start_date.isoformat()}' AS datetime2); +DECLARE @end_time datetime2 = DATEADD(day, 1, CAST('{end_date.isoformat()}' AS datetime2)); +DECLARE @shop nvarchar(100) = N'{shop_escaped}'; + +;WITH base AS ( + SELECT + c.id, + NULLIF(c.orderNo, '') AS order_no, + ISNULL(NULLIF(c.shopName, ''), N'未知店铺') AS shop_name, + ISNULL(NULLIF(LTRIM(RTRIM(c.mainReason)), ''), N'未标注') AS main_reason, + ISNULL(NULLIF(LTRIM(RTRIM(c.secondReason)), ''), N'未标注') AS second_reason, + ISNULL(NULLIF(LTRIM(RTRIM(c.thirdReason)), ''), N'未标注') AS third_reason, + CAST(c.content AS nvarchar(max)) AS content_text, + c.createdAt, + ROW_NUMBER() OVER (ORDER BY c.createdAt DESC, c.id DESC) AS rn, + COUNT(1) OVER () AS total_rows + FROM dbo.pc_kfChatLog AS c WITH (NOLOCK) + WHERE c.createdAt >= @start_time + AND c.createdAt < @end_time + AND (@shop = N'' OR c.shopName LIKE N'%' + @shop + N'%') +) +SELECT TOP ({max_rows}) + id, + order_no, + shop_name, + main_reason, + second_reason, + third_reason, + content_text, + total_rows +FROM base +ORDER BY rn ASC; +""" + + +def _build_order_snapshot_sql(start_date: dt.date, end_date: dt.date, shop: str) -> str: + shop_escaped = _sql_escape(shop.strip()) + return f""" +USE [JSDataMiddlePlatform]; +SET NOCOUNT ON; + +DECLARE @start_time datetime2 = CAST('{start_date.isoformat()}' AS datetime2); +DECLARE @end_time datetime2 = DATEADD(day, 1, CAST('{end_date.isoformat()}' AS datetime2)); +DECLARE @shop nvarchar(100) = N'{shop_escaped}'; + +;WITH chat_orders AS ( + SELECT DISTINCT NULLIF(c.orderNo, '') AS order_no + FROM dbo.pc_kfChatLog AS c WITH (NOLOCK) + WHERE c.createdAt >= @start_time + AND c.createdAt < @end_time + AND NULLIF(c.orderNo, '') IS NOT NULL + AND (@shop = N'' OR c.shopName LIKE N'%' + @shop + N'%') +) +SELECT + co.order_no, + CASE WHEN o.pay_time IS NOT NULL THEN 1 ELSE 0 END AS is_paid, + CASE WHEN o.send_time IS NOT NULL THEN 1 ELSE 0 END AS is_shipped, + CASE WHEN t.OrderNum IS NOT NULL THEN 1 ELSE 0 END AS has_ticket, + t.StatusId AS ticket_status, + DATEDIFF(minute, t.SubmitTime, t.kfshTime) AS submit_to_kf_minutes, + DATEDIFF(minute, t.kfshTime, t.cwshTime) AS kf_to_cw_minutes +FROM chat_orders AS co +OUTER APPLY ( + SELECT TOP 1 x.pay_time, x.send_time + FROM dbo.jst_order_express_details AS x WITH (NOLOCK) + WHERE x.so_id = co.order_no + ORDER BY x.updatedAt DESC, x.id DESC +) AS o +OUTER APPLY ( + SELECT TOP 1 i.OrderNum, i.StatusId, i.SubmitTime, i.kfshTime, i.cwshTime + FROM dbo.fys_Ticketing_install AS i WITH (NOLOCK) + WHERE i.OrderNum = co.order_no + ORDER BY i.SubmitTime DESC, i.Id DESC +) AS t; +""" + + +def _compute_role_score_rows( + start_date: dt.date, + end_date: dt.date, + shop: str, + limit: int, + agent_max_rows: int, +) -> List[Dict]: + chat_rows = _run_company_query(_build_agent_raw_sql(start_date, end_date, shop, agent_max_rows), limit=agent_max_rows) + if not chat_rows: + return [] + + total_rows = int(chat_rows[0].get("total_rows") or len(chat_rows)) + sampled_rows = len(chat_rows) + sample_scope = f"full_{sampled_rows}/{total_rows}" + if total_rows > sampled_rows: + sample_scope = f"sampled_recent_{sampled_rows}/{total_rows}" + roster_index = _load_cs_roster(shop) + + stats = defaultdict( + lambda: { + "session_set": set(), + "order_set": set(), + "primary_session_count": 0, + "message_count": 0, + "greeting_hits": 0, + "empathy_hits": 0, + "commitment_hits": 0, + "closing_hits": 0, + "risk_hits": 0, + "no_match_session_count": 0, + "missing_session_count": 0, + "role_source_roster_count": 0, + "role_source_reason_count": 0, + "roster_position_role": "", + "roster_shop_name": "", + "roster_display_name": "", + "alias_norms": set(), + } + ) + + for row in chat_rows: + dialogs = _parse_dialog_entries(row.get("content_text") or "") + if not dialogs: + continue + + main_reason = str(row.get("main_reason") or "") + second_reason = str(row.get("second_reason") or "") + third_reason = str(row.get("third_reason") or "") + role_reason = _classify_role(main_reason, second_reason, third_reason) + shop_name = str(row.get("shop_name") or "未知店铺") + order_no = (row.get("order_no") or "").strip() + session_key = order_no or f"id:{row.get('id')}" + + agent_turns = defaultdict(int) + agent_features = defaultdict(lambda: {"g": 0, "e": 0, "c": 0, "cl": 0, "r": 0, "m": 0}) + + for user_label, message in dialogs: + agent_name = _extract_agent_name(user_label) + if not agent_name: + continue + agent_turns[agent_name] += 1 + + msg = (message or "").strip() + f = agent_features[agent_name] + f["m"] += 1 + if _contains_any(msg, GREETING_KEYWORDS): + f["g"] += 1 + if _contains_any(msg, EMPATHY_KEYWORDS): + f["e"] += 1 + if _contains_any(msg, COMMITMENT_KEYWORDS): + f["c"] += 1 + if _contains_any(msg, CLOSING_KEYWORDS): + f["cl"] += 1 + if _contains_any(msg, RISK_KEYWORDS): + f["r"] += 1 + + if not agent_turns: + continue + + primary_agent = max(agent_turns.items(), key=lambda x: (x[1], x[0]))[0] + roster_entry = _pick_roster_entry(roster_index, shop_name, primary_agent) + roster_role = _normalize_role_text(str(roster_entry.get("position_role") or "")) if roster_entry else "" + role = roster_role or role_reason + key = (shop_name, primary_agent, role) + m = stats[key] + m["session_set"].add(session_key) + if order_no: + m["order_set"].add(order_no) + m["primary_session_count"] += 1 + if roster_role: + m["role_source_roster_count"] += 1 + m["roster_position_role"] = roster_role + else: + m["role_source_reason_count"] += 1 + if roster_entry: + if roster_entry.get("shop_name"): + m["roster_shop_name"] = str(roster_entry.get("shop_name")) + if roster_entry.get("display_name"): + m["roster_display_name"] = str(roster_entry.get("display_name")) + for alias in roster_entry.get("alias_norms") or []: + m["alias_norms"].add(alias) + + ff = agent_features[primary_agent] + m["message_count"] += ff["m"] + m["greeting_hits"] += ff["g"] + m["empathy_hits"] += ff["e"] + m["commitment_hits"] += ff["c"] + m["closing_hits"] += ff["cl"] + m["risk_hits"] += ff["r"] + + if main_reason == "无对应分类" or second_reason == "无对应分类": + m["no_match_session_count"] += 1 + if main_reason == "未标注" or second_reason == "未标注": + m["missing_session_count"] += 1 + + order_snapshot_rows = _run_company_query(_build_order_snapshot_sql(start_date, end_date, shop), limit=200000) + order_map: Dict[str, Dict] = {} + for r in order_snapshot_rows: + order_no = (r.get("order_no") or "").strip() + if not order_no: + continue + order_map[order_no] = { + "is_paid": int(r.get("is_paid") or 0), + "is_shipped": int(r.get("is_shipped") or 0), + "has_ticket": int(r.get("has_ticket") or 0), + "ticket_status": r.get("ticket_status"), + "submit_to_kf_minutes": r.get("submit_to_kf_minutes"), + } + + external_metrics = _load_external_cs_metrics(shop) + + result_rows: List[Dict] = [] + + for (shop_name, agent_name, role), m in stats.items(): + session_count = len(m["session_set"]) + message_count = m["message_count"] + if session_count <= 0 or message_count <= 0: + continue + + greeting_rate = m["greeting_hits"] / message_count + empathy_rate = m["empathy_hits"] / message_count + commitment_rate = m["commitment_hits"] / message_count + closing_rate = m["closing_hits"] / message_count + risk_rate = m["risk_hits"] / message_count + + no_match_rate = m["no_match_session_count"] / session_count + missing_rate = m["missing_session_count"] / session_count + + resp_score = max(0.0, min(100.0, 100.0 * (0.70 * commitment_rate + 0.30 * (1.0 - risk_rate)))) + sat_chat_score = max(0.0, min(100.0, 100.0 * (0.40 * empathy_rate + 0.35 * closing_rate + 0.25 * greeting_rate))) + risk_ctrl_score = max(0.0, min(100.0, 100.0 * (1.0 - (0.75 * no_match_rate + 0.25 * missing_rate)))) + + order_set = m["order_set"] + inquiry_order_count = len(order_set) + converted_order_count = 0 + ticket_linked_order_count = 0 + ticket_resolved_order_count = 0 + submit_to_kf_hours_list: List[float] = [] + + for order_no in order_set: + snap = order_map.get(order_no) + if not snap: + continue + if snap.get("is_paid") == 1 or snap.get("is_shipped") == 1: + converted_order_count += 1 + if snap.get("has_ticket") == 1: + ticket_linked_order_count += 1 + if str(snap.get("ticket_status")) == "2": + ticket_resolved_order_count += 1 + mins = snap.get("submit_to_kf_minutes") + try: + mins = float(mins) + except Exception: + mins = None + if mins is not None and 0 <= mins <= 43200: + submit_to_kf_hours_list.append(mins / 60.0) + + inquiry_conversion_rate_pct = _safe_pct(converted_order_count, inquiry_order_count) + ticket_resolve_rate_pct = _safe_pct(ticket_resolved_order_count, ticket_linked_order_count) + avg_submit_to_kf_hours = ( + round(sum(submit_to_kf_hours_list) / len(submit_to_kf_hours_list), 2) + if submit_to_kf_hours_list + else None + ) + + alias_norms = set(m.get("alias_norms") or set()) + for alias in _expand_agent_aliases(agent_name): + alias_norms.add(alias) + ext = _pick_external_metric(external_metrics, shop_name, agent_name, alias_norms) + ext_month_text = ext.get("month_text") if ext else "" + ext_shop_name = ext.get("shop_name") if ext else "" + ext_inquiry_count = ext.get("inquiry_count", 0) if ext else 0 + ext_paid_next_count = ext.get("paid_next_count", 0) if ext else 0 + ext_paid_final_count = ext.get("paid_final_count", 0) if ext else 0 + ext_conv_next_pct = ext.get("conv_next_pct") if ext else None + ext_conv_final_pct = ext.get("conv_final_pct") if ext else None + ext_chat_count = ext.get("chat_count", 0) if ext else 0 + ext_eval_show_count = ext.get("eval_show_count", 0) if ext else 0 + ext_eval_join_count = ext.get("eval_join_count", 0) if ext else 0 + ext_customer_satisfaction_pct = ext.get("customer_satisfaction_pct") if ext else None + ext_service_score_5 = ext.get("service_score_5") if ext else None + + sat_external_score = None + if ext_customer_satisfaction_pct is not None: + sat_external_score = max(0.0, min(100.0, float(ext_customer_satisfaction_pct))) + elif ext_service_score_5 is not None: + sat_external_score = max(0.0, min(100.0, float(ext_service_score_5) * 20.0)) + + if sat_external_score is None: + sat_final_score = sat_chat_score + satisfaction_source = "chat_lexicon" + else: + sat_final_score = 0.6 * sat_external_score + 0.4 * sat_chat_score + satisfaction_source = "cs_satisfaction+chat_lexicon" + + conversion_score = inquiry_conversion_rate_pct if inquiry_conversion_rate_pct is not None else 0.0 + conversion_source = "order_snapshot" + if ext_conv_final_pct is not None: + conversion_score = float(ext_conv_final_pct) + conversion_source = "cs_conversion_rate" + + resolution_score = ticket_resolve_rate_pct if ticket_resolve_rate_pct is not None else 60.0 + timeliness_score = _timeliness_score(avg_submit_to_kf_hours) + role_source = "cs_roster" if m["role_source_roster_count"] > 0 else "reason_inference" + roster_position_role = m["roster_position_role"] or "" + roster_shop_name = m["roster_shop_name"] or "" + roster_display_name = m["roster_display_name"] or "" + + if role == "售前": + role_score = ( + 0.20 * resp_score + + 0.25 * sat_final_score + + 0.40 * conversion_score + + 0.15 * risk_ctrl_score + ) + rule_note = "售前评分=响应20%+满意25%+询单转化40%+风险控制15%" + else: + role_score = ( + 0.15 * resp_score + + 0.20 * sat_final_score + + 0.35 * resolution_score + + 0.20 * timeliness_score + + 0.10 * risk_ctrl_score + ) + rule_note = "售后评分=响应15%+满意20%+工单闭环35%+处理时效20%+风险控制10%" + + reliable = session_count >= 10 and message_count >= 20 + + result_rows.append( + { + "shop_name": shop_name, + "agent_name": agent_name, + "position_role": role, + "role_source": role_source, + "roster_position_role": roster_position_role, + "roster_shop_name": roster_shop_name, + "roster_display_name": roster_display_name, + "session_count": session_count, + "primary_session_count": m["primary_session_count"], + "message_count": message_count, + "inquiry_order_count": inquiry_order_count, + "converted_order_count": converted_order_count, + "inquiry_conversion_rate_pct": _fmt(inquiry_conversion_rate_pct), + "ticket_linked_order_count": ticket_linked_order_count, + "ticket_resolved_order_count": ticket_resolved_order_count, + "ticket_resolve_rate_pct": _fmt(ticket_resolve_rate_pct), + "avg_submit_to_kf_hours": _fmt(avg_submit_to_kf_hours), + "external_month": ext_month_text, + "external_shop_name": ext_shop_name, + "external_inquiry_count": ext_inquiry_count, + "external_paid_next_count": ext_paid_next_count, + "external_paid_final_count": ext_paid_final_count, + "external_conv_next_pct": _fmt(ext_conv_next_pct), + "external_conv_final_pct": _fmt(ext_conv_final_pct), + "external_chat_count": ext_chat_count, + "external_eval_show_count": ext_eval_show_count, + "external_eval_join_count": ext_eval_join_count, + "external_customer_satisfaction_pct": _fmt(ext_customer_satisfaction_pct), + "external_service_score_5": _fmt(ext_service_score_5), + "resp_ability_score": round(resp_score, 2), + "satisfaction_chat_score": round(sat_chat_score, 2), + "satisfaction_external_score": _fmt(sat_external_score), + "satisfaction_score": round(sat_final_score, 2), + "conversion_score": round(conversion_score, 2), + "conversion_source": conversion_source, + "resolution_score": round(resolution_score, 2), + "timeliness_score": round(timeliness_score, 2), + "risk_control_score": round(risk_ctrl_score, 2), + "role_capability_score": round(role_score, 2), + "role_grade": _score_grade(role_score), + "is_reliable_sample": "是" if reliable else "否", + "sample_scope": sample_scope, + "satisfaction_source": satisfaction_source, + "score_rule_note": rule_note, + } + ) + + reliable_rows = [r for r in result_rows if r["is_reliable_sample"] == "是"] + ranked_rows = reliable_rows if reliable_rows else result_rows + ranked_rows.sort( + key=lambda x: ( + x["role_capability_score"], + x["session_count"], + x["message_count"], + x["agent_name"], + ), + reverse=True, + ) + return ranked_rows[:limit] + + +def _build_ticket_summary_sql(start_date: dt.date, end_date: dt.date, shop: str) -> str: + shop_escaped = _sql_escape(shop.strip()) + return f""" +USE [JSDataMiddlePlatform]; +SET NOCOUNT ON; +DECLARE @start_time datetime2 = CAST('{start_date.isoformat()}' AS datetime2); +DECLARE @end_time datetime2 = DATEADD(day, 1, CAST('{end_date.isoformat()}' AS datetime2)); +DECLARE @shop nvarchar(100) = N'{shop_escaped}'; + +;WITH chat_orders AS ( + SELECT DISTINCT + NULLIF(c.orderNo, '') AS order_no, + ISNULL(NULLIF(c.shopName, ''), N'未知店铺') AS shop_name + FROM dbo.pc_kfChatLog AS c WITH (NOLOCK) + WHERE c.createdAt >= @start_time + AND c.createdAt < @end_time + AND NULLIF(c.orderNo, '') IS NOT NULL + AND (@shop = N'' OR c.shopName LIKE N'%' + @shop + N'%') +), joined_tickets AS ( + SELECT + o.order_no, + o.shop_name, + i.StatusId, + i.SubmitTime, + i.kfshTime, + i.cwshTime, + DATEDIFF(minute, i.SubmitTime, i.kfshTime) AS submit_to_kf_minutes, + DATEDIFF(minute, i.kfshTime, i.cwshTime) AS kf_to_cw_minutes + FROM chat_orders AS o + LEFT JOIN dbo.fys_Ticketing_install AS i WITH (NOLOCK) + ON i.OrderNum = o.order_no +) +SELECT + CAST(@start_time AS date) AS start_date, + CAST(DATEADD(day, -1, @end_time) AS date) AS end_date, + (SELECT COUNT(1) FROM chat_orders) AS chat_order_count, + COUNT(DISTINCT CASE WHEN SubmitTime IS NOT NULL THEN order_no END) AS matched_ticket_orders, + COUNT(CASE WHEN SubmitTime IS NOT NULL THEN 1 END) AS matched_ticket_rows, + SUM(CASE WHEN StatusId = 2 THEN 1 ELSE 0 END) AS status_done_count, + SUM(CASE WHEN StatusId <> 2 AND StatusId IS NOT NULL THEN 1 ELSE 0 END) AS status_pending_count, + SUM(CASE WHEN SubmitTime IS NOT NULL AND kfshTime IS NULL THEN 1 ELSE 0 END) AS kf_audit_pending_count, + SUM(CASE WHEN SubmitTime IS NOT NULL AND kfshTime IS NOT NULL AND cwshTime IS NULL THEN 1 ELSE 0 END) AS cw_audit_pending_count, + CAST(AVG(CASE WHEN submit_to_kf_minutes BETWEEN 0 AND 43200 THEN submit_to_kf_minutes / 60.0 END) AS decimal(10,2)) AS avg_submit_to_kf_hours, + CAST(AVG(CASE WHEN kf_to_cw_minutes BETWEEN 0 AND 43200 THEN kf_to_cw_minutes / 60.0 END) AS decimal(10,2)) AS avg_kf_to_cw_hours +FROM joined_tickets; +""" + + +def _build_ticket_reason_sql(start_date: dt.date, end_date: dt.date, shop: str, limit: int) -> str: + shop_escaped = _sql_escape(shop.strip()) + return f""" +USE [JSDataMiddlePlatform]; +SET NOCOUNT ON; +DECLARE @start_time datetime2 = CAST('{start_date.isoformat()}' AS datetime2); +DECLARE @end_time datetime2 = DATEADD(day, 1, CAST('{end_date.isoformat()}' AS datetime2)); +DECLARE @shop nvarchar(100) = N'{shop_escaped}'; + +;WITH chat_orders AS ( + SELECT DISTINCT + NULLIF(c.orderNo, '') AS order_no, + ISNULL(NULLIF(c.shopName, ''), N'未知店铺') AS shop_name + FROM dbo.pc_kfChatLog AS c WITH (NOLOCK) + WHERE c.createdAt >= @start_time + AND c.createdAt < @end_time + AND NULLIF(c.orderNo, '') IS NOT NULL + AND (@shop = N'' OR c.shopName LIKE N'%' + @shop + N'%') +) +SELECT TOP ({limit}) + o.shop_name, + ISNULL(NULLIF(i.ErrorReason, ''), N'未填写') AS ticket_error_reason, + ISNULL(NULLIF(i.Type, ''), N'未填写') AS ticket_type, + ISNULL(CAST(i.StatusId AS varchar(20)), 'NULL') AS status_id, + COUNT(1) AS ticket_count +FROM chat_orders AS o +JOIN dbo.fys_Ticketing_install AS i WITH (NOLOCK) + ON i.OrderNum = o.order_no +GROUP BY + o.shop_name, + ISNULL(NULLIF(i.ErrorReason, ''), N'未填写'), + ISNULL(NULLIF(i.Type, ''), N'未填写'), + ISNULL(CAST(i.StatusId AS varchar(20)), 'NULL') +ORDER BY ticket_count DESC, o.shop_name ASC; +""" + + +def _build_ticket_item_sql(start_date: dt.date, end_date: dt.date, shop: str, limit: int) -> str: + shop_escaped = _sql_escape(shop.strip()) + return f""" +USE [JSDataMiddlePlatform]; +SET NOCOUNT ON; +DECLARE @start_time datetime2 = CAST('{start_date.isoformat()}' AS datetime2); +DECLARE @end_time datetime2 = DATEADD(day, 1, CAST('{end_date.isoformat()}' AS datetime2)); +DECLARE @shop nvarchar(100) = N'{shop_escaped}'; + +;WITH chat_orders AS ( + SELECT DISTINCT + NULLIF(c.orderNo, '') AS order_no, + ISNULL(NULLIF(c.shopName, ''), N'未知店铺') AS shop_name + FROM dbo.pc_kfChatLog AS c WITH (NOLOCK) + WHERE c.createdAt >= @start_time + AND c.createdAt < @end_time + AND NULLIF(c.orderNo, '') IS NOT NULL + AND (@shop = N'' OR c.shopName LIKE N'%' + @shop + N'%') +) +SELECT TOP ({limit}) + o.shop_name, + ISNULL(NULLIF(ii.remark, ''), N'未填写') AS install_item_remark, + ISNULL(NULLIF(ii.scmname, ''), N'未填写') AS install_provider, + COUNT(1) AS item_count, + COUNT(DISTINCT ii.Sourceno) AS order_count +FROM chat_orders AS o +JOIN dbo.fys_Ticketing_install_item AS ii WITH (NOLOCK) + ON ii.Sourceno = o.order_no +GROUP BY + o.shop_name, + ISNULL(NULLIF(ii.remark, ''), N'未填写'), + ISNULL(NULLIF(ii.scmname, ''), N'未填写') +ORDER BY item_count DESC, order_count DESC; +""" + + +def _build_platform_snapshot_sql(start_date: dt.date, end_date: dt.date, shop: str) -> str: + shop_escaped = _sql_escape(shop.strip()) + return f""" +USE [JSDataMiddlePlatform]; +SET NOCOUNT ON; +DECLARE @start_date date = '{start_date.isoformat()}'; +DECLARE @end_date date = '{end_date.isoformat()}'; +DECLARE @shop nvarchar(100) = N'{shop_escaped}'; + +WITH src AS ( + SELECT + 'jd_customerServiceScore' AS metric_source, + TRY_CONVERT(date, REPLACE(NULLIF([date], ''), '/', '-')) AS metric_date, + ISNULL(NULLIF(shopName, ''), N'未知店铺') AS shop_name + FROM dbo.jd_customerServiceScore WITH (NOLOCK) + WHERE (@shop = N'' OR shopName LIKE N'%' + @shop + N'%') + + UNION ALL + + SELECT + 'jd_customerServiceDuration', + TRY_CONVERT(date, REPLACE(NULLIF([date], ''), '/', '-')), + ISNULL(NULLIF(shopName, ''), N'未知店铺') + FROM dbo.jd_customerServiceDuration WITH (NOLOCK) + WHERE (@shop = N'' OR shopName LIKE N'%' + @shop + N'%') + + UNION ALL + + SELECT + 'pdd_DataCustomerService', + TRY_CONVERT(date, REPLACE(NULLIF(createTime, ''), '/', '-')), + ISNULL(NULLIF(shopName, ''), N'未知店铺') + FROM dbo.pdd_DataCustomerService WITH (NOLOCK) + WHERE (@shop = N'' OR shopName LIKE N'%' + @shop + N'%') + + UNION ALL + + SELECT + 'pdd_MallServiceScore', + COALESCE( + TRY_CONVERT(date, REPLACE(NULLIF(createTime, ''), '/', '-')), + TRY_CONVERT(date, createdAt) + ), + ISNULL(NULLIF(shopName, ''), N'未知店铺') + FROM dbo.pdd_MallServiceScore WITH (NOLOCK) + WHERE (@shop = N'' OR shopName LIKE N'%' + @shop + N'%') +) +SELECT + metric_source, + COUNT(1) AS rows_total, + MIN(metric_date) AS min_metric_date, + MAX(metric_date) AS max_metric_date, + SUM(CASE WHEN metric_date >= @start_date AND metric_date <= @end_date THEN 1 ELSE 0 END) AS rows_in_query_range, + CASE + WHEN MAX(metric_date) IS NULL THEN '无数据' + WHEN DATEDIFF(day, MAX(metric_date), @end_date) <= 30 THEN '新鲜(<=30天)' + WHEN DATEDIFF(day, MAX(metric_date), @end_date) <= 180 THEN '可参考(31-180天)' + ELSE '过期(>180天)' + END AS freshness_flag +FROM src +GROUP BY metric_source +ORDER BY metric_source ASC; +""" + + +def main() -> int: + today = dt.date.today() + default_start = today - dt.timedelta(days=6) + + parser = argparse.ArgumentParser(description="客服用户反馈体验/话术分析(增强版,含售前售后分岗评分)") + parser.add_argument("--start", type=_parse_date, default=default_start, help="开始日期 YYYY-MM-DD(默认近7天)") + parser.add_argument("--end", type=_parse_date, default=today, help="结束日期 YYYY-MM-DD(默认今天)") + parser.add_argument("--shop", default="", help="店铺关键词,可选") + parser.add_argument("--limit", type=int, default=20, help="每个结果集TOP条数") + parser.add_argument("--agent-max-rows", type=int, default=15000, help="客服评分阶段最多读取会话条数") + args = parser.parse_args() + + if args.start > args.end: + print("start date must be <= end date", file=sys.stderr) + return 2 + + if (args.end - args.start).days > 366: + print("date range too large, please keep within 367 days", file=sys.stderr) + return 2 + + limit = max(1, min(args.limit, 200)) + agent_max_rows = max(1000, min(args.agent_max_rows, 60000)) + + base_cmd = [ + BASE_BIN, + "--start", + args.start.isoformat(), + "--end", + args.end.isoformat(), + "--limit", + str(limit), + "--agent-max-rows", + str(agent_max_rows), + ] + if args.shop: + base_cmd.extend(["--shop", args.shop]) + + base_proc = subprocess.run(base_cmd, capture_output=True, text=True) + if base_proc.returncode != 0: + err = (base_proc.stderr or base_proc.stdout or "base analysis failed").strip() + print(err, file=sys.stderr) + return base_proc.returncode + + base_blocks, _ = _split_result_sets(base_proc.stdout or "") + + # Keep base result sets 1..8, replace legacy set9 with role-split score set + for idx in sorted(base_blocks.keys()): + if idx <= 8: + print("\n".join(base_blocks[idx])) + + next_idx = 9 + + try: + role_rows = _compute_role_score_rows( + start_date=args.start, + end_date=args.end, + shop=args.shop, + limit=limit, + agent_max_rows=agent_max_rows, + ) + _print_result_set( + next_idx, + [ + "shop_name", + "agent_name", + "position_role", + "role_source", + "roster_position_role", + "roster_shop_name", + "roster_display_name", + "session_count", + "primary_session_count", + "message_count", + "inquiry_order_count", + "converted_order_count", + "inquiry_conversion_rate_pct", + "ticket_linked_order_count", + "ticket_resolved_order_count", + "ticket_resolve_rate_pct", + "avg_submit_to_kf_hours", + "external_month", + "external_shop_name", + "external_inquiry_count", + "external_paid_next_count", + "external_paid_final_count", + "external_conv_next_pct", + "external_conv_final_pct", + "external_chat_count", + "external_eval_show_count", + "external_eval_join_count", + "external_customer_satisfaction_pct", + "external_service_score_5", + "resp_ability_score", + "satisfaction_chat_score", + "satisfaction_external_score", + "satisfaction_score", + "satisfaction_source", + "conversion_score", + "conversion_source", + "resolution_score", + "timeliness_score", + "risk_control_score", + "role_capability_score", + "role_grade", + "is_reliable_sample", + "sample_scope", + "score_rule_note", + ], + role_rows, + ) + except Exception as e: + _print_result_set(next_idx, ["error"], [{"error": f"分岗评分计算失败: {e}"}]) + next_idx += 1 + + try: + summary_rows = _run_company_query(_build_ticket_summary_sql(args.start, args.end, args.shop)) + reason_rows = _run_company_query(_build_ticket_reason_sql(args.start, args.end, args.shop, limit)) + item_rows = _run_company_query(_build_ticket_item_sql(args.start, args.end, args.shop, limit)) + platform_rows = _run_company_query(_build_platform_snapshot_sql(args.start, args.end, args.shop)) + + _print_result_set( + next_idx, + [ + "start_date", + "end_date", + "chat_order_count", + "matched_ticket_orders", + "matched_ticket_rows", + "status_done_count", + "status_pending_count", + "kf_audit_pending_count", + "cw_audit_pending_count", + "avg_submit_to_kf_hours", + "avg_kf_to_cw_hours", + ], + summary_rows, + ) + next_idx += 1 + + _print_result_set( + next_idx, + ["shop_name", "ticket_error_reason", "ticket_type", "status_id", "ticket_count"], + reason_rows, + ) + next_idx += 1 + + _print_result_set( + next_idx, + ["shop_name", "install_item_remark", "install_provider", "item_count", "order_count"], + item_rows, + ) + next_idx += 1 + + _print_result_set( + next_idx, + [ + "metric_source", + "rows_total", + "min_metric_date", + "max_metric_date", + "rows_in_query_range", + "freshness_flag", + ], + platform_rows, + ) + next_idx += 1 + + except Exception as e: + _print_result_set(next_idx, ["error"], [{"error": f"增强结果集计算失败: {e}"}]) + next_idx += 1 + + print(f"TOTAL_RESULT_SETS: {next_idx - 1}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/remote_sync/customer_service_experience_report.sql b/remote_sync/customer_service_experience_report.sql new file mode 100644 index 0000000..f9a6e5c --- /dev/null +++ b/remote_sync/customer_service_experience_report.sql @@ -0,0 +1,274 @@ +USE [JSDataMiddlePlatform]; +SET NOCOUNT ON; + +DECLARE @start_date date = DATEADD(day, -29, CAST(GETDATE() AS date)); +DECLARE @end_date date = CAST(GETDATE() AS date); +DECLARE @start_time datetime2 = CAST(@start_date AS datetime2); +DECLARE @end_time datetime2 = DATEADD(day, 1, CAST(@end_date AS datetime2)); +DECLARE @shop nvarchar(100) = N''; -- 可填店铺关键词,例如 N'亿家达' +DECLARE @limit int = 20; + +IF OBJECT_ID('tempdb..#chat_base') IS NOT NULL + DROP TABLE #chat_base; + +SELECT + c.id, + NULLIF(c.orderNo, '') AS order_no, + ISNULL(NULLIF(c.shopName, ''), N'未知店铺') AS shop_name, + c.createdAt, + c.updatedAt, + c.state, + ISNULL(NULLIF(LTRIM(RTRIM(c.mainReason)), ''), N'未标注') AS main_reason, + ISNULL(NULLIF(LTRIM(RTRIM(c.secondReason)), ''), N'未标注') AS second_reason, + ISNULL(NULLIF(LTRIM(RTRIM(c.thirdReason)), ''), N'未标注') AS third_reason, + CAST(c.content AS nvarchar(max)) AS content_text, + CAST(c.reasonContent AS nvarchar(max)) AS reason_text +INTO #chat_base +FROM dbo.pc_kfChatLog AS c WITH (NOLOCK) +WHERE c.createdAt >= @start_time + AND c.createdAt < @end_time + AND (@shop = N'' OR c.shopName LIKE N'%' + @shop + N'%'); + +IF OBJECT_ID('tempdb..#chat_norm') IS NOT NULL + DROP TABLE #chat_norm; + +SELECT + b.*, + CASE WHEN b.content_text IS NULL OR LTRIM(RTRIM(b.content_text)) IN (N'', N'[]') THEN 1 ELSE 0 END AS is_empty_content, + CASE WHEN b.main_reason = N'未标注' OR b.second_reason = N'未标注' THEN 1 ELSE 0 END AS is_missing_reason, + CASE WHEN b.main_reason = N'无对应分类' OR b.second_reason = N'无对应分类' THEN 1 ELSE 0 END AS is_no_match_reason, + CASE WHEN b.content_text LIKE N'%欢迎%' OR b.content_text LIKE N'%您好%' OR b.content_text LIKE N'%亲,%' OR b.content_text LIKE N'%亲,%' THEN 1 ELSE 0 END AS has_greeting, + CASE WHEN b.content_text LIKE N'%抱歉%' OR b.content_text LIKE N'%不好意思%' OR b.content_text LIKE N'%理解您%' THEN 1 ELSE 0 END AS has_empathy, + CASE WHEN b.content_text LIKE N'%马上%' OR b.content_text LIKE N'%尽快%' OR b.content_text LIKE N'%给您处理%' OR b.content_text LIKE N'%为您处理%' THEN 1 ELSE 0 END AS has_commitment, + CASE WHEN b.content_text LIKE N'%感谢%' OR b.content_text LIKE N'%祝您%' OR b.content_text LIKE N'%辛苦%' THEN 1 ELSE 0 END AS has_closing, + CASE WHEN b.content_text LIKE N'%不知道%' OR b.content_text LIKE N'%没办法%' OR b.content_text LIKE N'%无法处理%' OR b.content_text LIKE N'%不清楚%' OR b.content_text LIKE N'%等不了%' OR b.content_text LIKE N'%太慢%' THEN 1 ELSE 0 END AS has_risk_phrase +INTO #chat_norm +FROM #chat_base AS b; + +-- 1) 时间段客服体验总览 +SELECT + @start_date AS start_date, + @end_date AS end_date, + COUNT(*) AS session_count, + COUNT(DISTINCT order_no) AS order_count, + COUNT(DISTINCT shop_name) AS shop_count, + SUM(is_empty_content) AS empty_content_count, + CAST(100.0 * SUM(is_empty_content) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS empty_content_rate_pct, + SUM(is_missing_reason) AS missing_reason_count, + CAST(100.0 * SUM(is_missing_reason) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS missing_reason_rate_pct, + SUM(is_no_match_reason) AS no_match_reason_count, + CAST(100.0 * SUM(is_no_match_reason) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS no_match_reason_rate_pct, + SUM(CASE WHEN state = 1 THEN 1 ELSE 0 END) AS state_1_count, + SUM(CASE WHEN state = 2 THEN 1 ELSE 0 END) AS state_2_count +FROM #chat_norm; + +-- 2) 用户反馈问题结构 TOP(一级/二级) +SELECT TOP (@limit) + main_reason, + second_reason, + COUNT(*) AS session_count, + COUNT(DISTINCT order_no) AS order_count, + CAST(100.0 * COUNT(*) / NULLIF((SELECT COUNT(*) FROM #chat_norm), 0) AS decimal(6,2)) AS session_share_pct +FROM #chat_norm +GROUP BY main_reason, second_reason +ORDER BY session_count DESC, order_count DESC; + +-- 3) 客服体验店铺对比(体验分) +WITH shop_agg AS ( + SELECT + shop_name, + COUNT(*) AS session_count, + SUM(is_empty_content) AS empty_content_count, + SUM(is_missing_reason) AS missing_reason_count, + SUM(is_no_match_reason) AS no_match_reason_count + FROM #chat_norm + GROUP BY shop_name +) +SELECT TOP (@limit) + s.shop_name, + s.session_count, + CAST(100.0 * s.empty_content_count / NULLIF(s.session_count, 0) AS decimal(6,2)) AS empty_content_rate_pct, + CAST(100.0 * s.missing_reason_count / NULLIF(s.session_count, 0) AS decimal(6,2)) AS missing_reason_rate_pct, + CAST(100.0 * s.no_match_reason_count / NULLIF(s.session_count, 0) AS decimal(6,2)) AS no_match_reason_rate_pct, + CASE + WHEN ( + 100.0 + - (100.0 * s.missing_reason_count / NULLIF(s.session_count, 0)) * 0.4 + - (100.0 * s.no_match_reason_count / NULLIF(s.session_count, 0)) * 0.4 + - (100.0 * s.empty_content_count / NULLIF(s.session_count, 0)) * 0.2 + ) < 0 THEN CAST(0 AS decimal(6,2)) + ELSE CAST( + 100.0 + - (100.0 * s.missing_reason_count / NULLIF(s.session_count, 0)) * 0.4 + - (100.0 * s.no_match_reason_count / NULLIF(s.session_count, 0)) * 0.4 + - (100.0 * s.empty_content_count / NULLIF(s.session_count, 0)) * 0.2 + AS decimal(6,2) + ) + END AS experience_score +FROM shop_agg AS s +ORDER BY experience_score DESC, s.session_count DESC; + +-- 4) 话术覆盖分析 +SELECT + COUNT(*) AS session_count, + SUM(has_greeting) AS greeting_hit_count, + CAST(100.0 * SUM(has_greeting) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS greeting_hit_rate_pct, + SUM(has_empathy) AS empathy_hit_count, + CAST(100.0 * SUM(has_empathy) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS empathy_hit_rate_pct, + SUM(has_commitment) AS commitment_hit_count, + CAST(100.0 * SUM(has_commitment) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS commitment_hit_rate_pct, + SUM(has_closing) AS closing_hit_count, + CAST(100.0 * SUM(has_closing) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS closing_hit_rate_pct, + SUM(has_risk_phrase) AS risk_phrase_hit_count, + CAST(100.0 * SUM(has_risk_phrase) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS risk_phrase_hit_rate_pct +FROM #chat_norm; + +-- 5) 风险话术命中 TOP +WITH phrase_hits AS ( + SELECT + n.id, + n.order_no, + n.shop_name, + v.risk_phrase + FROM #chat_norm AS n + CROSS APPLY (VALUES + (CASE WHEN n.content_text LIKE N'%不知道%' THEN N'不知道' END), + (CASE WHEN n.content_text LIKE N'%没办法%' THEN N'没办法' END), + (CASE WHEN n.content_text LIKE N'%无法处理%' THEN N'无法处理' END), + (CASE WHEN n.content_text LIKE N'%不清楚%' THEN N'不清楚' END), + (CASE WHEN n.content_text LIKE N'%太慢%' THEN N'太慢' END), + (CASE WHEN n.content_text LIKE N'%等不了%' THEN N'等不了' END) + ) AS v(risk_phrase) + WHERE v.risk_phrase IS NOT NULL +) +SELECT TOP (@limit) + risk_phrase, + COUNT(*) AS hit_count, + COUNT(DISTINCT id) AS session_count, + COUNT(DISTINCT order_no) AS order_count +FROM phrase_hits +GROUP BY risk_phrase +ORDER BY session_count DESC, hit_count DESC; + +-- 6) 每日体验趋势 +SELECT + CONVERT(date, createdAt) AS stat_date, + COUNT(*) AS session_count, + CAST(100.0 * SUM(is_no_match_reason) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS no_match_reason_rate_pct, + CAST(100.0 * SUM(is_missing_reason) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS missing_reason_rate_pct, + CAST(100.0 * SUM(is_empty_content) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS empty_content_rate_pct, + CAST(100.0 * SUM(has_risk_phrase) / NULLIF(COUNT(*), 0) AS decimal(6,2)) AS risk_phrase_hit_rate_pct +FROM #chat_norm +GROUP BY CONVERT(date, createdAt) +ORDER BY stat_date ASC; + +-- 7) 对话案例(风险话术)+ 推荐回复 +WITH case_pool AS ( + SELECT + n.id, + n.order_no, + n.shop_name, + n.main_reason, + n.second_reason, + n.content_text, + CASE + WHEN n.content_text LIKE N'%不清楚%' THEN N'不清楚' + WHEN n.content_text LIKE N'%不知道%' THEN N'不知道' + WHEN n.content_text LIKE N'%没办法%' THEN N'没办法' + WHEN n.content_text LIKE N'%太慢%' THEN N'太慢' + WHEN n.content_text LIKE N'%等不了%' THEN N'等不了' + WHEN n.content_text LIKE N'%无法处理%' THEN N'无法处理' + ELSE NULL + END AS risk_tag + FROM #chat_norm AS n +) +SELECT TOP (@limit) + order_no, + shop_name, + main_reason, + second_reason, + risk_tag, + LEFT(REPLACE(REPLACE(ISNULL(content_text, N''), CHAR(13), N' '), CHAR(10), N' '), 220) AS dialogue_sample, + CASE + WHEN risk_tag IN (N'不清楚', N'不知道') + THEN N'建议回复:我先帮您核实一下该问题,预计5分钟内给您明确答复。' + WHEN risk_tag = N'没办法' + THEN N'建议回复:这个情况我来帮您申请处理方案,先给您两个可行选项并同步时效。' + WHEN risk_tag = N'无法处理' + THEN N'建议回复:我已升级给对应专员处理,预计今天内给您处理结果。' + WHEN risk_tag IN (N'太慢', N'等不了') + THEN N'建议回复:抱歉让您久等,我现在优先为您加急处理,并在10分钟内回报进展。' + ELSE N'建议回复:收到您的问题,我先确认订单与处理路径,稍后给您明确方案。' + END AS suggested_reply +FROM case_pool +WHERE risk_tag IS NOT NULL +ORDER BY id DESC; + +-- 8) 客服能力评分(参考天猫服务保障维度,按可用字段做代理映射) +;WITH score_base AS ( + SELECT + shop_name, + COUNT(*) AS session_count, + CAST(SUM(has_commitment) AS float) / NULLIF(COUNT(*), 0) AS commitment_rate, + CAST(SUM(has_empathy) AS float) / NULLIF(COUNT(*), 0) AS empathy_rate, + CAST(SUM(has_closing) AS float) / NULLIF(COUNT(*), 0) AS closing_rate, + CAST(SUM(has_risk_phrase) AS float) / NULLIF(COUNT(*), 0) AS risk_phrase_rate, + CAST(SUM(CASE WHEN second_reason LIKE N'%催退款%' OR second_reason LIKE N'%催取件%' THEN 1 ELSE 0 END) AS float) / NULLIF(COUNT(*), 0) AS refund_urge_rate, + CAST(SUM(is_no_match_reason) AS float) / NULLIF(COUNT(*), 0) AS no_match_rate, + CAST(SUM(is_missing_reason) AS float) / NULLIF(COUNT(*), 0) AS missing_reason_rate + FROM #chat_norm + GROUP BY shop_name +), +score_calc AS ( + SELECT + sb.*, + 100.0 * (0.70 * sb.commitment_rate + 0.30 * (1.0 - sb.risk_phrase_rate)) AS resp_ability_score, + 100.0 * (0.45 * sb.empathy_rate + 0.35 * sb.closing_rate + 0.20 * (1.0 - sb.risk_phrase_rate)) AS satisfaction_score, + 100.0 * (1.0 - sb.refund_urge_rate) AS refund_efficiency_score, + 100.0 * (1.0 - (0.80 * sb.no_match_rate + 0.20 * sb.missing_reason_rate)) AS help_risk_control_score + FROM score_base AS sb +) +SELECT TOP (@limit) + shop_name, + session_count, + CAST(resp_ability_score AS decimal(6,2)) AS resp_ability_score, + CAST(satisfaction_score AS decimal(6,2)) AS satisfaction_score, + CAST(refund_efficiency_score AS decimal(6,2)) AS refund_efficiency_score, + CAST(help_risk_control_score AS decimal(6,2)) AS help_risk_control_score, + CAST( + 0.30 * resp_ability_score + + 0.30 * satisfaction_score + + 0.20 * refund_efficiency_score + + 0.20 * help_risk_control_score + AS decimal(6,2) + ) AS service_capability_score, + CASE + WHEN ( + 0.30 * resp_ability_score + + 0.30 * satisfaction_score + + 0.20 * refund_efficiency_score + + 0.20 * help_risk_control_score + ) >= 90 THEN N'S' + WHEN ( + 0.30 * resp_ability_score + + 0.30 * satisfaction_score + + 0.20 * refund_efficiency_score + + 0.20 * help_risk_control_score + ) >= 80 THEN N'A' + WHEN ( + 0.30 * resp_ability_score + + 0.30 * satisfaction_score + + 0.20 * refund_efficiency_score + + 0.20 * help_risk_control_score + ) >= 70 THEN N'B' + WHEN ( + 0.30 * resp_ability_score + + 0.30 * satisfaction_score + + 0.20 * refund_efficiency_score + + 0.20 * help_risk_control_score + ) >= 60 THEN N'C' + ELSE N'D' + END AS service_grade, + N'映射说明: 响应能力≈旺旺响应能力; 满意度≈旺旺满意度; 退款效率≈退款处理体验; 求助风险控制≈平台求助风险(代理口径)' AS score_mapping_note +FROM score_calc +ORDER BY service_capability_score DESC, session_count DESC; diff --git a/remote_sync/dalongxia_analytics_client.js b/remote_sync/dalongxia_analytics_client.js new file mode 100755 index 0000000..9487b04 --- /dev/null +++ b/remote_sync/dalongxia_analytics_client.js @@ -0,0 +1,133 @@ +#!/usr/bin/env node +// 用法: +// OPEN_API_APP_KEY="robot_dalongxia" OPEN_API_APP_SECRET="你的secret" node dalongxia_analytics_client.js progress +// OPEN_API_APP_KEY="robot_dalongxia" OPEN_API_APP_SECRET="你的secret" node dalongxia_analytics_client.js overview + +const crypto = require("crypto"); + +const BASE_URL = process.env.OPEN_API_BASE_URL || process.env.OPEN_API_BASE || "https://qd.yydd.ai"; +const APP_KEY = process.env.OPEN_API_APP_KEY; +const APP_SECRET = process.env.OPEN_API_APP_SECRET; + +if (!APP_KEY || !APP_SECRET) { + console.error("缺少环境变量:OPEN_API_APP_KEY / OPEN_API_APP_SECRET"); + process.exit(1); +} + +function hmacSha256Hex(secret, payload) { + return crypto.createHmac("sha256", secret).update(payload, "utf8").digest("hex"); +} + +function nowTs() { + return String(Date.now()); +} + +async function postJson(path, body) { + const resp = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + const text = await resp.text(); + let data = {}; + try { + data = text ? JSON.parse(text) : {}; + } catch (err) { + throw new Error(`HTTP ${resp.status} 非JSON响应: ${text.slice(0, 300)}`); + } + + if (!resp.ok || data.code !== 200) { + throw new Error(`HTTP ${resp.status} / code=${data.code} / msg=${data.message}`); + } + return data.data; +} + +async function queryTaskProgressOverview({ dateRange = "month", month = "", deptId = "" } = {}) { + const timestamp = nowTs(); + const payload = [ + "analytics_task_progress_overview", + APP_KEY, + dateRange, + month || "", + String(deptId || ""), + timestamp, + ].join("|"); + const sign = hmacSha256Hex(APP_SECRET, payload); + + return postJson("/api/open/analytics/task-progress/overview", { + appKey: APP_KEY, + timestamp, + sign, + dateRange, + month, + deptId: deptId || undefined, + }); +} + +async function queryOverview({ + dateRange = "month", + month = "", + deptId = "", + userId = "", + includeTaskProgress = false, +} = {}) { + const timestamp = nowTs(); + const includeStr = String(Boolean(includeTaskProgress)); + + const payload = [ + "analytics_overview", + APP_KEY, + dateRange, + month || "", + String(deptId || ""), + String(userId || ""), + includeStr, + timestamp, + ].join("|"); + const sign = hmacSha256Hex(APP_SECRET, payload); + + return postJson("/api/open/analytics/overview", { + appKey: APP_KEY, + timestamp, + sign, + dateRange, + month, + deptId: deptId || undefined, + userId: userId || undefined, + includeTaskProgress: Boolean(includeTaskProgress), + }); +} + +(async () => { + try { + const mode = process.argv[2] || "progress"; + + if (mode === "progress") { + const data = await queryTaskProgressOverview({ + dateRange: process.env.ANALYTICS_DATE_RANGE || "month", + month: process.env.ANALYTICS_MONTH || "", + }); + console.log("任务进度总览查询成功:"); + console.log(JSON.stringify(data, null, 2)); + return; + } + + if (mode === "overview") { + const data = await queryOverview({ + dateRange: process.env.ANALYTICS_DATE_RANGE || "month", + month: process.env.ANALYTICS_MONTH || "", + includeTaskProgress: String(process.env.ANALYTICS_INCLUDE_TASK_PROGRESS || "false").toLowerCase() === "true", + }); + console.log("数据分析总览查询成功:"); + console.log(JSON.stringify(data, null, 2)); + return; + } + + throw new Error(`未知模式: ${mode},可用 progress / overview`); + } catch (err) { + console.error("查询失败:", err.message); + process.exit(1); + } +})(); + diff --git a/remote_sync/dalongxia_open_analytics.js b/remote_sync/dalongxia_open_analytics.js new file mode 100755 index 0000000..b64d4ae --- /dev/null +++ b/remote_sync/dalongxia_open_analytics.js @@ -0,0 +1,257 @@ +#!/usr/bin/env node +// file: dalongxia_open_analytics.js +// Node 18+ +// 用法示例: +// OPEN_API_APP_KEY="robot_dalongxia" OPEN_API_APP_SECRET="你的secret" node dalongxia_open_analytics.js designerPerformance +// OPEN_API_APP_KEY="robot_dalongxia" OPEN_API_APP_SECRET="你的secret" node dalongxia_open_analytics.js dimensionAnalysis + +const crypto = require("crypto"); + +const BASE_URL = process.env.OPEN_API_BASE_URL || process.env.OPEN_API_BASE || "https://qd.yydd.ai"; +const APP_KEY = process.env.OPEN_API_APP_KEY; +const APP_SECRET = process.env.OPEN_API_APP_SECRET; + +if (!APP_KEY || !APP_SECRET) { + console.error("缺少环境变量 OPEN_API_APP_KEY / OPEN_API_APP_SECRET"); + process.exit(1); +} + +function nowTs() { + return String(Date.now()); +} + +function sign(payload) { + return crypto.createHmac("sha256", APP_SECRET).update(payload, "utf8").digest("hex"); +} + +function payload(action, ...parts) { + return [action, APP_KEY, ...parts].join("|"); +} + +async function post(path, body) { + const resp = await fetch(`${BASE_URL}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const text = await resp.text(); + let json = {}; + try { + json = text ? JSON.parse(text) : {}; + } catch (err) { + throw new Error(`HTTP=${resp.status}, 非JSON响应: ${text.slice(0, 300)}`); + } + if (!resp.ok || json.code !== 200) { + throw new Error(`HTTP=${resp.status}, code=${json.code}, msg=${json.message}`); + } + return json.data; +} + +async function callOverview({ dateRange = "month", month = "", deptId = "", userId = "", includeTaskProgress = false } = {}) { + const ts = nowTs(); + const includeStr = String(Boolean(includeTaskProgress)); + const p = payload("analytics_overview", dateRange, month, String(deptId), String(userId), includeStr, ts); + return post("/api/open/analytics/overview", { + appKey: APP_KEY, + timestamp: ts, + sign: sign(p), + dateRange, + month, + deptId: deptId || undefined, + userId: userId || undefined, + includeTaskProgress: !!includeTaskProgress, + }); +} + +async function callTaskProgressOverview({ dateRange = "month", month = "", deptId = "" } = {}) { + const ts = nowTs(); + const p = payload("analytics_task_progress_overview", dateRange, month, String(deptId), ts); + return post("/api/open/analytics/task-progress/overview", { + appKey: APP_KEY, + timestamp: ts, + sign: sign(p), + dateRange, + month, + deptId: deptId || undefined, + }); +} + +async function callDemandStatistics({ dateRange = "month", month = "", deptId = "", userId = "" } = {}) { + const ts = nowTs(); + const p = payload("analytics_demand_statistics", dateRange, month, String(deptId), String(userId), ts); + return post("/api/open/analytics/demand-statistics", { + appKey: APP_KEY, + timestamp: ts, + sign: sign(p), + dateRange, + month, + deptId: deptId || undefined, + userId: userId || undefined, + }); +} + +async function callDesignerPerformance({ month = "", deptId = "" } = {}) { + const ts = nowTs(); + const p = payload("analytics_designer_performance", month, String(deptId), ts); + return post("/api/open/analytics/designer-performance", { + appKey: APP_KEY, + timestamp: ts, + sign: sign(p), + month, + deptId: deptId || undefined, + }); +} + +async function callProcessEfficiency({ month = "" } = {}) { + const ts = nowTs(); + const p = payload("analytics_process_efficiency", month, ts); + return post("/api/open/analytics/process-efficiency", { + appKey: APP_KEY, + timestamp: ts, + sign: sign(p), + month, + }); +} + +async function callWarnings() { + const ts = nowTs(); + const p = payload("analytics_warnings", ts); + return post("/api/open/analytics/warnings", { + appKey: APP_KEY, + timestamp: ts, + sign: sign(p), + }); +} + +async function callDimensionAnalysis({ dateRange = "month", month = "" } = {}) { + const ts = nowTs(); + const p = payload("analytics_dimension_analysis", dateRange, month, ts); + return post("/api/open/analytics/dimension-analysis", { + appKey: APP_KEY, + timestamp: ts, + sign: sign(p), + dateRange, + month, + }); +} + +async function callDesignerDeductionDetails({ userId, month = "" }) { + const ts = nowTs(); + const p = payload("analytics_designer_deduction_details", String(userId), month, ts); + return post("/api/open/analytics/designer-deduction-details", { + appKey: APP_KEY, + timestamp: ts, + sign: sign(p), + userId, + month, + }); +} + +async function callDesignerScoreDetails({ userId, month = "" }) { + const ts = nowTs(); + const p = payload("analytics_designer_score_details", String(userId), month, ts); + return post("/api/open/analytics/designer-score-details", { + appKey: APP_KEY, + timestamp: ts, + sign: sign(p), + userId, + month, + }); +} + +async function callPrimaryDeptStatistics({ startTime, endTime, deptId = "" }) { + const ts = nowTs(); + const p = payload("analytics_primary_dept_statistics", startTime, endTime, String(deptId), ts); + return post("/api/open/analytics/primary-dept/statistics", { + appKey: APP_KEY, + timestamp: ts, + sign: sign(p), + startTime, + endTime, + deptId: deptId || undefined, + }); +} + +async function callPrimaryDeptRanking({ startTime, endTime }) { + const ts = nowTs(); + const p = payload("analytics_primary_dept_ranking", startTime, endTime, ts); + return post("/api/open/analytics/primary-dept/ranking", { + appKey: APP_KEY, + timestamp: ts, + sign: sign(p), + startTime, + endTime, + }); +} + +async function callPrimaryTaskFlowEfficiency({ startTime, endTime }) { + const ts = nowTs(); + const p = payload("analytics_primary_task_flow_efficiency", startTime, endTime, ts); + return post("/api/open/analytics/primary-task/flow-efficiency", { + appKey: APP_KEY, + timestamp: ts, + sign: sign(p), + startTime, + endTime, + }); +} + +async function callPrimaryTaskBrandDistribution({ startTime, endTime }) { + const ts = nowTs(); + const p = payload("analytics_primary_task_brand_distribution", startTime, endTime, ts); + return post("/api/open/analytics/primary-task/brand-distribution", { + appKey: APP_KEY, + timestamp: ts, + sign: sign(p), + startTime, + endTime, + }); +} + +async function callPrimaryDeptUserRanking({ startTime, endTime }) { + const ts = nowTs(); + const p = payload("analytics_primary_dept_user_ranking", startTime, endTime, ts); + return post("/api/open/analytics/primary-dept/user-ranking", { + appKey: APP_KEY, + timestamp: ts, + sign: sign(p), + startTime, + endTime, + }); +} + +(async () => { + const mode = process.argv[2] || "designerPerformance"; + const month = process.argv[3] || "2026-02"; + const startTime = process.argv[4] || "2026-02-01T00:00:00"; + const endTime = process.argv[5] || "2026-02-27T23:59:59"; + + const map = { + overview: () => callOverview({ month }), + taskProgressOverview: () => callTaskProgressOverview({ month }), + demandStatistics: () => callDemandStatistics({ month }), + designerPerformance: () => callDesignerPerformance({ month }), + processEfficiency: () => callProcessEfficiency({ month }), + warnings: () => callWarnings(), + dimensionAnalysis: () => callDimensionAnalysis({ month }), + designerDeduction: () => callDesignerDeductionDetails({ userId: 38, month }), + designerScore: () => callDesignerScoreDetails({ userId: 38, month }), + primaryDeptStatistics: () => callPrimaryDeptStatistics({ startTime, endTime }), + primaryDeptRanking: () => callPrimaryDeptRanking({ startTime, endTime }), + primaryTaskFlowEfficiency: () => callPrimaryTaskFlowEfficiency({ startTime, endTime }), + primaryTaskBrandDistribution: () => callPrimaryTaskBrandDistribution({ startTime, endTime }), + primaryDeptUserRanking: () => callPrimaryDeptUserRanking({ startTime, endTime }), + }; + + if (!map[mode]) { + console.error("未知 mode:", mode); + process.exit(1); + } + + const data = await map[mode](); + console.log(JSON.stringify(data, null, 2)); +})().catch((err) => { + console.error("查询失败:", err.message); + process.exit(1); +}); + diff --git a/remote_sync/install-media-lookup-wrapper b/remote_sync/install-media-lookup-wrapper new file mode 100755 index 0000000..25459e3 --- /dev/null +++ b/remote_sync/install-media-lookup-wrapper @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +VENV="/root/.openclaw/.venv-db" +TOOL="/root/.openclaw/workspace/sqlserver/install_media_lookup.py" + +if [ ! -x "$VENV/bin/python" ]; then + echo "missing python runtime: $VENV/bin/python" >&2 + exit 1 +fi + +if [ ! -f "$TOOL" ]; then + echo "missing tool script: $TOOL" >&2 + exit 1 +fi + +exec "$VENV/bin/python" "$TOOL" "$@" diff --git a/remote_sync/install-media-lookup/SKILL.md b/remote_sync/install-media-lookup/SKILL.md new file mode 100644 index 0000000..0252e6b --- /dev/null +++ b/remote_sync/install-media-lookup/SKILL.md @@ -0,0 +1,56 @@ +--- +name: install-media-lookup +description: Query install drawing and install video links by order id. First map order id to product number from SQLServer, then query MySQL install-media database and return browser-accessible URLs. +metadata: + { "openclaw": { "emoji": "🧩", "os": ["linux"], "requires": { "bins": ["company-db", "install-media-lookup"] } } } +--- + +# install-media-lookup Skill (OpenClaw) + +Use this skill when user asks: +- 根据订单号查询安装图纸 +- 根据订单号查询安装视频 +- 查询安装说明书图片地址/视频地址 +- 订单号 -> 产品货号 -> 安装资源链接 + +## Command + +```bash +install-media-lookup --order-id "<商品订单号>" +``` + +Optional: + +```bash +install-media-lookup --order-id "<商品订单号>" --limit 200 +install-media-lookup --order-id "<商品订单号>" --mysql-schema "" --mysql-table "" +install-media-lookup --product-no "<产品货号>" +install-media-lookup --sku-id "" +``` + +## Workflow + +1. Resolve `order-id` to `product_no` from SQLServer (`jst_goods_profit_kpi_items_afs` + `jst_sku`). +2. Query MySQL install-media database by `product_no`. +3. Return drawing/video records and convert local path to browser URL: + - `D:\JianSunTuZhiFile\...` -> `https://img.jiansuntuzhi.cn/...` +4. Output three result sets: + - `order_to_product_mapping` + - `mysql_table_match_summary` + - `install_media_links` + +## Config + +- SQLServer creds (already used by existing `company-db`): + - `/root/.openclaw/credentials/company-sql.env` +- MySQL creds: + - `/root/.openclaw/credentials/install-media-mysql.env` +- Optional fixed MySQL table mapping (preferred in production): + - `/root/.openclaw/workspace/outputs/install-media-mysql-mapping.json` + +## Rules + +1. If user gives both order id and product number, trust both and merge results. +2. If MySQL auto discovery finds nothing, ask to补充/校准映射文件,不要编造链接。 +3. Always return both `raw_path` and converted `media_url`. +4. Never expose DB password in chat output. diff --git a/remote_sync/install_media_lookup.py b/remote_sync/install_media_lookup.py new file mode 100644 index 0000000..042949d --- /dev/null +++ b/remote_sync/install_media_lookup.py @@ -0,0 +1,859 @@ +#!/usr/bin/env python3 +import argparse +import datetime as dt +import json +import os +import pathlib +import re +import sys +from typing import Dict, List, Sequence, Tuple + +import pytds +import pymysql + +SQL_ENV_FILE = "/root/.openclaw/credentials/company-sql.env" +MYSQL_ENV_FILE = "/root/.openclaw/credentials/install-media-mysql.env" +DEFAULT_MAPPING_FILE = "/root/.openclaw/workspace/outputs/install-media-mysql-mapping.json" + + +def load_env_file(path: str) -> None: + p = pathlib.Path(path) + if not p.exists(): + return + for raw in p.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, val = line.split("=", 1) + key = key.strip() + val = val.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = val + + +def sql_conn_params() -> Dict[str, object]: + load_env_file(SQL_ENV_FILE) + return { + "server": os.environ.get("COMPANY_DB_HOST", "218.93.191.16"), + "port": int(os.environ.get("COMPANY_DB_PORT", "1433")), + "database": os.environ.get("COMPANY_DB_NAME", "JSDataMiddlePlatform"), + "user": os.environ.get("COMPANY_DB_USER", "nanting"), + "password": os.environ.get("COMPANY_DB_PASSWORD", ""), + "login_timeout": 15, + "timeout": 120, + } + + +def mysql_conn_params() -> Dict[str, object]: + load_env_file(MYSQL_ENV_FILE) + return { + "host": os.environ.get("INSTALL_MEDIA_MYSQL_HOST", "218.93.191.20"), + "port": int(os.environ.get("INSTALL_MEDIA_MYSQL_PORT", "3306")), + "user": os.environ.get("INSTALL_MEDIA_MYSQL_USER", "read_only_jiansun"), + "password": os.environ.get("INSTALL_MEDIA_MYSQL_PASSWORD", ""), + "charset": "utf8mb4", + "connect_timeout": int(os.environ.get("INSTALL_MEDIA_MYSQL_CONNECT_TIMEOUT", "8")), + "read_timeout": int(os.environ.get("INSTALL_MEDIA_MYSQL_READ_TIMEOUT", "15")), + "write_timeout": int(os.environ.get("INSTALL_MEDIA_MYSQL_WRITE_TIMEOUT", "15")), + "autocommit": True, + } + + +def sql_escape(value: str) -> str: + return value.replace("'", "''") + + +def qi(identifier: str) -> str: + return "`" + identifier.replace("`", "``") + "`" + + +def fmt_value(value): + if isinstance(value, (dt.datetime, dt.date, dt.time)): + return value.isoformat(sep=" ") + return value + + +def print_rows(columns: Sequence[str], rows: Sequence[Dict[str, object]]) -> None: + print("COLUMNS:", ", ".join(columns)) + if not rows: + print("ROWS: 0") + return + print(f"ROWS: {len(rows)}") + for row in rows: + obj = {c: fmt_value(row.get(c)) for c in columns} + print(json.dumps(obj, ensure_ascii=False, default=str)) + + +def query_order_product_mappings(order_id: str, limit: int) -> List[Dict[str, object]]: + if not order_id: + return [] + + params = sql_conn_params() + if not params["password"]: + raise RuntimeError("COMPANY_DB_PASSWORD is empty") + + oid = sql_escape(order_id.strip()) + lim = max(1, min(limit, 2000)) + + sql = f""" + USE [JSDataMiddlePlatform]; + SET NOCOUNT ON; + + DECLARE @order_id nvarchar(120) = N'{oid}'; + + WITH order_map AS ( + SELECT + NULLIF(a.undefined2, '') AS order_id, + NULLIF(a.undefined10, '') AS sku_id, + COALESCE(NULLIF(s.other_7, ''), NULLIF(a.undefined96, '')) AS product_no, + COALESCE(NULLIF(s.name, ''), NULLIF(a.undefined36, '')) AS sku_name, + COALESCE(NULLIF(a.undefined8, ''), NULLIF(e.shop_name, '')) AS shop_name, + COALESCE( + TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120), + TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120), + TRY_CONVERT(datetime2, NULLIF(e.send_time, ''), 120), + TRY_CONVERT(datetime2, a.createdAt) + ) AS send_time, + ROW_NUMBER() OVER ( + PARTITION BY NULLIF(a.undefined2, ''), COALESCE(NULLIF(s.other_7, ''), NULLIF(a.undefined96, ''), NULLIF(a.undefined10, '')) + ORDER BY COALESCE( + TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120), + TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120), + TRY_CONVERT(datetime2, NULLIF(e.send_time, ''), 120), + TRY_CONVERT(datetime2, a.createdAt) + ) DESC, + a.createdAt DESC + ) AS rn + FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK) + LEFT JOIN dbo.jst_sku AS s WITH (NOLOCK) + ON s.sku_id = NULLIF(a.undefined10, '') + LEFT JOIN dbo.jst_order_express_details AS e WITH (NOLOCK) + ON NULLIF(e.so_id, '') = NULLIF(a.undefined2, '') + WHERE ( + NULLIF(a.undefined2, '') = @order_id + OR NULLIF(a.undefined3, '') = @order_id + OR NULLIF(a.undefined3, '') LIKE @order_id + '%' + ) + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%' + ) + SELECT TOP ({lim}) + order_id, + sku_id, + product_no, + sku_name, + shop_name, + send_time + FROM order_map + WHERE rn = 1 + AND ISNULL(NULLIF(order_id, ''), '') <> '' + AND ISNULL(NULLIF(product_no, ''), '') <> '' + ORDER BY send_time DESC, order_id ASC; + """ + + with pytds.connect(**params) as conn: + cur = conn.cursor() + cur.execute(sql) + cols = [c[0] for c in cur.description] + data = [dict(zip(cols, row)) for row in cur.fetchall()] + cur.close() + return data + + +def _normalize_text(s: str) -> str: + return (s or "").strip().lower() + + +PRODUCT_COL_KEYS = [ + "productno", + "product_no", + "goodsno", + "goods_no", + "itemno", + "item_no", + "stylecode", + "style_code", + "spu", + "sku", + "barcode", + "procode", + "pro_code", + "货号", + "商品编码", + "产品编码", +] + +DRAWING_COL_KEYS = [ + "instruction", + "drawing", + "manual", + "install", + "tuzhi", + "图纸", + "安装图", + "说明书", +] + +VIDEO_COL_KEYS = [ + "video", + "mp4", + "movie", + "教程", + "视频", + "安装视频", +] + +PATH_COL_KEYS = [ + "path", + "url", + "file", + "img", + "image", + "地址", + "链接", + "文件", + "路径", +] + +SYSTEM_SCHEMAS = {"information_schema", "mysql", "performance_schema", "sys"} + + +def _contains_any(text: str, keys: Sequence[str]) -> bool: + low = _normalize_text(text) + return any(k in low for k in keys) + + +def discover_table_specs( + conn, + explicit_schema: str, + explicit_table: str, + max_tables: int, +) -> List[Dict[str, object]]: + where = ["TABLE_SCHEMA NOT IN ('information_schema','mysql','performance_schema','sys')"] + params: List[object] = [] + if explicit_schema: + where.append("TABLE_SCHEMA = %s") + params.append(explicit_schema) + if explicit_table: + where.append("TABLE_NAME = %s") + params.append(explicit_table) + + sql = f""" + SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, DATA_TYPE, COALESCE(COLUMN_COMMENT, '') + FROM information_schema.COLUMNS + WHERE {' AND '.join(where)} + ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION + """ + + with conn.cursor() as cur: + cur.execute(sql, params) + rows = cur.fetchall() + + grouped: Dict[Tuple[str, str], List[Tuple[str, str, str]]] = {} + for schema, table, col, dtype, comment in rows: + key = (schema, table) + grouped.setdefault(key, []).append((str(col), str(dtype), str(comment or ""))) + + specs: List[Dict[str, object]] = [] + for (schema, table), cols in grouped.items(): + product_cols: List[str] = [] + drawing_cols: List[str] = [] + video_cols: List[str] = [] + generic_media_cols: List[str] = [] + + for col_name, _dtype, col_comment in cols: + text = f"{col_name} {col_comment}" + has_path = _contains_any(text, PATH_COL_KEYS) + has_drawing = _contains_any(text, DRAWING_COL_KEYS) + has_video = _contains_any(text, VIDEO_COL_KEYS) + has_product = _contains_any(text, PRODUCT_COL_KEYS) + + if has_product: + product_cols.append(col_name) + + if has_drawing and (has_path or has_drawing): + drawing_cols.append(col_name) + elif has_video and (has_path or has_video): + video_cols.append(col_name) + elif has_path and (has_drawing or has_video): + generic_media_cols.append(col_name) + elif has_path and _contains_any(text, ["install", "instruction", "图纸", "视频"]): + generic_media_cols.append(col_name) + + media_cols = sorted(set(drawing_cols + video_cols + generic_media_cols)) + if not product_cols or not media_cols: + continue + + specs.append( + { + "schema": schema, + "table": table, + "product_cols": sorted(set(product_cols)), + "drawing_cols": sorted(set(drawing_cols)), + "video_cols": sorted(set(video_cols)), + "media_cols": media_cols, + } + ) + + specs.sort(key=lambda x: (x["schema"], x["table"])) + if max_tables > 0: + specs = specs[:max_tables] + return specs + + +def load_mapping_specs(mapping_file: str) -> List[Dict[str, object]]: + p = pathlib.Path(mapping_file) + if not p.exists(): + return [] + try: + obj = json.loads(p.read_text(encoding="utf-8")) + except Exception: + return [] + + rows = obj.get("tables") if isinstance(obj, dict) else None + if not isinstance(rows, list): + return [] + + specs: List[Dict[str, object]] = [] + for row in rows: + if not isinstance(row, dict): + continue + schema = str(row.get("schema", "")).strip() + table = str(row.get("table", "")).strip() + product_cols = [str(x).strip() for x in row.get("product_cols", []) if str(x).strip()] + drawing_cols = [str(x).strip() for x in row.get("drawing_cols", []) if str(x).strip()] + video_cols = [str(x).strip() for x in row.get("video_cols", []) if str(x).strip()] + media_cols = [str(x).strip() for x in row.get("media_cols", []) if str(x).strip()] + if not schema or not table or not product_cols: + continue + merged_media = sorted(set(media_cols + drawing_cols + video_cols)) + if not merged_media: + continue + specs.append( + { + "schema": schema, + "table": table, + "product_cols": sorted(set(product_cols)), + "drawing_cols": sorted(set(drawing_cols)), + "video_cols": sorted(set(video_cols)), + "media_cols": merged_media, + } + ) + return specs + + +def classify_media_type(col_name: str, value: str) -> str: + text = _normalize_text(f"{col_name} {value}") + if _contains_any(text, VIDEO_COL_KEYS) or re.search(r"\.(mp4|avi|mov|mkv|wmv)(\?|$)", text): + return "video" + if _contains_any(text, DRAWING_COL_KEYS) or re.search(r"\.(png|jpg|jpeg|webp|gif|bmp)(\?|$)", text): + return "drawing" + return "unknown" + + +def normalize_media_url(raw_path: str, local_prefix: str, cdn_prefix: str) -> str: + s = (raw_path or "").strip() + if not s: + return "" + + if s.startswith("http://") or s.startswith("https://"): + return s + + s_norm = s.replace("\\", "/") + local_norm = (local_prefix or "D:/JianSunTuZhiFile").replace("\\", "/").rstrip("/") + cdn_norm = (cdn_prefix or "https://img.jiansuntuzhi.cn/").rstrip("/") + "/" + + if s_norm.lower().startswith(local_norm.lower() + "/"): + rest = s_norm[len(local_norm) + 1 :] + return cdn_norm + rest + if s_norm.lower() == local_norm.lower(): + return cdn_norm.rstrip("/") + + marker = "jiansuntuzhifile/" + idx = s_norm.lower().find(marker) + if idx >= 0: + rest = s_norm[idx + len(marker) :] + return cdn_norm + rest + + if re.match(r"^[a-zA-Z]:/", s_norm): + return s_norm + + if s_norm.startswith("/"): + return cdn_norm + s_norm.lstrip("/") + + return cdn_norm + s_norm + + +def _query_table_rows(conn, sql: str, params: Sequence[object]) -> List[Dict[str, object]]: + with conn.cursor() as cur: + cur.execute(sql, params) + cols = [d[0] for d in cur.description] + data = [dict(zip(cols, row)) for row in cur.fetchall()] + return data + + +def query_jiansun_special_records( + conn, + schema: str, + product_no: str, + row_limit: int, + local_prefix: str, + cdn_prefix: str, +) -> Tuple[List[Dict[str, object]], List[Dict[str, object]]]: + """ + Deterministic path for jiansunblueprint: + product_no -> sheet1 -> missiontask -> missiontask_instructions/missiontask_video + """ + table_rows: List[Dict[str, object]] = [] + detail_rows: List[Dict[str, object]] = [] + + if not schema: + return table_rows, detail_rows + + with conn.cursor() as cur: + cur.execute(f"SHOW TABLES FROM {qi(schema)}") + table_names = {str(r[0]).lower() for r in cur.fetchall()} + + required = {"sheet1", "missiontask", "missiontask_instructions", "missiontask_video"} + if not required.issubset(table_names): + return table_rows, detail_rows + + lim = max(1, min(row_limit, 2000)) + pno = product_no.strip() + + # 1) sheet1: map product_no -> barcode/pictureId/sku model + sheet_sql = ( + f"SELECT {qi('sku(货号)')} AS product_no, {qi('工厂条形码')} AS barcode, " + f"{qi('图纸ID')} AS picture_id, {qi('SKU型号')} AS sku_model " + f"FROM {qi(schema)}.{qi('sheet1')} " + f"WHERE {qi('sku(货号)')} = %s OR {qi('sku(货号)')} LIKE %s " + f"LIMIT %s" + ) + sheet_rows = _query_table_rows(conn, sheet_sql, (pno, f"{pno}%", lim)) + table_rows.append( + { + "product_no": pno, + "source_schema": schema, + "source_table": "sheet1", + "matched_rows": len(sheet_rows), + "matched_media_items": 0, + } + ) + + barcodes = sorted({str(r.get("barcode", "")).strip() for r in sheet_rows if str(r.get("barcode", "")).strip()}) + picture_ids = sorted({str(r.get("picture_id", "")).strip() for r in sheet_rows if str(r.get("picture_id", "")).strip()}) + sku_models = sorted({str(r.get("sku_model", "")).strip() for r in sheet_rows if str(r.get("sku_model", "")).strip()}) + + # 2) missiontask: find task ids + match_parts: List[str] = [] + params: List[object] = [] + + match_parts.append(f"{qi('skucodes')} LIKE %s") + params.append(f"%{pno}%") + + for b in barcodes[:60]: + match_parts.append(f"{qi('skucodes')} LIKE %s") + params.append(f"%{b}%") + for pid in picture_ids[:60]: + match_parts.append(f"{qi('pictureId')} = %s") + params.append(pid) + for sm in sku_models[:40]: + match_parts.append(f"{qi('skuModel')} = %s") + params.append(sm) + + where_clause = ( + f"({qi('isDelete')} = '0' OR {qi('isDelete')} = 0 OR {qi('isDelete')} IS NULL)" + f" AND ({' OR '.join(match_parts)})" + ) + + mission_sql = ( + f"SELECT {qi('id')} AS task_id, {qi('taskId')} AS task_no, {qi('SPU')} AS spu, " + f"{qi('picture')} AS picture_path, {qi('pictureId')} AS picture_id, {qi('skucodes')} AS skucodes, " + f"{qi('withInstructions')} AS with_instructions, {qi('withVideo')} AS with_video, " + f"{qi('createdTime')} AS created_time " + f"FROM {qi(schema)}.{qi('missiontask')} " + f"WHERE {where_clause} " + f"ORDER BY {qi('createdTime')} DESC " + f"LIMIT %s" + ) + params.append(max(20, min(lim * 3, 500))) + mission_rows = _query_table_rows(conn, mission_sql, params) + table_rows.append( + { + "product_no": pno, + "source_schema": schema, + "source_table": "missiontask", + "matched_rows": len(mission_rows), + "matched_media_items": 0, + } + ) + + task_ids = [str(r.get("task_id", "")).strip() for r in mission_rows if str(r.get("task_id", "")).strip()] + if not task_ids: + return table_rows, detail_rows + + # 3) instructions + placeholders = ",".join(["%s"] * len(task_ids)) + ins_sql = ( + f"SELECT {qi('id')} AS id, {qi('hid')} AS task_id, {qi('url')} AS raw_path, {qi('orderNum')} AS order_num " + f"FROM {qi(schema)}.{qi('missiontask_instructions')} " + f"WHERE {qi('hid')} IN ({placeholders}) AND {qi('isDelete')} = 0 " + f"ORDER BY {qi('hid')} ASC, {qi('orderNum')} ASC" + ) + ins_rows = _query_table_rows(conn, ins_sql, task_ids) + for r in ins_rows: + raw_path = str(r.get("raw_path", "")).strip() + if not raw_path: + continue + detail_rows.append( + { + "product_no": pno, + "media_type": "drawing", + "media_url": normalize_media_url(raw_path, local_prefix, cdn_prefix), + "raw_path": raw_path, + "source_schema": schema, + "source_table": "missiontask_instructions", + "source_column": "url", + } + ) + + # 4) video + video_sql = ( + f"SELECT {qi('id')} AS id, {qi('hid')} AS task_id, {qi('url')} AS raw_path, " + f"{qi('videoId')} AS video_id, {qi('orderNum')} AS order_num " + f"FROM {qi(schema)}.{qi('missiontask_video')} " + f"WHERE {qi('hid')} IN ({placeholders}) AND {qi('isDelete')} = 0 " + f"ORDER BY {qi('hid')} ASC, {qi('orderNum')} ASC" + ) + video_rows = _query_table_rows(conn, video_sql, task_ids) + for r in video_rows: + raw_path = str(r.get("raw_path", "")).strip() + video_id = str(r.get("video_id", "")).strip() + media_url = "" + source_col = "url" + if raw_path: + media_url = normalize_media_url(raw_path, local_prefix, cdn_prefix) + elif video_id: + # youku id format: XNjUyMTkzNjkxMg== + media_url = f"https://v.youku.com/v_show/id_{video_id}.html" + raw_path = video_id + source_col = "videoId" + else: + continue + + detail_rows.append( + { + "product_no": pno, + "media_type": "video", + "media_url": media_url, + "raw_path": raw_path, + "source_schema": schema, + "source_table": "missiontask_video", + "source_column": source_col, + } + ) + + table_rows.append( + { + "product_no": pno, + "source_schema": schema, + "source_table": "missiontask_instructions", + "matched_rows": len(ins_rows), + "matched_media_items": sum(1 for x in detail_rows if x.get("source_table") == "missiontask_instructions"), + } + ) + table_rows.append( + { + "product_no": pno, + "source_schema": schema, + "source_table": "missiontask_video", + "matched_rows": len(video_rows), + "matched_media_items": sum(1 for x in detail_rows if x.get("source_table") == "missiontask_video"), + } + ) + return table_rows, detail_rows + + +def query_media_records( + conn, + specs: Sequence[Dict[str, object]], + product_no: str, + row_limit: int, + local_prefix: str, + cdn_prefix: str, +) -> Tuple[List[Dict[str, object]], List[Dict[str, object]]]: + detail_rows: List[Dict[str, object]] = [] + table_rows: List[Dict[str, object]] = [] + + for spec in specs: + schema = str(spec["schema"]) + table = str(spec["table"]) + product_cols = list(spec["product_cols"]) + media_cols = list(spec["media_cols"]) + drawing_cols = set(spec.get("drawing_cols", [])) + video_cols = set(spec.get("video_cols", [])) + + select_cols = sorted(set(product_cols + media_cols)) + where_parts: List[str] = [] + params: List[object] = [] + for col in product_cols: + qcol = qi(col) + where_parts.append(f"({qcol} = %s OR {qcol} LIKE %s)") + params.extend([product_no, f"{product_no}%"]) + + if not where_parts: + continue + + sql = ( + f"SELECT {', '.join(qi(c) for c in select_cols)} " + f"FROM {qi(schema)}.{qi(table)} " + f"WHERE {' OR '.join(where_parts)} " + f"LIMIT %s" + ) + params.append(max(1, min(row_limit, 2000))) + + with conn.cursor() as cur: + cur.execute(sql, params) + data = cur.fetchall() + + if not data: + continue + + col_index = {name: i for i, name in enumerate(select_cols)} + matched_media = 0 + + for row in data: + product_val = "" + for pcol in product_cols: + value = row[col_index[pcol]] + if value is not None and str(value).strip(): + product_val = str(value).strip() + break + + for mcol in media_cols: + value = row[col_index[mcol]] + if value is None: + continue + raw_path = str(value).strip() + if not raw_path: + continue + + media_type = classify_media_type(mcol, raw_path) + if mcol in drawing_cols: + media_type = "drawing" + elif mcol in video_cols: + media_type = "video" + + normalized = normalize_media_url(raw_path, local_prefix, cdn_prefix) + detail_rows.append( + { + "product_no": product_val or product_no, + "media_type": media_type, + "media_url": normalized, + "raw_path": raw_path, + "source_schema": schema, + "source_table": table, + "source_column": mcol, + } + ) + matched_media += 1 + + table_rows.append( + { + "product_no": product_no, + "source_schema": schema, + "source_table": table, + "matched_rows": len(data), + "matched_media_items": matched_media, + } + ) + + return table_rows, detail_rows + + +def dedupe_media_rows(rows: Sequence[Dict[str, object]]) -> List[Dict[str, object]]: + seen = set() + out: List[Dict[str, object]] = [] + for row in rows: + key = ( + row.get("product_no", ""), + row.get("media_type", ""), + row.get("media_url", ""), + row.get("raw_path", ""), + row.get("source_schema", ""), + row.get("source_table", ""), + row.get("source_column", ""), + ) + if key in seen: + continue + seen.add(key) + out.append(dict(row)) + return out + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Lookup install drawing/video links by order-id -> product-no -> MySQL media DB" + ) + parser.add_argument("--order-id", default="", help="商品订单号") + parser.add_argument("--product-no", default="", help="产品货号(可跳过订单映射直接查)") + parser.add_argument("--sku-id", default="", help="SKU编码(等价于 --product-no,直接在安装库中匹配)") + parser.add_argument("--limit", type=int, default=200, help="每个来源表最多查询行数") + parser.add_argument("--max-tables", type=int, default=120, help="自动发现时最多探测表数") + parser.add_argument("--mysql-schema", default="", help="指定MySQL schema,减少扫描范围") + parser.add_argument("--mysql-table", default="", help="指定MySQL table,减少扫描范围") + parser.add_argument("--mapping-file", default=DEFAULT_MAPPING_FILE, help="固定映射配置JSON") + parser.add_argument("--local-prefix", default=os.environ.get("INSTALL_MEDIA_LOCAL_PREFIX", "D:\\JianSunTuZhiFile"), help="本地路径前缀") + parser.add_argument("--cdn-prefix", default=os.environ.get("INSTALL_MEDIA_CDN_PREFIX", "https://img.jiansuntuzhi.cn/"), help="图纸/视频CDN前缀") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + order_id = args.order_id.strip() + direct_product = args.product_no.strip() or args.sku_id.strip() + + if not order_id and not direct_product: + print("STATUS: failed") + print("ERROR: missing order-id or product-no") + return 2 + + warnings: List[str] = [] + mapping_rows: List[Dict[str, object]] = [] + + if order_id: + try: + mapping_rows.extend(query_order_product_mappings(order_id, limit=300)) + except Exception as exc: + warnings.append(f"SQLSERVER_ORDER_MAPPING_FAILED: {exc}") + + if direct_product: + mapping_rows.append( + { + "order_id": order_id or "(direct)", + "sku_id": "", + "product_no": direct_product, + "sku_name": "", + "shop_name": "", + "send_time": None, + } + ) + + # dedupe mapping by (order_id, product_no, sku_id) + dedup = {} + for row in mapping_rows: + k = (str(row.get("order_id", "")).strip(), str(row.get("product_no", "")).strip(), str(row.get("sku_id", "")).strip()) + if not k[1]: + continue + dedup[k] = row + mapping_rows = list(dedup.values()) + + product_nos = sorted({str(r.get("product_no", "")).strip() for r in mapping_rows if str(r.get("product_no", "")).strip()}) + + table_summary_rows: List[Dict[str, object]] = [] + media_rows: List[Dict[str, object]] = [] + + if product_nos: + mysql_params = mysql_conn_params() + if not mysql_params["password"]: + warnings.append("MYSQL_PASSWORD_EMPTY: please set INSTALL_MEDIA_MYSQL_PASSWORD") + else: + try: + with pymysql.connect(**mysql_params) as conn: + schema_hint = args.mysql_schema.strip() or "jiansunblueprint" + source_mode = "special_jiansun" + discovered_count = 0 + + for pno in product_nos: + ts, ms = query_jiansun_special_records( + conn=conn, + schema=schema_hint, + product_no=pno, + row_limit=max(1, args.limit), + local_prefix=args.local_prefix, + cdn_prefix=args.cdn_prefix, + ) + table_summary_rows.extend(ts) + media_rows.extend(ms) + + if not ms: + specs = load_mapping_specs(args.mapping_file) + if specs: + source_mode = "mapping_file_fallback" + else: + specs = discover_table_specs( + conn, + args.mysql_schema.strip(), + args.mysql_table.strip(), + max(1, args.max_tables), + ) + source_mode = "auto_discovery_fallback" + discovered_count = len(specs) + ts2, ms2 = query_media_records( + conn=conn, + specs=specs, + product_no=pno, + row_limit=max(1, args.limit), + local_prefix=args.local_prefix, + cdn_prefix=args.cdn_prefix, + ) + table_summary_rows.extend(ts2) + media_rows.extend(ms2) + + print(f"DISCOVERY_MODE: {source_mode}") + print(f"DISCOVERED_TABLES: {discovered_count}") + except Exception as exc: + warnings.append(f"MYSQL_LOOKUP_FAILED: {exc}") + else: + warnings.append("NO_PRODUCT_NO_FROM_ORDER: no product_no mapped from order-id") + + media_rows = dedupe_media_rows(media_rows) + + status = "ok" + if not media_rows and warnings: + status = "partial" + if not media_rows and not product_nos: + status = "failed" + + print(f"STATUS: {status}") + print(f"ORDER_ID: {order_id or '(none)'}") + print(f"PRODUCT_NO_COUNT: {len(product_nos)}") + + print("RESULT_SET 1") + print("NAME: order_to_product_mapping") + print_rows( + ["order_id", "sku_id", "product_no", "sku_name", "shop_name", "send_time"], + mapping_rows, + ) + + print("RESULT_SET 2") + print("NAME: mysql_table_match_summary") + print_rows( + ["product_no", "source_schema", "source_table", "matched_rows", "matched_media_items"], + table_summary_rows, + ) + + print("RESULT_SET 3") + print("NAME: install_media_links") + print_rows( + ["product_no", "media_type", "media_url", "raw_path", "source_schema", "source_table", "source_column"], + media_rows, + ) + + if warnings: + print("WARNINGS:") + for w in warnings: + print(f"- {w}") + + if not media_rows: + print("NEXT_STEP:") + if warnings: + print("- verify MySQL network reachability and fixed table mapping file") + else: + print("- matched sku/product but no install drawing/video rows in current tables") + + return 0 if status in {"ok", "partial"} else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/remote_sync/last_week_afs_report.sql b/remote_sync/last_week_afs_report.sql new file mode 100644 index 0000000..da0a022 --- /dev/null +++ b/remote_sync/last_week_afs_report.sql @@ -0,0 +1,95 @@ +USE [JSDataMiddlePlatform]; +GO + +/* + Unified logical layer (temp-table based, no CREATE VIEW permission required). + Week window = previous natural week: + [start_of_last_week, start_of_this_week) +*/ + +DECLARE @week_start datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()) - 1, 0) AS datetime2); +DECLARE @week_end datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()), 0) AS datetime2); + +IF OBJECT_ID('tempdb..#filtered') IS NOT NULL + DROP TABLE #filtered; + +SELECT + a.id, + NULLIF(a.undefined2, '') AS order_id, + NULLIF(a.undefined1, '') AS inner_order_id, + COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name, + NULLIF(a.undefined9, '') AS shop_code, + NULLIF(a.undefined10, '') AS sku_id, + NULLIF(a.undefined56, '') AS afs_type, + NULLIF(a.undefined57, '') AS afs_reason, + NULLIF(a.undefined58, '') AS afs_result, + ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined47, '')) AS post_ship_apply_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined50, '')) AS post_ship_return_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS post_ship_refund_amount, + a.createdAt AS created_at, + CASE + WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1 + THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1) + ELSE 'unknown' + END AS platform_name +INTO #filtered +FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK) +WHERE a.createdAt >= @week_start + AND a.createdAt < @week_end + AND ISNULL(NULLIF(a.undefined2, ''), '') <> '' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%'; + +-- 1) weekly overview +SELECT + MIN(created_at) AS data_min_time, + MAX(created_at) AS data_max_time, + COUNT(*) AS afs_record_count, + COUNT(DISTINCT order_id) AS afs_order_count, + SUM(ISNULL(post_ship_apply_amount, 0)) AS apply_amount, + SUM(ISNULL(post_ship_return_amount, 0)) AS return_amount, + SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount +FROM #filtered; + +-- 2) platform distribution +SELECT + platform_name, + COUNT(*) AS afs_record_count, + COUNT(DISTINCT order_id) AS afs_order_count, + SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount +FROM #filtered +GROUP BY platform_name +ORDER BY afs_record_count DESC; + +-- 3) shop top 20 +SELECT TOP (20) + shop_name, + platform_name, + COUNT(*) AS afs_record_count, + COUNT(DISTINCT order_id) AS afs_order_count, + SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount +FROM #filtered +GROUP BY shop_name, platform_name +ORDER BY afs_record_count DESC; + +-- 4) reason top 20 +SELECT TOP (20) + ISNULL(NULLIF(afs_reason, ''), '未标注') AS afs_reason, + COUNT(*) AS afs_record_count, + SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount, + SUM(ISNULL(post_ship_return_amount, 0)) AS return_amount +FROM #filtered +GROUP BY ISNULL(NULLIF(afs_reason, ''), '未标注') +ORDER BY afs_record_count DESC; + +-- 5) type/result matrix +SELECT + ISNULL(NULLIF(afs_type, ''), '未标注') AS afs_type, + ISNULL(NULLIF(afs_result, ''), '未标注') AS afs_result, + COUNT(*) AS afs_record_count +FROM #filtered +GROUP BY + ISNULL(NULLIF(afs_type, ''), '未标注'), + ISNULL(NULLIF(afs_result, ''), '未标注') +ORDER BY afs_record_count DESC; diff --git a/remote_sync/market-brief-report b/remote_sync/market-brief-report new file mode 100755 index 0000000..1fdc810 --- /dev/null +++ b/remote_sync/market-brief-report @@ -0,0 +1,414 @@ +#!/usr/bin/env bash +set -euo pipefail + +keyword=${1:-电脑桌} +report_scope=${2:-淘宝口径} +workdir=/tmp/market-brief +mkdir -p "$workdir" +profile=${BROWSER_PROFILE:-openclaw} + +capture_snapshot() { + local page="$1" + local url="$2" + local wait_text="${3:-}" + local limit="${4:-260}" + + yuce-browser open "$url" >/dev/null 2>&1 || true + openclaw browser wait --browser-profile "$profile" --load domcontentloaded --timeout-ms 15000 >/dev/null 2>&1 || true + openclaw browser wait --browser-profile "$profile" --time 2000 >/dev/null 2>&1 || true + if [ -n "$wait_text" ]; then + openclaw browser wait --browser-profile "$profile" --text "$wait_text" --timeout-ms 12000 >/dev/null 2>&1 || true + fi + openclaw browser snapshot --browser-profile "$profile" --labels --limit "$limit" > "$workdir/${page}.snapshot.txt" || true +} + +# Multi-page evidence collection +declare -A PAGE_URLS +PAGE_URLS[leaf_category]='https://hq.yucekj.com/?report-app=%2Freport%2F%23%2F#/scene/M_TB_TAPE_ANALYSIS/M_TB_TAPE_ANALYSIS_0/report-view/CZqcPFPRsZ' +PAGE_URLS[ranking_list]='https://hq.yucekj.com/?report-app=%2Freport%2F%23%2F#/scene/M_TB_RANKING_LIST/M_TB_RANKING_LIST_0/report-view/hEq4a98B7B' +PAGE_URLS[industry_distribution]='https://hq.yucekj.com/?report-app=%2Freport%2F%23%2F#/scene/M_TB_INSIGHT/M_TB_INDUSTRY_DISTRIBUTION/report-view/sfQrjDMn5l' +PAGE_URLS[surging_rank]='https://hq.yucekj.com/?report-app=%2Freport%2F%23%2F#/scene/M_TB_SURGING_RANK/M_TB_SURGING_RANK_0/report-view/CD6nSlf6pC' + +LOGIN_URL='https://hq.yucekj.com/#/login?redirectUrl=https%3A%2F%2Fhq.yucekj.com%2F%23%2F' +HOME_URL='https://hq.yucekj.com/#/home?tab=TAOBAO_MNO' + +yuce-browser open "$LOGIN_URL" >/dev/null 2>&1 || true +yuce-browser open "$HOME_URL" >/dev/null 2>&1 || true +openclaw browser wait --browser-profile "$profile" --time 1500 >/dev/null 2>&1 || true + +capture_snapshot leaf_category "${PAGE_URLS[leaf_category]}" "销售金额" 360 +capture_snapshot ranking_list "${PAGE_URLS[ranking_list]}" "榜单" 260 +capture_snapshot industry_distribution "${PAGE_URLS[industry_distribution]}" "行业" 220 +capture_snapshot surging_rank "${PAGE_URLS[surging_rank]}" "飙升" 280 + +yuce-browser requests --filter yucekj.com > "$workdir/requests.txt" || true + +python3 - "$workdir" "$keyword" "$report_scope" <<'PY' +import json +import re +import sys +from collections import Counter +from datetime import datetime +from pathlib import Path + +workdir = Path(sys.argv[1]) +keyword = (sys.argv[2] or "").strip() +scope = (sys.argv[3] or "").strip() + +pages = { + "leaf_category": workdir / "leaf_category.snapshot.txt", + "ranking_list": workdir / "ranking_list.snapshot.txt", + "industry_distribution": workdir / "industry_distribution.snapshot.txt", + "surging_rank": workdir / "surging_rank.snapshot.txt", +} +page_text = {k: (p.read_text(encoding="utf-8", errors="ignore") if p.exists() else "") for k, p in pages.items()} +request_text = (workdir / "requests.txt").read_text(encoding="utf-8", errors="ignore") if (workdir / "requests.txt").exists() else "" + +ROW_RE = re.compile(r'row "([^\"]+)"') +PCT_RE = re.compile(r'^-?\d+(?:\.\d+)?%$') + +def norm(s: str) -> str: + return re.sub(r"[\s/()()【】\[\]·\-_,,。::;;]", "", (s or "")).lower() + + +def amount_to_num(s: str): + if not s: + return None + t = s.replace(",", "").strip() + mul = 1.0 + if t.endswith("亿"): + mul = 1e8 + t = t[:-1] + elif t.endswith("万"): + mul = 1e4 + t = t[:-1] + try: + return float(t) * mul + except Exception: + return None + + +def pct_to_num(s: str): + if not s: + return None + t = s.strip().replace("%", "") + try: + return float(t) + except Exception: + return None + + +def keyword_candidates(kw: str): + out = [] + if kw: + out.append(kw) + cleaned = re.sub(r"(类目|市场|市场数据|市场分析|行业分析|类目分析|竞品分析|趋势分析|关键词|热词|飙升词)$", "", kw or "").strip() + if cleaned and cleaned not in out: + out.append(cleaned) + alias_map = { + "书架": ["书架", "架类书架", "置物架", "收纳架"], + "电脑桌": ["电脑桌", "书桌", "学习桌"], + "茶桌": ["茶桌", "茶几", "家用茶台"], + "床垫": ["床垫", "席梦思", "乳胶床垫"], + } + for k, vals in alias_map.items(): + if k in (kw or ""): + for v in vals: + if v not in out: + out.append(v) + return [x for x in out if len(x) >= 2] + + +def parse_leaf_rows(text: str): + rows = [] + for row in ROW_RE.findall(text): + if "查看 详情" not in row: + continue + m = re.match(r'^(.*?) 查看 详情 ([^ ]+) (-?\d+(?:\.\d+)?)%$', row) + if not m: + continue + prefix = m.group(1).strip() + amount_raw = m.group(2).strip() + yoy_raw = f"{m.group(3)}%" + toks = prefix.split() + if len(toks) < 4: + continue + one, two, three = toks[0], toks[1], toks[2] + leaf = " ".join(toks[3:]) + rows.append( + { + "row": row, + "一级类目": one, + "二级类目": two, + "三级类目": three, + "叶子类目": leaf, + "销售金额": amount_raw, + "销售金额同比": yoy_raw, + "销售金额_num": amount_to_num(amount_raw), + "销售金额同比_num": pct_to_num(yoy_raw), + } + ) + rows.sort(key=lambda r: (r["销售金额_num"] is not None, r["销售金额_num"] or -1), reverse=True) + return rows + + +def parse_ranking_rows(text: str): + rows = [] + for row in ROW_RE.findall(text): + if not row.endswith(" 查看"): + continue + m = re.match(r'^(.*?) (\d{6,20}) (\S+) (\S+) 查看$', row) + if not m: + continue + rows.append( + { + "商品名称": m.group(1).strip(), + "商品ID": m.group(2).strip(), + "店铺": m.group(3).strip(), + "品牌": m.group(4).strip(), + "row": row, + } + ) + return rows + + +def parse_surging_rows(text: str): + rows = [] + for row in ROW_RE.findall(text): + # 商品名 店铺 连续增长月份 近6月平均单价 销售额 + m = re.match(r'^(.*?) (\S+) (\d+) ([\d,]+(?:\.\d+)?) ([\d,]+(?:\.\d+)?(?:万|亿)?)$', row) + if not m: + continue + rows.append( + { + "商品": m.group(1).strip(), + "店铺": m.group(2).strip(), + "连续增长月份": int(m.group(3)), + "近6月平均单价": m.group(4).strip(), + "销售金额": m.group(5).strip(), + "销售金额_num": amount_to_num(m.group(5).strip()), + "row": row, + } + ) + rows.sort(key=lambda r: (r["销售金额_num"] is not None, r["销售金额_num"] or -1), reverse=True) + return rows + + +def parse_industry(text: str): + m = re.search(r'一级类目 销售额 销售额同比\s+([^\s]+)\s+([\d,]+(?:\.\d+)?(?:万|亿)?)\s+(-?\d+(?:\.\d+)?)%', text) + if not m: + return None + return { + "一级类目": m.group(1), + "销售额": m.group(2), + "销售额_num": amount_to_num(m.group(2)), + "销售额同比": f"{m.group(3)}%", + "销售额同比_num": float(m.group(3)), + } + + +leaf_rows = parse_leaf_rows(page_text.get("leaf_category", "")) +ranking_rows = parse_ranking_rows(page_text.get("ranking_list", "")) +surging_rows = parse_surging_rows(page_text.get("surging_rank", "")) +industry = parse_industry(page_text.get("industry_distribution", "")) + +cand_norm = [norm(x) for x in keyword_candidates(keyword)] + +def row_hit(text: str): + n = norm(text) + return any(c in n for c in cand_norm) + +kw_leaf_candidates = [r for r in leaf_rows if row_hit(f"{r['三级类目']} {r['叶子类目']} {r['row']}")] +kw_leaf = kw_leaf_candidates[0] if kw_leaf_candidates else None + +kw_rank = None +if kw_leaf and kw_leaf in leaf_rows: + kw_rank = leaf_rows.index(kw_leaf) + 1 + +same_secondary = [] +if kw_leaf: + same_secondary = [r for r in leaf_rows if r["二级类目"] == kw_leaf["二级类目"]] + +secondary_top3 = same_secondary[:3] +secondary_growth_top3 = sorted(same_secondary, key=lambda r: (r["销售金额同比_num"] is not None, r["销售金额同比_num"] or -999), reverse=True)[:3] + +top10_amt = sum([r["销售金额_num"] or 0 for r in leaf_rows[:10]]) if leaf_rows else 0 +kw_share_of_total = None +if kw_leaf and industry and industry.get("销售额_num"): + if industry["销售额_num"] > 0 and kw_leaf.get("销售金额_num") is not None: + kw_share_of_total = kw_leaf["销售金额_num"] / industry["销售额_num"] * 100 + +ranking_top20 = ranking_rows[:20] +ranking_shop_counter = Counter([r["店铺"] for r in ranking_top20]) +ranking_brand_counter = Counter([r["品牌"] for r in ranking_top20]) +ranking_kw_hits = [r for r in ranking_top20 if row_hit(f"{r['商品名称']} {r['店铺']} {r['品牌']}")] + +surging_top10 = surging_rows[:10] +surging_kw_hits = [r for r in surging_top10 if row_hit(f"{r['商品']} {r['店铺']}")] + +api_lines = [line.strip() for line in request_text.splitlines() if "/api/" in line] +api_lines = api_lines[-12:] + +coverage = { + "leaf_category": bool(page_text.get("leaf_category", "")), + "ranking_list": bool(page_text.get("ranking_list", "")), + "industry_distribution": bool(page_text.get("industry_distribution", "")), + "surging_rank": bool(page_text.get("surging_rank", "")), +} + +metrics = { + "topic": keyword, + "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "scope": scope, + "coverage": coverage, + "industry": industry, + "leaf": { + "rows_count": len(leaf_rows), + "keyword_row": kw_leaf, + "keyword_rank": kw_rank, + "keyword_share_of_total_pct": kw_share_of_total, + "top10_amount_sum": top10_amt, + "secondary_top3": secondary_top3, + "secondary_growth_top3": secondary_growth_top3, + "decline_top3": sorted(leaf_rows, key=lambda r: (r["销售金额同比_num"] is not None, r["销售金额同比_num"] or 999))[:3], + }, + "ranking": { + "rows_count": len(ranking_rows), + "top20_unique_shop_count": len(ranking_shop_counter), + "top5_shops": ranking_shop_counter.most_common(5), + "top5_brands": ranking_brand_counter.most_common(5), + "keyword_hits": ranking_kw_hits[:5], + }, + "surging": { + "rows_count": len(surging_rows), + "top10": surging_top10, + "keyword_hits": surging_kw_hits[:5], + }, + "evidence": { + "pages": [ + {"name": "叶子类目分析", "source": "leaf_category.snapshot.txt", "captured": coverage["leaf_category"]}, + {"name": "品牌店铺商品热榜", "source": "ranking_list.snapshot.txt", "captured": coverage["ranking_list"]}, + {"name": "行业分布", "source": "industry_distribution.snapshot.txt", "captured": coverage["industry_distribution"]}, + {"name": "飙升品榜单", "source": "surging_rank.snapshot.txt", "captured": coverage["surging_rank"]}, + ], + "api_lines": api_lines, + }, +} + +(workdir / "metrics.json").write_text(json.dumps(metrics, ensure_ascii=False, indent=2), encoding="utf-8") + + +def fmt_pct(v): + if v is None: + return "未在当前页面取到" + return f"{v:.2f}%" + + +def fmt_ratio(v): + if v is None: + return "未在当前页面取到" + return f"{v:.2f}%" + + +print(f"# {keyword}市场分析简报(增强版)") +print() +print("## 已抓取证据") +print(f"- 时间: {metrics['generated_at']}") +print(f"- 范围: {metrics['scope']}") +for p in metrics["evidence"]["pages"]: + print(f"- 页面: {p['name']} ({'已抓取' if p['captured'] else '未抓取'})") +if industry: + print(f"- 行业总览: 一级类目={industry['一级类目']},销售额={industry['销售额']},同比={industry['销售额同比']}") +else: + print("- 行业总览: 未在当前页面取到") +if kw_leaf: + print(f"- 关键词命中: {kw_leaf['row']}") + print(f"- 关键词排名: 第{kw_rank}/{len(leaf_rows)}(按销售金额)") +else: + print("- 关键词命中: 未在当前页面取到") +print(f"- 请求证据(API): {len(api_lines)}条(展示最近6条)") +for line in api_lines[-6:]: + print(f" - {line}") + +print() +print("## 深度分析") +print("### 1) 规模与趋势") +if kw_leaf: + print(f"- {keyword}销售金额={kw_leaf['销售金额']},同比={kw_leaf['销售金额同比']}。") + print(f"- {keyword}占一级类目销售额占比={fmt_ratio(kw_share_of_total)}。") + if industry and kw_leaf.get("销售金额同比_num") is not None and industry.get("销售额同比_num") is not None: + gap = kw_leaf["销售金额同比_num"] - industry["销售额同比_num"] + direction = "跑赢" if gap >= 0 else "跑输" + print(f"- 相对行业同比差值={gap:.2f}pct,{direction}行业大盘。") + else: + print("- 相对行业同比差值: 未在当前页面取到") +else: + print("- 关键词行未命中,暂不输出规模趋势定量结论。") + +print("### 2) 类目结构") +if kw_leaf and same_secondary: + print(f"- {keyword}位于二级类目「{kw_leaf['二级类目']}」,同二级类目共抓取{len(same_secondary)}个叶子类目。") + if secondary_top3: + top_desc = ";".join([f"{r['叶子类目']}({r['销售金额']}, {r['销售金额同比']})" for r in secondary_top3]) + print(f"- 同二级类目销售TOP3: {top_desc}") + if secondary_growth_top3: + growth_desc = ";".join([f"{r['叶子类目']}({r['销售金额同比']})" for r in secondary_growth_top3]) + print(f"- 同二级类目增速TOP3: {growth_desc}") +else: + print("- 未命中关键词二级类目,结构分析不完整。") + +print("### 3) 竞争格局") +if ranking_rows: + print(f"- 热榜样本: 抓取{len(ranking_rows)}行,TOP20覆盖店铺数={metrics['ranking']['top20_unique_shop_count']}。") + if metrics["ranking"]["top5_shops"]: + shop_desc = ";".join([f"{k}({v})" for k, v in metrics["ranking"]["top5_shops"][:3]]) + print(f"- 高频店铺(样本): {shop_desc}") + if metrics["ranking"]["keyword_hits"]: + hit_desc = ";".join([f"{r['商品名称']}[{r['店铺']}]" for r in metrics["ranking"]["keyword_hits"][:3]]) + print(f"- 关键词相关商品(热榜命中): {hit_desc}") + else: + print("- 关键词相关商品(热榜命中): 未在当前页面取到") +else: + print("- 热榜数据未抓取到有效行。") + +print("### 4) 增长机会与风险") +if surging_rows: + print(f"- 飙升榜样本: 抓取{len(surging_rows)}行,TOP10中关键词命中={len(surging_kw_hits)}。") + if surging_top10: + top_surge = ";".join([f"{r['商品']}({r['销售金额']}, 连增{r['连续增长月份']}月)" for r in surging_top10[:3]]) + print(f"- 当前飙升代表样本: {top_surge}") +else: + print("- 飙升榜数据未抓取到有效行。") + +if metrics["leaf"]["decline_top3"]: + decline_desc = ";".join([f"{r['叶子类目']}({r['销售金额同比']})" for r in metrics["leaf"]["decline_top3"][:3]]) + print(f"- 同页下滑风险叶子类目TOP3: {decline_desc}") + +print() +print("## 可执行建议") +if kw_leaf: + print(f"- P1: 围绕「{kw_leaf['二级类目']}->{kw_leaf['叶子类目']}」拆分价格带与主卖点,先对齐同比下滑原因。") +else: + print(f"- P1: 先在叶子类目页精确命中「{keyword}」后再做定量策略。") +print("- P1: 以热榜TOP店铺与品牌为对标,复刻高频素材结构(标题词/卖点图/价格锚点)。") +print("- P2: 用飙升榜样本补充新品池,优先测试连续增长月份>=5的商品方向。") +print("- P2: 对同比显著下滑类目做止损策略(预算下调+创意替换+词包重组)。") +print("- P3: 补抓价格段布局、需求趋势、关键词布局三页后再做投放预算分配。") + +print() +print("## 风险与缺口") +if not kw_leaf: + print("- 关键词主行未命中,当前报告存在样本偏差风险。") +if kw_leaf and kw_leaf.get("销售金额同比") == "未在当前页面取到": + print("- 缺失关键词同比字段,无法做趋势判断。") +print("- 当前脚本未稳定抓到销售数量/转化率字段,件数口径需在详情/字段面板补抓。") +print("- 若页面筛选条件被改动且未点查询,指标可能失真(已要求SOP强制点查询)。") + +print() +print("## 下一步") +print("- 补抓: 价格段布局 + 需求趋势分析 + 关键词布局分析(同关键词、同时间窗)。") +print("- 追加: 近3个月滚动对比,验证当前同比结论是否持续。") +print("- 输出: 可选生成“策略执行单”(词包/价格带/竞店清单)。") +print() +print(f"(结构化指标文件: {workdir / 'metrics.json'})") +PY diff --git a/remote_sync/outputs/install-media-mysql-mapping.example.json b/remote_sync/outputs/install-media-mysql-mapping.example.json new file mode 100644 index 0000000..b59b226 --- /dev/null +++ b/remote_sync/outputs/install-media-mysql-mapping.example.json @@ -0,0 +1,12 @@ +{ + "tables": [ + { + "schema": "your_schema", + "table": "your_table", + "product_cols": ["product_no"], + "drawing_cols": ["instruction_img_path"], + "video_cols": ["install_video_path"], + "media_cols": [] + } + ] +} diff --git a/remote_sync/outputs/install-media-mysql-mapping.json b/remote_sync/outputs/install-media-mysql-mapping.json new file mode 100644 index 0000000..3a14a70 --- /dev/null +++ b/remote_sync/outputs/install-media-mysql-mapping.json @@ -0,0 +1,3 @@ +{ + "tables": [] +} diff --git a/remote_sync/outputs/market-analysis-page-sop.md b/remote_sync/outputs/market-analysis-page-sop.md new file mode 100644 index 0000000..9b52fd6 --- /dev/null +++ b/remote_sync/outputs/market-analysis-page-sop.md @@ -0,0 +1,90 @@ +# 市场数据页面采集 SOP(Yuce) + +适用范围:`hq.yucekj.com` / `yucekj.com` 的市场分析任务(行业/类目/竞品/趋势)。 + +## 1. 总体硬规则 + +1. 只允许访问 `hq.yucekj.com` 与 `yucekj.com`。 +2. 先采集证据再出结论,禁止凭记忆输出数字。 +3. 每次修改筛选条件(尤其时间周期)后,必须点击一次`查询`再抓取。 +4. 结果至少包含: + - 2 个页面快照证据 + - 1 条请求证据(`yuce-browser requests --filter yucekj.com`) +5. 若出现验证码/二次验证,立即暂停并要求人工处理。 + +## 2. 标准执行流程(所有页面通用) + +1. 打开站点并确认登录态。 +2. 进入目标模块页面(优先首页卡片点击,次选已知 report URL)。 +3. 设置筛选:时间周期、类目词(如“书架”)、平台/店铺等。 +4. 点击`查询`,等待表格/图表刷新。 +5. 抓取基础页面快照。 +6. 对目标行执行展开动作(`查看`/`详情`/`更多数据`等),抓取展开页快照。 +7. 导出请求证据并与快照一起入档。 +8. 按“证据 -> 结论 -> 建议 -> 缺口”输出简报。 + +## 3. 页面级采集清单(模块 + 按钮展开) + +> 字段以页面真实表头为准;以下是最低必采维度。 + +| 模块 | 主要采集维度(最低) | 常见按钮/展开动作 | 备注 | +|---|---|---|---| +| 市场洞察 | 市场规模、同比/环比、趋势区间、核心类目 | 查询、切换周期 | 先取总览,再下钻类目 | +| 叶子类目分析 | 一级/二级/三级类目、销售额、增速、占比 | `查看`、`详情` | 书架等关键词优先在该页命中 | +| 品牌店铺商品热榜 | 品牌/店铺/商品排名、销量/销售额、价格带 | `查看`、`详情` | 热榜可做竞品对标 | +| 飙升品榜单 | 飙升商品、增速、店铺、价格段 | `查看`、`详情`、`选择字段` | 常需先勾选字段再确定 | +| 品牌布局分析 | 品牌覆盖、价格分布、类目布局 | 查询、明细展开 | 对比头部/腰部品牌 | +| 竞店布局分析 | 竞店结构、渠道布局、商品结构 | 查看明细、店铺下钻 | 结合热榜看竞店策略 | +| 价格段布局 | 价格带占比、增速、区间分布 | 区间筛选、查询 | 用于定价建议 | +| 产品需求分析 | 需求词、需求强度、关联类目 | 查看明细、词下钻 | 与关键词布局联动 | +| 需求趋势分析 | 时间序列趋势、峰谷变化 | 切周期、查询 | 必须标注时间范围 | +| 关键词布局分析 | 关键词覆盖、词排名、流量/需求信号 | 查看明细、关键词下钻 | 支撑投放与标题优化 | + +## 4. 已验证的页面入口(可直达) + +- 叶子类目分析: + `https://hq.yucekj.com/?report-app=0F6A40AFA7D89D1A6C7EE117A96CC8DFDF11BFCDB41223ABDA93283A17D56A9B6548A87026A17EC9D7CC48B4A4A62AA6&scene=M_TB_TAPE_ANALYSIS&report-view=CZqcPFPRsZ` +- 品牌店铺商品热榜: + `https://hq.yucekj.com/?report-app=0F6A40AFA7D89D1A6C7EE117A96CC8DFDF11BFCDB41223ABDA93283A17D56A9B6548A87026A17EC9D7CC48B4A4A62AA6&scene=M_TB_RANKING_LIST&report-view=hEq4a98B7B` +- 市场洞察(行业分布页): + `https://hq.yucekj.com/?report-app=0F6A40AFA7D89D1A6C7EE117A96CC8DFDF11BFCDB41223ABDA93283A17D56A9B6548A87026A17EC9D7CC48B4A4A62AA6&scene=M_TB_INSIGHT&tab=M_TB_INDUSTRY_DISTRIBUTION&report-view=sfQrjDMn5l` +- 飙升品榜单: + `https://hq.yucekj.com/?report-app=0F6A40AFA7D89D1A6C7EE117A96CC8DFDF11BFCDB41223ABDA93283A17D56A9B6548A87026A17EC9D7CC48B4A4A62AA6&scene=M_TB_SURGING_RANK&report-view=CD6nSlf6pC` + +## 5. 按钮展开采集逻辑(强制) + +1. **行命中规则**:先精确匹配,再做标准化匹配(去空格、全半角统一、符号清洗),最后做包含匹配。 +2. **`查看/详情`**: + - 点击后等待页面稳定(建议 2-5 秒) + - 立刻抓取展开页快照 + - 记录是否发生 URL 跳转(含 `jumpingFilter`) +3. **`选择字段/更多数据`**: + - 打开字段面板 + - 勾选任务所需字段 + - 点击`确定` + - 再次`查询`并抓快照 +4. **分页/滚动**:首屏无目标行时,向下滚动或翻页继续命中,直到达到最大页数或命中成功。 +5. **失败回退**: + - 先回首页重进模块一次 + - 再改用 report URL 直达一次 + - 仍失败则输出“当前账号页面未取到”并给出缺口项 + +## 6. 证据落盘规范 + +- 快照:`/tmp/market-sop/..txt` +- 点击日志:`/tmp/market-sop/.click..txt` +- 请求日志:`/tmp/market-sop/.requests.txt` +- 每次任务至少保留: + - 1 份基础页快照 + - 1 份展开页快照 + - 1 份请求日志 + +## 7. 输出模板(给市场分析技能) + +1. 已抓取证据(模块/字段/值) +2. 核心结论(逐条引用证据) +3. 可执行建议(P1/P2/P3) +4. 风险与缺口(明确“未在当前页面取到”) +5. 下一步(1-3 条) + +> 备注:市场分析结果不做来源脱敏;保留原始指标表达,禁止改写数值。 diff --git a/remote_sync/product-retention-decision b/remote_sync/product-retention-decision new file mode 100755 index 0000000..fa298a0 --- /dev/null +++ b/remote_sync/product-retention-decision @@ -0,0 +1,514 @@ +#!/usr/bin/env python3 +import argparse +import subprocess +import sys + + +def _sql_escape(value: str) -> str: + return value.replace("'", "''") + + +def build_sql( + product_no: str, + province: str, + city: str, + district: str, + days: int, + warehouse_transfer_fee: float, + depreciation_rate: float, + limit: int, +) -> str: + pno = _sql_escape(product_no.strip()) + prov = _sql_escape(province.strip()) + city_v = _sql_escape((city or "").strip()) + dist_v = _sql_escape((district or "").strip()) + wh_fee = f"{warehouse_transfer_fee:.2f}" + dep_rate = f"{depreciation_rate:.4f}" + return f""" +USE [JSDataMiddlePlatform]; +SET NOCOUNT ON; + +DECLARE @product_no nvarchar(120) = N'{pno}'; +DECLARE @province nvarchar(50) = N'{prov}'; +DECLARE @city nvarchar(50) = N'{city_v}'; +DECLARE @district nvarchar(50) = N'{dist_v}'; +DECLARE @days int = {days}; +DECLARE @limit int = {limit}; +DECLARE @warehouse_transfer_fee decimal(18,2) = {wh_fee}; +DECLARE @depreciation_rate decimal(10,4) = {dep_rate}; +DECLARE @start_time datetime2 = DATEADD(day, -@days, GETDATE()); +DECLARE @end_time datetime2 = GETDATE(); + +DECLARE @province_norm nvarchar(50) = + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(@province, ''), '省', ''), '市', ''), '壮族自治区', ''), '回族自治区', ''), '维吾尔自治区', ''), '自治区', ''), '特别行政区', ''), '地区', ''), ' ', ''); +DECLARE @city_norm nvarchar(50) = + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(@city, ''), '省', ''), '市', ''), '壮族自治区', ''), '回族自治区', ''), '维吾尔自治区', ''), '自治区', ''), '特别行政区', ''), '地区', ''), ' ', ''); +DECLARE @district_norm nvarchar(50) = + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(@district, ''), '省', ''), '市', ''), '壮族自治区', ''), '回族自治区', ''), '维吾尔自治区', ''), '自治区', ''), '特别行政区', ''), '地区', ''), ' ', ''); + +IF OBJECT_ID('tempdb..#sku_pool') IS NOT NULL + DROP TABLE #sku_pool; + +SELECT DISTINCT + s.sku_id, + s.name AS sku_name, + s.other_7 AS product_no, + s.i_id AS style_code, + s.other_1 AS spu +INTO #sku_pool +FROM dbo.jst_sku AS s WITH (NOLOCK) +WHERE ISNULL(NULLIF(s.sku_id, ''), '') <> '' + AND ( + NULLIF(s.other_7, '') = @product_no + OR NULLIF(s.i_id, '') = @product_no + OR NULLIF(s.other_1, '') = @product_no + OR NULLIF(s.sku_id, '') = @product_no + OR NULLIF(s.other_7, '') LIKE @product_no + '%' + ); + +INSERT INTO #sku_pool (sku_id, sku_name, product_no, style_code, spu) +SELECT DISTINCT + NULLIF(a.undefined10, '') AS sku_id, + NULLIF(a.undefined36, '') AS sku_name, + NULLIF(a.undefined96, '') AS product_no, + NULL, + NULL +FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK) +LEFT JOIN #sku_pool AS p + ON p.sku_id = NULLIF(a.undefined10, '') +WHERE p.sku_id IS NULL + AND ISNULL(NULLIF(a.undefined10, ''), '') <> '' + AND ( + NULLIF(a.undefined96, '') = @product_no + OR NULLIF(a.undefined96, '') LIKE @product_no + '%' + ); + +IF OBJECT_ID('tempdb..#order_base') IS NOT NULL + DROP TABLE #order_base; + +SELECT + NULLIF(a.undefined2, '') AS order_id, + NULLIF(a.undefined10, '') AS sku_id, + COALESCE(NULLIF(s.other_7, ''), NULLIF(a.undefined96, ''), @product_no) AS product_no, + ISNULL(NULLIF(a.undefined8, ''), 'unknown') AS shop_name, + ISNULL(NULLIF(a.undefined73, ''), ISNULL(NULLIF(e.logistic_company_name, ''), '未标注')) AS express_company, + COALESCE(NULLIF(a.undefined66, ''), NULLIF(s.other_3, ''), '未标注') AS ship_warehouse_name, + ISNULL(NULLIF(a.undefined74, ''), '') AS province_raw, + ISNULL(NULLIF(a.undefined75, ''), '') AS city_raw, + ISNULL(NULLIF(e.receiver_address, ''), '') AS receiver_address, + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(NULLIF(a.undefined74, ''), ''), '省', ''), '市', ''), '壮族自治区', ''), '回族自治区', ''), '维吾尔自治区', ''), '自治区', ''), '特别行政区', ''), '地区', ''), ' ', '') AS province_norm, + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(NULLIF(a.undefined75, ''), ''), '省', ''), '市', ''), '壮族自治区', ''), '回族自治区', ''), '维吾尔自治区', ''), '自治区', ''), '特别行政区', ''), '地区', ''), ' ', '') AS city_norm, + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(ISNULL(NULLIF(e.receiver_address, ''), ''), '省', ''), '市', ''), '壮族自治区', ''), '回族自治区', ''), '维吾尔自治区', ''), '自治区', ''), '特别行政区', ''), '地区', ''), ' ', '') AS receiver_address_norm, + COALESCE( + TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120), + TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120), + TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 111), + TRY_CONVERT(datetime2, NULLIF(e.send_time, ''), 120), + TRY_CONVERT(datetime2, a.createdAt) + ) AS ship_dt, + COALESCE( + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined29, '')), + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')), + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined21, '')), + CAST(0 AS decimal(18, 2)) + ) AS sale_amount, + COALESCE( + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined31, '')), + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined22, '')), + CAST(0 AS decimal(18, 2)) + ) AS cost_amount, + COALESCE( + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')), + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')), + CAST(0 AS decimal(18, 2)) + ) AS shipped_qty, + COALESCE( + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined40, '')), + CAST(0 AS decimal(18, 2)) + ) + + COALESCE( + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined49, '')), + CAST(0 AS decimal(18, 2)) + ) AS return_qty, + COALESCE( + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined90, '')), + CAST(0 AS decimal(18, 2)) + ) AS return_freight_fee, + COALESCE( + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined79, '')), + CAST(0 AS decimal(18, 2)) + ) AS warehouse_transfer_fee_obs +INTO #order_base +FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK) +INNER JOIN #sku_pool AS p + ON p.sku_id = NULLIF(a.undefined10, '') +LEFT JOIN dbo.jst_sku AS s WITH (NOLOCK) + ON s.sku_id = NULLIF(a.undefined10, '') +LEFT JOIN dbo.jst_order_express_details AS e WITH (NOLOCK) + ON NULLIF(e.so_id, '') = NULLIF(a.undefined2, '') +WHERE ISNULL(NULLIF(a.undefined2, ''), '') <> '' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%' + AND a.createdAt >= DATEADD(day, -(@days + 7), GETDATE()); + +IF OBJECT_ID('tempdb..#region_line') IS NOT NULL + DROP TABLE #region_line; + +SELECT + ob.* +INTO #region_line +FROM #order_base AS ob +WHERE ob.ship_dt >= @start_time + AND ob.ship_dt < @end_time + AND ( + (@province_norm = '' AND ISNULL(NULLIF(ob.province_norm, ''), '') <> '') + OR ob.province_norm LIKE '%' + @province_norm + '%' + OR @province_norm LIKE '%' + ob.province_norm + '%' + OR ob.receiver_address_norm LIKE '%' + @province_norm + '%' + ) + AND ( + @city_norm = '' + OR ob.city_norm LIKE '%' + @city_norm + '%' + OR @city_norm LIKE '%' + ob.city_norm + '%' + OR ob.receiver_address_norm LIKE '%' + @city_norm + '%' + ) + AND ( + @district_norm = '' + OR ob.receiver_address LIKE '%' + @district + '%' + OR ob.receiver_address_norm LIKE '%' + @district_norm + '%' + ); + +IF OBJECT_ID('tempdb..#order_agg') IS NOT NULL + DROP TABLE #order_agg; + +SELECT + rl.order_id, + MAX(rl.shop_name) AS shop_name, + MAX(rl.product_no) AS product_no, + MAX(rl.ship_dt) AS ship_dt, + SUM(ISNULL(rl.sale_amount, 0)) AS sale_amount_order, + SUM(ISNULL(rl.cost_amount, 0)) AS cost_amount_order, + SUM(ISNULL(rl.shipped_qty, 0)) AS shipped_qty_order, + SUM(ISNULL(rl.return_qty, 0)) AS return_qty_order, + SUM(ISNULL(rl.return_freight_fee, 0)) AS return_freight_fee_order, + SUM(ISNULL(rl.warehouse_transfer_fee_obs, 0)) AS warehouse_transfer_fee_order +INTO #order_agg +FROM #region_line AS rl +GROUP BY rl.order_id; + +IF OBJECT_ID('tempdb..#freight_order') IS NOT NULL + DROP TABLE #freight_order; + +SELECT + NULLIF(f.so_id, '') AS order_id, + SUM(COALESCE(TRY_CONVERT(decimal(18, 2), f.freightCost), 0)) AS outbound_freight_order +INTO #freight_order +FROM dbo.fys_jstFreightCostDetail AS f WITH (NOLOCK) +INNER JOIN #region_line AS rl + ON rl.order_id = NULLIF(f.so_id, '') + AND rl.sku_id = NULLIF(f.sku_id, '') +WHERE f.send_date >= @start_time + AND f.send_date < @end_time +GROUP BY NULLIF(f.so_id, ''); + +IF OBJECT_ID('tempdb..#order_fact') IS NOT NULL + DROP TABLE #order_fact; + +SELECT + oa.order_id, + oa.shop_name, + oa.product_no, + oa.ship_dt, + oa.sale_amount_order, + oa.cost_amount_order, + oa.shipped_qty_order, + oa.return_qty_order, + oa.return_freight_fee_order, + oa.warehouse_transfer_fee_order, + COALESCE(fo.outbound_freight_order, 0) AS outbound_freight_order +INTO #order_fact +FROM #order_agg AS oa +LEFT JOIN #freight_order AS fo + ON fo.order_id = oa.order_id; + +IF OBJECT_ID('tempdb..#kpi') IS NOT NULL + DROP TABLE #kpi; + +SELECT + COUNT(*) AS sample_order_count, + SUM(ISNULL(ofa.shipped_qty_order, 0)) AS shipped_qty_sum, + SUM(ISNULL(ofa.return_qty_order, 0)) AS return_qty_sum, + SUM(ISNULL(ofa.sale_amount_order, 0)) AS sale_amount_sum, + SUM(ISNULL(ofa.cost_amount_order, 0)) AS cost_amount_sum, + AVG(NULLIF(ISNULL(ofa.outbound_freight_order, 0), 0)) AS avg_outbound_freight_nonzero, + AVG(ISNULL(ofa.outbound_freight_order, 0)) AS avg_outbound_freight_per_order, + AVG(NULLIF(ISNULL(ofa.return_freight_fee_order, 0), 0)) AS avg_return_freight_observed, + AVG(NULLIF(ISNULL(ofa.warehouse_transfer_fee_order, 0), 0)) AS avg_warehouse_transfer_observed, + MIN(ofa.ship_dt) AS first_ship_time, + MAX(ofa.ship_dt) AS last_ship_time +INTO #kpi +FROM #order_fact AS ofa; + +-- 1) 样本概览 +SELECT + @product_no AS input_product_no, + @province AS input_province, + @city AS input_city, + @district AS input_district, + @days AS lookback_days, + (SELECT COUNT(*) FROM #sku_pool) AS matched_sku_count, + k.sample_order_count, + k.shipped_qty_sum, + k.return_qty_sum, + CASE WHEN ISNULL(k.shipped_qty_sum, 0) = 0 THEN NULL ELSE CAST(k.return_qty_sum / NULLIF(k.shipped_qty_sum, 0) AS decimal(10,4)) END AS return_rate, + k.first_ship_time, + k.last_ship_time +FROM #kpi AS k; + +-- 2) 利润估算因子(按订单均值) +SELECT + CAST(CASE WHEN k.sample_order_count = 0 THEN NULL ELSE k.sale_amount_sum / NULLIF(k.sample_order_count, 0) END AS decimal(18,2)) AS avg_sale_amount_per_order, + CAST(CASE WHEN k.sample_order_count = 0 THEN NULL ELSE k.cost_amount_sum / NULLIF(k.sample_order_count, 0) END AS decimal(18,2)) AS avg_cost_amount_per_order, + CAST(ISNULL(k.avg_outbound_freight_per_order, 0) AS decimal(18,2)) AS est_outbound_freight_per_order_15d, + CAST( + COALESCE( + k.avg_return_freight_observed, + ISNULL(k.avg_outbound_freight_per_order, 0) + * CASE WHEN ISNULL(k.shipped_qty_sum, 0) = 0 THEN 0 ELSE k.return_qty_sum / NULLIF(k.shipped_qty_sum, 0) END, + 0 + ) AS decimal(18,2) + ) AS est_return_freight_per_order_15d, + CAST(COALESCE(k.avg_warehouse_transfer_observed, @warehouse_transfer_fee, 0) AS decimal(18,2)) AS warehouse_transfer_fee_used, + CAST( + CASE WHEN k.sample_order_count = 0 THEN NULL ELSE + (k.sale_amount_sum / NULLIF(k.sample_order_count, 0)) + - (k.cost_amount_sum / NULLIF(k.sample_order_count, 0)) + - ISNULL(k.avg_outbound_freight_per_order, 0) + - COALESCE( + k.avg_return_freight_observed, + ISNULL(k.avg_outbound_freight_per_order, 0) + * CASE WHEN ISNULL(k.shipped_qty_sum, 0) = 0 THEN 0 ELSE k.return_qty_sum / NULLIF(k.shipped_qty_sum, 0) END, + 0 + ) + - COALESCE(k.avg_warehouse_transfer_observed, @warehouse_transfer_fee, 0) + END AS decimal(18,2) + ) AS est_profit_per_order, + CAST( + CASE WHEN k.sample_order_count = 0 THEN NULL + WHEN (k.sale_amount_sum / NULLIF(k.sample_order_count, 0)) = 0 THEN NULL + ELSE ( + ( + (k.sale_amount_sum / NULLIF(k.sample_order_count, 0)) + - (k.cost_amount_sum / NULLIF(k.sample_order_count, 0)) + - ISNULL(k.avg_outbound_freight_per_order, 0) + - COALESCE( + k.avg_return_freight_observed, + ISNULL(k.avg_outbound_freight_per_order, 0) + * CASE WHEN ISNULL(k.shipped_qty_sum, 0) = 0 THEN 0 ELSE k.return_qty_sum / NULLIF(k.shipped_qty_sum, 0) END, + 0 + ) + - COALESCE(k.avg_warehouse_transfer_observed, @warehouse_transfer_fee, 0) + ) + / NULLIF((k.sale_amount_sum / NULLIF(k.sample_order_count, 0)), 0) + ) + END AS decimal(10,4) + ) AS est_profit_margin +FROM #kpi AS k; + +-- 3) 处置建议(退货/补偿/放货,含退回折旧损失) +WITH decision_base AS ( + SELECT + k.sample_order_count, + CAST(CASE WHEN k.sample_order_count = 0 THEN NULL ELSE k.sale_amount_sum / NULLIF(k.sample_order_count, 0) END AS decimal(18,2)) AS avg_sale, + CAST(CASE WHEN k.sample_order_count = 0 THEN NULL ELSE k.cost_amount_sum / NULLIF(k.sample_order_count, 0) END AS decimal(18,2)) AS avg_cost, + CAST(ISNULL(k.avg_outbound_freight_per_order, 0) AS decimal(18,2)) AS avg_outbound, + CAST(COALESCE( + k.avg_return_freight_observed, + ISNULL(k.avg_outbound_freight_per_order, 0) * CASE WHEN ISNULL(k.shipped_qty_sum, 0) = 0 THEN 0 ELSE k.return_qty_sum / NULLIF(k.shipped_qty_sum, 0) END, + 0 + ) AS decimal(18,2)) AS est_return_freight, + CAST(COALESCE(k.avg_warehouse_transfer_observed, @warehouse_transfer_fee, 0) AS decimal(18,2)) AS warehouse_transfer + FROM #kpi AS k +), +calc AS ( + SELECT + db.*, + CAST(ISNULL(db.avg_cost, 0) * @depreciation_rate AS decimal(18,2)) AS depreciation_loss_if_return, + CAST((ISNULL(db.avg_outbound, 0) + ISNULL(db.est_return_freight, 0) + ISNULL(db.warehouse_transfer, 0) + (ISNULL(db.avg_cost, 0) * @depreciation_rate)) AS decimal(18,2)) AS expected_loss_if_return, + CAST((ISNULL(db.avg_sale, 0) - ISNULL(db.avg_cost, 0) - ISNULL(db.avg_outbound, 0)) AS decimal(18,2)) AS keep_order_net_before_comp, + CAST((0 - ISNULL(db.avg_outbound, 0) - ISNULL(db.est_return_freight, 0) - ISNULL(db.warehouse_transfer, 0) - (ISNULL(db.avg_cost, 0) * @depreciation_rate)) AS decimal(18,2)) AS option_return_net_profit, + CAST((0 - ISNULL(db.avg_cost, 0) - ISNULL(db.avg_outbound, 0)) AS decimal(18,2)) AS option_release_net_profit + FROM decision_base AS db +), +range_calc AS ( + SELECT + c.*, + CAST( + CASE + WHEN c.sample_order_count = 0 THEN 0 + WHEN c.keep_order_net_before_comp <= 0 THEN 0 + ELSE + CASE + WHEN (c.keep_order_net_before_comp * 0.70) < (c.expected_loss_if_return * 0.90) THEN (c.keep_order_net_before_comp * 0.70) + ELSE (c.expected_loss_if_return * 0.90) + END + END + AS decimal(18,2) + ) AS comp_high_raw + FROM calc AS c +) +SELECT + rc.sample_order_count, + rc.keep_order_net_before_comp AS est_profit_per_order, + CAST(@depreciation_rate AS decimal(10,4)) AS depreciation_rate_used, + rc.depreciation_loss_if_return, + rc.expected_loss_if_return, + CAST( + CASE + WHEN rc.sample_order_count = 0 THEN 0 + WHEN rc.comp_high_raw <= 0 THEN 0 + ELSE rc.comp_high_raw * 0.50 + END AS decimal(18,2) + ) AS compensation_low, + CAST( + CASE + WHEN rc.sample_order_count = 0 THEN 0 + WHEN rc.comp_high_raw <= 0 THEN 0 + ELSE rc.comp_high_raw + END AS decimal(18,2) + ) AS compensation_high, + rc.option_return_net_profit, + rc.option_release_net_profit, + CAST( + rc.keep_order_net_before_comp + - (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw * 0.50 END) + AS decimal(18,2) + ) AS option_discount_net_profit_low, + CAST( + rc.keep_order_net_before_comp + - (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw END) + AS decimal(18,2) + ) AS option_discount_net_profit_high, + CASE + WHEN rc.sample_order_count = 0 THEN N'样本不足' + WHEN ( + rc.keep_order_net_before_comp + - (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw END) + ) >= rc.option_return_net_profit + AND ( + rc.keep_order_net_before_comp + - (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw END) + ) >= rc.option_release_net_profit + THEN N'优惠留客' + WHEN rc.option_return_net_profit >= rc.option_release_net_profit THEN N'退回' + ELSE N'放货' + END AS recommended_option, + CASE + WHEN rc.sample_order_count = 0 THEN N'样本不足,暂不做退补决策(建议放宽省市区或延长到30天)。' + WHEN ( + rc.keep_order_net_before_comp + - (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw END) + ) >= rc.option_return_net_profit + AND ( + rc.keep_order_net_before_comp + - (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw END) + ) >= rc.option_release_net_profit + THEN N'建议优先优惠留客:在建议补偿区间内,净收益优于退回和放货。' + WHEN rc.option_return_net_profit >= rc.option_release_net_profit + THEN N'建议优先退回:按当前折旧率和运费估算,退回净损失低于放货。' + ELSE N'建议优先放货:退回综合损失(含20%折旧)高于放货损失。' + END AS recommended_action, + CASE + WHEN rc.sample_order_count >= 30 THEN 'high' + WHEN rc.sample_order_count >= 10 THEN 'medium' + WHEN rc.sample_order_count > 0 THEN 'low' + ELSE 'none' + END AS confidence_level, + CAST( + CASE + WHEN rc.sample_order_count = 0 THEN NULL + ELSE rc.expected_loss_if_return + - (CASE WHEN rc.comp_high_raw <= 0 THEN 0 ELSE rc.comp_high_raw * 0.75 END) + END AS decimal(18,2) + ) AS est_saving_vs_return +FROM range_calc AS rc; + +-- 4) 快递/仓库策略(近15天样本) +SELECT TOP (@limit) + rl.express_company, + COUNT(DISTINCT rl.order_id) AS order_count, + SUM(ISNULL(rl.shipped_qty, 0)) AS shipped_qty_sum, + CAST( + SUM(ISNULL(rl.shipped_qty, 0)) + / NULLIF((SELECT SUM(ISNULL(shipped_qty, 0)) FROM #region_line), 0) + AS decimal(10,4) + ) AS shipped_qty_ratio +FROM #region_line AS rl +GROUP BY rl.express_company +ORDER BY shipped_qty_sum DESC, order_count DESC; + +SELECT TOP (@limit) + rl.ship_warehouse_name, + COUNT(DISTINCT rl.order_id) AS order_count, + SUM(ISNULL(rl.shipped_qty, 0)) AS shipped_qty_sum, + CAST( + SUM(ISNULL(rl.shipped_qty, 0)) + / NULLIF((SELECT SUM(ISNULL(shipped_qty, 0)) FROM #region_line), 0) + AS decimal(10,4) + ) AS shipped_qty_ratio +FROM #region_line AS rl +GROUP BY rl.ship_warehouse_name +ORDER BY shipped_qty_sum DESC, order_count DESC; +""" + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Estimate profit-based aftersale decision (退回/优惠/放货) by product_no + region, including return depreciation loss" + ) + parser.add_argument("--product-no", required=True, help="商品货号(product_no/style_code/spu/SKU)") + parser.add_argument("--province", required=True, help="省份") + parser.add_argument("--city", default="", help="城市(可选)") + parser.add_argument("--district", default="", help="区县(可选)") + parser.add_argument("--days", type=int, default=15, help="lookback window, default 15") + parser.add_argument("--warehouse-transfer-fee", type=float, default=0.0, help="仓储转运费兜底值(当样本无观测值时使用)") + parser.add_argument("--depreciation-rate", type=float, default=0.20, help="退回折旧损失率,默认0.20(20%%)") + parser.add_argument("--limit", type=int, default=10, help="max rows for strategy result sets") + args = parser.parse_args() + + product_no = args.product_no.strip() + province = args.province.strip() + if not product_no: + print("--product-no is required", file=sys.stderr) + return 2 + if not province: + print("--province is required", file=sys.stderr) + return 2 + + days = max(7, min(args.days, 90)) + limit = max(1, min(args.limit, 100)) + depreciation_rate = max(0.0, min(args.depreciation_rate, 1.0)) + + sql = build_sql( + product_no=product_no, + province=province, + city=args.city, + district=args.district, + days=days, + warehouse_transfer_fee=args.warehouse_transfer_fee, + depreciation_rate=depreciation_rate, + limit=limit, + ) + cmd = ["company-db", "query", "--sql", sql] + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "company-db failed").strip() + print(err, file=sys.stderr) + return proc.returncode + + print(proc.stdout.rstrip()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/remote_sync/product-retention-decision.SKILL.md b/remote_sync/product-retention-decision.SKILL.md new file mode 100644 index 0000000..13f139a --- /dev/null +++ b/remote_sync/product-retention-decision.SKILL.md @@ -0,0 +1,63 @@ +--- +name: product-retention-decision +description: Evaluate profit-based aftersale handling by product number and region, include return depreciation loss (default 20%), and recommend among return vs discount-compensation vs release-goods. +metadata: + { "openclaw": { "emoji": "💰", "os": ["linux"], "requires": { "bins": ["company-db", "product-retention-decision"] } } } +--- + +# product-retention-decision Skill (OpenClaw) + +Use this skill when user asks: +- 货号 + 省市区,判断退货还是补偿更划算 +- 计算该订单利润后给出补偿区间 +- 希望尽量留客、降低售后成本 +- 要求把退回折旧损失(如20%)纳入决策 +- 要求比较“退回/优惠/放货”三种方案 + +## Required inputs + +- `product_no` (货号/商品ID/SPU/SKU) +- `province` +- Optional: `city`, `district` + +## Command + +```bash +product-retention-decision --product-no "<货号>" --province "<省>" [--city "<市>"] [--district "<区县>"] --days 15 --warehouse-transfer-fee 0 --depreciation-rate 0.20 --limit 10 +``` + +## Cost model (15 days) + +估算按订单均值: + +- 销售价格(avg_sale_amount_per_order) +- 产品成本(avg_cost_amount_per_order) +- 发货运费(est_outbound_freight_per_order_15d) +- 退货运费(est_return_freight_per_order_15d) +- 仓储转运费(warehouse_transfer_fee_used) +- 退回折旧损失(depreciation_loss_if_return = 产品成本 * depreciation_rate) + +公式: + +`est_profit_per_order = 销售 - 成本 - 发货运费 - 退货运费 - 仓储转运费` + +三方案净收益比较: + +- `option_return_net_profit = -(发货运费 + 退货运费 + 仓储转运费 + 退回折旧损失)` +- `option_discount_net_profit_{low/high} = (销售 - 成本 - 发货运费) - 补偿金额` +- `option_release_net_profit = -(产品成本 + 发货运费)` (放货:仅退款不退货) + +## Output result sets + +1. 样本概览(订单数、发货件数、退货率) +2. 利润估算因子(5项成本+利润) +3. 处置建议(退回/优惠/放货三方案净收益、补偿区间、推荐方案、置信度) +4. 快递策略TOP(近15天) +5. 仓库策略TOP(近15天) + +## Execution rules + +1. Always run `exec: company-db health` once per turn before this command. +2. Piece/order counts first; amount as model inputs. +3. If no sample, return clear fallback (放宽地区条件或延长周期) and do not fake compensation. +4. This route must stay in SQL only. diff --git a/remote_sync/tmp_probe_mysql.py b/remote_sync/tmp_probe_mysql.py new file mode 100644 index 0000000..762316d --- /dev/null +++ b/remote_sync/tmp_probe_mysql.py @@ -0,0 +1,19 @@ +import pymysql +conn=pymysql.connect(host='218.93.191.20',port=3306,user='read_only_jiansun',password='^9B%@iQJESZrR!',db='jiansunblueprint',charset='utf8mb4',autocommit=True) +cur=conn.cursor() +cases = [ + ("select `sku(货号)`,`工厂条形码`,`图纸ID`,`SKU型号` from sheet1 where `sku(货号)` like %s limit 20", ("%DSG003919%",), "sheet1 like DSG003919"), + ("select `sku(货号)`,`工厂条形码`,`图纸ID`,`SKU型号` from sheet1 where `sku(货号)` like %s limit 20", ("%DSG0039%",), "sheet1 like DSG0039"), + ("select id,taskId,SPU,picture,pictureId,skucodes,withInstructions,withVideo,createdTime from missiontask where skucodes like %s limit 20", ("%DSG003919%",), "missiontask skucodes like DSG003919"), + ("select id,taskId,SPU,picture,pictureId,skucodes,withInstructions,withVideo,createdTime from missiontask where skucodes like %s limit 20", ("%DSG0039%",), "missiontask skucodes like DSG0039"), + ("select id,hid,url,orderNum from missiontask_instructions where hid in (select id from missiontask where skucodes like %s limit 20) limit 40", ("%DSG0039%",), "instructions via missiontask"), + ("select id,hid,url,orderNum from missiontask_video where hid in (select id from missiontask where skucodes like %s limit 20) limit 40", ("%DSG0039%",), "video via missiontask"), +] +for sql, params, title in cases: + print(f"\n=== {title} ===") + cur.execute(sql, params) + rows = cur.fetchall() + print('rows', len(rows)) + for r in rows[:10]: + print(r) +cur.close(); conn.close() diff --git a/remote_sync/tmp_probe_mysql2.py b/remote_sync/tmp_probe_mysql2.py new file mode 100644 index 0000000..71997b2 --- /dev/null +++ b/remote_sync/tmp_probe_mysql2.py @@ -0,0 +1,14 @@ +import pymysql +hid='26de2902-8c28-4ba9-97d4-167609043fcc' +conn=pymysql.connect(host='218.93.191.20',port=3306,user='read_only_jiansun',password='^9B%@iQJESZrR!',db='jiansunblueprint',charset='utf8mb4',autocommit=True) +cur=conn.cursor() +cur.execute('select id,hid,url,orderNum from missiontask_instructions where hid=%s order by orderNum asc limit 20',(hid,)) +print('instructions',len(cur.fetchall())) +cur.execute('select id,hid,url,orderNum,videoId from missiontask_video where hid=%s order by orderNum asc limit 20',(hid,)) +rows=cur.fetchall() +print('video',len(rows)) +for r in rows[:5]: + print(r) +cur.execute('select id,picture,pictureId,skucodes,withInstructions,withVideo from missiontask where id=%s',(hid,)) +print('task',cur.fetchone()) +cur.close();conn.close() diff --git a/remote_sync/tmp_probe_mysql3.py b/remote_sync/tmp_probe_mysql3.py new file mode 100644 index 0000000..b4bb395 --- /dev/null +++ b/remote_sync/tmp_probe_mysql3.py @@ -0,0 +1,15 @@ +import pymysql +hid='26de2902-8c28-4ba9-97d4-167609043fcc' +conn=pymysql.connect(host='218.93.191.20',port=3306,user='read_only_jiansun',password='^9B%@iQJESZrR!',db='jiansunblueprint',charset='utf8mb4',autocommit=True) +cur=conn.cursor() +cur.execute('select id,hid,url,orderNum from missiontask_instructions where hid=%s order by orderNum asc limit 10',(hid,)) +rows=cur.fetchall() +print('instructions',len(rows)) +for r in rows: + print(r) +cur.execute('select id,hid,url,orderNum,videoId from missiontask_video where hid=%s order by orderNum asc limit 10',(hid,)) +rows=cur.fetchall() +print('video',len(rows)) +for r in rows: + print(r) +cur.close();conn.close() diff --git a/remote_sync/tmp_probe_mysql4.py b/remote_sync/tmp_probe_mysql4.py new file mode 100644 index 0000000..c08ac9a --- /dev/null +++ b/remote_sync/tmp_probe_mysql4.py @@ -0,0 +1,13 @@ +import pymysql +conn=pymysql.connect(host='218.93.191.20',port=3306,user='read_only_jiansun',password='^9B%@iQJESZrR!',db='jiansunblueprint',charset='utf8mb4',autocommit=True) +cur=conn.cursor() +cur.execute("select id,picture,pictureId,skucodes,withInstructions,withVideo from missiontask where withVideo='1' and isDelete='0' order by createdTime desc limit 5") +rows=cur.fetchall() +print('tasks',len(rows)) +for r in rows: + print('task',r[0],'picid',r[2],'withVideo',r[5]) + cur.execute('select id,hid,url,orderNum,videoId from missiontask_video where hid=%s order by orderNum asc limit 5',(r[0],)) + v=cur.fetchall(); print(' video_rows',len(v)); + for x in v: + print(' ',x) +cur.close(); conn.close() diff --git a/remote_sync/web-task-router.SKILL.md b/remote_sync/web-task-router.SKILL.md new file mode 100644 index 0000000..5c459d0 --- /dev/null +++ b/remote_sync/web-task-router.SKILL.md @@ -0,0 +1,131 @@ +--- +name: web-task-router +description: Route website tasks by intent using a preset site registry, then decide whether to access public pages or login with saved credentials. Use when user asks to fetch/process data from websites, with or without login, especially market analysis tasks. +metadata: + { "openclaw": { "emoji": "🌐", "os": ["linux", "darwin"] } } +--- + +# web-task-router Skill (OpenClaw) + +Use this skill to choose the right website automatically and run login/public web tasks with human-in-the-loop verification. + +## Registry file + +- Read: `/root/.openclaw/workspace/websites.yaml` +- Use `routing_rules.hard_route_rules` first, then `sites[].purposes` + `sites[].keywords`. + +## Decision flow + +1. Parse user intent (what data/action is needed). +2. Check `routing_rules.hard_route_rules` first. +3. If user text matches any `when_keywords`, force `force_site_id`. +4. If no hard rule matched, choose one best site from `purposes/keywords`. +5. If no reliable match, ask one short clarification question. +6. If matched site has `login_required: false`, open target directly and proceed. +7. If `login_required: true`, resolve credentials, login, then execute task. + +## Hard rule for market analysis + +- If user request mentions market-analysis intent (for example: "市场分析", "行业分析", "类目分析", "竞品分析", "趋势分析"), always route to `ecommerce-market-analysis`. +- In this case, never use `web_search` or `web_fetch`. +- `browser` tool is disabled for market analysis. +- Preferred execution path: use `exec` with `yuce-browser open https://hq.yucekj.com` and `yuce-browser snapshot 60`. +- Page-level extraction and button-expansion SOP: + - `/root/.openclaw/workspace/outputs/market-analysis-page-sop.md` +- Do not output market-analysis metrics unless collected from yucekj pages in this run. +- If collection fails, ask the user for OTP/CAPTCHA/manual assist and pause. + +## Hard rule for company sales / aftersale analysis + +- If user request mentions company sales intent (for example: "销售数据", "业绩", "店铺业绩", "售后", "退款", "费比"), always route to `company-sales-db-analysis`. +- This route must use SQLServer queries only. Do not open the old web dashboard for these tasks. +- Preferred execution path: + 1) `exec`: `company-db health` + 2) `exec`: `company-db tables` + 3) `exec`: `company-db run-file /root/.openclaw/workspace/sqlserver/last_week_afs_report.sql` (or targeted SQL query) +- Before writing analysis, first read field catalog: + - `/root/.openclaw/workspace/outputs/company-sales-db-catalog.md` +- If a metric cannot be mapped to known tables/fields, return missing field list and ask one precise follow-up. + +## Hard rule for install drawing/video lookup + +- If user request mentions install media intent (for example: "安装图纸", "安装视频", "安装说明书", "图纸地址", "视频地址", "订单号查图纸"), always route to `install-media-db-analysis`. +- Preferred execution path: + 1) `exec`: `install-media-lookup --order-id "<订单号>"` + 2) if user gives product number directly: `exec`: `install-media-lookup --product-no "<货号>"` +- Keep two fields in final answer: + - `raw_path` + - `media_url` (browser URL after path conversion) +- URL conversion must follow: + - `D:\\JianSunTuZhiFile\\...` -> `https://img.jiansuntuzhi.cn/...` +- If no data or MySQL connection fails, return explicit failure reason and the next actionable check. Do not fabricate links. + +## Credential resolution order + +1. If `credentials.source = env`, read `credentials.username_env` and `credentials.password_env` from environment variables. +2. If env is missing and `fallback_source = 1password`, use `username_ref/password_ref`. +3. If still unresolved, ask user for manual input. + +## Login/verification policy + +- Do not print raw password in chat. +- If OTP/CAPTCHA appears, pause and ask user for input. +- After user replies with code (or says challenge is done), resume from paused step. + +## Safety rules + +- Operate only inside `allowed_domains`. +- If redirected outside allowed domains, stop and ask user. +- Keep actions non-destructive unless user explicitly requests changes. +- Summarize final result briefly with data outcome and status. + +## Autonomous module selection (yuce market-analysis) + +Use this deterministic map before clicking pages: + +- market size / growth / trend -> 市场洞察 +- category share / fine-grained opportunities -> 叶子类目分析 +- brand/shop/product competition -> 品牌店铺商品热榜, 竞店布局分析 +- consumer demand / keyword changes -> 产品需求分析, 需求趋势分析, 关键词布局分析 +- price strategy / price band -> 价格段布局, 品牌布局分析 + +Execution requirements: + +1. Build a mini plan: requested metrics -> target modules (inside yuce only). +2. Open/login yuce, navigate by module names, capture snapshots as evidence. +3. If any metric lacks visible data, return "not available in current view" instead of guessing. +4. Never use `cron` for market-analysis tasks. +5. Never output final numbers before evidence capture. + +### Strict module whitelist + +Only use these exact module names in planning or reporting: +市场洞察 / 叶子类目分析 / 品牌店铺商品热榜 / 飙升品榜单 / 品牌布局分析 / 竞店布局分析 / 价格段布局 / 产品需求分析 / 需求趋势分析 / 关键词布局分析 + +If model generates non-whitelist names, discard and regenerate with whitelist names only. + +## Analysis brief workflow (mandatory for market tasks) + +When user asks for market analysis report/brief: + +1. Route to yuce modules by requested metrics. +2. Collect evidence first: + - `yuce-browser open https://hq.yucekj.com` + - `yuce-browser snapshot ` on at least 2 relevant views + - `yuce-browser requests --filter yucekj.com` + - If any filter/date changed, click `查询` first (mandatory in SOP) +3. Build brief with fixed sections: + - 已抓取证据 + - 核心结论 + - 可执行建议 + - 风险与缺口 + - 下一步 +4. Mark inferred statements as `推断`, never present as hard fact. +5. If evidence is incomplete, stop at gap list and ask user one precise follow-up. + +### Deterministic brief command + +For requests like "分析简报/结论+建议", run: +- `market-brief-report ` + +Use command output as primary evidence text. Do not rewrite numeric values. diff --git a/remote_sync/web-task-router.remote.SKILL.md b/remote_sync/web-task-router.remote.SKILL.md new file mode 100644 index 0000000..b8a8bc9 --- /dev/null +++ b/remote_sync/web-task-router.remote.SKILL.md @@ -0,0 +1,119 @@ +--- +name: web-task-router +description: Route website tasks by intent using a preset site registry, then decide whether to access public pages or login with saved credentials. Use when user asks to fetch/process data from websites, with or without login, especially market analysis tasks. +metadata: + { "openclaw": { "emoji": "🌐", "os": ["linux", "darwin"] } } +--- + +# web-task-router Skill (OpenClaw) + +Use this skill to choose the right website automatically and run login/public web tasks with human-in-the-loop verification. + +## Registry file + +- Read: `/root/.openclaw/workspace/websites.yaml` +- Use `routing_rules.hard_route_rules` first, then `sites[].purposes` + `sites[].keywords`. + +## Decision flow + +1. Parse user intent (what data/action is needed). +2. Check `routing_rules.hard_route_rules` first. +3. If user text matches any `when_keywords`, force `force_site_id`. +4. If no hard rule matched, choose one best site from `purposes/keywords`. +5. If no reliable match, ask one short clarification question. +6. If matched site has `login_required: false`, open target directly and proceed. +7. If `login_required: true`, resolve credentials, login, then execute task. + +## Hard rule for market analysis + +- If user request mentions market-analysis intent (for example: "市场分析", "行业分析", "类目分析", "竞品分析", "趋势分析"), always route to `ecommerce-market-analysis`. +- In this case, never use `web_search` or `web_fetch`. +- `browser` tool is disabled for market analysis. +- Preferred execution path: use `exec` with `yuce-browser open https://hq.yucekj.com` and `yuce-browser snapshot 60`. +- Do not output market-analysis metrics unless collected from yucekj pages in this run. +- If collection fails, ask the user for OTP/CAPTCHA/manual assist and pause. + +## Hard rule for company sales / aftersale analysis + +- If user request mentions company sales intent (for example: "销售数据", "业绩", "店铺业绩", "售后", "退款", "费比", "客服体验", "用户反馈", "话术分析", "产品信息", "SKU编码", "材质", "预览图"), always route to `company-sales-db-analysis`. +- This route must use SQLServer queries only. Do not open the old web dashboard for these tasks. +- Preferred execution path: + 1) `exec`: `company-db health` + 2) Product info query (`SKU/成本/材质/预览图`): `exec`: `product-info "" --days 30 --limit 20` + 3) Product-ID SKU list query: `exec`: `product-skus-by-id "<商品id>" --limit 1000` + 4) Sales/aftersale analysis: `exec`: `company-db run-file /root/.openclaw/workspace/sqlserver/last_week_afs_report.sql` (or targeted SQL query) + 5) Customer-service experience/writing analysis: `exec`: `customer-service-analysis --start --end [--shop "店铺关键词"] --limit 20` +- For customer-service analysis outputs, include at least 2 dialogue snippets and recommended reply wording. +- For customer-service analysis outputs, include capability scoring by agent name (4 dimensions + overall grade). +- Before writing analysis, first read field catalog: + - `/root/.openclaw/workspace/outputs/company-sales-db-catalog.md` +- If a metric cannot be mapped to known tables/fields, return missing field list and ask one precise follow-up. + +## Credential resolution order + +1. If `credentials.source = env`, read `credentials.username_env` and `credentials.password_env` from environment variables. +2. If env is missing and `fallback_source = 1password`, use `username_ref/password_ref`. +3. If still unresolved, ask user for manual input. + +## Login/verification policy + +- Do not print raw password in chat. +- If OTP/CAPTCHA appears, pause and ask user for input. +- After user replies with code (or says challenge is done), resume from paused step. + +## Safety rules + +- Operate only inside `allowed_domains`. +- If redirected outside allowed domains, stop and ask user. +- Keep actions non-destructive unless user explicitly requests changes. +- Summarize final result briefly with data outcome and status. + +## Autonomous module selection (yuce market-analysis) + +Use this deterministic map before clicking pages: + +- market size / growth / trend -> 市场洞察 +- category share / fine-grained opportunities -> 叶子类目分析 +- brand/shop/product competition -> 品牌店铺商品热榜, 竞店布局分析 +- consumer demand / keyword changes -> 产品需求分析, 需求趋势分析, 关键词布局分析 +- price strategy / price band -> 价格段布局, 品牌布局分析 + +Execution requirements: + +1. Build a mini plan: requested metrics -> target modules (inside yuce only). +2. Open/login yuce, navigate by module names, capture snapshots as evidence. +3. If any metric lacks visible data, return "not available in current view" instead of guessing. +4. Never use `cron` for market-analysis tasks. +5. Never output final numbers before evidence capture. + +### Strict module whitelist + +Only use these exact module names in planning or reporting: +市场洞察 / 叶子类目分析 / 品牌店铺商品热榜 / 飙升品榜单 / 品牌布局分析 / 竞店布局分析 / 价格段布局 / 产品需求分析 / 需求趋势分析 / 关键词布局分析 + +If model generates non-whitelist names, discard and regenerate with whitelist names only. + +## Analysis brief workflow (mandatory for market tasks) + +When user asks for market analysis report/brief: + +1. Route to yuce modules by requested metrics. +2. Collect evidence first: + - `yuce-browser open https://hq.yucekj.com` + - `yuce-browser snapshot ` on at least 2 relevant views + - `yuce-browser requests --filter yucekj.com` +3. Build brief with fixed sections: + - 已抓取证据 + - 核心结论 + - 可执行建议 + - 风险与缺口 + - 下一步 +4. Mark inferred statements as `推断`, never present as hard fact. +5. If evidence is incomplete, stop at gap list and ask user one precise follow-up. + +### Deterministic brief command + +For requests like "分析简报/结论+建议", run: +- `market-brief-report ` + +Use command output as primary evidence text. Do not rewrite numeric values. diff --git a/remote_sync/websites.remote.yaml b/remote_sync/websites.remote.yaml new file mode 100644 index 0000000..5953446 --- /dev/null +++ b/remote_sync/websites.remote.yaml @@ -0,0 +1,187 @@ +version: 1 +default_policy: + credential_store: env + otp_policy: ask_in_chat + captcha_policy: pause_and_ask + domain_guard: true + ask_before_destructive: true +routing_rules: + prefer_exact_keyword_match: true + fallback_site_id: public-search + ask_when_ambiguous: true + hard_route_rules: + - when_keywords: + - 小红书 + - 红书 + - xhs + - 笔记 + - 种草 + - 小红书数据 + force_site_id: xiaohongshu-mcp + note: 小红书相关任务强制走本地 xiaohongshu-mcp 服务。 + - when_keywords: + - 市场分析 + - 行业分析 + - 类目分析 + - 竞品分析 + - 趋势分析 + force_site_id: ecommerce-market-analysis + note: 只要提到市场分析相关任务,强制走电商市场分析站点。 + - when_keywords: + - 销售数据 + - 销售报表 + - 销售看板 + - 公司销售 + - 售后数据 + - 售后分析 + - 退款分析 + - 退货分析 + - 业绩报表 + - 费比监控 + - 业绩数据 + - 业绩 + - 上周业绩 + - 产品信息 + - 商品ID + - SKU编码 + - 产品材质 + - 成本价 + - 预览图 + - 店铺业绩 + - 旗舰店业绩 + - 客服体验 + - 用户反馈 + - 话术分析 + - 上周 + force_site_id: company-sales-db-analysis + note: 销售/售后类任务强制走 SQLServer 数据库查询分析,不再走业绩网页。 +sites: +- id: xiaohongshu-mcp + name: 小红书数据服务(本地MCP) + purposes: + - 小红书数据查询 + - 小红书笔记搜索 + - 小红书互动分析 + keywords: + - 小红书 + - 红书 + - xhs + - 笔记 + - 种草 + login_required: true + entry_url: http://127.0.0.1:18060 + login_url: http://127.0.0.1:18060/api/v1/login/qrcode + allowed_domains: + - 127.0.0.1 + - localhost + notes: 通过 xhs-api 本地命令调用 xiaohongshu-mcp,先检查登录状态再执行查询。 +- id: public-search + name: 公开搜索(免登录) + purposes: + - 通用搜索 + - 公开资料查询 + - 无需登录任务 + keywords: + - 搜索 + - 查一下 + - 公开信息 + - 网页查询 + - 新闻 + - 资料 + login_required: false + entry_url: https://www.bing.com + allowed_domains: + - bing.com + - cn.bing.com +- id: ecommerce-market-analysis + name: 电商市场分析数据查询 + purposes: + - 电商市场分析 + - 行业数据查询 + - 竞品分析 + - 销量趋势 + keywords: + - 市场分析 + - 电商分析 + - 行业数据 + - 竞品 + - 趋势 + - yuce + - 预测科技 + login_required: true + entry_url: https://hq.yucekj.com + login_url: https://hq.yucekj.com/#/login?redirectUrl=https%3A%2F%2Fhq.yucekj.com%2F%23%2F + allowed_domains: + - hq.yucekj.com + - yucekj.com + credentials: + source: env + username_env: SITE_ECOMMERCE_MARKET_ANALYSIS_USERNAME + password_env: SITE_ECOMMERCE_MARKET_ANALYSIS_PASSWORD + fallback_source: 1password + username_ref: op://Business/ecommerce-market-analysis/username + password_ref: op://Business/ecommerce-market-analysis/password + notes: 登录后按用户任务提取报表/图表/指标。 +- id: company-sales-db-analysis + name: 公司销售/售后数据库分析 + purposes: + - 公司销售数据查询 + - 售后与退款分析 + - 店铺与平台对比 + - 费用与费比分析 + - 物流与安装协同分析 + keywords: + - 销售数据 + - 销售报表 + - 售后数据 + - 售后分析 + - 退款分析 + - 费比监控 + - 业绩数据 + - 业绩 + - 店铺业绩 + - 旗舰店业绩 + - 客服体验 + - 用户反馈 + - 话术分析 + - 产品信息 + - 商品ID + - SKU编码 + - 产品材质 + - 成本价 + - 预览图 + login_required: false + connector: sqlserver + data_source: + host_env: COMPANY_DB_HOST + port_env: COMPANY_DB_PORT + database_env: COMPANY_DB_NAME + user_env: COMPANY_DB_USER + password_env: COMPANY_DB_PASSWORD + allowed_tables: + - jst_goods_profit_kpi_items_afs + - jst_sku + - fys_shopInfo + - jst_order_express_details + - fys_SkuDataLibrary_SpuConfig + - op_operManage + - op_TargetManagement + - fys_Ticketing_install + - fys_Ticketing_install_item + - fys_jstFreightCost + - fys_jstFreightCostDetail + - pc_kfChatLog + query_principles: + - 销售/售后任务优先使用 SQL 查询,不再进入业绩网页。 + - 先读字段目录再写查询:/root/.openclaw/workspace/outputs/company-sales-db-catalog.md + - 优先复用模板 SQL:/root/.openclaw/workspace/sqlserver/last_week_afs_report.sql + - 产品信息查询(SKU编码/成本/材质/预览图)优先命令:`product-info "" --days 30 --limit 20` + - 商品ID下SKU清单查询优先命令:`product-skus-by-id "<商品id>" --limit 1000` + - 客服体验/话术分析优先命令:`customer-service-analysis --start 2026-02-01 --end 2026-02-09 --shop "亿家达" --limit 20` + - 客服体验/话术分析输出需包含:问题结构、话术覆盖、风险对话案例、建议回复、按客服名字的能力评分与等级。 + - 售后分析必须包含原因维度(一级/二级/来源),优先模板:/root/.openclaw/workspace/sqlserver/after_sale_360_report.sql + - 旧模板 /root/.openclaw/workspace/sqlserver/last_week_after_sale_analysis.sql 仅作为兼容入口,实际应输出售后360口径。 + - 默认以发货件数/售后件数/订单数作为主口径,金额字段仅作补充说明。 + - 非用户明确要求时,禁止按金额排序输出榜单,优先按件数排序。 + - 输出需注明:时间范围、表名、关键字段、是否排除配件/特殊单。 + notes: 统一数据源为 SQLServer。涉及公司销售、售后、退款、费比等任务,强制走数据库分析。 diff --git a/remote_sync/websites.yaml b/remote_sync/websites.yaml new file mode 100644 index 0000000..8f98cbe --- /dev/null +++ b/remote_sync/websites.yaml @@ -0,0 +1,210 @@ +version: 1 +default_policy: + credential_store: env + otp_policy: ask_in_chat + captcha_policy: pause_and_ask + domain_guard: true + ask_before_destructive: true +routing_rules: + prefer_exact_keyword_match: true + fallback_site_id: public-search + ask_when_ambiguous: true + hard_route_rules: + - when_keywords: + - 小红书 + - 红书 + - xhs + - 笔记 + - 种草 + - 小红书数据 + force_site_id: xiaohongshu-mcp + note: 小红书相关任务强制走本地 xiaohongshu-mcp 服务。 + - when_keywords: + - 市场分析 + - 行业分析 + - 类目分析 + - 竞品分析 + - 趋势分析 + force_site_id: ecommerce-market-analysis + note: 只要提到市场分析相关任务,强制走电商市场分析站点。 + - when_keywords: + - 安装图纸 + - 安装视频 + - 安装说明书 + - 图纸地址 + - 视频地址 + - 安装教程 + - 订单号查图纸 + - 订单号查视频 + force_site_id: install-media-db-analysis + note: 安装图纸/安装视频相关任务,强制走订单映射+安装资源数据库查询。 + - when_keywords: + - 销售数据 + - 销售报表 + - 销售看板 + - 公司销售 + - 售后数据 + - 售后分析 + - 退款分析 + - 退货分析 + - 业绩报表 + - 费比监控 + - 业绩数据 + - 业绩 + - 上周业绩 + - 店铺业绩 + - 旗舰店业绩 + - 上周 + force_site_id: company-sales-db-analysis + note: 销售/售后类任务强制走 SQLServer 数据库查询分析,不再走业绩网页。 +sites: +- id: xiaohongshu-mcp + name: 小红书数据服务(本地MCP) + purposes: + - 小红书数据查询 + - 小红书笔记搜索 + - 小红书互动分析 + keywords: + - 小红书 + - 红书 + - xhs + - 笔记 + - 种草 + login_required: true + entry_url: http://127.0.0.1:18060 + login_url: http://127.0.0.1:18060/api/v1/login/qrcode + allowed_domains: + - 127.0.0.1 + - localhost + notes: 通过 xhs-api 本地命令调用 xiaohongshu-mcp,先检查登录状态再执行查询。 +- id: public-search + name: 公开搜索(免登录) + purposes: + - 通用搜索 + - 公开资料查询 + - 无需登录任务 + keywords: + - 搜索 + - 查一下 + - 公开信息 + - 网页查询 + - 新闻 + - 资料 + login_required: false + entry_url: https://www.bing.com + allowed_domains: + - bing.com + - cn.bing.com +- id: ecommerce-market-analysis + name: 电商市场分析数据查询 + purposes: + - 电商市场分析 + - 行业数据查询 + - 竞品分析 + - 销量趋势 + keywords: + - 市场分析 + - 电商分析 + - 行业数据 + - 竞品 + - 趋势 + - yuce + - 预测科技 + login_required: true + entry_url: https://hq.yucekj.com + login_url: https://hq.yucekj.com/#/login?redirectUrl=https%3A%2F%2Fhq.yucekj.com%2F%23%2F + allowed_domains: + - hq.yucekj.com + - yucekj.com + credentials: + source: env + username_env: SITE_ECOMMERCE_MARKET_ANALYSIS_USERNAME + password_env: SITE_ECOMMERCE_MARKET_ANALYSIS_PASSWORD + fallback_source: 1password + username_ref: op://Business/ecommerce-market-analysis/username + password_ref: op://Business/ecommerce-market-analysis/password + notes: 登录后按用户任务提取报表/图表/指标。 +- id: install-media-db-analysis + name: 安装图纸与安装视频数据库查询 + purposes: + - 订单号查询安装图纸 + - 订单号查询安装视频 + - 货号查询安装说明书资源 + keywords: + - 安装图纸 + - 安装视频 + - 安装说明书 + - 图纸地址 + - 视频地址 + - 安装教程 + login_required: false + connector: sqlserver+mysql + data_source: + sqlserver: + host_env: COMPANY_DB_HOST + port_env: COMPANY_DB_PORT + database_env: COMPANY_DB_NAME + user_env: COMPANY_DB_USER + password_env: COMPANY_DB_PASSWORD + mysql: + host_env: INSTALL_MEDIA_MYSQL_HOST + port_env: INSTALL_MEDIA_MYSQL_PORT + user_env: INSTALL_MEDIA_MYSQL_USER + password_env: INSTALL_MEDIA_MYSQL_PASSWORD + query_principles: + - 先按订单号映射产品货号,再按货号查询安装图纸/安装视频。 + - 优先命令:`install-media-lookup --order-id "<订单号>"`。 + - 返回时保留 raw_path,同时返回浏览器可访问的 media_url。 + - 路径转换规则:`D:\\JianSunTuZhiFile\\...` -> `https://img.jiansuntuzhi.cn/...`。 + - 若未命中资源,返回空结果与下一步,不编造链接。 + notes: 专用于安装图纸/视频查询,禁止路由到市场分析或销售报表页面。 +- id: company-sales-db-analysis + name: 公司销售/售后数据库分析 + purposes: + - 公司销售数据查询 + - 售后与退款分析 + - 店铺与平台对比 + - 费用与费比分析 + - 物流与安装协同分析 + keywords: + - 销售数据 + - 销售报表 + - 售后数据 + - 售后分析 + - 退款分析 + - 费比监控 + - 业绩数据 + - 业绩 + - 店铺业绩 + - 旗舰店业绩 + login_required: false + connector: sqlserver + data_source: + host_env: COMPANY_DB_HOST + port_env: COMPANY_DB_PORT + database_env: COMPANY_DB_NAME + user_env: COMPANY_DB_USER + password_env: COMPANY_DB_PASSWORD + allowed_tables: + - jst_goods_profit_kpi_items_afs + - jst_sku + - fys_shopInfo + - jst_order_express_details + - fys_SkuDataLibrary_SpuConfig + - op_operManage + - op_TargetManagement + - fys_Ticketing_install + - fys_Ticketing_install_item + - fys_jstFreightCost + - fys_jstFreightCostDetail + - pc_kfChatLog + query_principles: + - 销售/售后任务优先使用 SQL 查询,不再进入业绩网页。 + - 先读字段目录再写查询:/root/.openclaw/workspace/outputs/company-sales-db-catalog.md + - 优先复用模板 SQL:/root/.openclaw/workspace/sqlserver/last_week_afs_report.sql + - 售后分析必须包含原因维度(一级/二级/来源),优先模板:/root/.openclaw/workspace/sqlserver/after_sale_360_report.sql + - 旧模板 /root/.openclaw/workspace/sqlserver/last_week_after_sale_analysis.sql 仅作为兼容入口,实际应输出售后360口径。 + - 默认以发货件数/售后件数/订单数作为主口径,金额字段仅作补充说明。 + - 非用户明确要求时,禁止按金额排序输出榜单,优先按件数排序。 + - 输出需注明:时间范围、表名、关键字段、是否排除配件/特殊单。 + notes: 统一数据源为 SQLServer。涉及公司销售、售后、退款、费比等任务,强制走数据库分析。 diff --git a/set_default_feishu_target.py b/set_default_feishu_target.py new file mode 100644 index 0000000..1866f1e --- /dev/null +++ b/set_default_feishu_target.py @@ -0,0 +1,53 @@ +from pathlib import Path + +DEFAULT_LINK = "https://acnuoqgha8fk.feishu.cn/wiki/RBSxwq8woiQDCUkRZfkcg7x8nMb?table=tblzuMy85WiRAUX5&view=vewuydm9EI" +DEFAULT_APP_TOKEN = "SZ4ebCKU3a9OdfspetxcSeg1n4g" +DEFAULT_TABLE_ID = "tblzuMy85WiRAUX5" + +agents = Path('/root/.openclaw/workspace/AGENTS.md') +text = agents.read_text(encoding='utf-8') + +start = text.find('## 5) Feishu Delivery Rules\n') +end = text.find('\n## 6) Workspace Paths\n', start) +if start == -1 or end == -1: + raise SystemExit('Feishu section not found in AGENTS.md') + +new_section = f'''## 5) Feishu Delivery Rules + +Trigger keywords: 推到飞书表格 / 写入飞书 / 同步飞书 / 发送飞书表格链接 + +Default target (pinned): +- default_link: {DEFAULT_LINK} +- default_app_token: {DEFAULT_APP_TOKEN} +- default_table_id: {DEFAULT_TABLE_ID} + +Required behavior: +- When user asks to push result to Feishu Bitable, run write action first (feishu_bitable_create_record or feishu_bitable_update_record). +- In continuous chat, if user says "把上一个结果推到飞书", use the previous assistant result as write payload. +- If user does not provide a link in current turn, ALWAYS use the pinned default target above. +- Do NOT ask user for Feishu link again unless user explicitly asks to switch to another table. +- In final reply, always include: + 1) push status (success/failed) + 2) target sheet link (actual link used) + 3) written record_id (or updated count) +- If write partially fails, return succeeded records and failed reason list separately. +- If user explicitly asks for "只要结果", keep reasoning short but still include the sheet link and write status line. + +''' + +text = text[:start] + new_section + text[end+1:] +agents.write_text(text, encoding='utf-8') +print('updated', agents) + +user = Path('/root/.openclaw/workspace/USER.md') +ut = user.read_text(encoding='utf-8') +line = f'- 飞书推送默认固定到:{DEFAULT_LINK}' +line2 = '- 除非我明确要求切换,不要再向我索要飞书表格链接。' +if line not in ut: + if not ut.endswith('\n'): + ut += '\n' + ut += line + '\n' +if line2 not in ut: + ut += line2 + '\n' +user.write_text(ut, encoding='utf-8') +print('updated', user) diff --git a/sku_stock_risk_report.sql b/sku_stock_risk_report.sql new file mode 100644 index 0000000..ff8f48d --- /dev/null +++ b/sku_stock_risk_report.sql @@ -0,0 +1,222 @@ +USE [JSDataMiddlePlatform]; +SET NOCOUNT ON; + +DECLARE @sku nvarchar(100) = N'CJ019317N1097V01'; +DECLARE @lookback_days int = 30; +DECLARE @lead_days int = 10; +DECLARE @has_exact bit = 0; +DECLARE @start_time datetime2 = DATEADD(day, -@lookback_days, GETDATE()); +DECLARE @end_time datetime2 = GETDATE(); + +IF EXISTS (SELECT 1 FROM dbo.jst_sku WITH (NOLOCK) WHERE sku_id = @sku) + SET @has_exact = 1; + +IF OBJECT_ID('tempdb..#matched_sku') IS NOT NULL + DROP TABLE #matched_sku; + +SELECT TOP (50) + s.sku_id, + s.name AS sku_name, + s.i_id AS style_code, + s.other_1 AS spu, + s.other_7 AS product_no, + s.cost_price, + s.other_10 AS board_material, + s.pic AS preview_pic_url, + s.modified +INTO #matched_sku +FROM dbo.jst_sku AS s WITH (NOLOCK) +WHERE + (@has_exact = 1 AND s.sku_id = @sku) + OR ( + @has_exact = 0 + AND ( + s.sku_id = @sku + OR s.i_id = @sku + OR s.other_1 = @sku + OR s.other_7 = @sku + OR s.sku_id LIKE '%' + @sku + '%' + ) + ) +ORDER BY CASE WHEN s.sku_id = @sku THEN 0 ELSE 1 END, s.modified DESC; + +IF OBJECT_ID('tempdb..#inv_latest') IS NOT NULL + DROP TABLE #inv_latest; + +WITH inv_rank AS ( + SELECT + NULLIF(i.sku_id, '') AS sku_id, + TRY_CONVERT(decimal(18, 2), i.qty) AS stock_qty, + TRY_CONVERT(decimal(18, 2), i.order_lock) AS order_lock_qty, + TRY_CONVERT(decimal(18, 2), i.pick_lock) AS pick_lock_qty, + TRY_CONVERT(decimal(18, 2), i.lock_qty) AS lock_qty, + TRY_CONVERT(decimal(18, 2), i.virtual_qty) AS virtual_qty, + TRY_CONVERT(decimal(18, 2), i.defective_qty) AS defective_qty, + TRY_CONVERT(decimal(18, 2), i.purchase_qty) AS purchase_qty, + TRY_CONVERT(decimal(18, 2), i.return_qty) AS return_qty, + TRY_CONVERT(decimal(18, 2), i.in_qty) AS in_qty, + TRY_CONVERT(decimal(18, 2), i.min_qty) AS min_qty, + TRY_CONVERT(decimal(18, 2), i.max_qty) AS max_qty, + i.wms_co_id, + i.name AS inventory_note, + COALESCE(i.updateTime, i.modified, i.createTime) AS inventory_snapshot_time, + ROW_NUMBER() OVER ( + PARTITION BY NULLIF(i.sku_id, '') + ORDER BY COALESCE(i.updateTime, i.modified, i.createTime) DESC, i.Id DESC + ) AS rn + FROM dbo.jst_inventoryInfo AS i WITH (NOLOCK) + WHERE NULLIF(i.sku_id, '') IN (SELECT sku_id FROM #matched_sku) +) +SELECT + sku_id, + stock_qty, + order_lock_qty, + pick_lock_qty, + lock_qty, + virtual_qty, + defective_qty, + purchase_qty, + return_qty, + in_qty, + min_qty, + max_qty, + wms_co_id, + inventory_note, + inventory_snapshot_time, + CASE + WHEN ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0) < 0 + THEN CAST(0 AS decimal(18, 2)) + ELSE ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0) + END AS available_qty +INTO #inv_latest +FROM inv_rank +WHERE rn = 1; + +IF OBJECT_ID('tempdb..#ship_base') IS NOT NULL + DROP TABLE #ship_base; + +SELECT + NULLIF(a.undefined10, '') AS sku_id, + NULLIF(a.undefined2, '') AS order_id, + COALESCE( + TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120), + TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120), + TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 111), + TRY_CONVERT(datetime2, a.createdAt) + ) AS ship_dt, + COALESCE( + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')), + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')), + CAST(0 AS decimal(18, 2)) + ) AS shipped_qty +INTO #ship_base +FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK) +WHERE NULLIF(a.undefined10, '') IN (SELECT sku_id FROM #matched_sku) + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%'; + +IF OBJECT_ID('tempdb..#ship_daily') IS NOT NULL + DROP TABLE #ship_daily; + +SELECT + sb.sku_id, + CAST(sb.ship_dt AS date) AS ship_date, + COUNT(DISTINCT sb.order_id) AS order_count, + SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum +INTO #ship_daily +FROM #ship_base AS sb +WHERE sb.ship_dt >= @start_time + AND sb.ship_dt < @end_time +GROUP BY sb.sku_id, CAST(sb.ship_dt AS date); + +IF OBJECT_ID('tempdb..#ship_stats') IS NOT NULL + DROP TABLE #ship_stats; + +SELECT + sd.sku_id, + SUM(CASE WHEN sd.ship_date >= CAST(DATEADD(day, -7, @end_time) AS date) THEN ISNULL(sd.shipped_qty_sum, 0) ELSE 0 END) AS shipped_qty_7d, + SUM(CASE WHEN sd.ship_date >= CAST(DATEADD(day, -30, @end_time) AS date) THEN ISNULL(sd.shipped_qty_sum, 0) ELSE 0 END) AS shipped_qty_30d, + MAX(sd.ship_date) AS last_ship_date +INTO #ship_stats +FROM #ship_daily AS sd +GROUP BY sd.sku_id; + +-- 1) SKU基础信息 + 库存快照 +SELECT + ms.sku_id, + ms.sku_name, + ms.style_code, + ms.spu, + ms.product_no, + ms.cost_price, + ms.board_material, + ms.preview_pic_url, + inv.stock_qty, + inv.order_lock_qty, + inv.pick_lock_qty, + inv.lock_qty, + inv.available_qty, + inv.inventory_snapshot_time, + inv.wms_co_id +FROM #matched_sku AS ms +LEFT JOIN #inv_latest AS inv + ON inv.sku_id = ms.sku_id +ORDER BY ms.modified DESC; + +-- 2) 近N天每日总发货件数 +SELECT + sd.sku_id, + ms.sku_name, + sd.ship_date, + sd.order_count, + sd.shipped_qty_sum +FROM #ship_daily AS sd +LEFT JOIN #matched_sku AS ms + ON ms.sku_id = sd.sku_id +ORDER BY sd.ship_date DESC; + +-- 3) 缺货预估与补货建议(补货安全线=10天) +SELECT + ms.sku_id, + ms.sku_name, + inv.available_qty, + ss.last_ship_date, + CAST(ISNULL(ss.shipped_qty_7d, 0) / 7.0 AS decimal(18, 2)) AS avg_daily_shipped_7d, + CAST(ISNULL(ss.shipped_qty_30d, 0) / 30.0 AS decimal(18, 2)) AS avg_daily_shipped_30d, + CAST( + CASE + WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN NULL + ELSE inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0) + END AS decimal(18, 2) + ) AS estimated_stockout_days, + @lead_days AS replenishment_lead_days, + CAST( + CASE + WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN 0 + WHEN inv.available_qty >= (ISNULL(ss.shipped_qty_30d, 0) / 30.0) * @lead_days THEN 0 + ELSE ((ISNULL(ss.shipped_qty_30d, 0) / 30.0) * @lead_days) - inv.available_qty + END AS decimal(18, 2) + ) AS suggest_replenish_qty_to_10d, + CASE + WHEN inv.sku_id IS NULL THEN '无库存快照' + WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN '近30天无发货' + WHEN inv.available_qty <= 0 THEN '缺货/可用库存<=0' + WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= @lead_days THEN '补货紧急(<=10天)' + WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= (@lead_days + 5) THEN '补货预警(10-15天)' + ELSE '库存安全' + END AS stock_risk_level +FROM #matched_sku AS ms +LEFT JOIN #inv_latest AS inv + ON inv.sku_id = ms.sku_id +LEFT JOIN #ship_stats AS ss + ON ss.sku_id = ms.sku_id +ORDER BY + CASE + WHEN inv.sku_id IS NULL THEN 9 + WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN 8 + WHEN inv.available_qty <= 0 THEN 1 + WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= @lead_days THEN 2 + WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= (@lead_days + 5) THEN 3 + ELSE 4 + END, + estimated_stockout_days ASC; diff --git a/sqlserver/README_SQL_TEMPLATES.md b/sqlserver/README_SQL_TEMPLATES.md new file mode 100644 index 0000000..ad96a0f --- /dev/null +++ b/sqlserver/README_SQL_TEMPLATES.md @@ -0,0 +1,71 @@ +# 销售分析常用 SQL 模板包 + +数据源:`JSDataMiddlePlatform`(SQLServer) +执行方式:`company-db run-file ` + +默认分析口径(已按最新要求): +- 主口径优先:`发货件数 / 售后件数 / 订单数` +- 金额口径:仅补充展示;除非明确要求,不作为主排序维度 + +## 模板列表 + +1. `shop_weekly_report.sql` + - 用途:店铺周报(平台/店铺销售、退款、毛利、高退款风险店铺) + - 默认时间:上一个自然周 + +2. `sku_structure_report.sql` + - 用途:SKU结构分析(类目结构、SKU Top、价格带结构) + - 默认时间:最近30天 + +3. `refund_reason_report.sql` + - 用途:退款原因分析(全局原因Top、平台内原因Top、店铺风险清单、类型矩阵) + - 默认时间:最近30天 + +4. `fee_ratio_alert.sql` + - 用途:费比异常预警(店铺级P1/P2/P3预警、全局日趋势) + - 默认时间:最近14天 + +5. `logistics_sla_report.sql` + - 用途:物流时效/SLA分析(支付到发货、发货到签收、延迟预警) + - 默认时间:最近30天 + +6. `freight_efficiency_report.sql` + - 用途:运费效率分析(单票成本、单位重量成本、省份/快递组合成本) + - 默认时间:最近30天 + +7. `install_service_quality_report.sql` + - 用途:安装服务质量(工单状态、服务商表现、待审核区域) + - 默认时间:最近90天 + +8. `customer_service_hotspot_report.sql` + - 用途:客服问题热点(问题Top、店铺问题热点、日趋势) + - 默认时间:最近30天 + +9. `link_target_coverage_report.sql` + - 用途:链接目标覆盖(在线链接、运营负载、类目/价格带分布) + - 默认时间:在线链接全量 + +10. `after_sale_360_report.sql` + - 用途:售后360分析(退款金额、一级/二级原因、类型矩阵、安装工单联动) + - 默认时间:上一个自然周 + +## 运行示例 + +```bash +company-db run-file /root/.openclaw/workspace/sqlserver/shop_weekly_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/sku_structure_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/refund_reason_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/fee_ratio_alert.sql +company-db run-file /root/.openclaw/workspace/sqlserver/logistics_sla_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/freight_efficiency_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/install_service_quality_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/customer_service_hotspot_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/link_target_coverage_report.sql +company-db run-file /root/.openclaw/workspace/sqlserver/after_sale_360_report.sql +``` + +## 注意事项 + +- 所有模板都默认过滤 `配件` 和 `特殊单` 标签。 +- 金额字段多为字符串,模板已使用 `TRY_CONVERT` 做安全转换。 +- 如需自定义时间,修改每个模板顶部变量即可。 diff --git a/sqlserver/after_sale_360_report.sql b/sqlserver/after_sale_360_report.sql new file mode 100644 index 0000000..174e833 --- /dev/null +++ b/sqlserver/after_sale_360_report.sql @@ -0,0 +1,193 @@ +USE [JSDataMiddlePlatform]; +GO + +/* + Template: 售后360分析(退款 + 原因 + 工单 + 客诉) + Window: 上一个自然周 [week_start, week_end) +*/ + +DECLARE @week_start datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()) - 1, 0) AS datetime2); +DECLARE @week_end datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()), 0) AS datetime2); + +IF OBJECT_ID('tempdb..#chat_latest') IS NOT NULL + DROP TABLE #chat_latest; + +WITH chat_rank AS ( + SELECT + NULLIF(c.orderNo, '') AS order_id, + ISNULL(NULLIF(c.mainReason, ''), '') AS main_reason, + ISNULL(NULLIF(c.secondReason, ''), '') AS second_reason, + ISNULL(NULLIF(c.thirdReason, ''), '') AS third_reason, + c.createdAt, + ROW_NUMBER() OVER ( + PARTITION BY NULLIF(c.orderNo, '') + ORDER BY c.createdAt DESC, c.id DESC + ) AS rn + FROM dbo.pc_kfChatLog AS c WITH (NOLOCK) + WHERE c.createdAt >= @week_start + AND c.createdAt < @week_end + AND ISNULL(NULLIF(c.orderNo, ''), '') <> '' +) +SELECT + order_id, + main_reason, + second_reason, + third_reason +INTO #chat_latest +FROM chat_rank +WHERE rn = 1; + +IF OBJECT_ID('tempdb..#afs') IS NOT NULL + DROP TABLE #afs; + +SELECT + NULLIF(a.undefined2, '') AS order_id, + COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name, + CASE + WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1 + THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1) + ELSE 'unknown' + END AS platform_name, + ISNULL(NULLIF(a.undefined56, ''), '未标注') AS afs_type, + ISNULL(NULLIF(a.undefined58, ''), '未标注') AS afs_result, + ISNULL(NULLIF(a.undefined57, ''), '') AS native_reason, + ISNULL(cl.main_reason, '') AS chat_main_reason, + ISNULL(cl.second_reason, '') AS chat_second_reason, + ISNULL(cl.third_reason, '') AS chat_third_reason, + CASE + WHEN ISNULL(NULLIF(a.undefined57, ''), '') <> '' THEN ISNULL(NULLIF(a.undefined57, ''), '') + WHEN ISNULL(cl.main_reason, '') <> '' THEN cl.main_reason + ELSE '未标注' + END AS afs_reason_l1, + CASE + WHEN ISNULL(cl.second_reason, '') <> '' THEN cl.second_reason + ELSE '未标注' + END AS afs_reason_l2, + CASE + WHEN ISNULL(cl.third_reason, '') <> '' THEN cl.third_reason + ELSE '未标注' + END AS afs_reason_l3, + CASE + WHEN ISNULL(NULLIF(a.undefined57, ''), '') <> '' THEN 'afs_reason' + WHEN ISNULL(cl.main_reason, '') <> '' THEN 'chat_main_reason' + ELSE 'unknown' + END AS reason_source, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')) AS order_qty, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')) AS shipped_qty, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')) AS payment_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined47, '')) AS apply_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined50, '')) AS return_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount +INTO #afs +FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK) +LEFT JOIN #chat_latest AS cl + ON cl.order_id = NULLIF(a.undefined2, '') +WHERE a.createdAt >= @week_start + AND a.createdAt < @week_end + AND ISNULL(NULLIF(a.undefined2, ''), '') <> '' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%'; + +IF OBJECT_ID('tempdb..#install') IS NOT NULL + DROP TABLE #install; + +SELECT + NULLIF(i.OrderNum, '') AS order_id, + i.SubmitTime, + TRY_CONVERT(int, i.StatusId) AS status_id, + TRY_CONVERT(int, i.installComplete) AS install_complete, + TRY_CONVERT(decimal(18, 2), i.Num) AS install_num, + TRY_CONVERT(decimal(18, 2), i.PayMent) AS payment_amount, + TRY_CONVERT(decimal(18, 2), i.installcost) AS install_cost, + TRY_CONVERT(decimal(18, 2), i.settleAmount) AS settle_amount, + TRY_CONVERT(int, i.kfsh) AS kf_audit, + TRY_CONVERT(int, i.cwsh) AS cw_audit, + ISNULL(NULLIF(i.Province, ''), 'unknown') AS province, + ISNULL(NULLIF(i.City, ''), 'unknown') AS city +INTO #install +FROM dbo.fys_Ticketing_install AS i WITH (NOLOCK) +WHERE i.SubmitTime >= @week_start + AND i.SubmitTime < @week_end + AND ISNULL(NULLIF(i.OrderNum, ''), '') <> '' + AND (i.[Delete] = 0 OR i.[Delete] IS NULL); + +-- 1) 售后总览(退款口径) +SELECT + @week_start AS week_start, + @week_end AS week_end, + COUNT(*) AS afs_record_count, + COUNT(DISTINCT order_id) AS afs_order_count, + SUM(ISNULL(order_qty, 0)) AS order_qty_sum, + SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum, + SUM(ISNULL(payment_amount, 0)) AS payment_amount, + SUM(ISNULL(refund_amount, 0)) AS refund_amount, + SUM(ISNULL(return_amount, 0)) AS return_amount, + CASE + WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL + ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0)) + END AS refund_rate +FROM #afs; + +-- 2) 售后原因分析(一级原因 + 来源) +SELECT TOP (30) + afs_reason_l1 AS afs_reason, + reason_source, + COUNT(*) AS afs_record_count, + COUNT(DISTINCT order_id) AS order_count, + SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum, + SUM(ISNULL(refund_amount, 0)) AS refund_amount, + CASE + WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL + ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0)) + END AS reason_refund_rate +FROM #afs +GROUP BY afs_reason_l1, reason_source +ORDER BY afs_record_count DESC; + +-- 3) 售后二级原因 TOP20 +SELECT TOP (20) + afs_reason_l1, + afs_reason_l2, + COUNT(*) AS afs_record_count, + SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum, + SUM(ISNULL(refund_amount, 0)) AS refund_amount +FROM #afs +GROUP BY afs_reason_l1, afs_reason_l2 +ORDER BY afs_record_count DESC; + +-- 4) 售后类型/结果矩阵 +SELECT + afs_type, + afs_result, + COUNT(*) AS afs_record_count, + SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum, + SUM(ISNULL(refund_amount, 0)) AS refund_amount +FROM #afs +GROUP BY afs_type, afs_result +ORDER BY afs_record_count DESC; + +-- 5) 安装工单概览(同周期) +SELECT + COUNT(*) AS install_ticket_count, + COUNT(DISTINCT order_id) AS install_order_count, + SUM(ISNULL(install_num, 0)) AS install_piece_count, + SUM(ISNULL(payment_amount, 0)) AS install_payment_amount, + SUM(ISNULL(install_cost, 0)) AS install_cost, + SUM(ISNULL(settle_amount, 0)) AS settle_amount, + SUM(CASE WHEN ISNULL(install_complete, 0) = 1 THEN 1 ELSE 0 END) AS install_completed_count, + SUM(CASE WHEN ISNULL(kf_audit, 0) <> 1 OR ISNULL(cw_audit, 0) <> 1 THEN 1 ELSE 0 END) AS pending_audit_count +FROM #install; + +-- 6) 售后退款与安装工单联动(按订单) +SELECT + COUNT(*) AS overlap_order_count, + SUM(ISNULL(a.refund_amount, 0)) AS overlap_refund_amount, + SUM(ISNULL(i.install_cost, 0)) AS overlap_install_cost, + SUM(CASE WHEN ISNULL(i.install_complete, 0) = 1 THEN 1 ELSE 0 END) AS overlap_install_completed_count +FROM ( + SELECT DISTINCT order_id, refund_amount + FROM #afs + WHERE ISNULL(order_id, '') <> '' +) a +INNER JOIN #install i + ON i.order_id = a.order_id; diff --git a/sqlserver/customer_service_hotspot_report.sql b/sqlserver/customer_service_hotspot_report.sql new file mode 100644 index 0000000..6b747ae --- /dev/null +++ b/sqlserver/customer_service_hotspot_report.sql @@ -0,0 +1,79 @@ +USE [JSDataMiddlePlatform]; +GO + +/* + Template: 客服问题热点分析 + Window: 最近30天 [date_from, date_to) +*/ + +DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -30, CAST(GETDATE() AS date)) AS datetime2); +DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2); + +IF OBJECT_ID('tempdb..#base') IS NOT NULL + DROP TABLE #base; + +SELECT + c.id, + c.orderNo AS order_id, + ISNULL(NULLIF(c.shopName, ''), 'unknown') AS shop_name, + ISNULL(NULLIF(e.platform_name, ''), 'unknown') AS platform_name, + ISNULL(NULLIF(c.mainReason, ''), '未标注') AS main_reason, + ISNULL(NULLIF(c.secondReason, ''), '未标注') AS second_reason, + ISNULL(NULLIF(c.thirdReason, ''), '未标注') AS third_reason, + c.createdAt +INTO #base +FROM dbo.pc_kfChatLog AS c WITH (NOLOCK) +LEFT JOIN dbo.jst_order_express_details AS e WITH (NOLOCK) + ON e.so_id = c.orderNo +WHERE c.createdAt >= @date_from + AND c.createdAt < @date_to + AND ISNULL(NULLIF(c.orderNo, ''), '') <> ''; + +-- 1) 总览 +SELECT + @date_from AS date_from, + @date_to AS date_to, + COUNT(*) AS chat_count, + COUNT(DISTINCT order_id) AS order_count, + COUNT(DISTINCT shop_name) AS shop_count +FROM #base; + +-- 2) 一级问题 TOP30 +SELECT TOP (30) + platform_name, + main_reason, + COUNT(*) AS chat_count, + COUNT(DISTINCT order_id) AS order_count +FROM #base +GROUP BY platform_name, main_reason +ORDER BY chat_count DESC; + +-- 3) 一级+二级问题组合 TOP30 +SELECT TOP (30) + main_reason, + second_reason, + COUNT(*) AS chat_count, + COUNT(DISTINCT order_id) AS order_count +FROM #base +GROUP BY main_reason, second_reason +ORDER BY chat_count DESC; + +-- 4) 店铺热点问题 +SELECT TOP (50) + shop_name, + platform_name, + main_reason, + COUNT(*) AS chat_count, + COUNT(DISTINCT order_id) AS order_count +FROM #base +GROUP BY shop_name, platform_name, main_reason +ORDER BY chat_count DESC; + +-- 5) 每日趋势 +SELECT + CAST(createdAt AS date) AS biz_date, + COUNT(*) AS chat_count, + COUNT(DISTINCT order_id) AS order_count +FROM #base +GROUP BY CAST(createdAt AS date) +ORDER BY biz_date DESC; diff --git a/sqlserver/fee_ratio_alert.sql b/sqlserver/fee_ratio_alert.sql new file mode 100644 index 0000000..d30407b --- /dev/null +++ b/sqlserver/fee_ratio_alert.sql @@ -0,0 +1,179 @@ +USE [JSDataMiddlePlatform]; +GO + +/* + Template: 费比异常预警 + Window: 最近14天 [date_from, date_to) +*/ + +DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -14, CAST(GETDATE() AS date)) AS datetime2); +DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2); + +IF OBJECT_ID('tempdb..#base') IS NOT NULL + DROP TABLE #base; + +SELECT + CAST(a.createdAt AS date) AS biz_date, + COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name, + CASE + WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1 + THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1) + ELSE 'unknown' + END AS platform_name, + NULLIF(a.undefined2, '') AS order_id, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')) AS payment_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined82, '')) AS fee_commission, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined85, '')) AS fee_taoke, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined86, '')) AS fee_freight_insurance, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined97, '')) AS fee_promotion, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined102, '')) AS fee_software_service, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined108, '')) AS fee_taobao_commission, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined109, '')) AS fee_jd_commission, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined110, '')) AS fee_pdd_commission, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined111, '')) AS fee_taogongchang_commission, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined112, '')) AS fee_alibaba_commission, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined113, '')) AS fee_douyin_commission, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined114, '')) AS fee_maoning_commission, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined115, '')) AS fee_vip_mp_commission, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined116, '')) AS fee_vip_commission, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined117, '')) AS fee_dewu_commission, + ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels +INTO #base +FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK) +WHERE a.createdAt >= @date_from + AND a.createdAt < @date_to + AND ISNULL(NULLIF(a.undefined2, ''), '') <> '' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%'; + +IF OBJECT_ID('tempdb..#agg') IS NOT NULL + DROP TABLE #agg; + +SELECT + shop_name, + platform_name, + COUNT(DISTINCT order_id) AS order_count, + SUM(ISNULL(payment_amount, 0)) AS payment_amount, + SUM(ISNULL(refund_amount, 0)) AS refund_amount, + SUM( + ABS(ISNULL(fee_commission, 0)) + + ABS(ISNULL(fee_taoke, 0)) + + ABS(ISNULL(fee_freight_insurance, 0)) + + ABS(ISNULL(fee_promotion, 0)) + + ABS(ISNULL(fee_software_service, 0)) + + ABS(ISNULL(fee_taobao_commission, 0)) + + ABS(ISNULL(fee_jd_commission, 0)) + + ABS(ISNULL(fee_pdd_commission, 0)) + + ABS(ISNULL(fee_taogongchang_commission, 0)) + + ABS(ISNULL(fee_alibaba_commission, 0)) + + ABS(ISNULL(fee_douyin_commission, 0)) + + ABS(ISNULL(fee_maoning_commission, 0)) + + ABS(ISNULL(fee_vip_mp_commission, 0)) + + ABS(ISNULL(fee_vip_commission, 0)) + + ABS(ISNULL(fee_dewu_commission, 0)) + ) AS fee_amount_abs +INTO #agg +FROM #base +GROUP BY shop_name, platform_name; + +-- 1) 全局预警总览 +SELECT + @date_from AS date_from, + @date_to AS date_to, + COUNT(*) AS shop_count, + SUM(order_count) AS order_count, + SUM(payment_amount) AS payment_amount, + SUM(refund_amount) AS refund_amount, + SUM(fee_amount_abs) AS fee_amount_abs, + CASE + WHEN SUM(payment_amount) = 0 THEN NULL + ELSE SUM(refund_amount) / SUM(payment_amount) + END AS refund_rate, + CASE + WHEN SUM(payment_amount) = 0 THEN NULL + ELSE SUM(fee_amount_abs) / SUM(payment_amount) + END AS fee_abs_rate +FROM #agg; + +-- 2) 店铺级异常清单 +SELECT + shop_name, + platform_name, + order_count, + payment_amount, + refund_amount, + fee_amount_abs, + CASE + WHEN payment_amount = 0 THEN NULL + ELSE refund_amount / payment_amount + END AS refund_rate, + CASE + WHEN payment_amount = 0 THEN NULL + ELSE fee_amount_abs / payment_amount + END AS fee_abs_rate, + CASE + WHEN payment_amount = 0 THEN 'P3' + WHEN (fee_amount_abs / payment_amount) >= 0.35 OR (refund_amount / payment_amount) >= 0.25 THEN 'P1' + WHEN (fee_amount_abs / payment_amount) >= 0.25 OR (refund_amount / payment_amount) >= 0.18 THEN 'P2' + ELSE 'P3' + END AS alert_level +FROM #agg +WHERE order_count >= 30 +ORDER BY + CASE + WHEN payment_amount = 0 THEN 0 + ELSE fee_amount_abs / payment_amount + END DESC, + refund_amount DESC; + +-- 3) 日趋势(全局) +SELECT + biz_date, + COUNT(DISTINCT order_id) AS order_count, + SUM(ISNULL(payment_amount, 0)) AS payment_amount, + SUM(ISNULL(refund_amount, 0)) AS refund_amount, + SUM( + ABS(ISNULL(fee_commission, 0)) + + ABS(ISNULL(fee_taoke, 0)) + + ABS(ISNULL(fee_freight_insurance, 0)) + + ABS(ISNULL(fee_promotion, 0)) + + ABS(ISNULL(fee_software_service, 0)) + + ABS(ISNULL(fee_taobao_commission, 0)) + + ABS(ISNULL(fee_jd_commission, 0)) + + ABS(ISNULL(fee_pdd_commission, 0)) + + ABS(ISNULL(fee_taogongchang_commission, 0)) + + ABS(ISNULL(fee_alibaba_commission, 0)) + + ABS(ISNULL(fee_douyin_commission, 0)) + + ABS(ISNULL(fee_maoning_commission, 0)) + + ABS(ISNULL(fee_vip_mp_commission, 0)) + + ABS(ISNULL(fee_vip_commission, 0)) + + ABS(ISNULL(fee_dewu_commission, 0)) + ) AS fee_amount_abs, + CASE + WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL + ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0)) + END AS refund_rate, + CASE + WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL + ELSE SUM( + ABS(ISNULL(fee_commission, 0)) + + ABS(ISNULL(fee_taoke, 0)) + + ABS(ISNULL(fee_freight_insurance, 0)) + + ABS(ISNULL(fee_promotion, 0)) + + ABS(ISNULL(fee_software_service, 0)) + + ABS(ISNULL(fee_taobao_commission, 0)) + + ABS(ISNULL(fee_jd_commission, 0)) + + ABS(ISNULL(fee_pdd_commission, 0)) + + ABS(ISNULL(fee_taogongchang_commission, 0)) + + ABS(ISNULL(fee_alibaba_commission, 0)) + + ABS(ISNULL(fee_douyin_commission, 0)) + + ABS(ISNULL(fee_maoning_commission, 0)) + + ABS(ISNULL(fee_vip_mp_commission, 0)) + + ABS(ISNULL(fee_vip_commission, 0)) + + ABS(ISNULL(fee_dewu_commission, 0)) + ) / SUM(ISNULL(payment_amount, 0)) + END AS fee_abs_rate +FROM #base +GROUP BY biz_date +ORDER BY biz_date DESC; diff --git a/sqlserver/freight_efficiency_report.sql b/sqlserver/freight_efficiency_report.sql new file mode 100644 index 0000000..4d80fd1 --- /dev/null +++ b/sqlserver/freight_efficiency_report.sql @@ -0,0 +1,103 @@ +USE [JSDataMiddlePlatform]; +GO + +/* + Template: 运费效率分析 + Window: 最近30天 [date_from, date_to) +*/ + +DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -30, CAST(GETDATE() AS date)) AS datetime2); +DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2); + +IF OBJECT_ID('tempdb..#base') IS NOT NULL + DROP TABLE #base; + +SELECT + fc.so_id AS order_id, + ISNULL(NULLIF(fc.shop_name, ''), 'unknown') AS shop_name, + CASE + WHEN CHARINDEX('-', ISNULL(fc.shop_name, '')) > 1 + THEN LEFT(fc.shop_name, CHARINDEX('-', fc.shop_name) - 1) + ELSE 'unknown' + END AS platform_name, + ISNULL(NULLIF(fc.receiver_state, ''), 'unknown') AS receiver_state, + ISNULL(NULLIF(fc.expressName, ''), 'unknown') AS express_name, + TRY_CONVERT(decimal(18, 2), fc.weight) AS weight, + TRY_CONVERT(decimal(18, 2), fc.freightCost) AS freight_cost, + fc.send_date, + fc.type AS freight_type +INTO #base +FROM dbo.fys_jstFreightCost AS fc WITH (NOLOCK) +WHERE fc.send_date >= @date_from + AND fc.send_date < @date_to + AND ISNULL(NULLIF(fc.so_id, ''), '') <> ''; + +-- 1) 总览 +SELECT + @date_from AS date_from, + @date_to AS date_to, + COUNT(*) AS shipment_count, + SUM(ISNULL(weight, 0)) AS total_weight, + SUM(ISNULL(freight_cost, 0)) AS total_freight_cost, + CASE + WHEN COUNT(*) = 0 THEN NULL + ELSE SUM(ISNULL(freight_cost, 0)) / COUNT(*) + END AS avg_cost_per_order, + CASE + WHEN SUM(ISNULL(weight, 0)) = 0 THEN NULL + ELSE SUM(ISNULL(freight_cost, 0)) / SUM(ISNULL(weight, 0)) + END AS avg_cost_per_kg +FROM #base; + +-- 2) 平台运费效率 +SELECT + platform_name, + COUNT(*) AS shipment_count, + SUM(ISNULL(weight, 0)) AS total_weight, + SUM(ISNULL(freight_cost, 0)) AS total_freight_cost, + CASE + WHEN COUNT(*) = 0 THEN NULL + ELSE SUM(ISNULL(freight_cost, 0)) / COUNT(*) + END AS avg_cost_per_order, + CASE + WHEN SUM(ISNULL(weight, 0)) = 0 THEN NULL + ELSE SUM(ISNULL(freight_cost, 0)) / SUM(ISNULL(weight, 0)) + END AS avg_cost_per_kg +FROM #base +GROUP BY platform_name +ORDER BY shipment_count DESC; + +-- 3) 店铺运费异常(发货单>=100) +SELECT TOP (50) + shop_name, + platform_name, + COUNT(*) AS shipment_count, + SUM(ISNULL(weight, 0)) AS total_weight, + SUM(ISNULL(freight_cost, 0)) AS total_freight_cost, + CASE + WHEN COUNT(*) = 0 THEN NULL + ELSE SUM(ISNULL(freight_cost, 0)) / COUNT(*) + END AS avg_cost_per_order, + CASE + WHEN SUM(ISNULL(weight, 0)) = 0 THEN NULL + ELSE SUM(ISNULL(freight_cost, 0)) / SUM(ISNULL(weight, 0)) + END AS avg_cost_per_kg +FROM #base +GROUP BY shop_name, platform_name +HAVING COUNT(*) >= 100 +ORDER BY avg_cost_per_kg DESC, shipment_count DESC; + +-- 4) 省份与快递公司组合成本 +SELECT TOP (50) + receiver_state, + express_name, + COUNT(*) AS shipment_count, + SUM(ISNULL(weight, 0)) AS total_weight, + SUM(ISNULL(freight_cost, 0)) AS total_freight_cost, + CASE + WHEN SUM(ISNULL(weight, 0)) = 0 THEN NULL + ELSE SUM(ISNULL(freight_cost, 0)) / SUM(ISNULL(weight, 0)) + END AS avg_cost_per_kg +FROM #base +GROUP BY receiver_state, express_name +ORDER BY total_freight_cost DESC; diff --git a/sqlserver/install_service_quality_report.sql b/sqlserver/install_service_quality_report.sql new file mode 100644 index 0000000..e9b9baf --- /dev/null +++ b/sqlserver/install_service_quality_report.sql @@ -0,0 +1,106 @@ +USE [JSDataMiddlePlatform]; +GO + +/* + Template: 安装服务质量分析 + Window: 最近90天 [date_from, date_to) +*/ + +DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -90, CAST(GETDATE() AS date)) AS datetime2); +DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2); + +IF OBJECT_ID('tempdb..#ticket') IS NOT NULL + DROP TABLE #ticket; + +SELECT + i.Id, + i.SubmitTime, + i.OrderNum, + ISNULL(NULLIF(i.ShopCode, ''), 'unknown') AS shop_code, + ISNULL(NULLIF(i.Province, ''), 'unknown') AS province, + ISNULL(NULLIF(i.City, ''), 'unknown') AS city, + ISNULL(NULLIF(i.Operation, ''), 'unknown') AS operation_user, + ISNULL(NULLIF(i.sourceFrom, ''), 'unknown') AS source_from, + TRY_CONVERT(int, i.StatusId) AS status_id, + TRY_CONVERT(int, i.installComplete) AS install_complete, + TRY_CONVERT(decimal(18, 2), i.PayMent) AS payment_amount, + TRY_CONVERT(decimal(18, 2), i.FinalPayMent) AS final_payment_amount, + TRY_CONVERT(decimal(18, 2), i.installcost) AS install_cost, + TRY_CONVERT(decimal(18, 2), i.settleAmount) AS settle_amount, + TRY_CONVERT(decimal(18, 2), i.acceptAmount) AS accept_amount, + TRY_CONVERT(int, i.kfsh) AS kf_audit, + TRY_CONVERT(int, i.cwsh) AS cw_audit, + i.SourceGuid +INTO #ticket +FROM dbo.fys_Ticketing_install AS i WITH (NOLOCK) +WHERE i.SubmitTime >= @date_from + AND i.SubmitTime < @date_to + AND (i.[Delete] = 0 OR i.[Delete] IS NULL) + AND ISNULL(NULLIF(i.OrderNum, ''), '') <> ''; + +IF OBJECT_ID('tempdb..#item_agg') IS NOT NULL + DROP TABLE #item_agg; + +SELECT + ii.guid AS source_guid, + COUNT(*) AS item_count, + SUM(ISNULL(ii.installcount, 0)) AS install_count, + SUM(ISNULL(ii.Payfee, 0)) AS pay_fee_sum, + SUM(ISNULL(ii.applyItemAmount, 0)) AS apply_item_amount_sum, + SUM(ISNULL(ii.settleItemAmount, 0)) AS settle_item_amount_sum, + SUM(ISNULL(ii.acceptItemAmount, 0)) AS accept_item_amount_sum, + MAX(ISNULL(NULLIF(ii.scmname, ''), 'unknown')) AS sample_scm_name +INTO #item_agg +FROM dbo.fys_Ticketing_install_item AS ii WITH (NOLOCK) +GROUP BY ii.guid; + +-- 1) 总览 +SELECT + @date_from AS date_from, + @date_to AS date_to, + COUNT(*) AS ticket_count, + COUNT(DISTINCT ordernum) AS order_count, + SUM(ISNULL(payment_amount, 0)) AS payment_amount, + SUM(ISNULL(final_payment_amount, 0)) AS final_payment_amount, + SUM(ISNULL(install_cost, 0)) AS install_cost, + SUM(ISNULL(settle_amount, 0)) AS settle_amount, + SUM(ISNULL(accept_amount, 0)) AS accept_amount +FROM #ticket; + +-- 2) 工单状态分布 +SELECT + status_id, + install_complete, + COUNT(*) AS ticket_count, + SUM(ISNULL(payment_amount, 0)) AS payment_amount, + SUM(ISNULL(install_cost, 0)) AS install_cost +FROM #ticket +GROUP BY status_id, install_complete +ORDER BY ticket_count DESC; + +-- 3) 服务商表现(取明细表scm) +SELECT TOP (30) + ISNULL(NULLIF(ii.scmname, ''), 'unknown') AS scm_name, + COUNT(*) AS item_count, + SUM(ISNULL(ii.installcount, 0)) AS install_count, + SUM(ISNULL(ii.Payfee, 0)) AS pay_fee_sum, + SUM(ISNULL(ii.settleItemAmount, 0)) AS settle_item_amount_sum, + SUM(ISNULL(ii.acceptItemAmount, 0)) AS accept_item_amount_sum +FROM dbo.fys_Ticketing_install_item AS ii WITH (NOLOCK) +WHERE ii.createTime >= @date_from + AND ii.createTime < @date_to +GROUP BY ISNULL(NULLIF(ii.scmname, ''), 'unknown') +ORDER BY item_count DESC; + +-- 4) 区域问题清单(客服/财务未审核) +SELECT TOP (50) + t.province, + t.city, + COUNT(*) AS pending_ticket_count, + SUM(ISNULL(t.payment_amount, 0)) AS payment_amount, + SUM(ISNULL(t.install_cost, 0)) AS install_cost +FROM #ticket AS t +WHERE ISNULL(t.kf_audit, 0) <> 1 + OR ISNULL(t.cw_audit, 0) <> 1 +GROUP BY t.province, t.city +ORDER BY pending_ticket_count DESC, payment_amount DESC; diff --git a/sqlserver/last_week_afs_report.sql b/sqlserver/last_week_afs_report.sql new file mode 100644 index 0000000..d80d595 --- /dev/null +++ b/sqlserver/last_week_afs_report.sql @@ -0,0 +1,103 @@ +USE [JSDataMiddlePlatform]; +GO + +/* + Unified logical layer (temp-table based, no CREATE VIEW permission required). + Week window = previous natural week: + [start_of_last_week, start_of_this_week) +*/ + +DECLARE @week_start datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()) - 1, 0) AS datetime2); +DECLARE @week_end datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()), 0) AS datetime2); + +IF OBJECT_ID('tempdb..#filtered') IS NOT NULL + DROP TABLE #filtered; + +SELECT + a.id, + NULLIF(a.undefined2, '') AS order_id, + NULLIF(a.undefined1, '') AS inner_order_id, + COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name, + NULLIF(a.undefined9, '') AS shop_code, + NULLIF(a.undefined10, '') AS sku_id, + NULLIF(a.undefined56, '') AS afs_type, + NULLIF(a.undefined57, '') AS afs_reason, + NULLIF(a.undefined58, '') AS afs_result, + ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')) AS order_qty, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')) AS shipped_qty, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined47, '')) AS post_ship_apply_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined50, '')) AS post_ship_return_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS post_ship_refund_amount, + a.createdAt AS created_at, + CASE + WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1 + THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1) + ELSE 'unknown' + END AS platform_name +INTO #filtered +FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK) +WHERE a.createdAt >= @week_start + AND a.createdAt < @week_end + AND ISNULL(NULLIF(a.undefined2, ''), '') <> '' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%'; + +-- 1) weekly overview +SELECT + MIN(created_at) AS data_min_time, + MAX(created_at) AS data_max_time, + COUNT(*) AS afs_record_count, + COUNT(DISTINCT order_id) AS afs_order_count, + SUM(ISNULL(order_qty, 0)) AS order_qty_sum, + SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum, + SUM(ISNULL(post_ship_apply_amount, 0)) AS apply_amount, + SUM(ISNULL(post_ship_return_amount, 0)) AS return_amount, + SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount +FROM #filtered; + +-- 2) platform distribution +SELECT + platform_name, + COUNT(*) AS afs_record_count, + COUNT(DISTINCT order_id) AS afs_order_count, + SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum, + SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount +FROM #filtered +GROUP BY platform_name +ORDER BY afs_record_count DESC; + +-- 3) shop top 20 +SELECT TOP (20) + shop_name, + platform_name, + COUNT(*) AS afs_record_count, + COUNT(DISTINCT order_id) AS afs_order_count, + SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum, + SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount +FROM #filtered +GROUP BY shop_name, platform_name +ORDER BY afs_record_count DESC; + +-- 4) reason top 20 +SELECT TOP (20) + ISNULL(NULLIF(afs_reason, ''), '未标注') AS afs_reason, + COUNT(*) AS afs_record_count, + SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum, + SUM(ISNULL(post_ship_refund_amount, 0)) AS refund_amount, + SUM(ISNULL(post_ship_return_amount, 0)) AS return_amount +FROM #filtered +GROUP BY ISNULL(NULLIF(afs_reason, ''), '未标注') +ORDER BY afs_record_count DESC; + +-- 5) type/result matrix +SELECT + ISNULL(NULLIF(afs_type, ''), '未标注') AS afs_type, + ISNULL(NULLIF(afs_result, ''), '未标注') AS afs_result, + COUNT(*) AS afs_record_count, + SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum +FROM #filtered +GROUP BY + ISNULL(NULLIF(afs_type, ''), '未标注'), + ISNULL(NULLIF(afs_result, ''), '未标注') +ORDER BY afs_record_count DESC; diff --git a/sqlserver/last_week_after_sale_analysis.sql b/sqlserver/last_week_after_sale_analysis.sql new file mode 100644 index 0000000..174e833 --- /dev/null +++ b/sqlserver/last_week_after_sale_analysis.sql @@ -0,0 +1,193 @@ +USE [JSDataMiddlePlatform]; +GO + +/* + Template: 售后360分析(退款 + 原因 + 工单 + 客诉) + Window: 上一个自然周 [week_start, week_end) +*/ + +DECLARE @week_start datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()) - 1, 0) AS datetime2); +DECLARE @week_end datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()), 0) AS datetime2); + +IF OBJECT_ID('tempdb..#chat_latest') IS NOT NULL + DROP TABLE #chat_latest; + +WITH chat_rank AS ( + SELECT + NULLIF(c.orderNo, '') AS order_id, + ISNULL(NULLIF(c.mainReason, ''), '') AS main_reason, + ISNULL(NULLIF(c.secondReason, ''), '') AS second_reason, + ISNULL(NULLIF(c.thirdReason, ''), '') AS third_reason, + c.createdAt, + ROW_NUMBER() OVER ( + PARTITION BY NULLIF(c.orderNo, '') + ORDER BY c.createdAt DESC, c.id DESC + ) AS rn + FROM dbo.pc_kfChatLog AS c WITH (NOLOCK) + WHERE c.createdAt >= @week_start + AND c.createdAt < @week_end + AND ISNULL(NULLIF(c.orderNo, ''), '') <> '' +) +SELECT + order_id, + main_reason, + second_reason, + third_reason +INTO #chat_latest +FROM chat_rank +WHERE rn = 1; + +IF OBJECT_ID('tempdb..#afs') IS NOT NULL + DROP TABLE #afs; + +SELECT + NULLIF(a.undefined2, '') AS order_id, + COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name, + CASE + WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1 + THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1) + ELSE 'unknown' + END AS platform_name, + ISNULL(NULLIF(a.undefined56, ''), '未标注') AS afs_type, + ISNULL(NULLIF(a.undefined58, ''), '未标注') AS afs_result, + ISNULL(NULLIF(a.undefined57, ''), '') AS native_reason, + ISNULL(cl.main_reason, '') AS chat_main_reason, + ISNULL(cl.second_reason, '') AS chat_second_reason, + ISNULL(cl.third_reason, '') AS chat_third_reason, + CASE + WHEN ISNULL(NULLIF(a.undefined57, ''), '') <> '' THEN ISNULL(NULLIF(a.undefined57, ''), '') + WHEN ISNULL(cl.main_reason, '') <> '' THEN cl.main_reason + ELSE '未标注' + END AS afs_reason_l1, + CASE + WHEN ISNULL(cl.second_reason, '') <> '' THEN cl.second_reason + ELSE '未标注' + END AS afs_reason_l2, + CASE + WHEN ISNULL(cl.third_reason, '') <> '' THEN cl.third_reason + ELSE '未标注' + END AS afs_reason_l3, + CASE + WHEN ISNULL(NULLIF(a.undefined57, ''), '') <> '' THEN 'afs_reason' + WHEN ISNULL(cl.main_reason, '') <> '' THEN 'chat_main_reason' + ELSE 'unknown' + END AS reason_source, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')) AS order_qty, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')) AS shipped_qty, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')) AS payment_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined47, '')) AS apply_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined50, '')) AS return_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount +INTO #afs +FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK) +LEFT JOIN #chat_latest AS cl + ON cl.order_id = NULLIF(a.undefined2, '') +WHERE a.createdAt >= @week_start + AND a.createdAt < @week_end + AND ISNULL(NULLIF(a.undefined2, ''), '') <> '' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%'; + +IF OBJECT_ID('tempdb..#install') IS NOT NULL + DROP TABLE #install; + +SELECT + NULLIF(i.OrderNum, '') AS order_id, + i.SubmitTime, + TRY_CONVERT(int, i.StatusId) AS status_id, + TRY_CONVERT(int, i.installComplete) AS install_complete, + TRY_CONVERT(decimal(18, 2), i.Num) AS install_num, + TRY_CONVERT(decimal(18, 2), i.PayMent) AS payment_amount, + TRY_CONVERT(decimal(18, 2), i.installcost) AS install_cost, + TRY_CONVERT(decimal(18, 2), i.settleAmount) AS settle_amount, + TRY_CONVERT(int, i.kfsh) AS kf_audit, + TRY_CONVERT(int, i.cwsh) AS cw_audit, + ISNULL(NULLIF(i.Province, ''), 'unknown') AS province, + ISNULL(NULLIF(i.City, ''), 'unknown') AS city +INTO #install +FROM dbo.fys_Ticketing_install AS i WITH (NOLOCK) +WHERE i.SubmitTime >= @week_start + AND i.SubmitTime < @week_end + AND ISNULL(NULLIF(i.OrderNum, ''), '') <> '' + AND (i.[Delete] = 0 OR i.[Delete] IS NULL); + +-- 1) 售后总览(退款口径) +SELECT + @week_start AS week_start, + @week_end AS week_end, + COUNT(*) AS afs_record_count, + COUNT(DISTINCT order_id) AS afs_order_count, + SUM(ISNULL(order_qty, 0)) AS order_qty_sum, + SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum, + SUM(ISNULL(payment_amount, 0)) AS payment_amount, + SUM(ISNULL(refund_amount, 0)) AS refund_amount, + SUM(ISNULL(return_amount, 0)) AS return_amount, + CASE + WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL + ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0)) + END AS refund_rate +FROM #afs; + +-- 2) 售后原因分析(一级原因 + 来源) +SELECT TOP (30) + afs_reason_l1 AS afs_reason, + reason_source, + COUNT(*) AS afs_record_count, + COUNT(DISTINCT order_id) AS order_count, + SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum, + SUM(ISNULL(refund_amount, 0)) AS refund_amount, + CASE + WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL + ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0)) + END AS reason_refund_rate +FROM #afs +GROUP BY afs_reason_l1, reason_source +ORDER BY afs_record_count DESC; + +-- 3) 售后二级原因 TOP20 +SELECT TOP (20) + afs_reason_l1, + afs_reason_l2, + COUNT(*) AS afs_record_count, + SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum, + SUM(ISNULL(refund_amount, 0)) AS refund_amount +FROM #afs +GROUP BY afs_reason_l1, afs_reason_l2 +ORDER BY afs_record_count DESC; + +-- 4) 售后类型/结果矩阵 +SELECT + afs_type, + afs_result, + COUNT(*) AS afs_record_count, + SUM(ISNULL(shipped_qty, 0)) AS shipped_qty_sum, + SUM(ISNULL(refund_amount, 0)) AS refund_amount +FROM #afs +GROUP BY afs_type, afs_result +ORDER BY afs_record_count DESC; + +-- 5) 安装工单概览(同周期) +SELECT + COUNT(*) AS install_ticket_count, + COUNT(DISTINCT order_id) AS install_order_count, + SUM(ISNULL(install_num, 0)) AS install_piece_count, + SUM(ISNULL(payment_amount, 0)) AS install_payment_amount, + SUM(ISNULL(install_cost, 0)) AS install_cost, + SUM(ISNULL(settle_amount, 0)) AS settle_amount, + SUM(CASE WHEN ISNULL(install_complete, 0) = 1 THEN 1 ELSE 0 END) AS install_completed_count, + SUM(CASE WHEN ISNULL(kf_audit, 0) <> 1 OR ISNULL(cw_audit, 0) <> 1 THEN 1 ELSE 0 END) AS pending_audit_count +FROM #install; + +-- 6) 售后退款与安装工单联动(按订单) +SELECT + COUNT(*) AS overlap_order_count, + SUM(ISNULL(a.refund_amount, 0)) AS overlap_refund_amount, + SUM(ISNULL(i.install_cost, 0)) AS overlap_install_cost, + SUM(CASE WHEN ISNULL(i.install_complete, 0) = 1 THEN 1 ELSE 0 END) AS overlap_install_completed_count +FROM ( + SELECT DISTINCT order_id, refund_amount + FROM #afs + WHERE ISNULL(order_id, '') <> '' +) a +INNER JOIN #install i + ON i.order_id = a.order_id; diff --git a/sqlserver/link_target_coverage_report.sql b/sqlserver/link_target_coverage_report.sql new file mode 100644 index 0000000..1ac4337 --- /dev/null +++ b/sqlserver/link_target_coverage_report.sql @@ -0,0 +1,77 @@ +USE [JSDataMiddlePlatform]; +GO + +/* + Template: 链接目标覆盖分析(运营视角) + Window: 在售链接(linkStatus = 当前在线) +*/ + +IF OBJECT_ID('tempdb..#base') IS NOT NULL + DROP TABLE #base; + +SELECT + t.Id, + ISNULL(NULLIF(t.shopname, ''), 'unknown') AS shop_name, + ISNULL(NULLIF(t.category, ''), 'unknown') AS category, + ISNULL(NULLIF(t.shopGoodsCategory, ''), 'unknown') AS shop_goods_category, + ISNULL(NULLIF(t.spu, ''), 'unknown') AS spu, + ISNULL(NULLIF(t.linkType, ''), 'unknown') AS link_type, + ISNULL(NULLIF(t.linkStatus, ''), 'unknown') AS link_status, + TRY_CONVERT(int, t.onSaleDays) AS on_sale_days, + TRY_CONVERT(decimal(18, 2), t.salesMoneyTarget) AS sales_money_target, + TRY_CONVERT(int, t.salesAmountTarget) AS sales_amount_target, + TRY_CONVERT(decimal(18, 2), t.salesMoneyTargetAvg) AS sales_money_target_avg, + TRY_CONVERT(int, t.salesAmountTargetAvg) AS sales_amount_target_avg, + ISNULL(NULLIF(t.oper, ''), 'unknown') AS oper, + ISNULL(NULLIF(t.cater, ''), 'unknown') AS cater, + ISNULL(NULLIF(t.ider, ''), 'unknown') AS designer, + ISNULL(NULLIF(t.dept, ''), 'unknown') AS dept, + ISNULL(NULLIF(t.topsgn, ''), 'unknown') AS topsgn, + ISNULL(NULLIF(t.bind, ''), 'unknown') AS price_band_label, + TRY_CONVERT(decimal(18, 2), t.priceBand) AS sale_floor_price +INTO #base +FROM dbo.op_TargetManagement AS t WITH (NOLOCK) +WHERE ISNULL(NULLIF(t.linkStatus, ''), 'unknown') = '当前在线'; + +-- 1) 在线链接总览 +SELECT + COUNT(*) AS online_link_count, + COUNT(DISTINCT shop_name) AS shop_count, + COUNT(DISTINCT spu) AS spu_count, + SUM(ISNULL(sales_money_target, 0)) AS sales_money_target, + SUM(ISNULL(sales_amount_target, 0)) AS sales_amount_target +FROM #base; + +-- 2) 店铺覆盖度 +SELECT TOP (50) + shop_name, + COUNT(*) AS online_link_count, + COUNT(DISTINCT spu) AS spu_count, + SUM(ISNULL(sales_money_target, 0)) AS sales_money_target, + AVG(ISNULL(on_sale_days, 0)) AS avg_on_sale_days +FROM #base +GROUP BY shop_name +ORDER BY online_link_count DESC; + +-- 3) 运营人负载 +SELECT TOP (50) + dept, + oper, + COUNT(*) AS online_link_count, + COUNT(DISTINCT shop_name) AS shop_count, + SUM(ISNULL(sales_money_target, 0)) AS sales_money_target +FROM #base +GROUP BY dept, oper +ORDER BY online_link_count DESC; + +-- 4) 类目与价格带分布 +SELECT + category, + shop_goods_category, + price_band_label, + COUNT(*) AS online_link_count, + AVG(ISNULL(sale_floor_price, 0)) AS avg_sale_floor_price, + SUM(ISNULL(sales_money_target, 0)) AS sales_money_target +FROM #base +GROUP BY category, shop_goods_category, price_band_label +ORDER BY online_link_count DESC; diff --git a/sqlserver/logistics_sla_report.sql b/sqlserver/logistics_sla_report.sql new file mode 100644 index 0000000..cb91bbd --- /dev/null +++ b/sqlserver/logistics_sla_report.sql @@ -0,0 +1,95 @@ +USE [JSDataMiddlePlatform]; +GO + +/* + Template: 物流时效/SLA分析 + Window: 最近30天 [date_from, date_to) +*/ + +DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -30, CAST(GETDATE() AS date)) AS datetime2); +DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2); + +IF OBJECT_ID('tempdb..#base') IS NOT NULL + DROP TABLE #base; + +SELECT + e.so_id AS order_id, + ISNULL(NULLIF(e.shop_name, ''), 'unknown') AS shop_name, + ISNULL(NULLIF(e.platform_name, ''), 'unknown') AS platform_name, + ISNULL(NULLIF(e.logistic_company_name, ''), 'unknown') AS logistic_company_name, + ISNULL(NULLIF(e.logistic_status_name, ''), 'unknown') AS logistic_status_name, + e.pay_time, + e.send_time, + e.fetch_time, + e.sign_time, + TRY_CONVERT(decimal(18, 4), e.weight) AS weight, + TRY_CONVERT(decimal(18, 4), e.f_weight) AS f_weight, + CASE + WHEN e.pay_time IS NOT NULL AND e.send_time IS NOT NULL + THEN DATEDIFF(HOUR, e.pay_time, e.send_time) + ELSE NULL + END AS pay_to_send_h, + CASE + WHEN e.send_time IS NOT NULL AND e.sign_time IS NOT NULL + THEN DATEDIFF(HOUR, e.send_time, e.sign_time) + ELSE NULL + END AS send_to_sign_h, + CASE + WHEN e.pay_time IS NOT NULL AND e.sign_time IS NOT NULL + THEN DATEDIFF(HOUR, e.pay_time, e.sign_time) + ELSE NULL + END AS pay_to_sign_h +INTO #base +FROM dbo.jst_order_express_details AS e WITH (NOLOCK) +WHERE e.createdAt >= @date_from + AND e.createdAt < @date_to + AND ISNULL(NULLIF(e.so_id, ''), '') <> '' + AND ISNULL(NULLIF(e.order_labels, ''), '') NOT LIKE '%配件%' + AND ISNULL(NULLIF(e.order_labels, ''), '') NOT LIKE '%特殊单%'; + +-- 1) 全局时效总览 +SELECT + @date_from AS date_from, + @date_to AS date_to, + COUNT(*) AS order_count, + SUM(CASE WHEN sign_time IS NOT NULL THEN 1 ELSE 0 END) AS signed_order_count, + AVG(CASE WHEN pay_to_send_h >= 0 THEN CAST(pay_to_send_h AS decimal(18, 2)) END) AS avg_pay_to_send_h, + AVG(CASE WHEN send_to_sign_h >= 0 THEN CAST(send_to_sign_h AS decimal(18, 2)) END) AS avg_send_to_sign_h, + AVG(CASE WHEN pay_to_sign_h >= 0 THEN CAST(pay_to_sign_h AS decimal(18, 2)) END) AS avg_pay_to_sign_h +FROM #base; + +-- 2) 平台时效 +SELECT + platform_name, + COUNT(*) AS order_count, + AVG(CASE WHEN pay_to_send_h >= 0 THEN CAST(pay_to_send_h AS decimal(18, 2)) END) AS avg_pay_to_send_h, + AVG(CASE WHEN send_to_sign_h >= 0 THEN CAST(send_to_sign_h AS decimal(18, 2)) END) AS avg_send_to_sign_h, + SUM(CASE WHEN pay_to_send_h > 48 THEN 1 ELSE 0 END) AS delayed_send_over_48h, + SUM(CASE WHEN send_to_sign_h > 120 THEN 1 ELSE 0 END) AS delayed_sign_over_120h +FROM #base +GROUP BY platform_name +ORDER BY order_count DESC; + +-- 3) 店铺时效预警(订单数>=100) +SELECT TOP (50) + shop_name, + platform_name, + COUNT(*) AS order_count, + AVG(CASE WHEN pay_to_send_h >= 0 THEN CAST(pay_to_send_h AS decimal(18, 2)) END) AS avg_pay_to_send_h, + AVG(CASE WHEN send_to_sign_h >= 0 THEN CAST(send_to_sign_h AS decimal(18, 2)) END) AS avg_send_to_sign_h, + SUM(CASE WHEN pay_to_send_h > 48 THEN 1 ELSE 0 END) AS delayed_send_over_48h, + SUM(CASE WHEN send_to_sign_h > 120 THEN 1 ELSE 0 END) AS delayed_sign_over_120h +FROM #base +GROUP BY shop_name, platform_name +HAVING COUNT(*) >= 100 +ORDER BY delayed_send_over_48h DESC, avg_send_to_sign_h DESC; + +-- 4) 快递公司表现 +SELECT TOP (30) + logistic_company_name, + COUNT(*) AS order_count, + AVG(CASE WHEN send_to_sign_h >= 0 THEN CAST(send_to_sign_h AS decimal(18, 2)) END) AS avg_send_to_sign_h, + SUM(CASE WHEN send_to_sign_h > 120 THEN 1 ELSE 0 END) AS delayed_sign_over_120h +FROM #base +GROUP BY logistic_company_name +ORDER BY order_count DESC; diff --git a/sqlserver/refund_reason_report.sql b/sqlserver/refund_reason_report.sql new file mode 100644 index 0000000..7abfd1c --- /dev/null +++ b/sqlserver/refund_reason_report.sql @@ -0,0 +1,161 @@ +USE [JSDataMiddlePlatform]; +GO + +/* + Template: 退款原因分析 + Window: 最近30天 [date_from, date_to) +*/ + +DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -30, CAST(GETDATE() AS date)) AS datetime2); +DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2); + +IF OBJECT_ID('tempdb..#chat_latest') IS NOT NULL + DROP TABLE #chat_latest; + +WITH chat_rank AS ( + SELECT + NULLIF(c.orderNo, '') AS order_id, + ISNULL(NULLIF(c.mainReason, ''), '') AS main_reason, + ISNULL(NULLIF(c.secondReason, ''), '') AS second_reason, + ISNULL(NULLIF(c.thirdReason, ''), '') AS third_reason, + ROW_NUMBER() OVER ( + PARTITION BY NULLIF(c.orderNo, '') + ORDER BY c.createdAt DESC, c.id DESC + ) AS rn + FROM dbo.pc_kfChatLog AS c WITH (NOLOCK) + WHERE c.createdAt >= @date_from + AND c.createdAt < @date_to + AND ISNULL(NULLIF(c.orderNo, ''), '') <> '' +) +SELECT + order_id, + main_reason, + second_reason, + third_reason +INTO #chat_latest +FROM chat_rank +WHERE rn = 1; + +IF OBJECT_ID('tempdb..#base') IS NOT NULL + DROP TABLE #base; + +SELECT + NULLIF(a.undefined2, '') AS order_id, + COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name, + CASE + WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1 + THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1) + ELSE 'unknown' + END AS platform_name, + ISNULL(NULLIF(a.undefined56, ''), '未标注') AS afs_type, + ISNULL(NULLIF(a.undefined57, ''), '') AS native_afs_reason, + ISNULL(cl.main_reason, '') AS chat_main_reason, + ISNULL(cl.second_reason, '') AS chat_second_reason, + ISNULL(cl.third_reason, '') AS chat_third_reason, + CASE + WHEN ISNULL(NULLIF(a.undefined57, ''), '') <> '' THEN ISNULL(NULLIF(a.undefined57, ''), '') + WHEN ISNULL(cl.main_reason, '') <> '' THEN cl.main_reason + ELSE '未标注' + END AS afs_reason_l1, + CASE + WHEN ISNULL(cl.second_reason, '') <> '' THEN cl.second_reason + ELSE '未标注' + END AS afs_reason_l2, + CASE + WHEN ISNULL(cl.third_reason, '') <> '' THEN cl.third_reason + ELSE '未标注' + END AS afs_reason_l3, + CASE + WHEN ISNULL(NULLIF(a.undefined57, ''), '') <> '' THEN 'afs_reason' + WHEN ISNULL(cl.main_reason, '') <> '' THEN 'chat_main_reason' + ELSE 'unknown' + END AS reason_source, + ISNULL(NULLIF(a.undefined58, ''), '未标注') AS afs_result, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined50, '')) AS return_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')) AS payment_amount, + ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels +INTO #base +FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK) +LEFT JOIN #chat_latest AS cl + ON cl.order_id = NULLIF(a.undefined2, '') +WHERE a.createdAt >= @date_from + AND a.createdAt < @date_to + AND ISNULL(NULLIF(a.undefined2, ''), '') <> '' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%'; + +-- 1) 全局退款原因 TOP30 +SELECT TOP (30) + afs_reason_l1 AS afs_reason, + reason_source, + COUNT(*) AS afs_record_count, + COUNT(DISTINCT order_id) AS order_count, + SUM(ISNULL(refund_amount, 0)) AS refund_amount, + SUM(ISNULL(return_amount, 0)) AS return_amount, + CASE + WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL + ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0)) + END AS reason_refund_rate +FROM #base +GROUP BY afs_reason_l1, reason_source +ORDER BY afs_record_count DESC; + +-- 2) 平台内原因 TOP10 +WITH reason_rank AS ( + SELECT + platform_name, + afs_reason_l1 AS afs_reason, + COUNT(*) AS afs_record_count, + SUM(ISNULL(refund_amount, 0)) AS refund_amount, + ROW_NUMBER() OVER ( + PARTITION BY platform_name + ORDER BY COUNT(*) DESC + ) AS rn + FROM #base + GROUP BY platform_name, afs_reason_l1 +) +SELECT + platform_name, + afs_reason, + afs_record_count, + refund_amount +FROM reason_rank +WHERE rn <= 10 +ORDER BY platform_name, afs_record_count DESC; + +-- 3) 二级原因 TOP20(可用于更细分原因) +SELECT TOP (20) + afs_reason_l1, + afs_reason_l2, + COUNT(*) AS afs_record_count, + SUM(ISNULL(refund_amount, 0)) AS refund_amount +FROM #base +GROUP BY afs_reason_l1, afs_reason_l2 +ORDER BY afs_record_count DESC; + +-- 4) 店铺退款风险清单(订单数>=50) +SELECT + shop_name, + platform_name, + COUNT(DISTINCT order_id) AS order_count, + SUM(ISNULL(payment_amount, 0)) AS payment_amount, + SUM(ISNULL(refund_amount, 0)) AS refund_amount, + CASE + WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL + ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0)) + END AS refund_rate +FROM #base +GROUP BY shop_name, platform_name +HAVING COUNT(DISTINCT order_id) >= 50 +ORDER BY refund_rate DESC, payment_amount DESC; + +-- 5) 售后类型/结果矩阵 +SELECT + afs_type, + afs_result, + COUNT(*) AS afs_record_count, + SUM(ISNULL(refund_amount, 0)) AS refund_amount +FROM #base +GROUP BY afs_type, afs_result +ORDER BY afs_record_count DESC; diff --git a/sqlserver/shop_weekly_report.sql b/sqlserver/shop_weekly_report.sql new file mode 100644 index 0000000..2ec0c8b --- /dev/null +++ b/sqlserver/shop_weekly_report.sql @@ -0,0 +1,111 @@ +USE [JSDataMiddlePlatform]; +GO + +/* + Template: 店铺周报 + Window: 上一个自然周 [week_start, week_end) +*/ + +DECLARE @week_start datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()) - 1, 0) AS datetime2); +DECLARE @week_end datetime2 = CAST(DATEADD(WEEK, DATEDIFF(WEEK, 0, GETDATE()), 0) AS datetime2); + +IF OBJECT_ID('tempdb..#base') IS NOT NULL + DROP TABLE #base; + +SELECT + NULLIF(a.undefined2, '') AS order_id, + COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name, + CASE + WHEN CHARINDEX('-', ISNULL(a.undefined8, '')) > 1 + THEN LEFT(a.undefined8, CHARINDEX('-', a.undefined8) - 1) + ELSE 'unknown' + END AS platform_name, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')) AS qty, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined21, '')) AS goods_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined22, '')) AS goods_cost, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')) AS payment_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined29, '')) AS actual_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined31, '')) AS actual_cost, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined47, '')) AS apply_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined50, '')) AS return_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount, + ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels, + a.createdAt AS created_at +INTO #base +FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK) +WHERE a.createdAt >= @week_start + AND a.createdAt < @week_end + AND ISNULL(NULLIF(a.undefined2, ''), '') <> '' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%'; + +-- 1) 周总览 +SELECT + @week_start AS week_start, + @week_end AS week_end, + COUNT(*) AS afs_record_count, + COUNT(DISTINCT order_id) AS order_count, + SUM(ISNULL(qty, 0)) AS qty_sum, + SUM(ISNULL(payment_amount, 0)) AS payment_amount, + SUM(ISNULL(actual_amount, 0)) AS actual_amount, + SUM(ISNULL(actual_cost, 0)) AS actual_cost, + SUM(ISNULL(refund_amount, 0)) AS refund_amount, + SUM(ISNULL(return_amount, 0)) AS return_amount +FROM #base; + +-- 2) 平台周报 +SELECT + platform_name, + COUNT(*) AS afs_record_count, + COUNT(DISTINCT order_id) AS order_count, + SUM(ISNULL(payment_amount, 0)) AS payment_amount, + SUM(ISNULL(refund_amount, 0)) AS refund_amount, + CASE + WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL + ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0)) + END AS refund_rate, + SUM(ISNULL(actual_amount, 0)) - SUM(ISNULL(actual_cost, 0)) AS gross_profit +FROM #base +GROUP BY platform_name +ORDER BY payment_amount DESC; + +-- 3) 店铺 TOP30 +SELECT TOP (30) + shop_name, + platform_name, + COUNT(*) AS afs_record_count, + COUNT(DISTINCT order_id) AS order_count, + SUM(ISNULL(payment_amount, 0)) AS payment_amount, + SUM(ISNULL(refund_amount, 0)) AS refund_amount, + CASE + WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL + ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0)) + END AS refund_rate, + SUM(ISNULL(actual_amount, 0)) - SUM(ISNULL(actual_cost, 0)) AS gross_profit, + CASE + WHEN SUM(ISNULL(actual_amount, 0)) = 0 THEN NULL + ELSE (SUM(ISNULL(actual_amount, 0)) - SUM(ISNULL(actual_cost, 0))) / SUM(ISNULL(actual_amount, 0)) + END AS gross_margin_rate +FROM #base +GROUP BY shop_name, platform_name +ORDER BY payment_amount DESC; + +-- 4) 高退款风险店铺(门槛:订单数>=50) +SELECT + shop_name, + platform_name, + COUNT(DISTINCT order_id) AS order_count, + SUM(ISNULL(payment_amount, 0)) AS payment_amount, + SUM(ISNULL(refund_amount, 0)) AS refund_amount, + CASE + WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN NULL + ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0)) + END AS refund_rate +FROM #base +GROUP BY shop_name, platform_name +HAVING COUNT(DISTINCT order_id) >= 50 + AND CASE + WHEN SUM(ISNULL(payment_amount, 0)) = 0 THEN 0 + ELSE SUM(ISNULL(refund_amount, 0)) / SUM(ISNULL(payment_amount, 0)) + END >= 0.15 +ORDER BY refund_rate DESC, payment_amount DESC; diff --git a/sqlserver/sku_structure_report.sql b/sqlserver/sku_structure_report.sql new file mode 100644 index 0000000..3589520 --- /dev/null +++ b/sqlserver/sku_structure_report.sql @@ -0,0 +1,124 @@ +USE [JSDataMiddlePlatform]; +GO + +/* + Template: SKU结构分析 + Window: 最近30天 [date_from, date_to) +*/ + +DECLARE @date_from datetime2 = CAST(DATEADD(DAY, -30, CAST(GETDATE() AS date)) AS datetime2); +DECLARE @date_to datetime2 = CAST(DATEADD(DAY, 1, CAST(GETDATE() AS date)) AS datetime2); + +IF OBJECT_ID('tempdb..#base') IS NOT NULL + DROP TABLE #base; + +SELECT + NULLIF(a.undefined2, '') AS order_id, + NULLIF(a.undefined10, '') AS sku_id, + COALESCE(NULLIF(a.undefined8, ''), 'unknown') AS shop_name, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')) AS qty, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined26, '')) AS payment_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined29, '')) AS actual_amount, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined31, '')) AS actual_cost, + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined52, '')) AS refund_amount, + ISNULL(NULLIF(a.undefined62, ''), '') AS order_labels +INTO #base +FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK) +WHERE a.createdAt >= @date_from + AND a.createdAt < @date_to + AND ISNULL(NULLIF(a.undefined2, ''), '') <> '' + AND ISNULL(NULLIF(a.undefined10, ''), '') <> '' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%'; + +IF OBJECT_ID('tempdb..#sku_agg') IS NOT NULL + DROP TABLE #sku_agg; + +SELECT + b.sku_id, + ISNULL(NULLIF(s.name, ''), 'unknown') AS sku_name, + ISNULL(NULLIF(s.other_1, ''), 'unknown') AS spu, + ISNULL(NULLIF(s.category, ''), 'unknown') AS category, + s.sale_price, + COUNT(*) AS afs_record_count, + COUNT(DISTINCT b.order_id) AS order_count, + SUM(ISNULL(b.qty, 0)) AS qty_sum, + SUM(ISNULL(b.payment_amount, 0)) AS payment_amount, + SUM(ISNULL(b.actual_amount, 0)) AS actual_amount, + SUM(ISNULL(b.actual_cost, 0)) AS actual_cost, + SUM(ISNULL(b.refund_amount, 0)) AS refund_amount +INTO #sku_agg +FROM #base AS b +LEFT JOIN dbo.jst_sku AS s WITH (NOLOCK) + ON s.sku_id = b.sku_id +GROUP BY + b.sku_id, + s.name, + s.other_1, + s.category, + s.sale_price; + +-- 1) 类目结构(按销售额) +SELECT TOP (20) + category, + COUNT(*) AS sku_count, + SUM(payment_amount) AS payment_amount, + CASE + WHEN SUM(SUM(payment_amount)) OVER () = 0 THEN NULL + ELSE SUM(payment_amount) / SUM(SUM(payment_amount)) OVER () + END AS payment_share +FROM #sku_agg +GROUP BY category +ORDER BY payment_amount DESC; + +-- 2) SKU TOP50(按销售额) +SELECT TOP (50) + sku_id, + sku_name, + spu, + category, + sale_price, + order_count, + qty_sum, + payment_amount, + refund_amount, + CASE + WHEN payment_amount = 0 THEN NULL + ELSE refund_amount / payment_amount + END AS refund_rate, + actual_amount - actual_cost AS gross_profit, + CASE + WHEN actual_amount = 0 THEN NULL + ELSE (actual_amount - actual_cost) / actual_amount + END AS gross_margin_rate +FROM #sku_agg +ORDER BY payment_amount DESC; + +-- 3) 价格带结构 +WITH price_band AS ( + SELECT + CASE + WHEN sale_price IS NULL THEN '未知价格' + WHEN sale_price < 100 THEN '0-99' + WHEN sale_price < 200 THEN '100-199' + WHEN sale_price < 300 THEN '200-299' + WHEN sale_price < 500 THEN '300-499' + ELSE '500+' + END AS price_band, + payment_amount, + order_count, + refund_amount + FROM #sku_agg +) +SELECT + price_band, + SUM(order_count) AS order_count, + SUM(payment_amount) AS payment_amount, + SUM(refund_amount) AS refund_amount, + CASE + WHEN SUM(payment_amount) = 0 THEN NULL + ELSE SUM(refund_amount) / SUM(payment_amount) + END AS refund_rate +FROM price_band +GROUP BY price_band +ORDER BY payment_amount DESC; diff --git a/stock-risk-alerts b/stock-risk-alerts new file mode 100755 index 0000000..dabbb25 --- /dev/null +++ b/stock-risk-alerts @@ -0,0 +1,488 @@ +#!/usr/bin/env python3 +import argparse +import subprocess +import sys + + +def build_sql(days: int, lead_days: int, warn_days: int, limit: int, include_safe: bool) -> str: + include_safe_bit = 1 if include_safe else 0 + return f""" +USE [JSDataMiddlePlatform]; +SET NOCOUNT ON; + +DECLARE @lookback_days int = {days}; +DECLARE @lead_days int = {lead_days}; +DECLARE @warn_days int = {warn_days}; +DECLARE @limit int = {limit}; +DECLARE @include_safe bit = {include_safe_bit}; +DECLARE @start_time datetime2 = DATEADD(day, -@lookback_days, GETDATE()); +DECLARE @end_time datetime2 = GETDATE(); + +IF OBJECT_ID('tempdb..#ship_base') IS NOT NULL + DROP TABLE #ship_base; + +SELECT + NULLIF(a.undefined10, '') AS sku_id, + NULLIF(a.undefined2, '') AS order_id, + NULLIF(a.undefined12, '') AS product_id, + ISNULL(NULLIF(a.undefined8, ''), 'unknown') AS shop_name, + CASE + WHEN CHARINDEX('-', ISNULL(NULLIF(a.undefined8, ''), '')) > 0 + THEN LEFT(ISNULL(NULLIF(a.undefined8, ''), ''), CHARINDEX('-', ISNULL(NULLIF(a.undefined8, ''), '')) - 1) + ELSE ISNULL(NULLIF(a.undefined8, ''), '未知渠道') + END AS shop_channel, + ISNULL(NULLIF(a.undefined66, ''), '') AS ship_warehouse_name, + COALESCE( + TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 120), + TRY_CONVERT(datetime2, REPLACE(NULLIF(a.undefined6, ''), '/', '-'), 120), + TRY_CONVERT(datetime2, NULLIF(a.undefined6, ''), 111), + TRY_CONVERT(datetime2, a.createdAt) + ) AS ship_dt, + COALESCE( + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined30, '')), + TRY_CONVERT(decimal(18, 2), NULLIF(a.undefined20, '')), + CAST(0 AS decimal(18, 2)) + ) AS shipped_qty +INTO #ship_base +FROM dbo.jst_goods_profit_kpi_items_afs AS a WITH (NOLOCK) +WHERE ISNULL(NULLIF(a.undefined10, ''), '') <> '' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%配件%' + AND ISNULL(NULLIF(a.undefined62, ''), '') NOT LIKE '%特殊单%'; + +IF OBJECT_ID('tempdb..#ship_stats') IS NOT NULL + DROP TABLE #ship_stats; + +SELECT + sb.sku_id, + COUNT(DISTINCT sb.order_id) AS order_count_30d, + COUNT(DISTINCT CASE WHEN ISNULL(sb.product_id, '') <> '' THEN sb.product_id END) AS product_id_count_30d, + COUNT(DISTINCT sb.shop_name) AS shop_count_30d, + SUM(CASE WHEN sb.ship_dt >= DATEADD(day, -7, @end_time) THEN ISNULL(sb.shipped_qty, 0) ELSE 0 END) AS shipped_qty_7d, + SUM(CASE WHEN sb.ship_dt >= DATEADD(day, -30, @end_time) THEN ISNULL(sb.shipped_qty, 0) ELSE 0 END) AS shipped_qty_30d, + MAX(sb.ship_dt) AS last_ship_time +INTO #ship_stats +FROM #ship_base AS sb +WHERE sb.ship_dt >= @start_time + AND sb.ship_dt < @end_time +GROUP BY sb.sku_id; + +IF OBJECT_ID('tempdb..#sku_ref') IS NOT NULL + DROP TABLE #sku_ref; + +SELECT + s.sku_id, + s.name AS sku_name, + s.other_1 AS spu, + s.i_id AS style_code, + s.category, + s.item_type, + s.brand, + s.supplier_name, + s.other_3 AS default_warehouse, + s.other_5 AS sku_status, + s.cost_price, + s.other_10 AS board_material, + s.modified +INTO #sku_ref +FROM dbo.jst_sku AS s WITH (NOLOCK) +WHERE NULLIF(s.sku_id, '') IN (SELECT sku_id FROM #ship_stats); + +IF OBJECT_ID('tempdb..#inv_latest') IS NOT NULL + DROP TABLE #inv_latest; + +WITH inv_rank AS ( + SELECT + NULLIF(i.sku_id, '') AS sku_id, + TRY_CONVERT(decimal(18, 2), i.qty) AS stock_qty, + TRY_CONVERT(decimal(18, 2), i.order_lock) AS order_lock_qty, + TRY_CONVERT(decimal(18, 2), i.pick_lock) AS pick_lock_qty, + TRY_CONVERT(decimal(18, 2), i.lock_qty) AS lock_qty, + TRY_CONVERT(decimal(18, 2), i.virtual_qty) AS virtual_qty, + TRY_CONVERT(decimal(18, 2), i.defective_qty) AS defective_qty, + TRY_CONVERT(decimal(18, 2), i.purchase_qty) AS purchase_qty, + TRY_CONVERT(decimal(18, 2), i.return_qty) AS return_qty, + TRY_CONVERT(decimal(18, 2), i.in_qty) AS in_qty, + TRY_CONVERT(decimal(18, 2), i.min_qty) AS min_qty, + TRY_CONVERT(decimal(18, 2), i.max_qty) AS max_qty, + i.wms_co_id, + i.name AS inventory_note, + COALESCE(i.updateTime, i.modified, i.createTime) AS inventory_snapshot_time, + ROW_NUMBER() OVER ( + PARTITION BY NULLIF(i.sku_id, '') + ORDER BY COALESCE(i.updateTime, i.modified, i.createTime) DESC, i.Id DESC + ) AS rn + FROM dbo.jst_inventoryInfo AS i WITH (NOLOCK) + WHERE NULLIF(i.sku_id, '') IN (SELECT sku_id FROM #ship_stats) +) +SELECT + sku_id, + stock_qty, + order_lock_qty, + pick_lock_qty, + lock_qty, + virtual_qty, + defective_qty, + purchase_qty, + return_qty, + in_qty, + min_qty, + max_qty, + wms_co_id, + inventory_note, + inventory_snapshot_time, + CASE + WHEN ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0) < 0 + THEN CAST(0 AS decimal(18, 2)) + ELSE ISNULL(stock_qty, 0) - ISNULL(order_lock_qty, 0) - ISNULL(pick_lock_qty, 0) - ISNULL(lock_qty, 0) + END AS available_qty +INTO #inv_latest +FROM inv_rank +WHERE rn = 1; + +IF OBJECT_ID('tempdb..#sku_owner') IS NOT NULL + DROP TABLE #sku_owner; + +SELECT + sr.sku_id, + COALESCE( + NULLIF(pc_exact.purchaser, ''), + NULLIF(pc_style.purchaser, ''), + NULLIF(pc_base.purchaser, ''), + '未分配' + ) AS purchaser_name, + COALESCE( + NULLIF(po_exact.oper, ''), + NULLIF(po_style.oper, ''), + NULLIF(po_base.oper, ''), + NULLIF(pc_exact.oper, ''), + NULLIF(pc_style.oper, ''), + NULLIF(pc_base.oper, ''), + '未分配' + ) AS spu_oper_name +INTO #sku_owner +FROM #sku_ref AS sr +OUTER APPLY ( + SELECT TOP 1 c.purchaser, c.oper + FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK) + WHERE NULLIF(c.spu, '') = NULLIF(sr.spu, '') + ORDER BY c.Id DESC +) AS pc_exact +OUTER APPLY ( + SELECT TOP 1 c.purchaser, c.oper + FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK) + WHERE NULLIF(c.spu, '') = NULLIF(sr.style_code, '') + ORDER BY c.Id DESC +) AS pc_style +OUTER APPLY ( + SELECT TOP 1 c.purchaser, c.oper + FROM dbo.fys_SkuDataLibrary_SpuConfig AS c WITH (NOLOCK) + WHERE NULLIF(c.spu, '') = CASE + WHEN CHARINDEX('K', ISNULL(sr.spu, '')) > 0 + THEN LEFT(sr.spu, CHARINDEX('K', sr.spu) - 1) + ELSE NULL + END + ORDER BY c.Id DESC +) AS pc_base +OUTER APPLY ( + SELECT TOP 1 o.oper + FROM dbo.op_operManage AS o WITH (NOLOCK) + WHERE NULLIF(o.spu, '') = NULLIF(sr.spu, '') + ORDER BY o.Id DESC +) AS po_exact +OUTER APPLY ( + SELECT TOP 1 o.oper + FROM dbo.op_operManage AS o WITH (NOLOCK) + WHERE NULLIF(o.spu, '') = NULLIF(sr.style_code, '') + ORDER BY o.Id DESC +) AS po_style +OUTER APPLY ( + SELECT TOP 1 o.oper + FROM dbo.op_operManage AS o WITH (NOLOCK) + WHERE NULLIF(o.spu, '') = CASE + WHEN CHARINDEX('K', ISNULL(sr.spu, '')) > 0 + THEN LEFT(sr.spu, CHARINDEX('K', sr.spu) - 1) + ELSE NULL + END + ORDER BY o.Id DESC +) AS po_base; + +IF OBJECT_ID('tempdb..#operator_top') IS NOT NULL + DROP TABLE #operator_top; + +WITH oper_rank AS ( + SELECT + sb.sku_id, + NULLIF(t.oper, '') AS oper_name, + SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d, + ROW_NUMBER() OVER ( + PARTITION BY sb.sku_id + ORDER BY SUM(ISNULL(sb.shipped_qty, 0)) DESC, NULLIF(t.oper, '') ASC + ) AS rn + FROM #ship_base AS sb + INNER JOIN dbo.op_TargetManagement AS t WITH (NOLOCK) + ON NULLIF(t.shopproid, '') = NULLIF(sb.product_id, '') + AND ISNULL(NULLIF(t.shopname, ''), '') = ISNULL(NULLIF(sb.shop_name, ''), '') + WHERE sb.ship_dt >= @start_time + AND sb.ship_dt < @end_time + AND ISNULL(NULLIF(t.oper, ''), '') <> '' + GROUP BY sb.sku_id, NULLIF(t.oper, '') +) +SELECT + sku_id, + oper_name AS operator_name +INTO #operator_top +FROM oper_rank +WHERE rn = 1; + +IF OBJECT_ID('tempdb..#channel_top') IS NOT NULL + DROP TABLE #channel_top; + +WITH channel_rank AS ( + SELECT + sb.sku_id, + sb.shop_channel, + SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d, + ROW_NUMBER() OVER ( + PARTITION BY sb.sku_id + ORDER BY SUM(ISNULL(sb.shipped_qty, 0)) DESC, sb.shop_channel ASC + ) AS rn + FROM #ship_base AS sb + WHERE sb.ship_dt >= @start_time + AND sb.ship_dt < @end_time + GROUP BY sb.sku_id, sb.shop_channel +) +SELECT + sku_id, + shop_channel AS top_shop_channel, + shipped_qty_sum_30d AS top_channel_shipped_qty_30d +INTO #channel_top +FROM channel_rank +WHERE rn = 1; + +IF OBJECT_ID('tempdb..#shop_top') IS NOT NULL + DROP TABLE #shop_top; + +WITH shop_rank AS ( + SELECT + sb.sku_id, + sb.shop_name, + SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d, + ROW_NUMBER() OVER ( + PARTITION BY sb.sku_id + ORDER BY SUM(ISNULL(sb.shipped_qty, 0)) DESC, sb.shop_name ASC + ) AS rn + FROM #ship_base AS sb + WHERE sb.ship_dt >= @start_time + AND sb.ship_dt < @end_time + GROUP BY sb.sku_id, sb.shop_name +) +SELECT + sku_id, + shop_name AS top_shop_name, + shipped_qty_sum_30d AS top_shop_shipped_qty_30d +INTO #shop_top +FROM shop_rank +WHERE rn = 1; + +IF OBJECT_ID('tempdb..#warehouse_top') IS NOT NULL + DROP TABLE #warehouse_top; + +WITH wh_rank AS ( + SELECT + sb.sku_id, + sb.ship_warehouse_name, + SUM(ISNULL(sb.shipped_qty, 0)) AS shipped_qty_sum_30d, + ROW_NUMBER() OVER ( + PARTITION BY sb.sku_id + ORDER BY SUM(ISNULL(sb.shipped_qty, 0)) DESC, sb.ship_warehouse_name ASC + ) AS rn + FROM #ship_base AS sb + WHERE sb.ship_dt >= @start_time + AND sb.ship_dt < @end_time + AND ISNULL(NULLIF(sb.ship_warehouse_name, ''), '') <> '' + GROUP BY sb.sku_id, sb.ship_warehouse_name +) +SELECT + sku_id, + ship_warehouse_name AS top_warehouse_name +INTO #warehouse_top +FROM wh_rank +WHERE rn = 1; + +IF OBJECT_ID('tempdb..#risk_eval') IS NOT NULL + DROP TABLE #risk_eval; + +SELECT + sr.sku_id, + sr.sku_name, + sr.spu, + sr.category, + sr.item_type, + sr.brand, + sr.cost_price, + sr.board_material, + sr.sku_status, + COALESCE(NULLIF(sr.supplier_name, ''), '未标注') AS factory_name, + COALESCE(NULLIF(ow.purchaser_name, ''), '未分配') AS purchaser_name, + COALESCE(NULLIF(ot.operator_name, ''), NULLIF(ow.spu_oper_name, ''), '未分配') AS operator_name, + COALESCE(NULLIF(wt.top_warehouse_name, ''), NULLIF(sr.default_warehouse, ''), NULLIF(inv.inventory_note, ''), '未标注') AS warehouse_name, + ct.top_shop_channel, + stp.top_shop_name, + ISNULL(ss.product_id_count_30d, 0) AS product_id_count_30d, + ISNULL(ss.shop_count_30d, 0) AS shop_count_30d, + ISNULL(ss.order_count_30d, 0) AS order_count_30d, + ISNULL(ss.shipped_qty_7d, 0) AS shipped_qty_7d, + ISNULL(ss.shipped_qty_30d, 0) AS shipped_qty_30d, + ss.last_ship_time, + inv.stock_qty, + inv.order_lock_qty, + inv.pick_lock_qty, + inv.lock_qty, + inv.available_qty, + inv.virtual_qty, + inv.defective_qty, + inv.purchase_qty, + inv.return_qty, + inv.in_qty, + inv.min_qty, + inv.max_qty, + inv.inventory_snapshot_time, + CAST(ISNULL(ss.shipped_qty_7d, 0) / 7.0 AS decimal(18, 2)) AS avg_daily_shipped_7d, + CAST(ISNULL(ss.shipped_qty_30d, 0) / 30.0 AS decimal(18, 2)) AS avg_daily_shipped_30d, + CAST( + CASE + WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN NULL + ELSE inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0) + END AS decimal(18, 2) + ) AS estimated_stockout_days, + @lead_days AS replenishment_lead_days, + CAST( + CASE + WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN 0 + WHEN inv.available_qty >= (ISNULL(ss.shipped_qty_30d, 0) / 30.0) * @lead_days THEN 0 + ELSE ((ISNULL(ss.shipped_qty_30d, 0) / 30.0) * @lead_days) - inv.available_qty + END AS decimal(18, 2) + ) AS suggest_replenish_qty_to_lead_days, + CASE + WHEN inv.sku_id IS NULL THEN '无库存快照' + WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN '近30天无发货' + WHEN inv.available_qty <= 0 THEN '缺货/可用库存<=0' + WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= @lead_days THEN '补货紧急(<=10天)' + WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= @warn_days THEN '补货预警(10-15天)' + ELSE '库存安全' + END AS stock_risk_level, + CASE + WHEN inv.sku_id IS NULL THEN 1 + WHEN ISNULL(ss.shipped_qty_30d, 0) <= 0 THEN 0 + WHEN inv.available_qty <= 0 THEN 1 + WHEN (inv.available_qty / NULLIF(ISNULL(ss.shipped_qty_30d, 0) / 30.0, 0)) <= @warn_days THEN 1 + ELSE 0 + END AS has_stock_risk +INTO #risk_eval +FROM #ship_stats AS ss +LEFT JOIN #sku_ref AS sr + ON sr.sku_id = ss.sku_id +LEFT JOIN #inv_latest AS inv + ON inv.sku_id = ss.sku_id +LEFT JOIN #sku_owner AS ow + ON ow.sku_id = ss.sku_id +LEFT JOIN #operator_top AS ot + ON ot.sku_id = ss.sku_id +LEFT JOIN #channel_top AS ct + ON ct.sku_id = ss.sku_id +LEFT JOIN #shop_top AS stp + ON stp.sku_id = ss.sku_id +LEFT JOIN #warehouse_top AS wt + ON wt.sku_id = ss.sku_id; + +-- 1) 汇总:近30天在售SKU + 缺货风险SKU数量 +SELECT + COUNT(*) AS sold_sku_count_30d, + SUM(CASE WHEN has_stock_risk = 1 THEN 1 ELSE 0 END) AS stock_risk_sku_count, + SUM(CASE WHEN stock_risk_level LIKE '补货紧急%' OR stock_risk_level LIKE '缺货%' THEN 1 ELSE 0 END) AS urgent_sku_count, + SUM(CASE WHEN stock_risk_level LIKE '补货预警%' THEN 1 ELSE 0 END) AS warning_sku_count, + @lookback_days AS lookback_days, + @lead_days AS replenishment_lead_days, + @warn_days AS warning_days +FROM #risk_eval; + +-- 2) 明细:全量SKU风险清单(默认只显示风险项) +SELECT TOP (@limit) + sku_id, + sku_name, + spu, + category, + item_type, + brand, + sku_status, + cost_price, + board_material, + factory_name, + purchaser_name, + operator_name, + warehouse_name, + top_shop_channel, + top_shop_name, + product_id_count_30d, + shop_count_30d, + order_count_30d, + shipped_qty_7d, + shipped_qty_30d, + avg_daily_shipped_7d, + avg_daily_shipped_30d, + stock_qty, + order_lock_qty, + pick_lock_qty, + lock_qty, + available_qty, + estimated_stockout_days, + replenishment_lead_days, + suggest_replenish_qty_to_lead_days, + stock_risk_level, + inventory_snapshot_time, + last_ship_time +FROM #risk_eval +WHERE @include_safe = 1 + OR has_stock_risk = 1 +ORDER BY + CASE + WHEN stock_risk_level LIKE '缺货%' THEN 1 + WHEN stock_risk_level LIKE '补货紧急%' THEN 2 + WHEN stock_risk_level LIKE '补货预警%' THEN 3 + WHEN stock_risk_level = '无库存快照' THEN 4 + ELSE 9 + END, + estimated_stockout_days ASC, + shipped_qty_30d DESC, + sku_id ASC; +""" + + +def main() -> int: + parser = argparse.ArgumentParser(description="All-SKU stock risk alert report") + parser.add_argument("--days", type=int, default=30, help="lookback window days, default 30") + parser.add_argument("--lead-days", type=int, default=10, help="replenishment lead time days") + parser.add_argument("--warn-days", type=int, default=15, help="warning line days (must >= lead-days)") + parser.add_argument("--limit", type=int, default=500, help="max rows for detail result set") + parser.add_argument("--include-safe", action="store_true", help="include safe SKUs in detail output") + args = parser.parse_args() + + days = max(7, min(args.days, 180)) + lead_days = max(1, min(args.lead_days, 60)) + warn_days = max(lead_days, min(args.warn_days, 90)) + limit = max(1, min(args.limit, 5000)) + + sql = build_sql(days, lead_days, warn_days, limit, args.include_safe) + cmd = ["company-db", "query", "--sql", sql] + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "company-db failed").strip() + print(err, file=sys.stderr) + return proc.returncode + + print(proc.stdout.rstrip()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stock-risk-to-feishu b/stock-risk-to-feishu new file mode 100755 index 0000000..08477ad --- /dev/null +++ b/stock-risk-to-feishu @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Dict, List, Tuple + + +DEFAULT_APP_TOKEN = "SZ4ebCKU3a9OdfspetxcSeg1n4g" +DEFAULT_TABLE_ID = "tblzuMy85WiRAUX5" +DEFAULT_TABLE_LINK = ( + "https://acnuoqgha8fk.feishu.cn/wiki/" + "RBSxwq8woiQDCUkRZfkcg7x8nMb?table=tblzuMy85WiRAUX5&view=vewuydm9EI" +) +OPENCLAW_CONFIG = "/root/.openclaw/openclaw.json" + + +REQUIRED_FIELDS = [ + "记录类型", + "批次ID", + "报表时间", + "统计周期天数", + "补货提前期天数", + "预警阈值天数", + "在售SKU数", + "风险SKU数", + "紧急SKU数", + "预警SKU数", + "SKU编码", + "SKU名称", + "SPU", + "风险等级", + "预计缺货天数", + "建议补货件数", + "可用库存", + "近7天发货件数", + "近30天发货件数", + "近30天日均发货", + "渠道", + "店铺", + "采购", + "运营", + "工厂", + "仓库", +] + + +def _http_json(method: str, url: str, headers: Dict[str, str], payload: Dict = None) -> Dict: + data = None + req_headers = dict(headers) + if payload is not None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req_headers["Content-Type"] = "application/json; charset=utf-8" + + req = urllib.request.Request(url, data=data, headers=req_headers, method=method) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + raw = resp.read().decode("utf-8") + if not raw: + return {} + return json.loads(raw) + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="ignore") + raise RuntimeError(f"HTTP {e.code} {url}: {body[:1000]}") from e + except Exception as e: + raise RuntimeError(f"request failed {method} {url}: {e}") from e + + +def _load_feishu_app() -> Tuple[str, str]: + app_id = os.getenv("FEISHU_APP_ID", "").strip() + app_secret = os.getenv("FEISHU_APP_SECRET", "").strip() + if app_id and app_secret: + return app_id, app_secret + + cfg_path = Path(OPENCLAW_CONFIG) + if not cfg_path.exists(): + raise RuntimeError("missing feishu credentials (env and openclaw.json both unavailable)") + + cfg = json.loads(cfg_path.read_text(encoding="utf-8")) + ch = (cfg.get("channels") or {}).get("feishu") or {} + app_id = (ch.get("appId") or "").strip() + app_secret = (ch.get("appSecret") or "").strip() + if not app_id or not app_secret: + raise RuntimeError("feishu appId/appSecret is empty in openclaw.json") + return app_id, app_secret + + +def _tenant_token(app_id: str, app_secret: str) -> str: + url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" + res = _http_json( + "POST", + url, + headers={}, + payload={"app_id": app_id, "app_secret": app_secret}, + ) + if res.get("code") != 0: + raise RuntimeError(f"feishu auth failed: {res}") + token = res.get("tenant_access_token") + if not token: + raise RuntimeError("feishu auth succeeded but tenant_access_token missing") + return token + + +def _parse_company_rows(output: str) -> Dict[int, Dict]: + result_sets: Dict[int, Dict] = {} + current_idx = None + current_cols: List[str] = [] + current_rows: List[Dict] = [] + + def flush(): + nonlocal current_idx, current_cols, current_rows + if current_idx is not None: + result_sets[current_idx] = {"columns": current_cols, "rows": current_rows} + current_idx = None + current_cols = [] + current_rows = [] + + for raw_line in output.splitlines(): + line = raw_line.strip() + if not line: + continue + if line.startswith("RESULT_SET "): + flush() + current_idx = int(line.split()[1]) + continue + if line.startswith("COLUMNS:"): + cols = line.split(":", 1)[1].strip() + current_cols = [c.strip() for c in cols.split(",")] + continue + if line.startswith("{") and line.endswith("}"): + try: + current_rows.append(json.loads(line)) + except Exception: + pass + continue + flush() + return result_sets + + +def _run_stock_risk(days: int, lead_days: int, warn_days: int, limit: int) -> Tuple[Dict, List[Dict]]: + cmd = [ + "stock-risk-alerts", + "--days", + str(days), + "--lead-days", + str(lead_days), + "--warn-days", + str(warn_days), + "--limit", + str(limit), + ] + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + err = (proc.stderr or proc.stdout or "stock-risk-alerts failed").strip() + raise RuntimeError(err) + parsed = _parse_company_rows(proc.stdout or "") + summary_rows = parsed.get(1, {}).get("rows", []) + detail_rows = parsed.get(2, {}).get("rows", []) + if not summary_rows: + raise RuntimeError("stock-risk-alerts returned no summary rows") + return summary_rows[0], detail_rows + + +def _list_fields(token: str, app_token: str, table_id: str) -> Dict[str, str]: + url = ( + f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields" + "?page_size=500" + ) + res = _http_json("GET", url, headers={"Authorization": f"Bearer {token}"}) + if res.get("code") != 0: + raise RuntimeError(f"list fields failed: {res}") + items = (((res.get("data") or {}).get("items")) or []) + out: Dict[str, str] = {} + for it in items: + name = it.get("field_name") + fid = it.get("field_id") + if name and fid: + out[name] = fid + return out + + +def _create_field(token: str, app_token: str, table_id: str, field_name: str) -> None: + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields" + payload = {"field_name": field_name, "type": 1} + res = _http_json("POST", url, headers={"Authorization": f"Bearer {token}"}, payload=payload) + if res.get("code") != 0: + raise RuntimeError(f"create field failed ({field_name}): {res}") + + +def _ensure_fields(token: str, app_token: str, table_id: str) -> None: + fields = _list_fields(token, app_token, table_id) + for name in REQUIRED_FIELDS: + if name not in fields: + _create_field(token, app_token, table_id, name) + # wait a bit for schema consistency + time.sleep(0.5) + + +def _create_record(token: str, app_token: str, table_id: str, fields: Dict[str, str]) -> str: + url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records" + payload = {"fields": fields} + res = _http_json("POST", url, headers={"Authorization": f"Bearer {token}"}, payload=payload) + if res.get("code") != 0: + raise RuntimeError(f"create record failed: {res}") + rec = (res.get("data") or {}).get("record") or {} + return rec.get("record_id") or rec.get("id") or "" + + +def _fmt(v) -> str: + if v is None: + return "" + if isinstance(v, float): + return f"{v:.2f}".rstrip("0").rstrip(".") + return str(v) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Push stock risk alerts to Feishu as structured rows") + parser.add_argument("--days", type=int, default=30) + parser.add_argument("--lead-days", type=int, default=10) + parser.add_argument("--warn-days", type=int, default=15) + parser.add_argument("--top", type=int, default=50, help="top N detail rows to push") + parser.add_argument("--app-token", default=DEFAULT_APP_TOKEN) + parser.add_argument("--table-id", default=DEFAULT_TABLE_ID) + parser.add_argument("--table-link", default=DEFAULT_TABLE_LINK) + args = parser.parse_args() + + days = max(7, min(args.days, 180)) + lead_days = max(1, min(args.lead_days, 60)) + warn_days = max(lead_days, min(args.warn_days, 90)) + top = max(1, min(args.top, 500)) + + summary, details = _run_stock_risk(days, lead_days, warn_days, limit=max(top, 50)) + + app_id, app_secret = _load_feishu_app() + token = _tenant_token(app_id, app_secret) + _ensure_fields(token, args.app_token, args.table_id) + + batch_id = time.strftime("%Y%m%d%H%M%S") + now_text = time.strftime("%Y-%m-%d %H:%M:%S") + + summary_record = { + "记录类型": "汇总", + "批次ID": batch_id, + "报表时间": now_text, + "统计周期天数": _fmt(summary.get("lookback_days", days)), + "补货提前期天数": _fmt(summary.get("replenishment_lead_days", lead_days)), + "预警阈值天数": _fmt(summary.get("warning_days", warn_days)), + "在售SKU数": _fmt(summary.get("sold_sku_count_30d")), + "风险SKU数": _fmt(summary.get("stock_risk_sku_count")), + "紧急SKU数": _fmt(summary.get("urgent_sku_count")), + "预警SKU数": _fmt(summary.get("warning_sku_count")), + } + summary_record_id = _create_record(token, args.app_token, args.table_id, summary_record) + + detail_record_ids: List[str] = [] + for row in details[:top]: + fields = { + "记录类型": "明细", + "批次ID": batch_id, + "报表时间": now_text, + "统计周期天数": _fmt(days), + "补货提前期天数": _fmt(lead_days), + "预警阈值天数": _fmt(warn_days), + "SKU编码": _fmt(row.get("sku_id")), + "SKU名称": _fmt(row.get("sku_name")), + "SPU": _fmt(row.get("spu")), + "风险等级": _fmt(row.get("stock_risk_level")), + "预计缺货天数": _fmt(row.get("estimated_stockout_days")), + "建议补货件数": _fmt(row.get("suggest_replenish_qty_to_lead_days")), + "可用库存": _fmt(row.get("available_qty")), + "近7天发货件数": _fmt(row.get("shipped_qty_7d")), + "近30天发货件数": _fmt(row.get("shipped_qty_30d")), + "近30天日均发货": _fmt(row.get("avg_daily_shipped_30d")), + "渠道": _fmt(row.get("top_shop_channel")), + "店铺": _fmt(row.get("top_shop_name")), + "采购": _fmt(row.get("purchaser_name")), + "运营": _fmt(row.get("operator_name")), + "工厂": _fmt(row.get("factory_name")), + "仓库": _fmt(row.get("warehouse_name")), + } + rec_id = _create_record(token, args.app_token, args.table_id, fields) + detail_record_ids.append(rec_id) + + print("PUSH_STATUS: success") + print(f"TABLE_LINK: {args.table_link}") + print(f"BATCH_ID: {batch_id}") + print(f"SUMMARY_RECORD_ID: {summary_record_id}") + print(f"DETAIL_RECORD_COUNT: {len(detail_record_ids)}") + if detail_record_ids: + print(f"FIRST_DETAIL_RECORD_ID: {detail_record_ids[0]}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as e: + print(f"PUSH_STATUS: failed\nERROR: {e}", file=sys.stderr) + raise diff --git a/test_feishu_bitable_openclaw.sh b/test_feishu_bitable_openclaw.sh new file mode 100644 index 0000000..57828d2 --- /dev/null +++ b/test_feishu_bitable_openclaw.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail +APP_TOKEN="SZ4ebCKU3a9OdfspetxcSeg1n4g" +TABLE_ID="tblzuMy85WiRAUX5" +TS="$(date +%F_%H:%M:%S)" +MSG1="调用 feishu_bitable_list_records,app_token=${APP_TOKEN},table_id=${TABLE_ID},page_size=3。返回记录数量和前3条文本字段。" +MSG2="调用 feishu_bitable_create_record,app_token=${APP_TOKEN},table_id=${TABLE_ID},fields={\"文本\":\"OpenClaw联调测试-${TS}\"}。返回新建record_id和写入内容。" +MSG3="再次调用 feishu_bitable_list_records,app_token=${APP_TOKEN},table_id=${TABLE_ID},page_size=5。返回最近5条的文本字段。" + +/usr/bin/openclaw agent --session-id feishu-e2e-$(date +%s)-1 --message "$MSG1" --json --timeout 120 > /tmp/feishu_test_step1.out 2>&1 || true +/usr/bin/openclaw agent --session-id feishu-e2e-$(date +%s)-2 --message "$MSG2" --json --timeout 120 > /tmp/feishu_test_step2.out 2>&1 || true +/usr/bin/openclaw agent --session-id feishu-e2e-$(date +%s)-3 --message "$MSG3" --json --timeout 120 > /tmp/feishu_test_step3.out 2>&1 || true + +python3 - <<'PY' +import json +from pathlib import Path + + +def parse(path): + raw = Path(path).read_text(encoding="utf-8", errors="ignore").strip() + dec = json.JSONDecoder() + data = None + try: + data, _ = dec.raw_decode(raw) + except Exception: + idx = raw.find('{') + while idx != -1: + try: + data, _ = dec.raw_decode(raw[idx:]) + break + except Exception: + idx = raw.find('{', idx + 1) + if not isinstance(data, dict): + return {"ok": False, "raw": raw[:600]} + payloads = data.get("result", {}).get("payloads", []) + text = "\n".join(p.get("text", "") for p in payloads if isinstance(p, dict) and p.get("text")) + return {"ok": True, "text": text[:2000], "status": data.get("status"), "summary": data.get("summary")} + +for i in (1, 2, 3): + r = parse(f"/tmp/feishu_test_step{i}.out") + print(f"===== STEP {i} =====") + if not r.get("ok"): + print("PARSE_FAIL", r.get("raw")) + else: + print("status=", r.get("status"), "summary=", r.get("summary")) + print(r.get("text") or "") +PY diff --git a/test_feishu_node.py b/test_feishu_node.py new file mode 100644 index 0000000..040954b --- /dev/null +++ b/test_feishu_node.py @@ -0,0 +1,25 @@ +import requests + +app_id = 'cli_a9000d2e8678dbde' +app_secret = 'E4sZQAEk4kZC1jYXE5JUodr8rtXnnB7W' +node = 'RBSxwq8woiQDCUkRZfkcg7x8nMb' + +r = requests.post( + 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', + json={'app_id': app_id, 'app_secret': app_secret}, + timeout=15, +) +print('token_status=', r.status_code) +print('token_body=', r.text) +res = r.json() +tok = res.get('tenant_access_token') +headers = {'Authorization': f'Bearer {tok}'} + +r2 = requests.get( + 'https://open.feishu.cn/open-apis/wiki/v2/spaces/get_node', + params={'token': node}, + headers=headers, + timeout=15, +) +print('node_status=', r2.status_code) +print('node_body=', r2.text) diff --git a/update_agents_feishu_continuous.py b/update_agents_feishu_continuous.py new file mode 100644 index 0000000..f7d89ef --- /dev/null +++ b/update_agents_feishu_continuous.py @@ -0,0 +1,28 @@ +from pathlib import Path +p=Path('/root/.openclaw/workspace/AGENTS.md') +text=p.read_text(encoding='utf-8') +old='''Required behavior: +- When user asks to push result to Feishu Bitable, run write action first (feishu_bitable_create_record or feishu_bitable_update_record). +- In final reply, always include: + 1) push status (success/failed) + 2) target sheet link (the user-provided wiki/base URL) + 3) written record_id (or updated count) +- If write partially fails, return succeeded records and failed reason list separately. +- If user explicitly asks for "只要结果", keep reasoning short but still include the sheet link and write status line. +''' +new='''Required behavior: +- When user asks to push result to Feishu Bitable, run write action first (feishu_bitable_create_record or feishu_bitable_update_record). +- In continuous chat, if user says "把上一个结果推到飞书", use the previous assistant result as write payload. +- If target sheet link is omitted this turn, reuse the most recent Feishu wiki/base link from this conversation; if no link exists, ask one short question for the link. +- In final reply, always include: + 1) push status (success/failed) + 2) target sheet link (the user-provided wiki/base URL) + 3) written record_id (or updated count) +- If write partially fails, return succeeded records and failed reason list separately. +- If user explicitly asks for "只要结果", keep reasoning short but still include the sheet link and write status line. +''' +if old not in text: + raise SystemExit('target block not found') +text=text.replace(old,new,1) +p.write_text(text,encoding='utf-8') +print('updated',p) diff --git a/update_agents_stock_rules.py b/update_agents_stock_rules.py new file mode 100644 index 0000000..0722eff --- /dev/null +++ b/update_agents_stock_rules.py @@ -0,0 +1,26 @@ +from pathlib import Path +p = Path('/root/.openclaw/workspace/AGENTS.md') +text = p.read_text(encoding='utf-8') +text = text.replace( + 'Trigger keywords: 销售 / 业绩 / 售后 / 退款 / 费比 / 店铺周报 / SKU结构 / 发货 / 工单 / 客诉 / 客服体验 / 用户反馈 / 话术分析 / 产品信息 / 商品ID / SKU编码 / 成本 / 材质 / 预览图', + 'Trigger keywords: 销售 / 业绩 / 售后 / 退款 / 费比 / 店铺周报 / SKU结构 / 发货 / 工单 / 客诉 / 客服体验 / 用户反馈 / 话术分析 / 产品信息 / 商品ID / SKU编码 / 成本 / 材质 / 预览图 / 库存 / 缺货 / 补货 / 安全库存' +) +text = text.replace( + '- 产品信息(SKU/成本/材质/预览图) -> `exec: product-info "" --days 30 --limit 20`', + '- 产品信息/库存预警(SKU/成本/材质/预览图/库存/发货件数/缺货天数) -> `exec: product-info "" --days 30 --limit 20`' +) +text = text.replace( + '- SKU编码, SKU名称, 成本价, 材质, 预览图地址', + '- SKU编码, SKU名称, 成本价, 材质, 预览图地址, 当前库存, 日均发货件数, 预计缺货天数, 补货建议' +) +extra = '''- 库存预警(强制): + - 必须输出 `estimated_stockout_days` 与 `replenishment_lead_days(=10)` + - 当 `estimated_stockout_days <= 10` 时,结论必须标记 `补货紧急` +''' +if '库存预警(强制)' not in text: + insert_after = '- 若存在售后数据,追加 售后总览 + 售后类型矩阵 + 售后原因TOP(近30天)\n' + if insert_after in text: + text = text.replace(insert_after, insert_after + extra) + +p.write_text(text, encoding='utf-8') +print('updated', p) diff --git a/update_company_db_tables.py b/update_company_db_tables.py new file mode 100644 index 0000000..aad3f57 --- /dev/null +++ b/update_company_db_tables.py @@ -0,0 +1,53 @@ +from pathlib import Path +p=Path('/root/.openclaw/workspace/sqlserver/company_db_tool.py') +text=p.read_text(encoding='utf-8') +old='''BUSINESS_TABLES = [ + "jst_goods_profit_kpi_items_afs", + "jst_sku", + "fys_shopInfo", + "jst_order_express_details", + "fys_SkuDataLibrary_SpuConfig", + "op_operManage", + "op_TargetManagement", + "fys_Ticketing_install", + "fys_Ticketing_install_item", + "fys_jstFreightCost", + "fys_jstFreightCostDetail", + "pc_kfChatLog", +] +''' +new='''BUSINESS_TABLES = [ + "jst_goods_profit_kpi_items_afs", + "jst_sku", + "fys_shopInfo", + "jst_order_express_details", + "fys_SkuDataLibrary_SpuConfig", + "op_operManage", + "op_TargetManagement", + "fys_Ticketing_install", + "fys_Ticketing_install_item", + "fys_jstFreightCost", + "fys_jstFreightCostDetail", + "pc_kfChatLog", + "jst_inventoryInfo", + "pm_InventoryMonitor", + "pm_stockMonitor", + "wp_stock", + "erp_wms_stockbiz", +] +''' +if old not in text: + raise SystemExit('target block not found') +text=text.replace(old,new,1) +# also update dimension row if exists +needle=''' ("时间维度", "创建时间、支付时间、发货时间、签收时间、订单完成时间", "全表通用时间字段(createdAt/createTime/pay_time/send_time 等)"), + ("客服问题维度", "聊天主次原因、问题内容、订单关联", "pc_kfChatLog"), +''' +insert=''' ("时间维度", "创建时间、支付时间、发货时间、签收时间、订单完成时间", "全表通用时间字段(createdAt/createTime/pay_time/send_time 等)"), + ("库存维度", "可用库存、锁定库存、在途库存、近7/30天发货均值、缺货预警", "jst_inventoryInfo, pm_InventoryMonitor, pm_stockMonitor, wp_stock, erp_wms_stockbiz"), + ("客服问题维度", "聊天主次原因、问题内容、订单关联", "pc_kfChatLog"), +''' +if needle in text: + text=text.replace(needle,insert,1) +p.write_text(text,encoding='utf-8') +print('updated',p) diff --git a/update_skill_stock_rules.py b/update_skill_stock_rules.py new file mode 100644 index 0000000..434ac19 --- /dev/null +++ b/update_skill_stock_rules.py @@ -0,0 +1,18 @@ +from pathlib import Path +p = Path('/root/.openclaw/skills/web-task-router/SKILL.md') +text = p.read_text(encoding='utf-8') +text = text.replace( + 'for example: "销售数据", "业绩", "店铺业绩", "售后", "退款", "费比", "客服体验", "用户反馈", "话术分析", "产品信息", "SKU编码", "材质", "预览图"', + 'for example: "销售数据", "业绩", "店铺业绩", "售后", "退款", "费比", "客服体验", "用户反馈", "话术分析", "产品信息", "SKU编码", "材质", "预览图", "库存", "缺货", "补货"' +) +text = text.replace( + ' 2) Product info query (`SKU/成本/材质/预览图`): `exec`: `product-info "" --days 30 --limit 20`', + ' 2) Product info / stock-risk query (`SKU/成本/材质/预览图/库存/发货件数/缺货天数`): `exec`: `product-info "" --days 30 --limit 20`' +) +extra = '- For stock-risk tasks, always include: current stock, daily shipped pieces, estimated stockout days, and whether it is within the 10-day replenishment lead line.\n' +if 'For stock-risk tasks' not in text: + anchor = ' 5) Customer-service experience/writing analysis: `exec`: `customer-service-analysis --start --end [--shop "店铺关键词"] --limit 20`\n' + if anchor in text: + text = text.replace(anchor, anchor + extra) +p.write_text(text, encoding='utf-8') +print('updated', p) diff --git a/update_sql_readme_stock.py b/update_sql_readme_stock.py new file mode 100644 index 0000000..99a89f9 --- /dev/null +++ b/update_sql_readme_stock.py @@ -0,0 +1,25 @@ +from pathlib import Path +p = Path('/root/.openclaw/workspace/sqlserver/README_SQL_TEMPLATES.md') +text = p.read_text(encoding='utf-8') +text = text.replace( + '- 用途:按 SKU/SPU/关键词查询产品基础信息(成本、材质、预览图地址)', + '- 用途:按 SKU/SPU/关键词查询产品基础信息 + 库存与缺货预警(成本、材质、预览图、库存、发货件数、预计缺货天数)' +) +if '- 结果集9:最近N天每日总发货件数(按SKU)' not in text: + text = text.replace( + ' - 结果集8:最近N天售后原因TOP(含来源)\n', + ' - 结果集8:最近N天售后原因TOP(含来源)\n' + ' - 结果集9:最近N天每日总发货件数(按SKU)\n' + ' - 结果集10:库存风险预估(当前库存/日均发货/预计缺货天数/补货建议,补货安全线=10天)\n' + ) +if '15. `sku_stock_risk_report.sql`' not in text: + insert = ''' +15. `sku_stock_risk_report.sql` + - 用途:按 SKU 编码查询库存、近30天日均发货件数、预计缺货天数、补货建议(安全线10天) + - 默认时间:最近30天(模板顶部可改 `@lookback_days`) + - 参数:修改模板顶部 `@sku` 即可 +''' + text = text.replace('14. `customer-service-analysis`(CLI辅助命令)\n', '14. `customer-service-analysis`(CLI辅助命令)\n' + insert) + +p.write_text(text, encoding='utf-8') +print('updated', p) diff --git a/update_tools_stock_helper.py b/update_tools_stock_helper.py new file mode 100644 index 0000000..6e0c7c4 --- /dev/null +++ b/update_tools_stock_helper.py @@ -0,0 +1,11 @@ +from pathlib import Path +p = Path('/root/.openclaw/workspace/TOOLS.md') +text = p.read_text(encoding='utf-8') +text = text.replace( + '- Returns:\n 1) Product master info (`cost_price`, `board_material`, `preview_pic_url`)\n 2) Last N days shipped/aftersale counts\n 3) Last N days shop distribution by shipped piece count\n', + '- Returns:\n 1) Product master info (`cost_price`, `board_material`, `preview_pic_url`)\n 2) Last N days shipped/aftersale counts\n 3) Last N days shop distribution by shipped piece count\n 4) Last N days daily shipped pieces by SKU\n 5) Stock risk estimation (`available_qty`, `avg_daily_shipped_30d`, `estimated_stockout_days`, `suggest_replenish_qty_to_10d`)\n' +) +if '- Replenishment warning rule: when `estimated_stockout_days <= 10`, mark as urgent restock.' not in text: + text += '\n- Replenishment warning rule: when `estimated_stockout_days <= 10`, mark as urgent restock.\n' +p.write_text(text, encoding='utf-8') +print('updated', p) diff --git a/update_websites_stock.py b/update_websites_stock.py new file mode 100644 index 0000000..6206acd --- /dev/null +++ b/update_websites_stock.py @@ -0,0 +1,27 @@ +from pathlib import Path +p=Path('/root/.openclaw/workspace/websites.yaml') +text=p.read_text(encoding='utf-8') +for kw in [' - 库存\n',' - 缺货\n',' - 补货\n',' - 安全库存\n']: + if kw not in text: + text=text.replace(' - 上周\n force_site_id: company-sales-db-analysis\n', ' - 上周\n'+kw+' force_site_id: company-sales-db-analysis\n') + +for kw in [' - 库存\n',' - 缺货\n',' - 补货\n',' - 安全库存\n']: + if kw not in text: + text=text.replace(' - 预览图\n login_required: false\n', ' - 预览图\n'+kw+' login_required: false\n') + +add_tables = [' - jst_inventoryInfo\n',' - pm_InventoryMonitor\n',' - pm_stockMonitor\n',' - wp_stock\n'] +for t in add_tables: + if t not in text: + text=text.replace(' - pc_kfChatLog\n', ' - pc_kfChatLog\n'+t) + +line=' - 产品信息查询(SKU编码/成本/材质/预览图)优先命令:`product-info "" --days 30 --limit 20`\n' +newline=' - 产品信息/库存查询(SKU编码/成本/材质/预览图/库存/缺货天数)优先命令:`product-info "" --days 30 --limit 20`\n' +if line in text: + text=text.replace(line,newline) + +stock_rule=' - 库存预警口径:输出当前库存、近30天日均发货件数、预计缺货天数;若预计缺货天数<=10天,必须标记补货紧急。\n' +if stock_rule not in text: + text=text.replace(' - 默认以发货件数/售后件数/订单数作为主口径,金额字段仅作补充说明。\n', ' - 默认以发货件数/售后件数/订单数作为主口径,金额字段仅作补充说明。\n'+stock_rule) + +p.write_text(text,encoding='utf-8') +print('updated',p) diff --git a/xhs-login-qrcode.png b/xhs-login-qrcode.png new file mode 100644 index 0000000..70d2788 Binary files /dev/null and b/xhs-login-qrcode.png differ diff --git a/xhs-main.zip b/xhs-main.zip new file mode 100644 index 0000000..d7deb11 Binary files /dev/null and b/xhs-main.zip differ diff --git a/xiaohongshu-mcp-main/.DS_Store b/xiaohongshu-mcp-main/.DS_Store new file mode 100644 index 0000000..247e79f Binary files /dev/null and b/xiaohongshu-mcp-main/.DS_Store differ diff --git a/xiaohongshu-mcp-main/.all-contributorsrc b/xiaohongshu-mcp-main/.all-contributorsrc new file mode 100644 index 0000000..696c7c6 --- /dev/null +++ b/xiaohongshu-mcp-main/.all-contributorsrc @@ -0,0 +1,212 @@ +{ + "files": [ + "README.md" + ], + "imageSize": 100, + "commit": false, + "commitType": "docs", + "commitConvention": "angular", + "contributors": [ + { + "login": "xpzouying", + "name": "zy", + "avatar_url": "https://avatars.githubusercontent.com/u/3946563?v=4", + "profile": "https://haha.ai", + "contributions": [ + "code", + "ideas", + "doc", + "design", + "maintenance", + "infra", + "review" + ] + }, + { + "login": "esperyong", + "name": "clearwater", + "avatar_url": "https://avatars.githubusercontent.com/u/1271815?v=4", + "profile": "http://www.hwbuluo.com", + "contributions": [ + "code" + ] + }, + { + "login": "laryzhong", + "name": "Zhongpeng", + "avatar_url": "https://avatars.githubusercontent.com/u/47939471?v=4", + "profile": "https://github.com/laryzhong", + "contributions": [ + "code" + ] + }, + { + "login": "DTDucas", + "name": "Duong Tran", + "avatar_url": "https://avatars.githubusercontent.com/u/105262836?v=4", + "profile": "https://github.com/DTDucas", + "contributions": [ + "code" + ] + }, + { + "login": "Angiin", + "name": "Angiin", + "avatar_url": "https://avatars.githubusercontent.com/u/17389304?v=4", + "profile": "https://github.com/Angiin", + "contributions": [ + "code" + ] + }, + { + "login": "muhenan", + "name": "Henan Mu", + "avatar_url": "https://avatars.githubusercontent.com/u/43441941?v=4", + "profile": "https://github.com/muhenan", + "contributions": [ + "code" + ] + }, + { + "login": "chengazhen", + "name": "Journey", + "avatar_url": "https://avatars.githubusercontent.com/u/52627267?v=4", + "profile": "https://github.com/chengazhen", + "contributions": [ + "code" + ] + }, + { + "login": "eveyuyi", + "name": "Eve Yu", + "avatar_url": "https://avatars.githubusercontent.com/u/69026872?v=4", + "profile": "https://github.com/eveyuyi", + "contributions": [ + "code" + ] + }, + { + "login": "CooperGuo", + "name": "CooperGuo", + "avatar_url": "https://avatars.githubusercontent.com/u/183056602?v=4", + "profile": "https://github.com/CooperGuo", + "contributions": [ + "code" + ] + }, + { + "login": "BiboyQG", + "name": "Banghao Chi", + "avatar_url": "https://avatars.githubusercontent.com/u/125724218?v=4", + "profile": "https://biboyqg.github.io/", + "contributions": [ + "code" + ] + }, + { + "login": "varz1", + "name": "varz1", + "avatar_url": "https://avatars.githubusercontent.com/u/60377372?v=4", + "profile": "https://github.com/varz1", + "contributions": [ + "code" + ] + }, + { + "login": "Meloyg", + "name": "Melo Y Guan", + "avatar_url": "https://avatars.githubusercontent.com/u/62586556?v=4", + "profile": "https://google.meloguan.site", + "contributions": [ + "code" + ] + }, + { + "login": "lmxdawn", + "name": "lmxdawn", + "avatar_url": "https://avatars.githubusercontent.com/u/21293193?v=4", + "profile": "https://github.com/lmxdawn", + "contributions": [ + "code" + ] + }, + { + "login": "haikow", + "name": "haikow", + "avatar_url": "https://avatars.githubusercontent.com/u/22428382?v=4", + "profile": "https://github.com/haikow", + "contributions": [ + "code" + ] + }, + { + "login": "a67793581", + "name": "Carlo", + "avatar_url": "https://avatars.githubusercontent.com/u/18513362?v=4", + "profile": "https://carlo-blog.aiju.fun/", + "contributions": [ + "code" + ] + }, + { + "login": "hrz394943230", + "name": "hrz", + "avatar_url": "https://avatars.githubusercontent.com/u/28583005?v=4", + "profile": "https://github.com/hrz394943230", + "contributions": [ + "code" + ] + }, + { + "login": "ctrlz526", + "name": "Ctrlz", + "avatar_url": "https://avatars.githubusercontent.com/u/143257420?v=4", + "profile": "https://github.com/ctrlz526", + "contributions": [ + "code" + ] + }, + { + "login": "flippancy", + "name": "flippancy", + "avatar_url": "https://avatars.githubusercontent.com/u/6467703?v=4", + "profile": "https://github.com/flippancy", + "contributions": [ + "code" + ] + }, + { + "login": "Infinityay", + "name": "Yuhang Lu", + "avatar_url": "https://avatars.githubusercontent.com/u/103165980?v=4", + "profile": "https://github.com/Infinityay", + "contributions": [ + "code" + ] + }, + { + "login": "triepod-ai", + "name": "Bryan Thompson", + "avatar_url": "https://avatars.githubusercontent.com/u/199543909?v=4", + "profile": "https://triepod.ai", + "contributions": [ + "code" + ] + }, + { + "login": "tanxxjun321", + "name": "tan jun", + "avatar_url": "https://avatars.githubusercontent.com/u/7806992?v=4", + "profile": "http://www.megvii.com", + "contributions": [ + "code" + ] + } + ], + "contributorsPerLine": 7, + "skipCi": true, + "repoType": "github", + "repoHost": "https://github.com", + "projectName": "xiaohongshu-mcp", + "projectOwner": "xpzouying" +} diff --git a/xiaohongshu-mcp-main/.cursor/mcp.json b/xiaohongshu-mcp-main/.cursor/mcp.json new file mode 100644 index 0000000..fa2aa23 --- /dev/null +++ b/xiaohongshu-mcp-main/.cursor/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "xiaohongshu-mcp": { + "url": "http://localhost:18060/mcp", + "description": "小红书内容发布服务 - MCP Streamable HTTP" + } + } +} diff --git a/xiaohongshu-mcp-main/.dockerignore b/xiaohongshu-mcp-main/.dockerignore new file mode 100644 index 0000000..c0c7eea --- /dev/null +++ b/xiaohongshu-mcp-main/.dockerignore @@ -0,0 +1,16 @@ +.git +.idea +.vscode +.claude +.cursor +.github +**/*.log +bin +dist +vendor +Dockerfile +docker-compose.yml +docker +.DS_Store + +cookies.json diff --git a/xiaohongshu-mcp-main/.github/CODEOWNERS b/xiaohongshu-mcp-main/.github/CODEOWNERS new file mode 100644 index 0000000..5a2a790 --- /dev/null +++ b/xiaohongshu-mcp-main/.github/CODEOWNERS @@ -0,0 +1 @@ +* @xpzouying diff --git a/xiaohongshu-mcp-main/.github/FUNDING.yml b/xiaohongshu-mcp-main/.github/FUNDING.yml new file mode 100644 index 0000000..1cb42be --- /dev/null +++ b/xiaohongshu-mcp-main/.github/FUNDING.yml @@ -0,0 +1,2 @@ +custom: + - "https://github.com/xpzouying/xiaohongshu-mcp#赞赏支持" diff --git a/xiaohongshu-mcp-main/.github/workflows/aliyun-docker-release.yml b/xiaohongshu-mcp-main/.github/workflows/aliyun-docker-release.yml new file mode 100644 index 0000000..735cd57 --- /dev/null +++ b/xiaohongshu-mcp-main/.github/workflows/aliyun-docker-release.yml @@ -0,0 +1,55 @@ +name: Aliyun Docker Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version tag (e.g., v1.0.0)' + required: true + default: 'v1.0.0' + +permissions: + contents: read + +jobs: + docker: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Aliyun Container Registry + uses: docker/login-action@v3 + with: + registry: crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com + username: ${{ secrets.ALIYUN_REGISTRY_USERNAME }} + password: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }} + + - name: Build and push Docker image (AMD64) + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + platforms: linux/amd64 + tags: | + crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp:${{ github.event.inputs.version }} + crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build and push Docker image (ARM64) + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.arm64 + push: true + platforms: linux/arm64 + tags: | + crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp:${{ github.event.inputs.version }}-arm64 + crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp:latest-arm64 + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/xiaohongshu-mcp-main/.github/workflows/docker-release.yml b/xiaohongshu-mcp-main/.github/workflows/docker-release.yml new file mode 100644 index 0000000..99e4551 --- /dev/null +++ b/xiaohongshu-mcp-main/.github/workflows/docker-release.yml @@ -0,0 +1,54 @@ +name: Docker Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version tag (e.g., v1.0.0)' + required: true + default: 'v1.0.0' + +permissions: + contents: read + +jobs: + docker: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: xpzouying + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image (AMD64) + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + platforms: linux/amd64 + tags: | + xpzouying/xiaohongshu-mcp:${{ github.event.inputs.version }} + xpzouying/xiaohongshu-mcp:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build and push Docker image (ARM64) + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.arm64 + push: true + platforms: linux/arm64 + tags: | + xpzouying/xiaohongshu-mcp:${{ github.event.inputs.version }}-arm64 + xpzouying/xiaohongshu-mcp:latest-arm64 + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/xiaohongshu-mcp-main/.github/workflows/release.yml b/xiaohongshu-mcp-main/.github/workflows/release.yml new file mode 100644 index 0000000..c1abca5 --- /dev/null +++ b/xiaohongshu-mcp-main/.github/workflows/release.yml @@ -0,0 +1,158 @@ +name: Build and Release + +on: + push: + branches: [ main ] + paths-ignore: + - 'README.md' + - 'README_EN.md' + - 'CLAUDE.md' + - '.all-contributorsrc' + - '.gitignore' + - '.dockerignore' + - 'Dockerfile' + - '.claude/**' + - '.cursor/**' + - '.github/**' + - '.vscode/**' + - 'assets/**' + - 'configs/**' + - 'cookies/**' + - 'docker/**' + - 'deploy/**' + - 'docs/**' + - 'donate/**' + - 'examples/**' + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + # 只在推送到 main 时运行,或手动触发 + if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch') + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.24' + + - name: Generate release name + id: version + run: | + TIMESTAMP=$(date +%Y.%m.%d.%H%M) + COMMIT_SHA=$(git rev-parse --short HEAD) + VERSION="v${TIMESTAMP}-${COMMIT_SHA}" + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "Generated version: ${VERSION}" + + - name: Build for multiple platforms + run: | + # 主程序构建 + # 禁用 CGO 以生成静态链接的二进制文件,避免 GLIBC 依赖问题 + # macOS ARM64 (Apple Silicon) + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o xiaohongshu-mcp-darwin-arm64 . + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o xiaohongshu-login-darwin-arm64 ./cmd/login + + # macOS Intel + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o xiaohongshu-mcp-darwin-amd64 . + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o xiaohongshu-login-darwin-amd64 ./cmd/login + + # Windows x64 + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o xiaohongshu-mcp-windows-amd64.exe . + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o xiaohongshu-login-windows-amd64.exe ./cmd/login + + # Linux x64 + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o xiaohongshu-mcp-linux-amd64 . + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o xiaohongshu-login-linux-amd64 ./cmd/login + + - name: Package binaries + run: | + # 创建压缩包 + # macOS ARM64 + tar czf xiaohongshu-mcp-darwin-arm64.tar.gz xiaohongshu-mcp-darwin-arm64 xiaohongshu-login-darwin-arm64 + + # macOS Intel + tar czf xiaohongshu-mcp-darwin-amd64.tar.gz xiaohongshu-mcp-darwin-amd64 xiaohongshu-login-darwin-amd64 + + # Windows x64 + zip xiaohongshu-mcp-windows-amd64.zip xiaohongshu-mcp-windows-amd64.exe xiaohongshu-login-windows-amd64.exe + + # Linux x64 + tar czf xiaohongshu-mcp-linux-amd64.tar.gz xiaohongshu-mcp-linux-amd64 xiaohongshu-login-linux-amd64 + + - name: Clean up old releases + run: | + # 获取所有自动构建的 releases (v开头的时间戳格式) + RELEASES=$(gh release list --limit 100 | grep -E '^v[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]{4}-' | awk '{print $3}' | tail -n +11) + + # 删除超过 10 个的旧 releases 和对应的 tags + for release in $RELEASES; do + echo "Deleting old release: $release" + gh release delete "$release" --yes --cleanup-tag + done + env: + GH_TOKEN: ${{ github.token }} + continue-on-error: true + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ steps.version.outputs.version }} + name: Release ${{ steps.version.outputs.version }} + draft: false + prerelease: false + body: | + ## 🔧 自动构建版本 + + **注意:这是自动构建的预发布版本,用于测试。正式版本请等待手动发布。** + + ### 📦 下载说明 + + 选择适合您系统的压缩包下载: + - **macOS Apple Silicon (M1/M2/M3)**: `xiaohongshu-mcp-darwin-arm64.tar.gz` + - **macOS Intel**: `xiaohongshu-mcp-darwin-amd64.tar.gz` + - **Windows x64**: `xiaohongshu-mcp-windows-amd64.zip` + - **Linux x64**: `xiaohongshu-mcp-linux-amd64.tar.gz` + + 每个压缩包包含: + - `xiaohongshu-mcp-*`: MCP 服务主程序 + - `xiaohongshu-login-*`: 登录工具 + + ### 🔧 使用方法 + + ```bash + # 1. 解压文件(macOS/Linux) + tar xzf xiaohongshu-mcp-darwin-arm64.tar.gz + + # 或 Windows + # 解压 xiaohongshu-mcp-windows-amd64.zip + + # 2. 运行登录工具 + ./xiaohongshu-login-darwin-arm64 + + # 3. 启动 MCP 服务 + ./xiaohongshu-mcp-darwin-arm64 + ``` + + ### 🐳 Docker 镜像 + + Docker 镜像需要手动触发构建,请到 Actions 页面运行 "Docker Release" workflow。 + + ### 📊 构建信息 + + - **Commit**: ${{ github.sha }} + - **Branch**: main + - **Build Time**: ${{ steps.version.outputs.version }} + files: | + xiaohongshu-mcp-darwin-arm64.tar.gz + xiaohongshu-mcp-darwin-amd64.tar.gz + xiaohongshu-mcp-windows-amd64.zip + xiaohongshu-mcp-linux-amd64.tar.gz diff --git a/xiaohongshu-mcp-main/.github/workflows/tag-release.yml b/xiaohongshu-mcp-main/.github/workflows/tag-release.yml new file mode 100644 index 0000000..b961e48 --- /dev/null +++ b/xiaohongshu-mcp-main/.github/workflows/tag-release.yml @@ -0,0 +1,153 @@ +name: Tag and Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version tag (e.g., v1.0.0)' + required: true + type: string + release_notes: + description: 'Release notes (optional)' + required: false + type: string + default: '' + +permissions: + contents: write + +jobs: + tag-and-release: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate version format + run: | + VERSION="${{ github.event.inputs.version }}" + if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.]+)?$ ]]; then + echo "Error: Version must follow semantic versioning (e.g., v1.0.0, v1.0.0-beta.1)" + exit 1 + fi + + # Check if tag already exists + if git rev-parse "$VERSION" >/dev/null 2>&1; then + echo "Error: Tag $VERSION already exists" + exit 1 + fi + + echo "Version $VERSION is valid" + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.24' + + - name: Build for multiple platforms + run: | + # 主程序构建 + # macOS ARM64 (Apple Silicon) + GOOS=darwin GOARCH=arm64 go build -o xiaohongshu-mcp-darwin-arm64 . + GOOS=darwin GOARCH=arm64 go build -o xiaohongshu-login-darwin-arm64 ./cmd/login + + # macOS Intel + GOOS=darwin GOARCH=amd64 go build -o xiaohongshu-mcp-darwin-amd64 . + GOOS=darwin GOARCH=amd64 go build -o xiaohongshu-login-darwin-amd64 ./cmd/login + + # Windows x64 + GOOS=windows GOARCH=amd64 go build -o xiaohongshu-mcp-windows-amd64.exe . + GOOS=windows GOARCH=amd64 go build -o xiaohongshu-login-windows-amd64.exe ./cmd/login + + # Linux x64 + GOOS=linux GOARCH=amd64 go build -o xiaohongshu-mcp-linux-amd64 . + GOOS=linux GOARCH=amd64 go build -o xiaohongshu-login-linux-amd64 ./cmd/login + + - name: Generate changelog + id: changelog + run: | + # Get the previous tag + PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + + if [ -z "$PREV_TAG" ]; then + echo "No previous tag found, including all commits" + COMMITS=$(git log --oneline --format="- %s (%h)" | head -20) + else + echo "Generating changelog from $PREV_TAG to HEAD" + COMMITS=$(git log $PREV_TAG..HEAD --oneline --format="- %s (%h)") + fi + + # Save to output + { + echo "commits<> $GITHUB_OUTPUT + + - name: Create tag and release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ github.event.inputs.version }} + name: Release ${{ github.event.inputs.version }} + draft: false + prerelease: ${{ contains(github.event.inputs.version, '-') }} + body: | + ## 🚀 新版本发布: ${{ github.event.inputs.version }} + + ${{ github.event.inputs.release_notes }} + + ### 📋 更新内容 + + ${{ steps.changelog.outputs.commits }} + + --- + + ### 📦 下载说明 + + **主程序(MCP 服务):** + - **macOS Apple Silicon**: `xiaohongshu-mcp-darwin-arm64` + - **macOS Intel**: `xiaohongshu-mcp-darwin-amd64` + - **Windows x64**: `xiaohongshu-mcp-windows-amd64.exe` + - **Linux x64**: `xiaohongshu-mcp-linux-amd64` + + **登录工具:** + - **macOS Apple Silicon**: `xiaohongshu-login-darwin-arm64` + - **macOS Intel**: `xiaohongshu-login-darwin-amd64` + - **Windows x64**: `xiaohongshu-login-windows-amd64.exe` + - **Linux x64**: `xiaohongshu-login-linux-amd64` + + ### 🔧 使用方法 + + ```bash + # 1. 首先运行登录工具获取 cookie + ./xiaohongshu-login-darwin-arm64 + + # 2. 然后启动 MCP 服务 + ./xiaohongshu-mcp-darwin-arm64 + + # 或指定参数 + ./xiaohongshu-mcp-darwin-arm64 -headless=false + ``` + + ### ⚠️ 注意事项 + + - 首次运行时会自动下载无头浏览器(约 150MB),请确保网络连接正常 + - 后续运行无需重复下载浏览器 + - 登录工具生成的 cookie 保存在 `~/.xiaohongshu/cookies.json` + + ### 📊 构建信息 + + - **Commit**: ${{ github.sha }} + - **Go Version**: 1.24 + - **Build Time**: ${{ github.event.repository.updated_at }} + files: | + xiaohongshu-mcp-darwin-arm64 + xiaohongshu-mcp-darwin-amd64 + xiaohongshu-mcp-windows-amd64.exe + xiaohongshu-mcp-linux-amd64 + xiaohongshu-login-darwin-arm64 + xiaohongshu-login-darwin-amd64 + xiaohongshu-login-windows-amd64.exe + xiaohongshu-login-linux-amd64 \ No newline at end of file diff --git a/xiaohongshu-mcp-main/.gitignore b/xiaohongshu-mcp-main/.gitignore new file mode 100644 index 0000000..3c995c5 --- /dev/null +++ b/xiaohongshu-mcp-main/.gitignore @@ -0,0 +1,44 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +.idea + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +# Editor/IDE +# .idea/ +# .vscode/ +.claude/ + +# Build artifacts +xiaohongshu-mcp + +# Test scripts +test_*.sh + +# Cookies files (contain sensitive login information) +cookies.json diff --git a/xiaohongshu-mcp-main/.vscode/mcp.json b/xiaohongshu-mcp-main/.vscode/mcp.json new file mode 100644 index 0000000..156372e --- /dev/null +++ b/xiaohongshu-mcp-main/.vscode/mcp.json @@ -0,0 +1,9 @@ +{ + "servers": { + "xiaohongshu-mcp": { + "url": "http://localhost:18060/mcp", + "type": "http" + } + }, + "inputs": [] +} diff --git a/xiaohongshu-mcp-main/CLAUDE.md b/xiaohongshu-mcp-main/CLAUDE.md new file mode 100644 index 0000000..e67c825 --- /dev/null +++ b/xiaohongshu-mcp-main/CLAUDE.md @@ -0,0 +1,7 @@ +- 要求每次修改完后,需要帮我格式化 Go 源码文件. +- 测试过程中产生的脚本和build中间文件,如果没有必要,则删除. +- 所有的feature变更,都需要使用分支进行开发. +- 在我未同意之前, 你不能推送到远程. +- 我需要: 1.本地 review; 2.远程 PR review. +- 不要过度设计, 保持代码的简洁和易读. +- 使用中文注释,一定要简洁明了.专业名词可以用英文. \ No newline at end of file diff --git a/xiaohongshu-mcp-main/DONATIONS.md b/xiaohongshu-mcp-main/DONATIONS.md new file mode 100644 index 0000000..761b975 --- /dev/null +++ b/xiaohongshu-mcp-main/DONATIONS.md @@ -0,0 +1,141 @@ +# 赞赏与公益捐赠公开账本 + +本项目的所有赞赏,将全部用于公益捐赠。 + +> 本页按月公开记录:收到的赞赏(默认匿名或使用对方指定昵称)、对应捐出、以及捐赠凭证截图(已脱敏)。 +> 如需更正/撤回署名,请开 Issue 或通过邮箱联系。 + +## 摘要 + +- 累计收到赞赏:¥ 969.95 +- 累计捐赠:¥ 1300 +- 最近更新时间:2026-02-05 + +--- + +## 维护说明 + +- **隐私**:默认匿名展示;仅在赞助者明确授权时展示昵称。请在截图中打码/涂抹交易号、手机号、邮箱、二维码关键元素等敏感信息。 +- **更正机制**:如有遗漏或需要修改,请开 Issue;所有更动保留在 Git 历史中。 + +--- + +## 月度明细 + +### 2026-01 + +**本月小结** + +- 收到赞赏合计:¥ 99.98 +- 捐出合计:¥ 200.00 + +**收到的赞赏** +| 日期 | 昵称 | 金额 | 备注 | +|------------|-----:|-----:|------| +| 2026-01-02 | K91431 | 49.99 | 赞赏码 | +| 2026-01-05 | Yancy | 49.99 | 赞赏码 | + +
+ + + +
donation-2026-01
+ + +### 2025-12 + +**本月小结** + +- 收到赞赏合计:¥ 440.09 +- 捐出合计:¥ 500.00 + +**收到的赞赏** +| 日期 | 昵称 | 金额 | 备注 | +|------------|-----:|-----:|------| +| 2025-12-03 | 源 | 39.90 | 微信红包 | +| 2025-12-07 | 来自于微信群的小爷 | 29.99 | 赞赏码 | +| 2025-12-08 | 来自于微信群的小爷 | 29.99 | 赞赏码 | +| 2025-12-08 | 无名大侠 | 9.99 | 赞赏码 | +| 2025-12-10 | 许掌柜 | 100.00 | 微信红包 | +| 2025-12-11 | matheasyer | 10.00 | 支付宝 | +| 2025-12-18 | 来自于微信群的小爷 | 29.99 | 微信红包 | +| 2025-12-19 | 查狸尼克 | 20.26 | 赞赏码 | +| 2025-12-19 | 无名大侠 | 49.99 | 赞赏码 | +| 2025-12-21 | 陈懂 | 50.00 | 赞赏码,for 泡芙小姐 | +| 2025-12-22 | G仔 | 49.99 | 赞赏码 | +| 2025-12-25 | 未来可期 | 19.99 | 赞赏码 | + + + + + + +
PixPin_2026-01-01_20-45-48PixPin_2026-01-01_20-46-03
+ + +### 2025-11 + +**本月小结** + +- 收到赞赏合计:¥ 249.96 +- 捐出合计:¥ 400.00。守望相助,驰援香江:为香港大浦火灾同胞筹集善款! + + + + + +
PixPin_2025-10-26_21-34-08
+ + +**收到的赞赏** +| 日期 | 昵称 | 金额 | 备注 | +|------------|-----:|-----:|------| +| 2025-11-05 | 勇敢的心 | 9.99 | 赞赏码 | +| 2025-11-10 | Sijin Yang | 99.99 | 赞赏码 | +| 2025-11-17 | cym | 29.99 | 赞赏码 | +| 2025-11-26 | 一虎君 | 10.00 | 赞赏码 | +| 2025-11-26 | Sijin Yang | 99.99 | 赞赏码 | + +### 2025-10 + +**本月小结** + +- 收到赞赏合计:¥ 109.93 +- 捐出合计:¥ 200.00。 9 月、10 月份一起汇总捐赠给「春蕾计划她们想上学」。 + + + + + +
PixPin_2025-10-26_21-34-08
+ + +**收到的赞赏** +| 日期 | 昵称 | 金额 | 备注 | +|------------|-----:|-----:|------| +| 2025-10-11 | Sijin Yang | 29.99 | 赞赏码 | +| 2025-10-13 | Sijin Yang | 29.99 | 赞赏码 | +| 2025-10-16 | RESOLUTION | 9.99 | 赞赏码 | +| 2025-10-17 | Sijin Yang | 9.99 | 赞赏码 | +| 2025-10-19 | 无名大侠 | 9.99 | 赞赏码 | +| 2025-10-22 | Sijin Yang | 9.99 | 赞赏码 | +| 2025-10-22 | 无名大侠 | 9.99 | 赞赏码 | + +### 2025-09 + +**本月小结** + +- 收到赞赏合计:¥ 69.99 +- 捐出合计:9 月、10 月份一起汇总捐赠给「春蕾计划她们想上学」。 + +**收到的赞赏** +| 日期 | 昵称 | 金额 | 备注 | +|------------|-----:|-----:|------| +| 2025-09-23 | 米爸 | 50.00 | 微信红包 | +| 2025-09-27 | 麦子 | 19.99 | 赞赏码 | + +--- + +## 变更记录 + +- 2025-10-26:初始化赞赏记录。汇总 2025 年 9 月、10 月份的赞赏,捐赠给「春蕾计划她们想上学」。 diff --git a/xiaohongshu-mcp-main/Dockerfile b/xiaohongshu-mcp-main/Dockerfile new file mode 100644 index 0000000..62a9c68 --- /dev/null +++ b/xiaohongshu-mcp-main/Dockerfile @@ -0,0 +1,86 @@ +# ---- build stage ---- +FROM golang:1.24 AS builder + +WORKDIR /src +# 配置 Go 模块代理为国内源 +ENV GOPROXY=https://goproxy.cn,direct +ENV GOSUMDB=sum.golang.google.cn + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /out/app . + +# ---- run stage ---- +FROM ubuntu:22.04 + +# 设置时区 +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +WORKDIR /app + +# 1. 先安装必要工具,然后配置阿里云镜像源 +RUN apt-get update && apt-get install -y ca-certificates wget gnupg && \ + sed -i 's|http://archive.ubuntu.com|https://mirrors.aliyun.com|g' /etc/apt/sources.list && \ + sed -i 's|http://security.ubuntu.com|https://mirrors.aliyun.com|g' /etc/apt/sources.list + +# 2. 添加 Google Chrome APT 源并安装 Chrome(更稳定的无头浏览器) +RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/googlechrome-linux-keyring.gpg && \ + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/googlechrome-linux-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list + +# 3. 安装 Google Chrome + 依赖(无头模式运行 rod) +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + fonts-liberation \ + libasound2 \ + libatk-bridge2.0-0 \ + libatk1.0-0 \ + libc6 \ + libcairo2 \ + libcups2 \ + libdbus-1-3 \ + libexpat1 \ + libfontconfig1 \ + libgbm1 \ + libgcc1 \ + libglib2.0-0 \ + libgtk-3-0 \ + libnspr4 \ + libnss3 \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libstdc++6 \ + libx11-6 \ + libx11-xcb1 \ + libxcb1 \ + libxcomposite1 \ + libxcursor1 \ + libxdamage1 \ + libxext6 \ + libxfixes3 \ + libxi6 \ + libxrandr2 \ + libxrender1 \ + libxss1 \ + libxtst6 \ + lsb-release \ + wget \ + xdg-utils \ + google-chrome-stable \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /out/app . + +# 4. 创建共享目录并设置权限 +RUN mkdir -p /app/images && \ + chmod 777 /app/images + +# 5. 设置默认 Chrome 路径(rod 会用) +ENV ROD_BROWSER_BIN=/usr/bin/google-chrome + +EXPOSE 18060 + +CMD ["./app"] + diff --git a/xiaohongshu-mcp-main/Dockerfile.arm64 b/xiaohongshu-mcp-main/Dockerfile.arm64 new file mode 100644 index 0000000..1f8f395 --- /dev/null +++ b/xiaohongshu-mcp-main/Dockerfile.arm64 @@ -0,0 +1,88 @@ +# Dockerfile for ARM64 architecture +# This Dockerfile uses Chromium (auto-downloaded by go-rod) instead of Google Chrome +# because Google Chrome does not provide official Linux ARM64 builds. + +# ---- build stage ---- +FROM golang:1.24 AS builder + +WORKDIR /src +# 配置 Go 模块代理为国内源 +ENV GOPROXY=https://goproxy.cn,direct +ENV GOSUMDB=sum.golang.google.cn + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +# 移除 GOARCH 硬编码,让构建系统根据目标平台自动选择架构 +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/app . + +# ---- run stage ---- +FROM ubuntu:22.04 + +# 设置时区 +ENV TZ=Asia/Shanghai +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +WORKDIR /app + +# 1. 先安装必要工具,然后配置阿里云镜像源 +RUN apt-get update && apt-get install -y ca-certificates wget gnupg && \ + sed -i 's|http://archive.ubuntu.com|https://mirrors.aliyun.com|g' /etc/apt/sources.list && \ + sed -i 's|http://security.ubuntu.com|https://mirrors.aliyun.com|g' /etc/apt/sources.list + +# 2. 安装 Chromium 运行所需的依赖库 +# 注意:不安装 Google Chrome,因为它不支持 ARM64 +# go-rod 会在首次运行时自动从 Playwright CDN 下载 ARM64 版本的 Chromium +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + fonts-liberation \ + libasound2 \ + libatk-bridge2.0-0 \ + libatk1.0-0 \ + libc6 \ + libcairo2 \ + libcups2 \ + libdbus-1-3 \ + libexpat1 \ + libfontconfig1 \ + libgbm1 \ + libgcc1 \ + libglib2.0-0 \ + libgtk-3-0 \ + libnspr4 \ + libnss3 \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libstdc++6 \ + libx11-6 \ + libx11-xcb1 \ + libxcb1 \ + libxcomposite1 \ + libxcursor1 \ + libxdamage1 \ + libxext6 \ + libxfixes3 \ + libxi6 \ + libxrandr2 \ + libxrender1 \ + libxss1 \ + libxtst6 \ + lsb-release \ + wget \ + xdg-utils \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /out/app . + +# 3. 创建共享目录并设置权限 +RUN mkdir -p /app/images && \ + chmod 777 /app/images + +# 4. 不设置 ROD_BROWSER_BIN 环境变量 +# go-rod 会自动检测并下载适合 ARM64 架构的 Chromium 浏览器 +# Chromium 下载源:https://playwright.azureedge.net/builds/chromium/ + +EXPOSE 18060 + +CMD ["./app"] diff --git a/xiaohongshu-mcp-main/README.md b/xiaohongshu-mcp-main/README.md new file mode 100644 index 0000000..9c5767f --- /dev/null +++ b/xiaohongshu-mcp-main/README.md @@ -0,0 +1,863 @@ +# xiaohongshu-mcp + + +[![All Contributors](https://img.shields.io/badge/all_contributors-21-orange.svg?style=flat-square)](#contributors-) + + +[![善款已捐](https://img.shields.io/badge/善款已捐-CNY%201300.00-brightgreen?style=flat-square)](./DONATIONS.md) +[![爱心汇聚](https://img.shields.io/badge/爱心汇聚-CNY%20969.95-blue?style=flat-square)](./DONATIONS.md) +[![Docker Pulls](https://img.shields.io/docker/pulls/xpzouying/xiaohongshu-mcp?style=flat-square&logo=docker)](https://hub.docker.com/r/xpzouying/xiaohongshu-mcp) + +MCP for 小红书/xiaohongshu.com。 + +- 我的博客文章:[haha.ai/xiaohongshu-mcp](https://www.haha.ai/xiaohongshu-mcp) + +**遇到任何问题,务必要先看 [各种疑难杂症](https://github.com/xpzouying/xiaohongshu-mcp/issues/56)**。 + +上面的 **疑难杂症** 列表后,还是解决不了你的部署问题,那么强烈推荐使用我写的另外一个工具:[xpzouying/x-mcp](https://github.com/xpzouying/x-mcp),这个工具不需要你进行部署,只需要通过浏览器插件就能驱动你的 MCP,对于非技术同学来说更加友好。 + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=xpzouying/xiaohongshu-mcp&type=Timeline)](https://www.star-history.com/#xpzouying/xiaohongshu-mcp&Timeline) + +## 赞赏支持 + +本项目所有的赞赏都会用于慈善捐赠。所有的慈善捐赠记录,请参考 [DONATIONS.md](./DONATIONS.md)。 + +**捐赠时,请备注 MCP 以及名字。** +如需更正/撤回署名,请开 Issue 或通过邮箱联系。 + +**支付宝(不展示二维码):** + +通过支付宝向 **xpzouying@gmail.com** 赞赏。 + +**微信:** + +WeChat Pay QR + +## 项目简介 + +**主要功能** + +> 💡 **提示:** 点击下方功能标题可展开查看视频演示 + +
+1. 登录和检查登录状态 + +第一步必须,小红书需要进行登录。可以检查当前登录状态。 + +**登录演示:** + +https://github.com/user-attachments/assets/8b05eb42-d437-41b7-9235-e2143f19e8b7 + +**检查登录状态演示:** + +https://github.com/user-attachments/assets/bd9a9a4a-58cb-4421-b8f3-015f703ce1f9 + +
+ +
+2. 发布图文内容 + +支持发布图文内容到小红书,包括标题、内容描述和图片。 + +**图片支持方式:** + +支持两种图片输入方式: + +1. **HTTP/HTTPS 图片链接** + + ``` + ["https://example.com/image1.jpg", "https://example.com/image2.png"] + ``` + +2. **本地图片绝对路径**(推荐) + ``` + ["/Users/username/Pictures/image1.jpg", "/home/user/images/image2.png"] + ``` + +**为什么推荐使用本地路径:** + +- ✅ 稳定性更好,不依赖网络 +- ✅ 上传速度更快 +- ✅ 避免图片链接失效问题 +- ✅ 支持更多图片格式 + +**发布图文帖子演示:** + +https://github.com/user-attachments/assets/8aee0814-eb96-40af-b871-e66e6bbb6b06 + +
+ +
+3. 发布视频内容 + +支持发布视频内容到小红书,包括标题、内容描述和本地视频文件。 + +**视频支持方式:** + +仅支持本地视频文件绝对路径: + +``` +"/Users/username/Videos/video.mp4" +``` + +**功能特点:** + +- ✅ 支持本地视频文件上传 +- ✅ 自动处理视频格式转换 +- ✅ 支持标题、内容描述和标签 +- ✅ 等待视频处理完成后自动发布 + +**注意事项:** + +- 仅支持本地视频文件,不支持 HTTP 链接 +- 视频处理时间较长,请耐心等待 +- 建议视频文件大小不超过 1GB + +
+ +
+4. 搜索内容 + +根据关键词搜索小红书内容。 + +**搜索帖子演示:** + +https://github.com/user-attachments/assets/03c5077d-6160-4b18-b629-2e40933a1fd3 + +
+ +
+5. 获取推荐列表 + +获取小红书首页推荐内容列表。 + +**获取推荐列表演示:** + +https://github.com/user-attachments/assets/110fc15d-46f2-4cca-bdad-9de5b5b8cc28 + +
+ +
+6. 获取帖子详情(包括互动数据和评论) + +获取小红书帖子的完整详情,包括: + +- 帖子内容(标题、描述、图片等) +- 用户信息 +- 互动数据(点赞、收藏、分享、评论数) +- 评论列表及子评论 + +**⚠️ 重要提示:** + +- 需要提供帖子 ID 和 xsec_token(两个参数缺一不可) +- 这两个参数可以从 Feed 列表或搜索结果中获取 +- 必须先登录才能使用此功能 + +**获取帖子详情演示:** + +https://github.com/user-attachments/assets/76a26130-a216-4371-a6b3-937b8fda092a + +
+ +
+7. 发表评论到帖子 + +支持自动发表评论到小红书帖子。 + +**功能说明:** + +- 自动定位评论输入框 +- 输入评论内容并发布 +- 支持 HTTP API 和 MCP 工具调用 + +**⚠️ 重要提示:** + +- 需要先登录才能使用此功能 +- 需要提供帖子 ID、xsec_token 和评论内容 +- 这些参数可以从 Feed 列表或搜索结果中获取 + +**发表评论演示:** + +https://github.com/user-attachments/assets/cc385b6c-422c-489b-a5fc-63e92c695b80 + +
+ +
+8. 获取用户个人主页 + +获取小红书用户的个人主页信息,包括用户基本信息和笔记内容。 + +**功能说明:** + +- 获取用户基本信息(昵称、简介、头像等) +- 获取关注数、粉丝数、获赞量统计 +- 获取用户发布的笔记内容列表 +- 支持 HTTP API 和 MCP 工具调用 + +**⚠️ 重要提示:** + +- 需要先登录才能使用此功能 +- 需要提供用户 ID 和 xsec_token +- 这些参数可以从 Feed 列表或搜索结果中获取 + +**返回信息包括:** + +- 用户基本信息:昵称、简介、头像、认证状态 +- 统计数据:关注数、粉丝数、获赞量、笔记数 +- 笔记列表:用户发布的所有公开笔记 + +
+ +**小红书基础运营知识** + +- **标题:(非常重要)小红书要求标题不超过 20 个字** +- **正文:(非常重要):正文不能超过 1000 个字** +- 当前支持图文发送以及视频发送:从推荐的角度看,图文的流量会比视频以及纯文字的更好。 +- (低优先级)可以考虑纯文字的支持。1. 个人感觉纯文字会大大增加运营的复杂度;2. 纯文字在我的使用场景的价值较低。 +- Tags:现已支持。添加合适的 Tags 能带来更多的流量。 +- 根据本人实操,小红书每天的发帖量应该是 **50 篇**。 +- **(非常重要)小红书的同一个账号不允许在多个网页端登录**,如果你登录了当前 xiaohongshu-mcp 后,就不要再在其他的网页端登录该账号,否则就会把当前 MCP 的账号“踢出登录”。你可以使用移动 App 端进行查看当前账号信息。 + +**风险说明** + +1. 该项目是在自己的另外一个项目的基础上开源出来的,原来的项目稳定运行一年多,没有出现过封号的情况,只有出现过 Cookies 过期需要重新登录。 +2. 我是使用 Claude Code 接入,稳定自动化运营数周后,验证没有问题后开源。 + +该项目是基于学习的目的,禁止一切违法行为。 + +**实操结果** + +第一天点赞/收藏数达到了 999+, + +CleanShot 2025-09-05 at 01 31 55@2x + +CleanShot 2025-09-05 at 01 32 49@2x + +一周左右的成果 + +CleanShot 2025-09-05 at 01 33 13@2x + +## 1. 使用教程 + +### 1.1. 快速开始(推荐) + +**方式一:下载预编译二进制文件** + +直接从 [GitHub Releases](https://github.com/xpzouying/xiaohongshu-mcp/releases) 下载对应平台的二进制文件: + +**主程序(MCP 服务):** + +- **macOS Apple Silicon**: `xiaohongshu-mcp-darwin-arm64` +- **macOS Intel**: `xiaohongshu-mcp-darwin-amd64` +- **Windows x64**: `xiaohongshu-mcp-windows-amd64.exe` +- **Linux x64**: `xiaohongshu-mcp-linux-amd64` + +**登录工具:** + +- **macOS Apple Silicon**: `xiaohongshu-login-darwin-arm64` +- **macOS Intel**: `xiaohongshu-login-darwin-amd64` +- **Windows x64**: `xiaohongshu-login-windows-amd64.exe` +- **Linux x64**: `xiaohongshu-login-linux-amd64` + +使用步骤: + +```bash +# 1. 首先运行登录工具 +chmod +x xiaohongshu-login-darwin-arm64 +./xiaohongshu-login-darwin-arm64 + +# 2. 然后启动 MCP 服务 +chmod +x xiaohongshu-mcp-darwin-arm64 +./xiaohongshu-mcp-darwin-arm64 +``` + +**⚠️ 重要提示**:首次运行时会自动下载无头浏览器(约 150MB),请确保网络连接正常。后续运行无需重复下载。 + +**方式二:源码编译** + +
+源码编译安装详情 + +依赖 Golang 环境,安装方法请参考 [Golang 官方文档](https://go.dev/doc/install)。 + +设置 Go 国内源的代理, + +```bash +# 配置 GOPROXY 环境变量,以下三选一 + +# 1. 七牛 CDN +go env -w GOPROXY=https://goproxy.cn,direct + +# 2. 阿里云 +go env -w GOPROXY=https://mirrors.aliyun.com/goproxy/,direct + +# 3. 官方 +go env -w GOPROXY=https://goproxy.io,direct +``` + +
+ +**方式三:使用 Docker 容器(最简单)** + +
+Docker 部署详情 + +使用 Docker 部署是最简单的方式,无需安装任何开发环境。 + +**1. 从 Docker Hub 拉取镜像(推荐)** + +我们提供了预构建的 Docker 镜像,可以直接从 Docker Hub 拉取使用: + +```bash +# 拉取最新镜像 +docker pull xpzouying/xiaohongshu-mcp +``` + +Docker Hub 地址:[https://hub.docker.com/r/xpzouying/xiaohongshu-mcp](https://hub.docker.com/r/xpzouying/xiaohongshu-mcp) + +**2. 使用 Docker Compose 启动(推荐)** + +我们提供了配置好的 `docker-compose.yml` 文件,可以直接使用: + +```bash +# 下载 docker-compose.yml +wget https://raw.githubusercontent.com/xpzouying/xiaohongshu-mcp/main/docker/docker-compose.yml + +# 或者如果已经克隆了项目,进入 docker 目录 +cd docker + +# 启动服务 +docker compose up -d + +# 查看日志 +docker compose logs -f + +# 停止服务 +docker compose stop +``` + +**3. 自己构建镜像(可选)** + +```bash +# 在项目根目录运行 +docker build -t xpzouying/xiaohongshu-mcp . +``` + +**4. 配置说明** + +Docker 版本会自动: + +- 配置 Chrome 浏览器和中文字体 +- 挂载 `./data` 用于存储 cookies +- 挂载 `./images` 用于存储发布的图片 +- 暴露 18060 端口供 MCP 连接 + +详细使用说明请参考:[Docker 部署指南](./docker/README.md) + +
+ +Windows 遇到问题首先看这里:[Windows 安装指南](./docs/windows_guide.md) + +### 1.2. 登录 + +第一次需要手动登录,需要保存小红书的登录状态。 + +**使用二进制文件**: + +```bash +# 运行对应平台的登录工具 +./xiaohongshu-login-darwin-arm64 +``` + +**使用源码**: + +```bash +go run cmd/login/main.go +``` + +### 1.3. 启动 MCP 服务 + +启动 xiaohongshu-mcp 服务。 + +**使用二进制文件**: + +```bash +# 默认:无头模式,没有浏览器界面 +./xiaohongshu-mcp-darwin-arm64 + +# 非无头模式,有浏览器界面 +./xiaohongshu-mcp-darwin-arm64 -headless=false +``` + +**使用源码**: + +```bash +# 默认:无头模式,没有浏览器界面 +go run . + +# 非无头模式,有浏览器界面 +go run . -headless=false +``` + +## 1.4. 验证 MCP + +```bash +npx @modelcontextprotocol/inspector +``` + +![运行 Inspector](./assets/run_inspect.png) + +运行后,打开红色标记的链接,配置 MCP inspector,输入 `http://localhost:18060/mcp` ,点击 `Connect` 按钮。 + +bf9532dd0b7ba423491accf511a467de + +**注意:** 左侧边框中的选项是否正确。 + +按照上面配置 MCP inspector 后,点击 `List Tools` 按钮,查看所有的 Tools。 + +## 1.5. 使用 MCP 发布 + +### 检查登录状态 + +![检查登录状态](./assets/check_login.gif) + +### 发布图文 + +示例中是从 https://unsplash.com/ 中随机找了个图片做测试。 + +![发布图文](./assets/inspect_mcp_publish.gif) + +### 搜索内容 + +使用搜索功能,根据关键词搜索小红书内容: + +![搜索内容](./assets/search_result.png) + +## 2. MCP 客户端接入 + +本服务支持标准的 Model Context Protocol (MCP),可以接入各种支持 MCP 的 AI 客户端。 + +### 2.1. 快速开始 + +#### 启动 MCP 服务 + +```bash +# 启动服务(默认无头模式) +go run . + +# 或者有界面模式 +go run . -headless=false +``` + +服务将运行在:`http://localhost:18060/mcp` + +#### 验证服务状态 + +```bash +# 测试 MCP 连接 +curl -X POST http://localhost:18060/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"initialize","params":{},"id":1}' +``` + +#### Claude Code CLI 接入 + +```bash +# 添加 HTTP MCP 服务器 +claude mcp add --transport http xiaohongshu-mcp http://localhost:18060/mcp + +# 检查 MCP 是否添加成功(确保 MCP 已经启动的前提下,运行下面命令) +claude mcp list +``` + +### 2.2. 支持的客户端 + +
+Claude Code CLI + +官方命令行工具,已在上面快速开始部分展示: + +```bash +# 添加 HTTP MCP 服务器 +claude mcp add --transport http xiaohongshu-mcp http://localhost:18060/mcp + +# 检查 MCP 是否添加成功(确保 MCP 已经启动的前提下,运行下面命令) +claude mcp list +``` + +
+ +
+Cursor + +#### 配置文件的方式 + +创建或编辑 MCP 配置文件: + +**项目级配置**(推荐): +在项目根目录创建 `.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "xiaohongshu-mcp": { + "url": "http://localhost:18060/mcp", + "description": "小红书内容发布服务 - MCP Streamable HTTP" + } + } +} +``` + +**全局配置**: +在用户目录创建 `~/.cursor/mcp.json` (同样内容)。 + +#### 使用步骤 + +1. 确保小红书 MCP 服务正在运行 +2. 保存配置文件后,重启 Cursor +3. 在 Cursor 聊天中,工具应该自动可用 +4. 可以通过聊天界面的 "Available Tools" 查看已连接的 MCP 工具 + +**Demo** + +插件 MCP 接入: + +![cursor_mcp_settings](./assets/cursor_mcp_settings.png) + +调用 MCP 工具:(以检查登录状态为例) + +![cursor_mcp_check_login](./assets/cursor_mcp_check_login.png) + +
+ +
+VSCode + +#### 方法一:使用命令面板配置 + +1. 按 `Ctrl/Cmd + Shift + P` 打开命令面板 +2. 运行 `MCP: Add Server` 命令 +3. 选择 `HTTP` 方式。 +4. 输入地址: `http://localhost:18060/mcp`,或者修改成对应的 Server 地址。 +5. 输入 MCP 名字: `xiaohongshu-mcp`。 + +#### 方法二:直接编辑配置文件 + +**工作区配置**(推荐): +在项目根目录创建 `.vscode/mcp.json`: + +```json +{ + "servers": { + "xiaohongshu-mcp": { + "url": "http://localhost:18060/mcp", + "type": "http" + } + }, + "inputs": [] +} +``` + +**查看配置**: + +![vscode_config](./assets/vscode_mcp_config.png) + +1. 确认运行状态。 +2. 查看 `tools` 是否正确检测。 + +**Demo** + +以搜索帖子内容为例: + +![vscode_mcp_search](./assets/vscode_search_demo.png) + +
+ +
+Google Gemini CLI + +在 `~/.gemini/settings.json` 或项目目录 `.gemini/settings.json` 中配置: + +```json +{ + "mcpServers": { + "xiaohongshu": { + "httpUrl": "http://localhost:18060/mcp", + "timeout": 30000 + } + } +} +``` + +更多信息请参考 [Gemini CLI MCP 文档](https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html) + +
+ +
+MCP Inspector + +调试工具,用于测试 MCP 连接: + +```bash +# 启动 MCP Inspector +npx @modelcontextprotocol/inspector + +# 在浏览器中连接到:http://localhost:18060/mcp +``` + +使用步骤: + +- 使用 MCP Inspector 测试连接 +- 测试 Ping Server 功能验证连接 +- 检查 List Tools 是否返回 6 个工具 + +
+ +
+Cline + +Cline 是一个强大的 AI 编程助手,支持 MCP 协议集成。 + +#### 配置方法 + +在 Cline 的 MCP 设置中添加以下配置: + +```json +{ + "xiaohongshu-mcp": { + "url": "http://localhost:18060/mcp", + "type": "streamableHttp", + "autoApprove": [], + "disabled": false + } +} +``` + +#### 使用步骤 + +1. 确保小红书 MCP 服务正在运行(`http://localhost:18060/mcp`) +2. 在 Cline 中打开 MCP 设置 +3. 添加上述配置到 MCP 服务器列表 +4. 保存配置并重启 Cline +5. 在对话中可以直接使用小红书相关功能 + +#### 配置说明 + +- `url`: MCP 服务地址 +- `type`: 使用 `streamableHttp` 类型以获得更好的性能 +- `autoApprove`: 可配置自动批准的工具列表(留空表示手动批准) +- `disabled`: 设置为 `false` 启用此 MCP 服务 + +#### 使用示例 + +配置完成后,可以在 Cline 中直接使用自然语言操作小红书: + +``` +帮我检查小红书登录状态 +``` + +``` +帮我发布一篇关于春天的图文到小红书,使用这张图片:/path/to/spring.jpg +``` + +``` +搜索小红书上关于"美食"的内容 +``` + +
+ +
+其他支持 HTTP MCP 的客户端 + +任何支持 HTTP MCP 协议的客户端都可以连接到:`http://localhost:18060/mcp` + +基本配置模板: + +```json +{ + "name": "xiaohongshu-mcp", + "url": "http://localhost:18060/mcp", + "type": "http" +} +``` + +
+ +### 2.3. 可用 MCP 工具 + +连接成功后,可使用以下 MCP 工具: + +- `check_login_status` - 检查小红书登录状态(无参数) +- `publish_content` - 发布图文内容到小红书(必需:title, content, images) + - `images`: 支持 HTTP 链接或本地绝对路径,推荐使用本地路径 +- `publish_with_video` - 发布视频内容到小红书(必需:title, content, video) + - `video`: 仅支持本地视频文件绝对路径 +- `list_feeds` - 获取小红书首页推荐列表(无参数) +- `search_feeds` - 搜索小红书内容(需要:keyword) +- `get_feed_detail` - 获取帖子详情(需要:feed_id, xsec_token) +- `post_comment_to_feed` - 发表评论到小红书帖子(需要:feed_id, xsec_token, content) +- `user_profile` - 获取用户个人主页信息(需要:user_id, xsec_token) + +### 2.4. 使用示例 + +使用 Claude Code 发布内容到小红书: + +**示例 1:使用 HTTP 图片链接** + +``` +帮我写一篇帖子发布到小红书上, +配图为:https://cn.bing.com/th?id=OHR.MaoriRock_EN-US6499689741_UHD.jpg&w=3840 +图片是:"纽西兰陶波湖的Ngātoroirangi矿湾毛利岩雕(© Joppi/Getty Images)" + +使用 xiaohongshu-mcp 进行发布。 +``` + +**示例 2:使用本地图片路径(推荐)** + +``` +帮我写一篇关于春天的帖子发布到小红书上, +使用这些本地图片: +- /Users/username/Pictures/spring_flowers.jpg +- /Users/username/Pictures/cherry_blossom.jpg + +使用 xiaohongshu-mcp 进行发布。 +``` + +**示例 3:发布视频内容** + +``` +帮我写一篇关于美食制作的视频发布到小红书上, +使用这个本地视频文件: +- /Users/username/Videos/cooking_tutorial.mp4 + +使用 xiaohongshu-mcp 的视频发布功能。 +``` + +![claude-cli 进行发布](./assets/claude_push.gif) + +**发布结果:** + +xiaohongshu-mcp 发布结果 + +### 2.5. 💬 MCP 使用常见问题解答 + +--- + +**Q:** 为什么检查登录用户名显示 `xiaghgngshu-mcp`? +**A:** 用户名是写死的。 + +--- + +**Q:** 显示发布成功后,但实际上没有显示? +**A:** 排查步骤如下: +1. 使用 **非无头模式** 重新发布一次。 +2. 更换 **不同的内容** 重新发布。 +3. 登录网页版小红书,查看账号是否被 **风控限制网页版发布**。 +4. 检查 **图片大小** 是否过大。 +5. 确认 **图片路径中没有中文字符**。 +6. 若使用网络图片地址,请确认 **图片链接可正常访问**。 + +--- + +**Q:** 在设备上运行 MCP 程序出现闪退如何解决? +**A:** +1. 建议 **从源码安装**。 +2. 或使用 **Docker 安装 xiaohongshu-mcp**,教程参考: + - [使用 Docker 安装 xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp#:~:text=%E6%96%B9%E5%BC%8F%E4%B8%89%EF%BC%9A%E4%BD%BF%E7%94%A8%20Docker%20%E5%AE%B9%E5%99%A8%EF%BC%88%E6%9C%80%E7%AE%80%E5%8D%95%EF%BC%89) + - [X-MCP 项目页面](https://github.com/xpzouying/x-mcp/) + +--- + +**Q:** 使用 `http://localhost:18060/mcp` 进行 MCP 验证时提示无法连接? +**A:** +- 在 **Docker 环境** 下,请使用 + 👉 [http://host.docker.internal:18060/mcp](http://host.docker.internal:18060/mcp) +- 在 **非 Docker 环境** 下,请使用 **本机 IPv4 地址** 访问。 + +--- + +## 3. 🌟 实战案例展示 (Community Showcases) + +> 💡 **强烈推荐查看**:这些都是社区贡献者的真实使用案例,包含详细的配置步骤和实战经验! + +### 📚 完整教程列表 + +1. **[n8n 完整集成教程](./examples/n8n/README.md)** - 工作流自动化平台集成 +2. **[Cherry Studio 完整配置教程](./examples/cherrystudio/README.md)** - AI 客户端完美接入 +3. **[Claude Code + Kimi K2 接入教程](./examples/claude-code/claude-code-kimi-k2.md)** - Claude Code 门槛太高,那么就接入 Kimi 国产大模型吧~ +4. **[AnythingLLM 完整指南](./examples/anythingLLM/readme.md)** - AnythingLLM 是一款 all-in-one 多模态 AI 客户端,支持 workflow 定义,支持多种大模型和插件扩展。 + +> 🎯 **提示**: 点击上方链接查看详细的图文教程,快速上手各种集成方案! +> +> 📢 **欢迎贡献**: 如果你有新的集成案例,欢迎提交 PR 分享给社区! + +## 4. 小红书 MCP 互助群 + +**重要:在群里问问题之前,请一定要先仔细看完 README 文档以及查看 Issues。** + + + +| 【飞书 3 群】:扫码进入 | 【微信群 13 群】:扫码进入 | +| ------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| qrcode_2qun | WechatIMG119 | + + +## 🙏 致谢贡献者 ✨ + +感谢以下所有为本项目做出贡献的朋友!(排名不分先后) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
zy
zy

💻 🤔 📖 🎨 🚧 🚇 👀
clearwater
clearwater

💻
Zhongpeng
Zhongpeng

💻
Duong Tran
Duong Tran

💻
Angiin
Angiin

💻
Henan Mu
Henan Mu

💻
Journey
Journey

💻
Eve Yu
Eve Yu

💻
CooperGuo
CooperGuo

💻
Banghao Chi
Banghao Chi

💻
varz1
varz1

💻
Melo Y Guan
Melo Y Guan

💻
lmxdawn
lmxdawn

💻
haikow
haikow

💻
Carlo
Carlo

💻
hrz
hrz

💻
Ctrlz
Ctrlz

💻
flippancy
flippancy

💻
Yuhang Lu
Yuhang Lu

💻
Bryan Thompson
Bryan Thompson

💻
tan jun
tan jun

💻
+ + + + + + +### ✨ 特别感谢 + + + + + + + + + +
wanpengxie
@wanpengxie
tanxxjun321
@tanxxjun321
Angiin
@Angiin
+ +本项目遵循 [all-contributors](https://github.com/all-contributors/all-contributors) 规范。欢迎任何形式的贡献! diff --git a/xiaohongshu-mcp-main/README_EN.md b/xiaohongshu-mcp-main/README_EN.md new file mode 100644 index 0000000..8774e43 --- /dev/null +++ b/xiaohongshu-mcp-main/README_EN.md @@ -0,0 +1,808 @@ +# xiaohongshu-mcp + + +[![All Contributors](https://img.shields.io/badge/all_contributors-17-orange.svg?style=flat-square)](#contributors-) + + +[![Philanthropy](https://img.shields.io/badge/Philanthropy-CNY%201300.00-brightgreen?style=flat-square)](./DONATIONS.md) +[![Gratitude](https://img.shields.io/badge/Gratitude-CNY%20969.95-blue?style=flat-square)](./DONATIONS.md) +[![Docker Pulls](https://img.shields.io/docker/pulls/xpzouying/xiaohongshu-mcp?style=flat-square&logo=docker)](https://hub.docker.com/r/xpzouying/xiaohongshu-mcp) + +MCP for RedNote (Xiaohongshu) platform. + +- My blog article: [haha.ai/xiaohongshu-mcp](https://www.haha.ai/xiaohongshu-mcp) + +**If you encounter any issues, be sure to check [Common Issues and Solutions](https://github.com/xpzouying/xiaohongshu-mcp/issues/56) first.** + +After checking the **Common Issues** list, if you still can't resolve your deployment problems, we strongly recommend using another tool I've created: [xpzouying/x-mcp](https://github.com/xpzouying/x-mcp). This tool doesn't require deployment - you only need a browser extension to drive your MCP, making it more user-friendly for non-technical users. + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=xpzouying/xiaohongshu-mcp&type=Timeline)](https://www.star-history.com/#xpzouying/xiaohongshu-mcp&Timeline) + +## Appreciation and Support + +All donations received for this project will be used for charitable giving. For all charitable donation records, please refer to [DONATIONS.md](./DONATIONS.md). + +**When donating, please note "MCP" and your name.** +If you need to correct/withdraw your name attribution, please open an Issue or contact via email. + +**Alipay (QR code not displayed):** + +Donate via Alipay to **xpzouying@gmail.com**. + +**WeChat:** + +WeChat Pay QR + +## Project Overview + +**Main Features** + +> 💡 **Tip:** Click on the feature titles below to expand and view video demonstrations + +
+1. Login and Check Login Status + +The first step is required - RedNote needs to be logged in. You can check current login status. + +**Login Demo:** + +https://github.com/user-attachments/assets/8b05eb42-d437-41b7-9235-e2143f19e8b7 + +**Check Login Status Demo:** + +https://github.com/user-attachments/assets/bd9a9a4a-58cb-4421-b8f3-015f703ce1f9 + +
+ +
+2. Publish Image and Text Content + +Supports publishing image and text content to RedNote, including title, content description, and images. + +**Image Support Methods:** + +Supports two image input methods: + +1. **HTTP/HTTPS Image Links** + + ``` + ["https://example.com/image1.jpg", "https://example.com/image2.png"] + ``` + +2. **Local Image Absolute Paths** (Recommended) + ``` + ["/Users/username/Pictures/image1.jpg", "/home/user/images/image2.png"] + ``` + +**Why Local Paths are Recommended:** + +- ✅ Better stability, not dependent on network +- ✅ Faster upload speed +- ✅ Avoid image link expiration issues +- ✅ Support more image formats + +**Publish Image-Text Post Demo:** + +https://github.com/user-attachments/assets/8aee0814-eb96-40af-b871-e66e6bbb6b06 + +
+ +
+3. Publish Video Content + +Supports publishing video content to RedNote, including title, content description, and local video files. + +**Video Support Methods:** + +Only supports local video file absolute paths: + +``` +"/Users/username/Videos/video.mp4" +``` + +**Features:** + +- ✅ Supports local video file upload +- ✅ Automatic video format processing +- ✅ Supports title, content description, and tags +- ✅ Automatically publishes after video processing is complete + +**Important Notes:** + +- Only supports local video files, not HTTP links +- Video processing takes longer, please be patient +- Recommended video file size should not exceed 1GB + +
+ +
+4. Search Content + +Search RedNote content by keywords. + +**Search Posts Demo:** + +https://github.com/user-attachments/assets/03c5077d-6160-4b18-b629-2e40933a1fd3 + +
+ +
+5. Get Recommendation List + +Get RedNote homepage recommendation content list. + +**Get Recommendation List Demo:** + +https://github.com/user-attachments/assets/110fc15d-46f2-4cca-bdad-9de5b5b8cc28 + +
+ +
+6. Get Post Details (Including Interaction Data and Comments) + +Get complete details of RedNote posts, including: + +- Post content (title, description, images, etc.) +- User information +- Interaction data (likes, favorites, shares, comment count) +- Comment list and sub-comments + +**⚠️ Important Note:** + +- Both post ID and xsec_token are required (both parameters are essential) +- These two parameters can be obtained from Feed list or search results +- Must login first to use this feature + +**Get Post Details Demo:** + +https://github.com/user-attachments/assets/76a26130-a216-4371-a6b3-937b8fda092a + +
+ +
+7. Post Comments to Posts + +Supports automatically posting comments to RedNote posts. + +**Feature Description:** + +- Automatically locate comment input box +- Input comment content and publish +- Supports HTTP API and MCP tool calls + +**⚠️ Important Note:** + +- Must login first to use this feature +- Need to provide post ID, xsec_token, and comment content +- These parameters can be obtained from Feed list or search results + +**Post Comment Demo:** + +https://github.com/user-attachments/assets/cc385b6c-422c-489b-a5fc-63e92c695b80 + +
+ +
+8. Get User Profile + +Get RedNote user's personal profile information, including basic user information and note content. + +**Feature Description:** + +- Get user basic information (nickname, bio, avatar, etc.) +- Get follower count, following count, likes count statistics +- Get user's published note content list +- Supports HTTP API and MCP tool calls + +**⚠️ Important Note:** + +- Must login first to use this feature +- Need to provide user ID and xsec_token +- These parameters can be obtained from Feed list or search results + +**Returned Information Includes:** + +- User basic info: nickname, bio, avatar, verification status +- Statistics: following count, follower count, likes count, note count +- Note list: all public notes published by the user + +
+ +**RedNote Basic Operation Knowledge** + +- **Title: (Very Important) RedNote requires titles to not exceed 20 characters** +- **Content: (Very Important) Content cannot exceed 1000 characters** +- Currently supports both image-text and video posting: From a recommendation perspective, image-text posts get better traffic than video or pure text. +- (Low priority) Pure text support can be considered. 1. I personally feel pure text would greatly increase operation complexity; 2. Pure text has low value in my use scenarios. +- Tags: Now supported. Adding appropriate tags can bring more traffic. +- According to my practical experience, RedNote should allow **50 posts** per day. +- **(Very Important) RedNote does not allow the same account to login on multiple web platforms**. If you login to the current xiaohongshu-mcp, don't login to that account on other web platforms, otherwise it will "kick out" the current MCP account login. You can use the mobile app to check current account information. + +**Risk Explanation** + +1. This project is open-sourced based on another project of mine. The original project has been running stably for over a year without any account bans, only occasional cookie expiration requiring re-login. +2. I used Claude Code CLI integration and verified stable automated operation for several weeks before open-sourcing. + +This project is for learning purposes only. All illegal activities are prohibited. + +**Practical Results** + +First day likes/favorites reached 999+, + +CleanShot 2025-09-05 at 01 31 55@2x + +CleanShot 2025-09-05 at 01 32 49@2x + +Results after about a week + +CleanShot 2025-09-05 at 01 33 13@2x + +## 1. Usage Tutorial + +### 1.1. Quick Start (Recommended) + +**Method 1: Download Pre-compiled Binaries** + +Download pre-compiled binaries for your platform directly from [GitHub Releases](https://github.com/xpzouying/xiaohongshu-mcp/releases): + +**Main Program (MCP Service):** + +- **macOS Apple Silicon**: `xiaohongshu-mcp-darwin-arm64` +- **macOS Intel**: `xiaohongshu-mcp-darwin-amd64` +- **Windows x64**: `xiaohongshu-mcp-windows-amd64.exe` +- **Linux x64**: `xiaohongshu-mcp-linux-amd64` + +**Login Tool:** + +- **macOS Apple Silicon**: `xiaohongshu-login-darwin-arm64` +- **macOS Intel**: `xiaohongshu-login-darwin-amd64` +- **Windows x64**: `xiaohongshu-login-windows-amd64.exe` +- **Linux x64**: `xiaohongshu-login-linux-amd64` + +Usage Steps: + +```bash +# 1. First run the login tool +chmod +x xiaohongshu-login-darwin-arm64 +./xiaohongshu-login-darwin-arm64 + +# 2. Then start the MCP service +chmod +x xiaohongshu-mcp-darwin-arm64 +./xiaohongshu-mcp-darwin-arm64 +``` + +**⚠️ Important Note**: The headless browser will be automatically downloaded on first run (about 150MB), please ensure a stable network connection. Subsequent runs will not require re-downloading. + +**Method 2: Build from Source** + +
+Build from Source Details + +Requires Golang environment. For installation instructions, please refer to [Golang Official Documentation](https://go.dev/doc/install). + +Set Go domestic proxy source: + +```bash +# Configure GOPROXY environment variable, choose one of the following three + +# 1. Qiniu CDN +go env -w GOPROXY=https://goproxy.cn,direct + +# 2. Alibaba Cloud +go env -w GOPROXY=https://mirrors.aliyun.com/goproxy/,direct + +# 3. Official +go env -w GOPROXY=https://goproxy.io,direct +``` + +
+ +**Method 3: Using Docker Container (Simplest)** + +
+Docker Deployment Details + +Using Docker deployment is the simplest method, requiring no development environment installation. + +**1. Pull Image from Docker Hub (Recommended)** + +We provide pre-built Docker images that can be directly pulled from Docker Hub: + +```bash +# Pull the latest image +docker pull xpzouying/xiaohongshu-mcp +``` + +Docker Hub URL: [https://hub.docker.com/r/xpzouying/xiaohongshu-mcp](https://hub.docker.com/r/xpzouying/xiaohongshu-mcp) + +**2. Start with Docker Compose (Recommended)** + +We provide a pre-configured `docker-compose.yml` file that can be used directly: + +```bash +# Download docker-compose.yml +wget https://raw.githubusercontent.com/xpzouying/xiaohongshu-mcp/main/docker/docker-compose.yml + +# Or if you've already cloned the project, enter the docker directory +cd docker + +# Start service +docker compose up -d + +# View logs +docker compose logs -f + +# Stop service +docker compose stop +``` + +**3. Build Image Yourself (Optional)** + +If you need to customize or modify the code, you can build the image yourself: + +```bash +# Run in project root directory +docker build -t xpzouying/xiaohongshu-mcp . +``` + +**4. Configuration Notes** + +The Docker version automatically: +- Configures Chrome browser and Chinese fonts +- Mounts `./data` for storing cookies +- Mounts `./images` for storing publish images +- Exposes port 18060 for MCP connection + +For detailed instructions, please refer to: [Docker Deployment Guide](./docker/README.md) + +
+ +For Windows issues, check here first: [Windows Installation Guide](./docs/windows_guide.md) + +### 1.2. Login + +First time requires manual login to save RedNote login status. + +**Using Binary Files:** + +```bash +# Run the login tool for your platform +./xiaohongshu-login-darwin-arm64 +``` + +**Using Source Code:** + +```bash +go run cmd/login/main.go +``` + +### 1.3. Start MCP Service + +Start xiaohongshu-mcp service. + +**Using Binary Files:** + +```bash +# Default: Headless mode, no browser interface +./xiaohongshu-mcp-darwin-arm64 + +# Non-headless mode, with browser interface +./xiaohongshu-mcp-darwin-arm64 -headless=false +``` + +**Using Source Code:** + +```bash +# Default: Headless mode, no browser interface +go run . + +# Non-headless mode, with browser interface +go run . -headless=false +``` + +## 1.4. Verify MCP + +```bash +npx @modelcontextprotocol/inspector +``` + +![Run Inspector](./assets/run_inspect.png) + +After running, open the red-marked link, configure MCP inspector, enter `http://localhost:18060/mcp`, and click the `Connect` button. + +![Configure MCP inspector](./assets/inspect_mcp.png) + +After configuring MCP inspector as above, click the `List Tools` button to view all Tools. + +## 1.5. Use MCP for Publishing + +### Check Login Status + +![Check Login Status](./assets/check_login.gif) + +### Publish Image-Text + +The example uses a random image from https://unsplash.com/ for testing. + +![Publish Image-Text](./assets/inspect_mcp_publish.gif) + +### Search Content + +Use search functionality to search RedNote content by keywords: + +![Search Content](./assets/search_result.png) + +## 2. MCP Client Integration + +This service supports the standard Model Context Protocol (MCP) and can integrate with various AI clients that support MCP. + +### 2.1. Quick Start + +#### Start MCP Service + +```bash +# Start service (default headless mode) +go run . + +# Or with interface mode +go run . -headless=false +``` + +Service will run at: `http://localhost:18060/mcp` + +#### Verify Service Status + +```bash +# Test MCP connection +curl -X POST http://localhost:18060/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"initialize","params":{},"id":1}' +``` + +#### Claude Code CLI Integration + +```bash +# Add HTTP MCP server +claude mcp add --transport http xiaohongshu-mcp http://localhost:18060/mcp +``` + +### 2.2. Supported Clients + +
+Claude Code CLI + +Official command line tool, already shown in the quick start section above: + +```bash +# Add HTTP MCP server +claude mcp add --transport http xiaohongshu-mcp http://localhost:18060/mcp +``` + +
+ +
+Cursor + +#### Configuration File Method + +Create or edit MCP configuration file: + +**Project-level configuration** (recommended): +Create `.cursor/mcp.json` in project root directory: + +```json +{ + "mcpServers": { + "xiaohongshu-mcp": { + "url": "http://localhost:18060/mcp", + "description": "RedNote content publishing service - MCP Streamable HTTP" + } + } +} +``` + +**Global configuration**: +Create `~/.cursor/mcp.json` in user directory (same content). + +#### Usage Steps + +1. Ensure RedNote MCP service is running +2. Save configuration file and restart Cursor +3. In Cursor chat, tools should be automatically available +4. You can view connected MCP tools through "Available Tools" in the chat interface + +**Demo** + +Plugin MCP integration: + +![cursor_mcp_settings](./assets/cursor_mcp_settings.png) + +Call MCP tools: (using check login status as example) + +![cursor_mcp_check_login](./assets/cursor_mcp_check_login.png) + +
+ +
+VSCode + +#### Method 1: Configure using Command Palette + +1. Press `Ctrl/Cmd + Shift + P` to open command palette +2. Run `MCP: Add Server` command +3. Select `HTTP` method. +4. Enter address: `http://localhost:18060/mcp`, or modify to corresponding Server address. +5. Enter MCP name: `xiaohongshu-mcp`. + +#### Method 2: Direct Configuration File Edit + +**Workspace configuration** (recommended): +Create `.vscode/mcp.json` in project root directory: + +```json +{ + "servers": { + "xiaohongshu-mcp": { + "url": "http://localhost:18060/mcp", + "type": "http" + } + }, + "inputs": [] +} +``` + +**View Configuration**: + +![vscode_config](./assets/vscode_mcp_config.png) + +1. Confirm running status. +2. Check if `tools` are correctly detected. + +**Demo** + +Using search post content as example: + +![vscode_mcp_search](./assets/vscode_search_demo.png) + +
+ +
+Google Gemini CLI + +Configure in `~/.gemini/settings.json` or project directory `.gemini/settings.json`: + +```json +{ + "mcpServers": { + "xiaohongshu": { + "httpUrl": "http://localhost:18060/mcp", + "timeout": 30000 + } + } +} +``` + +For more information, please refer to [Gemini CLI MCP Documentation](https://google-gemini.github.io/gemini-cli/docs/tools/mcp-server.html) + +
+ +
+MCP Inspector + +Debug tool for testing MCP connections: + +```bash +# Start MCP Inspector +npx @modelcontextprotocol/inspector + +# Connect in browser to: http://localhost:18060/mcp +``` + +Usage steps: + +- Use MCP Inspector to test connection +- Test Ping Server functionality to verify connection +- Check if List Tools returns 6 tools + +
+ +
+Cline + +Cline is a powerful AI programming assistant that supports MCP protocol integration. + +#### Configuration Method + +Add the following configuration to Cline's MCP settings: + +```json +{ + "xiaohongshu-mcp": { + "url": "http://localhost:18060/mcp", + "type": "streamableHttp", + "autoApprove": [], + "disabled": false + } +} +``` + +#### Usage Steps + +1. Ensure RedNote MCP service is running (`http://localhost:18060/mcp`) +2. Open MCP settings in Cline +3. Add the above configuration to the MCP server list +4. Save configuration and restart Cline +5. You can directly use RedNote-related features in conversations + +#### Configuration Explanation + +- `url`: MCP service address +- `type`: Use `streamableHttp` type for better performance +- `autoApprove`: Configurable auto-approve tool list (empty means manual approval) +- `disabled`: Set to `false` to enable this MCP service + +#### Usage Examples + +After configuration, you can use natural language to operate RedNote directly in Cline: + +``` +Help me check RedNote login status +``` + +``` +Help me publish a spring-themed image-text post to RedNote, using this image: /path/to/spring.jpg +``` + +``` +Search for content about "food" on RedNote +``` + +
+ +
+Other HTTP MCP Supporting Clients + +Any client supporting HTTP MCP protocol can connect to: `http://localhost:18060/mcp` + +Basic configuration template: + +```json +{ + "name": "xiaohongshu-mcp", + "url": "http://localhost:18060/mcp", + "type": "http" +} +``` + +
+ +### 2.3. Available MCP Tools + +After successful connection, you can use the following MCP tools: + +- `check_login_status` - Check RedNote login status (no parameters) +- `publish_content` - Publish image-text content to RedNote (required: title, content, images) + - `images`: Supports HTTP links or local absolute paths, local paths recommended +- `publish_with_video` - Publish video content to RedNote (required: title, content, video) + - `video`: Only supports local video file absolute paths +- `list_feeds` - Get RedNote homepage recommendation list (no parameters) +- `search_feeds` - Search RedNote content (required: keyword) +- `get_feed_detail` - Get post details (required: feed_id, xsec_token) +- `post_comment_to_feed` - Post comments to RedNote posts (required: feed_id, xsec_token, content) +- `user_profile` - Get user profile information (required: user_id, xsec_token) + +### 2.4. Usage Examples + +Using Claude Code to publish content to RedNote: + +**Example 1: Using HTTP Image Links** + +``` +Help me write a post to publish on RedNote, +with image: https://cn.bing.com/th?id=OHR.MaoriRock_EN-US6499689741_UHD.jpg&w=3840 +The image is: "Maori rock carving at Ngātoroirangi Mine Bay, Lake Taupo, New Zealand (© Joppi/Getty Images)" + +Use xiaohongshu-mcp for publishing. +``` + +**Example 2: Using Local Image Paths (Recommended)** + +``` +Help me write a post about spring to publish on RedNote, +using these local images: +- /Users/username/Pictures/spring_flowers.jpg +- /Users/username/Pictures/cherry_blossom.jpg + +Use xiaohongshu-mcp for publishing. +``` + +**Example 3: Publishing Video Content** + +``` +Help me write a video post about cooking tutorials to publish on RedNote, +using this local video file: +- /Users/username/Videos/cooking_tutorial.mp4 + +Use xiaohongshu-mcp's video publishing feature. +``` + +![claude-cli publishing](./assets/claude_push.gif) + +**Publishing Result:** + +xiaohongshu-mcp publishing result + +## 3. 🌟 Community Showcases + +> 💡 **Highly Recommended**: These are real-world use cases from community contributors, featuring detailed configuration steps and practical experiences! + +### 📚 Complete Tutorial List + +1. **[n8n Complete Integration Tutorial](./examples/n8n/README.md)** - Workflow automation platform integration +2. **[Cherry Studio Complete Configuration Tutorial](./examples/cherrystudio/README.md)** - Perfect AI client integration +3. **[Claude Code + Kimi K2 Integration Tutorial](./examples/claude-code/claude-code-kimi-k2.md)** - If Claude Code's barrier is too high, then integrate with Kimi domestic LLM! +4. **[AnythingLLM Complete Guide](./examples/anythingLLM/readme.md)** - AnythingLLM is an all-in-one multimodal AI client that supports workflow definition, multiple LLMs, and plugin extensions. + +> 🎯 **Tip**: Click the links above to view detailed step-by-step tutorials for quick setup of various integration solutions! +> +> 📢 **Contributions Welcome**: If you have new integration cases, feel free to submit a PR to share with the community! + +## 4. RedNote MCP Community Group + +**Important: Before asking questions in the group, please make sure to read the README documentation thoroughly and check Issues first.** + + + +| 【Feishu Group 3】: Scan to join | 【WeChat Group 10】: Scan to join | +| -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| qrcode_2qun | WechatIMG119 | + +## 🙏 Thanks to Contributors ✨ + +Thanks to all friends who have contributed to this project! (In no particular order) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
zy
zy

💻 🤔 📖 🎨 🚧 🚇 👀
clearwater
clearwater

💻
Zhongpeng
Zhongpeng

💻
Duong Tran
Duong Tran

💻
Angiin
Angiin

💻
Henan Mu
Henan Mu

💻
Journey
Journey

💻
Eve Yu
Eve Yu

💻
CooperGuo
CooperGuo

💻
Banghao Chi
Banghao Chi

💻
varz1
varz1

💻
Melo Y Guan
Melo Y Guan

💻
lmxdawn
lmxdawn

💻
haikow
haikow

💻
Carlo
Carlo

💻
hrz
hrz

💻
Ctrlz
Ctrlz

💻
+ + + + + + +### ✨ Special Thanks + +| Contributors | +| --------------------------------------------------------------------------------------------------------------------------- | +| [
@wanpengxie](https://github.com/wanpengxie) | + +This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! diff --git a/xiaohongshu-mcp-main/app_server.go b/xiaohongshu-mcp-main/app_server.go new file mode 100644 index 0000000..29d531a --- /dev/null +++ b/xiaohongshu-mcp-main/app_server.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gin-gonic/gin" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/sirupsen/logrus" +) + +// AppServer 应用服务器结构体,封装所有服务和处理器 +type AppServer struct { + xiaohongshuService *XiaohongshuService + mcpServer *mcp.Server + router *gin.Engine + httpServer *http.Server +} + +// NewAppServer 创建新的应用服务器实例 +func NewAppServer(xiaohongshuService *XiaohongshuService) *AppServer { + appServer := &AppServer{ + xiaohongshuService: xiaohongshuService, + } + + // 初始化 MCP Server(需要在创建 appServer 之后,因为工具注册需要访问 appServer) + appServer.mcpServer = InitMCPServer(appServer) + + return appServer +} + +// Start 启动服务器 +func (s *AppServer) Start(port string) error { + s.router = setupRoutes(s) + + s.httpServer = &http.Server{ + Addr: port, + Handler: s.router, + } + + // 启动服务器的 goroutine + go func() { + logrus.Infof("启动 HTTP 服务器: %s", port) + if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logrus.Errorf("服务器启动失败: %v", err) + os.Exit(1) + } + }() + + // 等待中断信号 + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + logrus.Infof("正在关闭服务器...") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := s.httpServer.Shutdown(ctx); err != nil { + logrus.Warnf("等待连接关闭超时,强制退出: %v", err) + } else { + logrus.Infof("服务器已优雅关闭") + } + + return nil +} diff --git a/xiaohongshu-mcp-main/assets/check_login.gif b/xiaohongshu-mcp-main/assets/check_login.gif new file mode 100644 index 0000000..33edd71 Binary files /dev/null and b/xiaohongshu-mcp-main/assets/check_login.gif differ diff --git a/xiaohongshu-mcp-main/assets/check_login.mp4 b/xiaohongshu-mcp-main/assets/check_login.mp4 new file mode 100644 index 0000000..6234c82 Binary files /dev/null and b/xiaohongshu-mcp-main/assets/check_login.mp4 differ diff --git a/xiaohongshu-mcp-main/assets/claude_push.gif b/xiaohongshu-mcp-main/assets/claude_push.gif new file mode 100644 index 0000000..e0c7f34 Binary files /dev/null and b/xiaohongshu-mcp-main/assets/claude_push.gif differ diff --git a/xiaohongshu-mcp-main/assets/claude_push.mp4 b/xiaohongshu-mcp-main/assets/claude_push.mp4 new file mode 100644 index 0000000..6be08f1 Binary files /dev/null and b/xiaohongshu-mcp-main/assets/claude_push.mp4 differ diff --git a/xiaohongshu-mcp-main/assets/cursor_mcp_check_login.png b/xiaohongshu-mcp-main/assets/cursor_mcp_check_login.png new file mode 100644 index 0000000..6925f6d Binary files /dev/null and b/xiaohongshu-mcp-main/assets/cursor_mcp_check_login.png differ diff --git a/xiaohongshu-mcp-main/assets/cursor_mcp_settings.png b/xiaohongshu-mcp-main/assets/cursor_mcp_settings.png new file mode 100644 index 0000000..bb56ef9 Binary files /dev/null and b/xiaohongshu-mcp-main/assets/cursor_mcp_settings.png differ diff --git a/xiaohongshu-mcp-main/assets/inspect_mcp.png b/xiaohongshu-mcp-main/assets/inspect_mcp.png new file mode 100644 index 0000000..7743855 Binary files /dev/null and b/xiaohongshu-mcp-main/assets/inspect_mcp.png differ diff --git a/xiaohongshu-mcp-main/assets/inspect_mcp_publish.gif b/xiaohongshu-mcp-main/assets/inspect_mcp_publish.gif new file mode 100644 index 0000000..9fa9265 Binary files /dev/null and b/xiaohongshu-mcp-main/assets/inspect_mcp_publish.gif differ diff --git a/xiaohongshu-mcp-main/assets/inspect_mcp_publish.mp4 b/xiaohongshu-mcp-main/assets/inspect_mcp_publish.mp4 new file mode 100644 index 0000000..a95a961 Binary files /dev/null and b/xiaohongshu-mcp-main/assets/inspect_mcp_publish.mp4 differ diff --git a/xiaohongshu-mcp-main/assets/publish_result.jpeg b/xiaohongshu-mcp-main/assets/publish_result.jpeg new file mode 100644 index 0000000..1ee154c Binary files /dev/null and b/xiaohongshu-mcp-main/assets/publish_result.jpeg differ diff --git a/xiaohongshu-mcp-main/assets/run_inspect.png b/xiaohongshu-mcp-main/assets/run_inspect.png new file mode 100644 index 0000000..4509bd6 Binary files /dev/null and b/xiaohongshu-mcp-main/assets/run_inspect.png differ diff --git a/xiaohongshu-mcp-main/assets/search_result.png b/xiaohongshu-mcp-main/assets/search_result.png new file mode 100644 index 0000000..8f740aa Binary files /dev/null and b/xiaohongshu-mcp-main/assets/search_result.png differ diff --git a/xiaohongshu-mcp-main/assets/vscode_mcp_config.png b/xiaohongshu-mcp-main/assets/vscode_mcp_config.png new file mode 100644 index 0000000..9d00b19 Binary files /dev/null and b/xiaohongshu-mcp-main/assets/vscode_mcp_config.png differ diff --git a/xiaohongshu-mcp-main/assets/vscode_search_demo.png b/xiaohongshu-mcp-main/assets/vscode_search_demo.png new file mode 100644 index 0000000..3d41663 Binary files /dev/null and b/xiaohongshu-mcp-main/assets/vscode_search_demo.png differ diff --git a/xiaohongshu-mcp-main/browser/browser.go b/xiaohongshu-mcp-main/browser/browser.go new file mode 100644 index 0000000..4da1392 --- /dev/null +++ b/xiaohongshu-mcp-main/browser/browser.go @@ -0,0 +1,46 @@ +package browser + +import ( + "github.com/sirupsen/logrus" + "github.com/xpzouying/headless_browser" + "github.com/xpzouying/xiaohongshu-mcp/cookies" +) + +type browserConfig struct { + binPath string +} + +type Option func(*browserConfig) + +func WithBinPath(binPath string) Option { + return func(c *browserConfig) { + c.binPath = binPath + } +} + +func NewBrowser(headless bool, options ...Option) *headless_browser.Browser { + cfg := &browserConfig{} + for _, opt := range options { + opt(cfg) + } + + opts := []headless_browser.Option{ + headless_browser.WithHeadless(headless), + } + if cfg.binPath != "" { + opts = append(opts, headless_browser.WithChromeBinPath(cfg.binPath)) + } + + // 加载 cookies + cookiePath := cookies.GetCookiesFilePath() + cookieLoader := cookies.NewLoadCookie(cookiePath) + + if data, err := cookieLoader.LoadCookies(); err == nil { + opts = append(opts, headless_browser.WithCookies(string(data))) + logrus.Debugf("loaded cookies from filesuccessfully") + } else { + logrus.Warnf("failed to load cookies: %v", err) + } + + return headless_browser.New(opts...) +} diff --git a/xiaohongshu-mcp-main/cmd/login/main.go b/xiaohongshu-mcp-main/cmd/login/main.go new file mode 100644 index 0000000..5a3fd96 --- /dev/null +++ b/xiaohongshu-mcp-main/cmd/login/main.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + + "github.com/go-rod/rod" + "github.com/sirupsen/logrus" + "github.com/xpzouying/xiaohongshu-mcp/browser" + "github.com/xpzouying/xiaohongshu-mcp/cookies" + "github.com/xpzouying/xiaohongshu-mcp/xiaohongshu" +) + +func main() { + var ( + binPath string // 浏览器二进制文件路径 + ) + flag.StringVar(&binPath, "bin", "", "浏览器二进制文件路径") + flag.Parse() + + // 登录的时候,需要界面,所以不能无头模式 + b := browser.NewBrowser(false, browser.WithBinPath(binPath)) + defer b.Close() + + page := b.NewPage() + defer page.Close() + + action := xiaohongshu.NewLogin(page) + + status, err := action.CheckLoginStatus(context.Background()) + if err != nil { + logrus.Fatalf("failed to check login status: %v", err) + } + + logrus.Infof("当前登录状态: %v", status) + + if status { + return + } + + // 开始登录流程 + logrus.Info("开始登录流程...") + if err = action.Login(context.Background()); err != nil { + logrus.Fatalf("登录失败: %v", err) + } else { + if err := saveCookies(page); err != nil { + logrus.Fatalf("failed to save cookies: %v", err) + } + } + + // 再次检查登录状态确认成功 + status, err = action.CheckLoginStatus(context.Background()) + if err != nil { + logrus.Fatalf("failed to check login status after login: %v", err) + } + + if status { + logrus.Info("登录成功!") + } else { + logrus.Error("登录流程完成但仍未登录") + } + +} + +func saveCookies(page *rod.Page) error { + cks, err := page.Browser().GetCookies() + if err != nil { + return err + } + + data, err := json.Marshal(cks) + if err != nil { + return err + } + + cookieLoader := cookies.NewLoadCookie(cookies.GetCookiesFilePath()) + return cookieLoader.SaveCookies(data) +} diff --git a/xiaohongshu-mcp-main/configs/browser.go b/xiaohongshu-mcp-main/configs/browser.go new file mode 100644 index 0000000..34f4538 --- /dev/null +++ b/xiaohongshu-mcp-main/configs/browser.go @@ -0,0 +1,24 @@ +package configs + +var ( + useHeadless = true + + binPath = "" +) + +func InitHeadless(h bool) { + useHeadless = h +} + +// IsHeadless 是否无头模式。 +func IsHeadless() bool { + return useHeadless +} + +func SetBinPath(b string) { + binPath = b +} + +func GetBinPath() string { + return binPath +} diff --git a/xiaohongshu-mcp-main/configs/image.go b/xiaohongshu-mcp-main/configs/image.go new file mode 100644 index 0000000..5d8d0d2 --- /dev/null +++ b/xiaohongshu-mcp-main/configs/image.go @@ -0,0 +1,14 @@ +package configs + +import ( + "os" + "path/filepath" +) + +const ( + ImagesDir = "xiaohongshu_images" +) + +func GetImagesPath() string { + return filepath.Join(os.TempDir(), ImagesDir) +} diff --git a/xiaohongshu-mcp-main/configs/username.go b/xiaohongshu-mcp-main/configs/username.go new file mode 100644 index 0000000..006e1fd --- /dev/null +++ b/xiaohongshu-mcp-main/configs/username.go @@ -0,0 +1,5 @@ +package configs + +const ( + Username = "xiaohongshu-mcp" +) diff --git a/xiaohongshu-mcp-main/cookies/cookies.go b/xiaohongshu-mcp-main/cookies/cookies.go new file mode 100644 index 0000000..fde7f04 --- /dev/null +++ b/xiaohongshu-mcp-main/cookies/cookies.go @@ -0,0 +1,76 @@ +package cookies + +import ( + "os" + "path/filepath" + + "github.com/pkg/errors" +) + +type Cookier interface { + LoadCookies() ([]byte, error) + SaveCookies(data []byte) error + DeleteCookies() error +} + +type localCookie struct { + path string +} + +func NewLoadCookie(path string) Cookier { + if path == "" { + panic("path is required") + } + + return &localCookie{ + path: path, + } +} + +// LoadCookies 从文件中加载 cookies。 +func (c *localCookie) LoadCookies() ([]byte, error) { + + data, err := os.ReadFile(c.path) + if err != nil { + return nil, errors.Wrap(err, "failed to read cookies from tmp file") + } + + return data, nil +} + +// SaveCookies 保存 cookies 到文件中。 +func (c *localCookie) SaveCookies(data []byte) error { + return os.WriteFile(c.path, data, 0644) +} + +// DeleteCookies 删除 cookies 文件。 +func (c *localCookie) DeleteCookies() error { + if _, err := os.Stat(c.path); os.IsNotExist(err) { + // 文件不存在,返回 nil(认为已经删除) + return nil + } + return os.Remove(c.path) +} + +// GetCookiesFilePath 获取 cookies 文件路径。 +// 为了向后兼容,如果旧路径 /tmp/cookies.json 存在,则继续使用; +// 否则使用当前目录下的 cookies.json +func GetCookiesFilePath() string { + // 旧路径:/tmp/cookies.json + tmpDir := os.TempDir() + oldPath := filepath.Join(tmpDir, "cookies.json") + + // 检查旧路径文件是否存在 + if _, err := os.Stat(oldPath); err == nil { + // 文件存在,使用旧路径(向后兼容) + return oldPath + } + + path := os.Getenv("COOKIES_PATH") // 判断环境变量 + if path == "" { + path = "cookies.json" // fallback,本地调试时用当前目录 + } + + // 文件不存在,使用新路径(当前目录) + return path +} diff --git a/xiaohongshu-mcp-main/deploy/macos/readme.md b/xiaohongshu-mcp-main/deploy/macos/readme.md new file mode 100644 index 0000000..eac34c6 --- /dev/null +++ b/xiaohongshu-mcp-main/deploy/macos/readme.md @@ -0,0 +1,62 @@ +## 后台运行小红书 MCP 的解决方案 - Mac 端 + +通过此方法你可以:通过系统进程管理小红书 MCP + +### 快速开始 + +#### 1. 安装配置 + +1. 打开当前目录下 xhsmcp.plist + 1. 必须:替换 {二进制路径} 为你的小红书 MCP 二进制路径 + 2. 必须:替换 {工作路径} 为你的小红书 MCP 工作路径,必须在有 cookies.json 文件的目录才能正常工作 + 3. 可选:修改默认日志路径 StandardOutPath + 4. 可选:修改默认错误日志路径 StandardErrorPath + 5. 可选:修改错误退出的行为是否重启 KeepAlive + 6. 可选:修改是否开机自动重启 RunAtLoad +2. 安装配置 + 1. ln -s {你编辑后的 plist} ~/Library/LaunchAgents/xhsmcp.plist + 2. launchctl load ~/Library/LaunchAgents/xhsmcp.plist + +至此就完成了配置安装 + +#### 2. 使用配置 + +启动小红书 MCP 服务 + +```bash +launchctl start xhsmcp +``` + +关闭小红书 MCP 服务 + +```bash +launchctl stop xhsmcp +``` + +查看服务状态,输出有进程 ID 则为运行中,也可以通过 curl 检查服务运行状态 + +```bash +launchctl list | grep xhsmcp +``` + +### Shell 脚本管理 (进阶用法) + +如果你使用 fish shell,可以安装该目录下的 xhsmcp.fish,实现类似这样的效果: + +``` bash +~/home +> launchctl list | grep + +- 0 xhsmcp + +~/home +> xhsmcp_status + +✗ xhsmcp 未运行 +是否启动服务? (yes/其他): yes +✓ 服务启动成功 (PID: 76061) + +~/home +> launchctl list | grep +76061 0 xhsmcp +``` diff --git a/xiaohongshu-mcp-main/deploy/macos/xhsmcp.fish b/xiaohongshu-mcp-main/deploy/macos/xhsmcp.fish new file mode 100644 index 0000000..288ce79 --- /dev/null +++ b/xiaohongshu-mcp-main/deploy/macos/xhsmcp.fish @@ -0,0 +1,43 @@ +function xhsmcp_stop + launchctl stop xhsmcp +end + +function xhsmcp_start + launchctl start xhsmcp +end + +function xhsmcp_status + gomcp + set service_name "xhsmcp" + + # 获取服务状态 + set pid_status (launchctl list | grep $service_name | awk '{print $1}') + + if test "$pid_status" != "-" + echo "✓ $service_name 正在运行 (PID: $pid_status)" + read -P "是否停止服务? (yes/其他): " answer + if test "$answer" = "yes" + xhsmcp_stop + echo "✓ 服务已停止" + else + echo "取消操作" + end + else + echo "✗ $service_name 未运行" + read -P "是否启动服务? (yes/其他): " answer + if test "$answer" = "yes" + xhsmcp_start + sleep 1 + set pid_status (launchctl list | grep $service_name | awk '{print $1}') + if test "$pid_status" != "-" + echo "✓ 服务启动成功 (PID: $pid_status)" + else + echo "✗ 服务启动失败,检查日志: /tmp/xhsmcp.err" + return 1 + end + else + echo "取消操作" + return 1 + end + end +end \ No newline at end of file diff --git a/xiaohongshu-mcp-main/deploy/macos/xhsmcp.plist b/xiaohongshu-mcp-main/deploy/macos/xhsmcp.plist new file mode 100644 index 0000000..99481a6 --- /dev/null +++ b/xiaohongshu-mcp-main/deploy/macos/xhsmcp.plist @@ -0,0 +1,28 @@ + + + + + Label + xhsmcp + + ProgramArguments + + {二进制路径} + + + WorkingDirectory + {工作路径} + + RunAtLoad + + + KeepAlive + + + StandardOutPath + /tmp/xhsmcp.log + + StandardErrorPath + /tmp/xhsmcp.err + + \ No newline at end of file diff --git a/xiaohongshu-mcp-main/docker/README.md b/xiaohongshu-mcp-main/docker/README.md new file mode 100644 index 0000000..32c85f0 --- /dev/null +++ b/xiaohongshu-mcp-main/docker/README.md @@ -0,0 +1,110 @@ +# Docker 使用说明 + +## 0. 重点注意 + +写在最前面。 + +- 启动后,会产生一个 `images/` 目录,用于存储发布的图片。它会挂载到 Docker 容器里面。 + 如果要使用本地图片发布的话,请确保图片拷贝到 `./images/` 目录下,并且让 MCP 在发布的时候,指定文件夹为:`/app/images`,否则一定失败。 + +## 1. 获取 Docker 镜像 + +### 1.1 从 Docker Hub 拉取(推荐) + +我们提供了预构建的 Docker 镜像,可以直接从 Docker Hub 拉取使用: + +```bash +# 拉取最新镜像 +docker pull xpzouying/xiaohongshu-mcp +``` + +Docker Hub 地址:[https://hub.docker.com/r/xpzouying/xiaohongshu-mcp](https://hub.docker.com/r/xpzouying/xiaohongshu-mcp) + +### 1.2 从阿里云镜像源拉取(国内用户推荐) + +国内用户可以使用阿里云容器镜像服务,拉取速度更快: + +```bash +# 拉取最新镜像 +docker pull crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp +``` + +### 1.3 自己构建镜像(可选) + +在有项目的Dockerfile的目录运行 + +```bash +docker build -t xpzouying/xiaohongshu-mcp . +``` + +`xpzouying/xiaohongshu-mcp`为镜像名称和版本。 + +image + +## 2. 手动 Docker Compose + +> **国内用户提示**:如需使用阿里云镜像源,请修改 `docker-compose.yml` 文件,注释掉 Docker Hub 镜像行,取消阿里云镜像行的注释: +> ```yaml +> # image: xpzouying/xiaohongshu-mcp +> image: crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp +> ``` + +```bash +# 注意:在 docker-compose.yml 文件的同一个目录,或者手动指定 docker-compose.yml。 + +# --- 启动 docker 容器 --- +# 启动 docker-compose +docker compose up -d + +# 查看日志 +docker logs -f xpzouying/xiaohongshu-mcp + +# 或者 +docker compose logs -f +``` + +查看日志,下面表示成功启动。 + +image + + +```bash +# 停止 docker-compose +docker compose stop + +# 查看实时日志 +docker logs -f xpzouying/xiaohongshu-mcp + +# 进入容器 +docker exec -it xiaohongshu-mcp bash + +# 手动更新容器 +docker compose pull && docker compose up -d +``` + +## 3. 使用 MCP-Inspector 进行连接 + +**注意 IP 换成你自己的 IP** + +image + +对应的 Docker 日志一切正常。 + +image + +## 4. 扫码登录 + +1. **重要**,一定要先把 App 提前打开,准备扫码登录。 +2. 尽快扫码,有可能二维码会过期。 + +打开 MCP-Inspector 获取二维码和进行扫码。 + +image + +image + +扫码成功后,再次扫码后,就会提示已经完成登录了。 + +image + + diff --git a/xiaohongshu-mcp-main/docker/docker-compose.yml b/xiaohongshu-mcp-main/docker/docker-compose.yml new file mode 100644 index 0000000..7bc3c79 --- /dev/null +++ b/xiaohongshu-mcp-main/docker/docker-compose.yml @@ -0,0 +1,18 @@ +services: + xiaohongshu-mcp: + # Docker Hub 镜像(默认) + image: xpzouying/xiaohongshu-mcp + # 阿里云镜像源(国内用户推荐,拉取更快) + # image: crpi-hocnvtkomt7w9v8t.cn-beijing.personal.cr.aliyuncs.com/xpzouying/xiaohongshu-mcp + container_name: xiaohongshu-mcp + restart: unless-stopped + init: true + tty: true + volumes: + - ./data:/app/data + - ./images:/app/images + environment: + - ROD_BROWSER_BIN=/usr/bin/google-chrome + - COOKIES_PATH=/app/data/cookies.json + ports: + - "18060:18060" \ No newline at end of file diff --git a/xiaohongshu-mcp-main/docs/API.md b/xiaohongshu-mcp-main/docs/API.md new file mode 100644 index 0000000..b2f36b8 --- /dev/null +++ b/xiaohongshu-mcp-main/docs/API.md @@ -0,0 +1,838 @@ +# 小红书 MCP HTTP API 文档 + +## 概述 + +该项目提供了小红书 MCP (Model Context Protocol) 服务的 HTTP API 接口,同时支持 MCP 协议和标准的 HTTP REST API。本文档描述了 HTTP API 的使用方法。 + +**Base URL**: `http://localhost:18060` + +**注意**: 以下响应示例仅展示主要字段结构,完整的字段信息请通过实际API调用查看。 + +## 通用响应格式 + +所有 API 响应都使用统一的 JSON 格式: + +### 成功响应 +```json +{ + "success": true, + "data": {}, + "message": "操作成功消息" +} +``` + +### 错误响应 +```json +{ + "error": "错误消息", + "code": "ERROR_CODE", + "details": "详细错误信息" +} +``` + +## API 端点一览 + +| 方法 | 端点 | 描述 | +|------|------|------| +| GET | `/health` | 健康检查 | +| GET | `/api/v1/login/status` | 检查登录状态 | +| GET | `/api/v1/login/qrcode` | 获取登录二维码 | +| DELETE | `/api/v1/login/cookies` | 删除 Cookies(重置登录) | +| POST | `/api/v1/publish` | 发布图文内容 | +| POST | `/api/v1/publish_video` | 发布视频内容 | +| GET | `/api/v1/feeds/list` | 获取 Feeds 列表 | +| GET/POST | `/api/v1/feeds/search` | 搜索 Feeds | +| POST | `/api/v1/feeds/detail` | 获取 Feed 详情 | +| POST | `/api/v1/user/profile` | 获取用户主页信息 | +| GET | `/api/v1/user/me` | 获取当前登录用户信息 | +| POST | `/api/v1/feeds/comment` | 发表评论 | +| POST | `/api/v1/feeds/comment/reply` | 回复评论 | + +--- + +## API 端点 + +### 1. 健康检查 + +检查服务状态。 + +**请求** +``` +GET /health +``` + +**响应** +```json +{ + "success": true, + "data": { + "status": "healthy", + "service": "xiaohongshu-mcp", + "account": "ai-report", + "timestamp": "now" + }, + "message": "服务正常" +} +``` + +--- + +### 2. 登录管理 + +#### 2.1 检查登录状态 + +检查当前用户的登录状态。 + +**请求** +``` +GET /api/v1/login/status +``` + +**响应** +```json +{ + "success": true, + "data": { + "is_logged_in": true, + "username": "用户名" + }, + "message": "检查登录状态成功" +} +``` + +#### 2.2 获取登录二维码 + +获取登录二维码,用于用户扫码登录。 + +**请求** +``` +GET /api/v1/login/qrcode +``` + +**响应** +```json +{ + "success": true, + "data": { + "timeout": "300", + "is_logged_in": false, + "img": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..." + }, + "message": "获取登录二维码成功" +} +``` + +**响应字段说明:** +- `timeout`: 二维码过期时间(秒) +- `is_logged_in`: 当前是否已登录 +- `img`: Base64 编码的二维码图片 + +#### 2.3 删除 Cookies(重置登录状态) + +删除本地存储的 cookies 文件,重置登录状态。 + +**请求** +``` +DELETE /api/v1/login/cookies +``` + +**响应** +```json +{ + "success": true, + "data": { + "cookie_path": "/path/to/cookies.json", + "message": "Cookies 已成功删除,登录状态已重置。下次操作时需要重新登录。" + }, + "message": "删除 cookies 成功" +} +``` + +--- + +### 3. 内容发布 + +#### 3.1 发布图文内容 + +发布图文笔记内容到小红书。 + +**请求** +``` +POST /api/v1/publish +Content-Type: application/json +``` + +**请求体** +```json +{ + "title": "笔记标题", + "content": "笔记内容", + "images": [ + "http://example.com/image1.jpg", + "http://example.com/image2.jpg" + ], + "tags": ["标签1", "标签2"] +} +``` + +**请求参数说明:** +- `title` (string, required): 笔记标题 +- `content` (string, required): 笔记内容 +- `images` (array, required): 图片URL数组,至少包含一张图片 +- `tags` (array, optional): 标签数组 + +**响应** +```json +{ + "success": true, + "data": { + "title": "笔记标题", + "content": "笔记内容", + "images": 2, + "status": "published", + "post_id": "64f1a2b3c4d5e6f7a8b9c0d1" + }, + "message": "发布成功" +} +``` + +#### 3.2 发布视频内容 + +发布视频内容到小红书(仅支持本地视频文件)。 + +**请求** +``` +POST /api/v1/publish_video +Content-Type: application/json +``` + +**请求体** +```json +{ + "title": "视频标题", + "content": "视频内容描述", + "video": "/Users/username/Videos/video.mp4", + "tags": ["标签1", "标签2"] +} +``` + +**请求参数说明:** +- `title` (string, required): 视频标题 +- `content` (string, required): 视频内容描述 +- `video` (string, required): 本地视频文件绝对路径 +- `tags` (array, optional): 标签数组 + +**响应** +```json +{ + "success": true, + "data": { + "title": "视频标题", + "content": "视频内容描述", + "video": "/Users/username/Videos/video.mp4", + "status": "发布完成", + "post_id": "64f1a2b3c4d5e6f7a8b9c0d1" + }, + "message": "视频发布成功" +} +``` + +**注意事项:** +- 仅支持本地视频文件路径,不支持 HTTP 链接 +- 视频处理时间较长,请耐心等待 +- 建议视频文件大小不超过 1GB + +--- + +### 4. Feed 管理 + +#### 4.1 获取 Feeds 列表 + +获取用户的 Feeds 列表。 + +**请求** +``` +GET /api/v1/feeds/list +``` + +**响应** +```json +{ + "success": true, + "data": { + "feeds": [ + { + "xsecToken": "security_token_value", + "id": "feed_id_1", + "modelType": "note", + "noteCard": { + "type": "normal", + "displayTitle": "笔记标题", + "user": { + "userId": "user_id_1", + "nickname": "用户昵称", + "nickName": "用户昵称", + "avatar": "https://example.com/avatar.jpg" + }, + "interactInfo": { + "liked": false, + "likedCount": "100", + "collected": false, + "collectedCount": "50", + "commentCount": "30", + "sharedCount": "10" + }, + "cover": { + "width": 1080, + "height": 1440, + "url": "https://example.com/cover.jpg", + "urlDefault": "https://example.com/cover_default.jpg", + "urlPre": "https://example.com/cover_pre.jpg", + "fileId": "file_id", + "infoList": [ + { + "imageScene": "WB_DFT", + "url": "https://example.com/image.jpg" + } + ] + }, + "video": { + "capa": { + "duration": 60 + } + } + }, + "index": 0 + } + ], + "count": 10 + }, + "message": "获取Feeds列表成功" +} +``` + +**响应字段说明:** +- `xsecToken`: 安全令牌,调用详情等接口时需要 +- `id`: Feed ID +- `modelType`: 模型类型,通常为 "note" +- `noteCard.type`: 笔记类型 +- `noteCard.video`: 视频信息(仅视频笔记有此字段) + - `capa.duration`: 视频时长(秒) +- `noteCard.interactInfo`: 互动信息 + - `liked`: 当前用户是否已点赞 + - `collected`: 当前用户是否已收藏 + - `likedCount`: 点赞数 + - `collectedCount`: 收藏数 + - `commentCount`: 评论数 + - `sharedCount`: 分享数 +``` + +#### 4.2 搜索 Feeds + +根据关键词搜索 Feeds,支持 GET 和 POST 两种请求方式。 + +**请求方式一:GET** +``` +GET /api/v1/feeds/search?keyword=搜索关键词 +``` + +**查询参数:** +- `keyword` (string, required): 搜索关键词 + +**请求方式二:POST(支持高级筛选)** +``` +POST /api/v1/feeds/search +Content-Type: application/json +``` + +**请求体** +```json +{ + "keyword": "搜索关键词", + "filters": { + "sort_by": "综合", + "note_type": "不限", + "publish_time": "不限", + "search_scope": "不限", + "location": "不限" + } +} +``` + +**筛选参数说明:** +- `sort_by` (string, optional): 排序依据,可选值:`综合`(默认) | `最新` | `最多点赞` | `最多评论` | `最多收藏` +- `note_type` (string, optional): 笔记类型,可选值:`不限`(默认) | `视频` | `图文` +- `publish_time` (string, optional): 发布时间,可选值:`不限`(默认) | `一天内` | `一周内` | `半年内` +- `search_scope` (string, optional): 搜索范围,可选值:`不限`(默认) | `已看过` | `未看过` | `已关注` +- `location` (string, optional): 位置距离,可选值:`不限`(默认) | `同城` | `附近` + +**响应** +```json +{ + "success": true, + "data": { + "feeds": [ + { + "xsecToken": "security_token_value", + "id": "feed_id_1", + "modelType": "note", + "noteCard": { + "type": "normal", + "displayTitle": "相关笔记标题", + "user": { + "userId": "user_id_1", + "nickname": "用户昵称", + "avatar": "https://example.com/avatar.jpg" + }, + "interactInfo": { + "liked": false, + "likedCount": "80", + "collected": false, + "collectedCount": "40", + "commentCount": "35", + "sharedCount": "15" + }, + "cover": { + "width": 1080, + "height": 1440, + "url": "https://example.com/cover.jpg", + "urlDefault": "https://example.com/cover_default.jpg" + }, + "video": null + }, + "index": 0 + } + ], + "count": 5 + }, + "message": "搜索Feeds成功" +} +``` + +**响应字段说明:** +- 响应结构与"获取 Feeds 列表"接口相同 +- `video`: 视频笔记时有此字段,图文笔记为 null +``` + +#### 4.3 获取 Feed 详情 + +获取指定 Feed 的详细信息,支持加载全部评论和自定义评论加载配置。 + +**请求** +``` +POST /api/v1/feeds/detail +Content-Type: application/json +``` + +**请求体** +```json +{ + "feed_id": "64f1a2b3c4d5e6f7a8b9c0d1", + "xsec_token": "security_token_here", + "load_all_comments": false, + "comment_config": { + "click_more_replies": true, + "max_replies_threshold": 50, + "max_comment_items": 100, + "scroll_speed": "normal" + } +} +``` + +**请求参数说明:** +- `feed_id` (string, required): Feed ID +- `xsec_token` (string, required): 安全令牌 +- `load_all_comments` (boolean, optional): 是否加载全部评论,默认 false +- `comment_config` (object, optional): 评论加载配置 + - `click_more_replies` (boolean): 是否点击"更多回复"按钮 + - `max_replies_threshold` (int): 回复数量阈值,超过这个数量的"更多"按钮将被跳过(0表示不跳过任何) + - `max_comment_items` (int): 最大加载评论数(.parent-comment 数量),0表示加载所有 + - `scroll_speed` (string): 滚动速度等级,可选值:`slow`(慢速) | `normal`(正常) | `fast`(快速) + +**响应** +```json +{ + "success": true, + "data": { + "feed_id": "64f1a2b3c4d5e6f7a8b9c0d1", + "data": { + "note": { + "noteId": "64f1a2b3c4d5e6f7a8b9c0d1", + "xsecToken": "security_token_value", + "title": "笔记标题", + "desc": "笔记详细内容描述", + "type": "normal", + "time": 1702195200000, + "ipLocation": "浙江", + "user": { + "userId": "user_id_123", + "nickname": "作者昵称", + "nickName": "作者昵称", + "avatar": "https://example.com/avatar.jpg" + }, + "interactInfo": { + "liked": false, + "likedCount": "100", + "collected": false, + "collectedCount": "80", + "commentCount": "50", + "sharedCount": "20" + }, + "imageList": [ + { + "width": 1080, + "height": 1440, + "urlDefault": "https://example.com/image1_default.jpg", + "urlPre": "https://example.com/image1_pre.jpg", + "livePhoto": false + } + ] + }, + "comments": { + "list": [ + { + "id": "comment_id_1", + "noteId": "64f1a2b3c4d5e6f7a8b9c0d1", + "content": "评论内容", + "likeCount": "10", + "createTime": 1702195200000, + "ipLocation": "北京", + "liked": false, + "userInfo": { + "userId": "commenter_id", + "nickname": "评论者昵称", + "avatar": "https://example.com/commenter_avatar.jpg" + }, + "subCommentCount": "5", + "subComments": [ + { + "id": "sub_comment_id_1", + "content": "子评论内容", + "createTime": 1702195300000, + "userInfo": { + "nickname": "回复者昵称" + } + } + ], + "showTags": ["热评"] + } + ], + "cursor": "next_cursor_value", + "hasMore": true + } + } + }, + "message": "获取Feed详情成功" +} +``` + +**响应字段说明:** +- `note.time`: 笔记发布时间戳(毫秒) +- `note.ipLocation`: 发布者 IP 归属地 +- `note.type`: 笔记类型 +- `note.interactInfo`: 互动信息 + - `liked`: 当前用户是否已点赞 + - `collected`: 当前用户是否已收藏 +- `note.imageList[].livePhoto`: 是否为 Live Photo +- `comments.list[].createTime`: 评论发布时间戳(毫秒) +- `comments.list[].ipLocation`: 评论者 IP 归属地 +- `comments.list[].likeCount`: 评论点赞数 +- `comments.list[].liked`: 当前用户是否已点赞该评论 +- `comments.list[].subCommentCount`: 子评论数量 +- `comments.list[].subComments`: 子评论列表 +- `comments.list[].showTags`: 显示标签(如 "热评") +- `comments.cursor`: 分页游标 +- `comments.hasMore`: 是否有更多评论 +``` + +--- + +### 5. 用户信息 + +#### 5.1 获取用户主页信息 + +获取指定用户的主页信息,包括基本信息、互动数据和发布的笔记列表。 + +**请求** +``` +POST /api/v1/user/profile +Content-Type: application/json +``` + +**请求体** +```json +{ + "user_id": "64f1a2b3c4d5e6f7a8b9c0d1", + "xsec_token": "security_token_here" +} +``` + +**请求参数说明:** +- `user_id` (string, required): 用户ID +- `xsec_token` (string, required): 安全令牌 + +**响应** +```json +{ + "success": true, + "data": { + "data": { + "userBasicInfo": { + "nickname": "用户昵称", + "desc": "用户个人描述", + "redId": "xiaohongshu_id", + "gender": 1, + "ipLocation": "浙江", + "images": "https://example.com/avatar.jpg", + "imageb": "https://example.com/background.jpg" + }, + "interactions": [ + { + "type": "follows", + "name": "关注", + "count": "1000" + }, + { + "type": "fans", + "name": "粉丝", + "count": "5000" + }, + { + "type": "interaction", + "name": "获赞与收藏", + "count": "10000" + } + ], + "feeds": [ + { + "xsecToken": "security_token_value", + "id": "feed_id_1", + "modelType": "note", + "noteCard": { + "displayTitle": "用户的笔记标题", + "interactInfo": { + "likedCount": "100", + "collectedCount": "50" + } + }, + "index": 0 + } + ] + } + }, + "message": "获取用户主页成功" +} +``` + +**响应字段说明:** +- `userBasicInfo.gender`: 性别(1: 男, 2: 女, 0: 未知) +- `userBasicInfo.ipLocation`: IP 归属地 +- `userBasicInfo.images`: 头像图片 URL +- `userBasicInfo.imageb`: 背景图片 URL +- `userBasicInfo.redId`: 小红书号 +- `interactions`: 互动数据数组 + - `type`: 类型(follows: 关注, fans: 粉丝, interaction: 获赞与收藏) + - `name`: 显示名称 + - `count`: 数量 +- `feeds`: 用户发布的笔记列表(结构同 Feed 列表) +``` + +#### 5.2 获取当前登录用户信息 + +获取当前登录用户的个人信息(无需传入 user_id),通过侧边栏导航到个人主页获取。 + +**请求** +``` +GET /api/v1/user/me +``` + +**响应** +```json +{ + "success": true, + "data": { + "data": { + "userBasicInfo": { + "nickname": "当前用户昵称", + "desc": "个人描述", + "redId": "xiaohongshu_id", + "gender": 1, + "ipLocation": "浙江", + "images": "https://example.com/my_avatar.jpg", + "imageb": "https://example.com/my_background.jpg" + }, + "interactions": [ + { + "type": "follows", + "name": "关注", + "count": "100" + }, + { + "type": "fans", + "name": "粉丝", + "count": "500" + }, + { + "type": "interaction", + "name": "获赞与收藏", + "count": "2000" + } + ], + "feeds": [ + { + "xsecToken": "security_token_value", + "id": "feed_id_1", + "modelType": "note", + "noteCard": { + "displayTitle": "我的笔记标题", + "interactInfo": { + "likedCount": "50", + "collectedCount": "30" + } + }, + "index": 0 + } + ] + } + }, + "message": "获取我的主页成功" +} +``` + +**响应字段说明:** +- 响应结构与"获取用户主页信息"接口相同 +- 此接口无需 `user_id` 和 `xsec_token` 参数,自动获取当前登录用户信息 +``` + +--- + +### 6. 评论管理 + +#### 6.1 发表评论 + +对指定 Feed 发表评论。 + +**请求** +``` +POST /api/v1/feeds/comment +Content-Type: application/json +``` + +**请求体** +```json +{ + "feed_id": "64f1a2b3c4d5e6f7a8b9c0d1", + "xsec_token": "security_token_here", + "content": "评论内容" +} +``` + +**请求参数说明:** +- `feed_id` (string, required): Feed ID +- `xsec_token` (string, required): 安全令牌 +- `content` (string, required): 评论内容 + +**响应** +```json +{ + "success": true, + "data": { + "feed_id": "64f1a2b3c4d5e6f7a8b9c0d1", + "success": true, + "message": "评论发表成功" + }, + "message": "评论发表成功" +} +``` + +#### 6.2 回复评论 + +回复指定评论。 + +**请求** +``` +POST /api/v1/feeds/comment/reply +Content-Type: application/json +``` + +**请求体** +```json +{ + "feed_id": "64f1a2b3c4d5e6f7a8b9c0d1", + "xsec_token": "security_token_here", + "comment_id": "comment_id_to_reply", + "user_id": "target_user_id", + "content": "回复内容" +} +``` + +**请求参数说明:** +- `feed_id` (string, required): Feed ID +- `xsec_token` (string, required): 安全令牌 +- `comment_id` (string, required*): 要回复的评论 ID(与 user_id 二选一必填) +- `user_id` (string, required*): 要回复的用户 ID(与 comment_id 二选一必填) +- `content` (string, required): 回复内容 + +**响应** +```json +{ + "success": true, + "data": { + "feed_id": "64f1a2b3c4d5e6f7a8b9c0d1", + "target_comment_id": "comment_id_to_reply", + "target_user_id": "target_user_id", + "success": true, + "message": "回复评论成功" + }, + "message": "回复评论成功" +} +``` + +--- + +## 错误代码 + +所有 API 在发生错误时会返回统一格式的错误响应。以下是可能出现的错误代码: + +| 错误代码 | HTTP 状态码 | 描述 | +|----------|-------------|------| +| `INVALID_REQUEST` | 400 | 请求参数错误或格式不正确 | +| `MISSING_KEYWORD` | 400 | 搜索时缺少关键词参数 | +| `STATUS_CHECK_FAILED` | 500 | 检查登录状态失败 | +| `DELETE_COOKIES_FAILED` | 500 | 删除 Cookies 失败 | +| `PUBLISH_FAILED` | 500 | 发布图文内容失败 | +| `PUBLISH_VIDEO_FAILED` | 500 | 发布视频内容失败 | +| `LIST_FEEDS_FAILED` | 500 | 获取 Feeds 列表失败 | +| `SEARCH_FEEDS_FAILED` | 500 | 搜索 Feeds 失败 | +| `GET_FEED_DETAIL_FAILED` | 500 | 获取 Feed 详情失败 | +| `GET_USER_PROFILE_FAILED` | 500 | 获取用户主页信息失败 | +| `GET_MY_PROFILE_FAILED` | 500 | 获取当前用户信息失败 | +| `POST_COMMENT_FAILED` | 500 | 发表评论失败 | +| `REPLY_COMMENT_FAILED` | 500 | 回复评论失败 | +| `INTERNAL_ERROR` | 500 | 服务器内部错误 | + +--- + +## 注意事项 + +1. **认证**: 部分 API 需要有效的登录状态,建议先调用登录状态检查接口确认登录。 + +2. **安全令牌**: `xsec_token` 是小红书的安全令牌,在调用需要该参数的接口时必须提供。 + +3. **图片上传**: 发布接口中的 `images` 参数需要提供可访问的图片URL。 + +4. **错误处理**: 所有接口在出错时都会返回统一格式的错误响应,请根据 `code` 字段进行相应的错误处理。 + +5. **日志记录**: 所有API调用都会被记录到服务日志中,包括请求方法、路径和状态码。 + +6. **跨域支持**: API 支持跨域请求 (CORS)。 + +## MCP 协议支持 + +除了上述HTTP API,本服务同时支持 MCP (Model Context Protocol) 协议: + +- **MCP 端点**: `/mcp` 和 `/mcp/*path` +- **协议类型**: 支持 JSON 响应格式的 Streamable HTTP +- **用途**: 可以通过MCP客户端调用相同的功能 + +更多MCP协议相关信息请参考 [Model Context Protocol 官方文档](https://modelcontextprotocol.io/)。 \ No newline at end of file diff --git a/xiaohongshu-mcp-main/docs/windows_guide.md b/xiaohongshu-mcp-main/docs/windows_guide.md new file mode 100644 index 0000000..3cf8908 --- /dev/null +++ b/xiaohongshu-mcp-main/docs/windows_guide.md @@ -0,0 +1,136 @@ +# Windows 安装指南(避免环境变量问题) + +在 Windows 部署过程,如果遇到问题,那么可以先参考本手册。 + +可以参考这里 https://github.com/xpzouying/xiaohongshu-mcp/issues/56 + +由于 xiaohongshu-mcp 采用的是 Go,NPX 则依赖 Node.JS。为了*避免后续遇到的环境变量等问题*,建议使用 Winget 来安装 Go 和 Node.JS,因为使用 Winget 安装后,Windows 会自动配置好对应的环境变量。 + +## 打开命令行 +打开命令行 + +1. Windows 搜索框中输入 CMD +2. 选择以管理员身份运行 + +## 安装 Go +在*命令行*中使用以下命令安装 Go (截图如下) +安装 Go + +```bash + winget install GoLang.Go +``` + +## 安装 Node.JS +继续在*命令行*中使用以下命令安装 Node.JS (截图如下) +安装 Node.JS + + +```bash + winget install OpenJS.NodeJS.LTS +``` + +祝大家使用 xiaohongshu-mcp 服务愉快哦~ + +# xiaohongshu-mcp Windows11快速搭建 + +## 1.  下载最新构建版本 + +[github.com](https://github.com/xpzouying/xiaohongshu-mcp/releases) + +如果当前系统为Windows 则选择 xiaohongshu-mcp-windows-amd64.zip 下载 + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_597379_Dw_WBLdYI-KsFlXm_1760067122?w=1137&h=633&type=image/png) + +下载完解压文件 + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_806026_wozodlNLyXADgJzQ_1760067150?w=1097&h=437&type=image/png) + +在当前文件夹中右键打开终端 + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_24479_igFOK7Lf332tlvkM_1760067218?w=1090&h=622&type=image/png) + +先运行登录命令程序 + +``` +./xiaohongshu-login-windows-amd64.exe +``` + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_557435_MEWWz-JeHubKmkhc_1760067518?w=1709&h=810&type=image/png) + +等待下载完 + +## 2.  解决Windows 11 报病毒问题 + +在运行之前的程序后会报病毒,如下图 + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_79147_lDOh7CnkzJEWiROM_1760067634?w=1761&h=518&type=image/png) + +这时候我们需要打开Windows 安全中心(Windows 11 版本演示) + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_436678__HrwxQPD57zZvW5h_1760067781?w=1424&h=932&type=image/png) + +点击进入管理设置后,查看最下方的排除项 + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_936924_6OPZpwjyICV7NlGc_1760067974?w=1166&h=916&type=image/png) + +把之前的错误程序的路径添加进去,如下图 + +要改成你当前报错的实际路径 + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_871687_NBwGzTWJ1RHTQgBQ_1760068159?w=1901&h=439&type=image/png) + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_710523_eExonqwWf2gSc5RD_1760068191?w=1838&h=658&type=image/png) + +总结解决路径办法 + +解决步骤: + +1. 打开 Windows 安全中心(Windows Security)。 + +2. 点击 病毒和威胁防护(Virus & threat protection)。 + +3. 在“病毒和威胁防护设置”下,点击 管理设置(Manage settings)。 + +4. 向下滚动,找到并点击 添加或删除排除项(Add or remove exclusions)。 + +5. 点击 添加排除项(Add an exclusion)。 + +6. 选择 文件夹(Folder)。 + +7. 导航到以下路径并选择该文件夹: + +``` +C:\Users\你的用户(当前电脑)\AppData\Local\Temp\leakless-amd64-adb80298fa6a3af7ced8b1c9b5f18007 +``` + +8.  . 确认添加排除项。 + +## 3.  启动程序 + +``` +./xiaohongshu-login-windows-amd64.exe +``` + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_986235_Vn-u3F7LZXOsYE6c_1760078263?w=1118&h=346&type=image/png) + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_215347_jIpS7bT7J6nQPIDs_1760078324?w=901&h=830&type=image/png) + +登录小红书 + +启动MCP服务 + +``` +./xiaohongshu-mcp-windows-amd64.exe +``` + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_66988_0r6LHv0FuL9Aidlv_1760094345?w=970&h=291&type=image/png) + +## 4.  MCP 验证 + +``` +npx @modelcontextprotocol/inspector +``` + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_861647_Lo0xw1oXyLKD5A2Y_1760165693?w=1074&h=452&type=image/png) + +![](https://wdcdn.qpic.cn/MTY4ODg1NTIyMTY1ODI2NQ_260079_5FFeEfMTVXaLGXoz_1760165797?w=1905&h=937&type=image/png) \ No newline at end of file diff --git a/xiaohongshu-mcp-main/donate/wechat@2x.png b/xiaohongshu-mcp-main/donate/wechat@2x.png new file mode 100644 index 0000000..6ec5fed Binary files /dev/null and b/xiaohongshu-mcp-main/donate/wechat@2x.png differ diff --git a/xiaohongshu-mcp-main/errors/errors.go b/xiaohongshu-mcp-main/errors/errors.go new file mode 100644 index 0000000..54869ee --- /dev/null +++ b/xiaohongshu-mcp-main/errors/errors.go @@ -0,0 +1,6 @@ +package errors + +import "errors" + +var ErrNoFeeds = errors.New("没有捕获到 feeds 数据") +var ErrNoFeedDetail = errors.New("没有捕获到 feed 详情数据") diff --git a/xiaohongshu-mcp-main/examples/README.md b/xiaohongshu-mcp-main/examples/README.md new file mode 100644 index 0000000..f52a0c0 --- /dev/null +++ b/xiaohongshu-mcp-main/examples/README.md @@ -0,0 +1,5 @@ +# 示例 + +单独创建目录,用于存放示例说明。 + +提交 PR 后会自动展示在首页 README 的贡献者名单中。 diff --git a/xiaohongshu-mcp-main/examples/anythingLLM/images/agent-workflow-config.png b/xiaohongshu-mcp-main/examples/anythingLLM/images/agent-workflow-config.png new file mode 100644 index 0000000..6a9832f Binary files /dev/null and b/xiaohongshu-mcp-main/examples/anythingLLM/images/agent-workflow-config.png differ diff --git a/xiaohongshu-mcp-main/examples/anythingLLM/images/anythingllm-install.png b/xiaohongshu-mcp-main/examples/anythingLLM/images/anythingllm-install.png new file mode 100644 index 0000000..ae59995 Binary files /dev/null and b/xiaohongshu-mcp-main/examples/anythingLLM/images/anythingllm-install.png differ diff --git a/xiaohongshu-mcp-main/examples/anythingLLM/images/direct-mcp-call.png b/xiaohongshu-mcp-main/examples/anythingLLM/images/direct-mcp-call.png new file mode 100644 index 0000000..034eca3 Binary files /dev/null and b/xiaohongshu-mcp-main/examples/anythingLLM/images/direct-mcp-call.png differ diff --git a/xiaohongshu-mcp-main/examples/anythingLLM/images/mcp-server-config.png b/xiaohongshu-mcp-main/examples/anythingLLM/images/mcp-server-config.png new file mode 100644 index 0000000..d415613 Binary files /dev/null and b/xiaohongshu-mcp-main/examples/anythingLLM/images/mcp-server-config.png differ diff --git a/xiaohongshu-mcp-main/examples/anythingLLM/images/workflow-execution-process.png b/xiaohongshu-mcp-main/examples/anythingLLM/images/workflow-execution-process.png new file mode 100644 index 0000000..078b643 Binary files /dev/null and b/xiaohongshu-mcp-main/examples/anythingLLM/images/workflow-execution-process.png differ diff --git a/xiaohongshu-mcp-main/examples/anythingLLM/images/workflow-execution-results.png b/xiaohongshu-mcp-main/examples/anythingLLM/images/workflow-execution-results.png new file mode 100644 index 0000000..ab20fd4 Binary files /dev/null and b/xiaohongshu-mcp-main/examples/anythingLLM/images/workflow-execution-results.png differ diff --git a/xiaohongshu-mcp-main/examples/anythingLLM/readme.md b/xiaohongshu-mcp-main/examples/anythingLLM/readme.md new file mode 100644 index 0000000..6358300 --- /dev/null +++ b/xiaohongshu-mcp-main/examples/anythingLLM/readme.md @@ -0,0 +1,123 @@ +# AnythingLLM 接入 xiaohongshu-mcp 完整指南 + +## 📋 概述 + +AnythingLLM 是一款all-in-one 多模态 AI 客户端,支持**workflow**定义,支持多种大模型和插件扩展。通过 AnythingLLM 调用 **xiaohongshu-mcp** 服务,可以直接在对话中调用小红书相关功能,实现自动化的内容创作与发布。 + +### ✅ 该工具链优势 + +- 支持 **本地笔记 → 润色 → 批量发布**,适合内容创作者账号日常运营 +- 相比于Claude Code节省token;支持免费开源模型 + +## 🚀 AnythingLLM 安装 + +下载 AnythingLLM 桌面端 👉 [下载地址](https://anythingllm.com/desktop) + +![AnythingLLM 安装界面](images/anythingllm-install.png) + +## 🔌 配置 xiaohongshu-mcp 服务 + +### 步骤 1:启动 xiaohongshu-mcp 服务 + +### 1.1 登录小红书账号 + +第一次使用需要手动登录,保存小红书的登录状态: + +```bash +# 登录小红书账号 +go run cmd/login/main.go +``` + +### 1.2 启动 MCP 服务 + +登录成功后,启动 xiaohongshu-mcp 服务: + +```bash +# 默认:无头模式,没有浏览器界面 +go run . + +# 或者:非无头模式,有浏览器界面(调试时使用) +go run . -headless=false +``` + +### 步骤 2:在 AnythingLLM 中添加 MCP 服务器(修改配置文件) + +### 2.1 定位配置文件 + +当第一次打开 **Agent Skills 页面** 时,AnythingLLM 会在 `storage` 目录下自动生成 MCP 配置文件(如果不存在的话)。 + +macOS(Desktop)的路径: + +``` +~/Library/Application\ Support/anythingllm-desktop/storage/plugins/anythingllm_mcp_servers.json +``` + +### 2.2 编辑配置文件 + +在 `anythingllm_mcp_servers.json` 中添加以下内容: + +```json +{ + "mcpServers": { + "xiaohongshu-mcp": { + "type": "streamable", + "url": "http://127.0.0.1:18060/mcp" + } + } +} +``` + +### 2.3 刷新加载 + +1. 保存文件 +2. 回到 AnythingLLM 的 **Agent Skills 页面** +3. 点击右上角 **Refresh** 按钮 + +此时能看到 `xiaohongshu-mcp` 出现在列表中。 + +![MCP 服务器配置成功](images/mcp-server-config.png) + +## 🎯 使用指南 + +### 方法一:直接对话中调用 MCP 工具 + +1. 创建新对话 +2. 在对话中输入 `@agent`,并调用 `xiaohongshu-mcp` +3. 通过自然语言直接指令,例如: + +``` +@agent 使用xiaohongshu-mcp 检查登录状态 +``` + +![直接调用 MCP 工具](images/direct-mcp-call.png) + +--- + +### 方法二:Agent Workflow 自动化发布本地笔记 + +![Agent Workflow 配置](images/agent-workflow-config.png) + +1. 新建 Agent flow,命名为 `publish_notes` +2. 设置 **Flow Variables**,包括本地文件路径(如 `file_path`)和 `notes` 内容 +3. 使用 **Read File** 块,读取本地笔记文件,存入 `notes` 变量 +4. 在 **LLM Instruction** 块写入逻辑: + + ``` + 多篇笔记原文为 ${notes} + 请使用xiaohongshu-mcp依次发布笔记。 + ``` + + +5. 在对话中输入 `@agent`调用 workflow,实现「本地笔记 → 自动发布」闭环 + +| Workflow 设置过程 | Workflow 调用结果 | +| --- | --- | +| Workflow 执行过程 | Workflow 执行结果 | + + +更多功能,参考官方docs:https://docs.anythingllm.com/agent-flows/overview + +## ✅ 总结 + +通过以上步骤,您就能在 AnythingLLM 中成功接入并使用 **xiaohongshu-mcp** 服务,实现 **本地笔记 → 润色 → 自动化发布到小红书** 的完整闭环工作流 🚀 + diff --git a/xiaohongshu-mcp-main/examples/cherrystudio/README.md b/xiaohongshu-mcp-main/examples/cherrystudio/README.md new file mode 100644 index 0000000..0b983de --- /dev/null +++ b/xiaohongshu-mcp-main/examples/cherrystudio/README.md @@ -0,0 +1,99 @@ +# Cherry Studio 接入 xiaohongshu-mcp 完整指南 + +## 📋 概述 + +Cherry Studio 是目前最热门的 AI 客户端之一,它简单易用且支持多种开源和闭源大模型。 + +通过 Cherry Studio 调用我们的 xiaohongshu-mcp 服务,您可以使用免费的开源大模型,无需 API key,无需复杂的配置文件,轻松实现小红书内容创作和发布功能。 + +## 🚀 Cherry Studio 安装 + +访问 [Cherry Studio 下载页面](https://www.cherry-ai.com/download) 下载适合您操作系统的安装包,按照提示安装即可。 + +![Cherry Studio 下载页面](./images/cherrystudio-install.png) + + +## 🔌 配置 xiaohongshu-mcp 服务 + +### 步骤 1:启动 xiaohongshu-mcp 服务 + +#### 1.1 登录小红书账号 + +第一次使用需要手动登录,保存小红书的登录状态: + +```bash +# 登录小红书账号 +go run cmd/login/main.go +``` + +#### 1.2 启动 MCP 服务 + +登录成功后,启动 xiaohongshu-mcp 服务: + +```bash +# 默认:无头模式,没有浏览器界面 +go run . + +# 或者:非无头模式,有浏览器界面(调试时使用) +go run . -headless=false +``` + +### 步骤 2:在 Cherry Studio 中添加 MCP 服务器 + +1. **打开 Cherry Studio 设置并添加 MCP 服务器** + - 点击右上角齿轮图标进入设置 + - 选择 "MCP" 标签页 + - 点击 "添加" 按钮 + - 点击 "快速创建" 按钮 + +![cherry-studio-settings](./images/cherrystudio-settings.png) + +2. **配置新的 MCP 服务器** + - 配置以下信息: + * 名称: xiaohongshu-mcp + * 类型: streamableHttp + * URL: http://localhost:18060/mcp + - 点击 "保存" 按钮 + - 点击启用开关 + +![cherry-studio-config](./images/cherrystudio-config.png) + +3. **测试连接** + - 在上一步的配置页面点击 "工具" 按钮 + - 如果链接成功,可以看到所有可用的工具,并且可以选择启用哪些工具 + +![cherry-studio-tools](./images/cherrystudio-tools.png) + +## 🎯 使用指南 + +### 创建新对话并在对话中启用我们的 MCP 工具 + +- 返回首页,点击 "添加助手" +- 选择模型,这里默认使用开源的 GLM-4.5-Flash 模型 +- 点击对话框下的工具 icon,勾选 xiaohongshu-mcp + +![cherry-studio-conversation](./images/cherrystudio-conversation.png) + +### 通过对话使用 MCP 工具 + +Cherry Studio 配合 xiaohongshu-mcp 可以实现多种智能功能: + +* 检查登录状态 + +![cherry-studio-use-1](./images/use-1.png) + +* 小红书站内搜索 + +![cherry-studio-use-2](./images/use-2.png) + +* 发布图文内容 + +![cherry-studio-use-3](./images/use-3.png) + +* 发布成功 + +![cherry-studio-use-4](./images/use-4.png) + +--- + +通过以上配置,您可以在 Cherry Studio 中高效地使用 xiaohongshu-mcp 服务,实现智能化的小红书内容创作和管理! \ No newline at end of file diff --git a/xiaohongshu-mcp-main/examples/cherrystudio/images/cherrystudio-config.png b/xiaohongshu-mcp-main/examples/cherrystudio/images/cherrystudio-config.png new file mode 100644 index 0000000..f3e1c35 Binary files /dev/null and b/xiaohongshu-mcp-main/examples/cherrystudio/images/cherrystudio-config.png differ diff --git a/xiaohongshu-mcp-main/examples/cherrystudio/images/cherrystudio-conversation.png b/xiaohongshu-mcp-main/examples/cherrystudio/images/cherrystudio-conversation.png new file mode 100644 index 0000000..4f8c9d9 Binary files /dev/null and b/xiaohongshu-mcp-main/examples/cherrystudio/images/cherrystudio-conversation.png differ diff --git a/xiaohongshu-mcp-main/examples/cherrystudio/images/cherrystudio-install.png b/xiaohongshu-mcp-main/examples/cherrystudio/images/cherrystudio-install.png new file mode 100644 index 0000000..742708d Binary files /dev/null and b/xiaohongshu-mcp-main/examples/cherrystudio/images/cherrystudio-install.png differ diff --git a/xiaohongshu-mcp-main/examples/cherrystudio/images/cherrystudio-settings.png b/xiaohongshu-mcp-main/examples/cherrystudio/images/cherrystudio-settings.png new file mode 100644 index 0000000..0a8a25f Binary files /dev/null and b/xiaohongshu-mcp-main/examples/cherrystudio/images/cherrystudio-settings.png differ diff --git a/xiaohongshu-mcp-main/examples/cherrystudio/images/cherrystudio-tools.png b/xiaohongshu-mcp-main/examples/cherrystudio/images/cherrystudio-tools.png new file mode 100644 index 0000000..4af02d8 Binary files /dev/null and b/xiaohongshu-mcp-main/examples/cherrystudio/images/cherrystudio-tools.png differ diff --git a/xiaohongshu-mcp-main/examples/cherrystudio/images/use-1.png b/xiaohongshu-mcp-main/examples/cherrystudio/images/use-1.png new file mode 100644 index 0000000..6e52bb8 Binary files /dev/null and b/xiaohongshu-mcp-main/examples/cherrystudio/images/use-1.png differ diff --git a/xiaohongshu-mcp-main/examples/cherrystudio/images/use-2.png b/xiaohongshu-mcp-main/examples/cherrystudio/images/use-2.png new file mode 100644 index 0000000..af727bc Binary files /dev/null and b/xiaohongshu-mcp-main/examples/cherrystudio/images/use-2.png differ diff --git a/xiaohongshu-mcp-main/examples/cherrystudio/images/use-3.png b/xiaohongshu-mcp-main/examples/cherrystudio/images/use-3.png new file mode 100644 index 0000000..428518c Binary files /dev/null and b/xiaohongshu-mcp-main/examples/cherrystudio/images/use-3.png differ diff --git a/xiaohongshu-mcp-main/examples/cherrystudio/images/use-4.png b/xiaohongshu-mcp-main/examples/cherrystudio/images/use-4.png new file mode 100644 index 0000000..14c6c09 Binary files /dev/null and b/xiaohongshu-mcp-main/examples/cherrystudio/images/use-4.png differ diff --git a/xiaohongshu-mcp-main/examples/claude-code/claude-code-kimi-k2.md b/xiaohongshu-mcp-main/examples/claude-code/claude-code-kimi-k2.md new file mode 100644 index 0000000..470df84 --- /dev/null +++ b/xiaohongshu-mcp-main/examples/claude-code/claude-code-kimi-k2.md @@ -0,0 +1,66 @@ +# Claude Code With kimi-k2 + +由于 Claude Code 的各种限制,对于普通用户来说门槛太高,不推荐普通用户使用,不过推荐一种替代方案,可以让 Claude Code 接入国内 kimi-k2 的模型,实现同样的功能。使用国内的其他支持 Claude Code 的模型厂商都大同小异,这里以 kimi-k2 为例。 + + +## 1. 申请 API Key。 + +前往Kimi开放平台申请API Key。 + +点击前往:[Kimi开放平台](https://platform.moonshot.cn/) - 点击 [控制台](https://platform.moonshot.cn/console) + +image + +点击进入 [API Key管理],新建一个新的 API Key,保存下来 API Key,后面会用到。 + +image + +## 2. 一键安装 + +直接参考开源项目:[LLM-Red-Team/kimi-cc](https://github.com/LLM-Red-Team/kimi-cc) + +**重点说明:** + +- 准备好上一步骤的 API Key,安装过程中会要求你输出 API Key(隐藏式的) + +一键安装脚本: + +```bash +bash -c "$(curl -fsSL https://raw.githubusercontent.com/LLM-Red-Team/kimi-cc/refs/heads/main/install.sh)" +``` + +安装过程中,会“暂停”要求输入 API Key,直接复制进去,然后回车即可。 + +image + +成功安装后,完成。 + +image + +安装成功后,一定要重启 SHELL 环境或者重新加载对应的环境变量,按照日志中,输入即可。 + +image + +按照提示,输入: + +image + +再次运行 [claude] 后,确认是否是自己输入的 API Key,确认后,选择 YES! + +image + +然后可能会让你确定一些协议,点击 YES 后,正式打开了 Claude Code,不过此时已经为你接上 Kimi-K2 的模型了。 + +image + +注意这里的 API-Key 是 Kimi API Key,API Base URL 是 moonshot.cn 域名下的 URL,表示连接到 Kimi 的 API 了。 + +## 3. 下载 MCP 程序 + +从 [Release](https://github.com/xpzouying/xiaohongshu-mcp/releases) 中下载对应的二进制后启动。(以 Ubuntu 系统为例) + + +## 4. 接入 MCP + +参考 [README 文档 - 接入 MCP 章节](https://github.com/xpzouying/xiaohongshu-mcp/tree/add-claude-code-kimi-k2-examples?tab=readme-ov-file#22-%E6%94%AF%E6%8C%81%E7%9A%84%E5%AE%A2%E6%88%B7%E7%AB%AF) + diff --git a/xiaohongshu-mcp-main/examples/n8n/README.md b/xiaohongshu-mcp-main/examples/n8n/README.md new file mode 100644 index 0000000..aa891b9 --- /dev/null +++ b/xiaohongshu-mcp-main/examples/n8n/README.md @@ -0,0 +1,209 @@ +# N8N 接入 xiaohongshu-mcp 完整指南 + +## 📋 概述 + +本文档详细介绍了如何部署汉化版 n8n 工作流平台,并集成 xiaohongshu-mcp 服务,实现自动化小红书内容发布功能。 + +## 🚀 环境准备 + +### 前置要求 +- Docker 和 Docker Compose 已安装 +- xiaohongshu-mcp 服务已正常启动 +- 有效的 DeepSeek API 密钥 + +## 📦 n8n 部署指南 + +### 1. 下载汉化包 + +前往 [n8n 汉化包项目](https://github.com/other-blowsnow/n8n-i18n-chinese/releases) 下载最新版本的汉化文件。 + +**操作步骤:** +1. 下载最新的汉化包压缩文件 +2. 解压下载的文件 +3. 确保解压后包含 `editor-ui/dist` 文件夹 + +### 2. Docker Compose 部署(推荐) + +创建 `docker-compose.yml` 文件,内容如下: + +```yaml +version: '3' + +services: + n8n: + image: n8nio/n8n + container_name: n8n + restart: unless-stopped + ports: + - "5678:5678" + volumes: + # 运行数据挂载 - 确保工作流数据持久化 + - ./n8n_data:/home/node/.n8n + # 汉化包挂载 - 替换为你的汉化包路径 + - ./editor-ui/dist:/usr/local/lib/node_modules/n8n/node_modules/n8n-editor-ui/dist + environment: + - N8N_HOST=localhost + - N8N_PORT=5678 + - N8N_PROTOCOL=http + # 可选:设置基本认证(增强安全性) + # - N8N_BASIC_AUTH_ACTIVE=true + # - N8N_BASIC_AUTH_USER=myuser + # - N8N_BASIC_AUTH_PASSWORD=mypassword + # 时区设置(亚洲/上海) + - GENERIC_TIMEZONE=Asia/Shanghai + # 调试时禁用安全Cookie(方便本地访问) + - N8N_SECURE_COOKIE=false + # 设置默认语言为简体中文 + - N8N_DEFAULT_LOCALE=zh-CN + networks: + - n8n-network + +networks: + n8n-network: + driver: bridge +``` + +**启动服务:** +```bash +docker-compose up -d +``` + +### 3. Docker 直接部署(备选方案) + +创建启动脚本或直接运行命令: + +```bash +docker run -it --name n8nChinese \ + -p 5678:5678 \ + -v "/path/to/editor-ui-dist:/usr/local/lib/node_modules/n8n/node_modules/n8n-editor-ui/dist" \ + -v "${HOME}/.n8n:/home/node/.n8n" \ + -e N8N_DEFAULT_LOCALE=zh-CN \ + -e N8N_SECURE_COOKIE=false \ + n8nio/n8n +``` + +### 4. 访问和初始化 + +1. 打开浏览器访问:http://localhost:5678 +2. 首次访问需要输入邮箱地址进行注册 +3. n8n 会向该邮箱发送激活码 +4. 按提示输入激活码完成初始化 + +![初始化界面](./images/image-20250915225901709.png) +![激活界面](./images/image-20250915225950626.png) + + +## ⚠️ 重要注意事项 + +- **数据持久化**:务必挂载本地目录保存工作流数据,避免容器重启后数据丢失 +- **端口冲突**:如端口 5678 被占用,可修改 `-p` 参数映射其他端口 +- **汉化配置**:`N8N_DEFAULT_LOCALE=zh-CN` 环境变量强制设置为简体中文界面 +- **安全警告**:生产环境建议启用基本认证和安全Cookie设置 + +## 🔌 接入 xiaohongshu-mcp 服务 + +### 前提条件 +确保 xiaohongshu-mcp 服务已正常启动并运行 + +### 配置步骤 + +#### 步骤 1:创建工作流 + +在 n8n 控制台中创建新的工作流: + +![创建工作流](./images/image-20250915225530994.png) + +#### 步骤 2:导入工作流配置 + +导入本目录中的配置文件: +- 文件名称:`自动发布笔记到小红书.json` +- 操作:点击"导入工作流"选择该文件 + +![导入工作流](./images/image-20250915230216557.png) + +#### 步骤 3:配置大模型节点 + +1. 选择 AI 大模型节点(支持 DeepSeek、OpenAI 等) +2. 配置大模型连接凭证 +3. 以 DeepSeek 为例,需要申请 API 密钥 + +**DeepSeek API 密钥申请:** +- 访问:[DeepSeek 平台](https://platform.deepseek.com/api_keys) +- 注册账号并获取 API 密钥 + +![选择大模型](./images/image-20250915230403977.png) +![配置凭证](./images/image-20250915230528047.png) +![完成配置](./images/image-20250915230614246.png) + +#### 步骤 4:配置 MCP 服务 + +1. **双击 MCP 节点进行配置** + +![配置MCP节点](./images/image-20250915231537715.png) + +2. **修改连接设置** + - 将 IP 地址修改为你实际的 xiaohongshu-mcp 服务 IP + - 默认导入所有可用的工具函数 + + + ![修改IP配置](./images/image-20250915231736534.png) + + +3. **测试连接** + - 点击"执行步骤"测试连接 + - 选择一个接口进行功能测试 + - 返回成功表示接入正常 + + + ![测试连接](./images/image-20250915232135744.png) + ![测试成功](./images/image-20250915232246623.png) + + +## 🎯 开始使用 + +### 执行工作流 + +1. 点击"开始执行该步骤" +2. 在聊天框中输入提示词 +3. 系统会自动处理并发布内容 + + +![开始执行](./images/image-20250915232457764.png) + +### 示例提示词 + +``` +给我发布一篇关于重庆旅游的小红书爆款笔记,配图找"重庆打卡"点赞最高的一张 +``` + +### 效果展示 + +![测试过程](./images/测试图.png) +![测试结果](./images/测试效果图.jpg) + + +## 🛠️ 故障排除 + +### 常见问题 + +1. **连接失败**:检查 xiaohongshu-mcp 服务是否正常运行 +2. **API 密钥错误**:确认 DeepSeek API 密钥有效且未过期 +3. **汉化不生效**:检查汉化包路径是否正确挂载 +4. **端口冲突**:修改 docker-compose.yml 中的端口映射 + +### 获取帮助 + +- 查看 n8n 官方文档:https://docs.n8n.io +- 参考 xiaohongshu-mcp 项目文档 +- 检查日志文件排查具体错误 + +## 📁 项目文件说明 + +- `docker-compose.yml` - Docker Compose 部署配置文件 +- `自动发布笔记到小红书.json` - n8n 工作流配置文件 +- `images/` - 说明文档相关截图 +- `editor-ui/dist/` - 汉化包文件(需自行下载) + +## 🎉 完成部署 + +通过以上步骤,您已成功部署汉化版 n8n 并集成 xiaohongshu-mcp 服务,可以开始自动化小红书内容发布工作了! \ No newline at end of file diff --git a/xiaohongshu-mcp-main/examples/n8n/images/image-20250915225530994.png b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915225530994.png new file mode 100644 index 0000000..a454683 Binary files /dev/null and b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915225530994.png differ diff --git a/xiaohongshu-mcp-main/examples/n8n/images/image-20250915225901709.png b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915225901709.png new file mode 100644 index 0000000..59f55ec Binary files /dev/null and b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915225901709.png differ diff --git a/xiaohongshu-mcp-main/examples/n8n/images/image-20250915225950626.png b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915225950626.png new file mode 100644 index 0000000..64c261b Binary files /dev/null and b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915225950626.png differ diff --git a/xiaohongshu-mcp-main/examples/n8n/images/image-20250915230216557.png b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915230216557.png new file mode 100644 index 0000000..a810871 Binary files /dev/null and b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915230216557.png differ diff --git a/xiaohongshu-mcp-main/examples/n8n/images/image-20250915230403977.png b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915230403977.png new file mode 100644 index 0000000..7f5fba4 Binary files /dev/null and b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915230403977.png differ diff --git a/xiaohongshu-mcp-main/examples/n8n/images/image-20250915230528047.png b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915230528047.png new file mode 100644 index 0000000..2b8c24c Binary files /dev/null and b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915230528047.png differ diff --git a/xiaohongshu-mcp-main/examples/n8n/images/image-20250915230614246.png b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915230614246.png new file mode 100644 index 0000000..ffe9d63 Binary files /dev/null and b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915230614246.png differ diff --git a/xiaohongshu-mcp-main/examples/n8n/images/image-20250915231537715.png b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915231537715.png new file mode 100644 index 0000000..0e6d84e Binary files /dev/null and b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915231537715.png differ diff --git a/xiaohongshu-mcp-main/examples/n8n/images/image-20250915231736534.png b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915231736534.png new file mode 100644 index 0000000..788750c Binary files /dev/null and b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915231736534.png differ diff --git a/xiaohongshu-mcp-main/examples/n8n/images/image-20250915232135744.png b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915232135744.png new file mode 100644 index 0000000..77619ff Binary files /dev/null and b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915232135744.png differ diff --git a/xiaohongshu-mcp-main/examples/n8n/images/image-20250915232246623.png b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915232246623.png new file mode 100644 index 0000000..2cc5b47 Binary files /dev/null and b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915232246623.png differ diff --git a/xiaohongshu-mcp-main/examples/n8n/images/image-20250915232457764.png b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915232457764.png new file mode 100644 index 0000000..8ff2b19 Binary files /dev/null and b/xiaohongshu-mcp-main/examples/n8n/images/image-20250915232457764.png differ diff --git "a/{self.openclaw_timeout}秒),请重试。\n\n if proc.returncode != 0:\n err = (proc.stderr or proc.stdout or openclaw" "b/{self.openclaw_timeout}秒),请重试。\n\n if proc.returncode != 0:\n err = (proc.stderr or proc.stdout or openclaw" new file mode 100644 index 0000000..e69de29 diff --git "a/{self.openclaw_timeout}秒),请重试。\n if proc.returncode != 0:\n err = (proc.stderr or proc.stdout or openclaw" "b/{self.openclaw_timeout}秒),请重试。\n if proc.returncode != 0:\n err = (proc.stderr or proc.stdout or openclaw" new file mode 100644 index 0000000..0440673 --- /dev/null +++ "b/{self.openclaw_timeout}秒),请重试。\n if proc.returncode != 0:\n err = (proc.stderr or proc.stdout or openclaw" @@ -0,0 +1 @@ +rewritten _run_openclaw diff --git "a/{self.openclaw_timeout}秒),请重试。',\n''])\n i += 1\n continue\n\n if line.strip()=='self.reply_text(answer, msg)':\n out.extend([\n' try:',\n' self.reply_text(answer, msg)',\n' self.logger.info(reply" "b/{self.openclaw_timeout}秒),请重试。',\n''])\n i += 1\n continue\n\n if line.strip()=='self.reply_text(answer, msg)':\n out.extend([\n' try:',\n' self.reply_text(answer, msg)',\n' self.logger.info(reply" new file mode 100644 index 0000000..e69de29 diff --git "a/{self.openclaw_timeout}秒),请重试。\\n\\n if proc.returncode != 0:\\n err = (proc.stderr or proc.stdout or openclaw" "b/{self.openclaw_timeout}秒),请重试。\\n\\n if proc.returncode != 0:\\n err = (proc.stderr or proc.stdout or openclaw" new file mode 100644 index 0000000..e69de29