Explorar o código

feat: add reusable Feishu data publisher skill

刘立冬 hai 3 días
pai
achega
a5218052fd

+ 6 - 0
.env.example

@@ -2,3 +2,9 @@ export ODPS_ACCESS_ID=''
 export ODPS_ACCESS_SECRET=''
 export ODPS_PROJECT=''
 export ODPS_ENDPOINT=''
+
+export FEISHU_APP_ID=''
+export FEISHU_APP_SECRET=''
+export FEISHU_TARGET_CHAT_ID=''
+# 或使用精确群名,由机器人可见群聊列表解析:
+# export FEISHU_TARGET_CHAT_NAME=''

+ 42 - 2
README.md

@@ -1,11 +1,12 @@
 # Data Query Skills
 
-面向团队私有仓库分发的 4 个 ODPS 数据查询 Skill:
+面向团队私有仓库分发的 4 个 ODPS 数据查询 Skill 和 1 个飞书发布 Skill
 
 - `odps-ad-risk-analysis`
 - `odps-growth-fission-report`
 - `odps-product-efficiency-report`
 - `query-user-behavior-path`
+- `feishu-data-publisher`
 
 每个 Skill 都可以独立安装,运行时只读取当前使用者自己的环境变量,不依赖作者机器上的项目目录或凭证文件。
 
@@ -24,6 +25,16 @@ export ODPS_PROJECT='ODPS 项目名'
 export ODPS_ENDPOINT='ODPS Endpoint'
 ```
 
+需要把查询结果发布为飞书在线表格时,再配置:
+
+```bash
+export FEISHU_APP_ID='飞书自建应用 App ID'
+export FEISHU_APP_SECRET='飞书自建应用 App Secret'
+export FEISHU_TARGET_CHAT_ID='目标群 chat_id'
+```
+
+飞书配置不是执行 ODPS 查询的前置条件。发布 Skill 支持 CSV、XLS 和 XLSX,上传后将链接权限设置为“互联网获得链接的任何人可编辑”,再把在线表格卡片发送到目标群。该权限较宽,发布前应确认结果中不含不应公开的数据。
+
 可以复制 `.env.example` 到个人私有配置目录,例如 `~/.config/data-query-skills/odps.env`,填写后执行:
 
 ```bash
@@ -42,7 +53,8 @@ python3 ~/.codex/skills/.system/skill-installer/scripts/install-skill-from-githu
     skills/odps-ad-risk-analysis \
     skills/odps-growth-fission-report \
     skills/odps-product-efficiency-report \
-    skills/query-user-behavior-path
+    skills/query-user-behavior-path \
+    skills/feishu-data-publisher
 ```
 
 私有仓库需要本机已有 Git 凭证,或者配置 `GH_TOKEN`。安装完成后,Skill 会在下一轮 Codex 对话中可用。
@@ -55,9 +67,37 @@ mkdir -p ~/.codex/skills
 cp -R data-query-skills/skills/* ~/.codex/skills/
 ```
 
+## 更新已安装 Skill
+
+在之前 clone 的目录中拉取并覆盖安装:
+
+```bash
+cd data-query-skills
+git pull --ff-only
+python3 -m pip install -r requirements.txt
+mkdir -p ~/.codex/skills
+cp -R skills/* ~/.codex/skills/
+```
+
+重新开启一轮 Codex 对话后使用新版本。
+
+## 发布查询结果到飞书
+
+可以让 Codex 在查询完成后调用 `feishu-data-publisher`,也可以直接运行:
+
+```bash
+python3 ~/.codex/skills/feishu-data-publisher/scripts/publish_table.py \
+  /path/to/report.xlsx \
+  --chat-name '前端组' \
+  --title '产品效率日报'
+```
+
+实际上传前可加 `--dry-run` 做本地预检;只读查询机器人可见群聊可使用 `--list-chats --name '群名'`。
+
 ## 安全边界
 
 - Skill 和 Git 仓库不得包含 Access ID、Access Secret、临时 Token 或带 Token 的 LogView URL。
 - `scripts/odps_module.py` 只打印 ODPS Instance ID,不打印 LogView URL。
 - 查询生成的 CSV、Excel、日志和个人 `.env` 默认不提交。
