Skill: longgggggg/web-insight (version 1.0.4)

--- name: web-insight description: "互联网内容洞察技能。面向全域网络信息监测与情报分析场景，提供多条件组合精准检索能力，可覆盖全网新闻、社交、资讯等多渠道公开内容。支持关键词、情感、时间、平台等多条件组合查询，依托 NLP 智能解析，实现内容去重、关键信息抽取与数据结构化输出，一键供 AI Agent 调用。适用于品牌管理、市场分析、竞品追踪、风险感知等场景。触发词：搜索、检索、查找、查询、舆情、信息、内容检索、监测、情报分析。前置条件：必须在技能项目根目录配置环境变量 FEEDAX_SEARCH_API_KEY（在 https://www.feedax.cn 申请，勿使用仓库内示例或他人 Key）。" requires_env: - FEEDAX_SEARCH_API_KEY install: "pip install -r requirements.txt" --- # 互联网内容洞察 (WebInsight) 面向全域网络信息监测与情报分析场景，提供多条件组合精准检索能力，可覆盖全网新闻、社交、资讯等多渠道公开内容。 --- ## 一、前置条件：配置 API Key **使用本技能前，必须先配置 API Key。** ### 检查配置 ```bash cat .env | grep FEEDAX_SEARCH_API_KEY ``` ### 如果未配置 在 `.env` 文件中添加： ```env FEEDAX_SEARCH_API_KEY=your_api_key_here ``` > API Key 请前往 https://www.feedax.cn 免费申请 ### 错误码说明 | 错误码 | 说明 | 解决方案 | | ------ | ------------- | --------------------------------- | | GE1003 | 未配置API Key | 前往 https://www.feedax.cn 申请 | | GE1004 | API Key已失效 | 检查有效性或重新申请 | | GE1005 | API Key已过期 | 重新申请 | | GE1006 | API Key无效 | 重新申请 | | GE1007 | 账户余额不足 | 前往充值 | --- ## 二、CLI 命令行工具 **文件**: `scripts/search_cli.py` ### 2.1 基础用法 ```bash # 基础搜索 python3 scripts/search_cli.py --query "医疗问题" # 搜索指定地域（最近7天） python3 scripts/search_cli.py --query "南京 315" --area "南京" --days 7 # 搜索指定媒体平台和情感倾向 python3 scripts/search_cli.py --query "医疗问题" --media "微博,抖音" --sentiment negative ``` ### 2.2 参数说明 #### 必填参数 | 参数 | 简写 | 说明 | | --------- | ---- | ---------- | | `--query` | `-q` | 搜索关键词 | **关键词语法**: - `|` = 或（OR），如 `北京|上海` - `&` = 与（AND），如 `(南京|金陵)&(杀人|凶杀)` - `()` = 分组 #### 分页与排序 | 参数 | 简写 | 默认值 | 说明 | | -------------- | ---- | ------------ | -------------------------------------- | | `--size` | `-s` | 50 | 返回条数（1-10000） | | `--search-after` | | | 深度分页游标，首次传空 | | `--sort` | | publish_time | 排序字段 | | `--sort-order` | | desc | 排序方式：asc/desc | **排序字段可选值**: - `publish_time` - **默认**，按发布时间 - `interact_count` - 按互动数（热度） - `comments_count` - 按评论数 - `likes_count` - 按点赞数 - `reposts_count` - 按转发数 - `fans_count` - 按粉丝数 #### 内容筛选 | 参数 | 简写 | 说明 | | ---------------- | ---- | ----------------------------------- | | `--media` | `-m` | 媒体平台，逗号分隔 | | `--author` | | 作者名称，精确匹配 | | `--msg-type` | | 消息类型：original/comment/repost | | `--content-type` | | 内容类型：video/picture/text | | `--sentiment` | | 情感：negative/neutral/positive/all | | `--media-class` | | 媒体分类：央媒/省媒/地市/商业/其他 | | `--domain` | | 内容领域 | | `--scene` | | 业务场景 | | `--area` | `-a` | 地域名称（支持省/市/区县） | | `--noise-flag` | | 噪声标识：0=非噪声，1=噪声 | | `--ai-abstract` | | 摘要类型：0=算法，1=AI | | `--verification` | | 认证类型：blue/yellow/normal | #### 时间筛选 | 参数 | 简写 | 默认值 | 说明 | | ------------- | ---- | ------ | ----------------------------------- | | `--days` | `-d` | 7 | 时间范围（天） | | `--time-from` | | | 开始时间，格式: yyyy-mm-dd HH:MM:SS | | `--time-to` | | | 结束时间，格式: yyyy-mm-dd HH:MM:SS | #### 互动数据筛选 | 参数 | 说明 | | ---------------- | ---------- | | `--min-interact` | 最小互动数 | | `--max-interact` | 最大互动数 | | `--min-comments` | 最小评论数 | | `--max-comments` | 最大评论数 | | `--min-reposts` | 最小转发数 | | `--max-reposts` | 最大转发数 | | `--min-likes` | 最小点赞数 | | `--max-likes` | 最大点赞数 | | `--min-fans` | 最小粉丝数 | | `--max-fans` | 最大粉丝数 | #### 输出控制 | 参数 | 简写 | 默认值 | 说明 | | -------------- | ---- | -------------------------- | ---------------- | | `--output-dir` | `-o` | ~/Desktop/舆情搜索结果/ | JSON文件输出目录 | | `--show-count` | | 5 | 对话展示条数 | ### 2.3 输出配置 | 配置项 | 值 | | -------- | ------------------------------------------------------------------------------ | | 输出目录 | `~/Desktop/舆情搜索结果/` | | 文件命名 | `查询词_时间戳.json` | | 标准字段 | `title`, `summary`, `authorName`, `publishTime`, `platformName`, `originalUrl` | ### 2.4 完整示例 ```bash # 搜索南京315相关负面信息，最近一周，只看微博抖音 python3 scripts/search_cli.py \ --query "(南京|金陵)&(315|消协|投诉|维权)" \ --area "南京" \ --days 7 \ --media "微博,抖音" \ --sentiment negative \ --size 20 \ --show-count 5 ``` --- ## 三、解析规则 ### 3.1 关键词解析 从用户输入中提取核心检索关键词： | 用户输入 | --query 参数 | | ---------------- | -------------------------------- | | 南京杀人案 | `(南京|金陵)&(杀人|凶杀|行凶)` | | 比亚迪汽车召回 | `比亚迪&(召回|退市|下架)` | | 北京暴雨 | `(北京|首都)&(暴雨|大雨|洪涝)` | **规则**: 1. 提取2-3个核心关键词 2. 为地名、动作词添加同义词 3. 人名/品牌不扩充 4. 情感词通过 `--sentiment` 参数控制 ### 3.2 媒体平台映射 | 用户表述 | --media 参数 | | ------------------ | -------------------------------- | | 抖音、快手、小红书 | `"抖音,快手,小红书"` | | 短视频平台 | `"抖音,快手,火山,西瓜"` | | 财经平台 | `"同花顺,股吧,东方财富,雪球"` | ### 3.3 情感倾向映射 | 用户表述 | --sentiment 参数 | | ------------- | ---------------- | | 负面/负面信息 | `negative` | | 正面/正面信息 | `positive` | | 中性 | `neutral` | | 全部情感 | `all` | ### 3.4 媒体级别映射 | 用户表述 | --media-class 参数 | | ------------- | ------------------ | | 央媒/央级媒体 | `"央媒"` | | 省媒/省级媒体 | `"省媒"` | | 省级及以上 | `"央媒,省媒"` | ### 3.5 消息类型映射 | 用户表述 | --msg-type 参数 | | ----------- | --------------- | | 原发/原创 | `original` | | 评论/评论区 | `comment` | | 转发 | `repost` | ### 3.6 认证类型映射 | 用户表述 | --verification 参数 | | ------------------- | ------------------- | | 蓝V/官方账号/机构号 | `blue` | | 黄V/个人认证/大V | `yellow` | | 普通用户/素人 | `normal` | ### 3.7 时间范围映射 | 用户表述 | --days 参数 | | ----------- | ----------- | | 6小时 | 不支持 | | 24小时/一天 | `1` | | 3天 | `3` | | 7天/一周 | `7` | | 30天/一个月 | `30` | ### 3.8 排序方式映射 **默认规则**：除非用户明确要求，否则使用 `publish_time` | 用户表述 | --sort 参数 | | ------------------ | ---------------- | | **无明确要求** | `publish_time` | | 按热度/热门/最火的 | `interact_count` | | 按评论数/最多评论 | `comments_count` | | 按点赞数/最多点赞 | `likes_count` | | 最新/最近/新发布的 | `publish_time` | --- ## 四、参考数据 ### 4.1 媒体平台列表 抖音、快手、微博、今日头条、火山、哔哩哔哩、小红书、微信、百家号、搜狐新闻、同花顺、股吧、东方财富、百度贴吧、网易、豆瓣网、知乎、西瓜、好看、黑猫投诉、腾讯新闻、新浪财经等 ### 4.2 领域分类 (--domain) **一级分类**: 社会民生、城市管理、社会问题、司法相关、涉政相关、生态环境、教育相关、三农相关、突发事件、涉警相关 **二级分类示例**: - **社会民生**: 劳动就业、消费问题、征地拆迁、医疗问题、住房问题、交通问题 - **突发事件**: 自然灾害、事故灾难、公共卫生事件、社会安全事件 - **涉警相关**: 治安管理、人身安全、涉毒、涉黑涉恶、诈骗 详见 `references/domains.json` ### 4.3 场景分类 (--scene) **一级场景**: 非法烟花、实名举报、自然灾害、维权场景、城市管理、交通事故、打架斗殴、涉警信息、校园安全、环境污染、医疗卫生、安全事故 **二级场景示例**: - **自然灾害**: 洪涝场景、沙尘雾霾 - **维权场景**: 讨薪维权、业主维权、拆迁维权 - **安全事故**: 火灾事故、坍塌事故、溺水事故 详见 `references/scenes.json` ### 4.4 参考文件 - `references/domains.json` - 领域分类完整列表 - `references/scenes.json` - 场景分类完整列表 - `references/media_names.json` - 媒体平台完整列表 - `assets/area_codes.json` - 国标区域编码数据 --- ## 五、执行流程 收到用户搜索请求后，按以下步骤执行： ### Step 1: 检查 API Key 配置 ```bash cat .env | grep FEEDAX_SEARCH_API_KEY ``` - 如果返回空或文件不存在，提示用户前往 https://www.feedax.cn 申请 - 如果存在有效的 API Key，继续下一步 ### Step 2: 解析用户输入 从用户自然语言中提取参数，映射到 CLI 参数： | 提取项 | CLI 参数 | 示例 | | -------- | ---------------- | ------------------------------------------ | | 关键词 | `--query` | "医疗问题" → `--query "医疗问题"` | | 返回条数 | `--size` | "返回100条" → `--size 100` | | 媒体平台 | `--media` | "微博、抖音" → `--media "微博,抖音"` | | 作者 | `--author` | "央视新闻" → `--author "央视新闻"` | | 消息类型 | `--msg-type` | "原发" → `--msg-type original` | | 内容类型 | `--content-type` | "视频" → `--content-type video` | | 情感倾向 | `--sentiment` | "负面" → `--sentiment negative` | | 媒体分类 | `--media-class` | "央媒" → `--media-class "央媒"` | | 领域 | `--domain` | "医疗问题" → `--domain "社会民生-医疗问题"` | | 场景 | `--scene` | "交通事故" → `--scene "交通事故"` | | 地域 | `--area` | "南京" → `--area "南京"` | | 认证类型 | `--verification` | "蓝V" → `--verification blue` | | 时间范围 | `--days` | "最近3天" → `--days 3` | | 开始时间 | `--time-from` | "从3月1日" → `--time-from "2026-03-01"` | | 结束时间 | `--time-to` | "到3月15日" → `--time-to "2026-03-15"` | | 互动数 | `--min-interact` | "互动超过1000" → `--min-interact 1000` | | 排序方式 | `--sort` | "按热度" → `--sort interact_count` | ### Step 3: 构建并执行搜索命令 根据解析结果构建命令： ```bash python3 scripts/search_cli.py \ --query "关键词表达式" \ --days 7 \ --area "地域" \ --media "平台1,平台2" \ --sentiment negative \ --domain "领域" \ --size 50 ``` ### Step 4: 返回结果 - 在对话中展示前5条数据摘要 - 完整数据自动保存至 `~/Desktop/舆情搜索结果/` 目录 --- ## 六、执行示例 ### 示例1：基础舆情搜索 **用户输入**: "搜索最近3天南京关于医疗问题的负面舆情，只看微博和抖音" ```bash python3 scripts/search_cli.py \ --query "(南京|金陵)&(医疗|就医|看病)" \ --days 3 \ --area "南京" \ --media "微博,抖音" \ --sentiment negative \ --domain "社会民生-医疗问题" ``` ### 示例2：热点事件追踪 **用户输入**: "搜索比亚迪汽车召回相关信息，按热度排序，返回100条" ```bash python3 scripts/search_cli.py \ --query "比亚迪&(召回|退市|下架|缺陷)" \ --days 7 \ --sort interact_count \ --size 100 ``` ### 示例3：官方账号监测 **用户输入**: "搜索央视新闻发布的关于教育的内容，最近一周" ```bash python3 scripts/search_cli.py \ --query "教育" \ --author "央视新闻" \ --days 7 \ --msg-type original ``` ### 示例4：短视频平台监测 **用户输入**: "搜索抖音快手上关于交通事故的视频，互动数超过1000" ```bash python3 scripts/search_cli.py \ --query "交通事故" \ --media "抖音,快手" \ --content-type video \ --scene "交通事故" \ --min-interact 1000 ``` ### 示例5：大V账号舆情 **用户输入**: "搜索蓝V账号发布的关于食品安全的负面信息" ```bash python3 scripts/search_cli.py \ --query "(食品安全|食品卫生|食物中毒)" \ --verification blue \ --sentiment negative \ --domain "社会民生-食品安全" ``` ### 示例6：精确时间范围搜索 **用户输入**: "搜索2026年3月1日到3月15日期间上海的房价相关信息" ```bash python3 scripts/search_cli.py \ --query "(上海|沪)&(房价|楼市|房产)" \ --area "上海" \ --time-from "2026-03-01 00:00:00" \ --time-to "2026-03-15 23:59:59" \ --domain "社会民生-住房问题" ``` ### 示例7：省级媒体监测 **用户输入**: "搜索央媒和省媒关于环境污染的报道" ```bash python3 scripts/search_cli.py \ --query "(环境污染|空气污染|水污染|土壤污染)" \ --media-class "央媒,省媒" \ --domain "生态环境" \ --msg-type original ```

