feat(宏观): ifinance 分类动态挂载为宏观 tab, 报警迁入监控, 下线油价/本地经济数据/策略研究
Docker Build and Push / build-image (push) Successful in 2m37s
Docker Build and Push / build-image (push) Successful in 2m37s
- 宏观只保留本地 新闻/经济文章, 外接 ifinance categories[] 动态成为 tab (纯前端直连, CORS/_headers, https) - 报警从宏观迁到监控第 4 个 tab (保留 Alert 模块, 供未来新增报警源) - 彻底删除油价/经济数据/策略研究: 前端组件+api、后端 apizero/data 路由、cron、fetchers、种子数据 - 新增迁移 DROP Indicator/IndicatorPoint/Strategy/OilPriceHistory; 重新生成 Prisma Client - CSP 放开 *.honor3.com (含端口通配), 避免新增子域反复重装外壳
This commit is contained in:
@@ -1,303 +0,0 @@
|
||||
// 东方财富妙想 MCP 数据源
|
||||
// 协议: StreamableHttp (MCP 2025-03-26 spec), HTTP POST + JSON-RPC 2.0
|
||||
// - 认证: header `em_api_key: <EM_API_KEY>`
|
||||
// - 妙想 MCP 是"自然语言驱动": 传 query 字符串, 内部 LLM 解析 + 调底层数据接口
|
||||
// - 重要: 每次调用 5-20s + LLM token 成本, 必须配合"DB 优先" (refresh 时已存在的指标跳过)
|
||||
//
|
||||
// 需要的 .env:
|
||||
// EM_API_KEY=xxx 必填
|
||||
// EASTMONEY_MCP_BASE=... 可选, 默认 https://mxapi.eastmoney.com/mxds/mcp
|
||||
//
|
||||
// INDICATORS 数组: 配置要拉的指标
|
||||
// 每项:
|
||||
// name → Indicator.name (主键)
|
||||
// groupName → Indicator.groupName
|
||||
// title → data.title
|
||||
// unit → data.unit
|
||||
// tool → MCP tool 名
|
||||
// query → 自然语言查询
|
||||
// pickSheet(sheets) → 选一张 sheet (默认取第一张)
|
||||
// parseSheet(sheet) → [{period, value}, ...] 默认按宽表转长表 (列=日期, 行=值)
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
const DEFAULT_BASE = 'https://mxapi.eastmoney.com/mxds/mcp'
|
||||
const TIMEOUT = 60000
|
||||
|
||||
// ====== INDICATORS 配置 ======
|
||||
// 跑 npm run data:discover 看可用 tool, 再加新指标
|
||||
const INDICATORS = [
|
||||
{
|
||||
name: 'china-cpi-yoy',
|
||||
groupName: 'china-macro',
|
||||
title: '中国 CPI 同比',
|
||||
unit: '%',
|
||||
tool: 'mx_macro_data',
|
||||
query: '查询中国 2015 年至今 CPI 月度同比数据,每一个月的数值',
|
||||
pickSheet: (sheets) =>
|
||||
sheets.find((s) => s.items?.[0]?.[0]?.includes('CPI') && s.items[0][0].includes('同比'))
|
||||
|| sheets.find((s) => s.sheetName?.includes('宏观数据'))
|
||||
|| sheets[0],
|
||||
},
|
||||
{
|
||||
name: 'china-gdp-yoy',
|
||||
groupName: 'china-macro',
|
||||
title: '中国 GDP 同比',
|
||||
unit: '%',
|
||||
tool: 'mx_macro_data',
|
||||
query: '查询中国 2015-2025 年 GDP 不变价同比增速,季度数据,每一季度的数值',
|
||||
pickSheet: (sheets) =>
|
||||
sheets.find((s) => s.items?.[0]?.[0]?.includes('GDP') && s.items[0][0].includes('同比'))
|
||||
|| sheets[0],
|
||||
},
|
||||
{
|
||||
// 国债收益率: MCP 返回日频, 用月末降采样成月频点
|
||||
name: 'china-bond-10y',
|
||||
groupName: 'china-macro',
|
||||
title: '中国国债 10 年期收益率',
|
||||
unit: '%',
|
||||
frequency: 'month',
|
||||
tool: 'mx_macro_data',
|
||||
query: '查询中国10年期国债到期收益率 2024年至今 每日数据',
|
||||
pickSheet: (sheets) =>
|
||||
sheets.find((s) => s.items?.some?.((row) => String(row[0] || '').includes('国债到期收益率:10年')))
|
||||
|| sheets[0],
|
||||
parseSheet: (sheet) => monthEndSample(sheet, '国债到期收益率:10年'),
|
||||
},
|
||||
{
|
||||
name: 'china-bond-1y',
|
||||
groupName: 'china-macro',
|
||||
title: '中国国债 1 年期收益率',
|
||||
unit: '%',
|
||||
frequency: 'month',
|
||||
tool: 'mx_macro_data',
|
||||
query: '查询中国1年期国债到期收益率 2024年至今 每日数据',
|
||||
pickSheet: (sheets) =>
|
||||
sheets.find((s) => s.items?.some?.((row) => String(row[0] || '').includes('国债到期收益率:1年')))
|
||||
|| sheets[0],
|
||||
parseSheet: (sheet) => monthEndSample(sheet, '国债到期收益率:1年'),
|
||||
},
|
||||
{
|
||||
name: 'china-lpr-1y',
|
||||
groupName: 'china-macro',
|
||||
title: '中国 LPR 1 年期',
|
||||
unit: '%',
|
||||
frequency: 'month',
|
||||
tool: 'mx_macro_data',
|
||||
query: '查询中国LPR 1年期贷款市场报价利率 2024年至今 月度数据,每一个月的数值',
|
||||
pickSheet: (sheets) =>
|
||||
sheets.find((s) => s.sheetName?.includes('宏观数据(月)'))
|
||||
|| sheets[0],
|
||||
parseSheet: (sheet) => pickRowByKeyword(sheet, '(LPR):1年'),
|
||||
},
|
||||
{
|
||||
name: 'china-lpr-5y',
|
||||
groupName: 'china-macro',
|
||||
title: '中国 LPR 5 年期',
|
||||
unit: '%',
|
||||
frequency: 'month',
|
||||
tool: 'mx_macro_data',
|
||||
query: '查询中国LPR 5年期以上贷款市场报价利率 2024年至今 月度数据,每一个月的数值',
|
||||
pickSheet: (sheets) =>
|
||||
sheets.find((s) => s.sheetName?.includes('宏观数据(月)'))
|
||||
|| sheets[0],
|
||||
parseSheet: (sheet) => pickRowByKeyword(sheet, '(LPR):5年'),
|
||||
},
|
||||
]
|
||||
|
||||
export const META = {
|
||||
name: 'eastmoney-mcp',
|
||||
description: '东方财富妙想 MCP (StreamableHttp, NL 查询)',
|
||||
docs: 'https://mxapi.eastmoney.com/mxds/doc/mcp-install.md',
|
||||
}
|
||||
|
||||
// ====== StreamableHttp 客户端 ======
|
||||
|
||||
class McpClient {
|
||||
constructor(baseUrl, apiKey) {
|
||||
this.baseUrl = baseUrl
|
||||
this.apiKey = apiKey
|
||||
this.id = 0
|
||||
}
|
||||
async _request(method, params) {
|
||||
const payload = { jsonrpc: '2.0', id: ++this.id, method, ...(params ? { params } : {}) }
|
||||
const resp = await axios.post(this.baseUrl, payload, {
|
||||
timeout: TIMEOUT,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
'em_api_key': this.apiKey,
|
||||
},
|
||||
responseType: 'text',
|
||||
transformResponse: [(d) => d],
|
||||
validateStatus: () => true,
|
||||
})
|
||||
if (resp.status >= 400) {
|
||||
throw new Error(`HTTP ${resp.status}: ${String(resp.data).slice(0, 300)}`)
|
||||
}
|
||||
const parsed = parseMcpResponse(String(resp.data || ''))
|
||||
// JSON-RPC 信封 {jsonrpc,id,result|error}, 统一解包 result
|
||||
if (parsed.error) {
|
||||
throw new Error(`MCP error ${parsed.error.code}: ${parsed.error.message}`)
|
||||
}
|
||||
return parsed.result || parsed
|
||||
}
|
||||
async listTools() {
|
||||
const r = await this._request('tools/list')
|
||||
return r.tools || []
|
||||
}
|
||||
async callTool(name, args = {}) {
|
||||
const r = await this._request('tools/call', { name, arguments: args })
|
||||
if (r.isError) {
|
||||
const msg = (r.content || []).map((c) => c.text || '').join('; ')
|
||||
throw new Error(`tool ${name} error: ${msg || JSON.stringify(r).slice(0, 200)}`)
|
||||
}
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
// 解析响应: JSON 或 SSE
|
||||
function parseMcpResponse(text) {
|
||||
const t = text.trim()
|
||||
if (t.startsWith('{')) return JSON.parse(t)
|
||||
if (t.startsWith('data:')) {
|
||||
const lines = t.split('\n').filter((l) => l.startsWith('data:'))
|
||||
let last = null
|
||||
for (const line of lines) {
|
||||
const json = line.slice(5).trim()
|
||||
if (!json || json === '[DONE]') continue
|
||||
try { last = JSON.parse(json) } catch { /* skip */ }
|
||||
}
|
||||
if (!last) throw new Error('SSE 无有效 data 块: ' + text.slice(0, 200))
|
||||
return last
|
||||
}
|
||||
throw new Error('未知响应格式: ' + t.slice(0, 200))
|
||||
}
|
||||
|
||||
// 默认 sheet 解析: 宽表 (列=日期, 行=值) → 长表
|
||||
// 例: { columns:["指标","来源","2024-12","2024-11"], items:[["CPI","统计局","0.1","0.2"]] }
|
||||
// → [{period:"2024-12", value:0.1}, {period:"2024-11", value:0.2}]
|
||||
// 返回时反转成从老到新
|
||||
function defaultParseSheet(sheet) {
|
||||
if (!sheet || !sheet.columns || !sheet.items || !sheet.items[0]) return []
|
||||
const cols = sheet.columns
|
||||
const row = sheet.items[0]
|
||||
const dates = cols.slice(2) // 跳过 [指标名, 来源]
|
||||
const values = row.slice(2)
|
||||
const out = []
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const v = parseFloat(values[i])
|
||||
if (!isNaN(v)) out.push({ period: dates[i], value: v })
|
||||
}
|
||||
return out.reverse() // 从老到新
|
||||
}
|
||||
|
||||
// 宽表 → 长表, 指定 item 行 (按指标全名前缀匹配), 日频列降采样为月末值
|
||||
// 例: columns=["指标","来源","2026-08-05","2026-08-04",...] → [{period:"2024-01",value},...]
|
||||
function monthEndSample(sheet, keyword) {
|
||||
if (!sheet || !sheet.columns || !sheet.items) return []
|
||||
const row = sheet.items.find((r) => String(r[0] || '').includes(keyword))
|
||||
if (!row) return []
|
||||
const cols = sheet.columns
|
||||
const dates = cols.slice(2) // 新 → 老
|
||||
const values = row.slice(2)
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const v = parseFloat(values[i])
|
||||
const m = String(dates[i] || '').slice(0, 7) // "2024-01-02" → "2024-01"
|
||||
if (!/^\d{4}-\d{2}$/.test(m) || Number.isNaN(v)) continue
|
||||
if (seen.has(m)) continue // 列是降序, 第一次遇到的即当月最后一个交易日
|
||||
seen.add(m)
|
||||
out.push({ period: m, value: v })
|
||||
}
|
||||
return out.reverse() // 从老到新
|
||||
}
|
||||
|
||||
// 宽表 → 长表, 指定 item 行 (按指标全名前缀匹配), 保留原频率 (月/季/年)
|
||||
function pickRowByKeyword(sheet, keyword) {
|
||||
if (!sheet || !sheet.columns || !sheet.items) return []
|
||||
const row = sheet.items.find((r) => String(r[0] || '').includes(keyword))
|
||||
if (!row) return []
|
||||
const cols = sheet.columns
|
||||
const dates = cols.slice(2)
|
||||
const values = row.slice(2)
|
||||
const out = []
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const v = parseFloat(values[i])
|
||||
if (!Number.isNaN(v)) out.push({ period: String(dates[i]), value: v })
|
||||
}
|
||||
return out.reverse() // 从老到新
|
||||
}
|
||||
|
||||
// ====== fetcher 入口 ======
|
||||
|
||||
export async function listAvailableTools() {
|
||||
const key = process.env.EM_API_KEY
|
||||
if (!key) throw new Error('缺少 EM_API_KEY')
|
||||
const base = process.env.EASTMONEY_MCP_BASE || DEFAULT_BASE
|
||||
const client = new McpClient(base, key)
|
||||
return client.listTools()
|
||||
}
|
||||
|
||||
// opts.skipNames: Set<string> — DB 已存在的指标名, 跳过不调 MCP (省 token)
|
||||
// opts.onlyNames: Set<string> — 白名单, 只跑这些 (用于补跑单个)
|
||||
export async function fetchAll(opts = {}) {
|
||||
if (INDICATORS.length === 0) {
|
||||
throw new Error('eastmoney-mcp: INDICATORS 数组为空, 加新指标见文件顶部注释')
|
||||
}
|
||||
const skip = opts.skipNames || new Set()
|
||||
const force = opts.forceNames || new Set()
|
||||
const only = opts.onlyNames || null
|
||||
|
||||
const key = process.env.EM_API_KEY
|
||||
if (!key) throw new Error('缺少 EM_API_KEY')
|
||||
const base = process.env.EASTMONEY_MCP_BASE || DEFAULT_BASE
|
||||
const client = new McpClient(base, key)
|
||||
|
||||
const results = []
|
||||
for (const cfg of INDICATORS) {
|
||||
if (skip.has(cfg.name) && !force.has(cfg.name)) {
|
||||
console.log(`[MCP] ${cfg.name} 跳过 (DB 已存在)`)
|
||||
continue
|
||||
}
|
||||
if (only && !only.has(cfg.name)) continue
|
||||
try {
|
||||
console.log(`[MCP] ${cfg.name} 调用 ${cfg.tool}: ${cfg.query}`)
|
||||
const r = await client.callTool(cfg.tool, { query: cfg.query })
|
||||
const text = (r.content || []).map((c) => c.text || '').join('')
|
||||
let payload
|
||||
try { payload = JSON.parse(text) } catch { payload = { raw: text } }
|
||||
const sheets = payload.data || (Array.isArray(payload) ? payload : [payload])
|
||||
const sheet = cfg.pickSheet ? cfg.pickSheet(sheets) : sheets[0]
|
||||
const parsed = cfg.parseSheet ? cfg.parseSheet(sheet) : defaultParseSheet(sheet)
|
||||
if (parsed.length === 0) {
|
||||
console.log(`[MCP] ${cfg.name} 返回空 rows, 跳过`)
|
||||
continue
|
||||
}
|
||||
results.push({
|
||||
name: cfg.name,
|
||||
groupName: cfg.groupName,
|
||||
data: {
|
||||
unit: cfg.unit,
|
||||
title: cfg.title,
|
||||
source: 'eastmoney-mcp',
|
||||
rows: parsed,
|
||||
},
|
||||
})
|
||||
console.log(`[MCP] ${cfg.name} OK, ${parsed.length} rows (${parsed[0]?.period}..${parsed.at(-1)?.period})`)
|
||||
} catch (e) {
|
||||
console.error(`[MCP] ${cfg.name} FAIL: ${e.message}`)
|
||||
}
|
||||
}
|
||||
// 全部被 DB 优先跳过时, 返回显式标记而不是空数组 (避免被误判为失败)
|
||||
if (results.length === 0 && INDICATORS.every((cfg) => skip.has(cfg.name) && !force.has(cfg.name))) {
|
||||
return { skipped: true, reason: 'DB 已存在全部指标' }
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
// Fetcher 注册表: 列出所有启用的数据源
|
||||
// 加新数据源只要:
|
||||
// 1. 写一个 scripts/fetchers/<source>.fetcher.js, 导出 META + fetchAll
|
||||
// 2. 在本文件 FETCHERS 数组里加一行
|
||||
// 3. 完事, refresh 脚本会自动跑
|
||||
|
||||
// 每项的 schema:
|
||||
// name: 数据源标识 (用于日志)
|
||||
// module: fetcher 模块路径 (相对 scripts/fetchers/)
|
||||
// enabled: true/false, 可由 env 关闭 (如 ENABLE_EASTMONEY_MCP=0)
|
||||
// env: 需要的环境变量名列表, 缺一即跳过
|
||||
export const FETCHERS = [
|
||||
{
|
||||
name: 'wb-indicators',
|
||||
module: './wb-indicators.fetcher.js',
|
||||
enabled: true,
|
||||
env: [],
|
||||
},
|
||||
{
|
||||
name: 'eastmoney-mcp',
|
||||
module: './eastmoney-mcp.fetcher.js',
|
||||
// 默认开启, 有 APIKey 才真正能跑 (fetcher 内部会自检)
|
||||
enabled: process.env.ENABLE_EASTMONEY_MCP !== '0',
|
||||
env: ['EM_API_KEY'],
|
||||
},
|
||||
// 后续在这里追加:
|
||||
// { name: 'fred', module: './fred.fetcher.js', enabled: true, env: ['FRED_API_KEY'] },
|
||||
// { name: 'tushare', module: './tushare.fetcher.js', enabled: true, env: ['TUSHARE_TOKEN'] },
|
||||
]
|
||||
|
||||
// 校验环境变量, 返回 { ok, missing[] }
|
||||
export function checkEnv(required) {
|
||||
const missing = required.filter((k) => !process.env[k] || !process.env[k].trim())
|
||||
return { ok: missing.length === 0, missing }
|
||||
}
|
||||
|
||||
// 加载并运行所有启用的 fetcher, 返回 [{ name, ok, items, error, skipped }]
|
||||
// 错误隔离: 一个 fetcher 挂了不影响其他
|
||||
// opts.skipNames: Set<string> — DB 优先, 已存在的指标名, 透传给 fetcher
|
||||
// opts.forceNames: Set<string> — 强制刷新名单, 即使 skipNames 里有也照拉
|
||||
export async function runFetchers(opts = {}) {
|
||||
const results = []
|
||||
for (const meta of FETCHERS) {
|
||||
if (!meta.enabled) {
|
||||
results.push({ name: meta.name, skipped: true, reason: 'disabled' })
|
||||
console.log(`[Fetcher] ${meta.name} 跳过 (disabled)`)
|
||||
continue
|
||||
}
|
||||
const envCheck = checkEnv(meta.env)
|
||||
if (!envCheck.ok) {
|
||||
results.push({ name: meta.name, skipped: true, reason: `缺少 env: ${envCheck.missing.join(', ')}` })
|
||||
console.log(`[Fetcher] ${meta.name} 跳过 (${envCheck.missing.join(', ')})`)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const mod = await import(meta.module)
|
||||
if (!mod.META || !mod.fetchAll) {
|
||||
throw new Error('fetcher 必须导出 META 和 fetchAll')
|
||||
}
|
||||
console.log(`[Fetcher] ${meta.name} 开始...`)
|
||||
const out = await mod.fetchAll({ skipNames: opts.skipNames, onlyNames: opts.onlyNames, forceNames: opts.forceNames })
|
||||
// fetcher 可返回 { skipped: true, reason } 表示"全部被 DB 优先跳过", 不算失败
|
||||
if (out && out.skipped) {
|
||||
results.push({ name: meta.name, skipped: true, reason: out.reason })
|
||||
console.log(`[Fetcher] ${meta.name} 跳过 (${out.reason})`)
|
||||
continue
|
||||
}
|
||||
const items = Array.isArray(out) ? out : []
|
||||
// 校验返回结构
|
||||
const valid = items.filter((it) => it && it.name && it.data && Array.isArray(it.data.rows))
|
||||
if (valid.length === 0) {
|
||||
throw new Error('fetcher 未返回任何有效指标 (需包含 name/data.rows)')
|
||||
}
|
||||
console.log(`[Fetcher] ${meta.name} 拉取 ${valid.length} 个指标`)
|
||||
results.push({ name: meta.name, ok: true, items: valid })
|
||||
} catch (e) {
|
||||
results.push({ name: meta.name, ok: false, error: e.message })
|
||||
console.error(`[Fetcher] ${meta.name} 失败: ${e.message}`)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
async function fetch(startDate = null, endDate = null, retryCount = 3) {
|
||||
try {
|
||||
const currentDate = new Date();
|
||||
|
||||
if (!startDate) {
|
||||
startDate = new Date(currentDate);
|
||||
startDate.setDate(startDate.getDate() - 30);
|
||||
startDate = startDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
if (!endDate) {
|
||||
endDate = currentDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
console.log('正在获取沪深300指数数据...');
|
||||
console.log(`时间范围: ${startDate} 至 ${endDate}`);
|
||||
console.log(`数据源: 腾讯财经API`);
|
||||
|
||||
// 沪深300指数代码
|
||||
const symbol = '000300';
|
||||
|
||||
// 使用腾讯财经的API端点获取历史数据
|
||||
const response = await axios.get('https://web.ifzq.gtimg.cn/appstock/app/kline/kline?param=sh' + symbol + ',day,' + startDate + ',' + endDate + ',640', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
},
|
||||
timeout: 10000 // 设置10秒超时
|
||||
});
|
||||
|
||||
console.log(`\nAPI状态: ${response.status}`);
|
||||
|
||||
const data = response.data;
|
||||
console.log(`响应数据:`, JSON.stringify(data, null, 2));
|
||||
|
||||
const allData = [];
|
||||
|
||||
if (data && data.data && data.data['sh' + symbol] && data.data['sh' + symbol].day) {
|
||||
const klineData = data.data['sh' + symbol].day;
|
||||
console.log(`数据条数: ${klineData.length}`);
|
||||
|
||||
for (const item of klineData) {
|
||||
allData.push({
|
||||
date: item[0],
|
||||
open: parseFloat(item[1]),
|
||||
close: parseFloat(item[2]),
|
||||
high: parseFloat(item[3]),
|
||||
low: parseFloat(item[4]),
|
||||
volume: parseFloat(item[5]),
|
||||
amount: 0, // 腾讯财经API没有返回成交额数据,设置为0
|
||||
source: '腾讯财经API',
|
||||
fetchTime: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log('数据条数: 0');
|
||||
}
|
||||
|
||||
allData.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
console.log(`\n共获取到 ${allData.length} 条沪深300指数数据`);
|
||||
|
||||
return {
|
||||
symbol: 'sh000300',
|
||||
name: '沪深300指数',
|
||||
startDate,
|
||||
endDate,
|
||||
count: allData.length,
|
||||
data: allData,
|
||||
fetchTime: new Date().toISOString(),
|
||||
note: '数据来源: 腾讯财经API'
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取沪深300指数数据失败:', error.message);
|
||||
if (error.response) {
|
||||
console.error(`HTTP状态码: ${error.response.status}`);
|
||||
console.error(`响应数据:`, error.response.data);
|
||||
}
|
||||
|
||||
// 重试机制
|
||||
if (retryCount > 0) {
|
||||
console.log(`正在重试... (剩余重试次数: ${retryCount - 1})`);
|
||||
// 等待1秒后重试
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
return fetch(startDate, endDate, retryCount - 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
fetch
|
||||
};
|
||||
|
||||
const isMainModule = () => {
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
const mainFile = process.argv[1];
|
||||
return currentFile === mainFile;
|
||||
};
|
||||
|
||||
function parseCommandLineArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
let startDate = null;
|
||||
let endDate = null;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--start' && i + 1 < args.length) {
|
||||
startDate = args[i + 1];
|
||||
i++;
|
||||
} else if (args[i] === '--end' && i + 1 < args.length) {
|
||||
endDate = args[i + 1];
|
||||
i++;
|
||||
} else if (args[i] === '--help') {
|
||||
console.log('使用方法:');
|
||||
console.log(' bun sh300.fetcher.js [选项]');
|
||||
console.log('');
|
||||
console.log('选项:');
|
||||
console.log(' --start YYYY-MM-DD 指定开始日期(默认:30天前)');
|
||||
console.log(' --end YYYY-MM-DD 指定结束日期(默认:今天)');
|
||||
console.log(' --help 显示帮助信息');
|
||||
console.log('');
|
||||
console.log('示例:');
|
||||
console.log(' bun sh300.fetcher.js');
|
||||
console.log(' bun sh300.fetcher.js --start 2024-01-01 --end 2024-01-31');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
const { startDate, endDate } = parseCommandLineArgs();
|
||||
|
||||
console.log('='.repeat(50));
|
||||
console.log('沪深300指数数据获取脚本');
|
||||
console.log('='.repeat(50));
|
||||
|
||||
fetch(startDate, endDate)
|
||||
.then(result => {
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log('获取结果:');
|
||||
console.log(` 指数代码: ${result.symbol}`);
|
||||
console.log(` 指数名称: ${result.name}`);
|
||||
console.log(` 开始日期: ${result.startDate}`);
|
||||
console.log(` 结束日期: ${result.endDate}`);
|
||||
console.log(` 数据条数: ${result.count}`);
|
||||
console.log(` 获取时间: ${result.fetchTime}`);
|
||||
console.log(` 提示: ${result.note}`);
|
||||
if (result.count > 0) {
|
||||
console.log('-'.repeat(50));
|
||||
console.log('指数数据:');
|
||||
result.data.forEach(item => {
|
||||
console.log(` ${item.date} 开盘: ${item.open} 最高: ${item.high} 最低: ${item.low} 收盘: ${item.close}`);
|
||||
});
|
||||
}
|
||||
console.log('='.repeat(50));
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('执行失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const FRANKFURTER_API = 'https://api.frankfurter.app';
|
||||
|
||||
async function fetch(startDate = null, endDate = null) {
|
||||
try {
|
||||
const currentDate = new Date();
|
||||
|
||||
if (!startDate) {
|
||||
startDate = new Date(currentDate);
|
||||
startDate.setDate(startDate.getDate() - 30);
|
||||
startDate = startDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
if (!endDate) {
|
||||
endDate = currentDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
console.log('正在获取人民币美元汇率数据...');
|
||||
console.log(`时间范围: ${startDate} 至 ${endDate}`);
|
||||
console.log(`数据源: Frankfurter API (欧洲央行汇率数据)`);
|
||||
|
||||
const response = await axios.get(`${FRANKFURTER_API}/${startDate}..${endDate}`, {
|
||||
params: {
|
||||
from: 'USD',
|
||||
to: 'CNY'
|
||||
},
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
console.log(`\nAPI状态: ${response.status}`);
|
||||
console.log(`数据条数: ${Object.keys(data.rates || {}).length}`);
|
||||
|
||||
const allRates = [];
|
||||
|
||||
if (data.rates) {
|
||||
for (const [date, rateObj] of Object.entries(data.rates)) {
|
||||
const rateValue = rateObj.CNY;
|
||||
allRates.push({
|
||||
date: date,
|
||||
currency: 'USD/CNY',
|
||||
centerPrice: parseFloat(rateValue.toFixed(4)),
|
||||
sellingRate: parseFloat(rateValue.toFixed(4)),
|
||||
buyingRate: parseFloat(rateValue.toFixed(4)),
|
||||
source: '欧洲央行 (Frankfurter API)',
|
||||
fetchTime: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
allRates.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
console.log(`\n共获取到 ${allRates.length} 条汇率数据`);
|
||||
|
||||
return {
|
||||
currency: 'USD/CNY',
|
||||
startDate,
|
||||
endDate,
|
||||
count: allRates.length,
|
||||
data: allRates,
|
||||
fetchTime: new Date().toISOString(),
|
||||
note: '数据来源: 欧洲央行, 通过 Frankfurter API 提供'
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取汇率数据失败:', error.message);
|
||||
if (error.response) {
|
||||
console.error(`HTTP状态码: ${error.response.status}`);
|
||||
console.error(`响应数据:`, error.response.data);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
fetch
|
||||
};
|
||||
|
||||
const isMainModule = () => {
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
const mainFile = process.argv[1];
|
||||
return currentFile === mainFile;
|
||||
};
|
||||
|
||||
function parseCommandLineArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
let startDate = null;
|
||||
let endDate = null;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--start' && i + 1 < args.length) {
|
||||
startDate = args[i + 1];
|
||||
i++;
|
||||
} else if (args[i] === '--end' && i + 1 < args.length) {
|
||||
endDate = args[i + 1];
|
||||
i++;
|
||||
} else if (args[i] === '--help') {
|
||||
console.log('使用方法:');
|
||||
console.log(' bun usd-cny-rate.fetcher.js [选项]');
|
||||
console.log('');
|
||||
console.log('选项:');
|
||||
console.log(' --start YYYY-MM-DD 指定开始日期(默认:30天前)');
|
||||
console.log(' --end YYYY-MM-DD 指定结束日期(默认:今天)');
|
||||
console.log(' --help 显示帮助信息');
|
||||
console.log('');
|
||||
console.log('示例:');
|
||||
console.log(' bun usd-cny-rate.fetcher.js');
|
||||
console.log(' bun usd-cny-rate.fetcher.js --start 2024-01-01 --end 2024-01-31');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
const { startDate, endDate } = parseCommandLineArgs();
|
||||
|
||||
console.log('='.repeat(50));
|
||||
console.log('人民币美元汇率获取脚本');
|
||||
console.log('='.repeat(50));
|
||||
|
||||
fetch(startDate, endDate)
|
||||
.then(result => {
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log('获取结果:');
|
||||
console.log(` 货币对: ${result.currency}`);
|
||||
console.log(` 开始日期: ${result.startDate}`);
|
||||
console.log(` 结束日期: ${result.endDate}`);
|
||||
console.log(` 数据条数: ${result.count}`);
|
||||
console.log(` 获取时间: ${result.fetchTime}`);
|
||||
console.log(` 提示: ${result.note}`);
|
||||
if (result.count > 0) {
|
||||
console.log('-'.repeat(50));
|
||||
console.log('汇率数据:');
|
||||
result.data.forEach(item => {
|
||||
console.log(` ${item.date} 中间价: ${item.centerPrice}`);
|
||||
});
|
||||
}
|
||||
console.log('='.repeat(50));
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('执行失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import axios from 'axios'
|
||||
|
||||
// World Bank API 数据源
|
||||
// 文档: https://datahelpdesk.worldbank.org/knowledgebase/articles/898581
|
||||
// 优点: 官方、JSON、免 key、稳定, 中国数据滞后约 1 年 (这是全球宏观数据物理上限)
|
||||
// 接口: GET /v2/country/{code}/indicator/{id}?format=json&date={startYear}:{endYear}&per_page=100
|
||||
|
||||
const WB_BASE = 'https://api.worldbank.org/v2'
|
||||
const TIMEOUT = 15000
|
||||
|
||||
// 6 个核心宏观指标
|
||||
// - 中国 GDP (现价美元) - 国民经济总规模
|
||||
// - 中国 GDP 增速 - 增长动能
|
||||
// - 中国 CPI 同比 - 通胀
|
||||
// - 中国失业率 - 就业
|
||||
// - 美国 GDP (现价美元) - 对标
|
||||
// - 美国 CPI 同比 - 对标
|
||||
export const WB_INDICATORS = [
|
||||
{ id: 'NY.GDP.MKTP.CD', name: 'china-gdp-usd', groupName: 'china-macro', title: '中国 GDP', unit: '万亿美元', scale: 1e-8, digits: 2 },
|
||||
{ id: 'NY.GDP.MKTP.KD.ZG',name: 'china-gdp-growth', groupName: 'china-macro', title: '中国 GDP 增速', unit: '%', scale: 1, digits: 2 },
|
||||
{ id: 'FP.CPI.TOTL.ZG', name: 'china-cpi', groupName: 'china-macro', title: '中国 CPI', unit: '%', scale: 1, digits: 2 },
|
||||
{ id: 'SL.UEM.TOTL.ZS', name: 'china-unemploy', groupName: 'china-macro', title: '中国失业率', unit: '%', scale: 1, digits: 2 },
|
||||
{ id: 'NY.GDP.MKTP.CD', name: 'usa-gdp-usd', groupName: 'usa-macro', title: '美国 GDP', unit: '万亿美元', scale: 1e-8, digits: 2 },
|
||||
{ id: 'FP.CPI.TOTL.ZG', name: 'usa-cpi', groupName: 'usa-macro', title: '美国 CPI', unit: '%', scale: 1, digits: 2 },
|
||||
]
|
||||
|
||||
// World Bank 中国是 CHN, 美国是 USA
|
||||
function countryCodeOf(name) {
|
||||
if (name.startsWith('china-')) return 'CHN'
|
||||
if (name.startsWith('usa-')) return 'USA'
|
||||
throw new Error('Unknown country for ' + name)
|
||||
}
|
||||
|
||||
// 拉单个指标从 startYear 到当前年
|
||||
// 返回 { name, groupName, title, unit, rows: [{ year, value }] }
|
||||
// 注意: WB 对连续请求会限流返回 400 Invalid value, 加 3 次退避重试
|
||||
export async function fetchIndicator(meta, startYear = 2015) {
|
||||
const country = countryCodeOf(meta.name)
|
||||
const currentYear = new Date().getFullYear()
|
||||
const url = `${WB_BASE}/country/${country}/indicator/${meta.id}?format=json&date=${startYear}:${currentYear}&per_page=200`
|
||||
|
||||
let resp
|
||||
const delays = [0, 1500, 4000]
|
||||
for (let attempt = 0; attempt < delays.length; attempt++) {
|
||||
if (delays[attempt]) await new Promise((r) => setTimeout(r, delays[attempt]))
|
||||
try {
|
||||
resp = await axios.get(url, { timeout: TIMEOUT, headers: { 'User-Agent': 'iboard-refresh/1.0' } })
|
||||
break
|
||||
} catch (e) {
|
||||
if (attempt === delays.length - 1) throw e
|
||||
console.log(`[WB] ${meta.name} 请求失败, ${delays[attempt + 1] / 1000}s 后重试 (${e.message})`)
|
||||
}
|
||||
}
|
||||
// WB 返回: [{page,pages,per_page,total,...}, [{indicator, country, value, date}, ...]]
|
||||
const data = resp.data
|
||||
if (!Array.isArray(data) || data.length < 2 || !Array.isArray(data[1])) {
|
||||
throw new Error('World Bank 返回结构异常: ' + JSON.stringify(data).slice(0, 200))
|
||||
}
|
||||
const points = data[1]
|
||||
.filter((p) => p.value !== null && p.value !== undefined)
|
||||
.map((p) => ({
|
||||
year: String(p.date),
|
||||
value: Number((Number(p.value) * meta.scale).toFixed(meta.digits)),
|
||||
}))
|
||||
.sort((a, b) => Number(a.year) - Number(b.year))
|
||||
|
||||
return {
|
||||
name: meta.name,
|
||||
groupName: meta.groupName,
|
||||
data: {
|
||||
unit: meta.unit,
|
||||
title: meta.title,
|
||||
source: 'World Bank API',
|
||||
rows: points,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const META = {
|
||||
name: 'wb-indicators',
|
||||
description: 'World Bank 官方 API 宏观数据',
|
||||
docs: 'https://datahelpdesk.worldbank.org/knowledgebase/articles/898581',
|
||||
}
|
||||
|
||||
// 拉全部 (返回符合 fetcher 契约的 [{name, groupName, data}, ...])
|
||||
// 错误隔离: 单个指标失败 -> 跳过 + 记日志, 不影响其他
|
||||
// opts.skipNames: Set<string> — DB 已存在的指标名, 跳过不拉 (WB 免费但也要省请求)
|
||||
export async function fetchAll(opts = {}) {
|
||||
const skip = opts.skipNames || new Set()
|
||||
const startYear = 2015
|
||||
const results = []
|
||||
for (const m of WB_INDICATORS) {
|
||||
if (skip.has(m.name)) {
|
||||
console.log(`[WB] ${m.name} 跳过 (DB 已存在)`)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const r = await fetchIndicator(m, startYear)
|
||||
console.log(`[WB] ${r.name} OK, ${r.data.rows.length} rows (${r.data.rows[0]?.year}..${r.data.rows.at(-1)?.year})`)
|
||||
results.push(r)
|
||||
} catch (e) {
|
||||
console.error(`[WB] ${m.name} FAIL: ${e.message}`)
|
||||
}
|
||||
}
|
||||
// 全部被 DB 优先跳过时, 返回显式标记而不是空数组 (避免被误判为失败)
|
||||
if (results.length === 0 && WB_INDICATORS.every((m) => skip.has(m.name))) {
|
||||
return { skipped: true, reason: 'DB 已存在全部指标' }
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user