+- 飞书 App Secret、tenant access token 和临时上传 token 不得写入日志或提交。
 - 表名、指标口径和风控规则仍属于内部资料,只应放在授权的私有仓库。

+ 1 - 0
requirements.txt

@@ -1,3 +1,4 @@
 pyodps
 pandas
 openpyxl
+httpx

+ 47 - 0
skills/feishu-data-publisher/SKILL.md

@@ -0,0 +1,47 @@
+---
+name: feishu-data-publisher
+description: 将本地 CSV、XLS 或 XLSX 数据文件上传并导入为飞书在线电子表格,把链接权限设置为“互联网获得链接的任何人可编辑”,并通过指定飞书机器人将表格卡片发送到目标群聊。用于数据查询完成后发布结果、查找机器人可见群聊、按群名解析 chat_id,或排查飞书上传、导入、权限与群消息问题。
+---
+
+# 飞书数据表发布
+
+将 `SKILL_DIR` 解析为当前 `SKILL.md` 所在的安装目录。所有脚本和参考资料都相对该目录解析;不得假定当前工作目录、作者仓库或作者机器。
+
+## 运行环境
+
+依赖 `httpx`。脚本只读取 `FEISHU_APP_ID`、`FEISHU_APP_SECRET`、`FEISHU_TARGET_CHAT_ID` 和可选的 `FEISHU_TARGET_CHAT_NAME`。不得把凭证写入 Skill、命令参数、输出文件或回复。
+
+首次使用或权限报错时阅读 [configuration.md](references/configuration.md)。
+
+## 工作流程
+
+1. 确认用户明确要求上传并发送飞书;仅查询数据时不得自动产生外部副作用。
+2. 确认结果文件存在,且后缀为 `.csv`、`.xls` 或 `.xlsx`。
+3. 确认目标群。优先使用用户本次给出的 `chat_id` 或群名,其次使用环境变量。群名不唯一时必须停止并列出候选,不得猜测。
+4. 如需只读检查机器人可见群聊:
+
+   `python3 "$SKILL_DIR/scripts/publish_table.py" --list-chats --name '前端组'`
+
+5. 先执行无副作用预检:
+
+   `python3 "$SKILL_DIR/scripts/publish_table.py" report.xlsx --dry-run`
+
+6. 用户确认目标和上传意图后执行发布:
+
+   `python3 "$SKILL_DIR/scripts/publish_table.py" report.xlsx --title '产品效率日报' --message '今日产品效率查询结果'`
+
+   临时覆盖目标群可使用 `--chat-id` 或 `--chat-name`。不要把群 ID 硬编码进 Skill。
+7. 检查 JSON 结果中的 `url`、`permission` 和 `message_sent`。完整成功顺序必须是:上传临时素材、导入在线表格、设置任何人可编辑、发送群卡片。权限失败时不得继续发送链接。
+8. 回复中只给出在线表格链接、目标群名称或 ID、权限结果和消息发送结果;不得输出 access token、app secret、临时上传 token 或完整 API 响应。
+
+## 权限规则
+
+- 默认且固定设置 `external_access_entity=open` 和 `link_share_entity=anyone_editable`,对应“互联网获得链接的任何人可编辑”。
+- 这是宽权限。每次发布前必须确认文件不含不应公开的数据;如用户要求更窄权限,不得沿用此模式,应先改造权限策略。
+- 目标群只用于接收消息,不作为文档协作者;访问由链接权限控制。
+
+## 资源
+
+- `scripts/feishu_client.py`:环境变量驱动的飞书 API 客户端。
+- `scripts/publish_table.py`:群聊查询、无副作用预检和发布入口。
+- `references/configuration.md`:环境变量、应用权限和故障排查。

