Skill: volcengine-skills/byted-market-insight-agent (version 1.0.1)

--- name: byted-market-insight-agent description: 火山引擎市场洞察助手。帮助用户获取品牌在各大社交平台和媒体渠道上的公开内容数据，通过 AI 筛选出真正值得关注的信息，并发现潜在商机线索。当用户提到以下任何场景时使用此技能：想知道最近网上有没有人在讨论自己的品牌或产品、想看看竞品最近在社交媒体上有什么动态、想了解某个话题或事件在网上的讨论热度和趋势、想定期获取和自己业务相关的行业资讯和热点内容、想把筛选后的内容数据接入自己的系统做进一步分析、想查看自己账号下有哪些监测任务或拉取某个任务的数据、想了解最新的中东局势关税贸易战AI大模型等热点话题的网络讨论、想查询商机线索信息获取 ACOR 评估竞争分析公司画像、提到火山引擎市场洞察 Volcengine Insight PullPost ListCustomSubsTask QueryClueInfo。即使用户没有直接提到"市场洞察"，只要涉及品牌声量追踪、行业动态了解、热点事件网络讨论、公开内容数据获取、商机查询与分析等需求，都应触发此技能。 version: 1.0.1 --- # Market Insight Agent — 火山引擎市场洞察助手 ## 用途与设计目标 本 Skill 旨在为 Agent 提供一个统一、稳定且无感的市场洞察服务调用入口。它整合了 [REDACTED_SECRET]（零依赖 Gateway 版本）和 [REDACTED_SECRET]（官方 SDK 版本）的优点，实现了以下目标： 1. **统一调用路径**：无论底层采用 API Gateway 还是官方 SDK，对 Agent 而言，始终通过 `scripts/client.py` 中 `list_custom_subs_task`、`pull_post`、`query_clue_info` 三个函数以相同的方式调用。 2. **动态 Provider 选择**：默认优先使用 API Gateway 方式。同时，Skill 能够根据当前环境（如环境变量配置）和运行时状态（如某条链路连续失败）动态地、智能地选择最佳调用路径。 3. **SDK 自动安装**：当需要使用 SDK 路径但环境中未安装 `volcengine-python-sdk` 时，Skill 会自动尝试安装，实现真正的“开箱即用”。 4. **状态与凭证持久化**：Skill 会将其运行状态（如上次成功的 Provider）和用户提供的凭证（若通过“最小化询问”获得）持久化到 Skill 私有的 `persist/` 目录中，实现会话间的状态保持和凭证复用。 5. **最小化询问**：仅在完全没有可用凭证或所有链路均认证失败时，才向 Agent 层发出明确的“最小化询问”请求，由 Agent 决定何时以及如何向用户获取凭证，避免了不必要的打扰。 ## 触发场景 当需要与火山引擎市场洞察服务交互时，应使用本 Skill。具体场景包括但不限于： - **品牌声量监测**：获取关于特定品牌或产品的社交媒体讨论。 - **竞品动态追踪**：监控竞争对手的市场活动、用户反馈。 - **行业趋势分析**：通过分析公开数据发现行业热点与变化。 - **热点话题总结**：对特定事件或话题进行深入的内容挖掘与分析。 - **监控任务管理**：查询、管理在市场洞察平台创建的订阅/监控任务。 - **商机线索查询**：拉取由 AI 生成的、包含 ACOR 评估、公司画像等的结构化商机线索。 ## Provider 选择策略 Skill 内部通过 `scripts/auth_resolver.py` 实现了一套智能的 Provider 选择策略，其决策顺序如下： 1. **会话粘性优先**：如果上一次调用成功，则优先复用该 Provider（Gateway 或 SDK），以保证链路稳定性。 2. **默认优先级**：在没有历史成功记录的情况下，默认按以下顺序尝试： - **API Gateway**：检查是否存在 `ARK_SKILL_API_BASE` 和 `ARK_SKILL_API_KEY` 环境变量。此路径无任何第三方 Python 库依赖，是首选。 - **官方 SDK**：检查是否存在 `VOLCSTACK_ACCESS_KEY_ID` 和 `VOLCSTACK_SECRET_ACCESS_KEY` 环境变量。 3. **动态降级**：如果某个 Provider 连续出现鉴权失败（401/403）或网络不可达，它将被临时标记为“降级”状态，在一段时间内自动切换到备用 Provider。 4. **最小化询问触发**：当上述所有路径均因缺少凭证而无法使用时，Skill 会抛出 `MissingCredentialsError`，并附带清晰的提示，告知 Agent 层应如何向用户请求凭证。 更多细节请参考 `references/usage.md`。 ## 危险操作限制 为了确保安全与合规，本 Skill 严格遵守以下限制： - **不执行 `aime skill upload` 或 `aime skill enable`**：Skill 的打包和部署应由用户或上层 CICD 流程明确发起，Skill 本身不包含任何自动上传或启用的逻辑。 - **不硬编码凭证**：所有 API Key、AK/SK 等敏感信息均通过环境变量或持久化的 `persist/auth.json` 文件读取，代码中不包含任何硬编码的凭证。 - **日志脱敏**：在打印 Debug 日志时，会自动对 API Gateway 地址、API Key 等敏感信息进行脱敏处理，仅打印非敏感的调用摘要。 - **持久化目录隔离**：所有持久化文件（状态、凭证）均存储在 Skill 根目录下的 `persist/` 目录中，确保与工作空间的其他部分隔离。 ## 环境变量在哪里找 本 Skill 在启动时会主动去帮你“找环境变量”，并按固定顺序决定实际生效的凭证来源： - 首先**必须扫描常见 shell 配置文件中的 `export` 行**（`~/.bashrc`、`~/.bash_profile`、`~/.zshrc`、`~/.profile`），只读解析其中形如 `export ARK_*/VOLCSTACK_*` 的简单常量定义，用于收集 Gateway 与 SDK 的候选凭证； - 然后默认假设用户已经在环境变量中配置了凭证：如果当前进程的环境变量（`os.environ`）中已经有对应的 `ARK_*` / `VOLCSTACK_*`，则认为这些值“已经生效”，**会覆盖前面从 rc 文件中收集到的候选值**； - 若在环境变量中仍然缺少成对凭证，则再回退到 Skill 私有的持久化文件 `persist/auth.json`（由 Agent 层在最小化询问后写入），尝试补全缺失的 Gateway 或 SDK 凭证； - 当上述三处都无法提供可用凭证时，才会抛出 `MissingCredentialsError`，由 Agent 层触发“最小化询问”，向用户要必要的少量信息。 顺序：**先扫描 rc → 再读取 os.environ（若已生效覆盖候选）→ 再读 persist/auth.json → 最后触发最小化询问**。 整个过程中，rc 文件的解析始终是只读的： - 只解析简单常量形式的 `export VAR=VALUE` 行，不会执行 `source`，不会展开 `$VAR`、`$()` 等表达式； - 日志中也只会输出脱敏后的摘要，而不会打印明文凭证； - 不会自动写回 rc 或修改用户环境。 典型自查方式包括： - 在终端中查看当前进程环境：`echo $ARK_SKILL_API_BASE` 或 `env | grep ARK_SKILL_API_BASE` - 在 rc 文件中搜索 export 语句：`grep -n "^export ARK_SKILL_API_BASE" ~/.bashrc ~/.bash_profile ~/.zshrc ~/.profile 2>/dev/null` ## 快速验证 你可以通过运行 `scripts/quick_validate.py` 来进行一次静态自检，它会检查关键文件是否存在、Python 版本是否满足要求，以及环境变量的配置情况，但不会发起任何网络请求。 ```bash python3 scripts/quick_validate.py ```

path: scripts/quick_validate.py
content: #!/usr/bin/env python3
# Copyright 2024 ByteDance, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""byted-market-insight-agent 静态自检脚本。

