Skill: longgggggg/trend-scope-report (version 1.0.3)

--- name: TrendScope description: "舆情趋势洞察技能。根据用户需求自动生成专业的舆情分析报告，包含情感分布、地域分布、关键词分析、媒体分布、时间趋势等多维度分析。触发词：舆情、报告、生成报告、舆情报告、分析报告、品牌分析、市场分析、竞品分析、趋势分析。" metadata: openclaw: requires: env: - FEEDAX_REPORT_API_KEY bins: - python3 primaryEnv: FEEDAX_REPORT_API_KEY --- # TrendScope - 舆情趋势洞察 根据用户需求自动生成专业的舆情分析报告，包含情感分布、地域分布、关键词分析、媒体分布、时间趋势等多维度分析。 --- ## 一、执行流程 收到用户报告生成请求后，按以下步骤执行： ## Step1：配置 API Key **使用本技能前，必须先配置 API Key。** ### 配置方式（任选其一） 1. **环境变量（推荐）**：在运行智能体的宿主环境中设置 `FEEDAX_REPORT_API_KEY`，或通过系统/密钥管理工具注入，**请勿在对话中粘贴或朗读密钥**。 2. **项目 `.env`**：在技能项目根目录创建或编辑 `.env`，写入 `FEEDAX_REPORT_API_KEY=...`（可参考仓库内 `.env.example`）。不要将 `.env` 内容复制到聊天或日志。 ### 如果未配置 向用户返回说明（**不要**要求用户把 Key 发到对话里）： > **GE1003**：未配置 API Key。请前往 https://www.feedax.cn 申请，并在宿主环境或项目 `.env` 中设置环境变量 `FEEDAX_REPORT_API_KEY`，**切勿在聊天中发送密钥**。 ### 数据与隐私 检索词、筛选条件与聚合参数会作为请求体提交到 Feedax 报告 API。请勿在 `--query` 或分析需求中输入身份证号、手机号、精确门牌等可识别个人身份的信息；涉及敏感主体时请自行脱敏后再检索。 ### Step 2: 解析用户输入 根据 **三、解析规则** 从用户自然语言中提取参数。 **特别注意**：如果用户输入中包含地域名称（省/市/区县），**必须**同时： 1. 在 `--query` 中加入地域关键词 2. 添加 `--area "地域名"` 参数进行地域筛选 ### Step 3: 执行报告生成命令 ```bash # 无地域时 python3 scripts/report_cli.py --query "关键词" --days 7 --full-analysis # 有地域时（如：长沙教育问题） python3 scripts/report_cli.py --query "(长沙|湖南)&(教育|学校|教学|学生|老师|教师)" --area "长沙" --days 7 --full-analysis ``` ### Step 4: 返回结果 - 在对话中展示报告摘要（总数据量、情感分布、地域TOP5、关键词TOP10等） - 完整报告自动保存至 `~/Desktop/舆情分析报告/` 目录 --- ## 二、CLI 参数说明 ### 2.1 必传参数 | CLI参数 | 简写 | 说明 | | ----------- | ------ | ------------------------------------ | | `--query` | `-q` | 全文检索关键词，匹配标题/内容/作者名 | **queryString 语法规则**: - **空格** = 与（AND） - **`&`** = 与（AND） - **`|`** = 或（OR） - **`!`** = 非（NOT） - **`()`** = 优先级分组 - 示例: `(北京|上海)&(十五届|十五大)&!(茶叶|鸡蛋)` ### 2.2 分页与排序 | CLI参数 | 简写 | 默认值 | 说明 | | ---------------- | ------ | ---------------- | ---------------------------- | | `--size` | `-s` | 10 | 返回条数，1-20 | | `--sort` | | `publish_time` | 排序字段，默认按发布时间排序 | | `--sort-order` | | `desc` | 排序方式：asc/desc | **--sort 可选值**: - `publish_time` - **默认**。按发布时间排序，获取最新内容 - `interact_count` - 按互动数（热度）排序 - `comments_count` - 按评论数排序 - `likes_count` - 按点赞数排序 - `reposts_count` - 按转发数排序 - `fans_count` - 按粉丝数排序 ### 2.3 内容筛选 | CLI参数 | 简写 | 说明 | | ------------------ | ------ | --------------------------------------- | | `--media` | `-m` | 媒体平台，逗号分隔（如: 微博,抖音） | | `--author` | | 作者名称，精确匹配 | | `--msg-type` | | 消息类型：original/comment/repost/all | | `--content-type` | | 内容类型：video/picture/text/all | | `--sentiment` | | 情感倾向：negative/neutral/positive/all | | `--media-class` | | 媒体分类（如: 中央媒体） | | `--domain` | | 内容领域（见参考文件） | | `--scene` | | 业务场景（见参考文件） | | `--area` | `-a` | 地域名称（如: 南京） | | `--verification` | | 认证类型：blue/yellow/normal | ### 2.4 时间筛选 | CLI参数 | 简写 | 说明 | | --------------- | ------ | ------------------------------------- | | `--days` | `-d` | 最近N天（如: --days 7），默认7天 | | `--time-from` | | 开始时间（格式: YYYY-MM-DD HH:MM:SS） | | `--time-to` | | 结束时间（格式: YYYY-MM-DD HH:MM:SS） | ### 2.5 互动数据筛选 | CLI参数 | 说明 | | ------------------ | ------------ | | `--interact-min` | 互动数最小值 | | `--interact-max` | 互动数最大值 | | `--comments-min` | 评论数最小值 | | `--comments-max` | 评论数最大值 | ### 2.6 报告控制参数 | CLI参数 | 简写 | 说明 | | ------------------- | ------ | ------------------------------------------ | | `--full-analysis` | `-f` | 启用全维度分析（所有统计维度） | | `--no-articles` | | 不返回热文列表（仅返回统计数据） | | `--output-dir` | `-o` | 报告输出目录，默认 ~/Desktop/舆情分析报告/ | | `--json-only` | | 仅输出JSON格式 | | `--quiet` | | 静默模式，不显示摘要 | **aggregationTypes 默认值说明**: - **默认模式**（非 `--full-analysis`）: `["sentiment", "keywords", "media_name", "area_code_province", "publish_time_histogram"]` - 返回：情感分布、关键词、媒体平台、地域分布、时间趋势 - **全维度模式**（`--full-analysis`）: `["sentiment", "area_code_province", "media_class", "verification_type", "keywords", "media_name", "channel", "publish_time_histogram"]` - 返回：所有8个维度的统计数据 > **注意**: 未在 `aggregationTypes` 中指定的维度，API 将返回 `null`。这是正常行为，并非错误。 **aggregationTypes 可选值**: | 参数值 | 含义说明 | 典型应用场景 | | -------------------------- | ---------------- | -------------------- | | `sentiment` | 情感倾向分布 | 舆情正负面分析 | | `area_code_province` | 地域分布（省级） | 省份/地区热力图 | | `media_class` | 媒体分类分布 | 不同媒体类型统计 | | `verification_type` | 作者认证类型分布 | 蓝V/黄V/普通用户统计 | | `keywords` | 关键词分布 | 生成词云图 | | `media_name` | 媒体名称分布 | 具体发布账号排名 | | `channel` | 通道聚合分布 | 数据来源通道统计 | | `publish_time_histogram` | 发布时间趋势 | 时间轴折线图/柱状图 | | `scenes` | 场景分布 | 业务场景分类统计 | --- ## 四、媒体平台列表 支持的媒体名称（`mediaNames`）： 抖音、快手、微博、今日头条、火山、哔哩哔哩、小红书、微信、有驾、百家号、搜狐新闻、同花顺、股吧、东方财富、百度贴吧、搜狐网、乙方宝招标、度小视、汽车之家、网易、百度新闻、豆瓣网、喜马拉雅、百度知道、易车网、B站、网易新闻、懂车帝、新浪网、雪球、知乎、西瓜、好看、黑猫投诉、UC头条、新浪新闻、法律快车、东方财富网、搜狐新闻APP、腾讯新闻、新浪财经、皮皮虾、趣头条、QQ浏览器、什么值得买、腾讯微视、一点资讯、美篇、携程旅行网、全国12315互联网平台 --- ## 五、媒体分类 (mediaClasses) | 分类 | 说明 | | -------------- | -------------- | | 其他 | 未分类媒体 | | 商业媒体 | 商业性质媒体 | | 行业媒体 | 行业垂直媒体 | | 民企 | 民营企业账号 | | KOL | 关键意见领袖 | | 省级媒体 | 省级官方媒体 | | 市&以下媒体 | 市级及以下媒体 | | 中央媒体 | 中央级官方媒体 | | 中央政府 | 中央政府机构 | | 区县及以下政府 | 区县级政府机构 | | 事业单位 | 事业单位账号 | | 市级政府 | 市级政府机构 | | 省级政府 | 省级政府机构 | | 社会团体 | 社会团体组织 | | 国企 | 国有企业 | | 服务机构 | 服务机构 | | 明星 | 明星账号 | | 央企 | 中央企业 | | 境外媒体 | 境外媒体 | | 高校 | 高等院校 | | 外国政府 | 外国政府机构 | | 智库 | 智库机构 | --- ## 三、解析规则 ### 3.1 关键词解析 (queryString) 从用户输入中提取核心检索关键词，生成搜索关键词表达式。 #### 语法规则 | 运算符 | 含义 | 示例 | | ------ | ------------ | --------------------------- | | `\|` | 或关系 (OR) | `北京\|首都\|京城` | | `&` | 与关系 (AND) | `(南京\|金陵)&(杀人\|凶杀)` | | 空格 | 与关系 (AND) | `(南京\|金陵) (杀人\|凶杀)` | | `()` | 分组 | `(北京\|上海)&(医疗\|卫生)` | | `!` | 非（NOT） | `北京&!(茶叶\|鸡蛋)` | #### 解析示例 | 用户输入 | queryString | 同时需要 --area | | ---------------- | --------------------------------- | --------------- | | 南京杀人案 | `(南京\|金陵)&(杀人\|凶杀\|行凶)` | `--area "南京"` | | 比亚迪汽车召回 | `比亚迪&(召回\|退市\|下架)` | 无 | | 北京暴雨 | `(北京\|首都)&(暴雨\|大雨\|洪涝)` | `--area "北京"` | | 江苏南京教育问题 | `(南京\|金陵)&(教育\|学校)` | `--area "南京"` | > **重要**：当用户输入中明确包含地域名称（如城市、省份）时，**必须同时传递 `--area` 参数**。 > - `--query` 中的地域关键词：匹配**内容中提到该地域**的文章 > - `--area` 参数：筛选**发布者所在地域**的文章 > - 两者配合使用可精确定位特定地域的本地舆情 ### 3.2 媒体平台映射 | 用户表述 | mediaNames | | ------------------ | -------------------------------------------------------------------------- | | 抖音、快手、小红书 | `["抖音", "快手", "小红书"]` | | 短视频平台 | `["抖音", "快手", "火山", "西瓜"]` | | 微博 | `["微博"]` | | 财经平台 | `["同花顺", "股吧", "东方财富", "雪球"]` | | 新闻平台 | `["今日头条", "百家号", "搜狐新闻", "网易新闻", "新浪新闻", "腾讯新闻"]` | ### 3.3 情感倾向映射 | 用户表述 | sentiments | | ------------- | -------------- | | 负面/负面信息 | `[-1]` | | 正面/正面信息 | `[1]` | | 中性 | `[0]` | | 全部情感 | `[-1, 0, 1]` | ### 3.4 媒体级别映射 | 用户表述 | mediaClasses | | ------------- | ------------------------------------------------------------------------------- | | 央媒/央级媒体 | `["中央媒体"]` | | 省媒/省级媒体 | `["省级媒体"]` | | 省级及以上 | `["中央媒体", "省级媒体"]` | | 地市级 | `["市&以下媒体"]` | | 商业媒体 | `["商业媒体"]` | | 官方账号 | `["中央媒体", "省级媒体", "市&以下媒体", "中央政府", "省级政府", "市级政府"]` | ### 3.5 消息类型映射 | 用户表述 | messageTypes | | ----------- | ------------- | | 原发/原创 | `[1]` | | 评论/评论区 | `[2]` | | 转发 | `[3]` | | 全部消息 | `[1, 2, 3]` | ### 3.6 内容类型映射 | 用户表述 | contentTypes | | --------- | --------------- | | 视频 | `["video"]` | | 图片/图文 | `["picture"]` | | 文本/文字 | `["text"]` | ### 3.7 认证类型映射 | 用户表述 | verificationType | | ------------------- | ---------------- | | 蓝V/官方账号/机构号 | `1` | | 黄V/个人认证/大V | `0` | | 普通用户/素人 | `-1` | ### 3.8 时间范围计算 | 用户表述 | 计算方式 | | ----------- | ----------------- | | 6小时 | 当前时间 - 6小时 | | 24小时/一天 | 当前时间 - 24小时 | | 3天 | 当前时间 - 3天 | | 7天/一周 | 当前时间 - 7天 | | 30天/一个月 | 当前时间 - 30天 | ### 3.9 排序方式映射 **默认规则**：除非用户明确要求，否则一律使用 `publish_time`（发布时间）排序 | 用户表述 | sortField | 说明 | | -------------------- | ------------------ | ---------------------------------- | | **无明确要求** | `publish_time` | **默认**，获取最新发布的内容 | | 按热度/热门/最火的 | `interact_count` | 按互动数（热度）排序 | | 按评论数/最多评论 | `comments_count` | 按评论数排序 | | 按点赞数/最多点赞 | `likes_count` | 按点赞数排序 | | 按转发数/最多转发 | `reposts_count` | 按转发数排序 | | 按粉丝数/大V账号 | `fans_count` | 按粉丝数排序 | | 最新/最近/新发布的 | `publish_time` | 按发布时间排序（默认） | --- ## 六、CLI 命令示例 **文件**: `scripts/report_cli.py` 通过命令行调用报告生成API，支持所有筛选参数和聚合维度。 ### 基础用法 ```bash # 基础报告生成 python3 scripts/report_cli.py --query "医疗问题" # 生成指定地域的报告（最近7天） python3 scripts/report_cli.py --query "南京 315" --area "南京" --days 7 # 生成指定平台和情感的报告 python3 scripts/report_cli.py --query "医疗问题" --media "微博,抖音" --sentiment negative # 生成完整分析报告（包含所有聚合维度） python3 scripts/report_cli.py --query "比亚迪" --days 30 --full-analysis ``` > 完整参数说明见 **二、CLI 参数说明** ### 完整示例 ```bash # 生成比亚迪最近30天的完整舆情分析报告 python3 scripts/report_cli.py \ --query "比亚迪" \ --days 30 \ --media "微博,抖音,今日头条" \ --full-analysis \ --output-dir "./reports" # 生成南京教育领域负面舆情报告 python3 scripts/report_cli.py \ --query "(南京|金陵) (教育|学校)" \ --area "南京" \ --days 7 \ --sentiment negative \ --full-analysis ``` ### 输出结果 1. **对话展示**：自动展示报告摘要（总数据量、情感分布、地域分布、关键词TOP10等） 2. **JSON报告文件**：完整数据保存至 `~/Desktop/舆情分析报告/查询词_时间戳.json` 3. **Markdown报告文件**：可视化报告保存至 `~/Desktop/舆情分析报告/查询词_时间戳.md` --- ## 七、参考文件 - `references/domains.json` - 领域分类完整列表 - `references/scenes.json` - 场景分类完整列表 - `references/media_names.json` - 媒体平台完整列表 - `references/aggregation_types.json` - 聚合维度完整列表 - `assets/area_codes.json` - 国标区域编码数据 --- ## 八、示例对话 ### 示例1：基础报告 **用户**: 生成一份关于比亚迪最近一个月的舆情分析报告 **参数解析**: | 提取项 | 用户表述 | CLI参数 | | -------- | ------------ | -------------------- | | 关键词 | 比亚迪 | `--query "比亚迪"` | | 时间范围 | 最近一个月 | `--days 30` | | 分析模式 | 舆情分析报告 | `--full-analysis` | **执行命令**: ```bash python3 scripts/report_cli.py --query "比亚迪" --days 30 --full-analysis ``` ### 示例2：指定平台和情感 **用户**: 帮我看看微博和抖音上关于南京地铁的负面舆情 **参数解析**: | 提取项 | 用户表述 | CLI参数 | | -------- | ---------- | ------------------------------ | | 关键词 | 南京地铁 | `--query "(南京\|金陵)&地铁"` | | 媒体平台 | 微博和抖音 | `--media "微博,抖音"` | | 情感倾向 | 负面舆情 | `--sentiment negative` | | 地域 | 南京 | `--area "南京"` | **执行命令**: ```bash python3 scripts/report_cli.py --query "(南京|金陵)&地铁" --media "微博,抖音" --sentiment negative --area "南京" --days 7 ``` ### 示例3：热度排序 **用户**: 最近3天最火的医疗相关话题有哪些 **参数解析**: | 提取项 | 用户表述 | CLI参数 | | -------- | -------- | ------------------------- | | 关键词 | 医疗 | `--query "医疗\|卫生"` | | 时间范围 | 最近3天 | `--days 3` | | 排序方式 | 最火的 | `--sort interact_count` | **执行命令**: ```bash python3 scripts/report_cli.py --query "医疗|卫生" --days 3 --sort interact_count ``` ### 示例4：央媒报道 **用户**: 统计一下央媒对新能源汽车的报道情况 **参数解析**: | 提取项 | 用户表述 | CLI参数 | | -------- | ---------- | ---------------------------- | | 关键词 | 新能源汽车 | `--query "新能源&汽车"` | | 媒体分类 | 央媒 | `--media-class "中央媒体"` | | 分析模式 | 统计 | `--full-analysis` | **执行命令**: ```bash python3 scripts/report_cli.py --query "新能源&汽车" --media-class "中央媒体" --days 7 --full-analysis ``` ### 示例5：精确时间范围 **用户**: 生成3月1日到3月15日期间关于315消费者权益的报告 **参数解析**: | 提取项 | 用户表述 | CLI参数 | | -------- | ------------- | ------------------------------------- | | 关键词 | 315消费者权益 | `--query "315&(消费者\|维权)"` | | 开始时间 | 3月1日 | `--time-from "2026-03-01 00:00:00"` | | 结束时间 | 3月15日 | `--time-to "2026-03-15 23:59:59"` | **执行命令**: ```bash python3 scripts/report_cli.py --query "315&(消费者|维权)" --time-from "2026-03-01 00:00:00" --time-to "2026-03-15 23:59:59" --full-analysis ``` --- ## 九、报告输出格式 ### 9.1 JSON格式（原始数据） ```json { "report_info": { "queryString": "比亚迪", "size": 10, "sortField": "publish_time", "sortOrder": "desc", "hotArticles": true, "aggregationTypes": ["sentiment", "keywords", "media_name", "area_code_province", "publish_time_histogram"], "timeFrom": "2026-03-03 00:00:00", "timeTo": "2026-04-02 23:59:59", "areaCodes": ["320100"], "report_time": "2026-04-02T18:00:00", "total": 77956 }, "data": { "articles": [...], "aggregation": { "sentimentDistribution": [...], "areaCodeProvinceDistribution": [...], "keywordsDistribution": [...], ... } } } ``` > **说明**：`report_info` 包含 API 调用时的完整请求参数，便于追溯和复现查询。 ### 9.2 Markdown格式（文本报告） 包含以下内容： - 报告概览（总数据量、时间范围） - 情感倾向分析（表格形式） - 地域分布分析（TOP10省份） - 媒体分布分析（媒体类型、平台分布） - 关键词分析（TOP20关键词） - 时间趋势分析（24小时趋势） - 热文列表（TOP10热文） ### 9.3 HTML格式（可视化报告） 生成美观的可视化 HTML 报告，可直接在浏览器中打开查看。包含以下模块： - **舆情概况** - 信息总量、负面/中性/正面统计卡片、情感占比进度条 - **传播渠道分布** - 视频/微博/APP等渠道的数量和占比表格 - **媒体平台分布** - TOP10 媒体平台排名表格 - **省级地域分布** - TOP15 省份/地区卡片展示 - **关键词云** - TOP20 关键词标签云（动态字号） - **传播趋势** - ECharts 时间趋势折线图（交互式） - **热门文章** - TOP20 热文列表（含标题、作者、平台、摘要） - **总结** - 核心发现汇总 > **说明**：HTML 报告使用 ECharts 图表库，需联网加载 CDN 资源。 --- ## 十、错误处理 当报告生成失败时，根据以下错误码判断原因并采取相应措施。 ### 10.1 API Key 错误码 CLI 优先读取环境变量 `FEEDAX_REPORT_API_KEY`；若仍为空，会尝试读取旧名 `FEEDAX_SEARCH_API_KEY`（兼容旧配置，建议尽快改为 `FEEDAX_REPORT_API_KEY`）。 | 错误码 | 说明 | 原因 | 解决方案 | | -------- | --------------- | -------------------------- | ----------------------------------- | | `GE1003` | 未配置API Key | 未设置 `FEEDAX_REPORT_API_KEY`（或兼容的旧名） | 在宿主环境或 `.env` 中配置，前往 <https://www.feedax.cn> 免费申请 | | `GE1004` | API Key已失效 | API Key 被禁用或撤销 | 检查API Key有效性，或重新申请 | | `GE1005` | API Key已过期 | API Key 超过有效期 | 前往 <https://www.feedax.cn> 重新申请 | | `GE1006` | API Key无效 | API Key 格式错误或不存在 | 前往 <https://www.feedax.cn> 重新申请 | | `GE1007` | 账户余额不足 | 账户配额已用完 | 前往 <https://www.feedax.cn> 充值 | ### 10.2 HTTP 状态码错误 | HTTP状态码 | 错误提示 | 原因 | 建议操作 | | ---------- | -------------------------------------- | ------------------------ | ------------------ | | 400 | 请求参数错误，请检查输入 | 参数格式或值不正确 | 检查 CLI 参数 | | 401/403 | API Key可能无效或已过期 | 认证失败 | 检查 API Key 配置 | | 429 | 请求过于频繁，请稍后再试 | 超过请求频率限制 | 等待后重试 | | 500 | 服务器内部错误 | 服务端异常 | 稍后重试 | | 502 | 服务暂时不可用，请稍后再试 | 网关错误 | 稍后重试 | | 503 | 服务维护中，请稍后再试 | 服务正在维护 | 稍后重试 | | 504 | 网关超时，请稍后再试 | 请求处理超时 | 稍后重试 | ### 10.3 网络错误 | 错误类型 | 错误提示 | 原因 | 建议操作 | | -------- | -------------------------------------- | ------------------ | ---------------------- | | 连接超时 | 请求超时，请稍后再试 | 网络延迟或服务繁忙 | 检查网络，稍后重试 | | 连接错误 | 无法连接到服务器，请检查网络或稍后再试 | 网络不可达 | 检查网络连接，稍后重试 |

