diff --git a/.gitignore b/.gitignore index 75930d0..a540450 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ lerna-debug.log* *.swo *~ dist/* + +# local env (contains API keys) +/.env diff --git a/package.json b/package.json index d910111..d8d5e34 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "start": "node --experimental-strip-types --no-warnings server/index.js", "db:migrate": "prisma migrate deploy", "db:seed": "node scripts/seed.cjs", + "data:refresh": "node --no-warnings scripts/refresh-indicators.cjs", + "data:discover": "node --no-warnings scripts/discover-mcp-tools.cjs", "icons": "node scripts/generate-icons.mjs", "tauri:dev": "tauri dev", "tauri:build": "tauri build", diff --git a/readme b/readme index d3507b6..3c49af3 100644 --- a/readme +++ b/readme @@ -4,3 +4,20 @@ - 数据主要是放各种经济数据和图表。 - 策略是放按照经济理论组织的相关数据和图表的对比。 + + + +## 新闻模块 +增加一个新闻模块,数据来源是每日夜间 3点,通过妙想 MCP 获取国内外最新经济政治新闻,按影响大小排序10条。 +存入数据库。 + + +## 报警模块 +World Bank 中的数据只用来对比妙想 MCP 拉取的数据正确性。当同一时间的同一项数据不同时,记录一个报警。 + + +## 首页 +首页显示新闻模块和报警模块的数据,按照时间最新优先,无限滚动,直到数据库中没有数据为止。 + + + diff --git a/scripts/debug-mcp.cjs b/scripts/debug-mcp.cjs new file mode 100644 index 0000000..423b327 --- /dev/null +++ b/scripts/debug-mcp.cjs @@ -0,0 +1,44 @@ +require('dotenv').config() +const axios = require('axios') + +async function call(tool, query) { + const base = process.env.EASTMONEY_MCP_BASE || 'https://mxapi.eastmoney.com/mxds/mcp' + const payload = { + jsonrpc: '2.0', id: 1, method: 'tools/call', + params: { name: tool, arguments: { query } }, + } + const resp = await axios.post(base, payload, { + timeout: 60000, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'em_api_key': process.env.EM_API_KEY, + }, + validateStatus: () => true, + responseType: 'text', + transformResponse: [(d) => d], + }) + const body = String(resp.data) + try { + const j = JSON.parse(body) + const text = j.result.content[0].text + const parsed = JSON.parse(text) + console.log(`\n=== ${tool}: "${query}" ===`) + console.log('sheets 数量:', parsed.data.length) + parsed.data.forEach((s, i) => { + console.log(`\nsheet[${i}]:`) + console.log(' sheetName:', s.sheetName) + console.log(' columns[0..3]:', s.columns.slice(0, 4)) + console.log(' items[0][0..3]:', s.items[0] ? s.items[0].slice(0, 4) : '(无)') + }) + } catch (e) { + console.log('解析失败:', e.message) + console.log(body.slice(0, 1000)) + } +} + +async function main() { + await call('mx_macro_data', '查询中国 2020-2025 年 CPI 月度同比数据') + await call('mx_macro_data', '查询中国 2015-2024 年 GDP 同比增速年度数据') +} +main().catch(console.error) diff --git a/scripts/discover-mcp-tools.cjs b/scripts/discover-mcp-tools.cjs new file mode 100644 index 0000000..6b4345c --- /dev/null +++ b/scripts/discover-mcp-tools.cjs @@ -0,0 +1,32 @@ +/** + * 列出东方财富妙想 MCP 暴露的所有工具 + * 用法: npm run data:discover + * 输出: tool 名称 + 描述 + 输入 schema + */ +require('dotenv').config() + +async function main() { + if (!process.env.EM_API_KEY) { + console.error('缺少 EM_API_KEY (在 .env 配)') + process.exit(1) + } + const { listAvailableTools } = await import('./fetchers/eastmoney-mcp.fetcher.js') + console.log('[Discover] 调妙想 MCP tools/list...') + const tools = await listAvailableTools() + console.log(`[Discover] 找到 ${tools.length} 个工具\n`) + for (const t of tools) { + console.log(`── ${t.name} ${'─'.repeat(Math.max(0, 60 - t.name.length))}`) + if (t.description) console.log(` ${t.description}`) + if (t.inputSchema) { + console.log(' inputSchema:') + const s = JSON.stringify(t.inputSchema, null, 2) + s.split('\n').forEach((line) => console.log(' ' + line)) + } + console.log('') + } +} + +main().catch((e) => { + console.error('[Discover] 失败:', e.message) + process.exit(1) +}) diff --git a/scripts/fetchers/eastmoney-mcp.fetcher.js b/scripts/fetchers/eastmoney-mcp.fetcher.js new file mode 100644 index 0000000..6ed104f --- /dev/null +++ b/scripts/fetchers/eastmoney-mcp.fetcher.js @@ -0,0 +1,210 @@ +// 东方财富妙想 MCP 数据源 +// 协议: StreamableHttp (MCP 2025-03-26 spec), HTTP POST + JSON-RPC 2.0 +// - 认证: header `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], + }, +] + +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() // 从老到新 +} + +// ====== 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 — DB 已存在的指标名, 跳过不调 MCP (省 token) +// opts.onlyNames: Set — 白名单, 只跑这些 (用于补跑单个) +export async function fetchAll(opts = {}) { + if (INDICATORS.length === 0) { + throw new Error('eastmoney-mcp: INDICATORS 数组为空, 加新指标见文件顶部注释') + } + const skip = opts.skipNames || 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)) { + 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))) { + return { skipped: true, reason: 'DB 已存在全部指标' } + } + return results +} + + + + diff --git a/scripts/fetchers/index.js b/scripts/fetchers/index.js new file mode 100644 index 0000000..7e3a869 --- /dev/null +++ b/scripts/fetchers/index.js @@ -0,0 +1,82 @@ +// Fetcher 注册表: 列出所有启用的数据源 +// 加新数据源只要: +// 1. 写一个 scripts/fetchers/.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 — DB 优先, 已存在的指标名, 透传给 fetcher +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 }) + // 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 +} + diff --git a/scripts/fetchers/wb-indicators.fetcher.js b/scripts/fetchers/wb-indicators.fetcher.js new file mode 100644 index 0000000..75b0d5e --- /dev/null +++ b/scripts/fetchers/wb-indicators.fetcher.js @@ -0,0 +1,113 @@ +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 — 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 +} + + + diff --git a/scripts/refresh-indicators.cjs b/scripts/refresh-indicators.cjs new file mode 100644 index 0000000..cdc14e6 --- /dev/null +++ b/scripts/refresh-indicators.cjs @@ -0,0 +1,97 @@ +/** + * 拉所有启用的数据源, upsert 到 Indicator 表 + * 用法: npm run data:refresh + * 幂等: 同 name 覆盖 data, 跨源独立, 错误隔离 + * + * DB 优先策略: + * 1. 先从 DB 查已存在的 name 集合 + * 2. 把集合传给每个 fetcher, fetcher 跳过已存在的 + * 3. 只对 DB 没有的指标调远程拉取, 节省 token (尤其妙想 MCP 这种 LLM 接口) + * + * 加新数据源: 写 scripts/fetchers/.fetcher.js 导出 META + fetchAll(opts), + * 然后在 scripts/fetchers/index.js 的 FETCHERS 注册 + */ +require('dotenv').config() +const mysql = require('mysql2/promise') + +function parseUrl(url) { + const u = new URL(url) + return { + host: u.hostname, + port: Number(u.port) || 3306, + user: decodeURIComponent(u.username), + password: decodeURIComponent(u.password), + database: u.pathname.replace(/^\//, ''), + } +} + +async function upsertIndicator(conn, payload) { + const sql = + 'INSERT INTO `Indicator` (`name`, `groupName`, `data`, `createdAt`, `updatedAt`) ' + + 'VALUES (?, ?, ?, NOW(3), NOW(3)) ' + + 'ON DUPLICATE KEY UPDATE `groupName` = VALUES(`groupName`), `data` = VALUES(`data`), `updatedAt` = NOW(3)' + const [r] = await conn.execute(sql, [payload.name, payload.groupName, JSON.stringify(payload.data)]) + return r.affectedRows +} + +async function fetchExistingNames(conn) { + const [rows] = await conn.execute('SELECT `name` FROM `Indicator`') + return new Set(rows.map((r) => r.name)) +} + +async function main() { + if (!process.env.DATABASE_URL) { + console.error('缺少 DATABASE_URL 环境变量') + process.exit(1) + } + const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL)) + try { + // 1) 查 DB 已存在的指标名 + const existing = await fetchExistingNames(conn) + console.log(`[Refresh] DB 已有 ${existing.size} 个指标: ${[...existing].join(', ') || '(空)'}`) + + // 2) 跑所有 fetcher, 传入 existing (DB 优先) + const { runFetchers } = await import('./fetchers/index.js') + const fetcherResults = await runFetchers({ skipNames: existing }) + + // 3) 汇总 + const allItems = [] + let skipped = 0, failed = 0 + for (const r of fetcherResults) { + if (r.skipped) { skipped++; continue } + if (!r.ok) { failed++; continue } + // fetcher 内部已经按 skipNames 过滤过, 这里再做一次防御性去重 + for (const it of r.items) { + if (existing.has(it.name)) continue + allItems.push(it) + } + } + console.log(`\n[Refresh] 汇总: ${allItems.length} 个新指标 (跳过 ${skipped} 个数据源, 失败 ${failed} 个, DB 已存在 ${existing.size} 个)`) + + if (failed > 0) { + console.error('[Refresh] 警告: 有数据源失败, 仅写入成功的指标') + } + + if (allItems.length === 0) { + console.log('[Refresh] 无新指标, 不写库 (DB 优先策略生效)') + return + } + + // 4) 写库 + console.log('[Refresh] 写入数据库...') + let ins = 0, upd = 0 + for (const it of allItems) { + const n = await upsertIndicator(conn, { name: it.name, groupName: it.groupName, data: it.data }) + if (n === 1) ins++ + else if (n === 2) upd++ + } + console.log(`[Refresh] 写入完成: ${ins} 新增, ${upd} 更新\n`) + } finally { + await conn.end() + } +} + +main().catch((e) => { + console.error('[Refresh] 异常:', e) + process.exit(1) +}) diff --git a/scripts/seed.cjs b/scripts/seed.cjs index 334b078..9b831ed 100644 --- a/scripts/seed.cjs +++ b/scripts/seed.cjs @@ -92,32 +92,9 @@ async function main() { } console.log('[Strategy] upsert 策略: ' + n2 + ' 行 (1=新增, 2=更新)'); - const indicators = [ - { name: 'china-bond-1y', groupName: 'china-bond', - data: { unit: '%', title: '中国国债1年', rows: [ - { month: '2024-01', value: 1.85 }, { month: '2024-02', value: 1.82 }, - { month: '2024-03', value: 1.78 }, { month: '2024-04', value: 1.75 }, - { month: '2024-05', value: 1.72 }, { month: '2024-06', value: 1.68 }, - ]}}, - { name: 'china-bond-10y', groupName: 'china-bond', - data: { unit: '%', title: '中国国债10年', rows: [ - { month: '2024-01', value: 2.65 }, { month: '2024-02', value: 2.62 }, - { month: '2024-03', value: 2.58 }, { month: '2024-04', value: 2.55 }, - { month: '2024-05', value: 2.52 }, { month: '2024-06', value: 2.48 }, - ]}}, - { name: 'us-bond-1y', groupName: 'us-bond', - data: { unit: '%', title: '美国国债1年', rows: [ - { month: '2024-01', value: 4.85 }, { month: '2024-02', value: 4.82 }, - { month: '2024-03', value: 4.78 }, { month: '2024-04', value: 4.65 }, - { month: '2024-05', value: 4.55 }, { month: '2024-06', value: 4.45 }, - ]}}, - { name: 'china-lpr-1y', groupName: 'china-lpr', - data: { unit: '%', title: '中国LPR 1年期', rows: [ - { month: '2024-01', value: 3.45 }, { month: '2024-02', value: 3.45 }, - { month: '2024-03', value: 3.45 }, { month: '2024-04', value: 3.40 }, - { month: '2024-05', value: 3.40 }, { month: '2024-06', value: 3.40 }, - ]}}, - ]; + // Indicator 表: 改由 scripts/refresh-indicators.cjs 从 World Bank API 拉取最新数据 + // 旧 4 个金融指标 (国债/LPR) 无稳定 JSON 数据源, 暂不维护 + const indicators = []; let n3 = 0; for (const i of indicators) { n3 += await upsertByName(conn, 'Indicator', diff --git a/src/pages/macro/DataView.jsx b/src/pages/macro/DataView.jsx index a73acac..d6236c1 100644 --- a/src/pages/macro/DataView.jsx +++ b/src/pages/macro/DataView.jsx @@ -7,6 +7,11 @@ import TrendBadge, { calcTrend, lastPoint } from '../../components/TrendBadge.js // 经济数据 // 列表态 (默认): 列出所有指标, 每行显示最新值 + 趋势徽标, 点击进入详情 // 详情态: 顶部 NavBar (返回) + 折线图 + 数据表 +// 统一数据行的时间字段: 兼容 {month}/{year}/{period} 三种存储形状 +function periodOf(r) { + return (r && (r.period || r.month || r.year)) || '' +} + export default function DataView() { const [indicators, setIndicators] = useState([]) const [err, setErr] = useState('') @@ -51,7 +56,7 @@ export default function DataView() { {lp.value}{unit} · {lp.month} : '暂无数据'} + description={lp ? <>{lp.value}{unit} · {periodOf(lp)} : '暂无数据'} extra={
{lp && Math.abs(change) >= 1 && ( @@ -77,6 +82,8 @@ export default function DataView() { function DataDetail({ indicator, onBack }) { const i = indicator const rows = (i.data && i.data.rows) || [] + // 图表 X 轴统一用 period 字段 + const chartRows = rows.map((r) => ({ ...r, period: periodOf(r) })) const lp = lastPoint(i.data) const { trend } = calcTrend(rows) const title = (i.data && i.data.title) || i.name @@ -86,14 +93,14 @@ function DataDetail({ indicator, onBack }) {
单位 {i.data.unit || '-'} · 来源 {i.data.source || 'seed'} · - {lp && <> 最新 {lp.value}{i.data.unit || ''} ({lp.month}) } + {lp && <> 最新 {lp.value}{i.data.unit || ''} ({periodOf(lp)}) }
- + - + @@ -108,7 +115,7 @@ function DataDetail({ indicator, onBack }) { {rows.map((r) => ( - {r.month}{r.value} + {periodOf(r)}{r.value} ))}