+ 4 - 0
skills/feishu-data-publisher/agents/openai.yaml

@@ -0,0 +1,4 @@
+interface:
+  display_name: "飞书数据表发布"
+  short_description: "把本地数据导入飞书表格并将可编辑链接发送到指定群聊"
+  default_prompt: "使用 $feishu-data-publisher 将查询结果发布为飞书在线表格并通知目标群聊。"

+ 56 - 0
skills/feishu-data-publisher/references/configuration.md

@@ -0,0 +1,56 @@
+# 配置与权限
+
+## 环境变量
+
+必须配置:
+
+```bash
+export FEISHU_APP_ID='飞书自建应用 App ID'
+export FEISHU_APP_SECRET='飞书自建应用 App Secret'
+```
+
+发送群消息时还需配置目标群,推荐使用稳定的群 ID:
+
+```bash
+export FEISHU_TARGET_CHAT_ID='oc_xxx'
+```
+
+也可使用群名,由脚本在机器人可见群中精确解析:
+
+```bash
+export FEISHU_TARGET_CHAT_NAME='前端组'
+```
+
+群名可能重名,因此生产环境推荐保存 `chat_id`。群 ID 不是应用密钥,但属于环境配置,不应硬编码进通用 Skill。
+
+可选配置:
+
+```bash
+export FEISHU_BASE_URL='https://open.feishu.cn/open-apis'
+```
+
+## 飞书应用准备
+
+- 应用必须启用机器人能力,并已发布到当前企业。
+- 机器人必须加入目标群,才能查到群并向群发送消息。
+- 应用需具备获取 tenant access token、上传素材、创建与查询云文档导入任务、管理电子表格权限、获取群信息和发送消息所需的权限。
+- 如果上传成功但权限设置失败,检查应用是否有“添加或管理云文档协作者/权限”和电子表格管理权限。
+- 如果可以导入但不能发消息,检查机器人是否在目标群、应用是否已发布,以及消息发送权限。
+
+## API 流程
+
+1. `POST /auth/v3/tenant_access_token/internal`
+2. `POST /drive/v1/medias/upload_all`,使用 `parent_type=ccm_import_open`
+3. `POST /drive/v1/import_tasks`
+4. `GET /drive/v1/import_tasks/{ticket}` 轮询结果
+5. `PATCH /drive/v1/permissions/{token}/public?type=sheet`
+6. `POST /im/v1/messages?receive_id_type=chat_id`
+
+脚本不会打印 tenant access token、App Secret 或临时素材 token。
+
+## 常见错误
+
+- `群名匹配多个群聊`:改用 `--chat-id` 或 `FEISHU_TARGET_CHAT_ID`。
+- `机器人看不到目标群`:先把机器人加入群,再用 `--list-chats` 检查。
+- `导入任务超时`:稍后重试,并确认文件大小和扩展名符合飞书限制。
+- `设置任何人可编辑失败`:确认企业安全策略允许互联网链接分享,且应用具备文档权限管理能力。

+ 334 - 0
skills/feishu-data-publisher/scripts/feishu_client.py

