stock-feed
A股每日新闻。从小红书、抖音、公众号三大平台搜索A股相关短讯,内置17个A股核心关键词一次性查询,默认近7天数据,自动过滤非股票内容,跨平台对比分析股市舆情。当用户需要研究A股舆情、股市讨论、大盘分析、选股策略、涨跌复盘时使用。触发词:A股、A股舆情、股市新闻、大盘分析、涨停、选股、A股复盘、股票讨论、股市热点。
#research
Install
npx skills add https://github.com/redfox-data/redfox-community/tree/main/skills/stock-feed
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install redfox-data-redfox-community@llmmart
git clone https://github.com/redfox-data/redfox-community.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole redfox-data/redfox-community collection as a plugin from our marketplace. Git is the plain clone.
README
A股每日新闻 / stock-feed
简介
一站式 A 股舆情研究工具,从小红书、抖音、公众号三大平台同时搜索 17 个 A 股核心关键词,自动过滤非股票内容,跨平台对比分析股市舆情趋势,输出结构化研究报告和可视化页面。
核心价值
- 17 个 A 股关键词一键覆盖全话题,零配置即可启动
- 三平台真实数据交叉验证,发现差异化舆情信号
- 自动过滤美妆、美食、旅游等非股票内容,只保留投资相关讨论
- 支持近 30 天任意时间范围回溯,追踪盘中与周度变化
适用对象
- 📈 散户投资者 — 快速了解市场情绪与热门板块讨论
- 🏦 财经研究员 — 获取跨平台舆情数据支撑研报分析
- 📰 财经自媒体 — 发现高互动内容素材与选题方向
功能特性
核心功能
- 17 词一键查询:内置 A 股、涨停、涨跌、选股、加仓等核心关键词,无需手动输入
- 三平台数据源:同时获取小红书、抖音、公众号真实用户讨论数据
- 智能内容过滤:自动识别并剔除非股票/投资相关内容,确保数据纯净
- 跨平台对比分析:综合三平台数据生成舆情洞察,多信号交叉验证
- 可视化 HTML 报告:交互式卡片报告,支持按平台筛选、数据排序
- 灵活时间范围:默认近 7 天,支持自定义 1~30 天,追踪趋势演变
- 自定义关键词:可指定个股、板块或概念名称进行定向查询
密钥获取与安全说明
- 本技能需要使用环境变量:
REDFOX_API_KEY。 REDFOX_API_KEY由 红狐 hub (https://redfox.hk)提供。- 请前往 红狐 hub 注册账号,获取
REDFOX_API_KEY。 - 配置设备环境变量
REDFOX_API_KEY后使用本技能。 - 在提供密钥前,请先确认密钥来源、可用范围、有效期及是否支持重置/撤销。
- 禁止在代码、提示词、日志或输出文件中硬编码/明文暴露密钥。
使用指南
直接用自然语言描述需求,无需记忆命令。
常用说法速查
| 意图 | 示例话术 | 效果 |
|---|---|---|
| 查看最新 A 股舆情 | "看看最新数据" | 使用 17 个内置关键词,搜索近 7 天三平台数据 |
| 查询特定个股 | "看看腾讯相关报道" | 定向搜索腾讯相关股票讨论 |
| 查询特定板块 | "半导体、芯片最新消息" | 按指定概念/板块搜索舆情 |
| 扩大时间范围 | "看看近 30 天 A 股舆情" | 搜索近一个月数据,追踪趋势变化 |
| 对比两只个股 | "比亚迪 vs 特斯拉舆情对比" | 跨平台对比两个标的的讨论热度与口碑 |
输出示例
报告以「数据速览」模块开头,按公众号→小红书→抖音顺序展示各平台 TOP5 作品表格(含标题超链接、作者、平台专属互动指标),随后输出核心发现、操作要点预判与风险说明,最后自动生成并打开可视化 HTML 报告。
使用场景
| 场景 | 角色 | 示例问法 | 收益 |
|---|---|---|---|
| 盘后复盘 | 散户投资者 | "今天 A 股什么情况" | 快速了解当日市场情绪与热门板块 |
| 个股研究 | 财经研究员 | "看看宁德时代的舆情" | 获取个股跨平台讨论热度与正负面情绪 |
| 板块追踪 | 财经自媒体 | "AI 板块最新消息" | 发现高互动内容素材,辅助选题 |
| 趋势分析 | 机构投资者 | "近 30 天 A 股舆情变化" | 追踪舆情趋势演变,辅助投资决策 |
Skill manifest
A股每日新闻
📝 简介
A股每日新闻是中国股市舆情研究工具,从小红书、抖音、公众号三大平台一次性搜索17个A股核心关键词(A股、A股市场、A股大盘、涨停、涨跌、潜力股、选股、加仓等),默认拉取近7天真实讨论数据,自动过滤非股票/投资相关内容,跨平台对比分析股市舆情趋势,输出结构化研究报告和可视化 HTML。
✨ 功能特性
| 功能模块 | 能力描述 | 核心价值 |
|---|---|---|
| 17词一键查询 | 内置A股核心关键词,一次覆盖全话题 | 零配置即用 |
| 三平台数据源 | 小红书、抖音、公众号真实数据 | 多维度舆情视角 |
| 跨平台对比 | 自动综合三平台数据生成洞察 | 发现差异化趋势 |
| 灵活时间范围 | 默认7天,支持自定义1~30天 | 追踪盘中/周度变化 |
| HTML 报告 | 交互式可视化报告生成 | 便于分享传播 |
| 历史回溯 | 支持近30天任意日期数据 | 追踪趋势演变 |
🔑 鉴权
脚本需配置 API Key 使用。从 红狐数据 获取个人 Key 并配置环境变量:
export REDFOX_API_KEY=ak_你的密钥
优先级:命令行 --api-key > REDFOX_API_KEY / X_API_KEY 环境变量 > 配置文件。
核心参数
| 参数 | 说明 | 默认值 |
|---|---|---|
keyword |
搜索关键词(可选,不传则使用内置17个A股关键词) | A股,A股市场,A股大盘,... 共17词 |
--platforms |
平台列表(不建议缩减) | xhs,dy,gzh |
--count |
每平台条数 | 50 |
--days |
时间范围(默认7天,用户指定时按需传入) | 7 |
--output-format |
json / html / both |
json |
--output-dir |
输出目录 | ~/Downloads/StockFeed |
平台信号解读:
| 平台 | 内容特征 | 关键指标 |
|---|---|---|
| 小红书 (xhs) | 散户分享、炒股心得、入门教程 | 收藏/点赞比高=实用信号 |
| 抖音 (dy) | 盘中速评、涨停解读、情绪传播 | 分享数=传播力 |
| 公众号 (gzh) | 深度复盘、策略分析、行业研报 | 阅读量=关注度,分享=认同 |
工作流程
1. 环境检查
运行前需确保已配置 API Key(环境变量 REDFOX_API_KEY 或 --api-key 参数)。如未配置,提示用户:
请配置 API Key:
export REDFOX_API_KEY=ak_你的密钥,注册地址 https://www.redfox.hk/login
2. 关键词处理
- 默认行为:不传 keyword 参数,脚本自动使用内置17个A股核心关键词
- 用户自定义:用户明确指定关键词时,传入
--keyword参数覆盖默认值 - 检查关键词是否过于模糊,若是则请用户具体化
3. 预研究
调用引擎前,并行运行 2-3 个 WebSearch 提取热词和背景:
A股 {TOPIC} 小红书 热门讨论→ 提取小红书热词A股 {TOPIC} 抖音 热门话题→ 提取抖音标签A股 {TOPIC} 最新动态→ 补充时效背景
4. 运行引擎
python3 scripts/stock_feed.py \
--platforms xhs,dy,gzh \
--count 50 \
--days 7 \
--output-format json \
--output-dir="${STOCK_FEED_MEMORY_DIR:-$HOME/Downloads/StockFeed}"
用户指定关键词时:
python3 scripts/stock_feed.py "半导体,芯片" \
--platforms xhs,dy,gzh \
--count 50 \
--days 7 \
--output-format json \
--output-dir="${STOCK_FEED_MEMORY_DIR:-$HOME/Downloads/StockFeed}"
前台运行,5分钟超时,不要后台。 读取完整输出,包含三平台的标题、作者、互动数据和链接。
5. WebSearch 补充
引擎完成后补充 1-2 个 WebSearch,覆盖雪球、东方财富、同花顺等引擎未覆盖的来源。排除 xiaohongshu.com / douyin.com / mp.weixin.qq.com(已被引擎覆盖)。
6. 综合输出
按 输出规则 生成研究报告。核心原则:
- 徽章之后、标题之前,必须输出「数据速览」模块:按公众号→小红书→抖音顺序,每平台展示 TOP5 作品表格(标题作为可点击超链接、作者、平台专属互动指标分列展示(公众号阅读/点赞/转发,小红书点赞/收藏/评论,抖音点赞/评论/分享)),模块末尾展示数据总量和 HTML 报告提示
- 报告以一级标题
# A股每日新闻开头 - 按话题/故事综合,非按平台罗列
- 多平台交叉验证的结论置信度最高
- 高互动内容权重最高(含真实用户信号)
- 每个叙事段落以粗体标题开头,后跟
-和正文 - 每条发现必须引用至少2个不同平台的来源(如:小红书+抖音、公众号+小红书)
- 引用必须为可点击的 Markdown 链接
- 核心发现之后必须附「操作要点预判」和「风险说明」两个固定区块
综合报告完成后,自动执行以下步骤生成 HTML 报告(无需询问用户):
# 直接从 JSON 生成 HTML(不调 API,不写入 Markdown)
python3 scripts/stock_feed.py --from-json "JSON文件路径" \
--output-dir="${STOCK_FEED_MEMORY_DIR:-$HOME/Downloads/StockFeed}"
# 自动打开 HTML 报告
open "HTML文件路径"
脚本会输出 HTML 文件路径,自动打开后将路径告知用户即可。
其他资源
Files (redfox-community)
-
references
-
output-rules.md 5.9 KB
# 输出规则与模板 ## 输出规则(LAW) **徽章(输出第一行,强制):** ``` 📈 stock-feed v{VERSION} · {YYYY-MM-DD} ``` **LAW 1** - 结尾不要 `Sources:` / `References:` 块,引擎页脚的 `📈 数据源:` 行是唯一引用。 **LAW 2** - 通用查询以 `# A股每日新闻` 标题开头(一级标题),第二段以 `我的发现:` 开头。对比查询必须用 `# {A} vs {B}: 跨平台舆情对比`。 **LAW 3** - 不用破折号(`—`/`–`),用 ` - `(空格连字符空格)。 **LAW 4** - 引擎页脚逐字包含在输出中,位于核心发现之后、邀请之前。 **LAW 5** - 每个引用用内联 Markdown 链接 `[名称](url)`,不用裸 URL。 **LAW 6** - 不要输出原始排名列表,将引擎数据转化为散文洞察。 **LAW 7** - 每条发现/核心发现必须展示多个交叉验证来源(至少2个不同平台),格式:`来源 [平台1@作者1](链接1)、[平台2@作者2](链接2)`,体现跨平台多信号验证。 **LAW 8** - 核心发现之后必须附「操作要点预判」和「风险说明」两个固定区块。 **LAW 9** - 徽章之后、标题之前,必须输出「数据速览」模块:按公众号→小红书→抖音顺序,每平台展示 TOP5 作品表格(标题超链接、作者、平台专属互动指标),模块末尾展示数据总量和 HTML 报告提示。公众号展示阅读/点赞/转发,小红书展示点赞/收藏/评论,抖音展示点赞/评论/分享。 --- ## 通用查询输出格式 ``` 📈 stock-feed v{VERSION} · {YYYY-MM-DD} ## 数据速览 **公众号 TOP5** | 标题 | 作者 | 📖阅读 | 👍点赞 | 🔄转发 | |------|------|--------|--------|--------| | [{title}]({url}) | {author} | {reads} | {likes} | {shares} | | ... | ... | ... | ... | ... | **小红书 TOP5** | 标题 | 作者 | 👍点赞 | 💬评论 | ⭐收藏 | |------|------|--------|--------|--------| | [{title}]({url}) | {author} | {likes} | {comments} | {collects} | | ... | ... | ... | ... | ... | **抖音 TOP5** | 标题 | 作者 | 👍点赞 | 💬评论 | 🔄分享 | |------|------|--------|--------|--------| | [{title}]({url}) | {author} | {likes} | {comments} | {shares} | | ... | ... | ... | ... | ... | 共拉取 {total} 条数据(公众号 {gzh} 条 / 小红书 {xhs} 条 / 抖音 {dy} 条),更多数据可查看 HTML 报告。 --- # A股每日新闻 我的发现: **{话题总结1}** - [1-2句话描述] 来源 [小红书@{作者1}](笔记链接1)、[抖音@{作者2}](视频链接2)、[公众号{作者3}](文章链接3) **{话题总结2}** - [1-2句话] 来源 [公众号{作者1}](文章链接1)、[小红书@{作者2}](笔记链接2) 核心发现: 1. [发现] - 来源 [公众号{作者名}](文章链接)、[抖音@{作者}](视频链接) 2. [发现] - 来源 [小红书@{作者}](笔记链接)、[公众号{作者名}](文章链接) 3. [发现] - 来源 [抖音@{作者}](视频链接)、[小红书@{作者}](笔记链接) 操作要点预判: - [基于多平台舆情信号的操作建议1] - [基于多平台舆情信号的操作建议2] - [基于多平台舆情信号的操作建议3] ⚠ 以上为社媒舆情综合分析,不构成投资建议。投资有风险,入市需谨慎。 {引擎页脚逐字粘贴} --- 我是 A股每日新闻 的专家,我可以帮你: - [基于研究的具体问题] - [深入分析某个板块或个股讨论] 随时可以深入。 HTML 报告已生成:[查看报告](HTML文件路径) 💡 支持自由调整查询时间范围,比如查最新1天、近30天等。是否需要每天9点将「A股舆情」的最新研究和数据推送给你? --- 📌 如果你想订阅股市大V账号动态,第一时间掌握KOL观点变化: - [公众号订阅Skill](https://redfox.hk/skills/no/H7TE4PQ3) - 订阅财经公众号,每日自动推送最新文章 - [抖音订阅Skill](https://redfox.hk/skills/no/bsaEmnyv) - 订阅财经抖音号,每日自动推送最新作品 ``` **引用格式规范(多来源交叉验证):** - 每条发现至少引用2个不同平台的来源,用中文顿号 `、` 连接 - 小红书:`[小红书@{作者}](作者链接)` 或 `[小红书笔记](笔记链接)` - 抖音:`[抖音@{作者}](作者链接)` 或 `[抖音视频](视频链接)` - 公众号:`[公众号{作者名}](文章链接)` - 示例:`来源 [小红书@散户小王](链接1)、[抖音@财经老张](链接2)、[公众号深度复盘](链接3)` - 链接取自引擎返回的 `url` 字段(作品链接)或 `author_link` 字段(作者链接),优先用作品链接 --- ## 对比查询输出格式 ``` 📈 stock-feed v{VERSION} · {YYYY-MM-DD} # {A} vs {B}: 跨平台舆情对比 ## 一句话结论 [哪个热度/口碑/讨论度更高] ## {实体1} - **优势**:[来源 [小红书@作者1](链接1)、[抖音@作者2](链接2)] - **劣势**:[来源 [公众号作者1](链接1)、[小红书@作者2](链接2)] ## {实体2} - **优势**:[来源 [抖音@作者1](链接1)、[公众号作者2](链接2)] - **劣势**:[来源 [小红书@作者1](链接1)、[抖音@作者2](链接2)] ## 正面对比 | 维度 | {实体1} | {实体2} | |------|---------|---------| | 小红书讨论 | ... | ... | | 抖音热度 | ... | ... | | 公众号评价 | ... | ... | | 适合谁 | ... | ... | ## 结论 **选 {实体1} 如果** [场景] / **选 {实体2} 如果** [场景] 操作要点预判: - [基于多平台舆情信号的操作建议1] - [基于多平台舆情信号的操作建议2] ⚠ 以上为社媒舆情综合分析,不构成投资建议。投资有风险,入市需谨慎。 {引擎页脚逐字粘贴} --- 📌 如果你想订阅股市大V账号动态,第一时间掌握KOL观点变化: - [公众号订阅Skill](https://redfox.hk/skills/no/H7TE4PQ3) - 订阅财经公众号,每日自动推送最新文章 - [抖音订阅Skill](https://redfox.hk/skills/no/bsaEmnyv) - 订阅财经抖音号,每日自动推送最新作品 ```
-
-
scripts
-
stock_feed.py 49.5 KB
#!/usr/bin/env python3 """ stock-feed: A股每日新闻 ========================================== 从小红书、抖音、公众号三大平台搜索A股相关短讯, 默认传入17个A股核心关键词,默认查询近7天数据。 Usage: python stock_feed.py python stock_feed.py --days 30 python stock_feed.py --keyword "半导体,芯片" python stock_feed.py --output-format html """ from __future__ import annotations import argparse import json import os import sys import time from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any from urllib.parse import quote # 强制 stdout/stderr UTF-8 编码(兼容所有平台) for _stream in (sys.stdout, sys.stderr): if hasattr(_stream, "reconfigure"): try: _stream.reconfigure(encoding="utf-8", errors="replace") except Exception: pass # ─── 常量 ────────────────────────────────────────────────────────────────────────── API_BASE = "https://redfox.hk/story/api/multiPlatform/workSearch" PLATFORMS = { "xhs": { "label": "小红书", "result_key": "xhsResult", }, "dy": { "label": "抖音", "result_key": "dyResult", }, "gzh": { "label": "公众号", "result_key": "gzhResult", }, } DEFAULT_COUNT = 50 DEFAULT_DAYS = 7 SOURCE_LABEL = "A股每日新闻-GitHub" # A股默认关键词(17个) DEFAULT_KEYWORDS = ( "A股,A股市场,A股大盘,A股分析,股票,涨停,涨跌," "潜力股,A股龙头,A股复盘,选股,加仓,调仓,补仓," "拆股,仓位管理,A股行情" ) # ─── API Key ──────────────────────────────────────────────────────────────────────── class InsufficientCreditsError(Exception): """API 积分不足错误""" pass def get_api_key(cli_key: str | None = None) -> str: """按优先级获取 API Key: 命令行 > 环境变量 > 配置文件""" if cli_key: return cli_key # 环境变量 for env_name in ("REDFOX_API_KEY", "X_API_KEY"): val = os.environ.get(env_name, "").strip() if val: return val # 配置文件 config_path = os.path.expanduser("~/.qoder/apis/redfox.json") if os.path.isfile(config_path): try: with open(config_path) as f: cfg = json.load(f) val = (cfg.get("api_key") or "").strip() if val: return val except Exception: pass return "" # ─── 数量解析 ─────────────────────────────────────────────────────────────────────── def parse_count(value: Any) -> int: """解析数量字段,支持 '1.2w'、'5000+' 等中文格式""" if value is None: return 0 if isinstance(value, (int, float)): return int(value) text = str(value).replace("+", "").replace(",", "").strip() if not text: return 0 try: if "w" in text.lower(): return int(float(text.lower().replace("w", "")) * 10000) if text.endswith("万"): return int(float(text[:-1]) * 10000) if text.endswith("亿"): return int(float(text[:-1]) * 100000000) return int(float(text)) except (TypeError, ValueError): return 0 def fuzzy_count(value: Any) -> str: """模糊化互动数,5000以下保留原始值""" num = parse_count(value) if num <= 0: return "--" if num < 5000: return str(num) if num < 10000: return "5000+" wan = num // 10000 return f"{wan}w+" # ─── HTTP 请求 ────────────────────────────────────────────────────────────────────── def _http_post(url: str, payload: dict, api_key: str, max_retries: int = 3) -> dict: """带重试的 HTTP POST 请求""" import urllib.request import urllib.error headers = { "Content-Type": "application/json", "X-API-KEY": api_key, "User-Agent": "stock-feed/1.0", } body = json.dumps(payload, ensure_ascii=False).encode("utf-8") last_error = None for attempt in range(max_retries): try: req = urllib.request.Request(url, data=body, headers=headers, method="POST") with urllib.request.urlopen(req, timeout=30) as resp: raw = resp.read().decode("utf-8") result = json.loads(raw) code = result.get("code") if code == 3108: time.sleep(5 * (attempt + 1)) continue if code == 3201: raise InsufficientCreditsError(result.get("msg", "积分不足")) if code not in (200, 2000): raise Exception(f"API 错误 code={code}: {result.get('msg', '未知')}") return result except urllib.error.HTTPError as e: last_error = f"HTTP {e.code}" if attempt < max_retries - 1: time.sleep(2 ** attempt) except urllib.error.URLError as e: last_error = f"网络错误: {e.reason}" if attempt < max_retries - 1: time.sleep(2 ** attempt) except Exception as e: last_error = str(e) if attempt < max_retries - 1: time.sleep(2 ** attempt) raise Exception(f"请求失败: {last_error}(已尝试 {max_retries} 次)") def _first_of(art: dict, *keys: str, default: Any = None) -> Any: """从文章字典中按优先级取第一个非空值""" for k in keys: v = art.get(k) if v is not None and v != "" and v != 0: return v return default def _normalize_article(art: dict, platform: str, idx: int) -> dict: """将不同平台的数据归一化为统一格式""" if platform == "xhs": return _normalize_xhs(art, idx) elif platform == "dy": return _normalize_dy(art, idx) elif platform == "gzh": return _normalize_gzh(art, idx) return art def _normalize_xhs(art: dict, idx: int) -> dict: """归一化小红书数据""" note_id = str(_first_of(art, "workId", "id", "noteId", "workUuid", "uuid", default="")) author_id = str(_first_of(art, "accountUserid", "authorId", "accountId", default="")) title_raw = _first_of(art, "workTitle", "title", "displayTitle", default="") desc_raw = _first_of(art, "workDesc", "desc", "displayDesc", "summary", default="") title = (title_raw or desc_raw or "无标题")[:200] desc = (desc_raw or "")[:500] note_link = _first_of(art, "workUrl", "shareInfoLink", "url", default="") if not note_link and note_id: xsec_token = art.get("xsecToken", "") if xsec_token: note_link = f"https://www.xiaohongshu.com/explore/{note_id}?xsec_token={xsec_token}" else: note_link = f"https://www.xiaohongshu.com/explore/{note_id}" author_link = f"https://www.xiaohongshu.com/user/profile/{author_id}" if author_id else "" author_name = _first_of(art, "accountNickname", "authorNickname", "author", "accountName", "nickname", default="未知") pub_time = _first_of(art, "workPublishTime", "createTime", "publishTime", "time", default="") if isinstance(pub_time, (int, float)) and pub_time > 1000000000000: from datetime import datetime as _dt try: pub_time = _dt.fromtimestamp(pub_time / 1000.0).strftime("%Y-%m-%d %H:%M:%S") except (OSError, ValueError): pub_time = str(pub_time) cover = _first_of(art, "coverUrl", "cover", default="") account_type = _first_of(art, "accountType", default="") work_type = _first_of(art, "workType", "noteType", default="") return { "id": f"XHS{idx}", "platform": "小红书", "platform_key": "xhs", "title": title, "desc": desc, "url": note_link, "author": author_name, "author_id": author_id, "author_link": author_link, "author_fans": fuzzy_count(_first_of(art, "authorFans", "followerCount", default=0)), "published_at": str(pub_time), "engagement": { "likes": parse_count(_first_of(art, "workLikedCount", "likedCount", "likeCount", default=0)), "comments": parse_count(_first_of(art, "workCommentsCount", "commentsCount", "commentCount", default=0)), "collects": parse_count(_first_of(art, "workCollectedCount", "collectedCount", "collectCount", default=0)), "shares": parse_count(_first_of(art, "workSharedCount", "sharedCount", "shareCount", default=0)), "interactions": parse_count(_first_of(art, "interactiveCount", default=0)), }, "engagement_display": _engagement_display(art, "xhs"), "cover": cover, "scores": _extract_scores(art), "account_type": account_type, "work_type": work_type, } def _normalize_dy(art: dict, idx: int) -> dict: """归一化抖音数据""" work_url = _first_of(art, "workUrl", "url", default="") title_raw = _first_of(art, "title", "desc", default="") desc_raw = _first_of(art, "desc", "summary", default="") title = (title_raw or "无标题")[:200] desc = (desc_raw or "")[:500] author_name = _first_of(art, "accountName", "author", "authorNickname", default="未知") author_id = str(_first_of(art, "accountId", "authorId", default="")) pub_time = _first_of(art, "publishTime", "createTime", default="") cover = _first_of(art, "cover", "coverUrl", default="") return { "id": f"DY{idx}", "platform": "抖音", "platform_key": "dy", "title": title, "desc": desc, "url": work_url, "author": author_name, "author_id": author_id, "author_link": f"https://www.douyin.com/user/{author_id}" if author_id else "", "author_fans": fuzzy_count(_first_of(art, "followerCount", "authorFans", default=0)), "published_at": str(pub_time), "engagement": { "likes": parse_count(_first_of(art, "likeCount", "likedCount", default=0)), "comments": parse_count(_first_of(art, "commentCount", "commentsCount", default=0)), "collects": parse_count(_first_of(art, "collectCount", "collectedCount", default=0)), "shares": parse_count(_first_of(art, "shareCount", "sharedCount", default=0)), }, "engagement_display": _engagement_display(art, "dy"), "cover": cover, "scores": _extract_scores(art), } def _normalize_gzh(art: dict, idx: int) -> dict: """归一化公众号数据""" url = _first_of(art, "url", "workUrl", default="") title = (art.get("title") or "无标题")[:200] summary = _first_of(art, "summary", "desc", default="") author_name = _first_of(art, "author", "accountName", default="-") author_id = str(_first_of(art, "accountId", "authorId", default="")) pub_time = _first_of(art, "publicTime", "publishTime", "createTime", default="") cover = _first_of(art, "imageUrl", "coverUrl", "cover", default="") return { "id": f"GZH{idx}", "platform": "公众号", "platform_key": "gzh", "title": title, "desc": (summary or "")[:500], "url": url, "author": author_name, "author_id": author_id, "author_link": "", "author_fans": fuzzy_count(_first_of(art, "followerCount", "authorFans", default=0)), "published_at": str(pub_time), "engagement": { "reads": parse_count(_first_of(art, "clicksCount", "readCount", default=0)), "likes": parse_count(_first_of(art, "likeCount", "likedCount", default=0)), "watches": parse_count(_first_of(art, "watchCount", default=0)), "collects": parse_count(_first_of(art, "collectCount", "collectedCount", default=0)), "shares": parse_count(_first_of(art, "shareCount", "sharedCount", default=0)), "comments": parse_count(_first_of(art, "commentsCount", "commentCount", default=0)), }, "engagement_display": _engagement_display(art, "gzh"), "cover": cover, "scores": _extract_scores(art), } def _engagement_display(art: dict, platform: str) -> str: """生成可读的互动数据字符串""" if platform == "xhs": likes = fuzzy_count(_first_of(art, "workLikedCount", "likedCount", "likeCount", default=0)) comments = fuzzy_count(_first_of(art, "workCommentsCount", "commentsCount", "commentCount", default=0)) collects = fuzzy_count(_first_of(art, "workCollectedCount", "collectedCount", "collectCount", default=0)) interactions = fuzzy_count(_first_of(art, "interactiveCount", default=0)) return f"🔥{interactions}互动 👍{likes} ⭐{collects} 💬{comments}" elif platform == "dy": likes = fuzzy_count(_first_of(art, "workLikedCount", "likeCount", "likedCount", default=0)) comments = fuzzy_count(_first_of(art, "workCommentsCount", "commentCount", "commentsCount", default=0)) shares = fuzzy_count(_first_of(art, "workSharedCount", "shareCount", "sharedCount", default=0)) collects = fuzzy_count(_first_of(art, "workCollectedCount", "collectCount", "collectedCount", default=0)) return f"👍{likes} 💬{comments} ⭐{collects} 🔄{shares}" elif platform == "gzh": reads = fuzzy_count(_first_of(art, "clicksCount", "readCount", default=0)) likes = fuzzy_count(_first_of(art, "likeCount", "likedCount", default=0)) watches = fuzzy_count(_first_of(art, "watchCount", default=0)) comments = fuzzy_count(_first_of(art, "commentsCount", "commentCount", default=0)) shares = fuzzy_count(_first_of(art, "shareCount", "sharedCount", default=0)) return f"📖{reads} 👍{likes} 👁{watches} 💬{comments} 🔄{shares}" return "" def _extract_scores(art: dict) -> dict: """提取评分字段""" return { "total": art.get("totalScore", 0), "relevance": art.get("relevanceScore", 0), "popularity": art.get("popularityScore", 0), "recency": art.get("recencyScore", 0), } # ─── 股票/投资相关性过滤 ────────────────────────────────────────────────────────────── _STOCK_KEYWORDS = frozenset([ # 股票市场术语 "A股", "a股", "股票", "股市", "涨停", "跌停", "大盘", "板块", "行情", "涨跌", "股价", "K线", "k线", "牛市", "熊市", "基金", "券商", "散户", "主力", "庄家", "筹码", "仓位", "建仓", "加仓", "减仓", "清仓", "止盈", "止损", "复盘", "龙头", "涨停板", "跌停板", "概念股", "题材股", "蓝筹", "创业板", "科创板", "港股", "美股", "指数", "大盘股", "小盘股", "封板", "连板", "打板", "半路板", "反包", "妖股", "牛股", "垃圾股", # 投资与交易 "投资", "理财", "选股", "持仓", "买入", "卖出", "分红", "估值", "市值", "股息", "收益率", "回报", "套利", "做空", "做多", # 财务与基本面 "财报", "年报", "季报", "营收", "净利", "毛利率", "利润", "亏损", "业绩", "ROE", "roe", "市盈率", "市净率", "现金流", # 行业与赛道 "半导体", "芯片", "新能源", "光伏", "锂电", "医药", "消费", "银行", "保险", "地产", "人工智能", "AI", "机器人", "汽车", "军工", "白酒", "食品饮料", "电子", "通信", "计算机", # 市场事件 "IPO", "ipo", "增发", "减持", "增持", "回购", "摘帽", "ST", "st", "退市", "重组", "并购", "收购", "股权转让", "定增", # 技术指标 "均线", "MACD", "macd", "RSI", "rsi", "布林", "支撑位", "压力位", "放量", "缩量", "换手率", "成交量", "量价", # 机构与政策 "央行", "证监会", "银保监", "降息", "降准", "MLF", "LPR", "北向资金", "外资", "融资", "融券", "两融", ]) # 明确非股票内容的排除关键词 _NON_STOCK_KEYWORDS = frozenset([ "美妆", "护肤", "穿搭", "化妆", "美甲", "发型", "美食", "食谱", "做菜", "烹饪", "旅游", "旅行", "景点", "酒店", "民宿", "娱乐", "综艺", "追剧", "明星", "偶像", ]) def _is_stock_related(item: dict) -> bool: """判断文章是否与股票/投资相关""" text = (item.get("title", "") + " " + item.get("desc", "")).lower() # 排除明确非股票内容 for kw in _NON_STOCK_KEYWORDS: if kw.lower() in text: return False # 匹配股票/投资关键词 for kw in _STOCK_KEYWORDS: if kw.lower() in text: return True return False # ─── 主搜索函数 ───────────────────────────────────────────────────────────────────── def search( keyword: str, platforms: list[str] | None = None, count: int = DEFAULT_COUNT, api_key: str | None = None, days: int = DEFAULT_DAYS, ) -> dict: """通过统一接口搜索多平台A股话题数据""" if not platforms: platforms = list(PLATFORMS.keys()) key = get_api_key(api_key) if not key: sys.stderr.write("\u274c 未找到 API Key,请先配置:\n") sys.stderr.write(" export REDFOX_API_KEY=ak_你的密钥\n") sys.stderr.write(" 或使用 --api-key 参数传入\n") sys.stderr.write(" 注册地址: https://www.redfox.hk/login\n") sys.stderr.flush() sys.exit(1) # 构建统一请求参数 today = datetime.now() start_date = (today - timedelta(days=days)).strftime("%Y-%m-%d") end_date = today.strftime("%Y-%m-%d") payload = { "keyword": keyword, "source": SOURCE_LABEL, "startDate": start_date, "endDate": end_date, } sys.stderr.write(f"[\u2699\ufe0f] 搜索中: {keyword} ...\n") sys.stderr.flush() results = {} credit_error = False try: result = _http_post(API_BASE, payload, key) data = result.get("data") or {} for p in platforms: if p not in PLATFORMS: results[p] = {"platform": p, "label": p, "items": [], "total": 0, "error": "未知平台"} continue plat = PLATFORMS[p] label = plat["label"] result_key = plat["result_key"] articles = data.get(result_key, []) if isinstance(articles, dict): articles = articles.get("articles", []) if not isinstance(articles, list): articles = [] # 去重并归一化 all_articles = [] seen_ids = set() for art in articles: uid = ( art.get("workUuid") or art.get("uuid") or art.get("id") or art.get("noteId") or "" ) if uid and uid in seen_ids: continue if uid: seen_ids.add(uid) item = _normalize_article(art, p, len(all_articles) + 1) all_articles.append(item) # 过滤仅保留股票/投资相关内容 filtered = [a for a in all_articles if _is_stock_related(a)] removed = len(all_articles) - len(filtered) if removed > 0: sys.stderr.write(f"[{label}] 过滤非股票内容 {removed} 条\n") sys.stderr.flush() sys.stderr.write(f"[{label}] 获取 {len(filtered)} 条\n") sys.stderr.flush() results[p] = { "platform": p, "label": label, "items": filtered[:count], "total": len(filtered[:count]), } except InsufficientCreditsError as e: sys.stderr.write(f"⚠️ {e}\n") sys.stderr.write(f"请配置个人 API Key: export REDFOX_API_KEY=你的密钥\n") sys.stderr.write(f"注册地址: https://www.redfox.hk/login\n") sys.stderr.flush() credit_error = True except Exception as e: sys.stderr.write(f"请求失败: {e}\n") sys.stderr.flush() # 为未处理的平台填充空结果 for p in platforms: if p not in results: results[p] = { "platform": p, "label": PLATFORMS[p]["label"], "items": [], "total": 0, } if credit_error: results[p]["error"] = "积分不足,请配置个人 API Key" # 汇总统计 total_items = sum(r["total"] for r in results.values()) today_utc = datetime.now(timezone.utc) return { "keyword": keyword, "searched_at": today_utc.isoformat(), "date_range": { "from": (today_utc - timedelta(days=days)).strftime("%Y-%m-%d"), "to": today_utc.strftime("%Y-%m-%d"), }, "platforms": results, "total_items": total_items, } # ─── JSON 输出 ────────────────────────────────────────────────────────────────────── def format_as_json(data: dict, max_items: int = 50) -> dict: """精简 JSON 格式(供 AI 智能体分析使用)""" output = { "keyword": data["keyword"], "searched_at": data["searched_at"], "date_range": data["date_range"], "total_items": data["total_items"], "platforms": {}, } for pkey, pdata in data["platforms"].items(): items = [] for item in pdata.get("items", [])[:max_items]: items.append({ "id": item["id"], "platform": item["platform"], "title": item["title"], "author": item["author"], "author_fans": item["author_fans"], "published_at": item["published_at"], "engagement_display": item["engagement_display"], "engagement": item["engagement"], "url": item["url"], "desc": item["desc"][:200], "scores": item.get("scores", {}), }) output["platforms"][pkey] = { "label": pdata["label"], "total": pdata["total"], "items": items, } if pdata.get("error"): output["platforms"][pkey]["error"] = pdata["error"] return output # ─── HTML 报告 ────────────────────────────────────────────────────────────────────── def _md_to_html(text: str) -> str: """简易 Markdown → HTML 转换(无第三方依赖)""" import re lines = text.split("\n") out = [] in_list = False for line in lines: stripped = line.strip() if stripped.startswith("### "): if in_list: out.append("</ul>"); in_list = False out.append(f'<h4>{stripped[4:]}</h4>') elif stripped.startswith("## "): if in_list: out.append("</ul>"); in_list = False out.append(f'<h3>{stripped[2:]}</h3>') elif stripped.startswith("# "): if in_list: out.append("</ul>"); in_list = False out.append(f'<h2>{stripped[2:]}</h2>') elif stripped == "---": if in_list: out.append("</ul>"); in_list = False out.append('<hr>') elif stripped.startswith("- "): if not in_list: out.append('<ul>'); in_list = True content = _md_inline(stripped[2:]) out.append(f'<li>{content}</li>') elif re.match(r'^\d+\.\s', stripped): if not in_list: out.append('<ol>'); in_list = True content = _md_inline(re.sub(r'^\d+\.\s', '', stripped)) out.append(f'<li>{content}</li>') elif not stripped: if in_list: out.append("</ul>"); in_list = False out.append('') else: if in_list: out.append("</ul>"); in_list = False out.append(f'<p>{_md_inline(stripped)}</p>') if in_list: out.append("</ul>") return "\n".join(out) def _md_inline(text: str) -> str: """行内 Markdown 转换:粗体、链接""" import re text = text.replace("&", "&").replace("<", "<").replace(">", ">") text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2" target="_blank">\1</a>', text) text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text) return text def format_as_html(data: dict, max_items: int = 50, report_html: str = "") -> str: """生成网站风格 HTML 报告""" keyword = data["keyword"] total = data["total_items"] date_range = data["date_range"] platform_meta = { "xhs": {"primary": "#ff2442", "bg": "#fff1f0", "icon": "📕", "name": "小红书"}, "dy": {"primary": "#161823", "bg": "#f5f5f5", "icon": "🎵", "name": "抖音"}, "gzh": {"primary": "#07c160", "bg": "#f0fff4", "icon": "📖", "name": "公众号"}, } stats_html = "" for pkey, pdata in data["platforms"].items(): meta = platform_meta.get(pkey, platform_meta["xhs"]) ptotal = pdata["total"] m_primary = meta["primary"] m_bg = meta["bg"] m_icon = meta["icon"] m_name = meta["name"] total_likes = sum(it.get("engagement", {}).get("likes", 0) for it in pdata.get("items", [])[:max_items]) total_reads = sum( it.get("engagement", {}).get("reads", 0) + it.get("engagement", {}).get("likes", 0) + it.get("engagement", {}).get("collects", 0) + it.get("engagement", {}).get("shares", 0) + it.get("engagement", {}).get("comments", 0) for it in pdata.get("items", [])[:max_items] ) stats_html += ( f'\n <div class="stat-card" style="--p-color: {m_primary}; --p-bg: {m_bg}">\n' f' <div class="stat-icon">{m_icon}</div>\n' f' <div class="stat-body">\n' f' <div class="stat-label">{m_name}</div>\n' f' <div class="stat-num">{ptotal} <small>条</small></div>\n' f' </div>\n' f' </div>' ) tabs_html = "" panels_html = "" for pkey, pdata in data["platforms"].items(): meta = platform_meta.get(pkey, platform_meta["xhs"]) label = pdata["label"] ptotal = pdata["total"] m_primary = meta["primary"] m_icon = meta["icon"] is_first = pkey == list(data["platforms"].keys())[0] active = " active" if is_first else "" tabs_html += ( '<button class="tab-btn' + active + '" data-platform="' + pkey + '" ' 'style="--tab-color: ' + m_primary + '">' + m_icon + ' ' + label + ' <span class="tab-count">' + str(ptotal) + '</span></button>\n' ) display = "block" if is_first else "none" items = pdata.get("items", [])[:max_items] error_html = "" if pdata.get("error"): error_html = f'<div class="error-banner">⚠️ {pdata["error"]}</div>' cards = "" for idx, item in enumerate(items): title_escaped = item["title"].replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) desc_escaped = item["desc"][:200].replace("&", "&").replace("<", "<").replace(">", ">") if item.get("desc") else "" author_escaped = item["author"].replace("&", "&").replace("<", "<").replace(">", ">") item_url = item.get("url", "") url_attr = 'href="' + item_url + '"' if item_url else 'href="#"' author_link = item.get("author_link", "") author_html = ('<a href="' + author_link + '" class="author-link" target="_blank">' + author_escaped + '</a>') if author_link else ('<span class="author-name">' + author_escaped + '</span>') # 互动数据标签 — 按平台展示对应字段,始终显示(包括0值),使用fuzzy_count格式化 eng = item.get("engagement", {}) eng_tags = "" if pkey == "gzh": reads = fuzzy_count(eng.get("reads", 0)) likes = fuzzy_count(eng.get("likes", 0)) shares = fuzzy_count(eng.get("shares", 0)) eng_tags = ('<span class="eng-tag reads">📖 ' + reads + '</span>' + '<span class="eng-tag">👍 ' + likes + '</span>' + '<span class="eng-tag">🔄 ' + shares + '</span>') elif pkey == "xhs": likes = fuzzy_count(eng.get("likes", 0)) collects = fuzzy_count(eng.get("collects", 0)) comments = fuzzy_count(eng.get("comments", 0)) eng_tags = ('<span class="eng-tag">👍 ' + likes + '</span>' + '<span class="eng-tag">⭐ ' + collects + '</span>' + '<span class="eng-tag">💬 ' + comments + '</span>') elif pkey == "dy": likes = fuzzy_count(eng.get("likes", 0)) comments = fuzzy_count(eng.get("comments", 0)) shares = fuzzy_count(eng.get("shares", 0)) eng_tags = ('<span class="eng-tag">👍 ' + likes + '</span>' + '<span class="eng-tag">💬 ' + comments + '</span>' + '<span class="eng-tag">🔄 ' + shares + '</span>') author_fans = item.get("author_fans", "--") pub_date = item.get("published_at", "")[:10] if item.get("published_at") else "--" desc_html = ('<p class="card-desc">' + desc_escaped + '</p>') if desc_escaped else '' rank_num = idx + 1 # 公众号没有粉丝数,不展示粉丝字段 if pkey == "gzh": fans_html = '' else: fans_html = ( ' <span class="dot">·</span>\n' ' <span class="fans">' + str(author_fans) + '粉</span>\n' ) cards += ( '\n <div class="card" style="--card-accent: ' + m_primary + '">\n' ' <div class="card-rank">' + str(rank_num) + '</div>\n' ' <div class="card-body">\n' ' <a ' + url_attr + ' class="card-title" target="_blank">' + title_escaped + '</a>\n' ' <div class="card-meta">\n' ' ' + author_html + '\n' + fans_html + ' <span class="dot">·</span>\n' ' <span class="time">' + pub_date + '</span>\n' ' </div>\n' ' ' + desc_html + '\n' ' <div class="card-footer">\n' ' <div class="engagement">' + eng_tags + '</div>\n' ' <a ' + url_attr + ' class="view-btn" target="_blank">查看原文 ↗</a>\n' ' </div>\n' ' </div>\n' ' </div>' ) no_data_html = "<div class='no-data-hint'><p>未查询到相关内容,建议更换关键词重试。</p></div>" if not items else "" panels_html += ( '\n <div class="tab-panel" id="panel-' + pkey + '" style="display: ' + display + '">\n' ' ' + error_html + '\n' ' <div class="card-list">\n' ' ' + cards + '\n' ' </div>\n' ' ' + no_data_html + '\n' ' </div>' ) report_section = "" if report_html: report_section = f''' <div class="report-section"> <h2 class="section-title">📝 研究报告</h2> <div class="report-content">{report_html}</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>A股每日新闻 · {keyword}</title> <style> * {{ margin: 0; padding: 0; box-sizing: border-box; }} :root {{ --primary: #dc2626; --primary-light: #fef2f2; --text: #1f2937; --text-secondary: #6b7280; --border: #e5e7eb; --bg: #f9fafb; --card-bg: #ffffff; --radius: 12px; }} body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Noto Sans SC', Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }} .navbar {{ background: white; border-bottom: 1px solid var(--border); padding: 12px 24px; position: sticky; top: 0; z-index: 100; display: flex; align-items: center; gap: 12px; }} .navbar .logo {{ font-size: 18px; font-weight: 800; color: var(--primary); letter-spacing: -0.5px; }} .navbar .logo span {{ color: #1e1b4b; }} .navbar .badge {{ background: var(--primary-light); color: var(--primary); font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px; }} .hero {{ background: linear-gradient(135deg, #7f1d1d 0%, #991b1b 50%, #dc2626 100%); color: white; padding: 48px 24px; text-align: center; }} .hero h1 {{ font-size: 28px; font-weight: 800; margin-bottom: 8px; }} .hero .keyword {{ display: inline-block; background: rgba(255,255,255,0.15); border-radius: 8px; padding: 4px 16px; font-size: 16px; margin: 8px 0; }} .hero .date-range {{ font-size: 14px; opacity: 0.8; }} .stats-row {{ display: flex; gap: 16px; padding: 24px; max-width: 960px; margin: -28px auto 0; position: relative; z-index: 10; flex-wrap: wrap; justify-content: center; }} .stat-card {{ background: white; border-radius: var(--radius); padding: 20px 24px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); flex: 1; min-width: 180px; display: flex; align-items: center; gap: 16px; border-left: 4px solid var(--p-color); }} .stat-icon {{ font-size: 32px; }} .stat-label {{ font-size: 13px; color: var(--text-secondary); }} .stat-num {{ font-size: 24px; font-weight: 800; color: var(--text); }} .stat-num small {{ font-size: 13px; font-weight: 400; color: var(--text-secondary); }} .main {{ max-width: 960px; margin: 24px auto; padding: 0 24px; }} .tab-bar {{ display: flex; gap: 4px; background: white; border-radius: var(--radius) var(--radius) 0 0; padding: 8px 8px 0; border: 1px solid var(--border); border-bottom: none; }} .tab-btn {{ padding: 12px 24px; border: none; background: transparent; font-size: 15px; font-weight: 600; cursor: pointer; color: var(--text-secondary); border-radius: 8px 8px 0 0; transition: all 0.2s; position: relative; }} .tab-btn:hover {{ background: var(--bg); color: var(--tab-color); }} .tab-btn.active {{ background: var(--bg); color: var(--tab-color); }} .tab-btn.active::after {{ content: ''; position: absolute; bottom: 0; left: 20%; right: 20%; height: 3px; background: var(--tab-color); border-radius: 3px 3px 0 0; }} .tab-count {{ font-size: 12px; background: var(--bg); padding: 2px 8px; border-radius: 10px; font-weight: 500; margin-left: 4px; }} .tab-btn.active .tab-count {{ background: var(--primary-light); color: var(--tab-color); }} .card-list {{ background: white; border: 1px solid var(--border); border-radius: 0 0 var(--radius) var(--radius); }} .card {{ display: flex; gap: 16px; padding: 20px 24px; border-bottom: 1px solid #f3f4f6; transition: background 0.15s; }} .card:last-child {{ border-bottom: none; }} .card:hover {{ background: #fafbfc; }} .card-rank {{ font-size: 20px; font-weight: 800; color: var(--card-accent); min-width: 32px; text-align: center; padding-top: 2px; opacity: 0.8; }} .card-body {{ flex: 1; min-width: 0; }} .card-title {{ font-size: 16px; font-weight: 600; color: var(--text); text-decoration: none; line-height: 1.5; display: block; transition: color 0.15s; }} .card-title:hover {{ color: var(--card-accent); }} .card-meta {{ font-size: 13px; color: var(--text-secondary); margin-top: 6px; display: flex; align-items: center; gap: 0; flex-wrap: wrap; }} .author-link {{ color: var(--primary); text-decoration: none; font-weight: 500; transition: color 0.15s; }} .author-link:hover {{ color: var(--card-accent); text-decoration: underline; }} .author-name {{ color: var(--primary); font-weight: 500; }} .dot {{ margin: 0 6px; }} .card-desc {{ font-size: 14px; color: var(--text-secondary); line-height: 1.6; margin-top: 8px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }} .card-footer {{ display: flex; justify-content: space-between; align-items: center; margin-top: 10px; }} .engagement {{ display: flex; gap: 8px; flex-wrap: wrap; }} .eng-tag {{ font-size: 12px; color: var(--text-secondary); background: #f3f4f6; padding: 2px 8px; border-radius: 4px; }} .eng-tag.reads {{ background: #ecfdf5; color: #065f46; }} .view-btn {{ font-size: 13px; color: var(--card-accent); text-decoration: none; font-weight: 500; white-space: nowrap; transition: opacity 0.15s; }} .view-btn:hover {{ opacity: 0.7; }} .error-banner {{ background: #fef3c7; color: #92400e; padding: 12px 16px; border-radius: 8px; margin: 16px 24px; font-size: 14px; }} .no-data-hint {{ text-align: center; color: var(--text-secondary); padding: 48px; }} .report-section {{ max-width: 960px; margin: 0 auto; padding: 0 24px; }} .section-title {{ font-size: 20px; font-weight: 800; color: var(--text); margin-bottom: 16px; padding-bottom: 8px; border-bottom: 2px solid var(--primary); }} .report-content {{ background: white; border: 1px solid var(--border); border-radius: var(--radius); padding: 32px; line-height: 1.8; color: var(--text); font-size: 15px; }} .report-content h2 {{ font-size: 20px; font-weight: 800; color: var(--primary); margin: 24px 0 12px; }} .report-content h3 {{ font-size: 18px; font-weight: 700; color: var(--text); margin: 20px 0 10px; }} .report-content h4 {{ font-size: 16px; font-weight: 700; color: var(--text); margin: 16px 0 8px; }} .report-content p {{ margin-bottom: 12px; }} .report-content strong {{ color: #7f1d1d; }} .report-content a {{ color: var(--primary); text-decoration: none; }} .report-content a:hover {{ text-decoration: underline; }} .report-content ul, .report-content ol {{ margin: 8px 0 12px 20px; }} .report-content li {{ margin-bottom: 4px; }} .report-content hr {{ border: none; border-top: 1px solid var(--border); margin: 20px 0; }} .footer {{ text-align: center; color: var(--text-secondary); font-size: 12px; padding: 32px 24px; margin-top: 16px; }} .footer a {{ color: var(--primary); text-decoration: none; }} @media (max-width: 640px) {{ .hero {{ padding: 32px 16px; }} .hero h1 {{ font-size: 20px; }} .stats-row {{ padding: 16px; margin-top: -20px; }} .stat-card {{ min-width: 140px; padding: 14px 16px; }} .stat-num {{ font-size: 20px; }} .main {{ padding: 0 12px; }} .card {{ padding: 14px 16px; gap: 10px; }} .card-rank {{ font-size: 16px; min-width: 24px; }} .card-title {{ font-size: 15px; }} }} </style> </head> <body> <nav class="navbar"> <div class="logo">📈 A股<span>每日新闻</span></div> <div class="badge">v1.0</div> </nav> <div class="hero"> <h1>A股每日新闻</h1> <div class="keyword">{keyword}</div> <div class="date-range">{date_range["from"]} ~ {date_range["to"]}</div> </div> <div class="stats-row"> {stats_html} <div class="stat-card" style="--p-color: var(--primary); --p-bg: var(--primary-light)"> <div class="stat-icon">📊</div> <div class="stat-body"> <div class="stat-label">合计</div> <div class="stat-num">{total} <small>条</small></div> </div> </div> </div> {report_section} <div class="main"> <div class="tab-bar"> {tabs_html} </div> {panels_html} </div> <div class="footer"> 数据来源:<a href="https://redfox.hk" target="_blank">redfox.hk</a> API · 小红书 / 抖音 / 公众号 · A股每日新闻<br> 互动数据为入库快照,实时数据可能持续增长 </div> <script> document.querySelectorAll('.tab-btn').forEach(btn => {{ btn.addEventListener('click', () => {{ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-panel').forEach(p => p.style.display = 'none'); btn.classList.add('active'); document.getElementById('panel-' + btn.dataset.platform).style.display = 'block'; }}); }}); </script> </body> </html>''' return html # ─── CLI ───────────────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser( description="stock-feed: A股每日新闻" ) parser.add_argument( "keyword", nargs="?", default=None, help="搜索关键词(默认使用A股17个核心关键词,--from-json 模式下可省略)" ) parser.add_argument( "--platforms", "-p", default="xhs,dy,gzh", help="平台列表,逗号分隔(默认: xhs,dy,gzh)" ) parser.add_argument( "--count", "-n", type=int, default=DEFAULT_COUNT, help=f"每个平台获取条数(默认: {DEFAULT_COUNT})" ) parser.add_argument( "--days", "-d", type=int, default=DEFAULT_DAYS, help=f"搜索时间范围,最近多少天(默认: {DEFAULT_DAYS},最大: 30)" ) parser.add_argument( "--output-format", "-f", choices=["json", "html", "both"], default="json", help="输出格式(默认: json,综合报告后再按需生成HTML)" ) parser.add_argument( "--output-dir", default=str(Path.home() / "Downloads" / "StockFeed"), help="HTML 输出目录" ) parser.add_argument( "--api-key", default=None, help="API Key(覆盖环境变量和配置文件)" ) parser.add_argument( "--max-items", type=int, default=50, help="输出中最多展示条数(默认: 50)" ) parser.add_argument( "--from-json", default=None, help="从已有 JSON 文件生成 HTML,不调用 API(值: JSON 文件路径)" ) parser.add_argument( "--report-file", default=None, help="研究报告 Markdown 文件路径(嵌入到 HTML 报告顶部)" ) parser.add_argument( "--debug", action="store_true", help="调试模式,打印原始 API 响应" ) args = parser.parse_args() # ── 从 JSON 生成 HTML 模式 ── if args.from_json: json_path = Path(args.from_json) if not json_path.exists(): sys.stderr.write(f"错误: JSON 文件不存在: {json_path}\n") sys.exit(1) raw = json.loads(json_path.read_text(encoding="utf-8")) data = { "keyword": raw.get("keyword", ""), "total_items": raw.get("total_items", 0), "date_range": raw.get("date_range", {}), "platforms": {}, } for pkey, pdata in raw.get("platforms", {}).items(): items = [] for it in pdata.get("items", []): item = dict(it) item["source"] = pkey items.append(item) data["platforms"][pkey] = { "label": pdata.get("label", pkey), "total": pdata.get("total", len(items)), "items": items, } report_html = "" if args.report_file: report_path = Path(args.report_file) if report_path.exists(): report_md = report_path.read_text(encoding="utf-8") report_html = _md_to_html(report_md) else: sys.stderr.write(f"⚠️ 报告文件不存在: {report_path},跳过\n") html_content = format_as_html(data, max_items=args.max_items, report_html=report_html) output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) base_name = json_path.stem html_file = output_dir / f"{base_name}.html" html_file.write_text(html_content, encoding="utf-8") sys.stderr.write(f"✅ HTML 已保存: {html_file}\n") sys.stdout.write(json.dumps({"files": {"html": str(html_file)}}, ensure_ascii=False) + "\n") sys.stdout.flush() return # ── 正常搜索模式 ── platforms = [] for p in args.platforms.split(","): p = p.strip().lower() if p in PLATFORMS: platforms.append(p) else: sys.stderr.write(f"未知平台: {p},可用: {', '.join(PLATFORMS.keys())}\n") if not platforms: sys.stderr.write("错误: 未指定有效平台\n") sys.exit(1) # 使用默认关键词或用户自定义关键词 keyword = args.keyword.strip() if args.keyword else DEFAULT_KEYWORDS sys.stderr.write(f"\n{'='*60}\n") sys.stderr.write(f"📈 A股每日新闻 · 搜索: {keyword}\n") sys.stderr.write(f"平台: {', '.join(PLATFORMS[p]['label'] for p in platforms)}\n") sys.stderr.write(f"每平台: {args.count} 条 | 时间: 近{args.days}天\n") sys.stderr.write(f"{'='*60}\n\n") sys.stderr.flush() try: data = search( keyword=keyword, platforms=platforms, count=args.count, api_key=args.api_key, days=args.days, ) except Exception as e: sys.stderr.write(f"\n❌ 搜索失败: {e}\n") sys.exit(1) output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) keyword_safe = keyword.replace('"', '').replace(' ', '_')[:30] timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") base_name = f"stock_feed_{keyword_safe}_{timestamp}" json_file = None if args.output_format in ("json", "both"): json_data = format_as_json(data, max_items=args.max_items) json_file = output_dir / f"{base_name}.json" json_file.write_text( json.dumps(json_data, ensure_ascii=False, indent=2), encoding="utf-8", ) sys.stderr.write(f"\n✅ JSON 已保存: {json_file}\n") html_file = None if args.output_format in ("html", "both"): html_content = format_as_html(data, max_items=args.max_items) html_file = output_dir / f"{base_name}.html" html_file.write_text(html_content, encoding="utf-8") sys.stderr.write(f"✅ HTML 已保存: {html_file}\n") sys.stderr.write(f"\n{'='*60}\n") sys.stderr.write(f"搜索完成!共 {data['total_items']} 条结果\n") for pkey, pdata in data["platforms"].items(): status = f"✅ {pdata['total']} 条" if not pdata.get("error") else f"❌ {pdata['error']}" sys.stderr.write(f" {pdata['label']}: {status}\n") sys.stderr.write(f"{'='*60}\n") sys.stderr.flush() # stdout 输出精简摘要(关键词截断避免终端截断) keyword_display = keyword if len(keyword) <= 40 else keyword[:40] + f"...(共{len(keyword.split(','))}词)" summary = { "keyword": keyword_display, "date_range": data["date_range"], "total_items": data["total_items"], "platforms": {p: v["total"] for p, v in data["platforms"].items()}, "files": {}, } if json_file: summary["files"]["json"] = str(json_file) if html_file: summary["files"]["html"] = str(html_file) sys.stdout.write(json.dumps(summary, ensure_ascii=False) + "\n") sys.stdout.flush() if __name__ == "__main__": main()
-
-
README.en.md 4.1 KB
# A-Share Daily News / stock-feed --- ## Introduction A one-stop A-share market sentiment research tool that simultaneously searches 17 core A-share keywords across Xiaohongshu (RED), Douyin (TikTok China), and WeChat Official Accounts. It automatically filters out non-stock content, performs cross-platform sentiment comparison, and delivers structured research reports with interactive visual dashboards. **Core Value** - 17 built-in A-share keywords cover all major topics — zero configuration needed - Cross-validate real user data across three platforms to uncover differentiated signals - Automatically filter out beauty, food, travel and other non-stock content - Support flexible time ranges from 1 to 30 days for trend tracking **Who Is It For** - 📈 Retail Investors — Quickly gauge market sentiment and trending sector discussions - 🏦 Financial Researchers — Gather cross-platform sentiment data to support analysis - 📰 Financial Content Creators — Discover high-engagement content for topic ideation --- ## Features ### Core Capabilities - **17-Keyword One-Click Query**: Built-in keywords like A-shares, limit-up, stock picks — no manual input needed - **Three-Platform Data Sources**: Simultaneously pull real user discussions from Xiaohongshu, Douyin, and WeChat Official Accounts - **Smart Content Filtering**: Automatically identify and remove non-stock/investment content for clean data - **Cross-Platform Analysis**: Synthesize data from all three platforms into actionable sentiment insights - **Interactive HTML Reports**: Card-based visual reports with platform filtering and data sorting - **Flexible Time Range**: Default 7 days, customizable from 1 to 30 days for trend tracking - **Custom Keywords**: Search by specific stocks, sectors, or concepts for targeted queries --- ## API Key Acquisition & Security - This skill requires the environment variable: `REDFOX_API_KEY`. - `REDFOX_API_KEY` is provided by [RedFoxHub](https://redfox.hk/settings/api-keys?source=github) (`https://redfox.hk`). - Please visit [RedFoxHub](https://redfox.hk?source=github) to register and obtain your `REDFOX_API_KEY`. - Configure the `REDFOX_API_KEY` environment variable on your device before using this skill. - Before providing a key, verify the source, scope, validity period, and whether reset/revocation is supported. - Never hardcode or expose keys in code, prompts, logs, or output files. --- ## Usage Guide Simply describe what you need in natural language — no commands to memorize. ### Quick Reference | Intent | Example Phrases | Result | | --- | --- | --- | | Check latest A-share sentiment | "Show me the latest data" | Search all 17 built-in keywords across 3 platforms for the past 7 days | | Query a specific stock | "Show me Tencent-related news" | Targeted search for Tencent stock discussions | | Query a specific sector | "Latest news on semiconductors and chips" | Search sentiment by specified concept or sector | | Expand time range | "Show me A-share sentiment for the past 30 days" | Search one month of data to track trend changes | | Compare two stocks | "BYD vs Tesla sentiment comparison" | Cross-platform comparison of discussion heat and reputation | ### Output Preview > Reports begin with a "Data Overview" section showing TOP 5 articles per platform in table format (with clickable title links, authors, and platform-specific engagement metrics), followed by core findings, actionable predictions, risk disclaimers, and an auto-generated interactive HTML report. --- ## Use Cases | Scenario | Role | Example Query | Benefit | | --- | --- | --- | --- | | Post-Market Review | Retail Investor | "How's the A-share market today?" | Quickly understand daily market sentiment and hot sectors | | Individual Stock Research | Financial Researcher | "Check CATL's sentiment" | Get cross-platform discussion heat and sentiment for a specific stock | | Sector Tracking | Content Creator | "Latest AI sector news" | Discover high-engagement content for topic ideation | | Trend Analysis | Institutional Investor | "A-share sentiment trends over 30 days" | Track sentiment evolution to support investment decisions | --- -
README.md 3.6 KB
# A股每日新闻 / stock-feed --- ## 简介 一站式 A 股舆情研究工具,从小红书、抖音、公众号三大平台同时搜索 17 个 A 股核心关键词,自动过滤非股票内容,跨平台对比分析股市舆情趋势,输出结构化研究报告和可视化页面。 **核心价值** - 17 个 A 股关键词一键覆盖全话题,零配置即可启动 - 三平台真实数据交叉验证,发现差异化舆情信号 - 自动过滤美妆、美食、旅游等非股票内容,只保留投资相关讨论 - 支持近 30 天任意时间范围回溯,追踪盘中与周度变化 **适用对象** - 📈 散户投资者 — 快速了解市场情绪与热门板块讨论 - 🏦 财经研究员 — 获取跨平台舆情数据支撑研报分析 - 📰 财经自媒体 — 发现高互动内容素材与选题方向 --- ## 功能特性 ### 核心功能 - **17 词一键查询**:内置 A 股、涨停、涨跌、选股、加仓等核心关键词,无需手动输入 - **三平台数据源**:同时获取小红书、抖音、公众号真实用户讨论数据 - **智能内容过滤**:自动识别并剔除非股票/投资相关内容,确保数据纯净 - **跨平台对比分析**:综合三平台数据生成舆情洞察,多信号交叉验证 - **可视化 HTML 报告**:交互式卡片报告,支持按平台筛选、数据排序 - **灵活时间范围**:默认近 7 天,支持自定义 1~30 天,追踪趋势演变 - **自定义关键词**:可指定个股、板块或概念名称进行定向查询 --- ## 密钥获取与安全说明 - 本技能需要使用环境变量:`REDFOX_API_KEY`。 - `REDFOX_API_KEY` 由 [红狐 hub](https://redfox.hk/settings/api-keys?source=github) (`https://redfox.hk`)提供。 - 请前往 [红狐 hub](https://redfox.hk?source=github) 注册账号,获取 `REDFOX_API_KEY`。 - 配置设备环境变量 `REDFOX_API_KEY` 后使用本技能。 - 在提供密钥前,请先确认密钥来源、可用范围、有效期及是否支持重置/撤销。 - 禁止在代码、提示词、日志或输出文件中硬编码/明文暴露密钥。 --- ## 使用指南 直接用自然语言描述需求,无需记忆命令。 ### 常用说法速查 | 意图 | 示例话术 | 效果 | | --- | --- | --- | | 查看最新 A 股舆情 | "看看最新数据" | 使用 17 个内置关键词,搜索近 7 天三平台数据 | | 查询特定个股 | "看看腾讯相关报道" | 定向搜索腾讯相关股票讨论 | | 查询特定板块 | "半导体、芯片最新消息" | 按指定概念/板块搜索舆情 | | 扩大时间范围 | "看看近 30 天 A 股舆情" | 搜索近一个月数据,追踪趋势变化 | | 对比两只个股 | "比亚迪 vs 特斯拉舆情对比" | 跨平台对比两个标的的讨论热度与口碑 | ### 输出示例 > 报告以「数据速览」模块开头,按公众号→小红书→抖音顺序展示各平台 TOP5 作品表格(含标题超链接、作者、平台专属互动指标),随后输出核心发现、操作要点预判与风险说明,最后自动生成并打开可视化 HTML 报告。 --- ## 使用场景 | 场景 | 角色 | 示例问法 | 收益 | | --- | --- | --- | --- | | 盘后复盘 | 散户投资者 | "今天 A 股什么情况" | 快速了解当日市场情绪与热门板块 | | 个股研究 | 财经研究员 | "看看宁德时代的舆情" | 获取个股跨平台讨论热度与正负面情绪 | | 板块追踪 | 财经自媒体 | "AI 板块最新消息" | 发现高互动内容素材,辅助选题 | | 趋势分析 | 机构投资者 | "近 30 天 A 股舆情变化" | 追踪舆情趋势演变,辅助投资决策 | --- -
SKILL.md 6.6 KB
--- name: stock-feed version: "1.0.0" description: "A股每日新闻。从小红书、抖音、公众号三大平台搜索A股相关短讯,内置17个A股核心关键词一次性查询,默认近7天数据,自动过滤非股票内容,跨平台对比分析股市舆情。当用户需要研究A股舆情、股市讨论、大盘分析、选股策略、涨跌复盘时使用。触发词:A股、A股舆情、股市新闻、大盘分析、涨停、选股、A股复盘、股票讨论、股市热点。" argument-hint: 'stock-feed | stock-feed --days 30' allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch homepage: https://github.com/redfox-data/redfox-community/stock-feed-skill repository: https://github.com/redfox-data/redfox-community author: redfox-community license: MIT user-invocable: true metadata: openclaw: emoji: "📈" requires: env: [] optionalEnv: - REDFOX_API_KEY bins: - python3 primaryEnv: REDFOX_API_KEY files: - "scripts/*" tags: - research - a-stock - stock-market - xiaohongshu - douyin - wechat - gzh - trends - social-media - analysis --- # A股每日新闻 ## 📝 简介 A股每日新闻是中国股市舆情研究工具,从小红书、抖音、公众号三大平台一次性搜索17个A股核心关键词(A股、A股市场、A股大盘、涨停、涨跌、潜力股、选股、加仓等),默认拉取近7天真实讨论数据,自动过滤非股票/投资相关内容,跨平台对比分析股市舆情趋势,输出结构化研究报告和可视化 HTML。 ## ✨ 功能特性 | 功能模块 | 能力描述 | 核心价值 | |---------|---------|---------| | 17词一键查询 | 内置A股核心关键词,一次覆盖全话题 | 零配置即用 | | 三平台数据源 | 小红书、抖音、公众号真实数据 | 多维度舆情视角 | | 跨平台对比 | 自动综合三平台数据生成洞察 | 发现差异化趋势 | | 灵活时间范围 | 默认7天,支持自定义1~30天 | 追踪盘中/周度变化 | | HTML 报告 | 交互式可视化报告生成 | 便于分享传播 | | 历史回溯 | 支持近30天任意日期数据 | 追踪趋势演变 | ## 🔑 鉴权 脚本需配置 API Key 使用。从 [红狐数据](https://www.redfox.hk/settings/api-keys?source=github) 获取个人 Key 并配置环境变量: ```bash export REDFOX_API_KEY=ak_你的密钥 ``` 优先级:命令行 `--api-key` > `REDFOX_API_KEY` / `X_API_KEY` 环境变量 > 配置文件。 --- ## 核心参数 | 参数 | 说明 | 默认值 | |------|------|--------| | `keyword` | 搜索关键词(可选,不传则使用内置17个A股关键词) | A股,A股市场,A股大盘,... 共17词 | | `--platforms` | 平台列表(不建议缩减) | `xhs,dy,gzh` | | `--count` | 每平台条数 | `50` | | `--days` | 时间范围(默认7天,用户指定时按需传入) | `7` | | `--output-format` | `json` / `html` / `both` | `json` | | `--output-dir` | 输出目录 | `~/Downloads/StockFeed` | **平台信号解读:** | 平台 | 内容特征 | 关键指标 | |------|---------|---------| | 小红书 (xhs) | 散户分享、炒股心得、入门教程 | 收藏/点赞比高=实用信号 | | 抖音 (dy) | 盘中速评、涨停解读、情绪传播 | 分享数=传播力 | | 公众号 (gzh) | 深度复盘、策略分析、行业研报 | 阅读量=关注度,分享=认同 | --- ## 工作流程 ### 1. 环境检查 运行前需确保已配置 API Key(环境变量 `REDFOX_API_KEY` 或 `--api-key` 参数)。如未配置,提示用户: > 请配置 API Key:`export REDFOX_API_KEY=ak_你的密钥`,注册地址 https://www.redfox.hk/login ### 2. 关键词处理 - **默认行为**:不传 keyword 参数,脚本自动使用内置17个A股核心关键词 - **用户自定义**:用户明确指定关键词时,传入 `--keyword` 参数覆盖默认值 - 检查关键词是否过于模糊,若是则请用户具体化 ### 3. 预研究 调用引擎前,并行运行 2-3 个 WebSearch 提取热词和背景: - `A股 {TOPIC} 小红书 热门讨论` → 提取小红书热词 - `A股 {TOPIC} 抖音 热门话题` → 提取抖音标签 - `A股 {TOPIC} 最新动态` → 补充时效背景 ### 4. 运行引擎 ```bash python3 scripts/stock_feed.py \ --platforms xhs,dy,gzh \ --count 50 \ --days 7 \ --output-format json \ --output-dir="${STOCK_FEED_MEMORY_DIR:-$HOME/Downloads/StockFeed}" ``` 用户指定关键词时: ```bash python3 scripts/stock_feed.py "半导体,芯片" \ --platforms xhs,dy,gzh \ --count 50 \ --days 7 \ --output-format json \ --output-dir="${STOCK_FEED_MEMORY_DIR:-$HOME/Downloads/StockFeed}" ``` **前台运行,5分钟超时,不要后台。** 读取完整输出,包含三平台的标题、作者、互动数据和链接。 ### 5. WebSearch 补充 引擎完成后补充 1-2 个 WebSearch,覆盖雪球、东方财富、同花顺等引擎未覆盖的来源。排除 xiaohongshu.com / douyin.com / mp.weixin.qq.com(已被引擎覆盖)。 ### 6. 综合输出 按 [输出规则](references/output-rules.md) 生成研究报告。核心原则: - **徽章之后、标题之前**,必须输出「数据速览」模块:按公众号→小红书→抖音顺序,每平台展示 TOP5 作品表格(标题作为可点击超链接、作者、平台专属互动指标分列展示(公众号阅读/点赞/转发,小红书点赞/收藏/评论,抖音点赞/评论/分享)),模块末尾展示数据总量和 HTML 报告提示 - 报告以一级标题 `# A股每日新闻` 开头 - 按话题/故事综合,非按平台罗列 - 多平台交叉验证的结论置信度最高 - 高互动内容权重最高(含真实用户信号) - 每个叙事段落以粗体标题开头,后跟 ` - ` 和正文 - **每条发现必须引用至少2个不同平台的来源**(如:小红书+抖音、公众号+小红书) - 引用必须为可点击的 Markdown 链接 - 核心发现之后必须附「操作要点预判」和「风险说明」两个固定区块 综合报告完成后,**自动执行**以下步骤生成 HTML 报告(无需询问用户): ```bash # 直接从 JSON 生成 HTML(不调 API,不写入 Markdown) python3 scripts/stock_feed.py --from-json "JSON文件路径" \ --output-dir="${STOCK_FEED_MEMORY_DIR:-$HOME/Downloads/StockFeed}" # 自动打开 HTML 报告 open "HTML文件路径" ``` 脚本会输出 HTML 文件路径,自动打开后将路径告知用户即可。 --- ## 其他资源 - [输出规则与模板](references/output-rules.md) - 输出规则(LAW)、通用/对比查询输出格式模板 - [脚本文件](scripts/stock_feed.py) - Python 引擎(Python 3.8+,仅标准库)
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.