path: requirements.txt
content: requests>=2.28.0
python-dotenv>=1.0.0


path: README.md
content: # 互联网内容洞察 (WebInsight)

面向全域网络信息监测与情报分析场景，提供多条件组合精准检索能力，可覆盖全网新闻、社交、资讯等多渠道公开内容。

支持关键词、情感、时间、平台等多条件组合查询，依托 NLP 智能解析，实现内容去重、关键信息抽取与数据结构化输出，一键供 AI Agent 调用。适用于品牌管理、市场分析、竞品追踪、风险感知等场景，高效提炼高价值信息，为业务决策提供数据支撑。

## 功能

- **舆情搜索** - 通过自然语言查询全网舆情数据
- **时间解析** - 智能解析"最近3天"、"上周"等时间表达
- **地区解析** - 支持省市区名称到国标区域代码的转换
- **多平台覆盖** - 抖音、微博、小红书、微信、B站等50+媒体平台

## 安装

用户对智能体说：

> 帮我安装并学习这个技能: https://gitee.com/feedax/WebInsight.git

## 配置

### 1. 获取 API Key

前往 [https://www.feedax.cn](https://www.feedax.cn) 申请 API Key。

### 2. 配置环境变量

本技能**必须**配置 Feedax API Key；可参考仓库中的 `.env.example`，在项目根目录创建 `.env`（该文件已在 `.gitignore` 中，**请勿**将含真实 Key 的 `.env` 提交到 Git）：

```env
FEEDAX_SEARCH_API_KEY=your_api_key_here
```

每人应使用自己在 [feedax.cn](https://www.feedax.cn) 申请的 Key，勿共用以免配额耗尽或泄露。

## 使用示例

安装后，直接用自然语言与智能体对话：

- "搜索最近3天关于新能源汽车的舆情"
- "查找微博上关于iPhone的负面评论"
- "检索江苏省医疗相关的热门内容"

## 依赖

```
requests>=2.28.0
python-dotenv>=1.0.0
```

## 许可证

MIT License


path: .env
content: # Feedax Search API Key
# 复制本文件为 .env 并填入你在 https://www.feedax.cn 申请的 Key（勿与他人共用）
# 也可参考 .env.example
FEEDAX_SEARCH_API_KEY=


path: scripts/parse_time_cli.py
content: #!/usr/bin/env python3
"""
时间解析 CLI 工具
计算 publishTimeFrom/publishTimeTo 毫秒时间戳

用法:
    python3 parse_time_cli.py
    python3 parse_time_cli.py --from "2026-03-25 00:00:00" --to "2026-03-31 23:59:59"
    python3 parse_time_cli.py --days 7
    python3 parse_time_cli.py --format 1774233538071
"""

import argparse
import sys
import time
from datetime import datetime
from typing import Dict, Any, Optional, Union


# 默认时间范围（7天）
DEFAULT_DAYS = 7


def parse_datetime(dt_str: str) -> int:
    """
    解析日期时间字符串，返回毫秒时间戳
    
    Args:
        dt_str: 日期时间字符串，格式 "yyyy-mm-dd HH:MM:SS" 或 "yyyy-mm-dd"
        
    Returns:
        毫秒时间戳
    """
    if not dt_str:
        return int(time.time() * 1000)
    
    dt_str = dt_str.strip()
    
    # 尝试多种格式
    formats = [
        "%Y-%m-%d %H:%M:%S",
        "%Y-%m-%d %H:%M",
        "%Y-%m-%d",
        "%Y/%m/%d %H:%M:%S",
        "%Y/%m/%d %H:%M",
        "%Y/%m/%d",
    ]
    
    for fmt in formats:
        try:
            dt = datetime.strptime(dt_str, fmt)
            return int(dt.timestamp() * 1000)
        except ValueError:
            continue
    
    raise ValueError(f"无法解析日期时间: {dt_str}，请使用格式 'yyyy-mm-dd HH:MM:SS'")


def calculate_publish_time(
    time_from: Optional[Union[str, int]] = None,
    time_to: Optional[Union[str, int]] = None
) -> Dict[str, Any]:
    """
    计算 publishTimeFrom 和 publishTimeTo
    
    Args:
        time_from: 开始时间
            - 字符串格式: "yyyy-mm-dd HH:MM:SS"
            - 整数格式: 毫秒时间戳
            - 不传: 默认为 time_to 往前推7天
        time_to: 结束时间
            - 字符串格式: "yyyy-mm-dd HH:MM:SS"
            - 整数格式: 毫秒时间戳
            - 不传: 默认为当前时间
        
    Returns:
        {
            "publishTimeFrom": 毫秒时间戳,
            "publishTimeTo": 毫秒时间戳
        }
    """
    # 处理结束时间
    if time_to is None:
        publish_time_to = int(time.time() * 1000)
    elif isinstance(time_to, str):
        publish_time_to = parse_datetime(time_to)
    else:
        publish_time_to = int(time_to)
    
    # 处理开始时间
    if time_from is None:
        # 默认往前推7天
        publish_time_from = publish_time_to - (DEFAULT_DAYS * 24 * 60 * 60 * 1000)
    elif isinstance(time_from, str):
        publish_time_from = parse_datetime(time_from)
    else:
        publish_time_from = int(time_from)
    
    return {
        "publishTimeFrom": publish_time_from,
        "publishTimeTo": publish_time_to
    }


def format_timestamp(ts: int) -> str:
    """将毫秒时间戳格式化为日期时间字符串"""
    dt = datetime.fromtimestamp(ts / 1000)
    return dt.strftime("%Y-%m-%d %H:%M:%S")


def main():
    parser = argparse.ArgumentParser(
        description='计算发布时间范围（毫秒时间戳）',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
示例:
  %(prog)s                                    # 默认最近7天
  %(prog)s --from "2026-03-25 00:00:00"       # 指定开始时间，结束时间为当前
  %(prog)s --to "2026-03-31 23:59:59"         # 指定结束时间，开始时间往前推7天
  %(prog)s --from "2026-03-25" --to "2026-03-31"  # 指定日期范围
  %(prog)s --days 3                         # 最近3天
  %(prog)s --format 1774233538071           # 将毫秒时间戳转为可读格式
        """
    )
    
    parser.add_argument('--from', dest='time_from', help='开始时间 (格式: yyyy-mm-dd HH:MM:SS 或 yyyy-mm-dd)')
    parser.add_argument('--to', dest='time_to', help='结束时间 (格式: yyyy-mm-dd HH:MM:SS 或 yyyy-mm-dd)，默认当前时间')
    parser.add_argument('--days', type=int, help='最近N天（与--from互斥）')
    parser.add_argument('--format', dest='format_ts', help='将毫秒时间戳转为可读格式')
    
    args = parser.parse_args()
    
    # 格式化时间戳模式
    if args.format_ts:
        try:
            ts = int(args.format_ts)
            readable = format_timestamp(ts)
            print(f"毫秒时间戳: {ts}")
            print(f"可读格式: {readable}")
            return
        except ValueError as e:
            print(f"错误: 无效的时间戳 - {e}", file=sys.stderr)
            sys.exit(1)
    
    # 计算时间范围
    try:
        if args.days and not args.time_from:
            # 使用days参数
            if args.time_to:
                time_to = parse_datetime(args.time_to)
            else:
                time_to = int(time.time() * 1000)
            
            time_from = time_to - (args.days * 24 * 60 * 60 * 1000)
            result = {
                "publishTimeFrom": time_from,
                "publishTimeTo": time_to
            }
        else:
            # 使用from/to参数
            result = calculate_publish_time(
                time_from=args.time_from,
                time_to=args.time_to
            )
        
        # 输出结果
        print("{")
        print(f'  "publishTimeFrom": {result["publishTimeFrom"]},')
        print(f'  "publishTimeTo": {result["publishTimeTo"]}')
        print("}")
        print()
        print("时间范围:")
        print(f"  开始: {format_timestamp(result['publishTimeFrom'])}")
        print(f"  结束: {format_timestamp(result['publishTimeTo'])}")
        
    except ValueError as e:
        print(f"错误: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/parse_area_cli.py
content: #!/usr/bin/env python3
"""
地域解析 CLI 工具
整合了 area_code.py 和 parse_area.py 功能
传入地域名称，返回对应的国标区域编码

用法:
    python3 parse_area_cli.py --area "北京"
    python3 parse_area_cli.py --area "江苏,南京,上海" --batch
    python3 parse_area_cli.py --search "海淀"
"""

import argparse
import json
import logging
import sys
from pathlib import Path
from dataclasses import dataclass
from typing import List, Dict, Any, Optional

logger = logging.getLogger(__name__)


@dataclass
class AreaCodeResult:
    """地域编码查询结果"""
    province: str
    province_code: str
    city: str
    city_code: str
    county: str
    county_code: str
    
    def get_code(self) -> str:
        """获取最精确的编码（优先县级 > 市级 > 省级）"""
        if self.county_code and self.county_code != self.city_code:
            return self.county_code
        if self.city_code and self.city_code != self.province_code:
            return self.city_code
        return self.province_code
    
    def to_dict(self) -> Dict[str, str]:
        return {
            "province": self.province,
            "province_code": self.province_code,
            "city": self.city,
            "city_code": self.city_code,
            "county": self.county,
            "county_code": self.county_code,
        }


# 全局地域编码索引（延迟加载）
_AREA_CODE_INDEX: Optional[Dict[str, AreaCodeResult]] = None
_AREA_CODE_BY_CODE: Optional[Dict[str, AreaCodeResult]] = None


def _load_area_codes() -> None:
    """从JSON文件加载地域编码数据"""
    global _AREA_CODE_INDEX, _AREA_CODE_BY_CODE
    
    if _AREA_CODE_INDEX is not None:
        return
    
    _AREA_CODE_INDEX = {}
    _AREA_CODE_BY_CODE = {}
    
    # 查找 area_codes.json 文件
    json_path = Path(__file__).parent.parent / "assets/area_codes.json"
    
    if not json_path.exists():
        logger.warning("area_codes.json not found at %s", json_path)
        return
    
    try:
        with open(json_path, "r", encoding="utf-8") as f:
            data = json.load(f)
        
        # 解析嵌套结构
        for province in data.get("data", []):
            province_name = province.get("name", "")
            province_code = province.get("code", "")
            
            # 添加省级
            result = AreaCodeResult(
                province=province_name,
                province_code=province_code,
                city="",
                city_code="",
                county="",
                county_code=""
            )
            _add_to_index(province_name, result)
            _AREA_CODE_BY_CODE[province_code] = result
            
            # 处理市级（直辖市的children直接是区县）
            for city in province.get("children", []):
                city_name = city.get("name", "")
                city_code = city.get("code", "")
                city_children = city.get("children", [])
                
                if city_children:
                    # 有children，说明是市级
                    city_result = AreaCodeResult(
                        province=province_name,
                        province_code=province_code,
                        city=city_name,
                        city_code=city_code,
                        county="",
                        county_code=""
                    )
                    _add_to_index(city_name, city_result)
                    _AREA_CODE_BY_CODE[city_code] = city_result
                    
                    # 处理县/区级
                    for county in city_children:
                        county_name = county.get("name", "")
                        county_code = county.get("code", "")
                        
                        county_result = AreaCodeResult(
                            province=province_name,
                            province_code=province_code,
                            city=city_name,
                            city_code=city_code,
                            county=county_name,
                            county_code=county_code
                        )
                        _add_to_index(county_name, county_result)
                        _AREA_CODE_BY_CODE[county_code] = county_result
                else:
                    # 无children，直辖市的区县
                    county_result = AreaCodeResult(
                        province=province_name,
                        province_code=province_code,
                        city=province_name,
                        city_code=province_code,
                        county=city_name,
                        county_code=city_code
                    )
                    _add_to_index(city_name, county_result)
                    _AREA_CODE_BY_CODE[city_code] = county_result
        
        logger.info("Loaded %d area codes", len(_AREA_CODE_INDEX))
        
    except Exception as e:
        logger.error("Failed to load area_codes.json: %s", e)


def _add_to_index(name: str, result: AreaCodeResult) -> None:
    """添加到索引，支持多种名称变体"""
    if not name:
        return
    
    # 原始名称
    _AREA_CODE_INDEX[name] = result
    
    # 去掉后缀的简称
    for suffix in ["省", "市", "区", "县", "自治区", "自治州", "自治县", "特别行政区"]:
        if name.endswith(suffix) and len(name) > len(suffix):
            short_name = name[:-len(suffix)]
            if short_name and short_name not in _AREA_CODE_INDEX:
                _AREA_CODE_INDEX[short_name] = result


def search_area_code(name: str) -> Dict[str, Any]:
    """
    搜索地域编码
    
    Args:
        name: 地域名称（如"海淀"、"北京海淀区"、"上海"等）
        
    Returns:
        {
            "query": "搜索词",
            "count": 匹配数量,
            "results": [匹配结果列表]
        }
    """
    # 确保数据已加载
    _load_area_codes()
    
    results: List[AreaCodeResult] = []
    
    # 清理输入
    name = name.strip()
    if not name:
        return {"query": name, "count": 0, "results": []}
    
    # 1. 直接匹配
    if name in _AREA_CODE_INDEX:
        results.append(_AREA_CODE_INDEX[name])
    
    # 2. 组合地名解析（如"北京海淀"、"上海徐汇"）- 找最精确的匹配（优先县级）
    if not results:
        best_match = None
        best_priority = -1  # 县级=3, 市级=2, 省级=1
        for key, value in _AREA_CODE_INDEX.items():
            if key in name:
                # 计算优先级：县级 > 市级 > 省级
                if value.county_code:
                    priority = 3
                elif value.city_code:
                    priority = 2
                else:
                    priority = 1
                
                if priority > best_priority:
                    best_match = value
                    best_priority = priority
        if best_match:
            results.append(best_match)
    
    # 3. 模糊匹配（如果组合匹配也失败）
    if not results:
        for key, value in _AREA_CODE_INDEX.items():
            if name in key or key in name:
                results.append(value)
                break
    
    return {
        "query": name,
        "count": len(results),
        "results": [r.to_dict() for r in results]
    }


def get_area_codes(area_names: List[str]) -> List[str]:
    """
    批量获取地域编码
    
    Args:
        area_names: 地域名称列表
        
    Returns:
        地域编码列表（去重）
    """
    codes = set()
    for name in area_names:
        result = search_area_code(name)
        for r in result["results"]:
            # 优先使用最精确的编码
            if r.get("county_code"):
                codes.add(r["county_code"])
            elif r.get("city_code"):
                codes.add(r["city_code"])
            elif r.get("province_code"):
                codes.add(r["province_code"])
    
    return list(codes)


def parse_area_codes(area_names: List[str]) -> List[str]:
    """
    将地域名称列表转换为国标区域编码列表
    
    Args:
        area_names: 地域名称列表，如 ["北京", "上海", "江苏"]
        
    Returns:
        地域编码列表，如 ["110000", "310000", "320000"]
    """
    if not area_names:
        return []
    
    return get_area_codes(area_names)


def parse_single_area(area_name: str) -> dict:
    """
    查询单个地域的详细信息
    
    Args:
        area_name: 地域名称，如 "北京"、"海淀"、"江苏"
        
    Returns:
        地域详细信息字典
    """
    return search_area_code(area_name)


def get_area_display_name(area_names: List[str]) -> str:
    """获取地域的展示名称"""
    if not area_names:
        return "全国"
    return "、".join(area_names)


def main():
    parser = argparse.ArgumentParser(
        description='地域名称与国标区域编码转换工具',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
示例:
  %(prog)s --area "北京"                      # 查询单个地域编码
  %(prog)s --area "江苏,南京,上海" --batch     # 批量查询地域编码
  %(prog)s --search "海淀"                    # 搜索地域详细信息
  %(prog)s --list                             # 列出常见地域编码
        """
    )
    
    parser.add_argument('--area', '-a', help='地域名称，多个用逗号分隔（如：北京,上海,江苏）')
    parser.add_argument('--batch', '-b', action='store_true', help='批量模式（与--area配合）')
    parser.add_argument('--search', '-s', help='搜索地域详细信息')
    parser.add_argument('--list', '-l', action='store_true', help='列出常见省市编码')
    
    args = parser.parse_args()
    
    # 列出常见地域编码
    if args.list:
        common_areas = {
            "北京": "110000", "天津": "120000", "河北": "130000", "山西": "140000",
            "内蒙古": "150000", "辽宁": "210000", "吉林": "220000", "黑龙江": "230000",
            "上海": "310000", "江苏": "320000", "浙江": "330000", "安徽": "340000",
            "福建": "350000", "江西": "360000", "山东": "370000", "河南": "410000",
            "湖北": "420000", "湖南": "430000", "广东": "440000", "广西": "450000",
            "海南": "460000", "重庆": "500000", "四川": "510000", "贵州": "520000",
            "云南": "530000", "西藏": "540000", "陕西": "610000", "甘肃": "620000",
            "青海": "630000", "宁夏": "640000", "新疆": "650000",
        }
        print("常见省市国标区域编码：")
        print("-" * 40)
        for name, code in common_areas.items():
            print(f"  {name:8s} -> {code}")
        return
    
    # 搜索模式
    if args.search:
        result = parse_single_area(args.search)
        print(f"查询: {result['query']}")
        print(f"匹配数: {result['count']}")
        if result['results']:
            print("结果:")
            for r in result['results']:
                print(f"  省份: {r['province']} ({r['province_code']})")
                if r['city']:
                    print(f"  城市: {r['city']} ({r['city_code']})")
                if r['county']:
                    print(f"  区县: {r['county']} ({r['county_code']})")
        else:
            print("未找到匹配结果")
        return
    
    # 批量查询模式
    if args.area:
        if args.batch:
            # 批量解析
            areas = [a.strip() for a in args.area.split(',') if a.strip()]
            codes = parse_area_codes(areas)
            print(f"地域: {get_area_display_name(areas)}")
            print(f"编码: {codes}")
        else:
            # 单个查询详细信息
            result = parse_single_area(args.area)
            print(f"查询: {result['query']}")
            print(f"匹配数: {result['count']}")
            if result['results']:
                r = result['results'][0]
                print(f"编码: {r['province_code'] or r['city_code'] or r['county_code']}")
                print(f"省份: {r['province']}")
                if r['city']:
                    print(f"城市: {r['city']}")
                if r['county']:
                    print(f"区县: {r['county']}")
            else:
                print("未找到匹配结果")
        return
    
    # 无参数时显示帮助
    parser.print_help()


if __name__ == "__main__":
    main()


path: scripts/search_cli.py
content: #!/usr/bin/env python3
"""
Info Search CLI - 舆情内容检索命令行工具
整合了API客户端和CLI功能

用法:
    python3 search_cli.py --query "热点新闻" --area "长沙" --days 7
    python3 search_cli.py --query "医疗问题" --media "微博,抖音" --sentiment negative
"""

import os
import sys
import json
import argparse
import requests
from pathlib import Path
from datetime import datetime
from typing import Dict, Any, List, Optional
from dotenv import load_dotenv

# 加载 .env 文件
env_path = Path(__file__).parent.parent / ".env"
load_dotenv(env_path)

# API配置
API_BASE_URL = "http://221.6.15.90:18011"
API_ENDPOINT = "/search-service/search"
[REDACTED_SECRET]"FEEDAX_SEARCH_API_KEY", "")
DEFAULT_TIMEOUT = 30

# 输出配置
DEFAULT_OUTPUT_DIR = Path.home() / "Desktop" / "舆情搜索结果"

# 标准输出字段
STANDARD_FIELDS = ["title", "summary", "authorName", "publishTime", "platformName", "originalUrl"]

# 地域编码索引（延迟加载）
# 存储 name -> {province_code, city_code, county_code} 的映射
_AREA_CODE_INDEX: Optional[Dict[str, Dict[str, str]]] = None


def _load_area_codes() -> Dict[str, Dict[str, str]]:
    """从JSON文件加载地域编码数据，返回名称到编码信息的映射"""
    global _AREA_CODE_INDEX
    
    if _AREA_CODE_INDEX is not None:
        return _AREA_CODE_INDEX
    
    _AREA_CODE_INDEX = {}
    
    # 查找 area_codes.json 文件
    json_path = Path(__file__).parent.parent / "assets/area_codes.json"
    
    if not json_path.exists():
        return _AREA_CODE_INDEX
    
    try:
        with open(json_path, "r", encoding="utf-8") as f:
            data = json.load(f)
        
        # 解析嵌套结构
        for province in data.get("data", []):
            province_name = province.get("name", "")
            province_code = province.get("code", "")
            
            # 添加省级
            _add_area_to_index(province_name, {
                "province_code": province_code,
                "city_code": "",
                "county_code": ""
            })
            
            # 处理市级（直辖市的children直接是区县）
            for city in province.get("children", []):
                city_name = city.get("name", "")
                city_code = city.get("code", "")
                city_children = city.get("children", [])
                
                if city_children:
                    # 有children，说明是市级
                    _add_area_to_index(city_name, {
                        "province_code": province_code,
                        "city_code": city_code,
                        "county_code": ""
                    })
                    
                    # 处理县/区级
                    for county in city_children:
                        county_name = county.get("name", "")
                        county_code = county.get("code", "")
                        _add_area_to_index(county_name, {
                            "province_code": province_code,
                            "city_code": city_code,
                            "county_code": county_code
                        })
                else:
                    # 无children，直辖市的区县
                    _add_area_to_index(city_name, {
                        "province_code": province_code,
                        "city_code": province_code,
                        "county_code": city_code
                    })
    except Exception:
        pass
    
    return _AREA_CODE_INDEX


def _add_area_to_index(name: str, info: Dict[str, str]) -> None:
    """添加地域到索引，支持多种名称变体"""
    if not name:
        return
    
    # 原始名称
    _AREA_CODE_INDEX[name] = info
    
    # 去掉后缀的简称
    for suffix in ["省", "市", "区", "县", "自治区", "自治州", "自治县", "特别行政区"]:
        if name.endswith(suffix) and len(name) > len(suffix):
            short_name = name[:-len(suffix)]
            if short_name and short_name not in _AREA_CODE_INDEX:
                _AREA_CODE_INDEX[short_name] = info


def _get_best_code(info: Dict[str, str]) -> str:
    """获取最精确的编码（优先县级 > 市级 > 省级）"""
    if info.get("county_code"):
        return info["county_code"]
    if info.get("city_code"):
        return info["city_code"]
    return info.get("province_code", "")


def _get_priority(info: Dict[str, str]) -> int:
    """计算优先级：县级=3, 市级=2, 省级=1"""
    if info.get("county_code"):
        return 3
    if info.get("city_code"):
        return 2
    return 1


def parse_area_to_codes(area_str: str) -> List[str]:
    """
    将地域名称字符串转换为国标区域编码列表
    
    Args:
        area_str: 地域名称，多个用逗号分隔，如 "北京,南京,上海"
        
    Returns:
        地域编码列表，如 ["110000", "320100", "310000"]
    """
    if not area_str:
        return []
    
    index = _load_area_codes()
    areas = [a.strip() for a in area_str.split(',') if a.strip()]
    codes = set()
    
    for area in areas:
        # 1. 直接匹配
        if area in index:
            codes.add(_get_best_code(index[area]))
            continue
        
        # 2. 组合地名解析（如"北京海淀"、"上海徐汇"）- 找最精确的匹配
        best_match = None
        best_priority = -1
        for name, info in index.items():
            if name in area:
                priority = _get_priority(info)
                if priority > best_priority:
                    best_match = info
                    best_priority = priority
        
        if best_match:
            codes.add(_get_best_code(best_match))
            continue
        
        # 3. 模糊匹配
        for name, info in index.items():
            if area in name or name in area:
                codes.add(_get_best_code(info))
                break
    
    return list(codes)


def parse_datetime_str(dt_str: str) -> Optional[int]:
    """
    解析日期时间字符串，返回毫秒时间戳
    
    Args:
        dt_str: 日期时间字符串，支持多种格式
        
    Returns:
        毫秒时间戳，解析失败返回 None
    """
    if not dt_str:
        return None
    
    dt_str = dt_str.strip()
    
    # 支持的格式
    formats = [
        "%Y-%m-%d %H:%M:%S",
        "%Y-%m-%d %H:%M",
        "%Y-%m-%d",
        "%Y/%m/%d %H:%M:%S",
        "%Y/%m/%d %H:%M",
        "%Y/%m/%d",
    ]
    
    for fmt in formats:
        try:
            dt = datetime.strptime(dt_str, fmt)
            return int(dt.timestamp() * 1000)
        except ValueError:
            continue
    
    return None


# 错误码映射
ERROR_CODES = {
    "GE1003": "未配置API Key，请前往 https://www.feedax.cn 申请，然后在项目根目录 .env 文件中配置 FEEDAX_SEARCH_API_KEY",
    "GE1004": "API Key已失效，请检查 .env 中的 FEEDAX_SEARCH_API_KEY 配置，或前往 https://www.feedax.cn 重新申请",
    "GE1005": "API Key已过期，请前往 https://www.feedax.cn 重新申请，并更新 .env 中的 FEEDAX_SEARCH_API_KEY",
    "GE1006": "API Key无效，请检查 .env 中的 FEEDAX_SEARCH_API_KEY 配置是否正确",
    "GE1007": "账户余额不足，请前往 https://www.feedax.cn 充值",
}

HTTP_ERROR_MESSAGES = {
    400: "请求参数错误，请检查输入",
    401: "未授权访问，API Key可能无效",
    403: "禁止访问，API Key可能已过期或失效",
    404: "接口不存在",
    429: "请求过于频繁，请稍后再试",
    500: "服务器内部错误",
    502: "服务暂时不可用，请稍后再试",
    503: "服务维护中，请稍后再试",
    504: "网关超时，请稍后再试",
}


def format_timestamp(ms_timestamp):
    """将毫秒时间戳转换为可读格式"""
    try:
        dt = datetime.fromtimestamp(ms_timestamp / 1000)
        return dt.strftime("%Y-%m-%d %H:%M:%S")
    except:
        return str(ms_timestamp)


def display_summary(data, max_items=5):
    """在对话中展示前N条数据摘要"""
    if not data or not isinstance(data, list):
        print("暂无数据")
        return
    
    items = data[:max_items]
    total = len(data)
    
    print(f"\n{'='*60}")
    print(f"📊 搜索结果摘要（共 {total} 条，展示前 {len(items)} 条）")
    print(f"{'='*60}\n")
    
    for i, raw_item in enumerate(items, 1):
        item = normalize_item(raw_item)
        title = item['title'] or '无标题'
        
        print(f"【{i}】{item['platformName'] or '未知平台'}")
        print(f"    标题: {title[:60]}{'...' if len(title) > 60 else ''}")
        print(f"    作者: {item['authorName'] or '未知作者'}")
        print(f"    时间: {format_timestamp(item['publishTime'])}")
        if item['originalUrl']:
            print(f"    链接: {item['originalUrl']}")
        print()
    
    if total > max_items:
        print(f"... 还有 {total - max_items} 条数据，详见JSON文件\n")


def normalize_item(item: Dict[str, Any]) -> Dict[str, Any]:
    """提取数据项的标准字段"""
    return {field: item.get(field, "" if field != "publishTime" else 0) for field in STANDARD_FIELDS}


def save_to_json(result, query, request_body=None, output_dir=None):
    """将完整结果保存为JSON文件"""
    if output_dir is None:
        output_dir = DEFAULT_OUTPUT_DIR
    
    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)
    
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    safe_query = "".join(c for c in query if c.isalnum() or c in ('_', '-'))[:20]
    filename = f"{safe_query}_{timestamp}.json"
    filepath = output_dir / filename
    
    # 转换数据为标准格式
    raw_data = result.get('data', []) if isinstance(result, dict) else []
    normalized_data = [normalize_item(item) for item in raw_data]
    
    search_info = {
        "query": query,
        "search_time": datetime.now().isoformat(),
        "total_results": len(normalized_data)
    }
    
    if request_body:
        search_info["request_body"] = request_body
    
    output_data = {
        "search_info": search_info,
        "data": normalized_data
    }
    
    with open(filepath, 'w', encoding='utf-8') as f:
        json.dump(output_data, f, indent=2, ensure_ascii=False)
    
    return filepath