@@ -0,0 +1,334 @@
+"""Small environment-driven Feishu client for publishing tabular files."""
+
+from __future__ import annotations
+
+import json
+import os
+import time
+from pathlib import Path
+from typing import Any
+
+import httpx
+
+
+SUPPORTED_EXTENSIONS = {"csv", "xls", "xlsx"}
+
+
+class FeishuApiError(RuntimeError):
+    """Raised when Feishu returns a failed response without leaking credentials."""
+
+
+class FeishuPublisher:
+    def __init__(
+        self,
+        app_id: str,
+        app_secret: str,
+        *,
+        base_url: str = "https://open.feishu.cn/open-apis",
+        timeout: float = 30.0,
+        client: httpx.Client | None = None,
+    ) -> None:
+        if not app_id.strip():
+            raise ValueError("FEISHU_APP_ID 未配置")
+        if not app_secret.strip():
+            raise ValueError("FEISHU_APP_SECRET 未配置")
+        self._app_id = app_id.strip()
+        self._app_secret = app_secret.strip()
+        self._base_url = base_url.rstrip("/")
+        self._owns_client = client is None
+        self._client = client or httpx.Client(timeout=timeout)
+
+    @classmethod
+    def from_env(cls, **kwargs: Any) -> "FeishuPublisher":
+        return cls(
+            os.getenv("FEISHU_APP_ID", ""),
+            os.getenv("FEISHU_APP_SECRET", ""),
+            base_url=os.getenv(
+                "FEISHU_BASE_URL", "https://open.feishu.cn/open-apis"
+            ),
+            **kwargs,
+        )
+
+    def close(self) -> None:
+        if self._owns_client:
+            self._client.close()
+
+    def __enter__(self) -> "FeishuPublisher":
+        return self
+
+    def __exit__(self, *_: object) -> None:
+        self.close()
+
+    @staticmethod
+    def _payload(response: httpx.Response, action: str) -> dict[str, Any]:
+        try:
+            response.raise_for_status()
+            payload = response.json()
+        except (httpx.HTTPError, ValueError) as exc:
+            raise FeishuApiError(f"{action}失败:HTTP 响应无效") from exc
+        if payload.get("code") != 0:
+            code = payload.get("code", "unknown")
+            message = payload.get("msg", "unknown error")
+            raise FeishuApiError(f"{action}失败:code={code}, msg={message}")
+        return payload
+
+    @staticmethod
+    def _headers(token: str) -> dict[str, str]:
+        return {"Authorization": f"Bearer {token}"}
+
+    def tenant_access_token(self) -> str:
+        response = self._client.post(
+            f"{self._base_url}/auth/v3/tenant_access_token/internal",
+            json={"app_id": self._app_id, "app_secret": self._app_secret},
+        )
+        return str(
+            self._payload(response, "获取 tenant access token")[
+                "tenant_access_token"
+            ]
+        )
+
+    def list_chats(self, token: str, name: str = "") -> list[dict[str, str]]:
+        matches: list[dict[str, str]] = []
+        page_token = ""
+        while True:
+            params: dict[str, str | int] = {"page_size": 100}
+            if page_token:
+                params["page_token"] = page_token
+            response = self._client.get(
+                f"{self._base_url}/im/v1/chats",
+                headers=self._headers(token),
+                params=params,
+            )
+            data = self._payload(response, "查询机器人可见群聊").get("data", {})
+            for item in data.get("items", []) or []:
+                chat_name = str(item.get("name") or "")
+                chat_id = str(item.get("chat_id") or "")
+                if chat_id and (not name or name in chat_name):
+                    matches.append({"name": chat_name, "chat_id": chat_id})
+            if not data.get("has_more"):
+                break
+            page_token = str(data.get("page_token") or "")
+            if not page_token:
+                break
+        return matches
+
+    def resolve_chat_id(self, token: str, chat_name: str) -> str:
+        exact = [
+            chat
+            for chat in self.list_chats(token, chat_name)
+            if chat["name"] == chat_name
+        ]
+        if not exact:
+            raise FeishuApiError(f"机器人可见群聊中未找到:{chat_name}")
+        if len(exact) > 1:
+            ids = ", ".join(chat["chat_id"] for chat in exact)
+            raise FeishuApiError(
+                f"群名匹配多个群聊:{chat_name};请改用 chat_id。候选:{ids}"
+            )
+        return exact[0]["chat_id"]
+
+    @staticmethod
+    def validate_file(file_path: Path) -> str:
+        if not file_path.is_file():
+            raise FileNotFoundError(f"结果文件不存在:{file_path}")
+        extension = file_path.suffix.lower().lstrip(".")
+        if extension not in SUPPORTED_EXTENSIONS:
+            allowed = ", ".join(sorted(SUPPORTED_EXTENSIONS))
+            raise ValueError(f"不支持的文件类型:.{extension};仅支持 {allowed}")
+        if file_path.stat().st_size == 0:
+            raise ValueError(f"结果文件为空:{file_path}")
+        return extension
+
+    def upload_import_source(
+        self, token: str, file_path: Path, extension: str
+    ) -> str:
+        extra = json.dumps(
+            {"obj_type": "sheet", "file_extension": extension},
+            ensure_ascii=False,
+        )
+        with file_path.open("rb") as handle:
+            response = self._client.post(
+                f"{self._base_url}/drive/v1/medias/upload_all",
+                headers=self._headers(token),
+                data={
+                    "file_name": file_path.name,
+                    "parent_type": "ccm_import_open",
+                    "size": str(file_path.stat().st_size),
+                    "extra": extra,
+                },
+                files={
+                    "file": (
+                        file_path.name,
+                        handle,
+                        "application/octet-stream",
+                    )
+                },
+            )
+        return str(
+            self._payload(response, "上传待导入文件")["data"]["file_token"]
+        )
+
+    def create_import_task(
+        self,
+        token: str,
+        file_token: str,
+        *,
+        extension: str,
+        title: str,
+    ) -> str:
+        response = self._client.post(
+            f"{self._base_url}/drive/v1/import_tasks",
+            headers={**self._headers(token), "Content-Type": "application/json"},
+            json={
+                "file_extension": extension,
+                "file_token": file_token,
+                "type": "sheet",
+                "file_name": title,
+                "point": {"mount_type": 1, "mount_key": ""},
+            },
+        )
+        return str(self._payload(response, "创建表格导入任务")["data"]["ticket"])
+
+    def wait_import_result(
+        self,
+        token: str,
+        ticket: str,
+        *,
+        max_wait: float = 90.0,
+        poll_interval: float = 2.0,
+    ) -> dict[str, Any]:
+        deadline = time.monotonic() + max_wait
+        while time.monotonic() < deadline:
+            response = self._client.get(
+                f"{self._base_url}/drive/v1/import_tasks/{ticket}",
+                headers=self._headers(token),
+            )
+            result = (
+                self._payload(response, "查询表格导入结果")
+                .get("data", {})
+                .get("result", {})
+            )
+            status = result.get("job_status")
+            if status == 0:
+                return result
+            if status == 3:
+                message = result.get("job_error_msg", "unknown error")
+                raise FeishuApiError(f"导入在线表格失败:{message}")
+            time.sleep(poll_interval)
+        raise TimeoutError(f"导入在线表格超过 {max_wait:g} 秒仍未完成")
+
+    def set_anyone_editable(
+        self, token: str, sheet_token: str, file_type: str = "sheet"
+    ) -> None:
+        response = self._client.patch(
+            f"{self._base_url}/drive/v1/permissions/{sheet_token}/public",
+            headers={**self._headers(token), "Content-Type": "application/json"},
+            params={"type": file_type},
+            json={
+                "external_access_entity": "open",
+                "link_share_entity": "anyone_editable",
+            },
+        )
+        self._payload(response, "设置任何人可编辑权限")
+
+    def send_sheet_card(
+        self,
+        token: str,
+        *,
+        chat_id: str,
+        title: str,
+        message: str,
+        url: str,
+    ) -> str:
+        card = {
+            "config": {"wide_screen_mode": True},
+            "header": {
+                "template": "blue",
+                "title": {"tag": "plain_text", "content": title},
+            },
+            "elements": [
+                {
+                    "tag": "div",
+                    "text": {"tag": "lark_md", "content": message},
+                },
+                {"tag": "hr"},
+                {
+                    "tag": "action",
+                    "actions": [
+                        {
+                            "tag": "button",
+                            "type": "primary",
+                            "text": {
+                                "tag": "plain_text",
+                                "content": "打开在线表格",
+                            },
+                            "url": url,
+                        }
+                    ],
+                },
+            ],
+        }
+        response = self._client.post(
+            f"{self._base_url}/im/v1/messages",
+            headers={**self._headers(token), "Content-Type": "application/json"},
+            params={"receive_id_type": "chat_id"},
+            json={
+                "receive_id": chat_id,
+                "msg_type": "interactive",
+                "content": json.dumps(card, ensure_ascii=False),
+            },
+        )
+        return str(self._payload(response, "发送飞书群卡片")["data"]["message_id"])
+
+    def publish(
+        self,
+        file_path: Path,
+        *,
+        title: str,
+        message: str,
+        chat_id: str = "",
+        chat_name: str = "",
+        notify: bool = True,
+    ) -> dict[str, Any]:
+        extension = self.validate_file(file_path)
+        token = self.tenant_access_token()
+        resolved_chat_id = chat_id.strip()
+        if notify and not resolved_chat_id:
+            if not chat_name.strip():
+                raise ValueError(
+                    "未配置目标群;请设置 FEISHU_TARGET_CHAT_ID、"
+                    "FEISHU_TARGET_CHAT_NAME 或命令行参数"
+                )
+            resolved_chat_id = self.resolve_chat_id(token, chat_name.strip())
+
+        file_token = self.upload_import_source(token, file_path, extension)
+        ticket = self.create_import_task(
+            token,
+            file_token,
+            extension=extension,
+            title=title,
+        )
+        imported = self.wait_import_result(token, ticket)
+        url = str(imported.get("url") or "")
+        sheet_token = str(imported.get("token") or "")
+        file_type = str(imported.get("type") or "sheet")
+        if not url or not sheet_token:
+            raise FeishuApiError("导入成功响应缺少在线表格 URL 或 token")
+
+        self.set_anyone_editable(token, sheet_token, file_type)
+        message_id = ""
+        if notify:
+            message_id = self.send_sheet_card(
+                token,
+                chat_id=resolved_chat_id,
+                title=title,
+                message=message,
+                url=url,
+            )
+        return {
+            "url": url,
+            "permission": "anyone_editable",
+            "message_sent": bool(message_id),
+            "chat_id": resolved_chat_id if notify else "",
+            "message_id": message_id,
+        }

