Skill: nonorongrong-design/mediainsight-ad-traffic-universal (version 1.1.2)

--- name: 媒体广告流量市场分析 description: 查询广告投放流量分布与趋势的数据分析技能。支持按行业、地域、媒体（OTT/移动端）、目标受众等多维度分析广告曝光数据，适用于媒体策略评估、竞品投放监测、行业广告趋势研究等场景。 version: 1.0.0 metadata: openclaw: requires: bins: - python3 primaryEnv: MEDIAINSIGHT_MCP_TOKEN envVars: - name: MEDIAINSIGHT_MCP_TOKEN required: false description: 可选。未提供时脚本会使用公开共享的演示 token（权限较小、可能随时失效）；正式分析建议使用自己的 MediaInsight MCP token。 --- # 媒体广告流量市场分析 ## 你会得到什么 - 一个可直接查询部分行业广告流量分布与趋势的分析技能 - 2 份免费 PDF 报告，可直接下载阅读 - 支持继续用你自己的 token 做更多行业更多媒体更细颗粒度分析 ## 免费附赠报告 - [免费报告 1：2024全年媒体广告流量市场分析](https://doc.weixin.qq.com/pdf/d3_AGwAzwarAPECNP0jI9V9pRkKJrYtL?scode=ANEAJwfLAAokGeAoaDAGwAzwarAPE) - [免费报告 2：2025全年媒体广告流量市场分析](https://doc.weixin.qq.com/pdf/d3_AGwAzwarAPECNaOYYZMA6R0ymjf1M?scode=ANEAJwfLAAoF9heh5bAGwAzwarAPE) 适合先看报告，再决定是否用自己的 token 做更细的行业、地域、媒体和 TA 分析。若浏览器中无法直接打开微信文档，请尝试在微信内访问对应链接。 ## 快速体验 从 **skill 根目录** 执行以下命令可直接体验。 整个 skill 目录可单独打包分享，不依赖仓库其他 Python 模块。 脚本内置了一个公开共享、默认小权限的 MediaInsight MCP token，可直接体验。 也可通过 `--token`、`--token-file` 或环境变量 `MEDIAINSIGHT_MCP_TOKEN` 覆盖。 无需 `pip install`，仅需 Python 3.10+ 与可访问目标接口的网络环境。 新建任务默认值： - 行业默认：`美妆个护类` - 广告主默认：`明略集团` - 品牌默认：`明略科技` - 媒体默认：当前 Token 可见的全部**媒体大类**（即 `type=1` 顶层媒体类目），不是全部具体媒体 默认输出可以理解为： - `美妆个护类` 行业 - 在当前默认 Token 可见的全部**媒介类型 / 媒体大类**上 - 查看广告流量分布 如果用户希望： - 查看其他行业 - 细化到具体媒体而不是媒体大类 - 使用更完整的权限范围 则需要使用自己的 token。 ```bash python3 ./scripts/submit_ad_task.py \ --task-name '演示：近1月广告流量分布' \ --region-name 北京市 \ --region-name 上海市 \ --region-name 广州市 \ --region-name 深圳市 \ --months-back 1 ``` > 默认会使用公开共享的小权限 demo token，可能随时失效或被限流。 > 如果需要更多维度数据或更稳定的结果，可显式传入 `--token` / `--token-file`，或设置环境变量 `MEDIAINSIGHT_MCP_TOKEN`。 ## 适用场景 当用户需要分析**广告流量**分布或趋势，并提供以下信息时，使用此技能： - **MediaInsight MCP token**：用于通过 MCP `get_ttc_token` 换取底层接口所需的 `ttc_token`。 - **业务约束条件**：包括行业、广告主、品牌、目标受众（TA）、地域范围、媒体范围等。 - 如果用户没有指定行业，默认使用 `美妆个护类`。 - 如果用户没有指定媒体，默认选择当前 Token 可见的全部**媒体大类**。这不是单个固定 ID，而是当前 Token 可见的 `type=1` 顶层媒体类目集合。 - 因此典型输出，不是“全行业、全媒体明细”，而是“指定行业在当前 Token 可见媒介类型上的广告流量”。 ## 核心入口 调用本目录下的脚本： ```bash ./scripts/submit_ad_task.py ``` 下载已完成任务结果时，调用： ```bash ./scripts/download_ad_task_report.py --biz-id 104433 --wait --extract-dir ./downloads/task-104433 ``` 注意： 任务创建成功后，报告文件生成通常还需要额外时间。 实际测试中，文件生成过程可能耗时约 10 分钟，建议优先使用 `--wait` 自动轮询后再下载。 如果不使用 `--wait` 且下载过早，脚本会返回 `report file is not ready for download`。 ## 执行流程 1. 先对 MCP 执行 `initialize` 建立会话，再在同一会话中调用 `get_ttc_token` 获取 `ttc_token`，并写入 Cookie `_mz_ttc_tkt`。 2. 读取该用户权限范围内的实时字典： - 数据集、行业、广告主、品牌、地域、目标受众（TA）、媒体、投放类型、广告点位类型。 3. 仅在该 Token 可见的选项范围内解析用户输入的名称为 ID。 4. 将"全部媒体"展开为该 Token 可见的**全量**媒体 ID 集合（而非部分子集）。 如果用户未指定媒体，也未要求 all media，则默认使用当前 Token 可见的全部**媒体大类**（type=1）。 5. 计算本次任务消耗的积分数。 6. 提交任务。 7. 任务完成后，如需取回结果文件，按 `bizId` 或内部 `taskId` 下载报告 ZIP，并可解压得到 CSV。 报告文件生成可能耗时约 10 分钟，建议使用 `--wait` 自动轮询直到文件就绪后再下载。 说明： - MCP 返回的 `_mz_ttc_tkt` 以当次会话、当次调用结果为准，测试或联调时应立刻拿当前返回值去访问 `api_v2`。 - 不要混用历史轮次换取到的 `_mz_ttc_tkt`，否则容易误判为 token 或权限异常。 ## 操作规则 1. **禁止硬编码**：不得跨用户硬编码广告主、品牌、媒体、地域或行业 ID，所有 ID 必须从当前 Token 的字典中动态解析。 2. **权限边界**：若请求的名称在当前 Token 的字典中不存在，立即停止，向用户说明该项超出当前账号权限范围。 3. **媒体展开规则**："全部媒体"必须展开为该 Token 可见的完整具体媒体列表，不得只取少量精选子集。 4. **名称匹配策略**：优先精确匹配；仅当精确匹配无结果且存在唯一部分匹配时，才使用模糊匹配。 5. **可审计性**：如需保留审计记录或保证任务可复现，将解析后的 Payload 写入文件（使用 `--payload-out` 参数）。 ## 标准调用示例 ```bash python3 ./scripts/submit_ad_task.py \ --task-name '近2月美妆个护媒体大类流量分布' \ --industry-name '美妆个护类' \ --gender female \ --age-range 20-49 \ --region-name 北京市 \ --region-name 上海市 \ --region-name 广州市 \ --region-name 深圳市 \ --months-back 2 \ --payload-out /tmp/mediainsight-task.json ``` ## 常用参数变体 | 场景 | 参数 | |------|------| | 使用默认行业 | 可省略 `--industry-name`，默认 `美妆个护类` | | 使用默认媒体大类 | 可省略 `--media-name` 和 `--all-media`，脚本会自动选择当前 Token 可见的全部 `type=1` 媒体大类 | | 展开全部叶级媒体 | `--all-media`，会展开为当前 Token 可见的全部 `type=3` 叶级媒体，数量取决于权限 | | 包含 OTT | 默认设备仅 `pc` + `mobile`；如需 OTT，显式添加 `--device ott` | | 仅查询单月数据 | `--months-back 1` | | 指定特定数据集 | `--dataset 202603` | | 指定具体媒体（替代全媒体） | `--media-name 爱奇艺 --media-name 腾讯新闻` | | 指定开放年龄段 | `--age-range 20+` 或 `--age-range 20岁及以上` | | 查询全部人群 | `--gender all --age-range all` | | 仅解析不提交（调试模式） | `--dry-run` | ## 年龄参数规则 `--age-range` 不要使用 `20-99` 这类近似写法。 请直接使用产品语义对应的年龄表达，脚本会自动映射到真实 TA 年龄桶。 - `20-49`：表示 20-24、25-29、30-34、35-39、40-44、45-49 - `20+`：表示 20 岁及以上，会覆盖到 `60岁及以上` - `20岁及以上`：等价于 `20+` - `all`：表示全部年龄段 如果用户说“男 20+”，应使用： ```bash --gender male --age-range 20+ ``` 不要改写成： ```bash --gender male --age-range 20-99 ``` ## 预期输出 脚本输出一个 JSON 对象，包含以下字段： | 字段 | 说明 | |------|------| | `login` | 登录状态 | | `session_file` | 会话文件路径 | | `resolvedPayload` | 解析后的完整任务参数 | | `coin` | 本次任务消耗的积分数 | | `create` | 任务创建结果（`--dry-run` 模式下不包含此字段） | 注意： `resolvedPayload.reportArgsAd.mediaList` 在三种情况下含义不同： - 省略媒体参数：默认写入当前 Token 可见的全部**媒体大类** ID（type=1） - `--all-media`：写入当前 Token 可见的全部叶级具体媒体 ID（type=3），数量取决于权限 - `--media-name ...`：写入用户指定名称解析出的媒体/媒体类目 ID 结果下载提醒： - 任务创建成功不等于结果文件立刻可下载 - 报告文件生成过程可能耗时约 10 分钟 - 建议直接使用 `download_ad_task_report.py --wait` 自动轮询后再下载 ## 异常处理 | 异常现象 | 原因判断 | |----------|----------| | 认证失败 | 先确认是否已执行 MCP `initialize`，并使用当次 `get_ttc_token` 返回的最新 `_mz_ttc_tkt` 访问目标接口 | | 名称解析失败 | 该名称超出当前账号的权限范围 | | 积分计算成功但任务提交失败 | 提交链路本身有效，失败原因在任务侧，非技能问题 |