def parse_error_response(response_data: Dict[str, Any], http_code: int = None) -> Dict[str, Any]:
    """解析错误响应，返回友好的错误信息"""
    error_code = response_data.get("code") or response_data.get("errorCode")
    
    if error_code:
        if isinstance(error_code, str) and error_code in ERROR_CODES:
            return {
                "success": False,
                "code": error_code,
                "error": ERROR_CODES[error_code],
                "message": ERROR_CODES[error_code],
                "data": None
            }
        elif isinstance(error_code, int) and error_code in HTTP_ERROR_MESSAGES:
            return {
                "success": False,
                "code": f"HTTP_{error_code}",
                "error": HTTP_ERROR_MESSAGES[error_code],
                "message": HTTP_ERROR_MESSAGES[error_code],
                "data": None
            }
    
    if http_code and http_code in HTTP_ERROR_MESSAGES:
        return {
            "success": False,
            "code": f"HTTP_{http_code}",
            "error": HTTP_ERROR_MESSAGES[http_code],
            "message": HTTP_ERROR_MESSAGES[http_code],
            "data": None
        }
    
    error_msg = response_data.get("error") or response_data.get("message") or "未知错误"
    return {
        "success": False,
        "code": "UNKNOWN",
        "error": error_msg,
        "message": error_msg,
        "data": None
    }


