@clawhub-yoocky-c307f8087e
提供 A股、港股、期货实时行情查询,股票/期货搜索及A股板块热门涨跌榜信息。
# Qutedance Quotes - 行情查询技能
## 简介
基于 Qutedance 的 quotedance-service 行情接口,提供:
- A 股 / 港股 / 期货 实时行情查询
- A 股板块热门涨跌信息(涨跌幅榜单)
- 股票/期货等标的搜索(支持按名称/代码模糊搜索)
适配你的 qutedance 工作区,用于在对话中快速查看关键标的和板块表现。
---
## 配置
- 行情服务(quotedance-service):
- 当前已直接指向你的线上实例:
- `serviceUrl: "https://quotedance.api.gapgap.cc"`
- Qutedance API Key:
- 为了简单易学,**直接写在配置文件中**:
- `apiKey: ""`
- 如需更安全的方式,可以以后再改成环境变量。
配置文件:`skills/qutedance-quotes/config.json`
```json
{
"serviceUrl": "https://quotedance.api.gapgap.cc",
"apiKey": "",
"defaults": {
"type": "cn",
"topPlatesCount": 10
}
}
```
---
## 能力
### 1️⃣ A 股 / 期货 / 港股 行情查询
- A 股:`type=cn`
- 港股:`type=hk`
- 期货:`type=futures`(默认)
脚本会调用:
- `GET /quotes/?list=CODE1,CODE2&type=cn|hk|futures`
输出内容包括:
- 代码、名称
- 最新价
- 涨跌幅(相对昨收价或结算价)
- 最高价 / 最低价
- 买一 / 卖一
### 2️⃣ A 股板块热门涨跌榜
- 接口:`GET /quotes/plate-top-info?count=N`
- 展示:
- 板块名称、平均涨跌幅(core_avg_pcp)
- 领涨股票(symbol, name, 涨跌幅、价格变动)
- 领跌股票(同上)
### 3️⃣ 标的搜索(股票 / 期货等)
- 接口:`GET /quotes/search`
- 支持参数:
- `q`: 搜索关键词(如“平安”)
- `type`: 市场类型(`cn` / `hk` / `futures` / `us` / `all` 等)
- `limit`: 返回数量上限(默认 20)
- 输出:
- 代码、名称、市场、交易所
---
## 在对话中如何使用
当用户说到:
- “看下 A 股 000001、600000 的行情”
- “查一下 M2605 和 RB2605 的期货报价”
- “看看今天 A 股涨跌幅榜、热门板块”
- “搜一下平安相关的 A 股有哪些”
Agent 应该:
1. 选用本技能 `qutedance-quotes`
2. 根据语义决定调用模式:
- 指定代码 → 调用 `/quotes/` 行情查询
- 想看涨跌榜/热门板块 → 调用 `/quotes/plate-top-info`
3. 将脚本输出的 Markdown 表格/列表直接呈现给用户,必要时附加解释。
---
## 手动脚本用法
从 `workspace-quotedance` 目录运行:
```bash
cd ~/.openclaw/workspace-quotedance
# A 股行情
node skills/qutedance-quotes/scripts/qutedance-quotes.js --type cn --list 000001,600000
# 期货行情
node skills/qutedance-quotes/scripts/qutedance-quotes.js --type futures --list M2605,RB2605
# A 股板块涨跌幅榜(前 10 个)
node skills/qutedance-quotes/scripts/qutedance-quotes.js --plates 10
# 搜索标的(按名称模糊搜索)
node skills/qutedance-quotes/scripts/qutedance-quotes.js --search --q 平安 --type cn --limit 10
```
---
## 实现细节
目录结构:
```text
skills/qutedance-quotes/
├── SKILL.md
├── config.json
└── scripts/
└── qutedance-quotes.js
```
脚本行为概要:
- 从 `SERVICE_URL` 和 `QUTEDANCE_API_KEY` 读取 quotedance-service 访问配置
- `getQuotes(list, type)`:
- 调用 `/quotes/` 接口
- 将结果格式化为 Markdown 表格
- `getPlateTopInfo(count)`:
- 调用 `/quotes/plate-top-info`
- 生成板块及其领涨/领跌股的列表说明
---
## 注意事项
- 请确保 quotedance-service 正常运行(或云端实例可访问)
- API Key 应通过环境变量配置,而不是写死在仓库文件中
- 行情数据仅供参考,不构成任何投资建议
FILE:config.json
{
"serviceUrl": "https://quotedance.api.gapgap.cc",
"apiKey": "",
"defaults": {
"type": "cn",
"topPlatesCount": 10
}
}
FILE:scripts/qutedance-quotes.js
#!/usr/bin/env node
/**
* Qutedance Quotes Skill
*
* 利用 quotedance-service 提供的行情 API:
* - 查看 A 股 / 期货 / 港股 实时报价
* - 查看 A 股板块热门信息(涨跌幅榜单)
*
* 基础 API 参考:project/quotedance-service/docs/api.md
*/
const fs = require('fs');
const path = require('path');
const SKILL_DIR = path.resolve(__dirname, '..');
const CONFIG = require(path.join(SKILL_DIR, 'config.json'));
const SERVICE_URL =
CONFIG.serviceUrl ||
'http://localhost:5000';
// API Key:优先使用 config.json 里的 apiKey,其次才看环境变量
// 这样对你来说“看文件就会改”,更简单易学
const API_KEY =
CONFIG.apiKey ||
process.env.QUTEDANCE_API_KEY ||
'';
function log(msg) {
const ts = new Date().toISOString();
console.log('[' + ts + '] ' + msg);
}
async function fetchJson(pathname, params = {}) {
const url = new URL(SERVICE_URL.replace(/\/+$/, '') + pathname);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, String(value));
}
});
const headers = {
Accept: 'application/json'
};
if (API_KEY) {
// quotedance-service 目前支持通过 X-API-Key 进行鉴权
headers['X-API-Key'] = API_KEY;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try {
const res = await fetch(url.toString(), {
method: 'GET',
headers,
signal: controller.signal
});
clearTimeout(timeout);
if (!res.ok) {
throw new Error('HTTP ' + res.status + ' ' + (await res.text()));
}
return await res.json();
} catch (e) {
clearTimeout(timeout);
throw e;
}
}
function formatPercent(x) {
if (x === null || x === undefined || isNaN(x)) return 'N/A';
const pct = x * 100;
const sign = pct > 0 ? '+' : '';
return sign + pct.toFixed(2) + '%';
}
function formatNumber(x, digits = 2) {
if (x === null || x === undefined || isNaN(x)) return 'N/A';
return Number(x).toFixed(digits);
}
async function getQuotes(list, type) {
const data = await fetchJson('/quotes/', {
list,
type
});
const codes = list.split(',').map(s => s.trim()).filter(Boolean);
const lines = [];
lines.push('### 行情报价');
lines.push('');
lines.push('| 代码 | 名称 | 最新价 | 涨跌幅 | 最高 | 最低 | 买一 | 卖一 |');
lines.push('|------|------|--------|--------|------|------|------|------|');
codes.forEach(code => {
const q = data[code];
if (!q) {
lines.push(`| code | - | - | - | - | - | - | - |`);
return;
}
if (type === 'cn' || type === 'hk') {
const now = q.now ?? q.current;
const change =
q.close && now ? (now - q.close) / q.close : undefined;
lines.push(
`| code | q.name || '' | formatNumber(now) | formatPercent(
change
) | formatNumber(q.high) | formatNumber(
q.low
) | formatNumber(q.buy) | formatNumber(q.sell) |`
);
} else {
// futures
const now = q.current;
const change = q.change; // 已经是比例
lines.push(
`| code | q.name || q.commodity_name || '' | formatNumber(
now
) | formatPercent(change) | formatNumber(
q.high
) | formatNumber(q.low) | formatNumber(
q.buy
) | formatNumber(q.sell) |`
);
}
});
return lines.join('\n');
}
async function getPlateTopInfo(count) {
const data = await fetchJson('/quotes/plate-top-info', {
count: count || CONFIG.defaults.topPlatesCount || 10
});
const plates = data.plates || [];
if (!plates.length) return '当前没有获取到板块数据。';
const lines = [];
lines.push('### A股板块涨跌幅榜');
lines.push('');
plates.forEach(plate => {
const name = plate.plate_name;
const pct = plate.core_avg_pcp;
const pctStr = formatPercent(pct);
const direction = pct > 0 ? '🚀 领涨' : pct < 0 ? '📉 领跌' : '⚖️ 平稳';
lines.push(`- direction 板块:**name**(平均涨跌幅:pctStr)`);
const risers = plate.led_rising_stocks || [];
const fallers = plate.led_falling_stocks || [];
if (risers.length) {
const r = risers[0];
lines.push(
` - 领涨:r.stock_chi_name (r.symbol) formatPercent(
r.change_percent
),价格变动 formatNumber(r.price_change)`
);
}
if (fallers.length) {
const f = fallers[0];
lines.push(
` - 领跌:f.stock_chi_name (f.symbol) formatPercent(
f.change_percent
),价格变动 formatNumber(f.price_change)`
);
}
lines.push('');
});
return lines.join('\n');
}
async function searchQuotes(query, type, limit) {
const data = await fetchJson('/quotes/search', {
q: query || '',
type: type || 'all',
limit: limit || 20
});
if (!Array.isArray(data) || data.length === 0) {
return '未找到匹配的标的。';
}
const lines = [];
lines.push('### 标的搜索结果');
lines.push('');
lines.push('| 代码 | 名称 | 市场 | 交易所 |');
lines.push('|------|------|------|--------|');
data.forEach(item => {
lines.push(
`| item.code || '-' | item.name || '-' | item.market || '-' | item.exchange || '-' |`
);
});
return lines.join('\n');
}
async function main() {
const args = process.argv.slice(2);
let mode = 'quotes'; // 'quotes' | 'plates' | 'search'
let type = CONFIG.defaults.type || 'cn';
let list = '';
let platesCount = CONFIG.defaults.topPlatesCount || 10;
let searchQuery = '';
let searchLimit = 20;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--type' && i + 1 < args.length) {
type = args[++i];
} else if (arg === '--list' && i + 1 < args.length) {
list = args[++i];
} else if ((arg === '--search' || arg === '-s')) {
mode = 'search';
} else if ((arg === '--q' || arg === '--query') && i + 1 < args.length) {
searchQuery = args[++i];
} else if (arg === '--limit' && i + 1 < args.length) {
searchLimit = parseInt(args[++i], 10) || searchLimit;
} else if (arg === '--plates') {
mode = 'plates';
if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
platesCount = parseInt(args[++i], 10) || platesCount;
}
}
}
try {
if (mode === 'plates') {
log('获取 A 股板块热门信息...');
const out = await getPlateTopInfo(platesCount);
console.log(out);
return;
}
if (mode === 'search') {
log('搜索标的...');
const out = await searchQuotes(searchQuery, type, searchLimit);
console.log(out);
return;
}
if (!list) {
console.error(
'用法示例:\n' +
' 查看 A 股: node qutedance-quotes.js --type cn --list 000001,600000\n' +
' 查看期货: node qutedance-quotes.js --type futures --list M2605,RB2605\n' +
' 查看板块榜单: node qutedance-quotes.js --plates 10\n' +
' 搜索标的: node qutedance-quotes.js --search --q 平安 --type cn --limit 10'
);
process.exit(1);
}
log(`获取 type 行情,代码:list`);
const out = await getQuotes(list, type);
console.log(out);
} catch (e) {
console.error('获取行情数据失败:', e.message || e);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = {
getQuotes,
getPlateTopInfo,
searchQuotes,
main
};
聚合已启用订阅源的RSS资讯,支持按源名关键词过滤,返回最近几天内最新文章的Markdown列表,含本地缓存。
## RSS Digest - 资讯流聚合技能
基于你自己的 **quotedance-service Feeds API** 和本地 `RSSHub (http://localhost:1200)`,为 Agent 提供一个「最近几天资讯流」的聚合能力。
- 默认:先从 quotedance-service 获取你已启用的订阅源,再由本地逐个抓取 RSS,生成 Markdown 资讯流。
- 可选:支持按 **资讯源名称关键字** 过滤,例如“只看 少数派 / 机核网 / 某个专栏”。
- 支持简单的 **本地缓存**,在配置的 TTL 内重复调用不会频繁打服务端和 RSS 源站。
---
### 配置
配置文件:`skills/quotedance-rss-digest/config.json`
```json
{
"serviceUrl": "https://quotedance.api.gapgap.cc",
"apiKey": "与 qutedance-quotes 相同的 API Key",
"rsshubUrl": "http://localhost:1200",
"defaults": {
"recentDays": 3,
"limit": 100,
"cacheTtlMinutes": 30,
"sourceCacheTtlMinutes": 15
}
}
```
- **serviceUrl**:quotedance-service 的线上地址(与你现在使用的一致)
- **apiKey**:和 `qutedance-quotes/config.json` 中一致,通过 `X-API-Key` 鉴权
- **rsshubUrl**:你本地 RSSHub 服务地址(当订阅源 `rss_url` 为相对路径时会自动拼接)
- **defaults.recentDays**:默认拉取「最近几天」的天数
- **defaults.limit**:最终输出的最大文章数量
- **defaults.cacheTtlMinutes**:缓存有效期,分钟
- **defaults.sourceCacheTtlMinutes**:订阅源列表缓存有效期,分钟(默认建议 15)
---
### 能力
#### 1️⃣ 全部订阅源资讯流
- 调用 quotedance-service:
- `GET /feeds/registry`
- 通过 `X-API-Key` 自动识别用户,返回该用户 **已启用的订阅源列表**
- 脚本在本地做:
- 逐个抓取订阅源的 `rss_url`(支持 RSS / Atom)
- 本地聚合并去重(优先按链接去重)
- 只保留最近 `recentDays` 天内的文章
- 截断为最多 `limit` 条
- 格式化为 Markdown 列表(标题、来源、时间、链接)
- 结果写入简单缓存文件(同一参数组合在 TTL 内重复调用直接命中缓存)
#### 2️⃣ 按资讯源名称筛选
- 用户可以在对话中描述:
- “看下少数派最近几天的文章”
- “只看机核网的更新”
- 脚本会:
- 先从 `/feeds/registry` 拉取该用户订阅源
- 本地抓取这些订阅源的 RSS 内容并聚合
- 在本地用 **资讯源名称关键字** 做模糊过滤(例如匹配 `source_name` / `feed_title` / `source.name` 等字段)
- 同样支持最近 N 天过滤与缓存
> 注意:由于服务端的数据结构可能调整,脚本在取“来源名称”时做了多种字段兜底,只要大致有个 source/name/title 字段就能工作。
---
### 在对话中如何使用
当用户说:
- “帮我汇总下最近几天的资讯流”
- “看下我订阅源最近 3 天有啥值得看的”
- “只看 少数派 的更新”
- “汇总最近 5 天我所有订阅源的新文章”
Agent 应该:
1. 选择本技能 `rss-digest`
2. 推断参数:
- 若用户没有提天数 → 使用 `defaults.recentDays`
- 若用户提到“最近 N 天 / 两三天 / 一周左右” → 转成具体天数
- 若提到具体资讯源名称 → 作为 name 关键字过滤
3. 调用脚本生成 Markdown 资讯流,并直接展示给用户,必要时做少量总结。
---
### 手动脚本用法
从 `workspace-quotedance` 目录运行:
```bash
cd ~/.openclaw/workspace-quotedance
# 默认:最近 defaults.recentDays 天,全部源
node skills/quotedance-rss-digest/scripts/rss-digest.js
# 指定最近 N 天
node skills/quotedance-rss-digest/scripts/rss-digest.js --days 5
# 只看某个资讯源(按名称关键字模糊匹配)
node skills/quotedance-rss-digest/scripts/rss-digest.js --name 少数派
# 同时指定天数和输出数量
node skills/quotedance-rss-digest/scripts/rss-digest.js --days 7 --limit 50
# 强制刷新(忽略文章缓存和订阅源缓存)
node skills/quotedance-rss-digest/scripts/rss-digest.js --refresh
# 只刷新订阅源列表(文章缓存仍按原规则)
node skills/quotedance-rss-digest/scripts/rss-digest.js --refresh-sources
# 清空缓存并立即重新抓取
node skills/quotedance-rss-digest/scripts/rss-digest.js --clear-cache
```
---
### 目录结构
```text
skills/quotedance-rss-digest/
├── SKILL.md
├── config.json
├── scripts/
│ └── rss-digest.js
└── memory/
└── rss-cache-*.json # 本地缓存(脚本运行时自动创建)
```
---
### 实现概要
- 从 `config.json` 读取:
- `serviceUrl` / `apiKey` / `rsshubUrl` / `defaults`
- 请求 quotedance-service:
- 使用 `X-API-Key` 做鉴权
- 调用 `/feeds/registry` 获取当前用户已启用订阅源
- 本地处理:
- 先读取订阅源缓存(`sourceCacheTtlMinutes`),超时后重新请求 `/feeds/registry`
- 对每个订阅源抓取 RSS/Atom(`rss_url` 为相对路径时会拼接 `rsshubUrl`)
- 聚合、去重并按发布时间倒序排序
- 根据 `recentDays` 过滤最近几天的文章
- 可选按 `name` 关键字过滤资讯源
- 限制输出为 `limit` 条
- 生成结构化 Markdown 文本
- 结合 `cacheTtlMinutes` 做文章缓存(按 days + name + limit 组合区分)
FILE:config.json
{
"serviceUrl": "https://quotedance.api.gapgap.cc",
"apiKey": "",
"rsshubUrl": "http://localhost:1200",
"defaults": {
"recentDays": 3,
"limit": 100,
"cacheTtlMinutes": 30,
"sourceCacheTtlMinutes": 15
}
}
FILE:scripts/rss-digest.js
#!/usr/bin/env node
/**
* RSS Digest Skill
*
* 利用 quotedance-service 的 Feeds API:
* - 聚合当前用户订阅源的最新文章
* - 支持“最近 N 天”过滤
* - 支持按资讯源名称关键字过滤
* - 本地文件缓存,避免频繁打接口
*/
const fs = require('fs');
const path = require('path');
const SKILL_DIR = path.resolve(__dirname, '..');
const CONFIG = require(path.join(SKILL_DIR, 'config.json'));
const SERVICE_URL =
CONFIG.serviceUrl ||
'http://localhost:5000';
const API_KEY =
CONFIG.apiKey ||
process.env.QUTEDANCE_API_KEY ||
'';
const MEMORY_DIR = path.join(SKILL_DIR, 'memory');
const SOURCE_CACHE_FILE = path.join(MEMORY_DIR, 'rss-source-cache.json');
if (!fs.existsSync(MEMORY_DIR)) {
fs.mkdirSync(MEMORY_DIR, { recursive: true });
}
function log(msg) {
const ts = new Date().toISOString();
console.log('[' + ts + '] ' + msg);
}
function formatDateTime(d) {
if (!(d instanceof Date) || isNaN(d.getTime())) return '未知时间';
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const h = String(d.getHours()).padStart(2, '0');
const min = String(d.getMinutes()).padStart(2, '0');
return y + '-' + m + '-' + day + ' ' + h + ':' + min;
}
async function fetchJson(pathname, params = {}) {
const url = new URL(SERVICE_URL.replace(/\/+$/, '') + pathname);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, String(value));
}
});
const headers = {
Accept: 'application/json'
};
if (API_KEY) {
headers['X-API-Key'] = API_KEY;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20000);
try {
const res = await fetch(url.toString(), {
method: 'GET',
headers,
signal: controller.signal
});
clearTimeout(timeout);
if (!res.ok) {
throw new Error('HTTP ' + res.status + ' ' + (await res.text()));
}
return await res.json();
} catch (e) {
clearTimeout(timeout);
throw e;
}
}
function decodeHtmlEntities(text) {
if (!text) return '';
return String(text)
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(///g, '/')
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)));
}
function stripHtml(text) {
return decodeHtmlEntities(String(text || '').replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
}
function pickTag(block, tagName) {
const re = new RegExp('<' + tagName + '(?:\\s[^>]*)?>([\\s\\S]*?)<\\/' + tagName + '>', 'i');
const m = block.match(re);
return m ? decodeHtmlEntities(m[1].trim()) : '';
}
function pickAtomLink(block) {
const m = block.match(/<link\b[^>]*href="([^"]+)"[^>]*\/?>/i) || block.match(/<link\b[^>]*href='([^']+)'[^>]*\/?>/i);
return m ? decodeHtmlEntities(m[1].trim()) : '';
}
function parseRssOrAtom(xmlText, source) {
const text = String(xmlText || '');
const isAtom = /<feed\b/i.test(text);
const blocks = isAtom
? text.match(/<entry\b[\s\S]*?<\/entry>/gi) || []
: text.match(/<item\b[\s\S]*?<\/item>/gi) || [];
return blocks.map(block => {
const title = pickTag(block, 'title');
const link = isAtom
? (pickAtomLink(block) || pickTag(block, 'id'))
: (pickTag(block, 'link') || pickTag(block, 'guid'));
const publishedAt =
pickTag(block, 'pubDate') ||
pickTag(block, 'published') ||
pickTag(block, 'updated') ||
pickTag(block, 'dc:date');
const rawSummary =
pickTag(block, 'description') ||
pickTag(block, 'summary') ||
pickTag(block, 'content:encoded') ||
pickTag(block, 'content');
const summary = stripHtml(rawSummary);
return {
title: title || '(无标题)',
link,
published_at: publishedAt || '',
source_name: source.name || '',
source_id: source.id || '',
source_category: source.category || '',
summary
};
});
}
function normalizeRssUrl(rawUrl) {
const value = String(rawUrl || '').trim();
if (!value) return '';
if (/^https?:\/\//i.test(value)) return value;
const rsshubBase = String(CONFIG.rsshubUrl || '').trim().replace(/\/+$/, '');
if (!rsshubBase) return '';
if (value.startsWith('/')) return rsshubBase + value;
return rsshubBase + '/' + value;
}
async function fetchTextByUrl(url) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20000);
try {
const res = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml;q=0.9, */*;q=0.8'
},
signal: controller.signal
});
clearTimeout(timeout);
if (!res.ok) {
throw new Error('HTTP ' + res.status + ' ' + (await res.text()));
}
return await res.text();
} catch (e) {
clearTimeout(timeout);
throw e;
}
}
async function mapWithConcurrency(items, concurrency, iterator) {
const ret = [];
let index = 0;
const workers = Array(Math.max(1, concurrency)).fill(null).map(async () => {
while (true) {
const i = index++;
if (i >= items.length) return;
ret[i] = await iterator(items[i], i);
}
});
await Promise.all(workers);
return ret;
}
function getSourceName(article) {
if (!article || typeof article !== 'object') return '';
return (
article.source_name ||
article.sourceTitle ||
article.feed_title ||
(article.source && (article.source.name || article.source.title)) ||
article.source ||
''
);
}
function getArticleUrl(article) {
if (!article || typeof article !== 'object') return '';
return (
article.url ||
article.link ||
(article.source && article.source.url) ||
''
);
}
function parseArticleDate(article) {
if (!article || typeof article !== 'object') return null;
const candidates = [
article.published_at,
article.publishedAt,
article.pubDate,
article.date,
article.created_at,
article.updated_at
].filter(Boolean);
for (const s of candidates) {
const d = new Date(s);
if (!isNaN(d.getTime())) return d;
}
return null;
}
function dedupeArticles(articles) {
const seen = new Set();
const out = [];
for (const a of Array.isArray(articles) ? articles : []) {
const link = getArticleUrl(a).trim();
const title = String(a.title || '').trim();
const d = parseArticleDate(a);
const dateKey = d ? d.toISOString() : '';
const key = link || (title + '|' + dateKey);
if (!key || seen.has(key)) continue;
seen.add(key);
out.push(a);
}
out.sort((a, b) => {
const da = parseArticleDate(a);
const db = parseArticleDate(b);
return (db ? db.getTime() : 0) - (da ? da.getTime() : 0);
});
return out;
}
function getCacheFilePath(options) {
const name = options.name || 'all';
const days = options.days;
const limit = options.limit;
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_') || 'all';
const fileName = 'rss-cache-' + safeName + '-d' + days + '-l' + limit + '.json';
return path.join(MEMORY_DIR, fileName);
}
function readSourceCache(cacheTtlMinutes) {
if (!fs.existsSync(SOURCE_CACHE_FILE)) return null;
try {
const raw = fs.readFileSync(SOURCE_CACHE_FILE, 'utf-8');
const data = JSON.parse(raw);
if (!data || !data.timestamp) return null;
const ts = new Date(data.timestamp);
if (isNaN(ts.getTime())) return null;
const ageMs = Date.now() - ts.getTime();
const ttlMs = (cacheTtlMinutes || 0) * 60 * 1000;
if (ttlMs > 0 && ageMs > ttlMs) {
return null;
}
return Array.isArray(data.sources) ? data.sources : null;
} catch {
return null;
}
}
function writeSourceCache(sources) {
const payload = {
timestamp: new Date().toISOString(),
sources: Array.isArray(sources) ? sources : []
};
try {
fs.writeFileSync(SOURCE_CACHE_FILE, JSON.stringify(payload, null, 2), 'utf-8');
} catch (e) {
log('写入订阅源缓存失败: ' + e.message);
}
}
function clearAllCaches() {
try {
const files = fs.readdirSync(MEMORY_DIR);
let removed = 0;
for (const fileName of files) {
if (/^rss-cache-.*\.json$/i.test(fileName) || fileName === path.basename(SOURCE_CACHE_FILE)) {
fs.unlinkSync(path.join(MEMORY_DIR, fileName));
removed += 1;
}
}
return removed;
} catch (e) {
log('清理缓存失败: ' + e.message);
return 0;
}
}
function readCache(options, cacheTtlMinutes) {
const filePath = getCacheFilePath(options);
if (!fs.existsSync(filePath)) return null;
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(raw);
if (!data || !data.timestamp) return null;
const ts = new Date(data.timestamp);
if (isNaN(ts.getTime())) return null;
const ageMs = Date.now() - ts.getTime();
const ttlMs = (cacheTtlMinutes || 0) * 60 * 1000;
if (ttlMs > 0 && ageMs > ttlMs) {
return null;
}
return data.articles || [];
} catch {
return null;
}
}
function writeCache(options, articles) {
const filePath = getCacheFilePath(options);
const payload = {
timestamp: new Date().toISOString(),
params: options,
articles: articles
};
try {
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8');
} catch (e) {
// 缓存失败不影响主流程
log('写入缓存失败: ' + e.message);
}
}
function filterArticles(articles, { days, name, limit }) {
const now = Date.now();
const windowMs = (days || 0) > 0 ? days * 24 * 60 * 60 * 1000 : 0;
let filtered = Array.isArray(articles) ? articles.slice() : [];
if (windowMs > 0) {
filtered = filtered.filter(a => {
const d = parseArticleDate(a);
if (!d) return true; // 没有时间字段时不强制过滤,避免误杀
return now - d.getTime() <= windowMs;
});
}
if (name && name.trim()) {
const keyword = name.trim().toLowerCase();
filtered = filtered.filter(a => {
const sourceName = getSourceName(a).toLowerCase();
return sourceName && sourceName.includes(keyword);
});
}
if (limit && filtered.length > limit) {
filtered = filtered.slice(0, limit);
}
return filtered;
}
function formatDigestMarkdown(articles, { days, name }) {
const now = new Date();
const titleParts = ['📚 订阅资讯流汇总'];
if (days && days > 0) {
titleParts.push('最近 ' + days + ' 天');
}
if (name && name.trim()) {
titleParts.push('(来源包含:' + name.trim() + ')');
}
const header = '# ' + titleParts.join(' ');
if (!articles.length) {
return (
header +
'\n\n' +
'当前筛选条件下没有找到文章,可以尝试放宽时间范围或移除来源名称过滤。'
);
}
let out = header + '\n\n';
out += '生成时间:' + formatDateTime(now) + '\n\n';
articles.forEach((a, idx) => {
const articleTitle = a.title || a.subject || '(无标题)';
const sourceName = getSourceName(a) || '未知来源';
const d = parseArticleDate(a);
const dateStr = d ? formatDateTime(d) : '时间未知';
const url = getArticleUrl(a);
out += (idx + 1) + '. **' + articleTitle + '**\n';
out += ' - 来源:' + sourceName + '\n';
out += ' - 时间:' + dateStr + '\n';
if (url) {
out += ' - 链接:' + url + '\n';
}
if (a.summary || a.description) {
const summary = String(a.summary || a.description).trim();
if (summary) {
const short =
summary.length > 200 ? summary.slice(0, 200) + '…' : summary;
out += ' - 摘要:' + short + '\n';
}
}
out += '\n';
});
return out;
}
async function getRssDigest({ days, limit, name, refreshSources, forceRefresh }) {
const defaults = (CONFIG && CONFIG.defaults) || {};
const effectiveDays = days || defaults.recentDays || 3;
const effectiveLimit = limit || defaults.limit || 100;
const cacheTtlMinutes = defaults.cacheTtlMinutes || 30;
const sourceCacheTtlMinutes = defaults.sourceCacheTtlMinutes || 15;
const cacheOptions = {
days: effectiveDays,
limit: effectiveLimit,
name: name || ''
};
const cached = forceRefresh ? null : readCache(cacheOptions, cacheTtlMinutes);
if (cached) {
log('命中本地缓存');
const filteredFromCache = filterArticles(cached, {
days: effectiveDays,
name,
limit: effectiveLimit
});
return formatDigestMarkdown(filteredFromCache, {
days: effectiveDays,
name
});
}
let sources = null;
if (!forceRefresh && !refreshSources) {
sources = readSourceCache(sourceCacheTtlMinutes);
if (sources) {
log('命中订阅源缓存');
}
}
if (!sources) {
log('从 quotedance-service 获取订阅源列表...');
const sourcePayload = await fetchJson('/feeds/registry');
sources = Array.isArray(sourcePayload.sources)
? sourcePayload.sources
: Array.isArray(sourcePayload)
? sourcePayload
: [];
writeSourceCache(sources);
}
if (!sources.length) {
return formatDigestMarkdown([], {
days: effectiveDays,
name
});
}
const fetchResults = await mapWithConcurrency(sources, 5, async source => {
const feedUrl = normalizeRssUrl(source.rss_url);
if (!feedUrl) {
log('跳过无效 rss_url:' + (source.name || source.id || 'unknown'));
return [];
}
try {
const xml = await fetchTextByUrl(feedUrl);
const parsed = parseRssOrAtom(xml, source);
return parsed;
} catch (e) {
log('抓取失败:' + (source.name || source.id || 'unknown') + ' - ' + (e.message || e));
return [];
}
});
const allArticles = dedupeArticles(fetchResults.flat());
log('本地抓取并聚合文章数量:' + allArticles.length);
const filtered = filterArticles(allArticles, {
days: effectiveDays,
name,
limit: effectiveLimit
});
writeCache(cacheOptions, allArticles);
return formatDigestMarkdown(filtered, {
days: effectiveDays,
name
});
}
async function main() {
const args = process.argv.slice(2);
let days = CONFIG.defaults && CONFIG.defaults.recentDays
? CONFIG.defaults.recentDays
: 3;
let limit = CONFIG.defaults && CONFIG.defaults.limit
? CONFIG.defaults.limit
: 100;
let name = '';
let refreshSources = false;
let forceRefresh = false;
let clearCache = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if ((arg === '--days' || arg === '-d') && i + 1 < args.length) {
const v = parseInt(args[++i], 10);
if (!isNaN(v) && v > 0) days = v;
} else if ((arg === '--limit' || arg === '-l') && i + 1 < args.length) {
const v = parseInt(args[++i], 10);
if (!isNaN(v) && v > 0) limit = v;
} else if ((arg === '--name' || arg === '-n') && i + 1 < args.length) {
name = args[++i];
} else if (arg === '--refresh-sources') {
refreshSources = true;
} else if (arg === '--refresh' || arg === '--force-refresh') {
forceRefresh = true;
refreshSources = true;
} else if (arg === '--clear-cache') {
clearCache = true;
forceRefresh = true;
refreshSources = true;
}
}
try {
if (clearCache) {
const removed = clearAllCaches();
log('已清理缓存文件:' + removed);
}
const markdown = await getRssDigest({ days, limit, name, refreshSources, forceRefresh });
console.log(markdown);
} catch (e) {
console.error('获取资讯流失败:', e.message || e);
process.exit(1);
}
}
if (require.main === module) {
main();
}
module.exports = {
getRssDigest,
clearAllCaches,
main
};
FILE:memory/rss-source-cache.json
{
"timestamp": "2026-03-14T02:04:43.561Z",
"sources": [
{
"category": "科技",
"fetch_interval": 30,
"id": "69514599b65ba9154199a58f",
"isCustom": false,
"name": "36氪",
"rss_url": "https://36kr.com/feed"
},
{
"category": "股票资讯",
"fetch_interval": 30,
"id": "695e72232ef20390f7f00dbb",
"isCustom": false,
"name": "雪球热话题",
"rss_url": "https://xueqiu.com/hots/topic/rss"
},
{
"category": "",
"fetch_interval": 30,
"id": "34b925c3190a8ec060901df516892af1",
"isCustom": true,
"name": "杨康平同学",
"rss_url": "http://localhost:1200/wechat/sogou/%E6%9D%A8%E5%BA%B7%E5%B9%B3%E5%90%8C%E5%AD%A6"
},
{
"category": "",
"fetch_interval": 30,
"id": "62a6fa07e756b0da3bd4c40231ef8328",
"isCustom": true,
"name": "猫笔刀",
"rss_url": "http://localhost:1200/wechat/sogou/%E7%8C%AB%E7%AC%94%E5%88%80"
},
{
"category": "",
"fetch_interval": 30,
"id": "206cf34d93aeed1a8ef044dc874912f1",
"isCustom": true,
"name": "发牌手杰克",
"rss_url": "http://localhost:1200/wechat/sogou/%E5%8F%91%E7%89%8C%E6%89%8B%E6%9D%B0%E5%85%8B"
},
{
"category": "",
"fetch_interval": 30,
"id": "097f9b91079941301e72fc59aa10359b",
"isCustom": true,
"name": "政信三公子",
"rss_url": "http://localhost:1200/wechat/sogou/%E6%94%BF%E4%BF%A1%E4%B8%89%E5%85%AC%E5%AD%90"
},
{
"category": "",
"fetch_interval": 30,
"id": "99b7507525cf9b10223efa9a0ec7522a",
"isCustom": true,
"name": "白刃行走",
"rss_url": "http://localhost:1200/wechat/sogou/%E7%99%BD%E5%88%83%E8%A1%8C%E8%B5%B0"
}
]
}提供全球市场数据和专业投研情报,自动生成结构化日报和周末市场分析报告。
# quotedance-market - 全球市场投研情报官
专业的全球市场投研日报技能,提供结构化、有思考维度的市场分析。
---
## 🎯 核心能力
### 数据源融合
- **美股行情**:Yahoo Finance(道指、纳指、标普500及重点个股)
- **A股行情**:quotedance-service(主要指数、自选股)
- **期货行情**:quotedance-service(黄金、原油、螺纹钢、豆粕等)
- **A股板块榜**:quotedance-service(涨跌幅Top N)
- **专业资讯**:Bloomberg、Reuters、华尔街见闻、金十数据(最优质最实时)
### 智能输出风格
- **交易日日报**:市场数据 + 热点主题 + 投资机会 + 风险提醒
- **周末休整日**:本周回顾 + 下周前瞻 + 风险雷达 + 思考题
- **自动切换**:根据日期自动选择日报或周末版本
---
## ⚙️ 配置文件
`skills/quotedance-market/config.json`
```json
{
"serviceUrl": "https://quotedance.api.gapgap.cc",
"apiKey": "",
"watchlist": {
"us": ["^DJI", "^IXIC", "^GSPC", "AAPL", "NVDA", "TSLA"],
"cn": ["000001", "399001", "399006"],
"futures": ["M2605", "RB2605", "AU0", "SC0"]
},
"defaults": {
"plateTopCount": 10,
"opportunityCount": 5,
"newsCount": 10
},
"network": {
"useProxy": true,
"proxyUrl": "",
"timeoutMs": 25000,
"requestRetries": 2,
"enableCurlFallback": true
}
}
```
---
## 📋 报告结构
### 交易日版本
```
📈 市场情报日报 | 日期
├── 全球市场状态
│ ├── 美股/港股/A股
│ └── 期货(黄金、原油)
├── 今日热点主题
│ ├── AI & 科技
│ ├── 宏观政策
│ └── 地缘风险
├── 投资机会(3-5个)
├── 风险提醒
└── 操作策略建议
```
### 周末版本
```
📈 市场情报日报 | 日期
├── 周期: 周末休整日
├── 全球市场状态
├── 本周回顾
├── 🔥 本周热点主题
├── 📅 下周关键节点(日历表)
├── ⚠️ 风险雷达(高/中风险)
├── 💭 周末思考题
├── 📝 操作策略建议
└── 🎉 今日小彩蛋
```
---
## 🚀 使用方式
### 命令行执行
```bash
cd ~/.openclaw/workspace-quotedance
# 默认生成今日市场情报
node skills/quotedance-market/scripts/market-scan.js
# 强制刷新数据
node skills/quotedance-market/scripts/market-scan.js --refresh
# 输出网络诊断信息(代理、重试、超时配置)
node skills/quotedance-market/scripts/market-scan.js --net-debug
```
### Agent 触发条件
当用户说以下内容时,自动调用本技能:
- "市场日报"、"市场情报"
- "今日市场"、"市场简报"
- "生成市场报告"
- "整理市场信息"
- "早报"、"晚报"
---
## 📊 数据获取逻辑
### 1. 行情数据
- **美股**:Yahoo Finance API
- **A股/期货**:quotedance-service API
- **板块榜**:`/quotes/plate-top-info`
### 2. 资讯数据
**优先级排序(最优质最实时):**
1. **Bloomberg** - 全球金融快讯
2. **Reuters** - 国际新闻
3. **华尔街见闻** - 中文专业财经
4. **金十数据** - 实时快讯
5. **CoinDesk** - 加密货币专业
6. **The Block** - Web3深度
**不再使用:** 用户订阅源(RSS聚合)
---
## 💡 输出特点
### 专业性
- 数据来源明确标注
- 风险分级(高/中/低)
- 节点重要性标记(⭐ 数量)
### 前瞻性
- 下周关键事件日历
- 风险雷达提前预警
- 周末思考题引导复盘
### 可读性
- 表格化数据展示
- Emoji图示增强识别
- 分段清晰,重点突出
---
## 🔧 实现细节
### 目录结构
```
skills/quotedance-market/
├── SKILL.md # 本文件
├── config.json # 配置
├── scripts/
│ └── market-scan.js # 主脚本
└── memory/
├── market-YYYY-MM-DD.json # 历史快照
└── source-cache.json # 资讯源缓存
```
### 核心函数
- `fetchUsMarkets()` - 美股行情(Yahoo)
- `fetchQuotedanceQuotes()` - A股/期货(quotedance)
- `fetchPlateLeaders()` - 板块榜
- `fetchProfessionalNews()` - 专业资讯源
- `generateWeekdayReport()` - 交易日报告
- `generateWeekendReport()` - 周末报告
- `analyzeOpportunities()` - 识别投资机会
---
## ⚠️ 注意事项
1. **Yahoo Finance 在中国大陆被墙**,可能获取失败
2. **资讯源可能超时或限制**,脚本会降级处理
3. **周末版本**更注重前瞻性,交易日版本更注重实时性
4. 所有数据仅供参考,不构成投资建议
---
**维护者**: Alpha (quotedance agent)
**最后更新**: 2026-03-14
FILE:config.json
{
"serviceUrl": "https://quotedance.api.gapgap.cc",
"apiKey": "",
"rsshubUrl": "http://localhost:1200",
"watchlist": {
"us": ["^DJI", "^IXIC", "^GSPC", "AAPL", "NVDA", "TSLA"],
"cn": ["399001", "399006"],
"futures": ["M2605", "RB2605", "AU0", "SC0"]
},
"defaults": {
"plateTopCount": 8,
"sourceCacheTtlMinutes": 15,
"opportunityCount": 5,
"newsCount": 10
},
"network": {
"useProxy": true,
"proxyUrl": "",
"timeoutMs": 25000,
"requestRetries": 2,
"enableCurlFallback": true,
"forceProxyDomains": [
"query1.finance.yahoo.com",
"query2.finance.yahoo.com",
"news.google.com",
"feeds.bloomberg.com",
"feeds.reuters.com",
"wallstreetcn.com",
"jin10.com",
"coindesk.com",
"theblock.co"
]
}
}
FILE:scripts/market-scan.js
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const CONFIG = require('../config.json');
const SKILL_DIR = path.resolve(__dirname, '..');
const MEMORY_DIR = path.join(SKILL_DIR, 'memory');
const NEWS_CACHE_FILE = path.join(MEMORY_DIR, 'source-cache.json');
const NETWORK = CONFIG.network || {};
const DEFAULT_PROXY_DOMAINS = [
'finance.yahoo.com',
'query1.finance.yahoo.com',
'query2.finance.yahoo.com',
'news.google.com',
'bloomberg.com',
'reuters.com',
'wallstreetcn.com',
'jin10.com',
'coindesk.com',
'theblock.co'
];
const PROXY_URL =
NETWORK.proxyUrl ||
process.env.HTTPS_PROXY ||
process.env.HTTP_PROXY ||
process.env.ALL_PROXY ||
'';
const REQUEST_TIMEOUT_MS = Number(NETWORK.timeoutMs) || 25000;
const REQUEST_RETRIES = Number(NETWORK.requestRetries) || 2;
const ENABLE_CURL_FALLBACK = NETWORK.enableCurlFallback !== false;
let proxyDispatcher = null;
try {
const undici = require('undici');
if (PROXY_URL && undici && undici.ProxyAgent) {
proxyDispatcher = new undici.ProxyAgent(PROXY_URL);
}
} catch {}
if (!fs.existsSync(MEMORY_DIR)) {
fs.mkdirSync(MEMORY_DIR, { recursive: true });
}
function log(msg) {
const ts = new Date().toISOString();
console.log('[' + ts + '] ' + msg);
}
function formatDate(date = new Date()) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return y + '-' + m + '-' + d;
}
function formatDateTime(date = new Date()) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const h = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
return y + '-' + m + '-' + d + ' ' + h + ':' + min;
}
function isWeekend(date = new Date()) {
const day = date.getDay();
return day === 0 || day === 6;
}
function toNumber(v) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function formatPrice(v) {
const n = toNumber(v);
if (n === null) return '-';
return n.toLocaleString('zh-CN', { maximumFractionDigits: 4 });
}
function formatPercentValue(v) {
const n = toNumber(v);
if (n === null) return '-';
return (n > 0 ? '+' : '') + n.toFixed(2) + '%';
}
function formatPercentRatio(v) {
const n = toNumber(v);
if (n === null) return '-';
return formatPercentValue(n * 100);
}
function iconByValue(v) {
const n = toNumber(v);
if (n === null) return '⚪';
if (n > 0) return '🟢';
if (n < 0) return '🔴';
return '⚪';
}
function shouldUseProxy(url) {
if (!PROXY_URL) return false;
if (NETWORK.useProxy === true) return true;
if (NETWORK.useProxy === false) return false;
try {
const host = new URL(url).hostname.toLowerCase();
const domains = Array.isArray(NETWORK.forceProxyDomains) && NETWORK.forceProxyDomains.length
? NETWORK.forceProxyDomains
: DEFAULT_PROXY_DOMAINS;
return domains.some(domain => host.includes(String(domain).toLowerCase()));
} catch {
return false;
}
}
function fetchWithTimeout(url, headers, timeoutMs, useProxy) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const options = {
method: 'GET',
headers,
signal: controller.signal
};
if (useProxy && proxyDispatcher) {
options.dispatcher = proxyDispatcher;
}
return fetch(url, options)
.then(async res => {
clearTimeout(timeout);
if (!res.ok) {
throw new Error('HTTP ' + res.status + ' ' + (await res.text()));
}
return res;
})
.catch(e => {
clearTimeout(timeout);
throw e;
});
}
function curlFetch(url, headers, timeoutMs) {
const args = ['-L', '--max-time', String(Math.max(5, Math.ceil(timeoutMs / 1000))), url];
Object.entries(headers || {}).forEach(([k, v]) => {
args.push('-H', k + ': ' + v);
});
if (PROXY_URL) {
args.push('--proxy', PROXY_URL);
}
return execFileSync('curl', args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
});
}
async function fetchJson(url, headers = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
const useProxy = shouldUseProxy(url);
const maxTry = Math.max(1, REQUEST_RETRIES + 1);
let lastError = null;
for (let i = 0; i < maxTry; i++) {
try {
const res = await fetchWithTimeout(url, headers, timeoutMs, useProxy);
return await res.json();
} catch (e) {
lastError = e;
if (i < maxTry - 1) {
await new Promise(r => setTimeout(r, 300 * (i + 1)));
}
}
}
if (ENABLE_CURL_FALLBACK) {
try {
const body = curlFetch(url, headers, timeoutMs);
return JSON.parse(body);
} catch (e) {
lastError = e;
}
}
throw lastError || new Error('fetch json failed');
}
async function fetchText(url, headers = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
const useProxy = shouldUseProxy(url);
const maxTry = Math.max(1, REQUEST_RETRIES + 1);
let lastError = null;
for (let i = 0; i < maxTry; i++) {
try {
const res = await fetchWithTimeout(url, headers, timeoutMs, useProxy);
return await res.text();
} catch (e) {
lastError = e;
if (i < maxTry - 1) {
await new Promise(r => setTimeout(r, 300 * (i + 1)));
}
}
}
if (ENABLE_CURL_FALLBACK) {
try {
return curlFetch(url, headers, timeoutMs);
} catch (e) {
lastError = e;
}
}
throw lastError || new Error('fetch text failed');
}
function getServiceUrl() {
return String(CONFIG.serviceUrl || 'http://localhost:5000').replace(/\/+$/, '');
}
function getApiHeaders() {
const headers = { Accept: 'application/json' };
const key = CONFIG.apiKey || process.env.QUTEDANCE_API_KEY || '';
if (key) headers['X-API-Key'] = key;
return headers;
}
async function fetchQuotedance(pathname, params = {}) {
const url = new URL(getServiceUrl() + pathname);
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') {
url.searchParams.set(k, String(v));
}
});
return fetchJson(url.toString(), getApiHeaders(), 30000);
}
async function fetchYahooQuote(symbol) {
const url = 'https://query1.finance.yahoo.com/v7/finance/quote?symbols=' + encodeURIComponent(symbol);
const data = await fetchJson(
url,
{
Accept: 'application/json',
'User-Agent': 'Mozilla/5.0'
},
15000
);
const item = data?.quoteResponse?.result?.[0];
if (item) {
return {
symbol: item.symbol || symbol,
name: item.longName || item.shortName || symbol,
price: toNumber(item.regularMarketPrice),
changePercent: toNumber(item.regularMarketChangePercent)
};
}
const chartUrl =
'https://query2.finance.yahoo.com/v8/finance/chart/' +
encodeURIComponent(symbol) +
'?range=1d&interval=1d';
const chartData = await fetchJson(
chartUrl,
{
Accept: 'application/json',
'User-Agent': 'Mozilla/5.0'
},
15000
);
const meta = chartData?.chart?.result?.[0]?.meta;
if (!meta) return null;
const regular = toNumber(meta.regularMarketPrice);
const prev = toNumber(meta.previousClose);
const changePercent = regular !== null && prev ? ((regular - prev) / prev) * 100 : null;
return {
symbol: meta.symbol || symbol,
name: meta.shortName || symbol,
price: regular,
changePercent
};
}
async function fetchStooqQuote(symbol) {
if (!symbol || symbol.startsWith('^')) return null;
const stooqSymbol = symbol.toLowerCase() + '.us';
const url = 'https://stooq.com/q/l/?s=' + encodeURIComponent(stooqSymbol) + '&f=sd2t2ohlcvn&e=csv';
const csv = await fetchText(
url,
{
Accept: 'text/plain',
'User-Agent': 'Mozilla/5.0'
},
15000
);
const lines = String(csv || '').trim().split('\n');
if (lines.length < 2) return null;
const parts = lines[1].split(',');
if (parts.length < 7) return null;
const close = toNumber(parts[6]);
if (close === null) return null;
return {
symbol,
name: symbol,
price: close,
changePercent: null
};
}
async function fetchUsMarkets() {
const symbols = Array.isArray(CONFIG.watchlist?.us) ? CONFIG.watchlist.us : [];
const out = [];
const missing = [];
for (const symbol of symbols) {
try {
const one = await fetchYahooQuote(symbol);
if (one) {
out.push(one);
} else {
missing.push(symbol);
}
} catch (e) {
log('Yahoo 获取失败: ' + symbol + ' - ' + (e.message || e));
missing.push(symbol);
}
}
if (missing.length) {
const usSymbols = missing.filter(s => !s.startsWith('^'));
if (usSymbols.length) {
try {
const payload = await fetchQuotedance('/quotes/', {
type: 'us',
list: usSymbols.join(',')
});
usSymbols.forEach(symbol => {
const q = payload ? payload[symbol] : null;
if (!q) return;
const now = toNumber(q.now ?? q.current);
const close = toNumber(q.close);
const changePercent = now !== null && close ? ((now - close) / close) * 100 : null;
if (now !== null) {
out.push({
symbol,
name: q.name || symbol,
price: now,
changePercent
});
}
});
} catch (e) {
log('quotedance 美股兜底失败: ' + (e.message || e));
}
}
}
if (missing.length) {
for (const symbol of missing) {
const exists = out.some(item => item.symbol === symbol);
if (exists) continue;
try {
const one = await fetchStooqQuote(symbol);
if (one) {
out.push(one);
} else {
log('Stooq 无可用数据: ' + symbol);
}
} catch (e) {
log('Stooq 获取失败: ' + symbol + ' - ' + (e.message || e));
}
}
}
return out;
}
async function fetchQuotedanceQuotes(type, symbols) {
if (!symbols.length) return [];
const payload = await fetchQuotedance('/quotes/', {
type,
list: symbols.join(',')
});
return symbols.map(symbol => {
const q = payload ? payload[symbol] : null;
if (!q) return null;
if (type === 'futures') {
return {
symbol,
name: q.name || q.commodity_name || symbol,
price: toNumber(q.current),
changeRatio: toNumber(q.change)
};
}
const now = toNumber(q.now ?? q.current);
const close = toNumber(q.close);
const changeRatio = now !== null && close ? (now - close) / close : null;
return {
symbol,
name: q.name || symbol,
price: now,
changeRatio
};
}).filter(Boolean);
}
async function fetchPlateLeaders() {
const count = Number(CONFIG.defaults?.plateTopCount) || 10;
const payload = await fetchQuotedance('/quotes/plate-top-info', { count });
return Array.isArray(payload?.plates) ? payload.plates : [];
}
function decodeHtml(text) {
return String(text || '')
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)));
}
function readNewsCache(ttlMinutes) {
if (!fs.existsSync(NEWS_CACHE_FILE)) return null;
try {
const data = JSON.parse(fs.readFileSync(NEWS_CACHE_FILE, 'utf-8'));
if (!data || !data.timestamp || !Array.isArray(data.news)) return null;
const ts = new Date(data.timestamp);
if (isNaN(ts.getTime())) return null;
const ttlMs = ttlMinutes * 60 * 1000;
if (ttlMs > 0 && Date.now() - ts.getTime() > ttlMs) return null;
return data.news;
} catch {
return null;
}
}
function writeNewsCache(news) {
const payload = {
timestamp: new Date().toISOString(),
news: Array.isArray(news) ? news : []
};
fs.writeFileSync(NEWS_CACHE_FILE, JSON.stringify(payload, null, 2), 'utf-8');
}
function pickRssTag(block, tagName) {
const re = new RegExp('<' + tagName + '(?:\\s[^>]*)?>([\\s\\S]*?)<\\/' + tagName + '>', 'i');
const m = block.match(re);
return m ? decodeHtml(m[1].trim()) : '';
}
function parseRssItems(xmlText, sourceName, itemLimit) {
const xml = String(xmlText || '');
const items = xml.match(/<item\b[\s\S]*?<\/item>/gi) || [];
return items.slice(0, itemLimit).map(block => {
const title = pickRssTag(block, 'title') || '(无标题)';
const link = pickRssTag(block, 'link');
const pubDate = pickRssTag(block, 'pubDate') || pickRssTag(block, 'dc:date');
return {
source: sourceName,
title,
link,
publishedAt: pubDate
};
});
}
async function fetchProfessionalNews(refresh = false) {
const newsCount = Number(CONFIG.defaults?.newsCount) || 10;
const ttl = Number(CONFIG.defaults?.sourceCacheTtlMinutes) || 15;
if (!refresh) {
const cached = readNewsCache(ttl);
if (cached) {
log('命中资讯缓存');
return cached.slice(0, newsCount);
}
}
const feeds = [
{
source: 'Bloomberg',
urls: [
'https://feeds.bloomberg.com/markets/news.rss',
'https://news.google.com/rss/search?q=Bloomberg+market&hl=en-US&gl=US&ceid=US:en'
]
},
{
source: 'Reuters',
urls: [
'https://feeds.reuters.com/reuters/businessNews',
'https://news.google.com/rss/search?q=Reuters+markets&hl=en-US&gl=US&ceid=US:en'
]
},
{
source: '华尔街见闻',
urls: [
'https://wallstreetcn.com/rss',
'https://news.google.com/rss/search?q=%E5%8D%8E%E5%B0%94%E8%A1%97%E8%A7%81%E9%97%BB&hl=zh-CN&gl=CN&ceid=CN:zh-Hans'
]
},
{
source: '金十数据',
urls: [
'https://www.jin10.com/rss',
'https://news.google.com/rss/search?q=%E9%87%91%E5%8D%81%E6%95%B0%E6%8D%AE&hl=zh-CN&gl=CN&ceid=CN:zh-Hans'
]
},
{
source: 'CoinDesk',
urls: ['https://www.coindesk.com/arc/outboundfeeds/rss/']
},
{
source: 'The Block',
urls: ['https://www.theblock.co/rss.xml']
}
];
const combined = [];
for (const feed of feeds) {
let ok = false;
let lastError = null;
for (const url of feed.urls) {
try {
const xml = await fetchText(url, {
Accept: 'application/rss+xml, application/xml, text/xml',
'User-Agent': 'Mozilla/5.0'
}, 15000);
const parsed = parseRssItems(xml, feed.source, 4);
if (parsed.length) {
combined.push(...parsed);
ok = true;
break;
}
} catch (e) {
lastError = e;
}
}
if (!ok && lastError) {
log('资讯源获取失败: ' + feed.source + ' - ' + (lastError.message || lastError));
}
}
const deduped = [];
const seen = new Set();
for (const n of combined) {
const key = (n.link || '') + '|' + (n.title || '');
if (!key.trim() || seen.has(key)) continue;
seen.add(key);
deduped.push(n);
}
writeNewsCache(deduped);
return deduped.slice(0, newsCount);
}
function analyzeOpportunities(data) {
const targetCount = Number(CONFIG.defaults?.opportunityCount) || 5;
const opportunities = [];
const risks = [];
data.us.forEach(item => {
const p = toNumber(item.changePercent);
if (p === null) return;
if (p >= 2) {
opportunities.push('美股动能:' + item.name + ' (' + item.symbol + ') ' + formatPercentValue(p));
} else if (p <= -2) {
risks.push('美股回撤:' + item.name + ' (' + item.symbol + ') ' + formatPercentValue(p));
}
});
data.cn.forEach(item => {
const r = toNumber(item.changeRatio);
if (r === null) return;
const p = r * 100;
if (p >= 1.5) {
opportunities.push('A股走强:' + item.name + ' (' + item.symbol + ') ' + formatPercentValue(p));
} else if (p <= -1.5) {
risks.push('A股承压:' + item.name + ' (' + item.symbol + ') ' + formatPercentValue(p));
}
});
data.futures.forEach(item => {
const r = toNumber(item.changeRatio);
if (r === null) return;
const p = r * 100;
if (p >= 2) {
opportunities.push('期货趋势:' + item.name + ' (' + item.symbol + ') ' + formatPercentValue(p));
} else if (p <= -2) {
risks.push('期货波动:' + item.name + ' (' + item.symbol + ') ' + formatPercentValue(p));
}
});
data.plates.slice(0, 3).forEach(plate => {
const p = toNumber(plate.core_avg_pcp);
if (p === null) return;
if (p > 0) {
opportunities.push('板块强势:' + plate.plate_name + ' ' + formatPercentRatio(p));
}
});
data.plates.slice(-3).forEach(plate => {
const p = toNumber(plate.core_avg_pcp);
if (p === null) return;
if (p < 0) {
risks.push('板块回撤:' + plate.plate_name + ' ' + formatPercentRatio(p));
}
});
return {
opportunities: opportunities.slice(0, targetCount),
risks: risks.slice(0, Math.max(targetCount, 5))
};
}
function detectThemes(news) {
const themes = {
ai: 0,
macro: 0,
geo: 0
};
news.forEach(item => {
const t = (item.title || '').toLowerCase();
if (/ai|artificial intelligence|chip|nvidia|semiconductor|科技|算力/.test(t)) themes.ai += 1;
if (/fed|ecb|pmi|cpi|inflation|rate|央行|利率|通胀|政策/.test(t)) themes.macro += 1;
if (/war|tariff|sanction|oil|middle east|地缘|冲突|制裁/.test(t)) themes.geo += 1;
});
return themes;
}
function buildGlobalMarketSection(data) {
const lines = [];
lines.push('## 🌍 全球市场状态');
lines.push('');
lines.push('### 美股');
if (!data.us.length) {
lines.push('- 暂无数据');
} else {
data.us.forEach(item => {
lines.push('- ' + iconByValue(item.changePercent) + ' ' + item.name + ' (' + item.symbol + ') ' + formatPrice(item.price) + ' ' + formatPercentValue(item.changePercent));
});
}
lines.push('');
lines.push('### A股');
if (!data.cn.length) {
lines.push('- 暂无数据');
} else {
data.cn.forEach(item => {
const p = toNumber(item.changeRatio);
lines.push('- ' + iconByValue(p) + ' ' + item.name + ' (' + item.symbol + ') ' + formatPrice(item.price) + ' ' + formatPercentRatio(item.changeRatio));
});
}
lines.push('');
lines.push('### 期货');
if (!data.futures.length) {
lines.push('- 暂无数据');
} else {
data.futures.forEach(item => {
const p = toNumber(item.changeRatio);
lines.push('- ' + iconByValue(p) + ' ' + item.name + ' (' + item.symbol + ') ' + formatPrice(item.price) + ' ' + formatPercentRatio(item.changeRatio));
});
}
lines.push('');
lines.push('### A股板块涨跌幅榜');
if (!data.plates.length) {
lines.push('- 暂无数据');
} else {
data.plates.forEach(plate => {
lines.push('- ' + plate.plate_name + ':' + formatPercentRatio(plate.core_avg_pcp));
});
}
lines.push('');
return lines.join('\n');
}
function generateWeekdayReport(snapshot) {
const now = new Date();
const themes = detectThemes(snapshot.news);
const lines = [];
lines.push('📈 市场情报日报 | ' + formatDate(now));
lines.push('更新时间:' + formatDateTime(now));
lines.push('数据源:Yahoo + quotedance-service + Bloomberg/Reuters/华尔街见闻/金十等');
lines.push('');
lines.push(buildGlobalMarketSection(snapshot));
lines.push('## 🔥 今日热点主题');
lines.push('- AI & 科技:' + themes.ai + ' 条相关资讯');
lines.push('- 宏观政策:' + themes.macro + ' 条相关资讯');
lines.push('- 地缘风险:' + themes.geo + ' 条相关资讯');
lines.push('');
lines.push('## ⚡ 投资机会');
if (!snapshot.analysis.opportunities.length) {
lines.push('- 当前未识别到明确高胜率机会,建议保持仓位纪律');
} else {
snapshot.analysis.opportunities.forEach(item => lines.push('- ' + item));
}
lines.push('');
lines.push('## ⚠️ 风险提醒');
if (!snapshot.analysis.risks.length) {
lines.push('- 风险信号有限,关注事件驱动和盘中波动');
} else {
snapshot.analysis.risks.forEach(item => lines.push('- ' + item));
}
lines.push('');
lines.push('## 📝 操作策略建议');
lines.push('- 先看强势板块能否延续,再考虑顺势加仓');
lines.push('- 对高波动品种保持止损,避免情绪化追涨杀跌');
lines.push('- 宏观事件前降低杠杆,优先保护回撤');
lines.push('');
lines.push('## 📰 专业资讯');
snapshot.news.forEach((n, i) => {
const link = n.link ? ' - ' + n.link : '';
lines.push((i + 1) + '. [' + n.source + '] ' + n.title + link);
});
return lines.join('\n');
}
function upcomingWeekCalendar() {
const now = new Date();
const nextMonday = new Date(now);
const day = nextMonday.getDay();
const offset = day === 0 ? 1 : 8 - day;
nextMonday.setDate(nextMonday.getDate() + offset);
const labels = ['周一', '周二', '周三', '周四', '周五'];
const templates = [
'美国通胀相关数据观察',
'中国高频经济数据跟踪',
'美联储官员讲话窗口',
'OPEC与能源链价格监控',
'周度仓位与风险复盘'
];
return labels.map((label, idx) => {
const d = new Date(nextMonday);
d.setDate(nextMonday.getDate() + idx);
return {
date: formatDate(d),
label,
event: templates[idx]
};
});
}
function generateWeekendReport(snapshot) {
const now = new Date();
const themes = detectThemes(snapshot.news);
const calendar = upcomingWeekCalendar();
const lines = [];
lines.push('📈 市场情报日报 | ' + formatDate(now));
lines.push('周期:周末休整日');
lines.push('更新时间:' + formatDateTime(now));
lines.push('');
lines.push(buildGlobalMarketSection(snapshot));
lines.push('## 📌 本周回顾');
lines.push('- AI & 科技资讯热度:' + themes.ai + '(关注是否进入估值兑现阶段)');
lines.push('- 宏观政策资讯热度:' + themes.macro + '(关注下周政策与数据共振)');
lines.push('- 地缘风险资讯热度:' + themes.geo + '(关注能源与避险链条)');
lines.push('');
lines.push('## 🔥 本周热点主题');
lines.push('- 科技成长与大盘风格切换');
lines.push('- 通胀与利率预期反复');
lines.push('- 大宗商品与周期板块弹性');
lines.push('');
lines.push('## 📅 下周关键节点');
calendar.forEach(item => {
lines.push('- ' + item.date + ' ' + item.label + ':' + item.event);
});
lines.push('');
lines.push('## ⚠️ 风险雷达');
lines.push('- 高风险:地缘事件与能源价格突发波动');
lines.push('- 中风险:宏观数据不及预期引发风格急切换');
lines.push('- 中风险:高位热门板块拥挤交易回撤');
lines.push('');
lines.push('## 💭 周末思考题');
lines.push('- 若下周风险偏好下降,你的仓位结构是否有防守腿?');
lines.push('- 哪个板块具备“基本面改善 + 资金增配”的双重逻辑?');
lines.push('');
lines.push('## 📝 操作策略建议');
lines.push('- 先做风险预算,再做收益预期');
lines.push('- 把仓位分成核心仓与战术仓,避免单边押注');
lines.push('- 预设止盈止损与触发条件,提高执行一致性');
lines.push('');
lines.push('## 🎉 今日小彩蛋');
lines.push('- 复盘不是为了证明自己对,而是为了下次更早发现自己错。');
lines.push('');
lines.push('## 📰 专业资讯');
snapshot.news.forEach((n, i) => {
const link = n.link ? ' - ' + n.link : '';
lines.push((i + 1) + '. [' + n.source + '] ' + n.title + link);
});
return lines.join('\n');
}
function saveSnapshot(snapshot) {
const file = path.join(MEMORY_DIR, 'market-' + formatDate() + '.json');
fs.writeFileSync(file, JSON.stringify(snapshot, null, 2), 'utf-8');
return file;
}
async function safeCall(label, runner, fallback) {
try {
return await runner();
} catch (e) {
log(label + '失败: ' + (e.message || e));
return fallback;
}
}
async function main() {
const args = process.argv.slice(2);
const forceRefresh = args.includes('--refresh');
const netDebug = args.includes('--net-debug');
if (netDebug) {
log('network.proxy=' + (PROXY_URL ? PROXY_URL : ''));
log('network.useProxy=' + String(NETWORK.useProxy));
log('network.requestRetries=' + String(REQUEST_RETRIES));
log('network.timeoutMs=' + String(REQUEST_TIMEOUT_MS));
log('network.enableCurlFallback=' + String(ENABLE_CURL_FALLBACK));
log('network.proxyDispatcher=' + String(Boolean(proxyDispatcher)));
}
const cnSymbols = Array.isArray(CONFIG.watchlist?.cn) ? CONFIG.watchlist.cn : [];
const futuresSymbols = Array.isArray(CONFIG.watchlist?.futures) ? CONFIG.watchlist.futures : [];
const [us, cn, futures, plates, news] = await Promise.all([
safeCall('美股行情', () => fetchUsMarkets(), []),
safeCall('A股行情', () => fetchQuotedanceQuotes('cn', cnSymbols), []),
safeCall('期货行情', () => fetchQuotedanceQuotes('futures', futuresSymbols), []),
safeCall('板块榜', () => fetchPlateLeaders(), []),
safeCall('专业资讯', () => fetchProfessionalNews(forceRefresh), [])
]);
const analysis = analyzeOpportunities({ us, cn, futures, plates });
const snapshot = {
timestamp: new Date().toISOString(),
us,
cn,
futures,
plates,
news,
analysis
};
const file = saveSnapshot(snapshot);
log('市场快照已保存: ' + file);
const report = isWeekend() ? generateWeekendReport(snapshot) : generateWeekdayReport(snapshot);
console.log('\n=== MARKET_REPORT_START ===');
console.log(report);
console.log('=== MARKET_REPORT_END ===\n');
return report;
}
if (require.main === module) {
main().catch(err => {
console.error(err);
process.exit(1);
});
}
module.exports = {
main,
fetchUsMarkets,
fetchQuotedanceQuotes,
fetchPlateLeaders,
fetchProfessionalNews,
generateWeekdayReport,
generateWeekendReport,
analyzeOpportunities
};