不进行任何外网调用，仅做以下检查：
- 关键文件与目录是否存在；
- Python 版本是否满足推荐要求；
- 关键环境变量是否已设置（仅提示，不强制）；
- 官方 SDK 是否可导入（可选依赖）。
"""

from __future__ import annotations

import importlib.util
import os
import sys
from pathlib import Path


ROOT_DIR = Path(__file__).resolve().parent.parent


def check_python_version() -> bool:
    major, minor = sys.version_info[:2]
    ok = (major, minor) >= (3, 8)
    print(f"[CHECK] Python 版本: {major}.{minor} (要求 >= 3.8) -> {'OK' if ok else 'WARN'}")
    return ok


def check_files() -> bool:
    required = [
        ROOT_DIR / "SKILL.md",
        ROOT_DIR / "scripts" / "client.py",
        ROOT_DIR / "scripts" / "auth_resolver.py",
        ROOT_DIR / "scripts" / "providers" / "gateway_provider.py",
        ROOT_DIR / "scripts" / "providers" / "sdk_provider.py",
        ROOT_DIR / "references" / "usage.md",
        ROOT_DIR / "references" / "api-diff.md",
        ROOT_DIR / "persist",
    ]
    ok = True
    for path in required:
        exists = path.exists()
        kind = "目录" if path.is_dir() else "文件"
        print(f"[CHECK] {kind}: {path.relative_to(ROOT_DIR)!s} -> {'OK' if exists else 'MISSING'}")
        if not exists:
            ok = False
    return ok


def check_env() -> None:
    print("[INFO] 环境变量检查（未设置不影响静态自检，仅影响实际调用）:")
    missing_key = False
    for name in [
        "ARK_SKILL_API_BASE",
        "ARK_SKILL_API_KEY",
        "VOLCSTACK_ACCESS_KEY_ID",
        "VOLCSTACK_SECRET_ACCESS_KEY",
        "VOLCSTACK_REGION",
        "MARKET_INSIGHT_AUTO_PIP",
    ]:
        value = os.getenv(name)
        if value:
            print(f"  - {name}: 已设置（长度={len(value)}）")
        else:
            print(f"  - {name}: 未设置")
            if name in {
                "ARK_SKILL_API_BASE",
                "ARK_SKILL_API_KEY",
                "VOLCSTACK_ACCESS_KEY_ID",
                "VOLCSTACK_SECRET_ACCESS_KEY",
            }:
                missing_key = True
    if missing_key:
        print(
            "[HINT] 检测到部分关键环境变量未设置，可在 ~/.bashrc / ~/.bash_profile / ~/.zshrc / ~/.profile 中查找 export 语句（仅提示，不自动生效）。"
        )


def check_sdk_optional() -> None:
    print("[INFO] 官方 SDK 可选依赖检查：")
    spec_core = importlib.util.find_spec("volcenginesdkcore")
    spec_insight = importlib.util.find_spec("volcenginesdkinsight")
    if spec_core and spec_insight:
        print("  - volcengine SDK: 已检测到 (volcenginesdkcore / volcenginesdkinsight)")
    else:
        print(
            "  - volcengine SDK: 未检测到（运行时需要时将由 auth_resolver 自动尝试安装）"
        )


def main() -> int:
    print("=== byted-market-insight-agent 静态自检 ===")
    ok_py = check_python_version()
    ok_files = check_files()
    check_env()
    check_sdk_optional()

    all_ok = ok_py and ok_files
    print(f"[RESULT] 静态自检 {'通过' if all_ok else '存在问题，请根据上方提示修复后再打包'}")
    return 0 if all_ok else 1


if __name__ == "__main__":  # pragma: no cover
    raise SystemExit(main())


path: scripts/client.py
content: #!/usr/bin/env python3
# Copyright 2024 ByteDance, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""市场洞察 Agent 统一客户端入口。

对外暴露三个函数：
- list_custom_subs_task
- pull_post
- query_clue_info

内部通过 auth_resolver 自动选择 Gateway 或 SDK Provider：
- 默认优先 Gateway，按会话状态实现粘性；
- 在需要 SDK 时自动安装依赖；
- 统一返回结构；
- 提供 CLI 入口便于本地调试。
"""

from __future__ import annotations

import argparse
import json
import sys
from typing import Any, Dict, Optional, Set

from auth_resolver import (
    AuthError,
    NetworkError,
    MissingCredentialsError,
    MINIMAL_ASK_HINT,
    get_provider,
    mark_degraded,
    record_success,
)


# ------------------------ 内部调度封装 ------------------------


def _invoke_with_fallback(method_name: str, **params: Any) -> Dict[str, Any]:
    """统一的调度封装：

    - 首次按照会话粘性 + 默认优先级选择 Provider；
    - 若发生 AuthError，则将当前 Provider 标记为降级并尝试另一条链路；
    - 若所有 Provider 均不可用，则抛出 MissingCredentialsError，交由 Agent 层进行最小化询问；
    - NetworkError 直接向上抛出（内部已做退避重试）。
    """

    exclude: Set[str] = set()
    last_error: Optional[Exception] = None

    # 最多尝试两条 Provider 链路（gateway + sdk）
    for _ in range(2):
        provider = get_provider(exclude)
        try:
            func = getattr(provider, method_name)
        except AttributeError as exc:  # noqa: BLE001
            raise RuntimeError(
                f"Provider {provider.name} 不支持方法 {method_name}"
            ) from exc

        try:
            result = func(**params)
            record_success(provider.name)
            return result
        except AuthError as exc:
            # 鉴权错误：标记当前 Provider 降级，尝试其他链路
            mark_degraded(provider.name, exc)
            exclude.add(provider.name)
            last_error = exc
        except NetworkError as exc:
            # 网络错误：不切换链路，直接抛出
            last_error = exc
            raise

    # 所有 Provider 均不可用：触发最小化询问
    if isinstance(last_error, AuthError) or last_error is None:
        raise MissingCredentialsError()

    # 兜底：返回最后一个错误
    raise last_error


# ------------------------ 对外函数（Python 调用） ------------------------


def list_custom_subs_task(
    *,
    Status: int = 2,
    TaskName: Optional[str] = None,
    PageNum: int = 1,
    PageSize: int = 30,
) -> Dict[str, Any]:
    """查询订阅/监控任务列表（ListCustomSubsTask）。

    返回结构：{"InsightSaasTaskList": [...], "Total": int}
    """

    return _invoke_with_fallback(
        "list_custom_subs_task",
        Status=Status,
        TaskName=TaskName,
        PageNum=PageNum,
        PageSize=PageSize,
    )


def pull_post(
    *,
    TaskID: int,
    StartTime: str,
    EndTime: str,
    Size: int = 50,
    PageToken: Optional[str] = None,
) -> Dict[str, Any]:
    """拉取监测任务的 AI 精筛数据（PullPost）。

    返回结构：{"ItemDocs": [...], "HasMore": bool, "NextPageToken": str | None}
    """

    return _invoke_with_fallback(
        "pull_post",
        TaskID=TaskID,
        StartTime=StartTime,
        EndTime=EndTime,
        Size=Size,
        PageToken=PageToken,
    )


def query_clue_info(
    *,
    StartTime: str,
    EndTime: str,
    MaxResults: int = 10,
    NextToken: Optional[str] = None,
) -> Dict[str, Any]:
    """查询商机信息（QueryClueInfo）。

    返回结构：{"ClueList": [...], "NextToken": str | None, "ResultCnt": int}
    其中 ClueText 字段若存在，将被解析为结构化对象。
    """

    return _invoke_with_fallback(
        "query_clue_info",
        StartTime=StartTime,
        EndTime=EndTime,
        MaxResults=MaxResults,
        NextToken=NextToken,
    )


# ------------------------ CLI 入口 ------------------------


def _build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="市场洞察 Agent 统一客户端（Gateway + SDK 无感切换）",
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    # list_custom_subs_task
    p_list = subparsers.add_parser(
        "list_custom_subs_task",
        help="查询订阅/监控任务列表 (ListCustomSubsTask)",
    )
    p_list.add_argument("--Status", type=int, default=2, choices=[1, 2], help="任务状态过滤：1=运行中, 2=全部（默认 2）")
    p_list.add_argument("--TaskName", type=str, default=None, help="按任务名称模糊搜索（可选）")
    p_list.add_argument("--PageNum", type=int, default=1, help="页码，从 1 开始（默认 1）")
    p_list.add_argument("--PageSize", type=int, default=30, help="每页条数（默认 30）")

    # pull_post
    p_pull = subparsers.add_parser(
        "pull_post",
        help="拉取监测任务 AI 精筛数据 (PullPost)",
    )
    p_pull.add_argument("--TaskID", type=int, required=True, help="监测任务 ID")
    p_pull.add_argument("--StartTime", type=str, required=True, help='数据起始时间，格式 "YYYY-MM-DD HH:MM:SS"')
    p_pull.add_argument("--EndTime", type=str, required=True, help='数据结束时间，格式 "YYYY-MM-DD HH:MM:SS"')
    p_pull.add_argument("--Size", type=int, default=50, help="每页条数（默认 50）")
    p_pull.add_argument("--PageToken", type=str, default=None, help="分页游标（可选）")

    # query_clue_info
    p_clue = subparsers.add_parser(
        "query_clue_info",
        help="查询商机信息 (QueryClueInfo)",
    )
    p_clue.add_argument("--StartTime", type=str, required=True, help='数据起始时间，格式 "YYYY-MM-DD HH:MM:SS"')
    p_clue.add_argument("--EndTime", type=str, required=True, help='数据结束时间，格式 "YYYY-MM-DD HH:MM:SS"')
    p_clue.add_argument("--MaxResults", type=int, default=10, help="每页最大返回条数（默认 10）")
    p_clue.add_argument("--NextToken", type=str, default=None, help="分页游标（可选）")

    return parser


def _run_cli(argv: Optional[list[str]] = None) -> int:
    parser = _build_parser()
    args = parser.parse_args(argv)

    try:
        if args.command == "list_custom_subs_task":
            result = list_custom_subs_task(
                Status=args.Status,
                TaskName=args.TaskName,
                PageNum=args.PageNum,
                PageSize=args.PageSize,
            )
        elif args.command == "pull_post":
            result = pull_post(
                TaskID=args.TaskID,
                StartTime=args.StartTime,
                EndTime=args.EndTime,
                Size=args.Size,
                PageToken=args.PageToken,
            )
        elif args.command == "query_clue_info":
            result = query_clue_info(
                StartTime=args.StartTime,
                EndTime=args.EndTime,
                MaxResults=args.MaxResults,
                NextToken=args.NextToken,
            )
        else:  # 理论上不会触达
            parser.print_help()
            return 1

        print(json.dumps(result, ensure_ascii=False, indent=2))
        return 0

    except MissingCredentialsError as exc:
        # 提示 Agent 层应进行最小化询问
        print(str(exc or MINIMAL_ASK_HINT), file=sys.stderr)
        return 2
    except AuthError as exc:
        print(f"鉴权错误：{exc}", file=sys.stderr)
        return 3
    except NetworkError as exc:
        print(f"网络/网关错误：{exc}", file=sys.stderr)
        return 4
    except KeyboardInterrupt:
        print("已中断", file=sys.stderr)
        return 130


if __name__ == "__main__":  # pragma: no cover
    raise SystemExit(_run_cli())


path: scripts/auth_resolver.py
content: #!/usr/bin/env python3
# Copyright 2024 ByteDance, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""市场洞察 Agent 统一鉴权与 Provider 选择模块。

职责：
- 读取环境变量与本地持久化状态/凭证；
- 在 Gateway 与 SDK 之间做出决策，默认优先 Gateway；
- 实现会话级粘性（尽量复用上一次成功的 Provider）；
- 在需要 SDK 时自动安装 `volcengine-python-sdk>=5.0.22`；
- 将状态持久化到 `persist/state.json`，凭证持久化到 `persist/auth.json`；
- 暴露“最小化询问”占位提示，由 Agent 层据此调用 ask_user 获取凭证。