path: README.md
content: # TrendScope - 舆情趋势洞察

基于 Feedax API 的舆情分析报告生成工具。

本 Skill 可实现全网内容智能分析与专业报告自动生成，具备多源信息归集、情感研判、热点挖掘等能力。

## 功能特性

- 📊 **多维度数据分析**: 情感分布、地域分布、媒体分布、关键词分析、时间趋势等
- 📝 **自动生成报告**: 支持 JSON 原始数据 + Markdown + Html 可视化报告
- 🎯 **多条件筛选**: 支持媒体平台、情感倾向、时间范围、地域等多维度筛选
- ⚡ **一键调用**: AI Agent 可直接调用，快速生成专业报告

## 适用场景

- 品牌动态监测
- 市场分析报告
- 竞品追踪分析
- 热点事件分析
- 舆情趋势研判

## 快速开始

### 1. 安装

用户对智能体说：

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

### 2. 配置 API Key

在宿主环境设置 `FEEDAX_REPORT_API_KEY`，或使用项目内 `.env`（**勿在对话中粘贴密钥**）：

```bash
cp .env.example .env
# 编辑 .env，设置 FEEDAX_REPORT_API_KEY=你的密钥
```

### 3. 生成报告

```bash
# 基础报告
python3 scripts/report_cli.py --query "比亚迪"

# 指定时间范围和地域
python3 scripts/report_cli.py --query "南京 教育" --area "南京" --days 7

# 全维度分析
python3 scripts/report_cli.py --query "医疗问题" --full-analysis

# 指定平台和情感
python3 scripts/report_cli.py --query "315投诉" --media "微博,抖音" --sentiment negative
```