path: README.md
content: # 媒体广告流量市场分析

用自然语言描述需求，自动查询广告投放流量的分布与趋势数据。支持按行业、地域、媒体（OTT/移动端）、目标受众等多维度组合分析。

---

## 你会得到什么

- 一个可直接查询部分行业广告流量分布与趋势的分析技能
- 2 份免费 PDF 报告，可直接下载阅读
- 支持继续用你自己的 token 做更多行业更多媒体更细颗粒度分析

## 免费附赠报告

- [免费报告 1：2024全年媒体广告流量市场分析](https://doc.weixin.qq.com/pdf/d3_AGwAzwarAPECNP0jI9V9pRkKJrYtL?scode=ANEAJwfLAAokGeAoaDAGwAzwarAPE)
- [免费报告 2：2025全年媒体广告流量市场分析](https://doc.weixin.qq.com/pdf/d3_AGwAzwarAPECNaOYYZMA6R0ymjf1M?scode=ANEAJwfLAAoF9heh5bAGwAzwarAPE)

适合先看报告，再决定是否用自己的 token 做更细的行业、地域、媒体和 TA 分析。若浏览器中无法直接打开微信文档，请尝试在微信内访问对应链接。

## 能做什么

- 查询指定行业在各媒体平台的广告曝光分布
- 对比不同地域、不同时段的投放流量趋势
- 按性别、年龄段等受众维度过滤数据
- 自动展开"全部媒体"为当前账号可见的完整媒体列表
- 下载已完成任务的结果 ZIP，并可解压查看 CSV

## 30 秒体验

脚本内置了一个公开共享、默认小权限的 MCP token，可直接体验真实提交流程；也可以通过 `--token`、`--token-file` 或环境变量 `MEDIAINSIGHT_MCP_TOKEN` 覆盖：

默认值说明：

- 行业默认：`美妆个护类`
- 广告主默认：`明略集团`
- 品牌默认：`明略科技`
- 媒体默认：当前 token 可见的全部**媒体大类**（即 `type=1` 顶层媒体类目），不是全部具体媒体

因此，这个公开默认体验更接近于：

- 查看 `美妆个护类`
- 在所有可见**媒介类型 / 媒体大类**上的
- 广告流量分布

```bash
python3 ./scripts/submit_ad_task.py \
  --task-name '演示：近1月广告流量分布' \
  --region-name 北京市 \
  --region-name 上海市 \
  --region-name 广州市 \
  --region-name 深圳市 \
  --months-back 1
```

> 默认会使用公开共享的小权限 demo token 先建立 MCP 会话，再调取 `get_ttc_token`，随后访问真实 MediaInsight 接口；该 token 可能随时失效或被限流。
> 如果省略 `--industry-name`，会默认使用 `美妆个护类`；如果省略媒体参数，会默认选择当前 token 可见的全部媒体大类。
> 如果你想看其他行业，或者想把媒体范围细化到具体媒体明细，通常需要切换成你自己的 token。
> 无需 `pip install`，仅需 Python 3.10+。

## 示例输出

```json
{
  "login": {
    "code": 0,
    "msg": "success"
  },
  "session_file": "/tmp/mediainsight-skill-xxxxx.json",
  "resolvedPayload": {
    "name": "演示：近1月广告流量分布",
    "advertiserStid": "000000080000000000000692",
    "brandStidList": ["000000100000000000010184"],
    "regionInfo": {
      "list": [
        "000000000000000000000012",
        "000000000000000000000020",
        "000000000000000000000124",
        "000000000000000000000126"
      ],
      "isPackage": false
    },
    "taInfoList": [
      [{"id": "gender-female-id", "type": 1}, {"id": "age-20-24-id", "type": 2}, {"id": "age-25-34-id", "type": 2}, {"id": "age-35-44-id", "type": 2}, {"id": "age-45-49-id", "type": 2}]
    ],
    "reportArgsAd": {
      "dataSet": "202504",
      "industryInfo": {
        "list": ["000000130000000000001960"],
        "isPackage": false
      },
      "campaignInfo": {
        "list": [],
        "isPackage": false
      },
      "deviceList": [0, 1],
      "mediaList": ["visible-root-media-type1-id-1", "visible-root-media-type1-id-2"],
      "adSpotTypeList": {
        "list": [],
        "isPackage": false
      },
      "indicators": ["impPassion"],
      "charts": [
        "freq-capping",
        "data",
        "total_metrics",
        "flow-distribution-media",
        "flow-distribution-platform",
        "flow-distribution-industry",
        "flow-distribution-ta",
        "flow-distribution-region",
        "frequency-saturation-freq-capping"
      ]
    }
  },
  "coin": {
    "code": 0,
    "msg": "OK",
    "data": {
      "coinCost": 20
    }
  },
  "create": {
    "code": 0,
    "msg": "OK",
    "data": 104422
  }
}
```

> 上面是贴近真实结构的示例输出，具体字段值会随当前 token 的权限范围、可见媒体数和当月数据集变化而变化。

## 查看更多行业数据

如果你要扩展到其他行业，或进一步细化到具体媒体明细，通常需要换用你自己的 token。

**[申请正式账号](https://jsj.top/f/OO3GKM)**

正式账号每月赠送固定金币额度，额度不足时可充值。

申请后使用自己的 MCP token 运行：

```bash
python3 ./scripts/submit_ad_task.py \
  --token '<YOUR_MCP_TOKEN>' \
  --task-name '近2月美妆个护媒体大类流量分布' \
  --industry-name '美妆个护类' \
  --gender female \
  --age-range 25-44 \
  --region-name 上海市 \
  --months-back 2
```

> 可见的行业、广告主、品牌、媒体、地域、TA 和数据集，都会根据当前 token 的实际权限动态解析。
> 如果未显式指定媒体，脚本默认会选择当前 token 可见的全部媒体大类；只有使用 `--all-media` 时，才会展开为全部叶级具体媒体，数量取决于当前 token 权限。
> 默认设备是 `pc` 和 `mobile`；如果需要包含 OTT，请显式加上 `--device ott`。

## 下载任务结果

任务完成后，可按创建接口返回的 `bizId` 下载结果：

```bash
python3 ./scripts/download_ad_task_report.py \
  --biz-id 104433 \
  --wait \
  --extract-dir ./downloads/task-104433
```

也支持直接传内部 `taskId`：

```bash
python3 ./scripts/download_ad_task_report.py \
  --task-id 4433 \
  --wait \
  --extract-dir ./downloads/task-4433
```

注意：

- 任务创建成功后，报告文件通常不会立刻生成完成
- 实测文件生成过程可能耗时约 10 分钟
- 可使用 `--wait` 自动轮询直到文件可下载
- 如果不使用 `--wait` 且下载过早，脚本会返回 `report file is not ready for download`
- 下载的 CSV 文件为 GBK 编码，读取时请指定 `encoding='gbk'`（如 pandas：`pd.read_csv(path, encoding='gbk')`）

## 认证说明

- 这套 skill 访问 `api_v2` 时，不是直接拿 MCP token 调业务接口。
- 正确链路是：`MCP token -> initialize -> get_ttc_token -> Cookie _mz_ttc_tkt -> api_v2`
- `_mz_ttc_tkt` 以当次 MCP 会话返回值为准，联调时应直接使用当前返回值，不要混用历史轮次换出的 cookie。

## 安装要求

- Python 3.10+
- 网络需可访问 `https://mediainsight.cn.miaozhen.com`

## 年龄参数怎么写

`--age-range` 请尽量按产品语义直接填写，不要自己发明近似区间。

- `20-49`
  适用于明确的闭区间年龄段。
- `20+`
  表示 20 岁及以上，会自动覆盖到 `60岁及以上`。
- `20岁及以上`
  与 `20+` 等价。
- `all`
  表示全部年龄段。

推荐示例：

```bash
python3 ./scripts/submit_ad_task.py \
  --task-name '男20plus京津深' \
  --industry-name '美妆个护类' \
  --region-name 北京市 \
  --region-name 天津市 \
  --region-name 深圳市 \
  --gender male \
  --age-range '20+' \
  --months-back 2
```

不推荐写法：

```bash
--age-range 20-99
```

因为产品里的高龄段实际是 `60岁及以上`，不是无限细分到 99 岁。

## 常见问题

**为什么我查的行业或媒体不存在？**
数据权限由账号决定。演示账号仅开放部分维度，申请正式账号后可查询完整范围。

**提交成功后在哪里看结果？**
脚本会输出创建接口的原始响应；其中任务标识通常在 `create.data`。数据处理完成后，可用 `download_ad_task_report.py` 按 `bizId` 或内部 `taskId` 下载结果文件，也可到对应平台查看分析报告。报告文件生成可能耗时约 10 分钟，建议直接使用 `--wait` 自动轮询下载。

**金币怎么算？**
每次提交前脚本会输出本次预计消耗的金币信息（通常在 `coin.data`），可提前确认再决定是否继续。


path: agents/openai.yaml
content: interface:
  display_name: "媒体广告流量市场分析"
  short_description: "查询部分行业广告流量分布与趋势，附赠两份年度市场分析 PDF"
  brand_color: "#1F6FEB"
  default_prompt: "帮我查询广告流量分布或趋势。先用公开共享的 demo token 试跑；如果我需要更多行业、更多媒体或更细颗粒度分析，再提示我切换自己的 token。"

dependencies:
  tools:
    - type: "mcp"
      value: "mediainsight-open-api"
      description: "MediaInsight MCP server for get_ttc_token"
      transport: "streamable_http"
      url: "https://open-api-mediainsight.cn.miaozhen.com/mcp_server/mcp"

policy:
  allow_implicit_invocation: true


path: scripts/download_ad_task_report.py
content: from __future__ import annotations

import argparse
import json
from pathlib import Path
import re
import sys
from tempfile import NamedTemporaryFile
import time
from zipfile import ZipFile

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

import os

from mediainsight_client import (  # noqa: E402
    DEFAULT_BASE_URL,
    DEFAULT_MCP_URL,
    MediaInsightClient,
    MediaInsightError,
    resolve_media_insight_auth,
)
from submit_ad_task import DEFAULT_DEMO_TOKEN  # noqa: E402


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Download a completed MediaInsight ad-traffic report by internal task id."
    )
    auth = parser.add_mutually_exclusive_group(required=False)
    auth.add_argument(
        "--token",
        dest="token",
        help="MediaInsight MCP token.",
    )
    auth.add_argument(
        "--token-file",
        dest="token_file",
        help="Path to a file containing the MediaInsight token.",
    )
    parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
    parser.add_argument("--mcp-url", default=DEFAULT_MCP_URL, help="MediaInsight MCP streamable HTTP endpoint.")
    parser.add_argument(
        "--mcp-token-type",
        type=int,
        default=0,
        help="Pass-through type for MCP get_ttc_token. Use 1 only after an API 401.",
    )
    parser.add_argument("--tenant-id", type=int, help="Optional tenant id to switch to after login.")
    id_group = parser.add_mutually_exclusive_group(required=True)
    id_group.add_argument("--task-id", type=int, help="Internal MediaInsight task id.")
    id_group.add_argument("--biz-id", type=int, help="External MediaInsight bizId returned by task creation.")
    parser.add_argument(
        "--output",
        help="Optional path for the downloaded report archive. Defaults to ./downloads/task-<id>.zip",
    )
    parser.add_argument(
        "--extract-dir",
        help="Optional directory to extract the downloaded zip contents into after download.",
    )
    parser.add_argument(
        "--wait",
        action="store_true",
        help="Poll file generation status until the report is ready, then download it.",
    )
    parser.add_argument(
        "--poll-interval",
        type=int,
        default=30,
        help="Seconds between status polls when --wait is enabled.",
    )
    parser.add_argument(
        "--wait-timeout",
        type=int,
        default=900,
        help="Maximum seconds to wait for report generation when --wait is enabled.",
    )
    parser.add_argument("--session-file", help="Optional path to persist the login session.")
    return parser


def load_token(args: argparse.Namespace) -> str:
    if args.[REDACTED_SECRET] args.token.strip()
    if args.token_file:
        return Path(args.token_file).read_text(encoding="utf-8").strip()
    env_token = os.environ.get("MEDIAINSIGHT_MCP_TOKEN", "").strip()
    if env_token:
        return env_token
    return DEFAULT_DEMO_TOKEN


def using_demo_token(args: argparse.Namespace) -> bool:
    return not (
        args.token
        or args.token_file
        or os.environ.get("MEDIAINSIGHT_MCP_TOKEN", "").strip()
    )


def ensure_success(response: object, action: str) -> dict:
    if not isinstance(response, dict):
        raise MediaInsightError(f"{action} returned an unexpected payload")
    code = response.get("code")
    if code not in (0, "0", None):
        message = response.get("msg") or response.get("message") or "unknown error"
        raise MediaInsightError(f"{action} failed: {message}")
    return response


def derive_output_path(task_id: int, output: str | None) -> Path:
    if output:
        return Path(output)
    return Path.cwd() / "downloads" / f"task-{task_id}.zip"


def safe_name(name: str) -> str:
    return re.sub(r"[\\/:*?\"<>|]+", "-", name).strip() or "report"


def wait_until_report_ready(client: MediaInsightClient, task_id: int, *, interval: int, timeout: int) -> dict:
    if interval <= 0:
        raise MediaInsightError("--poll-interval must be positive")
    if timeout <= 0:
        raise MediaInsightError("--wait-timeout must be positive")

    start = time.monotonic()
    deadline = start + timeout
    while True:
        latest_status = ensure_success(client.task_report_file_gen_status(task_id), "file generation status")
        if latest_status.get("data", {}).get("status") == 2:
            return latest_status
        if time.monotonic() >= deadline:
            raise MediaInsightError("report file was not ready before wait timeout")
        elapsed = int(time.monotonic() - start)
        print(f"Report not ready yet (task_id={task_id}, elapsed={elapsed}s), retrying in {interval}s...", file=sys.stderr)
        time.sleep(interval)


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()

    try:
        if using_demo_token(args):
            print(
                "Using the public shared demo token with limited permissions. It may expire at any time; switch to your own MEDIAINSIGHT_MCP_TOKEN or --token if download fails.",
                file=sys.stderr,
            )
        auth = resolve_media_insight_auth(load_token(args), mcp_url=args.mcp_url, mcp_token_type=args.mcp_token_type)
        if args.session_file:
            session_file = Path(args.session_file)
        else:
            tmp = NamedTemporaryFile(prefix="mediainsight-download-", suffix=".json", delete=False)
            tmp.close()
            session_file = Path(tmp.name)
        client = MediaInsightClient(
            base_url=args.base_url,
            session_file=session_file,
            ttc_token=auth.get("token"),
        )
        if args.tenant_id:
            ensure_success(client.switch_tenant(args.tenant_id), "switch tenant")

        task_id = args.task_id
        if task_id is None:
            task_id = client.find_internal_task_id(args.biz_id)

        if args.wait:
            status_payload = wait_until_report_ready(
                client,
                task_id,
                interval=args.poll_interval,
                timeout=args.wait_timeout,
            )
        else:
            status_payload = ensure_success(client.task_report_file_gen_status(task_id), "file generation status")
        if status_payload.get("data", {}).get("status") != 2:
            raise MediaInsightError("report file is not ready for download")

        detail_payload = ensure_success(client.task_report_detail(task_id), "report detail")
        detail = detail_payload.get("data", {}) if isinstance(detail_payload.get("data"), dict) else {}

        archive_bytes, headers = client.task_download_report(task_id)
        output_path = derive_output_path(task_id, args.output)
        output_path.parent.mkdir(parents=True, exist_ok=True)
        output_path.write_bytes(archive_bytes)

        extracted_files: list[str] = []
        if args.extract_dir:
            extract_dir = Path(args.extract_dir)
            extract_dir.mkdir(parents=True, exist_ok=True)
            with ZipFile(output_path) as archive:
                for info in archive.infolist():
                    target = extract_dir / safe_name(info.filename)
                    target.write_bytes(archive.read(info))
                    extracted_files.append(str(target))

        print(
            json.dumps(
                {
                    "taskId": task_id,
                    "bizId": args.biz_id or detail.get("bizId"),
                    "taskName": detail.get("name"),
                    "status": status_payload.get("data", {}).get("status"),
                    "contentType": headers.get("Content-Type"),
                    "output": str(output_path),
                    "extractedFiles": extracted_files,
                },
                ensure_ascii=False,
                indent=2,
            )
        )
        return 0
    except MediaInsightError as exc:
        print(str(exc), file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/mediainsight_client.py
content: from __future__ import annotations

from http.cookiejar import Cookie, CookieJar
from http.cookies import SimpleCookie
import json
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode, urljoin, urlparse
from urllib.request import HTTPCookieProcessor, OpenerDirector, Request, build_opener


DEFAULT_BASE_URL = "https://mediainsight.cn.miaozhen.com/api_v2"
DEFAULT_SESSION_FILE = Path(".mediainsight-session.json")
DEFAULT_MCP_URL = "https://open-api-mediainsight.cn.miaozhen.com/mcp_server/mcp"
AD_TRAFFIC_ONLY_FORBIDDEN_KEYS = frozenset(
    {
        "reportTouchMediaHavior",
        "reportArgsTouchMedia",
        "forcesForMediaInsight",
        "reportArgsReachCurve",
        "reportArgsReachCurveAvg",
        "reportArgsReachCurveAverage",
        "reportArgsReachCurveCustom",
    }
)


class MediaInsightError(RuntimeError):
    """Raised when a MediaInsight request fails."""


def _friendly_http_error(context: str, status_code: int, body: str) -> str:
    detail = body.strip() or "empty response body"
    if status_code == 401:
        return (
            f"{context} failed with HTTP 401: token may be expired, revoked, or unauthorized. "
            f"If you are using the public demo token, switch to your own MEDIAINSIGHT_MCP_TOKEN, --token, or --token-file and retry. Raw response: {detail}"
        )
    if status_code == 403:
        return (
            f"{context} failed with HTTP 403: the current token does not have permission for this action or dataset. "
            f"Try your own token if you need broader coverage. Raw response: {detail}"
        )
    return f"{context} failed with HTTP {status_code}: {detail}"


class MediaInsightMcpClient:
    def __init__(self, mcp_url: str = DEFAULT_MCP_URL, authorization: str = "") -> None:
        self.mcp_url = mcp_url.rstrip("/")
        self.[REDACTED_SECRET]()
        self.session_id: str | None = None

    def _request(self, payload: dict[str, Any]) -> dict[str, Any]:
        data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        request = Request(url=self.mcp_url, data=data, method="POST")
        request.add_header("Accept", "text/event-stream, application/json")
        request.add_header("Content-Type", "application/json")
        if self.[REDACTED_SECRET]("Authorization", self.authorization)
        if self.session_id:
            request.add_header("Mcp-Session-Id", self.session_id)

        try:
            response = build_opener().open(request)
            self.session_id = response.headers.get("Mcp-Session-Id") or self.session_id
            body = response.read().decode("utf-8")
        except HTTPError as exc:
            body = exc.read().decode("utf-8", errors="replace")
            raise MediaInsightError(_friendly_http_error("MCP request", exc.code, body)) from exc
        except URLError as exc:
            raise MediaInsightError(f"MCP request failed: {exc.reason}") from exc

        message = _parse_mcp_event_stream(body)
        if not isinstance(message, dict):
            raise MediaInsightError("MCP returned an unexpected payload")
        if "error" in message:
            error = message.get("error") or {}
            code = error.get("code")
            detail = error.get("message") or "unknown MCP error"
            raise MediaInsightError(f"MCP error {code}: {detail}")
        return message

    def initialize(self) -> None:
        self._request(
            {
                "jsonrpc": "2.0",
                "id": 1,
                "method": "initialize",
                "params": {
                    "protocolVersion": "2025-03-26",
                    "capabilities": {},
                    "clientInfo": {"name": "mediainsight-ad-traffic", "version": "1.0"},
                },
            }
        )

    def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]:
        if not self.session_id:
            self.initialize()
        response = self._request(
            {
                "jsonrpc": "2.0",
                "id": 2,
                "method": "tools/call",
                "params": {"name": name, "arguments": arguments or {}},
            }
        )
        result = response.get("result")
        if not isinstance(result, dict):
            raise MediaInsightError("MCP tool call returned an unexpected payload")
        if result.get("isError"):
            raise MediaInsightError(f"MCP tool {name} returned an error")
        return result


def _parse_mcp_event_stream(body: str) -> dict[str, Any]:
    for line in body.splitlines():
        if not line.startswith("data: "):
            continue
        raw = line[6:].strip()
        if not raw:
            continue
        try:
            payload = json.loads(raw)
        except json.JSONDecodeError as exc:
            raise MediaInsightError("unable to parse MCP event stream payload") from exc
        if isinstance(payload, dict):
            return payload
    raise MediaInsightError("MCP response did not contain a JSON-RPC message")


def normalize_ad_traffic_payload(payload: dict[str, Any]) -> dict[str, Any]:
    normalized = dict(payload)
    if "reportAdData" in normalized and "reportArgsAd" not in normalized:
        normalized["reportArgsAd"] = normalized.pop("reportAdData")
    return normalized


def fetch_ttc_token_from_mcp(
    mcp_token: str,
    *,
    mcp_url: str = DEFAULT_MCP_URL,
    token_type: int = 0,
) -> str:
    client = MediaInsightMcpClient(mcp_url=mcp_url, authorization=mcp_token)
    result = client.call_tool("get_ttc_token", {"type": token_type})
    structured = result.get("structuredContent", {})
    if not isinstance(structured, dict):
        raise MediaInsightError("MCP get_ttc_token did not return structured content")

    raw_result = structured.get("result")
    if not isinstance(raw_result, str) or not raw_result.strip():
        raise MediaInsightError("MCP get_ttc_token returned an empty result")

    try:
        payload = json.loads(raw_result)
    except json.JSONDecodeError as exc:
        raise MediaInsightError("unable to parse get_ttc_token result payload") from exc

    if not isinstance(payload, dict):
        raise MediaInsightError("get_ttc_token result payload is not an object")
    if payload.get("code") not in (0, "0"):
        message = payload.get("message") or payload.get("msg") or "unknown error"
        raise MediaInsightError(f"get_ttc_token failed: {message}")

    [REDACTED_SECRET]"data")
    if not isinstance(token, str) or not token.strip():
        raise MediaInsightError("get_ttc_token did not return a TTC token")
    return token.strip()


def resolve_media_insight_auth(
    token: str,
    *,
    mcp_url: str = DEFAULT_MCP_URL,
    mcp_token_type: int = 0,
) -> dict[str, str]:
    stripped = token.strip()
    if not stripped:
        raise MediaInsightError("MediaInsight token is empty")

    return {
        "mode": "ttc_token",
        "token": fetch_ttc_token_from_mcp(
            stripped,
            mcp_url=mcp_url,
            token_type=mcp_token_type,
        ),
    }


def ensure_ad_traffic_only_payload(payload: dict[str, Any]) -> None:
    if "reportArgsAd" not in payload and "reportAdData" not in payload:
        raise MediaInsightError("payload must include reportArgsAd")

    forbidden = sorted(key for key in AD_TRAFFIC_ONLY_FORBIDDEN_KEYS if key in payload)
    if forbidden:
        joined = ", ".join(forbidden)
        raise MediaInsightError(
            f"payload must stay ad-traffic-only; remove unsupported sections: {joined}"
        )


def _make_cookie(base_url: str, name: str, value: str) -> Cookie:
    parsed = urlparse(base_url)
    domain = parsed.hostname or ""
    return Cookie(
        version=0,
        name=name,
        value=value,
        port=None,
        port_specified=False,
        domain=domain,
        domain_specified=bool(domain),
        domain_initial_dot=domain.startswith("."),
        path="/",
        path_specified=True,
        secure=parsed.scheme == "https",
        expires=None,
        discard=True,
        comment=None,
        comment_url=None,
        rest={},
        rfc2109=False,
    )


def _cookies_as_dict(cookie_jar: CookieJar) -> dict[str, str]:
    return {cookie.name: cookie.value for cookie in cookie_jar}


def _load_session_payload(session_file: Path) -> dict[str, Any]:
    if not session_file.exists():
        return {}
    raw = session_file.read_text(encoding="utf-8").strip()
    if not raw:
        return {}
    try:
        payload = json.loads(raw)
    except json.JSONDecodeError:
        return {}
    if not isinstance(payload, dict):
        return {}
    return payload


def _set_response_cookies(base_url: str, cookie_jar: CookieJar, response: Any) -> None:
    headers = getattr(response, "headers", None)
    if headers is None:
        return

    raw_cookies: list[str] = []
    if hasattr(headers, "get_all"):
        raw_cookies.extend(headers.get_all("Set-Cookie", []))
    elif hasattr(headers, "get"):
        maybe_cookie = headers.get("Set-Cookie")
        if maybe_cookie:
            raw_cookies.append(maybe_cookie)

    for raw_cookie in raw_cookies:
        parsed = SimpleCookie()
        parsed.load(raw_cookie)
        for morsel in parsed.values():
            cookie_jar.set_cookie(_make_cookie(base_url, morsel.key, morsel.value))


def _collect_media_leaf_ids(nodes: list[dict[str, Any]]) -> list[str]:
    leaf_ids: list[str] = []
    for node in nodes:
        children = node.get("children")
        if isinstance(children, list) and children:
            leaf_ids.extend(_collect_media_leaf_ids(children))
            continue
        node_id = node.get("id")
        node_type = node.get("type")
        if isinstance(node_id, str) and node_id and node_type == 3:
            leaf_ids.append(node_id)
    return leaf_ids


class MediaInsightClient:
    def __init__(
        self,
        base_url: str = DEFAULT_BASE_URL,
        session_file: Path | str = DEFAULT_SESSION_FILE,
        ttc_token: str | None = None,
    ) -> None:
        self.base_url = base_url.rstrip("/")
        self.session_file = Path(session_file)
        self.ttc_token = ttc_token.strip() if ttc_token else None
        self.cookie_jar = CookieJar()
        self._opener: OpenerDirector = build_opener(HTTPCookieProcessor(self.cookie_jar))
        self._restore_session()
        if self.ttc_token:
            self.set_ttc_token(self.ttc_token)

    def _restore_session(self) -> None:
        payload = _load_session_payload(self.session_file)
        if not payload:
            return

        saved_base_url = str(payload.get("base_url") or "").rstrip("/")
        if saved_base_url:
            self.base_url = saved_base_url

        cookies = payload.get("cookies", {})
        if not isinstance(cookies, dict):
            return

        for name, value in cookies.items():
            self.cookie_jar.set_cookie(_make_cookie(self.base_url, str(name), str(value)))

    def _save_session(self) -> None:
        payload = {
            "base_url": self.base_url,
            "cookies": _cookies_as_dict(self.cookie_jar),
        }
        self.session_file.write_text(
            json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
            encoding="utf-8",
        )

    def _cookie_header(self) -> str | None:
        cookies = _cookies_as_dict(self.cookie_jar)
        if not cookies:
            return None
        return "; ".join(f"{name}={value}" for name, value in cookies.items())

    def set_ttc_token(self, token: str) -> None:
        self.ttc_token = token.strip()
        self.cookie_jar.set_cookie(_make_cookie(self.base_url, "_mz_ttc_tkt", self.ttc_token))
        self._save_session()

    def _request(
        self,
        method: str,
        path: str,
        *,
        query: dict[str, Any] | None = None,
        json_body: dict[str, Any] | None = None,
    ) -> Any:
        url = urljoin(f"{self.base_url}/", path.lstrip("/"))
        if query:
            query_items = {key: value for key, value in query.items() if value is not None}
            if query_items:
                url = f"{url}?{urlencode(query_items, doseq=True)}"

        data: bytes | None = None
        if json_body is not None:
            data = json.dumps(json_body, ensure_ascii=False).encode("utf-8")

        request = Request(url=url, data=data, method=method.upper())
        request.add_header("Accept", "application/json")
        cookie_header = self._cookie_header()
        if cookie_header:
            request.add_header("Cookie", cookie_header)
        if json_body is not None:
            request.add_header("Content-Type", "application/json")

        try:
            response = self._opener.open(request)
            _set_response_cookies(self.base_url, self.cookie_jar, response)
            raw_body = response.read().decode("utf-8")
        except HTTPError as exc:
            raw_body = exc.read().decode("utf-8", errors="replace")
            raise MediaInsightError(
                _friendly_http_error(f"{method.upper()} {path}", exc.code, raw_body)
            ) from exc
        except URLError as exc:
            raise MediaInsightError(f"{method.upper()} {path} failed: {exc.reason}") from exc

        self._save_session()

        if not raw_body.strip():
            return {}

        try:
            return json.loads(raw_body)
        except json.JSONDecodeError:
            return raw_body

    def _request_bytes(
        self,
        method: str,
        path: str,
        *,
        query: dict[str, Any] | None = None,
    ) -> tuple[bytes, dict[str, str]]:
        url = urljoin(f"{self.base_url}/", path.lstrip("/"))
        if query:
            query_items = {key: value for key, value in query.items() if value is not None}
            if query_items:
                url = f"{url}?{urlencode(query_items, doseq=True)}"

        request = Request(url=url, method=method.upper())
        request.add_header("Accept", "*/*")
        cookie_header = self._cookie_header()
        if cookie_header:
            request.add_header("Cookie", cookie_header)

        try:
            response = self._opener.open(request)
            _set_response_cookies(self.base_url, self.cookie_jar, response)
            raw_body = response.read()
            headers = {key: value for key, value in response.headers.items()}
        except HTTPError as exc:
            raw_body = exc.read().decode("utf-8", errors="replace")
            raise MediaInsightError(
                _friendly_http_error(f"{method.upper()} {path}", exc.code, raw_body)
            ) from exc
        except URLError as exc:
            raise MediaInsightError(f"{method.upper()} {path} failed: {exc.reason}") from exc

        self._save_session()
        return raw_body, headers

    def login(self, username: str, password: str) -> Any:
        return self._request("POST", "/user/login", json_body={"username": username, "password": password})

    def switch_tenant(self, tenant_id: int) -> Any:
        return self._request("GET", "/user/switch-tenant", query={"id": tenant_id})

    def task_data_set(self) -> Any:
        return self._request("GET", "/task/data-set")

    def dict_region(self) -> Any:
        return self._request("GET", "/dict/region")

    def dict_industry(self) -> Any:
        return self._request("GET", "/dict/industry")

    def dict_adviser(self) -> Any:
        return self._request("GET", "/dict/adviser")

    def dict_brand(self, advertiser_stid: str) -> Any:
        return self._request("GET", "/dict/brand", query={"advertiserStid": advertiser_stid})

    def dict_campaign_type(self) -> Any:
        return self._request("GET", "/dict/campaign-type")

    def dict_media(self) -> Any:
        return self._request("GET", "/dict/media")

    def dict_ad_spot_type(self) -> Any:
        return self._request("GET", "/dict/ad-spot-type")

    def dict_ta(self) -> Any:
        return self._request("GET", "/dict/ta")

    def _expand_media_list(self, payload: dict[str, Any]) -> dict[str, Any]:
        report_args = payload.get("reportArgsAd")
        if not isinstance(report_args, dict):
            return payload

        media_list = report_args.get("mediaList")
        if media_list not in (["__ALL__"], ["*"], "__ALL__", "*"):
            return payload

        media_response = self.dict_media()
        media_nodes = media_response.get("data", []) if isinstance(media_response, dict) else []
        if not isinstance(media_nodes, list):
            raise MediaInsightError("dict/media returned an unexpected payload")

        expanded_media_ids = _collect_media_leaf_ids(media_nodes)
        if not expanded_media_ids:
            raise MediaInsightError("unable to expand mediaList=__ALL__; no media leaf ids found")

        expanded_payload = dict(payload)
        expanded_report_args = dict(report_args)
        expanded_report_args["mediaList"] = expanded_media_ids
        expanded_payload["reportArgsAd"] = expanded_report_args
        return expanded_payload

    def task_calculate_coin(self, payload: dict[str, Any]) -> Any:
        normalized_payload = normalize_ad_traffic_payload(payload)
        ensure_ad_traffic_only_payload(normalized_payload)
        normalized_payload = self._expand_media_list(normalized_payload)
        return self._request("POST", "/task/calculate-coin", json_body=normalized_payload)

    def task_add(self, payload: dict[str, Any]) -> Any:
        normalized_payload = normalize_ad_traffic_payload(payload)
        ensure_ad_traffic_only_payload(normalized_payload)
        normalized_payload = self._expand_media_list(normalized_payload)
        return self._request("POST", "/task/add", json_body=normalized_payload)

    def task_list(self, *, page: int = 1, page_size: int = 20) -> Any:
        return self._request("GET", "/task/list", query={"page": page, "pageSize": page_size})

    def find_internal_task_id(self, biz_id: int | str, *, max_pages: int = 10, page_size: int = 50) -> int:
        target = str(biz_id)
        for page in range(1, max_pages + 1):
            payload = self.task_list(page=page, page_size=page_size)
            data = payload.get("data", {}) if isinstance(payload, dict) else {}
            tasks = data.get("list", []) if isinstance(data, dict) else []
            if not isinstance(tasks, list):
                break
            for task in tasks:
                if str(task.get("bizId")) == target:
                    task_id = task.get("id")
                    if isinstance(task_id, int):
                        return task_id
                    if isinstance(task_id, str) and task_id.isdigit():
                        return int(task_id)
            total = data.get("total") if isinstance(data, dict) else None
            if isinstance(total, int) and page * page_size >= total:
                break
        raise MediaInsightError(f"unable to resolve internal task id from bizId {biz_id}")

    def task_report_detail(self, task_id: int) -> Any:
        return self._request("GET", "/task/report-detail", query={"taskId": task_id})

    def task_report_file_gen_status(self, task_id: int) -> Any:
        return self._request("GET", "/task/report/file-gen-status", query={"taskId": task_id})

    def task_download_report(self, task_id: int) -> tuple[bytes, dict[str, str]]:
        return self._request_bytes("GET", "/task/download-report", query={"taskId": task_id})


path: scripts/submit_ad_task.py
content: from __future__ import annotations

import argparse
from datetime import datetime
import os
import json
import re
import sys
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Any

SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from mediainsight_client import (  # noqa: E402
    DEFAULT_MCP_URL,
    MediaInsightClient,
    MediaInsightError,
    resolve_media_insight_auth,
)

# Public shared demo token for ClawHub trial use. Keep its permissions narrow and assume
# anyone who can inspect this public skill package can also see and use this token.
DEFAULT_DEMO_TOKEN = (
    "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9."
    [REDACTED_SECRET]
    "WHhldVN6eExfc082M2p4UDUtcWlKS2hXNDlXUzEzUSIsImlhdCI6MTc3ODA0ODA3NDk3NH0."
    [REDACTED_SECRET]
)

DEFAULT_CHARTS = [
    "freq-capping",
    "data",
    "total_metrics",
    "flow-distribution-media",
    "flow-distribution-platform",
    "flow-distribution-industry",
    "flow-distribution-ta",
    "flow-distribution-region",
    "frequency-saturation-freq-capping",
]
DEFAULT_INDICATORS = ["impPassion"]
DEFAULT_INDUSTRY_NAME = "美妆个护类"
DEFAULT_ADVERTISER_NAME = "明略集团"
DEFAULT_BRAND_NAME = "明略科技"
DEVICE_IDS = {
    "pc": 0,
    "mobile": 1,
    "pm": 2,
    "ott": 3,
}
GENDER_ALIASES = {
    "female": "女",
    "woman": "女",
    "women": "女",
    "f": "女",
    "male": "男",
    "man": "男",
    "men": "男",
    "m": "男",
    "all": "",
    "any": "",
}


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Submit a MediaInsight ad-traffic task from a MediaInsight MCP token plus user-visible dictionary resolution."
    )
    auth = parser.add_mutually_exclusive_group(required=False)
    auth.add_argument(
        "--token",
        dest="token",
        help="MediaInsight MCP token.",
    )
    auth.add_argument(
        "--token-file",
        dest="token_file",
        help="Path to a file containing the MediaInsight token.",
    )

    parser.add_argument("--base-url", default="https://mediainsight.cn.miaozhen.com/api_v2")
    parser.add_argument("--mcp-url", default=DEFAULT_MCP_URL, help="MediaInsight MCP streamable HTTP endpoint.")
    parser.add_argument(
        "--mcp-token-type",
        type=int,
        default=0,
        help="Pass-through type for MCP get_ttc_token. Use 1 only after an API 401.",
    )
    parser.add_argument("--tenant-id", type=int, help="Optional tenant id to switch to after login.")
    parser.add_argument("--task-name", required=True)
    parser.add_argument(
        "--task-name-unique",
        action="store_true",
        help="Append a timestamp suffix to the task name before submission.",
    )
    parser.add_argument("--industry-name", default=DEFAULT_INDUSTRY_NAME)
    parser.add_argument("--advertiser-name", default=DEFAULT_ADVERTISER_NAME)
    parser.add_argument("--brand-name", default=DEFAULT_BRAND_NAME)
    parser.add_argument("--gender", default="female")
    parser.add_argument("--age-range", default="20-49", help="Inclusive age range, e.g. 20-49.")
    parser.add_argument("--region-name", action="append", required=True, dest="region_names")
    parser.add_argument("--campaign-type-name", action="append", dest="campaign_type_names")
    parser.add_argument(
        "--device",
        action="append",
        dest="devices",
        help="pc, mobile, pm, ott. Defaults to pc + mobile; add --device ott to include OTT.",
    )
    parser.add_argument("--media-name", action="append", dest="media_names")
    parser.add_argument(
        "--all-media",
        action="store_true",
        help="Expand to all visible leaf media ids (type=3); actual count depends on token permissions.",
    )
    parser.add_argument("--dataset", action="append", dest="datasets", help="Explicit dataset id, repeatable.")
    parser.add_argument("--months-back", type=int, default=1, help="Use the latest N visible datasets if --dataset is omitted.")
    parser.add_argument("--indicator", action="append", dest="indicators")
    parser.add_argument("--chart", action="append", dest="charts")
    parser.add_argument("--ad-spot-type-name", action="append", dest="ad_spot_type_names")
    parser.add_argument("--payload-out", help="Optional path to write the resolved payload.")
    parser.add_argument("--session-file", help="Optional path to persist the login session.")
    parser.add_argument("--dry-run", action="store_true", help="Resolve payload and coin cost, but do not create the task.")
    return parser