+ 104 - 0
skills/feishu-data-publisher/scripts/publish_table.py

@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+"""Publish a CSV/XLS/XLSX file as a Feishu online spreadsheet."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+from pathlib import Path
+
+from feishu_client import FeishuPublisher
+
+
+def build_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(
+        description=(
+            "将 CSV/XLS/XLSX 导入飞书在线表格,设置任何人可编辑,"
+            "并可发送到群聊。"
+        )
+    )
+    parser.add_argument("file", nargs="?", help="待发布的数据文件")
+    parser.add_argument("--title", help="在线表格和消息卡片标题")
+    parser.add_argument("--message", default="数据查询结果已生成,请点击查看。")
+    target = parser.add_mutually_exclusive_group()
+    target.add_argument("--chat-id", help="目标飞书群 chat_id")
+    target.add_argument("--chat-name", help="机器人可见群中的精确群名")
+    parser.add_argument("--no-notify", action="store_true", help="只创建表格,不发群消息")
+    parser.add_argument("--dry-run", action="store_true", help="仅校验本地输入,不调用飞书 API")
+    parser.add_argument("--list-chats", action="store_true", help="只读列出机器人可见群聊")
+    parser.add_argument("--name", default="", help="配合 --list-chats 按名称模糊过滤")
+    return parser
+
+
+def print_json(value: object) -> None:
+    print(json.dumps(value, ensure_ascii=False, indent=2))
+
+
+def main() -> int:
+    args = build_parser().parse_args()
+    try:
+        if args.list_chats:
+            with FeishuPublisher.from_env() as publisher:
+                token = publisher.tenant_access_token()
+                print_json(publisher.list_chats(token, args.name))
+            return 0
+
+        if not args.file:
+            raise ValueError("必须提供结果文件,或使用 --list-chats")
+        file_path = Path(args.file).expanduser().resolve()
+        extension = FeishuPublisher.validate_file(file_path)
+        title = (args.title or file_path.stem).strip()
+        if not title:
+            raise ValueError("表格标题不能为空")
+
+        chat_id = (
+            args.chat_id
+            if args.chat_id is not None
+            else os.getenv("FEISHU_TARGET_CHAT_ID", "")
+        ).strip()
+        chat_name = (
+            args.chat_name
+            if args.chat_name is not None
+            else os.getenv("FEISHU_TARGET_CHAT_NAME", "")
+        ).strip()
+        notify = not args.no_notify
+        if notify and not chat_id and not chat_name:
+            raise ValueError(
+                "未配置目标群;请使用 --chat-id/--chat-name 或设置 "
+                "FEISHU_TARGET_CHAT_ID/FEISHU_TARGET_CHAT_NAME"
+            )
+
+        if args.dry_run:
+            print_json(
+                {
+                    "dry_run": True,
+                    "file": str(file_path),
+                    "file_extension": extension,
+                    "title": title,
+                    "permission": "anyone_editable",
+                    "notify": notify,
+                    "target": chat_id or chat_name,
+                }
+            )
+            return 0
+
+        with FeishuPublisher.from_env() as publisher:
+            result = publisher.publish(
+                file_path,
+                title=title,
+                message=args.message,
+                chat_id=chat_id,
+                chat_name=chat_name,
+                notify=notify,
+            )
+        print_json(result)
+        return 0
+    except Exception as exc:
+        print(f"发布失败:{exc}", file=sys.stderr)
+        return 1
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 1 - 0
skills/odps-ad-risk-analysis/SKILL.md

@@ -30,6 +30,7 @@ description: 查询和维护 ODPS 广告风险用户策略,覆盖广告播放
    `python3 "$SKILL_DIR/scripts/run_sql.py" <query.sql> <result.csv>`
 
 7. 输出前校验事件粒度、日期覆盖、行数和黑名单层级行为。
+8. 仅当用户明确要求上传飞书时,使用 `$feishu-data-publisher` 发布最终 CSV 或 Excel;未明确要求时只保留本地结果。
 
 ## 不可变规则
 

+ 1 - 0
skills/odps-growth-fission-report/SKILL.md

@@ -32,6 +32,7 @@ description: 生成、执行、校验并解释参数化 ODPS 首层增长和 T0
    `python3 "$SKILL_DIR/scripts/format_report.py" normalized.json raw_facts.csv full_report.csv --aggregate-output aggregate_report.csv`
 
 10. 执行 [metrics.md](references/metrics.md) 中的全部校验,并分别解释头部、推荐和全部流量。
+11. 仅当用户明确要求上传飞书时,使用 `$feishu-data-publisher` 发布最终 CSV 或 Excel;未明确要求时只保留本地结果。
 
 ## 参数规则
 

+ 1 - 0
skills/odps-product-efficiency-report/SKILL.md

@@ -31,6 +31,7 @@ description: 生成、执行、校验并解释参数化 ODPS 产品效率实验
    `python3 "$SKILL_DIR/scripts/format_report.py" normalized.json raw_facts.csv full_report.csv --aggregate-output aggregate_report.csv`
 
 9. 执行 [metrics.md](references/metrics.md) 中的全部校验,并分别解释头部、推荐和全部流量。
+10. 仅当用户明确要求上传飞书时,使用 `$feishu-data-publisher` 发布最终 CSV 或 Excel;未明确要求时只保留本地结果。
 
 ## 参数规则
 

+ 1 - 0
skills/query-user-behavior-path/SKILL.md

@@ -27,6 +27,7 @@ description: 从 loghubods 视频、广告、播放、简单事件和用户活
 
 3. 报告输出路径、总行数、各来源行数以及行数为零的来源。
 4. 校验时间顺序、Excel 时间格式、技术事件排除和中文行为定义。
+5. 仅当用户明确要求上传飞书时,使用 `$feishu-data-publisher` 发布最终 Excel;未明确要求时只保留本地结果。
 
 ## 实时流程