注意：本模块不做任何真实网络调用，只负责决策与配置。
"""

from __future__ import annotations

import json
import os
import re
import subprocess
import sys
import time
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Dict, Optional, Set

# ------------------------ 路径与常量 ------------------------

ROOT_DIR = Path(__file__).resolve().parent.parent
PERSIST_DIR = ROOT_DIR / "persist"
STATE_FILE = PERSIST_DIR / "state.json"
AUTH_FILE = PERSIST_DIR / "auth.json"

# SDK 依赖要求
SDK_PACKAGE_NAME = "volcengine-python-sdk"
SDK_MIN_VERSION_SPEC = "volcengine-python-sdk>=5.0.22"

# 提示 Agent 层如何最小化询问用户
MINIMAL_ASK_HINT = (
    "当前缺少可用的市场洞察访问凭证。\n"
    "Agent 层应调用 ask_user，引导用户择一提供：\n"
    "1) API Gateway 地址 + API Key (ARK_SKILL_API_BASE / ARK_SKILL_API_KEY)，或\n"
    "2) 官方 AK/SK (VOLCSTACK_ACCESS_KEY_ID / VOLCSTACK_SECRET_ACCESS_KEY)。\n"
    "一套凭证即可，Skill 会自动选择最合适的链路，并在本地持久化，后续会话复用。"
)


# ------------------------ 自定义异常 ------------------------


class AuthError(RuntimeError):
    """鉴权失败：凭证缺失或无效。"""


class NetworkError(RuntimeError):
    """网络或网关错误（含 429 限流），已经过内部重试。"""


class MissingCredentialsError(RuntimeError):
    """完全缺少可用凭证时抛出，由 Agent 层触发最小化询问。"""

    def __init__(self, message: str | None = None) -> None:
        super().__init__(message or MINIMAL_ASK_HINT)


class ProviderNotAvailableError(RuntimeError):
    """指定 Provider 不可用（如被标记为降级或缺少依赖）。"""


class SdkNotInstalledError(RuntimeError):
    """SDK 未安装或无法导入。"""


# ------------------------ 状态与凭证结构 ------------------------


@dataclass
class ProviderState:
    """Provider 运行状态，持久化到 state.json。

    字段说明：
    - provider: 上一次成功使用的 Provider 名称（"gateway" / "sdk"）。
    - degraded: 某个 Provider 是否处于降级状态（例如近期连续鉴权失败）。
    - sdk_installed: 是否已经在当前环境成功安装并导入过 SDK。
    - sdk_install_failed: SDK 安装是否已失败（失败后不再反复尝试）。
    - last_success_at: 最近一次成功调用的时间戳（秒）。
    - last_error: 最近一次错误的摘要信息（供调试使用，不含敏感信息）。
    """

    provider: Optional[str] = None
    degraded: Dict[str, bool] = field(
        default_factory=lambda: {"gateway": False, "sdk": False}
    )
    sdk_installed: bool = False
    sdk_install_failed: bool = False
    last_success_at: Optional[float] = None
    last_error: Optional[Dict[str, str]] = None


@dataclass
class AuthConfig:
    """统一的鉴权配置视图，综合环境变量与本地持久化 auth.json。

    gateway_* 字段用于 Gateway 调用；sdk_* 字段用于 SDK 调用。
    环境变量始终优先于本地 auth.json。
    """

    gateway_api_base: Optional[str] = None
    gateway_api_key: Optional[str] = None
    sdk_access_key_id: Optional[str] = None
    sdk_secret_access_key: Optional[str] = None
    sdk_region: str = "cn-beijing"


def _mask_value(value: str) -> str:
    """对环境变量值做简单脱敏处理：保留前 4 位和后 2 位，中间使用 ***。

    说明：对于长度较短的值，仍会进行脱敏，避免直接暴露完整内容。
    """
    v = value.strip()
    if not v:
        return "***"
    if len(v) <= 6:
        # 短值仅保留首尾一位
        if len(v) <= 2:
            return "***"
        return f"{v[0]}***{v[-1]}"
    return f"{v[:4]}***{v[-2:]}"


# 常见 rc 文件中允许解析的目标变量集合
RC_ENV_TARGET_VARS = {
    "ARK_SKILL_API_BASE",
    "ARK_SKILL_API_KEY",
    "VOLCSTACK_ACCESS_KEY_ID",
    "VOLCSTACK_SECRET_ACCESS_KEY",
    "VOLCSTACK_REGION",
}

# 缓存 rc 解析结果，避免多次重复读取
_RC_ENV_CACHE: Dict[str, str] = {}
_RC_ENV_SOURCE: Dict[str, str] = {}


def load_rc_env() -> Dict[str, str]:
    """从常见 rc 文件中解析 export 行，返回可用的环境变量值（不含脱敏）。

    注意：
    - 不执行 shell，不展开变量；
    - 仅解析形如 `export VAR=VALUE` 的简单单行；
    - VALUE 中若包含 `$`、`` ` ``、`$(` 等符号，则视为不可解析并跳过；
    - 仅收集 RC_ENV_TARGET_VARS 中定义的变量；
    - 若同一变量在多个文件中出现，只保留最先解析到的一份。
    """
    if _RC_ENV_CACHE:
        return dict(_RC_ENV_CACHE)

    home = Path.home()
    rc_files = [
        ("~/.bashrc", home / ".bashrc"),
        ("~/.bash_profile", home / ".bash_profile"),
        ("~/.zshrc", home / ".zshrc"),
        ("~/.profile", home / ".profile"),
    ]

    pattern = re.compile(r"^\s*export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$")

    for display_name, path in rc_files:
        if not path.exists() or not path.is_file():
            continue

        try:
            content = path.read_text(encoding="utf-8", errors="ignore")
        except Exception:
            # 单个文件读取失败不影响整体结果
            continue

        for line in content.splitlines():
            match = pattern.match(line)
            if not match:
                continue

            var_name, raw_value = match.group(1), match.group(2).strip()
            if var_name not in RC_ENV_TARGET_VARS:
                continue
            if var_name in _RC_ENV_CACHE:
                # 已经解析过该变量，保持最先出现的定义
                continue
            if not raw_value:
                continue

            # 对包含明显 shell 展开/命令替换的复杂表达式不做解析
            if any(sym in raw_value for sym in ("$(", "`")):
                continue

            # 去掉成对包裹的引号
            if (
                (raw_value.startswith('"') and raw_value.endswith('"'))
                or (raw_value.startswith("'") and raw_value.endswith("'"))
            ):
                raw_value = raw_value[1:-1].strip()

            if not raw_value:
                continue

            _RC_ENV_CACHE[var_name] = raw_value
            _RC_ENV_SOURCE[var_name] = display_name

    return dict(_RC_ENV_CACHE)


def discover_env_candidates() -> list[dict]:
    """只读扫描常见 shell rc 文件中的环境变量定义（脱敏后用于提示）。

    仅在当前进程缺少 ARK_* 或 VOLCSTACK_* 中任意一组凭证时才有意义：
    - 扫描 ~/.bashrc、~/.bash_profile、~/.zshrc、~/.profile（若存在）；
    - 仅匹配形如 `export VAR=VALUE` 的简单单行；
    - 支持的变量包括 RC_ENV_TARGET_VARS 中的定义；
    - 返回示例结构：
      {"file": "~/.bashrc", "vars": {"ARK_SKILL_API_BASE": "xxxx***yy", ...}} 列表。
    """
    # 当前进程如果已经具备两组凭证，则无需扫描 rc 文件
    has_ark = bool(os.getenv("ARK_SKILL_API_BASE") and os.getenv("ARK_SKILL_API_KEY"))
    has_volc = bool(
        os.getenv("VOLCSTACK_ACCESS_KEY_ID")
        and os.getenv("VOLCSTACK_SECRET_ACCESS_KEY")
    )
    if has_ark and has_volc:
        return []

    rc_env = load_rc_env()
    if not rc_env:
        return []

    # 根据变量来源文件聚合，并对值做脱敏
    file_to_vars: Dict[str, Dict[str, str]] = {}
    for var_name, raw_value in rc_env.items():
        file_label = _RC_ENV_SOURCE.get(var_name, "")
        if not file_label:
            continue
        masked = _mask_value(raw_value)
        bucket = file_to_vars.setdefault(file_label, {})
        bucket[var_name] = masked

    results: list[dict] = []
    for file_label, vars_map in file_to_vars.items():
        if vars_map:
            results.append({"file": file_label, "vars": vars_map})

    return results


# ------------------------ 文件读写工具 ------------------------


def _safe_read_json(path: Path) -> Dict:
    if not path.exists():
        return {}
    try:
        text = path.read_text(encoding="utf-8")
        if not text.strip():
            return {}
        return json.loads(text)
    except Exception:
        # 损坏或不可解析时忽略，避免阻塞运行
        return {}


def _safe_write_json(path: Path, data: Dict) -> None:
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        # 避免 NaN/Infinity 等不可序列化值
        payload = json.dumps(data, ensure_ascii=False)
        path.write_text(payload, encoding="utf-8")
    except Exception:
        # 状态写入失败不应影响主流程
        return


def load_state() -> ProviderState:
    data = _safe_read_json(STATE_FILE)
    if not data:
        return ProviderState()

    try:
        return ProviderState(
            provider=data.get("provider"),
            degraded=data.get("degraded") or {"gateway": False, "sdk": False},
            sdk_installed=bool(data.get("sdk_installed")),
            sdk_install_failed=bool(data.get("sdk_install_failed")),
            last_success_at=data.get("last_success_at"),
            last_error=data.get("last_error"),
        )
    except Exception:
        return ProviderState()


def save_state(state: ProviderState) -> None:
    _safe_write_json(STATE_FILE, asdict(state))


def load_auth() -> AuthConfig:
    """合并环境变量、本地 auth.json 与 rc 自动填充，优先级为：
    os.environ > auth.json > rc 文件。
    """

    raw = _safe_read_json(AUTH_FILE)
    gateway_raw = raw.get("gateway") if isinstance(raw.get("gateway"), dict) else {}
    sdk_raw = raw.get("sdk") if isinstance(raw.get("sdk"), dict) else {}

    # 1) 环境变量优先，其次为 auth.json
    gateway_api_base = os.getenv("ARK_SKILL_API_BASE") or gateway_raw.get("api_base")
    gateway_api_key = os.getenv("ARK_SKILL_API_KEY") or gateway_raw.get("api_key")

    sdk_access_key_id = os.getenv("VOLCSTACK_ACCESS_KEY_ID") or sdk_raw.get(
        "access_key_id"
    )
    sdk_secret_access_key = os.getenv("VOLCSTACK_SECRET_ACCESS_KEY") or sdk_raw.get(
        "secret_access_key"
    )
    sdk_region_env = os.getenv("VOLCSTACK_REGION")
    sdk_region_auth = sdk_raw.get("region")
    sdk_region = sdk_region_env or sdk_region_auth or "cn-beijing"

    auth = AuthConfig(
        gateway_api_base=gateway_api_base,
        gateway_api_key=gateway_api_key,
        sdk_access_key_id=sdk_access_key_id,
        sdk_secret_access_key=sdk_secret_access_key,
        sdk_region=sdk_region,
    )

    # 2) 若某一 Provider 的成对变量缺失，则尝试从 rc 文件自动填充（最低优先级）
    need_gateway = not (auth.gateway_api_base and auth.gateway_api_key)
    need_sdk = not (auth.sdk_access_key_id and auth.sdk_secret_access_key)

    if need_gateway or need_sdk:
        rc_env = load_rc_env()

        # Gateway: 需要同时存在 BASE 与 KEY
        if need_gateway:
            rc_base = rc_env.get("ARK_SKILL_API_BASE")
            rc_key = rc_env.get("ARK_SKILL_API_KEY")
            if rc_base and rc_key:
                auth.gateway_api_base = rc_base
                auth.gateway_api_key = rc_key
                file_label = (
                    _RC_ENV_SOURCE.get("ARK_SKILL_API_BASE")
                    or _RC_ENV_SOURCE.get("ARK_SKILL_API_KEY")
                    or "rc 文件"
                )
                masked_base = _mask_value(rc_base)
                masked_key = _mask_value(rc_key)
                print(
                    "[INFO] 从 {file} 检测到 Gateway 凭证定义（已自动采用，未持久化）："
                    "ARK_SKILL_API_BASE={base}, ARK_SKILL_API_KEY={key}".format(
                        file=file_label,
                        base=masked_base,
                        key=masked_key,
                    )
                )

        # SDK: 需要同时存在 AK 与 SK，可选 region
        if need_sdk:
            rc_ak = rc_env.get("VOLCSTACK_ACCESS_KEY_ID")
            rc_sk = rc_env.get("VOLCSTACK_SECRET_ACCESS_KEY")
            if rc_ak and rc_sk:
                auth.sdk_access_key_id = rc_ak
                auth.sdk_secret_access_key = rc_sk
                # 仅当环境变量/持久化中未显式配置 region 时，才使用 rc 中的 region
                rc_region = rc_env.get("VOLCSTACK_REGION")
                if rc_region and not (sdk_region_env or sdk_region_auth):
                    auth.sdk_region = rc_region

                file_label = (
                    _RC_ENV_SOURCE.get("VOLCSTACK_ACCESS_KEY_ID")
                    or _RC_ENV_SOURCE.get("VOLCSTACK_SECRET_ACCESS_KEY")
                    or "rc 文件"
                )
                masked_ak = _mask_value(rc_ak)
                masked_sk = _mask_value(rc_sk)
                print(
                    "[INFO] 从 {file} 检测到 SDK 凭证定义（已自动采用，未持久化）："
                    "VOLCSTACK_ACCESS_KEY_ID={ak}, VOLCSTACK_SECRET_ACCESS_KEY={sk}".format(
                        file=file_label,
                        ak=masked_ak,
                        sk=masked_sk,
                    )
                )

    return auth


def save_auth_gateway(api_base: str, api_key: str) -> None:
    raw = _safe_read_json(AUTH_FILE)
    raw["gateway"] = {
        "api_base": api_base,
        "api_key": api_key,
    }
    _safe_write_json(AUTH_FILE, raw)


def save_auth_sdk(access_key_id: str, secret_access_key: str, region: str) -> None:
    raw = _safe_read_json(AUTH_FILE)
    raw["sdk"] = {
        "access_key_id": access_key_id,
        "secret_access_key": secret_access_key,
        "region": region,
    }
    _safe_write_json(AUTH_FILE, raw)


# ------------------------ SDK 安装与检测 ------------------------


def _try_import_sdk() -> bool:
    """尝试导入 SDK 相关模块。成功返回 True，失败返回 False。"""

    try:
        import importlib

        importlib.import_module("volcenginesdkinsight")
        importlib.import_module("volcenginesdkcore")
        return True
    except Exception:
        return False


def ensure_sdk_available(state: Optional[ProviderState] = None) -> bool:
    """确保 SDK 可用：

    - 若已标记安装失败，直接返回 False；
    - 若已安装且可导入，返回 True；
    - 否则尝试通过 pip 安装一次 `volcengine-python-sdk>=5.0.22`；
    - 安装成功则返回 True，失败时标记 sdk_install_failed，返回 False。
    """

    st = state or load_state()

    if st.sdk_install_failed:
        return False

    if _try_import_sdk():
        if not st.sdk_installed:
            st.sdk_installed = True
            save_state(st)
        return True

    # 尝试自动安装
    auto_pip_flag = os.getenv("MARKET_INSIGHT_AUTO_PIP", "1").lower()
    allow_auto_pip = auto_pip_flag in {"1", "true", "yes", "y"}

    if not allow_auto_pip:
        return False

    print(
        "[INFO] 检测到 SDK 未安装，正在自动安装 "
        f"{SDK_MIN_VERSION_SPEC}（仅在缺少时执行一次）..."
    )

    cmd = [sys.executable, "-m", "pip", "install", SDK_MIN_VERSION_SPEC]

    try:
        proc = subprocess.run(
            cmd,
            check=False,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
        )
    except Exception as exc:  # noqa: BLE001
        print(f"[WARN] 自动安装 SDK 失败: {exc}")
        st.sdk_install_failed = True
        save_state(st)
        return False

    if proc.returncode != 0:
        print("[WARN] 自动安装 SDK 失败，后续将不再自动重试。")
        st.sdk_install_failed = True
        save_state(st)
        return False

    if _try_import_sdk():
        st.sdk_installed = True
        st.sdk_install_failed = False
        save_state(st)
        print("[INFO] SDK 安装并导入成功。")
        return True

    print("[WARN] SDK 安装后仍无法导入，将标记为安装失败。")
    st.sdk_install_failed = True
    save_state(st)
    return False


# ------------------------ Provider 封装 ------------------------


class BaseProvider:
    """统一的 Provider 抽象，具体实现由 Gateway / SDK 封装。"""

    name: str

    def list_custom_subs_task(self, **params):  # type: ignore[no-untyped-def]
        raise NotImplementedError

    def pull_post(self, **params):  # type: ignore[no-untyped-def]
        raise NotImplementedError

    def query_clue_info(self, **params):  # type: ignore[no-untyped-def]
        raise NotImplementedError


class GatewayProviderWrapper(BaseProvider):
    def __init__(self, api_base: str, api_key: str) -> None:
        self.name = "gateway"
        # 避免重复斜杠
        self.api_base = api_base.rstrip("/")
        self.[REDACTED_SECRET]

        # 延迟导入，避免初始化阶段的循环依赖
        from providers import gateway_provider  # type: ignore[import]

        self._impl = gateway_provider

    def list_custom_subs_task(self, **params):  # type: ignore[no-untyped-def]
        return self._impl.list_custom_subs_task(
            api_base=self.api_base,
            [REDACTED_SECRET],
            **params,
        )

    def pull_post(self, **params):  # type: ignore[no-untyped-def]
        return self._impl.pull_post(
            api_base=self.api_base,
            [REDACTED_SECRET],
            **params,
        )

    def query_clue_info(self, **params):  # type: ignore[no-untyped-def]
        return self._impl.query_clue_info(
            api_base=self.api_base,
            [REDACTED_SECRET],
            **params,
        )


class SdkProviderWrapper(BaseProvider):
    def __init__(self, access_key_id: str, secret_access_key: str, region: str) -> None:
        self.name = "sdk"
        self.[REDACTED_SECRET]
        self.secret_access_key = secret_access_key
        self.region = region

        if not ensure_sdk_available():
            raise SdkNotInstalledError(
                "volcengine-python-sdk 未安装或无法导入，且自动安装失败。"
            )

        from providers import sdk_provider  # type: ignore[import]

        self._impl = sdk_provider

    def list_custom_subs_task(self, **params):  # type: ignore[no-untyped-def]
        return self._impl.list_custom_subs_task(
            [REDACTED_SECRET],
            secret_access_key=self.secret_access_key,
            region=self.region,
            **params,
        )

    def pull_post(self, **params):  # type: ignore[no-untyped-def]
        return self._impl.pull_post(
            [REDACTED_SECRET],
            secret_access_key=self.secret_access_key,
            region=self.region,
            **params,
        )

    def query_clue_info(self, **params):  # type: ignore[no-untyped-def]
        return self._impl.query_clue_info(
            [REDACTED_SECRET],
            secret_access_key=self.secret_access_key,
            region=self.region,
            **params,
        )


# ------------------------ Provider 选择逻辑 ------------------------


def _pick_sticky_provider(
    state: ProviderState,
    auth: AuthConfig,
    exclude: Set[str],
) -> Optional[BaseProvider]:
    """若存在上一次成功 Provider 且未被排除，则优先尝试。"""

    name = state.provider
    if not name or name in exclude:
        return None

    if name == "gateway":
        if (
            auth.gateway_api_base
            and auth.gateway_api_key
            and not state.degraded.get("gateway", False)
        ):
            return GatewayProviderWrapper(auth.gateway_api_base, auth.gateway_api_key)

    if name == "sdk":
        if (
            auth.sdk_access_key_id
            and auth.sdk_secret_access_key
            and not state.degraded.get("sdk", False)
        ):
            return SdkProviderWrapper(
                auth.sdk_access_key_id,
                auth.sdk_secret_access_key,
                auth.sdk_region,
            )

    return None


def _pick_fresh_provider(
    state: ProviderState,
    auth: AuthConfig,
    exclude: Set[str],
) -> Optional[BaseProvider]:
    """按优先级选择 Provider：Gateway → SDK。"""

    # 默认优先 Gateway
    if (
        "gateway" not in exclude
        and auth.gateway_api_base
        and auth.gateway_api_key
        and not state.degraded.get("gateway", False)
    ):
        return GatewayProviderWrapper(auth.gateway_api_base, auth.gateway_api_key)

    # 其次 SDK
    if (
        "sdk" not in exclude
        and auth.sdk_access_key_id
        and auth.sdk_secret_access_key
        and not state.degraded.get("sdk", False)
    ):
        return SdkProviderWrapper(
            auth.sdk_access_key_id,
            auth.sdk_secret_access_key,
            auth.sdk_region,
        )

    return None


def get_provider(exclude: Optional[Set[str]] = None) -> BaseProvider:
    """决策当前应使用的 Provider。

    优先级：
    1. 若 state.provider 存在且未被排除，且对应凭证与状态可用，则优先使用（会话级粘性）；
    2. 否则按默认顺序尝试：Gateway → SDK；
    3. 若两者均不可用，则抛出 MissingCredentialsError，交由 Agent 层进行最小化询问。
    """

    exclude = exclude or set()
    state = load_state()
    auth = load_auth()

    # 先尝试粘性 Provider
    try:
        sticky = _pick_sticky_provider(state, auth, exclude)
        if sticky is not None:
            print(f"[INFO] 使用会话粘性 Provider: {sticky.name}")
            return sticky
    except (SdkNotInstalledError, ProviderNotAvailableError):
        # 如果粘性 Provider 初始化失败，继续尝试后续 Provider
        pass

    # 再按默认顺序选择
    try:
        fresh = _pick_fresh_provider(state, auth, exclude)
        if fresh is not None:
            print(f"[INFO] 使用 Provider: {fresh.name}")
            return fresh
    except SdkNotInstalledError:
        # SDK 初始化失败时，不再尝试 SDK
        state.degraded["sdk"] = True
        save_state(state)

    # 均不可用：缺少凭证或已全部降级
    candidates = discover_env_candidates()
    if candidates:
        # 在异常信息中追加只读环境变量位置提示，帮助用户排查 rc 文件
        lines = [
            "另：在以下文件中发现可能的环境变量定义，但当前进程未生效："
        ]
        for item in candidates:
            file_label = item.get("file") or ""
            vars_map = item.get("vars") or {}
            if not vars_map:
                continue
            pairs = ", ".join(f"{k}={v}" for k, v in vars_map.items())
            lines.append(f"- {file_label}: {pairs}")
        lines.append("请确认是否 export 以及启动环境是否 source 相应 rc 文件。")
        extra_msg = "\n".join(lines)
        raise MissingCredentialsError(f"{MINIMAL_ASK_HINT}\n\n{extra_msg}")
    raise MissingCredentialsError()


# ------------------------ 状态更新工具 ------------------------


def record_success(provider_name: str) -> None:
    """记录 Provider 调用成功，更新粘性状态。"""

    state = load_state()
    state.provider = provider_name
    state.degraded[provider_name] = False
    state.last_success_at = time.time()
    state.last_error = None
    save_state(state)


def mark_degraded(provider_name: str, error: Exception) -> None:
    """将指定 Provider 标记为降级，并记录错误摘要。"""

    state = load_state()
    state.degraded[provider_name] = True
    state.last_error = {
        "provider": provider_name,
        "type": error.__class__.__name__,
        "message": str(error)[:200],  # 避免异常文本过长
    }
    save_state(state)


__all__ = [
    "AuthError",
    "NetworkError",
    "MissingCredentialsError",
    "ProviderNotAvailableError",
    "SdkNotInstalledError",
    "ProviderState",
    "AuthConfig",
    "MINIMAL_ASK_HINT",
    "load_rc_env",
    "discover_env_candidates",
    "load_state",
    "save_state",
    "load_auth",
    "save_auth_gateway",
    "save_auth_sdk",
    "ensure_sdk_available",
    "BaseProvider",
    "GatewayProviderWrapper",
    "SdkProviderWrapper",
    "get_provider",
    "record_success",
    "mark_degraded",
]


path: scripts/providers/gateway_provider.py
content: #!/usr/bin/env python3
# Copyright 2024 ByteDance, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Gateway Provider 实现。

使用 urllib 通过 API Gateway 调用市场洞察三大接口：
- ListCustomSubsTask（GET + JSON Body）
- PullPost（POST + JSON Body）
- QueryClueInfo（GET + JSON Body）

本模块：
- 仅依赖标准库；
- 统一解析网关返回的 Result；
- 对 QueryClueInfo 的 ClueText 做 json.loads 解析；
- 对网络错误和 429 做退避重试；
- 不打印密钥或完整网关地址，仅输出非敏感 Debug 摘要。
"""

from __future__ import annotations

import json
import time
import urllib.error
import urllib.request
from typing import Any, Dict, Optional

from auth_resolver import AuthError, NetworkError

API_VERSION = "2025-09-05"
DEFAULT_TIMEOUT = 30
MAX_RETRIES = 3
BACKOFF_BASE_SECONDS = 0.5


def _sanitize_host(url: str) -> str:
    """去掉 scheme 和查询参数，只保留主机部分并做简单截断。"""

    # 形如 https://host/path?query
    without_scheme = url.split("://", 1)[-1]
    host = without_scheme.split("/", 1)[0]
    if len(host) > 40:
        return host[:37] + "..."
    return host


def _debug_request_summary(action: str, method: str, url: str, payload: Dict[str, Any]) -> None:
    host = _sanitize_host(url)
    page_num = payload.get("PageNum")
    page_size = payload.get("PageSize") or payload.get("Size") or payload.get("MaxResults")
    print(
        f"[DEBUG][gateway] Action={action} method={method} host={host} "
        f"PageNum={page_num} PageSize/Size/MaxResults={page_size}"
    )


def _debug_response_summary(action: str, body: bytes) -> None:
    try:
        text = body.decode("utf-8", errors="replace")
    except Exception:  # noqa: BLE001
        return
    snippet = text[:500]
    print(
        f"[DEBUG][gateway] Action={action} 响应体前 500 字符:" f" {snippet!r}"
    )


def _build_url(api_base: str, action: str) -> str:
    return f"{api_base.rstrip('/')}/?Action={action}&Version={API_VERSION}"


def _do_request(
    *,
    method: str,
    url: str,
    api_key: str,
    payload: Dict[str, Any],
    action: str,
) -> Dict[str, Any]:
    """执行单次 HTTP 请求并解析 JSON 响应，包含重试与错误分类。"""

    data = json.dumps(payload).encode("utf-8")
    headers = {
        "Content-Type": "application/json; charset=UTF-8",
        "Authorization": f"Bearer {api_key}",
        "ServiceName": "insight",
    }

    for attempt in range(MAX_RETRIES):
        try:
            _debug_request_summary(action, method, url, payload)

            if method == "GET":
                req = urllib.request.Request(url, data=data, headers=headers)
                # 有 body 时 urllib 默认 POST，这里强制改为 GET
                req.get_method = lambda: "GET"  # type: ignore[assignment]
            else:
                req = urllib.request.Request(
                    url, data=data, headers=headers, method="POST"
                )

            with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT) as resp:
                body = resp.read()
                _debug_response_summary(action, body)
                try:
                    parsed = json.loads(body.decode("utf-8"))
                except Exception as exc:  # noqa: BLE001
                    raise NetworkError(
                        f"Gateway 响应不是合法 JSON（Action={action}）: {exc}"
                    ) from exc

                if not isinstance(parsed, dict):
                    raise NetworkError(
                        f"Gateway 响应 JSON 顶层不是对象（Action={action}）"
                    )
                return parsed

        except urllib.error.HTTPError as e:  # HTTP 层错误
            status = e.code
            # 读出错误体但不打印，以避免泄漏敏感信息
            try:
                _ = e.read()
            except Exception:  # noqa: BLE001
                _ = b""

            if status in (401, 403):
                raise AuthError(f"Gateway 鉴权失败（HTTP {status}）") from e

            if status == 429 or 500 <= status < 600:
                # 限流或服务器错误：退避重试
                if attempt < MAX_RETRIES - 1:
                    delay = BACKOFF_BASE_SECONDS * (2 ** attempt)
                    print(
                        f"[WARN][gateway] Action={action} HTTP {status}，" f"{delay:.2f}s 后重试..."
                    )
                    time.sleep(delay)
                    continue
                raise NetworkError(
                    f"Gateway 请求失败（HTTP {status}，已重试 {MAX_RETRIES} 次）"
                ) from e

            # 其它 HTTP 视为一次性错误，不再重试
            raise NetworkError(f"Gateway 请求异常（HTTP {status}）") from e

        except urllib.error.URLError as e:
            # 网络错误（DNS/连接超时等）
            if attempt < MAX_RETRIES - 1:
                delay = BACKOFF_BASE_SECONDS * (2 ** attempt)
                print(
                    f"[WARN][gateway] Action={action} 网络错误 {e.reason}，" f"{delay:.2f}s 后重试..."
                )
                time.sleep(delay)
                continue
            raise NetworkError(
                f"Gateway 网络错误（Action={action}）：{e.reason}"
            ) from e

    # 理论上不会到达这里
    raise NetworkError(f"Gateway 请求失败（Action={action}）：重试耗尽")


# ------------------------ 统一封装的三个接口 ------------------------


def list_custom_subs_task(
    *,
    api_base: str,
    api_key: str,
    Status: int = 2,
    TaskName: Optional[str] = None,
    PageNum: int = 1,
    PageSize: int = 30,
) -> Dict[str, Any]:
    """调用 ListCustomSubsTask，返回统一结构：

    {"InsightSaasTaskList": [...], "Total": int}
    """

    url = _build_url(api_base, "ListCustomSubsTask")

    payload: Dict[str, Any] = {
        "Status": Status,
        "PageNum": PageNum,
        "PageSize": PageSize,
    }
    if TaskName:
        payload["TaskName"] = TaskName

    raw = _do_request(method="GET", url=url, [REDACTED_SECRET], payload=payload, action="ListCustomSubsTask")

    result = raw.get("Result") or {}
    if not isinstance(result, dict):
        raise NetworkError("ListCustomSubsTask 响应缺少 Result 字段或类型错误")

    tasks = result.get("InsightSaasTaskList") or []
    total_raw = result.get("Total", 0)
    try:
        total = int(total_raw)
    except Exception:  # noqa: BLE001
        total = 0

    # 业务为空：返回空列表和 0 即可
    return {
        "InsightSaasTaskList": tasks,
        "Total": total,
    }


def pull_post(
    *,
    api_base: str,
    api_key: str,
    TaskID: int,
    StartTime: str,
    EndTime: str,
    Size: int = 50,
    PageToken: Optional[str] = None,
) -> Dict[str, Any]:
    """调用 PullPost，返回统一结构：

    {"ItemDocs": [...], "HasMore": bool, "NextPageToken": str | None}
    """

    url = _build_url(api_base, "PullPost")

    payload: Dict[str, Any] = {
        "TaskID": TaskID,
        "StartTime": StartTime,
        "EndTime": EndTime,
        "Size": Size,
    }
    if PageToken:
        payload["PageToken"] = PageToken

    raw = _do_request(method="POST", url=url, [REDACTED_SECRET], payload=payload, action="PullPost")

    result = raw.get("Result") or {}
    if not isinstance(result, dict):
        raise NetworkError("PullPost 响应缺少 Result 字段或类型错误")

    docs = result.get("ItemDocs") or []
    has_more = bool(result.get("HasMore", False))
    next_token = result.get("NextPageToken")

    return {
        "ItemDocs": docs,
        "HasMore": has_more,
        "NextPageToken": next_token,
    }


def query_clue_info(
    *,
    api_base: str,
    api_key: str,
    StartTime: str,
    EndTime: str,
    MaxResults: int = 10,
    NextToken: Optional[str] = None,
) -> Dict[str, Any]:
    """调用 QueryClueInfo，返回统一结构：

    {"ClueList": [...], "NextToken": str | None, "ResultCnt": int}

    其中每个元素的 ClueText 字段若为 JSON 字符串，将被解析为对象。
    """

    url = _build_url(api_base, "QueryClueInfo")

    payload: Dict[str, Any] = {
        "StartTime": StartTime,
        "EndTime": EndTime,
        "MaxResults": MaxResults,
    }
    if NextToken:
        payload["NextToken"] = NextToken

    raw = _do_request(method="GET", url=url, [REDACTED_SECRET], payload=payload, action="QueryClueInfo")

    result = raw.get("Result") or {}
    if not isinstance(result, dict):
        raise NetworkError("QueryClueInfo 响应缺少 Result 字段或类型错误")

    clues = result.get("ClueList") or []
    parsed_clues = []
    for item in clues:
        if isinstance(item, dict):
            parsed = dict(item)
        else:
            # 非 dict 的异常数据直接跳过
            continue

        raw_text = parsed.get("ClueText")
        if isinstance(raw_text, str) and raw_text:
            try:
                parsed["ClueText"] = json.loads(raw_text)
            except Exception:  # noqa: BLE001
                # 保留原始字符串，避免因单条解析失败影响整体
                pass

        parsed_clues.append(parsed)

    result_cnt_raw = result.get("ResultCnt")
    try:
        result_cnt = int(result_cnt_raw) if result_cnt_raw is not None else len(parsed_clues)
    except Exception:  # noqa: BLE001
        result_cnt = len(parsed_clues)

    next_token = result.get("NextToken")

    return {
        "ClueList": parsed_clues,
        "NextToken": next_token,
        "ResultCnt": result_cnt,
    }


__all__ = [
    "list_custom_subs_task",
    "pull_post",
    "query_clue_info",
]


path: scripts/providers/sdk_provider.py
content: #!/usr/bin/env python3
# Copyright 2024 ByteDance, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""SDK Provider 实现。

通过官方 `volcengine-python-sdk` 调用市场洞察三大接口：
- ListCustomSubsTask
- PullPost
- QueryClueInfo

本模块：
- 依赖 `volcengine-python-sdk>=5.0.22`；
- 不负责自动安装 SDK（由 auth_resolver.ensure_sdk_available 统一处理）；
- 将统一 PascalCase 参数映射到 SDK 的 snake_case 请求体；
- 返回与 Gateway Provider 一致的顶层结构；
- 对 QueryClueInfo 的 ClueText 做 json.loads 解析，并提供 `ClueText` 字段。
"""

from __future__ import annotations

from typing import Any, Dict, Optional

from auth_resolver import AuthError, NetworkError


def _init_sdk_config(access_key_id: str, secret_access_key: str, region: str) -> None:
    """初始化 SDK 全局配置（每次调用按给定 AK/SK/Region 覆盖）。"""

    try:
        import volcenginesdkcore  # type: ignore[import]
    except ImportError as exc:  # noqa: BLE001
        raise NetworkError(f"导入 volcenginesdkcore 失败: {exc}") from exc

    configuration = volcenginesdkcore.Configuration()
    configuration.ak = access_key_id
    configuration.sk = secret_access_key
    configuration.region = region

    # 重试与超时配置，与便携版示例保持一致
    configuration.auto_retry = True
    configuration.num_max_retries = 5
    configuration.retry_error_codes = {"Throttling", "RequestLimitExceeded"}
    configuration.connect_timeout = 10
    configuration.read_timeout = 30

    volcenginesdkcore.Configuration.set_default(configuration)


def _get_insight_api():
    try:
        from volcenginesdkinsight import INSIGHTApi  # type: ignore[import]
    except ImportError as exc:  # noqa: BLE001
        raise NetworkError(f"导入 volcenginesdkinsight 失败: {exc}") from exc

    return INSIGHTApi()


def _model_to_plain(value: Any) -> Any:
    """将 SDK 返回的模型对象递归转换为基础类型（dict/list/标量）。"""

    if isinstance(value, list):
        return [_model_to_plain(v) for v in value]
    if isinstance(value, dict):
        return {k: _model_to_plain(v) for k, v in value.items()}

    # 优先使用 SDK 模型自带的 to_dict
    if hasattr(value, "to_dict") and callable(getattr(value, "to_dict")):
        try:
            return _model_to_plain(value.to_dict())
        except Exception:  # noqa: BLE001
            return str(value)

    # 回退到 __dict__
    if hasattr(value, "__dict__") and not isinstance(value, type):
        try:
            return _model_to_plain(vars(value))
        except Exception:  # noqa: BLE001
            return str(value)

    return value


def _handle_api_exception(action: str, exc: Exception) -> None:
    """统一将 SDK 异常转换为 AuthError 或 NetworkError。"""

    try:
        from volcenginesdkcore.rest import ApiException  # type: ignore[import]
    except Exception:  # noqa: BLE001
        # 未能导入 ApiException，则一律视为网络错误
        raise NetworkError(f"{action} 调用异常: {exc}") from exc

    if isinstance(exc, ApiException):
        status = getattr(exc, "status", None)
        if status in (401, 403):
            raise AuthError(f"SDK {action} 鉴权失败（HTTP {status}）") from exc
        if status == 429 or (isinstance(status, int) and status >= 500):
            raise NetworkError(f"SDK {action} 请求失败（HTTP {status}）") from exc
        raise NetworkError(f"SDK {action} 调用失败: {exc}") from exc

    raise NetworkError(f"SDK {action} 调用异常: {exc}") from exc


# ------------------------ 统一封装的三个接口 ------------------------


def list_custom_subs_task(
    *,
    access_key_id: str,
    secret_access_key: str,
    region: str,
    Status: int = 2,
    TaskName: Optional[str] = None,
    PageNum: int = 1,
    PageSize: int = 30,
) -> Dict[str, Any]:
    """通过 SDK 调用 ListCustomSubsTask，返回统一结构：

    {"InsightSaasTaskList": [...], "Total": int}
    """

    _init_sdk_config(access_key_id, secret_access_key, region)

    try:
        from volcenginesdkinsight import (  # type: ignore[import]
            INSIGHTApi,
            ListCustomSubsTaskRequest,
        )
    except ImportError as exc:  # noqa: BLE001
        raise NetworkError(f"导入 volcenginesdkinsight 失败: {exc}") from exc

    api = INSIGHTApi()

    # SDK 使用 snake_case 参数
    request = ListCustomSubsTaskRequest(
        status=Status,
        page_num=PageNum,
        page_size=PageSize,
    )
    if TaskName:
        request.task_name = TaskName

    try:
        response = api.list_custom_subs_task(request)
    except Exception as exc:  # noqa: BLE001
        _handle_api_exception("ListCustomSubsTask", exc)
        raise  # for type checker

    tasks = getattr(response, "insight_saas_task_list", None) or []
    total_raw = getattr(response, "total", 0)
    try:
        total = int(total_raw)
    except Exception:  # noqa: BLE001
        total = 0

    tasks_plain = _model_to_plain(tasks)

    return {
        "InsightSaasTaskList": tasks_plain,
        "Total": total,
    }


def pull_post(
    *,
    access_key_id: str,
    secret_access_key: str,
    region: str,
    TaskID: int,
    StartTime: str,
    EndTime: str,
    Size: int = 50,
    PageToken: Optional[str] = None,
) -> Dict[str, Any]:
    """通过 SDK 调用 PullPost，返回统一结构：

    {"ItemDocs": [...], "HasMore": bool, "NextPageToken": str | None}
    """

    _init_sdk_config(access_key_id, secret_access_key, region)

    try:
        from volcenginesdkinsight import (  # type: ignore[import]
            INSIGHTApi,
            PullPostRequest,
        )
    except ImportError as exc:  # noqa: BLE001
        raise NetworkError(f"导入 volcenginesdkinsight 失败: {exc}") from exc

    api = INSIGHTApi()

    request = PullPostRequest(
        task_id=TaskID,
        start_time=StartTime,
        end_time=EndTime,
        size=Size,
    )
    if PageToken:
        request.page_token = PageToken

    try:
        response = api.pull_post(request)
    except Exception as exc:  # noqa: BLE001
        _handle_api_exception("PullPost", exc)
        raise

    docs = getattr(response, "item_docs", None) or []
    has_more = bool(getattr(response, "has_more", False))
    next_page_token = getattr(response, "next_page_token", None)

    docs_plain = _model_to_plain(docs)

    return {
        "ItemDocs": docs_plain,
        "HasMore": has_more,
        "NextPageToken": next_page_token,
    }


def query_clue_info(
    *,
    access_key_id: str,
    secret_access_key: str,
    region: str,
    StartTime: str,
    EndTime: str,
    MaxResults: int = 10,
    NextToken: Optional[str] = None,
) -> Dict[str, Any]:
    """通过 SDK 调用 QueryClueInfo，返回统一结构：

    {"ClueList": [...], "NextToken": str | None, "ResultCnt": int}

    其中每个元素的 ClueText 字段为解析后的对象（若解析成功），并保留原始字段。"""

    _init_sdk_config(access_key_id, secret_access_key, region)

    try:
        from volcenginesdkinsight import (  # type: ignore[import]
            INSIGHTApi,
            QueryClueInfoRequest,
        )
    except ImportError as exc:  # noqa: BLE001
        raise NetworkError(f"导入 volcenginesdkinsight 失败: {exc}") from exc

    api = INSIGHTApi()

    request = QueryClueInfoRequest(
        start_time=StartTime,
        end_time=EndTime,
    )
    if MaxResults:
        request.max_results = MaxResults
    if NextToken:
        request.next_token = NextToken

    try:
        response = api.query_clue_info(request)
    except Exception as exc:  # noqa: BLE001
        _handle_api_exception("QueryClueInfo", exc)
        raise

    clues = getattr(response, "clue_list", None) or []
    next_token = getattr(response, "next_token", None)
    result_cnt_raw = getattr(response, "result_cnt", None)

    clues_plain = _model_to_plain(clues)  # 期望为 List[dict]
    parsed_clues = []
    for item in clues_plain:
        if not isinstance(item, dict):
            continue
        parsed = dict(item)
        raw_text = parsed.get("ClueText") or parsed.get("clue_text")
        if isinstance(raw_text, str) and raw_text:
            try:
                import json

                parsed["ClueText"] = json.loads(raw_text)
            except Exception:  # noqa: BLE001
                # 保留原始字符串
                parsed.setdefault("ClueText", raw_text)
        parsed_clues.append(parsed)

    try:
        result_cnt = int(result_cnt_raw) if result_cnt_raw is not None else len(parsed_clues)
    except Exception:  # noqa: BLE001
        result_cnt = len(parsed_clues)

    return {
        "ClueList": parsed_clues,
        "NextToken": next_token,
        "ResultCnt": result_cnt,
    }


__all__ = [
    "list_custom_subs_task",
    "pull_post",
    "query_clue_info",
]


path: references/usage.md
content: # 使用与集成指南

本指南说明了如何与 `byted-market-insight-agent` Skill 交互，包括统一的接口参数、返回结构、最小化询问的触发条件以及持久化状态文件的字段含义。

## 统一接口与返回结构

无论底层使用 Gateway 还是 SDK，Skill 对外暴露的三个核心函数 (`scripts/client.py`) 始终保持一致的签名和返回结构。所有函数参数和返回值的键名均采用 `PascalCase`。

### 1. `list_custom_subs_task`

查询订阅/监控任务列表。

-   **调用参数**:
    -   `Status` (int, optional): 任务状态过滤。`1`=运行中, `2`=全部状态。默认 `2`。
    -   `TaskName` (str, optional): 按任务名称模糊搜索。默认不过滤。
    -   `PageNum` (int, optional): 页码，从 1 开始。默认 `1`。
    -   `PageSize` (int, optional): 每页条数。默认 `30`。

-   **返回结构**: `Dict[str, Any]`
    ```json
    {
      "InsightSaasTaskList": [
        {
          "TaskID": "1509",
          "Name": "某品牌声量监测",
          "Aim": "...",
          "Status": "1",
          "CreateTime": "1722409980",
          "...": "..."
        }
      ],
      "Total": 1
    }
    ```
    -   `InsightSaasTaskList` (list): 任务对象列表。
    -   `Total` (int): 符合条件的总任务数。

### 2. `pull_post`

拉取指定监测任务的 AI 精筛数据。

-   **调用参数**:
    -   `TaskID` (int, required): 监测任务 ID。
    -   `StartTime` (str, required): 数据起始时间，格式 `"YYYY-MM-DD HH:MM:SS"`。
    -   `EndTime` (str, required): 数据结束时间，格式 `"YYYY-MM-DD HH:MM:SS"`。
    -   `Size` (int, optional): 每页条数。默认 `50`。
    -   `PageToken` (str, optional): 分页游标，首次调用不传，后续从上一次响应中获取。

-   **返回结构**: `Dict[str, Any]`
    ```json
    {
      "ItemDocs": [
        {
          "PostID": "...",
          "Title": "...",
          "Summary": "...",
          "URL": "...",
          "Emotion": "positive",
          "...": "..."
        }
      ],
      "HasMore": true,
      "NextPageToken": "xxx-yyy-zzz"
    }
    ```
    -   `ItemDocs` (list): 数据对象列表。
    -   `HasMore` (bool): 是否还有更多数据可供拉取。
    -   `NextPageToken` (str | None): 下一页的游标。如果为 `None` 或不存在，表示已是最后一页。

### 3. `query_clue_info`

查询 AI 生成的商机信息。

-   **调用参数**:
    -   `StartTime` (str, required): 数据起始时间，格式 `"YYYY-MM-DD HH:MM:SS"`。
    -   `EndTime` (str, required): 数据结束时间，格式 `"YYYY-MM-DD HH:MM:SS"`。
    -   `MaxResults` (int, optional): 每页最大返回条数。默认 `10`。
    -   `NextToken` (str, optional): 分页游标，首次调用不传。

-   **返回结构**: `Dict[str, Any]`
    ```json
    {
      "ClueList": [
        {
          "ClueID": "...",
          "CreateTime": "...",
          "ClueText": {
            "opportunity_briefing": { "title": "...", "priority_level": "P2", "...": "..." },
            "company_profile": { "legal_name": "...", "...": "..." },
            "acorn_assessment": { "...": "..." },
            "...": "..."
          }
        }
      ],
      "NextToken": "1722409980123",
      "ResultCnt": 1
    }
    ```
    -   `ClueList` (list): 商机对象列表。注意 `ClueText` 字段已被自动解析为结构化 JSON 对象。
    -   `NextToken` (str | None): 下一页的游标。
    -   `ResultCnt` (int): 当前返回的商机数量。

## 最小化询问触发条件

Skill 自身不会直接与最终用户交互。当需要凭证时，它会抛出 `MissingCredentialsError` 异常，并附带明确的提示信息，交由调用方（例如 Agent）来决定如何处理。

**触发条件**：

-   在初次调用时，环境变量 (`ARK_*` 和 `VOLCSTACK_*`) 和持久化的 `persist/auth.json` 文件中均未找到任何有效的凭证。
-   已有的两套凭证（Gateway 和 SDK）都已因连续的 `AuthError` (401/403) 而被标记为“降级”状态。

**异常提示信息示例** (由 `auth_resolver.MINIMAL_ASK_HINT` 定义):

```
当前缺少可用的市场洞察访问凭证。
Agent 层应调用 ask_user，引导用户择一提供：
1) API Gateway 地址 + API Key (ARK_SKILL_API_BASE / ARK_SKILL_API_KEY)，或
2) 官方 AK/SK (VOLCSTACK_ACCESS_KEY_ID / VOLCSTACK_SECRET_ACCESS_KEY)。
一套凭证即可，Skill 会自动选择最合适的链路，并在本地持久化，后续会话复用。
```

当 Agent 捕获到此异常时，应向用户展示一个表单或通过对话引导用户提供上述凭证。获取后，Agent 应调用 `auth_resolver` 中的 `save_auth_gateway` 或 `save_auth_sdk` 方法将凭证持久化，然后再次尝试调用 `client.py` 中的函数。

## 持久化状态文件说明

Skill 会在 `persist/` 目录下创建两个 JSON 文件来维护状态和凭证。

### 1. `state.json`

记录 Skill 的运行时状态，用于实现会话粘性和动态决策。

-   **`provider`**: (str | null) 上一次成功使用的 Provider 名称，值为 `"gateway"` 或 `"sdk"`。用于实现会话粘性。
-   **`degraded`**: (dict) 记录每个 Provider 是否处于“降级”状态。
    -   `"gateway": true` 表示 Gateway 链路近期连续失败，应暂时避免使用。
    -   `"sdk": true` 表示 SDK 链路近期连续失败。
-   **`sdk_installed`**: (bool) 标记 `volcengine-python-sdk` 是否已在当前环境中成功安装。`true` 表示已安装，可以跳过安装检查。
-   **`sdk_install_failed`**: (bool) 标记 SDK 自动安装是否已失败过。`true` 表示安装失败，后续不再尝试自动安装，避免反复卡顿。
-   **`last_success_at`**: (float | null) 最近一次成功调用的 Unix 时间戳。可用于实现更复杂的状态过期逻辑。
-   **`last_error`**: (dict | null) 最近一次错误的摘要信息，包含 Provider 名称、错误类型和消息，用于调试。

### 2. `auth.json`

存储由“最小化询问”流程获取的用户凭证。**注意：环境变量中设置的凭证优先级高于此文件。**

-   **`gateway`**: (dict)
    -   `"api_base"`: (str) API Gateway 的基础 URL。
    -   `"api_key"`: (str) API Key (Bearer Token)。
-   **`sdk`**: (dict)
    -   `"access_key_id"`: (str) 官方 Access Key ID。
    -   `"secret_access_key"`: (str) 官方 Secret Access Key。
    -   `"region"`: (str) 服务区域，例如 `"cn-beijing"`。


## 环境变量来源与排查

为了减少对用户的打扰，本 Skill 会尽量“自己把凭证找全”，实际读取与决策的顺序为：

1.  **Shell 配置文件扫描 (rc)**：启动时首先扫描常见 rc 文件（`~/.bashrc`、`~/.bash_profile`、`~/.zshrc`、`~/.profile`）中的简单 `export ARK_*/VOLCSTACK_*` 行，只读解析出 Gateway 与 SDK 的候选凭证；
2.  **进程环境变量 (`os.environ`)**：默认假设用户已经在环境变量中配置了凭证，如果当前进程环境中存在 `ARK_*` / `VOLCSTACK_*`，则视为“已生效”的配置，**会覆盖前面从 rc 中收集到的同名候选值**；
3.  **持久化凭证文件 (`persist/auth.json`)**：当环境变量中仍缺少成对凭证时，Skill 会再尝试从该文件中读取由“最小化询问”流程保存的 Gateway / SDK 凭证，用于补全；
4.  如果上述三处都无法提供任意一套完整的 Gateway 或 SDK 凭证，才会抛出 `MissingCredentialsError`，交给 Agent 层向用户发起最小化询问。

顺序可以概括为：**先扫描 rc → 再读取 os.environ（若已生效覆盖候选）→ 再读 persist/auth.json → 最后触发最小化询问**。

在实现上，对 rc 文件的处理始终是安全、只读的：

- 只解析形如 `export VAR=VALUE` 的简单常量行，不执行 `source`，不会展开包含 `$`、`` ` ``、`$(` 等符号的表达式（此类行会被跳过）；
- 仅关注以下变量：`ARK_SKILL_API_BASE`、`ARK_SKILL_API_KEY`、`VOLCSTACK_ACCESS_KEY_ID`、`VOLCSTACK_SECRET_ACCESS_KEY`、`VOLCSTACK_REGION`；
- 解析成功的值只在内存中生效，不会写回 `auth.json`，日志中也不会打印明文凭证，只输出脱敏后的摘要信息。

### 常见定义位置

- **交互式终端或启动脚本**：`export ARK_SKILL_API_BASE=...`
- **用户 Home 目录下的配置文件**：`~/.bashrc`、`~/.bash_profile`、`~/.zshrc`、`~/.profile`。
- **平台或容器注入**：在启动命令前通过 `export ... && python ...` 注入。

### 如何自查

1.  **检查当前进程环境是否生效**：
    ```bash
    # 查看单个变量
    echo $ARK_SKILL_API_BASE

    # 在所有环境变量中搜索
    env | grep ARK_SKILL_API_BASE
    ```

2.  **在配置文件中搜索定义**：
    ```bash
    # 在常见 rc 文件中搜索 export 语句，忽略不存在的文件错误
    grep -n "^export ARK_SKILL_API_BASE" ~/.bashrc ~/.bash_profile ~/.zshrc ~/.profile 2>/dev/null
    ```

### 重要提示

- 在绝大多数情况下，只要你在 rc 文件或启动命令中正确写了 `export ARK_*` / `VOLCSTACK_*`，Skill 就能通过“先扫描 rc、再读取环境变量、再看 auth.json”的顺序自动找到并采用合适的凭证，无需你在对话中重复说明“已经配在环境变量里了”。
- 当两套凭证（Gateway 与 SDK）在环境变量、`auth.json` 和 rc 文件中都缺失时，Skill 会抛出 `MissingCredentialsError`，并附带在这些 rc 文件中找到的候选凭证提示，帮助你快速定位问题。


path: references/api-diff.md
content: # API 差异说明

本 Skill 在内部封装了 Gateway 和 SDK 两种调用方式，它们在 HTTP 方法、参数命名风格等方面存在差异。本 Skill 的 `client.py` 已经将这些差异完全抹平，对外暴露统一的 `PascalCase` 风格接口。

了解这些底层差异有助于在排查问题时更好地理解 Debug 日志。

## HTTP 方法与参数传递差异

| 接口                 | Gateway (urllib)                                  | SDK (`volcengine-python-sdk`)                     |
| -------------------- | ------------------------------------------------- | ------------------------------------------------- |
| `ListCustomSubsTask` | `GET` 方法，但请求体为 JSON (`application/json`)  | `POST` 方法，请求体为 JSON (`application/json`)   |
| `PullPost`           | `POST` 方法，请求体为 JSON (`application/json`)   | `POST` 方法，请求体为 JSON (`application/json`)   |
| `QueryClueInfo`      | `GET` 方法，但请求体为 JSON (`application/json`)  | `POST` 方法，请求体为 JSON (`application/json`)   |

**核心差异点**：

-   通过 Gateway 调用 `ListCustomSubsTask` 和 `QueryClueInfo` 时，虽然是 `GET` 请求，但业务参数是通过 JSON Body 传递的，这与标准的 RESTful `GET` 请求不同。`gateway_provider.py` 内部通过重写 `urllib.request.Request.get_method` 来实现这一特殊需求。
-   SDK 调用则统一使用 `POST` 方法，更符合其 RPC 风格。

## 参数命名风格差异

| 统一接口参数（`client.py`） | Gateway (`gateway_provider.py`) | SDK (`sdk_provider.py`)           |
| --------------------------- | ------------------------------- | --------------------------------- |
| `TaskID`                    | `TaskID`                        | `task_id`                         |
| `TaskName`                  | `TaskName`                      | `task_name`                       |
| `StartTime`                 | `StartTime`                     | `start_time`                      |
| `EndTime`                   | `EndTime`                       | `end_time`                        |
| `PageNum`                   | `PageNum`                       | `page_num`                        |
| `PageSize`                  | `PageSize`                      | `page_size`                       |
| `PageToken`                 | `PageToken`                     | `page_token`                      |
| `Size` (for PullPost)       | `Size`                          | `size`                            |
| `MaxResults`                | `MaxResults`                    | `max_results`                     |
| `NextToken`                 | `NextToken`                     | `next_token`                      |

-   **Gateway Provider**：直接使用与 `client.py` 相同的 `PascalCase` 参数。
-   **SDK Provider**：在内部将 `PascalCase` 参数转换为 SDK 请求体对象所需的 `snake_case` 参数。

## 关键返回字段差异

| 统一返回字段                | Gateway (`Result` 字段内) | SDK (响应对象属性)          |
| --------------------------- | ------------------------- | --------------------------- |
| `InsightSaasTaskList`       | `InsightSaasTaskList`     | `insight_saas_task_list`    |
| `ItemDocs`                  | `ItemDocs`                | `item_docs`                 |
| `ClueList`                  | `ClueList`                | `clue_list`                 |
| `Total`                     | `Total`                   | `total`                     |
| `HasMore`                   | `HasMore`                 | `has_more`                  |
| `NextPageToken`             | `NextPageToken`           | `next_page_token`           |
| `ResultCnt`                 | `ResultCnt`               | `result_cnt`                |

`sdk_provider.py` 在返回前，会将 SDK 响应对象的 `snake_case` 属性名转换为与其他 Provider 一致的 `PascalCase` 键名。

## 重要提醒：`QueryClueInfo` 的 `ClueText` 必须解析

在 `QueryClueInfo` 接口的返回结果中，`ClueList` 里的每一个商机对象都包含一个 `ClueText` 字段。

-   **原始类型**：无论通过 Gateway 还是 SDK 获取，`ClueText` 的原始类型都是一个 **JSON 字符串**。
-   **统一处理**：本 Skill 的 `gateway_provider.py` 和 `sdk_provider.py` 在返回数据前，都会自动尝试对 `ClueText` 字段进行 `json.loads()` 解析。
    -   如果解析成功，`ClueText` 的值会变为一个结构化的 Python 字典。
    -   如果解析失败（例如内容为空或格式错误），将保持其原始的字符串形态，避免因单条数据异常导致整个请求失败。

因此，使用本 Skill 的 `client.py` 调用 `query_clue_info` 时，可以直接像操作字典一样访问 `ClueText` 的内部字段，例如 `clue['ClueText']['opportunity_briefing']['title']`。


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