## 输出文件

报告文件默认保存至 `~/Desktop/舆情分析报告/` 目录：

- `{查询词}_{时间戳}.json` - 原始数据（JSON格式）
- `{查询词}_{时间戳}.md` - 可视化报告（Markdown格式）
- `{查询词}_{时间戳}.html` - 可视化报告（HTML格式）

## 文档

详见 `skill.md` 获取完整的参数说明和使用指南。


path: assets/report_template.html
content: <!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>江苏省医疗卫生舆情分析报告</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: "Microsoft YaHei", sans-serif; background: linear-gradient(135deg, #667eea, #764ba2); min-height: 100vh; padding: 20px; }
.container { max-width: 1200px; margin: 0 auto; background: #fff; border-radius: 16px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
.header { background: linear-gradient(135deg, #1e3c72, #2a5298); color: white; padding: 40px; text-align: center; }
.header h1 { font-size: 28px; margin-bottom: 10px; }
.content { padding: 30px 40px; }
.section { margin-bottom: 45px; }
.section-title { font-size: 20px; color: #1e3c72; padding-bottom: 12px; border-bottom: 3px solid #667eea; margin-bottom: 25px; font-weight: 600; }

.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 25px; }
.stat-card { padding: 25px; border-radius: 12px; text-align: center; color: white; }
.stat-card.total { background: linear-gradient(135deg, #667eea, #764ba2); }
.stat-card.negative { background: linear-gradient(135deg, #ff6b6b, #ee5a5a); }
.stat-card.neutral { background: linear-gradient(135deg, #feca57, #f9c846); color: #333; }
.stat-card.positive { background: linear-gradient(135deg, #26de81, #20bf6b); }
.stat-card .number { font-size: 32px; font-weight: 700; }

.progress-bar { height: 12px; background: #e8e8e8; border-radius: 6px; overflow: hidden; display: flex; margin-bottom: 20px; }
.progress-bar .fill.negative { background: #ff6b6b; }
.progress-bar .fill.neutral { background: #feca57; }
.progress-bar .fill.positive { background: #26de81; }

.chart-container { background: #f8f9fa; border-radius: 12px; padding: 20px; }
.chart-box { height: 350px; }

.keyword-cloud { display: flex; flex-wrap: wrap; gap: 10px; padding: 20px; justify-content: center; }
.kw-tag { padding: 6px 16px; border-radius: 20px; color: white; }

.article-item { background: #f8f9fa; border-radius: 12px; padding: 15px; margin-bottom: 15px; }
.article-title { font-size: 15px; color: #1e3c72; font-weight: 600; margin-bottom: 8px; }
.article-title a { color: inherit; text-decoration: none; }
.article-meta { font-size: 12px; color: #666; display: flex; gap: 15px; }
.article-summary { font-size: 13px; color: #555; margin-top: 8px; }

table { width: 100%; border-collapse: collapse; }
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #eee; }
th { background: #f8f9fa; font-weight: 600; }
.bar { height: 8px; background: #e8e8e8; border-radius: 4px; overflow: hidden; }
.fill { height: 100%; background: #667eea; }

.area-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 15px; }
.area-card { background: #f8f9fa; padding: 15px; border-radius: 10px; text-align: center; }
.area-card .name { font-size: 14px; color: #333; }
.area-card .count { font-size: 20px; font-weight: 700; color: #667eea; }

.risk-box { background: #fff3cd; border-left: 4px solid #ffc107; padding: 20px; border-radius: 8px; margin-top: 15px; }
.risk-box h4 { color: #856404; margin-bottom: 12px; }
.risk-box ul { color: #856404; padding-left: 20px; }
.risk-box li { margin: 8px 0; }

.summary-box { background: linear-gradient(135deg, #667eea, #764ba2); color: white; padding: 25px; border-radius: 12px; margin-top: 20px; }
.summary-box h3 { margin-bottom: 15px; }
.summary-box ul { list-style: none; padding: 0; }
.summary-box li { padding: 6px 0; opacity: 0.95; }
.summary-box li::before { content: "✓"; margin-right: 10px; }

.footer { background: #f8f9fa; padding: 20px; text-align: center; color: #666; font-size: 13px; }
@media (max-width: 900px) { .stats-grid { grid-template-columns: repeat(2, 1fr); } .area-grid { grid-template-columns: repeat(3, 1fr); } }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🏥 江苏省医疗卫生舆情分析报告</h1>
<p>分析时间: 2026-03-11 ~ 2026-03-12 | 生成时间: 2026-03-13 15:01:21</p>
</div>
<div class="content">

<div class="section">
<h2 class="section-title">📊 舆情概况</h2>
<div class="stats-grid">
<div class="stat-card total"><div class="number">2981</div><div>信息总量</div></div><div class="stat-card negative"><div class="number">2424</div><div>负面</div></div><div class="stat-card neutral"><div class="number">543</div><div>中性</div></div><div class="stat-card positive"><div class="number">14</div><div>正面</div></div></div>
<div class="progress-bar">
<div class="fill negative" style="width:81.31%"></div>
<div class="fill neutral" style="width:18.22%"></div>
<div class="fill positive" style="width:0.47%"></div>
</div>
<p>整体舆情情绪偏向<strong style="color:#ff6b6b">负面</strong>，负面信息占比81.31%，主要涉及医疗纠纷、医患矛盾、医疗费用等问题。</p>
</div>

<div class="section">
<h2 class="section-title">📡 传播渠道分布</h2>
<table><thead><tr><th>渠道</th><th>数量</th><th>占比</th><th>趋势</th></tr></thead><tbody><tr><td>视频</td><td>1428</td><td>47.90%</td><td><div class="bar"><div class="fill" style="width:47.90338812479034%"></div></div></td></tr><tr><td>微博</td><td>923</td><td>30.96%</td><td><div class="bar"><div class="fill" style="width:30.962764173096275%"></div></div></td></tr><tr><td>头条</td><td>356</td><td>11.94%</td><td><div class="bar"><div class="fill" style="width:11.94230124119423%"></div></div></td></tr><tr><td>APP</td><td>85</td><td>2.85%</td><td><div class="bar"><div class="fill" style="width:2.851392150285139%"></div></div></td></tr><tr><td>微信</td><td>73</td><td>2.45%</td><td><div class="bar"><div class="fill" style="width:2.4488426702448844%"></div></div></td></tr><tr><td>论坛</td><td>57</td><td>1.91%</td><td><div class="bar"><div class="fill" style="width:1.912110030191211%"></div></div></td></tr><tr><td>网站</td><td>47</td><td>1.58%</td><td><div class="bar"><div class="fill" style="width:1.5766521301576653%"></div></div></td></tr><tr><td>问答</td><td>12</td><td>0.40%</td><td><div class="bar"><div class="fill" style="width:0.4025494800402549%"></div></div></td></tr></tbody></table></div>
<div class="section">
<h2 class="section-title">📺 视频平台分布</h2>
<table><thead><tr><th>平台</th><th>数量</th><th>占比</th></tr></thead><tbody><tr><td>今日头条</td><td>358</td><td>12.01%</td></tr><tr><td>抖音</td><td>358</td><td>12.01%</td></tr><tr><td>快手</td><td>169</td><td>5.67%</td></tr><tr><td>微博</td><td>146</td><td>4.90%</td></tr><tr><td>火山</td><td>134</td><td>4.50%</td></tr><tr><td>小红书</td><td>104</td><td>3.49%</td></tr><tr><td>西瓜</td><td>71</td><td>2.38%</td></tr><tr><td>懂车帝</td><td>25</td><td>0.84%</td></tr><tr><td>百家号</td><td>23</td><td>0.77%</td></tr><tr><td>哔哩哔哩</td><td>21</td><td>0.70%</td></tr></tbody></table></div>
<div class="section">
<h2 class="section-title">🗺️ 省级地域分布</h2>
<div class="area-grid"><div class="area-card"><div class="name">全国</div><div class="count">2236</div></div><div class="area-card"><div class="name">江苏省</div><div class="count">1644</div></div><div class="area-card"><div class="name">山东省</div><div class="count">220</div></div><div class="area-card"><div class="name">北京市</div><div class="count">56</div></div><div class="area-card"><div class="name">安徽省</div><div class="count">37</div></div><div class="area-card"><div class="name">广东省</div><div class="count">28</div></div><div class="area-card"><div class="name">河南省</div><div class="count">27</div></div><div class="area-card"><div class="name">河北省</div><div class="count">25</div></div><div class="area-card"><div class="name">浙江省</div><div class="count">20</div></div><div class="area-card"><div class="name">吉林省</div><div class="count">18</div></div><div class="area-card"><div class="name">湖南省</div><div class="count">16</div></div><div class="area-card"><div class="name">山西省</div><div class="count">15</div></div><div class="area-card"><div class="name">上海市</div><div class="count">15</div></div><div class="area-card"><div class="name">福建省</div><div class="count">13</div></div><div class="area-card"><div class="name">辽宁省</div><div class="count">12</div></div></div></div>
<div class="section">
<h2 class="section-title">🔑 关键词云</h2>
<div class="keyword-cloud"><span class="kw-tag" style="font-size:27px;background:#667eea">医院 (1047)</span><span class="kw-tag" style="font-size:21px;background:#764ba2">医生 (643)</span><span class="kw-tag" style="font-size:16px;background:#ff6b6b">医疗 (289)</span><span class="kw-tag" style="font-size:15px;background:#26de81">患者 (239)</span><span class="kw-tag" style="font-size:14px;background:#feca57">南京 (170)</span><span class="kw-tag" style="font-size:14px;background:#667eea">江苏 (163)</span><span class="kw-tag" style="font-size:13px;background:#764ba2">人员 (139)</span><span class="kw-tag" style="font-size:13px;background:#ff6b6b">孩子 (118)</span><span class="kw-tag" style="font-size:13px;background:#26de81">毓璜顶 (109)</span><span class="kw-tag" style="font-size:13px;background:#feca57">病人 (97)</span><span class="kw-tag" style="font-size:13px;background:#667eea">女士 (96)</span><span class="kw-tag" style="font-size:13px;background:#764ba2">嘿料 (81)</span><span class="kw-tag" style="font-size:13px;background:#ff6b6b">手术 (81)</span><span class="kw-tag" style="font-size:13px;background:#26de81">视频 (81)</span><span class="kw-tag" style="font-size:13px;background:#feca57">上海 (80)</span><span class="kw-tag" style="font-size:13px;background:#667eea">烟台 (80)</span><span class="kw-tag" style="font-size:13px;background:#764ba2">事故 (77)</span><span class="kw-tag" style="font-size:13px;background:#ff6b6b">时间 (77)</span><span class="kw-tag" style="font-size:13px;background:#26de81">主任 (75)</span><span class="kw-tag" style="font-size:13px;background:#feca57">醫生 (75)</span></div></div>

<div class="section">
<h2 class="section-title">📈 传播趋势</h2>
<div id="trendChart" class="chart-box"></div>
</div>

<script>
var trendChart = echarts.init(document.getElementById('trendChart'));
var trendOption = {
    tooltip: { trigger: 'axis' },
    xAxis: { type: 'category', data: ['2026-03-10', '2026-03-11', '2026-03-12'] },
    yAxis: { type: 'value', name: '数量' },
    series: [{
        data: [245, 1858, 878],
        type: 'line',
        smooth: true,
        areaStyle: { opacity: 0.3 },
        itemStyle: { color: '#667eea' },
        lineStyle: { width: 3 }
    }]
};
trendChart.setOption(trendOption);
</script>

<div class="section">
<h2 class="section-title">🔥 热门文章</h2>
<div class="article-item">
<div class="article-title"><a href="https://www.iesdouyin.com/share/video/7616212349669707051" target="_blank">1. 还记得这位医疗反腐的女斗士吗！</a></div>
<div class="article-meta">
<span>📰 抖音</span>
<span>👤 含笑半步癫</span>
<span>🕐 03-12 11:54</span>
</div>
<div class="article-summary">摘要：还记得这位医疗反腐的女斗士吗！“走廊医生疯子医生兰越峰举报“过度医疗”打响反医腐第一枪最后以悲剧收场...</div>
</div>
<div class="article-item">
<div class="article-title"><a href="https://www.iesdouyin.com/share/video/7616302367981542827?app=aweme_hotsoon" target="_blank">2. 致素未谋面的好心人和亲朋好友：
我只有一个儿子 我想让他活下来
大家好！我是李振英，来自河南省开封市葛岗镇花东村，我是一位近60岁的母亲，我唯一的儿子崔玉龙今年</a></div>
<div class="article-meta">
<span>📰 火山</span>
<span>👤 崔玉龙(人间大爱 好人一生平安)</span>
<span>🕐 03-12 17:43</span>
</div>
<div class="article-summary">摘要：致素未谋面的好心人和亲朋好友：
我只有一个儿子 我想让他活下来
大家好！</div>
</div>
<div class="article-item">
<div class="article-title"><a href="https://www.iesdouyin.com/share/video/7616302367981542827" target="_blank">3. 致素未谋面的好心人和亲朋好友：
我只有一个儿子 我想让他活下来
大家好！我是李振英，来自河南省开封市葛岗镇花东村，我是一位近60岁的母亲，我唯一的儿子崔玉龙今年</a></div>
<div class="article-meta">
<span>📰 抖音</span>
<span>👤 崔玉龙(人间大爱 好人一生平安)</span>
<span>🕐 03-12 17:43</span>
</div>
<div class="article-summary">摘要：致素未谋面的好心人和亲朋好友：
我只有一个儿子 我想让他活下来
大家好！</div>
</div>
<div class="article-item">
<div class="article-title"><a href="https://www.iesdouyin.com/share/video/7616252003953726331" target="_blank">4. 挣点小钱容易吗？伤筋动骨不说，还伤心，不是为了毛爷爷，谁受这罪😱😱</a></div>
<div class="article-meta">
<span>📰 抖音</span>
<span>👤 ♬ 不離不棄♬</span>
<span>🕐 03-12 14:27</span>
</div>
<div class="article-summary">摘要：挣点小钱容易吗？伤筋动骨不说，还伤心，不是为了毛爷爷，谁受这罪😱😱我这得去省医院，还是市医院，看大家...</div>
</div>
<div class="article-item">
<div class="article-title"><a href="https://www.iesdouyin.com/share/video/7615977402434470065" target="_blank">5. 医者仁心，你们做到哪，</a></div>
<div class="article-meta">
<span>📰 抖音</span>
<span>👤 愉悦祥云🍎</span>
<span>🕐 03-11 20:42</span>
</div>
<div class="article-summary">摘要：医者仁心，你们做到哪，徐州三院心内科某主任，你打着免费连线，要我们充值钻，多次第一，连不上，我们反应...</div>
</div>
<div class="article-item">
<div class="article-title"><a href="https://www.iesdouyin.com/share/video/7615977402434470065?app=aweme_hotsoon" target="_blank">6. 医者仁心，你们做到哪，</a></div>
<div class="article-meta">
<span>📰 火山</span>
<span>👤 愉悦祥云🍎</span>
<span>🕐 03-11 20:42</span>
</div>
<div class="article-summary">摘要：医者仁心，你们做到哪，徐州三院心内科某主任，你打着免费连线，要我们充值钻，多次第一，连不上，我们反应...</div>
</div>
<div class="article-item">
<div class="article-title"><a href="https://www.iesdouyin.com/share/note/7616237602023131003" target="_blank">7. 永远逃脱不掉的罪恶行为。#宿迁 #宿迁市 #江苏 #江苏dou知道</a></div>
<div class="article-meta">
<span>📰 抖音</span>
<span>👤 江苏宿迁中西结合医院急诊医疗事故死亡家属</span>
<span>🕐 03-12 13:31</span>
</div>
<div class="article-summary">摘要：永远逃脱不掉的罪恶行为。#宿迁 #宿迁市 #江苏 #江苏dou知道 宿迁中西医结合医院还我命#宿迁市...</div>
</div>
<div class="article-item">
<div class="article-title"><a href="https://channels.weixin.qq.com/web/pages/feed?oid=zm3U7fZbCIc=" target="_blank">8. 上海一女子投诉拜博口腔磨错牙   竟被医院标注“有精神病史请拒诊”   主治医生不承认给磨错牙  维权还遭医院“有病”“欠骂”等措辞辱骂#拜博口腔#投诉#维权</a></div>
<div class="article-meta">
<span>📰 微信视频号</span>
<span>👤 江苏经济报</span>
<span>🕐 03-11 14:51</span>
</div>
<div class="article-summary">摘要：上海一女子投诉拜博口腔磨错牙   竟被医院标注“有精神病史请拒诊”   主治医生不承认给磨错牙  维...</div>
</div>
<div class="article-item">
<div class="article-title"><a href="https://www.iesdouyin.com/share/video/7615885724918963706" target="_blank">9. 就这样行走都难还要想着给儿带孩子可怜天下父母心，老伴的双腿都疼医生说是半月板磨损，求那位好心人指个明</a></div>
<div class="article-meta">
<span>📰 抖音</span>
<span>👤 幸福一生</span>
<span>🕐 03-11 14:46</span>
</div>
<div class="article-summary">摘要：就这样行走都难还要想着给儿带孩子可怜天下父母心，老伴的双腿都疼医生说是半月板磨损，求那位好心人指个明...</div>
</div>
<div class="article-item">
<div class="article-title"><a href="https://www.iesdouyin.com/share/video/7616293025266599354" target="_blank">10. 所有的网购平台都是骗子专门骗老百姓，我买四年的车里子树苗，樱桃树苗结果都是李子树苗。有在江苏买的，有</a></div>
<div class="article-meta">
<span>📰 抖音</span>
<span>👤 82188983169飞虎</span>
<span>🕐 03-12 17:06</span>
</div>
<div class="article-summary">摘要：所有的网购平台都是骗子专门骗老百姓，我买四年的车里子树苗，樱桃树苗结果都是李子树苗。</div>
</div>
<div class="article-item">
<div class="article-title"><a href="https://www.iesdouyin.com/share/video/7616293025266599354?app=aweme_hotsoon" target="_blank">11. 所有的网购平台都是骗子专门骗老百姓，我买四年的车里子树苗，樱桃树苗结果都是李子树苗。有在江苏买的，有</a></div>
<div class="article-meta">
<span>📰 火山</span>
<span>👤 82188983169飞虎</span>
<span>🕐 03-12 17:06</span>
</div>
<div class="article-summary">摘要：所有的网购平台都是骗子专门骗老百姓，我买四年的车里子树苗，樱桃树苗结果都是李子树苗。</div>
</div>
<div class="article-item">
<div class="article-title"><a href="https://www.iesdouyin.com/share/video/7615762266093762150" target="_blank">12. 宝贝我们等你回家失踪类型：被偷寻亲编号：671858姓名：尹秀燕性别：女出生日期：1992-11-1</a></div>
<div class="article-meta">
<span>📰 抖音</span>
<span>👤 心想事成</span>
<span>🕐 03-11 06:47</span>
</div>
<div class="article-summary">摘要：宝贝我们等你回家失踪类型：被偷寻亲编号：671858姓名：尹秀燕性别：女出生日期：1992-11-1...</div>
</div>
<div class="article-item">
<div class="article-title"><a href="https://www.iesdouyin.com/share/video/7615762266093762150?app=aweme_hotsoon" target="_blank">13. 宝贝我们等你回家失踪类型：被偷寻亲编号：671858姓名：尹秀燕性别：女出生日期：1992-11-1</a></div>
<div class="article-meta">
<span>📰 火山</span>
<span>👤 心想事成</span>
<span>🕐 03-11 06:47</span>
</div>
<div class="article-summary">摘要：宝贝我们等你回家失踪类型：被偷寻亲编号：671858姓名：尹秀燕性别：女出生日期：1992-11-1...</div>
</div>
<div class="article-item">
<div class="article-title"><a href="https://www.iesdouyin.com/share/video/7615878342323896955" target="_blank">14. 陪同名同岁的姊妹康复训练！</a></div>
<div class="article-meta">
<span>📰 抖音</span>
<span>👤 @沐沐奶奶呀！</span>
<span>🕐 03-11 14:17</span>
</div>
<div class="article-summary">摘要：陪同名同岁的姊妹康复训练！累，困，委屈，不可言喻的酸楚，护工很难！</div>
</div>
<div class="article-item">
<div class="article-title"><a href="https://www.iesdouyin.com/share/video/7616218991946856421?app=aweme_hotsoon" target="_blank">15. 庸医我只能这么说，两个专家给我查血什么都查不到，钱花了几干，奔牛到常州，挂了10天水，还是痛的无法忍</a></div>
<div class="article-meta">
<span>📰 火山</span>
<span>👤 开心</span>
<span>🕐 03-12 12:19</span>
</div>
<div class="article-summary">摘要：庸医我只能这么说，两个专家给我查血什么都查不到，钱花了几干，奔牛到常州，挂了10天水，还是痛的无法忍...</div>
</div>
<div class="article-item">
<div class="article-title"><a href="https://www.iesdouyin.com/share/video/7616218991946856421" target="_blank">16. 庸医我只能这么说，两个专家给我查血什么都查不到，钱花了几干，奔牛到常州，挂了10天水，还是痛的无法忍</a></div>
<div class="article-meta">
<span>📰 抖音</span>
<span>👤 开心</span>
<span>🕐 03-12 12:19</span>
</div>
<div class="article-summary">摘要：庸医我只能这么说，两个专家给我查血什么都查不到，钱花了几干，奔牛到常州，挂了10天水，还是痛的无法忍...</div>
</div>
<div class="article-item">
<div class="article-title"><a href="https://www.iesdouyin.com/share/video/7615898405571933166" target="_blank">17. @公安部刑侦局 @江苏政法 @江苏警方 @宿迁警方 @沭阳公安 @公安部新闻传媒 沭阳县发生重大刑事案件。医生谋划杀害病人，不仅让不懂医的人们震惊，还让正直无私</a></div>
<div class="article-meta">
<span>📰 抖音</span>
<span>👤 勇敢一点🌻</span>
<span>🕐 03-11 15:35</span>
</div>
<div class="article-summary">摘要：@公安部刑侦局 @江苏政法 @江苏警方 @宿迁警方 @沭阳公安 @公安部新闻传媒 沭阳县发生重大刑事...</div>
</div>
<div class="article-item">
<div class="article-title"><a href="https://www.iesdouyin.com/share/video/7615898405571933166?app=aweme_hotsoon" target="_blank">18. @公安部刑侦局 @江苏政法 @江苏警方 @宿迁警方 @沭阳公安 @公安部新闻传媒 沭阳县发生重大刑事案件。医生谋划杀害病人，不仅让不懂医的人们震惊，还让正直无私</a></div>
<div class="article-meta">
<span>📰 火山</span>
<span>👤 勇敢一点🌻</span>
<span>🕐 03-11 15:35</span>
</div>
<div class="article-summary">摘要：@公安部刑侦局 @江苏政法 @江苏警方 @宿迁警方 @沭阳公安 @公安部新闻传媒 沭阳县发生重大刑事...</div>
</div>
<div class="article-item">
<div class="article-title"><a href="https://www.iesdouyin.com/share/video/7615831326072333925" target="_blank">19. 药也吃了膏也擦了就是不见好，反而越来越严重😭😭😭</a></div>
<div class="article-meta">
<span>📰 抖音</span>
<span>👤 洋豆豆</span>
<span>🕐 03-11 11:15</span>
</div>
<div class="article-summary">摘要：药也吃了膏也擦了就是不见好，反而越来越严重😭😭😭</div>
</div>
<div class="article-item">
<div class="article-title"><a href="https://www.iesdouyin.com/share/video/7615833208143252081?app=aweme_hotsoon" target="_blank">20. 希望一切顺利</a></div>
<div class="article-meta">
<span>📰 火山</span>
<span>👤 十一奶奶</span>
<span>🕐 03-11 11:22</span>
</div>
<div class="article-summary">摘要：希望一切顺利锁检查室（2）但家不要有事。当我听到医生说。他问里面有个。无锡。然后。问我同意和解不。</div>
</div>
<div class="section">
<h2 class="section-title">⚠️ 风险预警</h2>
<div class="risk-box">
<h4>主要风险点</h4>
<ul>
<li>医疗纠纷类：手术事故、医患矛盾、医疗费用等负面事件频繁出现</li>
<li>医疗反腐：医疗反腐话题引发关注，如"走廊医生"兰越峰事件</li>
<li>看病难贵：看病难、看病贵仍是公众关注焦点</li>
<li>医疗求助：众筹治病类信息较多，反映部分患者经济负担重</li>
<li>医疗刑事：出现医生谋划杀害病人等重大刑事案件舆情</li>
</ul>
</div>
</div>

<div class="section">
<h2 class="section-title">📋 总结</h2>
<div class="summary-box">
<h3>核心发现与建议</h3>
<ul>
<li>舆情以负面为主(81%)，主要涉及医疗纠纷、医患矛盾</li>
<li>视频平台(抖音/快手)是主要传播渠道，占比近80%</li>
<li>南京、苏州、无锡等地区关注度较高</li>
<li>医疗反腐、医疗救助类内容热度较高</li>
<li><strong>建议：</strong>持续监测重大医疗事故和医患纠纷事件，及时回应公众关切</li>
</ul>
</div>
</div>

</div>
<div class="footer">
<p>报告生成时间: 2026-03-13 15:01:21 | 数据来源: 泛舆情系统</p>
</div>
</div>
</body>
</html>

path: scripts/report_cli.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
舆情报告生成 CLI 工具
基于 /search-service/report 接口封装

使用方法:
    python3 scripts/report_cli.py --query "关键词" [选项]

示例:
    python3 scripts/report_cli.py --query "(南京|金陵)&(医疗|卫生)" --days 3 --area "南京" --full-analysis
"""

import os
import sys
import json
import argparse
from datetime import datetime, timedelta
from pathlib import Path
from dataclasses import dataclass
from typing import List, Dict, Any, Optional

import requests
from dotenv import load_dotenv

# 加载 .env 文件（从脚本所在目录向上两级找到项目根目录）
SCRIPT_DIR = Path(__file__).parent
PROJECT_ROOT = SCRIPT_DIR.parent
load_dotenv(dotenv_path=PROJECT_ROOT / ".env", override=True)

# 固定 API 根地址
API_BASE_URL = "http://221.6.15.90:18011"
REPORT_ENDPOINT = "/search-service/report"
DEFAULT_API_KEY = (
    os.getenv("FEEDAX_REPORT_API_KEY", "").strip()
    or os.getenv("FEEDAX_SEARCH_API_KEY", "").strip()
)
DEFAULT_OUTPUT_DIR = os.path.expanduser("~/Desktop/舆情分析报告")


# ============== 地域解析功能 ==============

@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 = {}
    
    json_path = Path(__file__).parent.parent / "assets/area_codes.json"
    
    if not json_path.exists():
        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
            
            for city in province.get("children", []):
                city_name = city.get("name", "")
                city_code = city.get("code", "")
                city_children = city.get("children", [])
                
                if city_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:
                    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
    except Exception:
        pass


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]:
    """搜索地域编码"""
    _load_area_codes()
    
    results: List[AreaCodeResult] = []
    name = name.strip()
    if not name:
        return {"query": name, "count": 0, "results": []}
    
    if name in _AREA_CODE_INDEX:
        results.append(_AREA_CODE_INDEX[name])
    
    if not results:
        best_match = None
        best_priority = -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)
    
    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 parse_area_codes(area_names: List[str]) -> List[str]:
    """
    将地域名称列表转换为国标区域编码列表
    
    Args:
        area_names: 地域名称列表，如 ["北京", "上海", "江苏"]
        
    Returns:
        地域编码列表，如 ["110000", "310000", "320000"]
    """
    if not area_names:
        return []
    
    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(area_str: str) -> tuple:
    """
    解析地域参数，返回 (area_codes, area_name)
    
    Args:
        area_str: 地域名称，多个用逗号分隔
        
    Returns:
        (area_codes, area_name): 地域编码列表和地域名称
    """
    if not area_str:
        return [], None
    
    area_names = [a.strip() for a in area_str.split(",") if a.strip()]
    area_codes = parse_area_codes(area_names)
    area_name = "、".join(area_names)
    
    return area_codes, area_name


# ============== 地域解析功能结束 ==============


def get_default_aggregation_types():
    """默认聚合维度"""
    return ["sentiment", "keywords", "media_name", "area_code_province", "publish_time_histogram"]


def get_full_aggregation_types():
    """全维度聚合"""
    return ["sentiment", "area_code_province", "media_class", "verification_type", 
            "keywords", "media_name", "channel", "publish_time_histogram"]


def format_timestamp(ts):
    """格式化时间戳"""
    if not ts:
        return "未知时间"
    try:
        dt = datetime.fromtimestamp(ts / 1000)
        return dt.strftime("%Y-%m-%d %H:%M:%S")
    except:
        return str(ts)


def parse_datetime(dt_str):
    """解析日期时间字符串为毫秒时间戳"""
    formats = [
        "%Y-%m-%d %H:%M:%S",
        "%Y-%m-%d",
        "%Y/%m/%d %H:%M:%S",
        "%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}")


def get_section_number(num):
    """获取中文章节编号"""
    chinese_nums = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二"]
    return chinese_nums[num] if num < len(chinese_nums) else str(num)


def parse_sentiment(sentiment_str):
    """解析情感参数"""
    mapping = {
        "negative": [-1], "负面": [-1], "-1": [-1],
        "neutral": [0], "中性": [0], "0": [0],
        "positive": [1], "正面": [1], "1": [1],
        "all": [], "全部": []
    }
    return mapping.get(sentiment_str.lower(), [])


def parse_msg_type(msg_type_str):
    """解析消息类型"""
    mapping = {
        "original": [1], "原发": [1], "1": [1],
        "comment": [2], "评论": [2], "2": [2],
        "repost": [3], "转发": [3], "3": [3],
        "all": [1, 2, 3], "全部": [1, 2, 3]
    }
    return mapping.get(msg_type_str.lower(), [1])


def parse_content_type(content_type_str):
    """解析内容类型"""
    mapping = {
        "video": ["video"], "视频": ["video"],
        "picture": ["picture"], "图片": ["picture"],
        "text": ["text"], "文字": ["text"],
        "all": [], "全部": []
    }
    return mapping.get(content_type_str.lower(), [])


def parse_verification(verification_str):
    """解析认证类型"""
    mapping = {
        "blue": 1, "蓝v": 1, "蓝V": 1, "官方": 1,
        "yellow": 0, "黄v": 0, "黄V": 0,
        "normal": -1, "普通": -1
    }
    return mapping.get(verification_str, None)


def call_report_api(query, **kwargs):
    """调用报告API"""
    if not DEFAULT_API_KEY:
        print("❌ 错误: 未配置 API Key")
        print("   请在环境变量或 .env 中设置 FEEDAX_REPORT_API_KEY（或旧名 FEEDAX_SEARCH_API_KEY）")
        print("   或前往 https://www.feedax.cn 申请")
        return None
    
    payload = {
        "queryString": query,
        "size": min(kwargs.get("size", 10), 20),
        "sortField": kwargs.get("sort_field", "publish_time"),
        "sortOrder": kwargs.get("sort_order", "desc"),
        "hotArticles": kwargs.get("hot_articles", True),
        "aggregationTypes": kwargs.get("aggregation_types") or get_default_aggregation_types()
    }
    
    if kwargs.get("media_names"):
        payload["mediaNames"] = kwargs["media_names"]
    if kwargs.get("author_name"):
        payload["authorName"] = kwargs["author_name"]
    if kwargs.get("message_types"):
        payload["messageTypes"] = kwargs["message_types"]
    if kwargs.get("content_types"):
        payload["contentTypes"] = kwargs["content_types"]
    if kwargs.get("sentiments"):
        payload["sentiments"] = kwargs["sentiments"]
    if kwargs.get("media_classes"):
        payload["mediaClasses"] = kwargs["media_classes"]
    if kwargs.get("domains"):
        payload["domains"] = kwargs["domains"]
    if kwargs.get("scenes"):
        payload["scenes"] = kwargs["scenes"]
    if kwargs.get("area_codes"):
        payload["areaCodes"] = kwargs["area_codes"]
    if kwargs.get("verification_type") is not None:
        payload["verificationType"] = kwargs["verification_type"]
    if kwargs.get("time_from"):
        payload["timeFrom"] = kwargs["time_from"]
    if kwargs.get("time_to"):
        payload["timeTo"] = kwargs["time_to"]
    if kwargs.get("interact_count_min"):
        payload["interactCountMin"] = kwargs["interact_count_min"]
    if kwargs.get("interact_count_max"):
        payload["interactCountMax"] = kwargs["interact_count_max"]
    if kwargs.get("comments_count_min"):
        payload["commentsCountMin"] = kwargs["comments_count_min"]
    if kwargs.get("comments_count_max"):
        payload["commentsCountMax"] = kwargs["comments_count_max"]
    
    try:
        print("\n🔄 正在调用API...")
        response = requests.post(
            f"{API_BASE_URL}{REPORT_ENDPOINT}",
            json=payload,
            headers={"Content-Type": "application/json; charset=UTF-8", "x-api-key": DEFAULT_API_KEY},
            timeout=30
        )
        
        if response.status_code == 401:
            print("❌ API Key 无效或已过期")
            return None
        if response.status_code == 403:
            print("❌ API Key 权限不足")
            return None
        if response.status_code != 200:
            print(f"❌ API请求失败: HTTP {response.status_code}")
            return None
        
        result = response.json()
        result["_request_params"] = payload  # 保存请求参数
        return result
    except requests.exceptions.Timeout:
        print("❌ 请求超时，请稍后重试")
        return None
    except requests.exceptions.ConnectionError:
        print("❌ 网络连接失败，请检查网络")
        return None
    except Exception as e:
        print(f"❌ 请求异常: {str(e)}")
        return None


def display_report_summary(data, query):
    """在终端显示报告摘要"""
    if not data:
        print("暂无数据")
        return
    
    total = data.get("total", 0)
    aggregation = data.get("aggregation", {}) or {}
    
    print("\n" + "="*60)
    print("📊 舆情分析报告摘要")
    print("="*60)
    print(f"\n查询关键词: {query}")
    print(f"总数据量: {total:,} 条")
    
    sentiment_dist = aggregation.get("sentimentDistribution", [])
    if sentiment_dist:
        print("\n📈 情感倾向分布:")
        for item in sentiment_dist:
            key, count = item.get("key", ""), item.get("docCount", 0)
            pct = (count / total * 100) if total > 0 else 0
            print(f"   {key}: {count:,} 条 ({pct:.1f}%)")
    
    area_dist = aggregation.get("areaCodeProvinceDistribution", [])
    if area_dist:
        print("\n🗺️ 地域分布 TOP5:")
        for i, item in enumerate(area_dist[:5], 1):
            print(f"   {i}. {item.get('key', '')}: {item.get('docCount', 0):,} 条")
    
    keyword_dist = aggregation.get("keywordsDistribution", [])
    if keyword_dist:
        print("\n🔑 关键词 TOP10:")
        keywords = [item.get("key", "") for item in keyword_dist[:10]]
        print(f"   {', '.join(keywords)}")
    
    media_dist = aggregation.get("mediaNameDistribution", [])
    if media_dist:
        print("\n📺 主要媒体平台:")
        for i, item in enumerate(media_dist[:5], 1):
            print(f"   {i}. {item.get('key', '')}: {item.get('docCount', 0):,} 条")
    
    print("\n" + "="*60 + "\n")


def generate_markdown_report(data, query, output_path):
    """生成Markdown格式报告"""
    total = data.get("total", 0)
    aggregation = data.get("aggregation", {}) or {}
    articles = data.get("articles", [])
    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    
    has_data = {k: v for k, v in aggregation.items() if v is not None and isinstance(v, list) and len(v) > 0}
    
    md = f"""# 舆情分析报告

**生成时间**: {now}  
**查询关键词**: {query}  
**总数据量**: {total:,} 条

---

## 一、报告概览

本次分析共采集到 **{total:,}** 条相关舆情数据。

"""
    
    section_num = 2
    
    if "sentimentDistribution" in has_data:
        md += f"---\n\n## {get_section_number(section_num)}、情感倾向分析\n\n"
        section_num += 1
        md += "| 情感类型 | 数量 | 占比 |\n|---------|------|------|\n"
        for item in has_data["sentimentDistribution"]:
            key, count = item.get("key", ""), item.get("docCount", 0)
            pct = (count / total * 100) if total > 0 else 0
            md += f"| {key} | {count:,} | {pct:.1f}% |\n"
        md += "\n"
    
    if "areaCodeProvinceDistribution" in has_data:
        md += f"---\n\n## {get_section_number(section_num)}、地域分布分析\n\n"
        section_num += 1
        md += "### TOP10 省份/地区\n\n| 排名 | 省份/地区 | 数量 |\n|-----|----------|------|\n"
        for i, item in enumerate(has_data["areaCodeProvinceDistribution"][:10], 1):
            md += f"| {i} | {item.get('key', '')} | {item.get('docCount', 0):,} |\n"
        md += "\n"
    
    if "keywordsDistribution" in has_data:
        md += f"---\n\n## {get_section_number(section_num)}、关键词分析\n\n"
        section_num += 1
        md += "### TOP20 关键词\n\n| 排名 | 关键词 | 出现次数 |\n|-----|--------|---------|\n"
        for i, item in enumerate(has_data["keywordsDistribution"][:20], 1):
            md += f"| {i} | {item.get('key', '')} | {item.get('docCount', 0):,} |\n"
        md += "\n"
    
    if "mediaNameDistribution" in has_data:
        md += f"---\n\n## {get_section_number(section_num)}、媒体平台分布\n\n"
        section_num += 1
        md += "### TOP10 媒体平台\n\n| 排名 | 媒体平台 | 数量 |\n|-----|---------|------|\n"
        for i, item in enumerate(has_data["mediaNameDistribution"][:10], 1):
            md += f"| {i} | {item.get('key', '')} | {item.get('docCount', 0):,} |\n"
        md += "\n"
    
    if "mediaClassDistribution" in has_data:
        md += f"---\n\n## {get_section_number(section_num)}、媒体分类分布\n\n"
        section_num += 1
        md += "| 媒体分类 | 数量 | 占比 |\n|---------|------|------|\n"
        for item in has_data["mediaClassDistribution"]:
            key, count = item.get("key", ""), item.get("docCount", 0)
            pct = (count / total * 100) if total > 0 else 0
            md += f"| {key} | {count:,} | {pct:.1f}% |\n"
        md += "\n"
    
    if "verificationTypeDistribution" in has_data:
        md += f"---\n\n## {get_section_number(section_num)}、认证类型分布\n\n"
        section_num += 1
        md += "| 认证类型 | 数量 | 占比 |\n|---------|------|------|\n"
        for item in has_data["verificationTypeDistribution"]:
            key, count = item.get("key", ""), item.get("docCount", 0)
            pct = (count / total * 100) if total > 0 else 0
            md += f"| {key} | {count:,} | {pct:.1f}% |\n"
        md += "\n"
    
    if "channelDistribution" in has_data:
        md += f"---\n\n## {get_section_number(section_num)}、通道分布\n\n"
        section_num += 1
        md += "| 通道 | 数量 |\n|-----|------|\n"
        for item in has_data["channelDistribution"][:10]:
            md += f"| {item.get('key', '')} | {item.get('docCount', 0):,} |\n"
        md += "\n"
    
    if "publishTimeTrend" in has_data:
        md += f"---\n\n## {get_section_number(section_num)}、时间趋势分析\n\n"
        section_num += 1
        md += "| 时间 | 数量 |\n|-----|------|\n"
        for item in has_data["publishTimeTrend"][-24:]:
            time_str = format_timestamp(item.get("key"))
            md += f"| {time_str} | {item.get('docCount', 0):,} |\n"
        md += "\n"
    
    if articles:
        md += f"---\n\n## {get_section_number(section_num)}、热文列表\n\n"
        section_num += 1
        for i, article in enumerate(articles[:10], 1):
            title = article.get("title", "无标题")
            author = article.get("authorName", "未知")
            media = article.get("mediaName", "未知")
            pub_time = format_timestamp(article.get("publishTime"))
            interact = article.get("interactCount", 0)
            url = article.get("url", "")
            
            md += f"### {i}. {title}\n\n"
            md += f"- **作者**: {author}\n"
            md += f"- **平台**: {media}\n"
            md += f"- **发布时间**: {pub_time}\n"
            md += f"- **互动数**: {interact:,}\n"
            if url:
                md += f"- **链接**: {url}\n"
            md += "\n"
    
    md += "---\n\n*报告由舆情分析系统自动生成*\n"
    
    os.makedirs(os.path.dirname(output_path), exist_ok=True)
    with open(output_path, "w", encoding="utf-8") as f:
        f.write(md)
    
    return output_path


def save_json_report(data, output_path):
    """保存JSON格式报告"""
    # report_info = API调用参数
    request_params = data.get("_request_params", {})
    request_params["report_time"] = datetime.now().isoformat()
    request_params["total"] = data.get("total", 0)
    
    report = {
        "report_info": request_params,
        "data": {
            "articles": data.get("articles", []),
            "aggregation": data.get("aggregation", {})
        }
    }
    
    os.makedirs(os.path.dirname(output_path), exist_ok=True)
    with open(output_path, "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    
    return output_path


def generate_html_report(data, query, output_path):
    """生成HTML格式报告"""
    total = data.get("total", 0)
    aggregation = data.get("aggregation", {}) or {}
    articles = data.get("articles", [])
    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    
    # 情感分布数据
    sentiment_data = aggregation.get("sentimentDistribution", [])
    negative_count = sum(item.get("docCount", 0) for item in sentiment_data if item.get("key") == "负面")
    neutral_count = sum(item.get("docCount", 0) for item in sentiment_data if item.get("key") == "中性")
    positive_count = sum(item.get("docCount", 0) for item in sentiment_data if item.get("key") == "正面")
    
    negative_pct = (negative_count / total * 100) if total > 0 else 0
    neutral_pct = (neutral_count / total * 100) if total > 0 else 0
    positive_pct = (positive_count / total * 100) if total > 0 else 0
    
    # 确定主要情感倾向
    if negative_pct >= neutral_pct and negative_pct >= positive_pct:
        main_sentiment = "负面"
        sentiment_color = "#ff6b6b"
    elif positive_pct >= neutral_pct:
        main_sentiment = "正面"
        sentiment_color = "#26de81"
    else:
        main_sentiment = "中性"
        sentiment_color = "#feca57"
    
    # 渠道分布
    channel_data = aggregation.get("channelDistribution") or []
    channel_rows = ""
    for item in channel_data[:10]:
        key = item.get("key", "")
        count = item.get("docCount", 0)
        pct = (count / total * 100) if total > 0 else 0
        channel_rows += f'<tr><td>{key}</td><td>{count:,}</td><td>{pct:.2f}%</td><td><div class="bar"><div class="fill" style="width:{pct}%"></div></div></td></tr>'
    
    # 媒体平台分布
    media_data = aggregation.get("mediaNameDistribution") or []
    media_rows = ""
    for item in media_data[:10]:
        key = item.get("key", "")
        count = item.get("docCount", 0)
        pct = (count / total * 100) if total > 0 else 0
        media_rows += f'<tr><td>{key}</td><td>{count:,}</td><td>{pct:.2f}%</td></tr>'
    
    # 地域分布
    area_data = aggregation.get("areaCodeProvinceDistribution") or []
    area_cards = ""
    for item in area_data[:15]:
        name = item.get("key", "")
        count = item.get("docCount", 0)
        area_cards += f'<div class="area-card"><div class="name">{name}</div><div class="count">{count:,}</div></div>'
    
    # 关键词云
    keywords_data = aggregation.get("keywordsDistribution") or []
    colors = ["#667eea", "#764ba2", "#ff6b6b", "#26de81", "#feca57"]
    keyword_tags = ""
    max_count = keywords_data[0].get("docCount", 1) if keywords_data else 1
    for i, item in enumerate(keywords_data[:20]):
        key = item.get("key", "")
        count = item.get("docCount", 0)
        font_size = max(13, min(27, int(13 + (count / max_count) * 14)))
        color = colors[i % len(colors)]
        keyword_tags += f'<span class="kw-tag" style="font-size:{font_size}px;background:{color}">{key} ({count:,})</span>'
    
    # 时间趋势数据
    trend_data = aggregation.get("publishTimeTrend") or []
    trend_labels = [item.get("key", "") for item in trend_data[-24:]]
    trend_values = [item.get("docCount", 0) for item in trend_data[-24:]]
    
    # 热门文章
    article_items = ""
    for i, article in enumerate(articles[:20], 1):
        title = article.get("title") or "无标题"
        summary = article.get("summary") or ""
        author = article.get("authorName") or "未知"
        platform = article.get("platformName") or "未知"
        pub_time = format_timestamp(article.get("publishTime"))
        url = article.get("originalUrl") or "#"
        
        article_items += f'''<div class="article-item">
<div class="article-title"><a href="{url}" target="_blank">{i}. {title[:80]}{"..." if len(title) > 80 else ""}</a></div>
<div class="article-meta">
<span>📰 {platform}</span>
<span>👤 {author}</span>
<span>🕐 {pub_time}</span>
</div>
<div class="article-summary">摘要：{summary[:100]}{"..." if len(summary) > 100 else ""}</div>
</div>'''
    
    html = f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>舆情分析报告 - {query[:30]}</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: "Microsoft YaHei", sans-serif; background: linear-gradient(135deg, #667eea, #764ba2); min-height: 100vh; padding: 20px; }}
.container {{ max-width: 1200px; margin: 0 auto; background: #fff; border-radius: 16px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }}
.header {{ background: linear-gradient(135deg, #1e3c72, #2a5298); color: white; padding: 40px; text-align: center; }}
.header h1 {{ font-size: 28px; margin-bottom: 10px; }}
.content {{ padding: 30px 40px; }}
.section {{ margin-bottom: 45px; }}
.section-title {{ font-size: 20px; color: #1e3c72; padding-bottom: 12px; border-bottom: 3px solid #667eea; margin-bottom: 25px; font-weight: 600; }}

.stats-grid {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 25px; }}
.stat-card {{ padding: 25px; border-radius: 12px; text-align: center; color: white; }}
.stat-card.total {{ background: linear-gradient(135deg, #667eea, #764ba2); }}
.stat-card.negative {{ background: linear-gradient(135deg, #ff6b6b, #ee5a5a); }}
.stat-card.neutral {{ background: linear-gradient(135deg, #feca57, #f9c846); color: #333; }}
.stat-card.positive {{ background: linear-gradient(135deg, #26de81, #20bf6b); }}
.stat-card .number {{ font-size: 32px; font-weight: 700; }}

.progress-bar {{ height: 12px; background: #e8e8e8; border-radius: 6px; overflow: hidden; display: flex; margin-bottom: 20px; }}
.progress-bar .fill.negative {{ background: #ff6b6b; }}
.progress-bar .fill.neutral {{ background: #feca57; }}
.progress-bar .fill.positive {{ background: #26de81; }}

.chart-container {{ background: #f8f9fa; border-radius: 12px; padding: 20px; }}
.chart-box {{ height: 350px; }}

.keyword-cloud {{ display: flex; flex-wrap: wrap; gap: 10px; padding: 20px; justify-content: center; }}
.kw-tag {{ padding: 6px 16px; border-radius: 20px; color: white; }}

.article-item {{ background: #f8f9fa; border-radius: 12px; padding: 15px; margin-bottom: 15px; }}
.article-title {{ font-size: 15px; color: #1e3c72; font-weight: 600; margin-bottom: 8px; }}
.article-title a {{ color: inherit; text-decoration: none; }}
.article-meta {{ font-size: 12px; color: #666; display: flex; gap: 15px; }}
.article-summary {{ font-size: 13px; color: #555; margin-top: 8px; }}

table {{ width: 100%; border-collapse: collapse; }}
th, td {{ padding: 10px; text-align: left; border-bottom: 1px solid #eee; }}
th {{ background: #f8f9fa; font-weight: 600; }}
.bar {{ height: 8px; background: #e8e8e8; border-radius: 4px; overflow: hidden; }}
.fill {{ height: 100%; background: #667eea; }}

.area-grid {{ display: grid; grid-template-columns: repeat(5, 1fr); gap: 15px; }}
.area-card {{ background: #f8f9fa; padding: 15px; border-radius: 10px; text-align: center; }}
.area-card .name {{ font-size: 14px; color: #333; }}
.area-card .count {{ font-size: 20px; font-weight: 700; color: #667eea; }}

.summary-box {{ background: linear-gradient(135deg, #667eea, #764ba2); color: white; padding: 25px; border-radius: 12px; margin-top: 20px; }}
.summary-box h3 {{ margin-bottom: 15px; }}
.summary-box ul {{ list-style: none; padding: 0; }}
.summary-box li {{ padding: 6px 0; opacity: 0.95; }}
.summary-box li::before {{ content: "✓"; margin-right: 10px; }}

.footer {{ background: #f8f9fa; padding: 20px; text-align: center; color: #666; font-size: 13px; }}
@media (max-width: 900px) {{ .stats-grid {{ grid-template-columns: repeat(2, 1fr); }} .area-grid {{ grid-template-columns: repeat(3, 1fr); }} }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📊 舆情分析报告</h1>
<p>查询关键词: {query} | 生成时间: {now}</p>
</div>
<div class="content">

<div class="section">
<h2 class="section-title">📊 舆情概况</h2>
<div class="stats-grid">
<div class="stat-card total"><div class="number">{total:,}</div><div>信息总量</div></div>
<div class="stat-card negative"><div class="number">{negative_count:,}</div><div>负面</div></div>
<div class="stat-card neutral"><div class="number">{neutral_count:,}</div><div>中性</div></div>
<div class="stat-card positive"><div class="number">{positive_count:,}</div><div>正面</div></div>
</div>
<div class="progress-bar">
<div class="fill negative" style="width:{negative_pct:.2f}%"></div>
<div class="fill neutral" style="width:{neutral_pct:.2f}%"></div>
<div class="fill positive" style="width:{positive_pct:.2f}%"></div>
</div>
<p>整体舆情情绪偏向<strong style="color:{sentiment_color}">{main_sentiment}</strong>，{main_sentiment}信息占比{max(negative_pct, neutral_pct, positive_pct):.1f}%。</p>
</div>

<div class="section">
<h2 class="section-title">📡 传播渠道分布</h2>
<table><thead><tr><th>渠道</th><th>数量</th><th>占比</th><th>趋势</th></tr></thead><tbody>{channel_rows}</tbody></table>
</div>

<div class="section">
<h2 class="section-title">📺 媒体平台分布</h2>
<table><thead><tr><th>平台</th><th>数量</th><th>占比</th></tr></thead><tbody>{media_rows}</tbody></table>
</div>

<div class="section">
<h2 class="section-title">🗺️ 省级地域分布</h2>
<div class="area-grid">{area_cards}</div>
</div>

<div class="section">
<h2 class="section-title">🔑 关键词云</h2>
<div class="keyword-cloud">{keyword_tags}</div>
</div>

<div class="section">
<h2 class="section-title">📈 传播趋势</h2>
<div id="trendChart" class="chart-box"></div>
</div>

<script>
var trendChart = echarts.init(document.getElementById('trendChart'));
var trendOption = {{
    tooltip: {{ trigger: 'axis' }},
    xAxis: {{ type: 'category', data: {trend_labels} }},
    yAxis: {{ type: 'value', name: '数量' }},
    series: [{{
        data: {trend_values},
        type: 'line',
        smooth: true,
        areaStyle: {{ opacity: 0.3 }},
        itemStyle: {{ color: '#667eea' }},
        lineStyle: {{ width: 3 }}
    }}]
}};
trendChart.setOption(trendOption);
</script>

<div class="section">
<h2 class="section-title">🔥 热门文章</h2>
{article_items}
</div>

<div class="section">
<h2 class="section-title">📋 总结</h2>
<div class="summary-box">
<h3>核心发现</h3>
<ul>
<li>本次分析共采集到 {total:,} 条相关舆情数据</li>
<li>舆情以{main_sentiment}为主({max(negative_pct, neutral_pct, positive_pct):.1f}%)</li>
<li>主要传播渠道: {", ".join([item.get("key", "") for item in channel_data[:3]])}</li>
<li>热门关键词: {", ".join([item.get("key", "") for item in keywords_data[:5]])}</li>
</ul>
</div>
</div>

</div>
<div class="footer">
<p>报告生成时间: {now} | 数据来源: 舆情分析系统</p>
</div>
</div>
</body>
</html>'''
    
    os.makedirs(os.path.dirname(output_path), exist_ok=True)
    with open(output_path, "w", encoding="utf-8") as f:
        f.write(html)
    
    return output_path


def create_argument_parser():
    """创建命令行参数解析器"""
    parser = argparse.ArgumentParser(
        description="舆情报告生成 CLI 工具",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
示例:
  # 基础查询
  python3 scripts/report_cli.py --query "比亚迪"
  
  # 指定时间范围
  python3 scripts/report_cli.py --query "比亚迪" --days 7
  
  # 全维度分析
  python3 scripts/report_cli.py --query "(南京|金陵)&(医疗|卫生)" --days 3 --area "南京" --full-analysis
  
  # 指定媒体平台和情感
  python3 scripts/report_cli.py --query "新能源汽车" --media "微博,抖音" --sentiment negative
  
  # 按热度排序
  python3 scripts/report_cli.py --query "热点事件" --sort interact_count --size 20
"""
    )
    
    parser.add_argument("--query", "-q", required=True, help="查询关键词 (支持 &|!() 运算符)")
    
    parser.add_argument("--size", "-n", type=int, default=10, help="返回条数 (1-20, 默认10)")
    parser.add_argument("--sort", "-s", default="publish_time", 
                        choices=["publish_time", "interact_count", "comments_count", "likes_count", "reposts_count", "fans_count"],
                        help="排序字段 (默认: publish_time)")
    parser.add_argument("--sort-order", default="desc", choices=["asc", "desc"], help="排序方式 (默认: desc)")
    
    parser.add_argument("--media", "-m", help="媒体平台 (逗号分隔, 如: 微博,抖音)")
    parser.add_argument("--author", "-a", help="作者名称")
    parser.add_argument("--msg-type", choices=["original", "comment", "repost", "all"], help="消息类型")
    parser.add_argument("--content-type", choices=["video", "picture", "text", "all"], help="内容类型")
    parser.add_argument("--sentiment", choices=["negative", "neutral", "positive", "all"], help="情感倾向")
    parser.add_argument("--media-class", help="媒体分类 (如: 中央媒体)")
    parser.add_argument("--domain", help="内容领域")
    parser.add_argument("--scene", help="业务场景")
    parser.add_argument("--area", help="地域名称")
    parser.add_argument("--verification", choices=["blue", "yellow", "normal"], help="认证类型")
    
    parser.add_argument("--days", "-d", type=int, help="最近N天 (与 --time-from/--time-to 互斥)")
    parser.add_argument("--time-from", help="开始时间 (格式: YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS)")
    parser.add_argument("--time-to", help="结束时间 (格式: YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS)")
    
    parser.add_argument("--interact-min", type=int, help="互动数最小值")
    parser.add_argument("--interact-max", type=int, help="互动数最大值")
    parser.add_argument("--comments-min", type=int, help="评论数最小值")
    parser.add_argument("--comments-max", type=int, help="评论数最大值")
    
    parser.add_argument("--full-analysis", "-f", action="store_true", help="全维度分析模式")
    parser.add_argument("--no-articles", action="store_true", help="不返回热文列表")
    parser.add_argument("--output-dir", "-o", default=DEFAULT_OUTPUT_DIR, help="报告输出目录")
    parser.add_argument("--json-only", action="store_true", help="仅输出JSON格式")
    parser.add_argument("--quiet", action="store_true", help="静默模式，不显示摘要")
    
    return parser


def main():
    """主函数"""
    parser = create_argument_parser()
    args = parser.parse_args()
    
    time_from = None
    time_to = None
    
    if args.days:
        now = datetime.now()
        time_from = int((now - timedelta(days=args.days)).timestamp() * 1000)
        time_to = int(now.timestamp() * 1000)
    else:
        if args.time_from:
            time_from = parse_datetime(args.time_from)
        if args.time_to:
            time_to = parse_datetime(args.time_to)
    
    api_kwargs = {
        "size": args.size,
        "sort_field": args.sort,
        "sort_order": args.sort_order,
        "hot_articles": not args.no_articles,
        "aggregation_types": get_full_aggregation_types() if args.full_analysis else get_default_aggregation_types(),
        "time_from": time_from,
        "time_to": time_to
    }
    
    if args.media:
        api_kwargs["media_names"] = [m.strip() for m in args.media.split(",")]
    if args.author:
        api_kwargs["author_name"] = args.author
    if args.msg_type:
        api_kwargs["message_types"] = parse_msg_type(args.msg_type)
    if args.content_type:
        api_kwargs["content_types"] = parse_content_type(args.content_type)
    if args.sentiment:
        api_kwargs["sentiments"] = parse_sentiment(args.sentiment)
    if args.media_class:
        api_kwargs["media_classes"] = [args.media_class]
    if args.domain:
        api_kwargs["domains"] = [args.domain]
    if args.scene:
        api_kwargs["scenes"] = [args.scene]
    if args.area:
        area_codes, _ = parse_area(args.area)
        if area_codes:
            api_kwargs["area_codes"] = area_codes
    if args.verification:
        api_kwargs["verification_type"] = parse_verification(args.verification)
    if args.interact_min:
        api_kwargs["interact_count_min"] = args.interact_min
    if args.interact_max:
        api_kwargs["interact_count_max"] = args.interact_max
    if args.comments_min:
        api_kwargs["comments_count_min"] = args.comments_min
    if args.comments_max:
        api_kwargs["comments_count_max"] = args.comments_max
    
    data = call_report_api(args.query, **api_kwargs)
    
    if not data:
        sys.exit(1)
    
    if not args.quiet:
        display_report_summary(data, args.query)
    
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    safe_query = args.query[:20].replace("/", "_").replace("\\", "_").replace("|", "_").replace("&", "_")
    base_filename = f"report_{safe_query}_{timestamp}"
    
    json_path = os.path.join(args.output_dir, f"{base_filename}.json")
    json_path = save_json_report(data, json_path)
    print(f"✅ JSON报告已保存: {json_path}")
    
    if not args.json_only:
        md_path = os.path.join(args.output_dir, f"{base_filename}.md")
        md_path = generate_markdown_report(data, args.query, md_path)
        print(f"✅ Markdown报告已保存: {md_path}")
        
        html_path = os.path.join(args.output_dir, f"{base_filename}.html")
        html_path = generate_html_report(data, args.query, html_path)
        print(f"✅ HTML报告已保存: {html_path}")
    
    print("\n🎉 报告生成完成!")


if __name__ == "__main__":
    main()

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