def load_token(args: argparse.Namespace) -> str:
    if args.[REDACTED_SECRET] args.token.strip()
    if args.token_file:
        return Path(args.token_file).read_text(encoding="utf-8").strip()
    env_token = os.environ.get("MEDIAINSIGHT_MCP_TOKEN", "").strip()
    if env_token:
        return env_token
    return DEFAULT_DEMO_TOKEN


def normalize_name(value: str) -> str:
    normalized = value.strip().lower().replace(" ", "")
    normalized = re.sub(r"[省市区县特别行政自治区回族维吾尔壮族土家族苗族蒙古族藏族朝鲜族哈萨克族柯尔克孜族傣族白族自治州盟]+$", "", normalized)
    return normalized


def flatten_tree(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
    flattened: list[dict[str, Any]] = []
    for node in nodes:
        flattened.append(node)
        children = node.get("children")
        if isinstance(children, list) and children:
            flattened.extend(flatten_tree(children))
    return flattened


def collapse_same_id(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
    deduped: dict[str, dict[str, Any]] = {}
    for item in items:
        item_id = str(item.get("id", ""))
        if not item_id:
            continue
        current = deduped.get(item_id)
        if current is None or int(item.get("type", -1)) > int(current.get("type", -1)):
            deduped[item_id] = item
    return list(deduped.values())


def flatten_ta(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
    flattened = flatten_tree(nodes)
    return [node for node in flattened if isinstance(node.get("id"), str) and node.get("name")]


def collect_media_leaf_ids(nodes: list[dict[str, Any]]) -> list[str]:
    leaf_ids: list[str] = []
    for node in nodes:
        children = node.get("children")
        if isinstance(children, list) and children:
            leaf_ids.extend(collect_media_leaf_ids(children))
            continue
        node_id = node.get("id")
        node_type = node.get("type")
        if isinstance(node_id, str) and node_id and node_type == 3:
            leaf_ids.append(node_id)
    return leaf_ids


def collect_media_root_ids(nodes: list[dict[str, Any]]) -> list[str]:
    return [str(node["id"]) for node in nodes if isinstance(node, dict) and node.get("type") == 1 and node.get("id")]


def match_one(items: list[dict[str, Any]], target: str, label: str) -> dict[str, Any]:
    raw_target = target.strip()
    exact_raw = collapse_same_id([item for item in items if str(item.get("name", "")).strip() == raw_target])
    if len(exact_raw) == 1:
        return exact_raw[0]
    if len(exact_raw) > 1:
        raise MediaInsightError(f"{label} name '{target}' is ambiguous")

    normalized_target = normalize_name(target)
    exact = collapse_same_id(
        [item for item in items if normalize_name(str(item.get("name", ""))) == normalized_target]
    )
    if len(exact) == 1:
        return exact[0]
    if len(exact) > 1:
        raise MediaInsightError(f"{label} name '{target}' is ambiguous")

    partial = collapse_same_id(
        [item for item in items if normalized_target in normalize_name(str(item.get("name", "")))]
    )
    if len(partial) == 1:
        return partial[0]
    if not partial:
        raise MediaInsightError(f"{label} name '{target}' is not visible for the current token")
    raise MediaInsightError(f"{label} name '{target}' matches multiple visible options")


def match_many(items: list[dict[str, Any]], targets: list[str], label: str) -> list[dict[str, Any]]:
    return [match_one(items, target, label) for target in targets]


def pick_datasets(visible: list[str], requested: list[str] | None, months_back: int) -> str:
    if requested:
        missing = [dataset for dataset in requested if dataset not in visible]
        if missing:
            raise MediaInsightError(f"dataset(s) not visible for the current token: {', '.join(missing)}")
        return ",".join(requested)

    if months_back <= 0:
        raise MediaInsightError("--months-back must be positive")
    visible_datasets = sorted(
        [dataset for dataset in visible if isinstance(dataset, str) and dataset],
        reverse=True,
    )
    chosen = visible_datasets[:months_back]
    if not chosen:
        raise MediaInsightError("no visible datasets returned by task/data-set")
    return ",".join(sorted(chosen))


def parse_age_range(raw: str) -> tuple[int, int]:
    normalized = raw.strip().lower()
    if normalized in {"all", "any", "*"}:
        return 0, 200
    open_ended = re.fullmatch(r"\s*(\d{1,2})\s*(?:\+|岁及以上)\s*", raw)
    if open_ended:
        return int(open_ended.group(1)), 200
    match = re.fullmatch(r"\s*(\d{1,2})\s*-\s*(\d{1,2})\s*", raw)
    if not match:
        raise MediaInsightError("--age-range must look like 20-49, 20+, 20岁及以上, or be 'all'")
    start, end = int(match.group(1)), int(match.group(2))
    if start > end:
        raise MediaInsightError("--age-range start cannot be greater than end")
    return start, end


def parse_age_bucket(name: str) -> tuple[int, int] | None:
    closed_range = re.fullmatch(r"(\d{1,2})-(\d{1,2})岁", name)
    if closed_range:
        return int(closed_range.group(1)), int(closed_range.group(2))

    open_ended = re.fullmatch(r"(\d{1,2})岁及以上", name)
    if open_ended:
        start = int(open_ended.group(1))
        return start, 200

    under_cap = re.fullmatch(r"(\d{1,2})岁及以下", name)
    if under_cap:
        end = int(under_cap.group(1))
        return 0, end

    return None


def build_ta_info(ta_nodes: list[dict[str, Any]], gender: str, age_range: str) -> list[list[dict[str, Any]]]:
    flattened = flatten_ta(ta_nodes)
    gender_key = GENDER_ALIASES.get(gender.strip().lower(), gender.strip())
    ta_group: list[dict[str, Any]] = []

    if gender_key:
        gender_node = match_one(flattened, gender_key, "gender")
        ta_group.append({"id": gender_node["id"], "type": int(gender_node["type"])})
    else:
        all_gender_nodes = []
        for node in flattened:
            name = str(node.get("name", "")).strip()
            if name in {"男", "女"}:
                all_gender_nodes.append({"id": node["id"], "type": int(node["type"])})
        deduped: dict[tuple[str, int], dict[str, Any]] = {}
        for node in all_gender_nodes:
            deduped[(str(node["id"]), int(node["type"]))] = node
        ta_group.extend(deduped.values())

    start_age, end_age = parse_age_range(age_range)
    for node in flattened:
        name = str(node.get("name", ""))
        bucket = parse_age_bucket(name)
        if bucket is None:
            continue
        bucket_start, bucket_end = bucket
        if bucket_end < start_age or bucket_start > end_age:
            continue
        ta_group.append({"id": node["id"], "type": int(node["type"])})

    deduped_group: dict[tuple[str, int], dict[str, Any]] = {}
    for node in ta_group:
        deduped_group[(str(node["id"]), int(node["type"]))] = node

    if not deduped_group:
        raise MediaInsightError("TA resolution produced an empty audience group")
    return [list(deduped_group.values())]


def device_ids_from_args(raw_devices: list[str] | None) -> list[int]:
    requested = raw_devices or ["pc", "mobile"]
    resolved: list[int] = []
    for raw in requested:
        key = raw.strip().lower()
        if key not in DEVICE_IDS:
            raise MediaInsightError(f"unsupported device '{raw}'")
        resolved.append(DEVICE_IDS[key])
    return sorted(set(resolved))


def resolve_payload(client: MediaInsightClient, args: argparse.Namespace) -> dict[str, Any]:
    task_name = args.task_name
    if args.task_name_unique:
        suffix = datetime.now().strftime("%Y%m%d-%H%M%S")
        task_name = f"{task_name}-{suffix}"

    datasets_payload = client.task_data_set()
    visible_datasets = datasets_payload.get("data", [])
    if not isinstance(visible_datasets, list):
        raise MediaInsightError("task/data-set returned an unexpected payload")

    industries_payload = client.dict_industry()
    visible_industries = flatten_tree(industries_payload.get("data", []))
    industry = match_one(visible_industries, args.industry_name, "industry")

    advertisers_payload = client.dict_adviser()
    visible_advertisers = advertisers_payload.get("data", [])
    if not isinstance(visible_advertisers, list):
        raise MediaInsightError("dict/adviser returned an unexpected payload")
    advertiser = match_one(visible_advertisers, args.advertiser_name, "advertiser")

    brands_payload = client.dict_brand(advertiser["id"])
    visible_brands = brands_payload.get("data", [])
    if not isinstance(visible_brands, list):
        raise MediaInsightError("dict/brand returned an unexpected payload")
    brand = match_one(visible_brands, args.brand_name, "brand")

    regions_payload = client.dict_region()
    visible_regions = flatten_tree(regions_payload.get("data", []))
    regions = match_many(visible_regions, args.region_names, "region")

    ta_payload = client.dict_ta()
    visible_ta = ta_payload.get("data", [])
    if not isinstance(visible_ta, list):
        raise MediaInsightError("dict/ta returned an unexpected payload")

    media_payload = client.dict_media()
    media_nodes = media_payload.get("data", [])
    if not isinstance(media_nodes, list):
        raise MediaInsightError("dict/media returned an unexpected payload")
    visible_media = flatten_tree(media_nodes)
    media_leaf_ids = collect_media_leaf_ids(media_nodes)
    if args.all_media:
        resolved_media_ids = media_leaf_ids
    else:
        if not args.media_names:
            resolved_media_ids = collect_media_root_ids(media_nodes)
            if not resolved_media_ids:
                raise MediaInsightError("no visible top-level media categories returned by dict/media")
        else:
            matched_media = match_many(visible_media, args.media_names, "media")
            resolved_media_ids = [str(item["id"]) for item in matched_media]

    campaign_type_ids: list[str] = []
    if args.campaign_type_names:
        campaign_types_payload = client.dict_campaign_type()
        visible_campaign_types = campaign_types_payload.get("data", [])
        if not isinstance(visible_campaign_types, list):
            raise MediaInsightError("dict/campaign-type returned an unexpected payload")
        campaign_type_ids = [str(item["id"]) for item in match_many(visible_campaign_types, args.campaign_type_names, "campaign type")]

    ad_spot_type_ids: list[str] = []
    if args.ad_spot_type_names:
        ad_spot_types_payload = client.dict_ad_spot_type()
        visible_ad_spot_types = ad_spot_types_payload.get("data", [])
        if not isinstance(visible_ad_spot_types, list):
            raise MediaInsightError("dict/ad-spot-type returned an unexpected payload")
        ad_spot_type_ids = [str(item["id"]) for item in match_many(visible_ad_spot_types, args.ad_spot_type_names, "ad spot type")]

    payload = {
        "name": task_name,
        "advertiserStid": str(advertiser["id"]),
        "brandStidList": [str(brand["id"])],
        "regionInfo": {
            "list": [str(item["id"]) for item in regions],
            "isPackage": False,
        },
        "taInfoList": build_ta_info(visible_ta, args.gender, args.age_range),
        "reportArgsAd": {
            "dataSet": pick_datasets(visible_datasets, args.datasets, args.months_back),
            "industryInfo": {
                "list": [str(industry["id"])],
                "isPackage": False,
            },
            "campaignInfo": {
                "list": campaign_type_ids,
                "isPackage": False,
            },
            "deviceList": device_ids_from_args(args.devices),
            "mediaList": resolved_media_ids,
            "adSpotTypeList": {
                "list": ad_spot_type_ids,
                "isPackage": False,
            },
            "indicators": args.indicators or DEFAULT_INDICATORS,
            "charts": args.charts or DEFAULT_CHARTS,
        },
    }
    return payload


def ensure_success(response: Any, action: str) -> dict[str, Any]:
    if not isinstance(response, dict):
        raise MediaInsightError(f"{action} returned an unexpected payload")
    code = response.get("code")
    if code not in (0, "0", None):
        message = response.get("msg") or response.get("message") or "unknown error"
        raise MediaInsightError(f"{action} failed: {message}")
    return response


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()

    using_demo_token = not (
        args.token
        or args.token_file
        or os.environ.get("MEDIAINSIGHT_MCP_TOKEN", "").strip()
    )

    session_file = Path(args.session_file) if args.session_file else None
    if session_file is None:
        tmp = NamedTemporaryFile(prefix="mediainsight-skill-", suffix=".json", delete=False)
        tmp.close()
        session_file = Path(tmp.name)

    try:
        if using_demo_token:
            print(
                "Using the public shared demo token with limited permissions. It may expire at any time; switch to your own MEDIAINSIGHT_MCP_TOKEN or --token for broader coverage.",
                file=sys.stderr,
            )
        auth = resolve_media_insight_auth(load_token(args), mcp_url=args.mcp_url, mcp_token_type=args.mcp_token_type)
        client = MediaInsightClient(
            base_url=args.base_url,
            session_file=session_file,
            ttc_token=auth.get("token"),
        )
        login_payload: dict[str, Any] = {"code": 0, "msg": "success", "mode": auth["mode"], "tokenSource": "demo" if using_demo_token else "user"}
        if args.tenant_id:
            ensure_success(client.switch_tenant(args.tenant_id), "switch tenant")

        payload = resolve_payload(client, args)
        if args.payload_out:
            Path(args.payload_out).write_text(
                json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
                encoding="utf-8",
            )

        coin = ensure_success(client.task_calculate_coin(payload), "calculate coin")
        output: dict[str, Any] = {
            "login": login_payload,
            "session_file": str(session_file),
            "resolvedPayload": payload,
            "coin": coin,
        }
        if not args.dry_run:
            output["create"] = ensure_success(client.task_add(payload), "create task")
        print(json.dumps(output, ensure_ascii=False, indent=2))
        return 0
    except MediaInsightError as exc:
        print(str(exc), file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)