class InfoSearchClient:
    """舆情内容检索API客户端"""
    
    def __init__(self, [REDACTED_SECRET] = None, base_url: Optional[str] = None):
        self.[REDACTED_SECRET] or API_KEY
        self.base_url = base_url or API_BASE_URL
        self.endpoint = API_ENDPOINT
    
    def check_api_key(self) -> tuple[bool, Optional[str]]:
        """检查API Key是否有效"""
        if not self.api_key or not self.api_key.strip():
            return False, ERROR_CODES["GE1003"]
        return True, None
    
    def search_with_params(self, params: Dict[str, Any]) -> Dict[str, Any]:
        """使用参数字典进行搜索"""
        is_valid, error_msg = self.check_api_key()
        if not is_valid:
            return {
                "success": False,
                "code": "GE1003",
                "error": error_msg,
                "message": error_msg,
                "data": None
            }
        
        query_string = params.get("queryString", "")
        if not query_string:
            return {
                "success": False,
                "code": "PARAM_ERROR",
                "error": "queryString不能为空",
                "message": "queryString不能为空",
                "data": None
            }
        
        payload = {
            "apiKey": self.api_key,
            "queryString": query_string,
            "size": params.get("size", 20),
            "sortField": params.get("sortField", "publish_time"),
            "sortOrder": params.get("sortOrder", "desc"),
        }
        
        # 可选参数
        optional_fields = [
            "searchAfter", "mediaNames", "messageTypes", "contentTypes",
            "sentiments", "mediaClasses", "domains", "scenes", "areaCodes",
            "noiseFlag", "verificationType", "publishTimeFrom", "publishTimeTo",
            "fansCountMin", "fansCountMax", "commentsCountMin", "commentsCountMax",
            "repostsCountMin", "repostsCountMax", "likesCountMin", "likesCountMax",
            "interactCountMin", "interactCountMax"
        ]
        
        for field in optional_fields:
            if field in params and params[field] is not None:
                payload[field] = params[field]
        
        url = f"{self.base_url}{self.endpoint}"
        
        try:
            response = requests.post(
                url,
                json=payload,
                headers={
                    "Content-Type": "application/json; charset=UTF-8",
                    "x-api-key": self.api_key
                },
                timeout=DEFAULT_TIMEOUT
            )
            
            if response.status_code != 200:
                try:
                    error_data = response.json()
                except:
                    error_data = {"message": response.text}
                return parse_error_response(error_data, response.status_code)
            
            result = response.json()
            
            if result.get("code") not in [200, "200", None]:
                return parse_error_response(result)
            
            return {
                "success": True,
                "code": result.get("code", 200),
                "message": result.get("message", "检索成功"),
                "data": result.get("data", [])
            }
            
        except requests.exceptions.Timeout:
            return {
                "success": False,
                "code": "TIMEOUT",
                "error": "请求超时，请稍后再试",
                "message": "请求超时，请稍后再试",
                "data": None
            }
        except requests.exceptions.ConnectionError:
            return {
                "success": False,
                "code": "CONNECTION_ERROR",
                "error": "无法连接到服务器，请检查网络或稍后再试",
                "message": "无法连接到服务器，请检查网络或稍后再试",
                "data": None
            }
        except Exception as e:
            return {
                "success": False,
                "code": "UNKNOWN_ERROR",
                "error": f"请求失败: {str(e)}",
                "message": f"请求失败: {str(e)}",
                "data": None
            }


