// ═══════════════════════════════════════════════════════════ // fundamentals.js — server 端抓取公司財報(金鑰不外洩、無需付費) // 主來源:Yahoo quoteSummary(季/年 損益、現金流、資產負債 + 估值) // 後備: SEC EDGAR companyfacts(美股,官方、免金鑰) // 皆正規化成同一形狀供 fincheck.js 使用。 // 沿用 fred.js 的 server 端 fetch + User-Agent 模式。 // ═══════════════════════════════════════════════════════════ const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36'; const SEC_UA = 'EmmyInvestDashboard/1.0 (personal learning tool; contact@example.com)'; async function jget(url, headers = {}, ms = 9000) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), ms); try { const res = await fetch(url, { headers: { 'User-Agent': UA, ...headers }, signal: ctrl.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } finally { clearTimeout(timer); } } const num = (x) => (x && typeof x === 'object' && 'raw' in x) ? x.raw : (typeof x === 'number' ? x : null); // 用結束年月當季標籤(避免不同公司會計年度造成的「第幾季」混淆) function quarterLabel(endISO) { const d = new Date(endISO); if (isNaN(d)) return String(endISO || ''); return `${d.getUTCFullYear()}/${String(d.getUTCMonth() + 1).padStart(2, '0')}`; } const pct = (a, b) => (a != null && b) ? (a / b) * 100 : null; function enrich(p) { p.grossMargin = pct(p.grossProfit, p.revenue); p.operatingMargin = pct(p.operatingIncome, p.revenue); p.netMargin = pct(p.netIncome, p.revenue); return p; } // ─── 現價(Yahoo chart v8,免 crumb;query1 失敗改 query2)─── async function getPrice(symbol) { for (const host of ['query1', 'query2']) { try { const d = await jget(`https://${host}.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?range=5d&interval=1d`); const r = d?.chart?.result?.[0]; if (!r) continue; let p = r.meta?.regularMarketPrice; if (p == null) { const cl = (r.indicators?.quote?.[0]?.close || []).filter(x => x != null); p = cl.length ? cl[cl.length - 1] : null; } return { price: p != null ? p : null, name: r.meta?.shortName || r.meta?.longName || null, currency: r.meta?.currency || null }; } catch { /* try next host */ } } return { price: null, name: null, currency: null }; } // ─── Yahoo quoteSummary(需 cookie + crumb)─── let _auth = { cookie: null, crumb: null, at: 0 }; async function yahooAuth() { if (_auth.crumb && Date.now() - _auth.at < 3600e3) return _auth; const r1 = await fetch('https://fc.yahoo.com/', { headers: { 'User-Agent': UA } }).catch(() => null); const cookie = (r1 && (r1.headers.get('set-cookie') || '')).split(';')[0] || ''; const r2 = await fetch('https://query2.finance.yahoo.com/v1/test/getcrumb', { headers: { 'User-Agent': UA, Cookie: cookie } }); const crumb = (await r2.text()).trim(); if (!crumb || crumb.includes('<')) throw new Error('無法取得 Yahoo crumb'); _auth = { cookie, crumb, at: Date.now() }; return _auth; } async function fetchYahoo(symbol) { const { cookie, crumb } = await yahooAuth(); const mods = [ 'price', 'summaryDetail', 'defaultKeyStatistics', 'financialData', 'incomeStatementHistory', 'incomeStatementHistoryQuarterly', 'cashflowStatementHistory', 'cashflowStatementHistoryQuarterly', 'balanceSheetHistoryQuarterly', ].join('%2C'); const url = `https://query2.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(symbol)}?modules=${mods}&crumb=${encodeURIComponent(crumb)}`; const d = await jget(url, { Cookie: cookie }); const r = d?.quoteSummary?.result?.[0]; if (!r) throw new Error('Yahoo 無資料'); const incQ = r.incomeStatementHistoryQuarterly?.incomeStatementHistory || []; const cfQ = r.cashflowStatementHistoryQuarterly?.cashflowStatements || []; const incY = r.incomeStatementHistory?.incomeStatementHistory || []; const cfY = r.cashflowStatementHistory?.cashflowStatements || []; const bsQ = r.balanceSheetHistoryQuarterly?.balanceSheetStatements || []; const shares = num(r.defaultKeyStatistics?.sharesOutstanding); const cfByDate = {}; for (const c of cfQ) cfByDate[num(c.endDate)] = c; const quarters = incQ.map(s => { const end = num(s.endDate); const cf = cfByDate[end] || {}; const netIncome = num(s.netIncome); return enrich({ end: end ? new Date(end * 1000).toISOString().slice(0, 10) : null, label: end ? quarterLabel(end * 1000) : '', revenue: num(s.totalRevenue), grossProfit: num(s.grossProfit), operatingIncome: num(s.operatingIncome), netIncome, eps: (netIncome != null && shares) ? netIncome / shares : null, capex: num(cf.capitalExpenditures), ocf: num(cf.totalCashFromOperatingActivities), }); }); const cfYByDate = {}; for (const c of cfY) cfYByDate[num(c.endDate)] = c; const annual = incY.map(s => { const end = num(s.endDate); const cf = cfYByDate[end] || {}; return enrich({ end: end ? new Date(end * 1000).toISOString().slice(0, 10) : null, label: end ? String(new Date(end * 1000).getUTCFullYear()) : '', revenue: num(s.totalRevenue), grossProfit: num(s.grossProfit), netIncome: num(s.netIncome), capex: num(cf.capitalExpenditures), ocf: num(cf.totalCashFromOperatingActivities), }); }); const bs = bsQ[0] || {}; const totalAssets = num(bs.totalAssets); const totalLiabilities = num(bs.totalLiab); const balance = { end: num(bs.endDate) ? new Date(num(bs.endDate) * 1000).toISOString().slice(0, 10) : null, totalAssets, totalLiabilities, cash: num(bs.cash), totalDebt: (num(bs.shortLongTermDebt) || 0) + (num(bs.longTermDebt) || 0) || null, debtToAssets: pct(totalLiabilities, totalAssets), }; return { source: 'Yahoo Finance', name: num(r.price?.shortName) || r.price?.shortName || r.price?.longName || symbol, currency: r.price?.currency || null, peTrailing: num(r.summaryDetail?.trailingPE), marketCap: num(r.price?.marketCap), quarters, annual, balance, }; } // ─── SEC EDGAR companyfacts(美股後備)─── let _tickerMap = null; async function tickerToCik(symbol) { if (!_tickerMap) { const d = await jget('https://www.sec.gov/files/company_tickers.json', { 'User-Agent': SEC_UA }); _tickerMap = {}; for (const k of Object.keys(d)) _tickerMap[String(d[k].ticker).toUpperCase()] = { cik: String(d[k].cik_str).padStart(10, '0'), name: d[k].title }; } return _tickerMap[symbol] || null; } // 公司常會在不同年度換 XBRL 概念名(如營收 tag 變更),因此把所有候選概念 // 的序列合併成一條時間線,再用 durationSeries / instantLatest 去重取最新。 function mergeUnits(facts, names, unit = 'USD') { let out = []; for (const n of names) { const u = facts[n]?.units?.[unit]; if (u) out = out.concat(u); } return out.length ? out : null; } function pickShares(facts, names) { let out = []; for (const n of names) { const u = facts[n]?.units?.['USD/shares'] || facts[n]?.units?.shares; if (u) out = out.concat(u); } return out.length ? out : null; } // 取「期間型」概念的最近 N 期(quarterly ≈ 80-100 天 / annual ≈ 350-380 天) function durationSeries(units, kind) { if (!units) return []; const lo = kind === 'q' ? 80 : 330, hi = kind === 'q' ? 100 : 380; const byEnd = {}; for (const e of units) { if (!e.start || !e.end) continue; const days = (new Date(e.end) - new Date(e.start)) / 86400000; if (days < lo || days > hi) continue; // 同一 end 取較新申報(10-Q/10-K) if (!byEnd[e.end] || (e.filed || '') > (byEnd[e.end].filed || '')) byEnd[e.end] = e; } return Object.values(byEnd).sort((a, b) => (a.end < b.end ? 1 : -1)); } function instantLatest(units) { if (!units) return null; const sorted = units.filter(e => e.end).sort((a, b) => (a.end < b.end ? 1 : -1)); return sorted[0] || null; } async function fetchEdgar(symbol) { const hit = await tickerToCik(symbol); if (!hit) throw new Error('SEC EDGAR 查無此美股代號'); const cf = await jget(`https://data.sec.gov/api/xbrl/companyfacts/CIK${hit.cik}.json`, { 'User-Agent': SEC_UA }); const g = cf.facts?.['us-gaap'] || {}; const REV = ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'RevenueFromContractWithCustomerIncludingAssessedTax', 'SalesRevenueNet', 'SalesRevenueGoodsNet']; const revU = mergeUnits(g, REV); const gpU = mergeUnits(g, ['GrossProfit']); const oiU = mergeUnits(g, ['OperatingIncomeLoss']); const niU = mergeUnits(g, ['NetIncomeLoss']); const epsU = pickShares(g, ['EarningsPerShareDiluted', 'EarningsPerShareBasic']); const capU = mergeUnits(g, ['PaymentsToAcquirePropertyPlantAndEquipment', 'PaymentsToAcquireProductiveAssets']); const ocfU = mergeUnits(g, ['NetCashProvidedByUsedInOperatingActivities', 'NetCashProvidedByUsedInOperatingActivitiesContinuingOperations']); const mapByEnd = (series) => { const m = {}; for (const e of series) m[e.end] = e.val; return m; }; const revQ = durationSeries(revU, 'q'), gpQ = durationSeries(gpU, 'q'), oiQ = durationSeries(oiU, 'q'), niQ = durationSeries(niU, 'q'), epsQ = durationSeries(epsU, 'q'); const gpM = mapByEnd(gpQ), oiM = mapByEnd(oiQ), niM = mapByEnd(niQ), epsM = mapByEnd(epsQ); const quarters = revQ.slice(0, 8).map(e => enrich({ end: e.end, label: quarterLabel(e.end), revenue: e.val, grossProfit: gpM[e.end] ?? null, operatingIncome: oiM[e.end] ?? null, netIncome: niM[e.end] ?? null, eps: epsM[e.end] ?? null, capex: null, ocf: null, })); const revY = durationSeries(revU, 'y'), niY = durationSeries(niU, 'y'), capY = durationSeries(capU, 'y'), ocfY = durationSeries(ocfU, 'y'), gpY = durationSeries(gpU, 'y'); const niYM = mapByEnd(niY), capYM = mapByEnd(capY), ocfYM = mapByEnd(ocfY), gpYM = mapByEnd(gpY); const annual = revY.slice(0, 5).map(e => enrich({ end: e.end, label: String(new Date(e.end).getUTCFullYear()), revenue: e.val, grossProfit: gpYM[e.end] ?? null, netIncome: niYM[e.end] ?? null, capex: capYM[e.end] != null ? -Math.abs(capYM[e.end]) : null, ocf: ocfYM[e.end] ?? null, })); const assetsE = instantLatest(mergeUnits(g, ['Assets'])); const liabE = instantLatest(mergeUnits(g, ['Liabilities'])); const cashE = instantLatest(mergeUnits(g, ['CashAndCashEquivalentsAtCarryingValue', 'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents'])); const balance = { end: assetsE?.end || null, totalAssets: assetsE?.val ?? null, totalLiabilities: liabE?.val ?? null, cash: cashE?.val ?? null, totalDebt: null, debtToAssets: pct(liabE?.val, assetsE?.val), }; if (!quarters.length && !annual.length) throw new Error('EDGAR 無 us-gaap 財報資料(可能為以 IFRS 申報的外國發行人,建議改查其美股同業)'); return { source: 'SEC EDGAR', name: cf.entityName || hit.name || symbol, currency: 'USD', peTrailing: null, marketCap: null, quarters, annual, balance }; } // ─── 輕量「是否有新財報」探針(美股;只抓 submissions,比 companyfacts 小很多)─── // 回傳最近一筆財報類申報的識別碼,用來判斷自上次抓取後是否出現新財報。 export async function getLatestFilingInfo(symbol) { const hit = await tickerToCik(symbol); if (!hit) return null; const d = await jget(`https://data.sec.gov/submissions/CIK${hit.cik}.json`, { 'User-Agent': SEC_UA }); const f = d.filings?.recent; if (!f || !Array.isArray(f.form)) return null; const FORMS = new Set(['10-Q', '10-K', '20-F', '40-F', '6-K']); for (let i = 0; i < f.form.length; i++) { if (FORMS.has(f.form[i])) return { accn: f.accessionNumber?.[i] || null, form: f.form[i], filingDate: f.filingDate?.[i] || null }; } return null; } // ─── 對外:取得正規化財報(價格 + 兩來源擇優)─── export async function getFundamentals(symbol) { const priceInfo = await getPrice(symbol); let data = null, errs = []; try { data = await fetchYahoo(symbol); if (!data.quarters?.length && !data.annual?.length) { errs.push('Yahoo 無財報期間'); data = null; } } catch (e) { errs.push('Yahoo: ' + (e?.message || e)); } if (!data) { try { data = await fetchEdgar(symbol); } catch (e) { errs.push('EDGAR: ' + (e?.message || e)); } } if (!data) throw new Error('兩個來源都取不到財報(' + errs.join(';') + ')'); const asOf = data.quarters?.[0]?.label || data.annual?.[0]?.label || null; return { symbol, name: data.name || priceInfo.name || symbol, currency: data.currency || priceInfo.currency || 'USD', source: data.source, asOf, price: priceInfo.price, peTrailing: data.peTrailing ?? null, marketCap: data.marketCap ?? null, quarters: data.quarters || [], annual: data.annual || [], balance: data.balance || {}, }; }