def main():
    parser = argparse.ArgumentParser(
        description='舆情内容检索命令行工具',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
示例:
  %(prog)s --query "热点新闻" --area "长沙" --days 7
  %(prog)s --query "医疗问题" --media "微博,抖音" --sentiment negative
  %(prog)s --query "交通事故" --domain "突发事件" --scene "交通事故"
        """
    )
    
    # === 必填参数 ===
    parser.add_argument('--query', '-q', required=True, help='搜索关键词 (queryString)')
    
    # === 分页与排序 (2.2) ===
    parser.add_argument('--size', '-s', type=int, default=50, help='返回条数，默认50 (size)')
    parser.add_argument('--search-after', help='深度分页游标，首次传空 (searchAfter)')
    parser.add_argument('--sort', default='publish_time',
                        choices=['publish_time', 'interact_count', 'comments_count',
                                'likes_count', 'reposts_count', 'fans_count'],
                        help='排序字段，默认按发布时间 (sortField)')
    parser.add_argument('--sort-order', default='desc', choices=['asc', 'desc'],
                        help='排序方式，默认降序 (sortOrder)')
    
    # === 内容筛选 (2.3) ===
    parser.add_argument('--media', '-m', help='媒体平台，逗号分隔 (mediaNames)')
    parser.add_argument('--author', help='作者名称，精确匹配 (authorName)')
    parser.add_argument('--msg-type', choices=['original', 'comment', 'repost'],
                        help='消息类型 (messageTypes)')
    parser.add_argument('--content-type', choices=['video', 'picture', 'text'],
                        help='内容类型 (contentTypes)')
    parser.add_argument('--sentiment', choices=['negative', 'neutral', 'positive', 'all'],
                        help='情感倾向 (sentiments)')
    parser.add_argument('--media-class', help='媒体分类：央媒/省媒/地市/商业/其他 (mediaClasses)')
    parser.add_argument('--domain', help='内容领域 (domains)')
    parser.add_argument('--scene', help='业务场景 (scenes)')
    parser.add_argument('--area', '-a', help='地域名称 (areaCodes)')
    parser.add_argument('--noise-flag', type=int, choices=[0, 1], default=0,
                        help='噪声标识：0=非噪声, 1=噪声 (noiseFlag)')
    parser.add_argument('--ai-abstract', type=int, choices=[0, 1],
                        help='摘要类型：0=算法摘要, 1=AI摘要 (aiAbstractFlag)')
    parser.add_argument('--verification', choices=['blue', 'yellow', 'normal'],
                        help='认证类型 (verificationType)')
    
    # === 时间筛选 (2.4) ===
    parser.add_argument('--days', '-d', type=int, default=7, help='时间范围（天），默认7天')
    parser.add_argument('--time-from', help='开始时间 (publishTimeFrom)，格式: yyyy-mm-dd HH:MM:SS')
    parser.add_argument('--time-to', help='结束时间 (publishTimeTo)，格式: yyyy-mm-dd HH:MM:SS')
    
    # === 互动数据筛选 (2.5) ===
    parser.add_argument('--min-interact', type=int, help='最小互动数 (interactCountMin)')
    parser.add_argument('--max-interact', type=int, help='最大互动数 (interactCountMax)')
    parser.add_argument('--min-comments', type=int, help='最小评论数 (commentsCountMin)')
    parser.add_argument('--max-comments', type=int, help='最大评论数 (commentsCountMax)')
    parser.add_argument('--min-reposts', type=int, help='最小转发数 (repostsCountMin)')
    parser.add_argument('--max-reposts', type=int, help='最大转发数 (repostsCountMax)')
    parser.add_argument('--min-likes', type=int, help='最小点赞数 (likesCountMin)')
    parser.add_argument('--max-likes', type=int, help='最大点赞数 (likesCountMax)')
    parser.add_argument('--min-fans', type=int, help='最小粉丝数 (fansCountMin)')
    parser.add_argument('--max-fans', type=int, help='最大粉丝数 (fansCountMax)')
    
    # === 输出控制 ===
    parser.add_argument('--output-dir', '-o', help='JSON文件输出目录')
    parser.add_argument('--show-count', type=int, default=5, help='对话中展示的条数，默认5条')
    
    args = parser.parse_args()
    
    # 构建搜索参数
    params = {
        "queryString": args.query,
        "size": args.size,
        "sortField": args.sort,
        "sortOrder": args.sort_order
    }
    
    # 处理深度分页游标
    if args.search_after:
        params["searchAfter"] = args.search_after
    
    # 处理时间范围
    import time as time_module
    
    if args.time_from or args.time_to:
        # 使用精确时间（内嵌解析）
        if args.time_from:
            ts = parse_datetime_str(args.time_from)
            if ts:
                params["publishTimeFrom"] = ts
            else:
                print(f"警告: 无法解析开始时间 '{args.time_from}'，使用默认时间范围", file=sys.stderr)
        
        if args.time_to:
            ts = parse_datetime_str(args.time_to)
            if ts:
                params["publishTimeTo"] = ts
            else:
                print(f"警告: 无法解析结束时间 '{args.time_to}'，使用当前时间", file=sys.stderr)
        
        # 如果只指定了一个时间，补充另一个
        if "publishTimeFrom" not in params:
            params["publishTimeFrom"] = params.get("publishTimeTo", int(time_module.time() * 1000)) - (args.days * 24 * 60 * 60 * 1000)
        if "publishTimeTo" not in params:
            params["publishTimeTo"] = int(time_module.time() * 1000)
    else:
        # 使用 days 参数
        publish_time_to = int(time_module.time() * 1000)
        publish_time_from = publish_time_to - (args.days * 24 * 60 * 60 * 1000)
        params["publishTimeFrom"] = publish_time_from
        params["publishTimeTo"] = publish_time_to
    
    # 处理地域（内嵌解析，支持省/市/区县）
    if args.area:
        codes = parse_area_to_codes(args.area)
        if codes:
            params["areaCodes"] = codes
    
    # 处理媒体平台
    if args.media:
        media_names = [m.strip() for m in args.media.split(',')]
        params["mediaNames"] = media_names
    
    # 处理作者名称
    if args.author:
        params["authorName"] = args.author
    
    # 处理情感倾向
    if args.sentiment:
        sentiment_map = {
            'negative': [-1],
            'neutral': [0],
            'positive': [1],
            'all': [-1, 0, 1]
        }
        params["sentiments"] = sentiment_map[args.sentiment]
    
    # 处理消息类型
    if args.msg_type:
        msg_type_map = {
            'original': [1],
            'comment': [2],
            'repost': [3]
        }
        params["messageTypes"] = msg_type_map[args.msg_type]
    
    # 处理领域和场景
    if args.domain:
        params["domains"] = [args.domain]
    if args.scene:
        params["scenes"] = [args.scene]
    
    # 处理媒体分类
    if args.media_class:
        params["mediaClasses"] = [args.media_class]
    
    # 处理噪声标识
    if args.noise_flag is not None:
        params["noiseFlag"] = args.noise_flag
    
    # 处理AI摘要
    if args.ai_abstract is not None:
        params["aiAbstractFlag"] = args.ai_abstract
    
    # 处理互动数筛选
    if args.min_interact:
        params["interactCountMin"] = args.min_interact
    if args.max_interact:
        params["interactCountMax"] = args.max_interact
    
    # 处理评论数筛选
    if args.min_comments:
        params["commentsCountMin"] = args.min_comments
    if args.max_comments:
        params["commentsCountMax"] = args.max_comments
    
    # 处理转发数筛选
    if args.min_reposts:
        params["repostsCountMin"] = args.min_reposts
    if args.max_reposts:
        params["repostsCountMax"] = args.max_reposts
    
    # 处理点赞数筛选
    if args.min_likes:
        params["likesCountMin"] = args.min_likes
    if args.max_likes:
        params["likesCountMax"] = args.max_likes
    
    # 处理粉丝数筛选
    if args.min_fans:
        params["fansCountMin"] = args.min_fans
    if args.max_fans:
        params["fansCountMax"] = args.max_fans
    
    # 处理内容类型
    if args.content_type:
        params["contentTypes"] = [args.content_type]
    
    # 处理认证类型
    if args.verification:
        verification_map = {
            'blue': 1,
            'yellow': 0,
            'normal': -1
        }
        params["verificationType"] = verification_map[args.verification]
    
    # 执行搜索
    try:
        client = InfoSearchClient()
        result = client.search_with_params(params)
        
        # 判断成功：code=200 或 success=True
        is_success = False
        if isinstance(result, dict):
            code = result.get('code')
            if code in [200, "200"] or result.get('success') is True:
                is_success = True
        
        if is_success:
            data = result.get('data') or []
            display_summary(data, max_items=args.show_count)
            filepath = save_to_json(result, args.query, request_body=params, output_dir=args.output_dir)
            print(f"📁 完整数据已保存至: {filepath}")
        else:
            error_msg = result.get('message') or result.get('error') or '未知错误' if isinstance(result, dict) else str(result)
            print(f"❌ 搜索失败: {error_msg}", file=sys.stderr)
            sys.exit(1)
            
    except Exception as e:
        print(f"❌ 搜索失败: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()


path: references/scenes.json
content: {
    "scenes": [
        {
            "name": "非法烟花",
            "children": []
        },
        {
            "name": "实名举报",
            "children": []
        },
        {
            "name": "自然灾害",
            "children": ["洪涝场景", "沙尘雾霾"]
        },
        {
            "name": "维权场景",
            "children": ["讨薪维权", "业主维权", "拆迁维权", "政府相关"]
        },
        {
            "name": "城市管理",
            "children": ["城管执法", "车辆违停", "占道经营"]
        },
        {
            "name": "交通事故",
            "children": []
        },
        {
            "name": "打架斗殴",
            "children": []
        },
        {
            "name": "涉警信息",
            "children": []
        },
        {
            "name": "校园安全",
            "children": []
        },
        {
            "name": "环境污染",
            "children": []
        },
        {
            "name": "医疗卫生",
            "children": []
        },
        {
            "name": "安全事故",
            "children": ["火灾事故", "坍塌事故", "溺水事故"]
        }
    ]
}


path: references/media_names.json
content: {
    "mediaNames": [
        "抖音",
        "快手",
        "微博",
        "今日头条",
        "火山",
        "哔哩哔哩",
        "小红书",
        "微信",
        "有驾",
        "百家号",
        "搜狐新闻",
        "同花顺",
        "股吧",
        "东方财富",
        "百度贴吧",
        "搜狐网",
        "乙方宝招标",
        "度小视",
        "汽车之家",
        "网易",
        "百度新闻",
        "豆瓣网",
        "喜马拉雅",
        "百度知道",
        "易车网",
        "B站",
        "网易新闻",
        "懂车帝",
        "新浪网",
        "雪球",
        "知乎",
        "西瓜",
        "好看",
        "黑猫投诉",
        "UC头条",
        "新浪新闻",
        "法律快车",
        "东方财富网",
        "搜狐新闻APP",
        "腾讯新闻",
        "新浪财经",
        "皮皮虾",
        "趣头条",
        "QQ浏览器",
        "什么值得买",
        "腾讯微视",
        "一点资讯",
        "美篇",
        "携程旅行网",
        "全国12315互联网平台"
    ],
    "categories": {
        "短视频平台": ["抖音", "快手", "火山", "西瓜", "好看", "度小视", "腾讯微视", "皮皮虾"],
        "社交媒体": ["微博", "微信", "小红书"],
        "资讯平台": ["今日头条", "百家号", "网易新闻", "腾讯新闻", "搜狐新闻", "新浪新闻", "UC头条", "一点资讯", "趣头条"],
        "视频平台": ["哔哩哔哩", "B站"],
        "财经平台": ["同花顺", "股吧", "东方财富", "东方财富网", "雪球", "新浪财经"],
        "问答社区": ["知乎", "百度知道", "百度贴吧", "豆瓣网"],
        "汽车平台": ["有驾", "汽车之家", "易车网", "懂车帝"],
        "投诉平台": ["黑猫投诉", "全国12315互联网平台"],
        "其他": ["喜马拉雅", "法律快车", "乙方宝招标", "QQ浏览器", "什么值得买", "美篇", "携程旅行网", "搜狐网", "新浪网", "网易", "百度新闻", "搜狐新闻APP"]
    }
}


path: references/domains.json
content: {
    "domains": [
        {
            "name": "其它",
            "children": []
        },
        {
            "name": "社会民生",
            "children": ["劳动就业", "消费问题", "征地拆迁", "医疗问题", "住房问题", "交通问题", "社会保障", "食品安全", "家庭问题"]
        },
        {
            "name": "城市管理",
            "children": ["城市执法", "城市住房", "城市交通", "城市水电气热", "市政工程", "市容环卫"]
        },
        {
            "name": "社会问题",
            "children": ["贫富分化", "民族问题", "心理健康问题", "性别歧视"]
        },
        {
            "name": "司法相关",
            "children": ["知识产权", "案件相关"]
        },
        {
            "name": "涉政相关",
            "children": ["国家施政方针", "政府选举", "纪检监察"]
        },
        {
            "name": "生态环境",
            "children": ["环保问题", "环境污染", "生态破坏"]
        },
        {
            "name": "教育相关",
            "children": ["教育体制", "校园饮食", "校车安全", "校内安全问题", "教师问题", "教育腐败", "校园维权", "热点教育问题"]
        },
        {
            "name": "三农相关",
            "children": ["农民", "农村", "农业"]
        },
        {
            "name": "突发事件",
            "children": ["自然灾害", "事故灾难", "公共卫生事件", "社会安全事件"]
        },
        {
            "name": "涉警相关",
            "children": ["治安管理", "人身安全", "涉警", "涉毒", "涉黑涉恶", "诈骗"]
        },
        {
            "name": "敏感信息",
            "children": []
        },
        {
            "name": "应急领域",
            "children": ["洪涝灾害", "自然灾难", "突发事故", "安全事故"]
        },
        {
            "name": "重点关注",
            "children": ["地域信息"]
        }
    ]
}


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