2026-06-03 09:21:58 +00:00
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
// 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);
|
2026-06-03 16:42:07 +00:00
|
|
|
|
const parseNum = (s) => {
|
|
|
|
|
|
if (s == null) return null;
|
|
|
|
|
|
const n = Number(String(s).replace(/[$,%\s,]/g, ''));
|
|
|
|
|
|
return Number.isFinite(n) ? n : null;
|
|
|
|
|
|
};
|
|
|
|
|
|
function parseRange(s) {
|
|
|
|
|
|
if (!s) return {};
|
|
|
|
|
|
const parts = String(s).replace(/\$/g, '').split(/\s*(?:[-–]|\/)\s*/).map(parseNum).filter(v => v != null);
|
|
|
|
|
|
return parts.length >= 2 ? { low: Math.min(parts[0], parts[1]), high: Math.max(parts[0], parts[1]) } : {};
|
|
|
|
|
|
}
|
2026-06-03 09:21:58 +00:00
|
|
|
|
// 用結束年月當季標籤(避免不同公司會計年度造成的「第幾季」混淆)
|
|
|
|
|
|
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) {
|
2026-06-03 16:42:07 +00:00
|
|
|
|
let out = {
|
|
|
|
|
|
price: null, name: null, currency: null, exchange: null, marketCap: null, sharesOutstanding: null,
|
|
|
|
|
|
peTrailing: null, targetPrice: null, dividendYield: null, change: null, changePercent: null,
|
|
|
|
|
|
marketTime: null, previousClose: null, dayHigh: null, dayLow: null, volume: null,
|
|
|
|
|
|
avgVolume: null, fiftyTwoWeekHigh: null, fiftyTwoWeekLow: null, fiftyDayAverage: null,
|
|
|
|
|
|
twoHundredDayAverage: null, source: null,
|
|
|
|
|
|
};
|
|
|
|
|
|
try {
|
|
|
|
|
|
const n = await jget(`https://api.nasdaq.com/api/quote/${encodeURIComponent(symbol)}/summary?assetclass=stocks`, {
|
|
|
|
|
|
Accept: 'application/json', Origin: 'https://www.nasdaq.com', Referer: 'https://www.nasdaq.com/',
|
|
|
|
|
|
});
|
|
|
|
|
|
const s = n?.data?.summaryData || {};
|
|
|
|
|
|
const dayRange = parseRange(s.TodayHighLow?.value);
|
|
|
|
|
|
const yearRange = parseRange(s.FiftTwoWeekHighLow?.value || s['52WeekHighLow']?.value);
|
|
|
|
|
|
out.marketCap = parseNum(s.MarketCap?.value);
|
|
|
|
|
|
out.targetPrice = parseNum(s.OneYrTarget?.value);
|
|
|
|
|
|
out.dividendYield = parseNum(s.Yield?.value);
|
|
|
|
|
|
out.previousClose = parseNum(s.PreviousClose?.value);
|
|
|
|
|
|
out.price = out.previousClose;
|
|
|
|
|
|
out.dayHigh = dayRange.high ?? out.dayHigh;
|
|
|
|
|
|
out.dayLow = dayRange.low ?? out.dayLow;
|
|
|
|
|
|
out.volume = parseNum(s.ShareVolume?.value);
|
|
|
|
|
|
out.avgVolume = parseNum(s.AverageVolume?.value);
|
|
|
|
|
|
out.fiftyTwoWeekHigh = yearRange.high ?? out.fiftyTwoWeekHigh;
|
|
|
|
|
|
out.fiftyTwoWeekLow = yearRange.low ?? out.fiftyTwoWeekLow;
|
|
|
|
|
|
out.source = 'Nasdaq';
|
|
|
|
|
|
} catch { /* Yahoo fallback below */ }
|
|
|
|
|
|
try {
|
|
|
|
|
|
const n = await jget(`https://api.nasdaq.com/api/quote/${encodeURIComponent(symbol)}/info?assetclass=stocks`, {
|
|
|
|
|
|
Accept: 'application/json', Origin: 'https://www.nasdaq.com', Referer: 'https://www.nasdaq.com/',
|
|
|
|
|
|
});
|
|
|
|
|
|
const d = n?.data || {};
|
|
|
|
|
|
const p = d.primaryData || {};
|
|
|
|
|
|
const dayRange = parseRange(d.keyStats?.dayrange?.value);
|
|
|
|
|
|
const yearRange = parseRange(d.keyStats?.fiftyTwoWeekHighLow?.value);
|
|
|
|
|
|
out = {
|
|
|
|
|
|
...out,
|
|
|
|
|
|
price: parseNum(p.lastSalePrice) ?? out.price,
|
|
|
|
|
|
name: d.companyName || out.name,
|
|
|
|
|
|
exchange: d.exchange || out.exchange,
|
|
|
|
|
|
change: parseNum(p.netChange) ?? out.change,
|
|
|
|
|
|
changePercent: parseNum(p.percentageChange) ?? out.changePercent,
|
|
|
|
|
|
marketTime: p.lastTradeTimestamp || out.marketTime,
|
|
|
|
|
|
dayHigh: dayRange.high ?? out.dayHigh,
|
|
|
|
|
|
dayLow: dayRange.low ?? out.dayLow,
|
|
|
|
|
|
volume: parseNum(p.volume) ?? out.volume,
|
|
|
|
|
|
fiftyTwoWeekHigh: yearRange.high ?? out.fiftyTwoWeekHigh,
|
|
|
|
|
|
fiftyTwoWeekLow: yearRange.low ?? out.fiftyTwoWeekLow,
|
|
|
|
|
|
bidPrice: parseNum(p.bidPrice),
|
|
|
|
|
|
askPrice: parseNum(p.askPrice),
|
|
|
|
|
|
marketStatus: d.marketStatus || null,
|
|
|
|
|
|
isRealTime: p.isRealTime ?? null,
|
|
|
|
|
|
notifications: d.notifications || [],
|
|
|
|
|
|
source: out.source ? `${out.source} + Nasdaq Info` : 'Nasdaq Info',
|
|
|
|
|
|
};
|
|
|
|
|
|
} catch { /* Yahoo fallback below */ }
|
|
|
|
|
|
try {
|
|
|
|
|
|
const q = await jget(`https://query1.finance.yahoo.com/v7/finance/quote?symbols=${encodeURIComponent(symbol)}`);
|
|
|
|
|
|
const r = q?.quoteResponse?.result?.[0];
|
|
|
|
|
|
if (r) out = {
|
|
|
|
|
|
...out,
|
|
|
|
|
|
price: r.regularMarketPrice ?? null,
|
|
|
|
|
|
name: r.shortName || r.longName || null,
|
|
|
|
|
|
currency: r.currency || null,
|
|
|
|
|
|
exchange: r.fullExchangeName || r.exchange || null,
|
|
|
|
|
|
marketCap: r.marketCap ?? out.marketCap,
|
|
|
|
|
|
sharesOutstanding: r.sharesOutstanding ?? null,
|
|
|
|
|
|
peTrailing: r.trailingPE ?? null,
|
|
|
|
|
|
change: r.regularMarketChange ?? null,
|
|
|
|
|
|
changePercent: r.regularMarketChangePercent ?? null,
|
|
|
|
|
|
marketTime: r.regularMarketTime ? new Date(r.regularMarketTime * 1000).toISOString() : null,
|
|
|
|
|
|
previousClose: r.regularMarketPreviousClose ?? out.previousClose,
|
|
|
|
|
|
dayHigh: r.regularMarketDayHigh ?? out.dayHigh,
|
|
|
|
|
|
dayLow: r.regularMarketDayLow ?? out.dayLow,
|
|
|
|
|
|
volume: r.regularMarketVolume ?? out.volume,
|
|
|
|
|
|
avgVolume: r.averageDailyVolume3Month ?? r.averageDailyVolume10Day ?? out.avgVolume,
|
|
|
|
|
|
fiftyTwoWeekHigh: r.fiftyTwoWeekHigh ?? out.fiftyTwoWeekHigh,
|
|
|
|
|
|
fiftyTwoWeekLow: r.fiftyTwoWeekLow ?? out.fiftyTwoWeekLow,
|
|
|
|
|
|
fiftyDayAverage: r.fiftyDayAverage ?? null,
|
|
|
|
|
|
twoHundredDayAverage: r.twoHundredDayAverage ?? null,
|
|
|
|
|
|
source: out.source ? `${out.source} + Yahoo` : 'Yahoo Finance',
|
|
|
|
|
|
};
|
|
|
|
|
|
} catch { /* chart fallback below */ }
|
|
|
|
|
|
if (out.price != null) return out;
|
2026-06-03 09:21:58 +00:00
|
|
|
|
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; }
|
2026-06-03 16:42:07 +00:00
|
|
|
|
return {
|
|
|
|
|
|
...out,
|
|
|
|
|
|
price: p != null ? p : null,
|
|
|
|
|
|
name: out.name || r.meta?.shortName || r.meta?.longName || null,
|
|
|
|
|
|
currency: out.currency || r.meta?.currency || null,
|
|
|
|
|
|
exchange: out.exchange || r.meta?.exchangeName || null,
|
|
|
|
|
|
marketTime: out.marketTime || (r.meta?.regularMarketTime ? new Date(r.meta.regularMarketTime * 1000).toISOString() : null),
|
|
|
|
|
|
source: out.source || 'Yahoo Chart',
|
|
|
|
|
|
};
|
2026-06-03 09:21:58 +00:00
|
|
|
|
} catch { /* try next host */ }
|
|
|
|
|
|
}
|
2026-06-03 16:42:07 +00:00
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function getQuote(symbol) {
|
|
|
|
|
|
return getPrice(String(symbol || '').trim().toUpperCase());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function getCompanyProfile(symbol) {
|
|
|
|
|
|
symbol = String(symbol || '').trim().toUpperCase();
|
|
|
|
|
|
const [quote, profile] = await Promise.all([
|
|
|
|
|
|
getQuote(symbol).catch(() => ({})),
|
|
|
|
|
|
jget(`https://api.nasdaq.com/api/company/${encodeURIComponent(symbol)}/company-profile?assetclass=stocks`, {
|
|
|
|
|
|
Accept: 'application/json', Origin: 'https://www.nasdaq.com', Referer: 'https://www.nasdaq.com/',
|
|
|
|
|
|
}).catch(() => null),
|
|
|
|
|
|
]);
|
|
|
|
|
|
const p = profile?.data || {};
|
|
|
|
|
|
const val = (k) => p[k]?.value ?? null;
|
|
|
|
|
|
return {
|
|
|
|
|
|
symbol,
|
|
|
|
|
|
name: val('CompanyName') || quote.name || symbol,
|
|
|
|
|
|
description: val('CompanyDescription'),
|
|
|
|
|
|
sector: val('Sector'),
|
|
|
|
|
|
industry: val('Industry'),
|
|
|
|
|
|
region: val('Region'),
|
|
|
|
|
|
address: val('Address'),
|
|
|
|
|
|
phone: val('Phone'),
|
|
|
|
|
|
website: val('CompanyUrl'),
|
|
|
|
|
|
exchange: quote.exchange || null,
|
|
|
|
|
|
marketStatus: quote.marketStatus || null,
|
|
|
|
|
|
bidPrice: quote.bidPrice ?? null,
|
|
|
|
|
|
askPrice: quote.askPrice ?? null,
|
|
|
|
|
|
isRealTime: quote.isRealTime ?? null,
|
|
|
|
|
|
notifications: quote.notifications || [],
|
|
|
|
|
|
quote,
|
|
|
|
|
|
source: profile?.data ? 'Nasdaq profile' : quote.source || null,
|
|
|
|
|
|
};
|
2026-06-03 09:21:58 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 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;
|
|
|
|
|
|
}
|
2026-06-04 09:32:28 +00:00
|
|
|
|
function mapEarningsTrendPeriod(t) {
|
|
|
|
|
|
if (!t) return null;
|
|
|
|
|
|
const ee = t.earningsEstimate || {};
|
|
|
|
|
|
const rev = t.revenueEstimate || {};
|
|
|
|
|
|
const ebitda = t.ebitdaEstimate || {};
|
|
|
|
|
|
const growthPct = (g) => {
|
|
|
|
|
|
const v = num(g);
|
|
|
|
|
|
if (v == null) return null;
|
|
|
|
|
|
return Math.abs(v) <= 1.5 ? v * 100 : v;
|
|
|
|
|
|
};
|
|
|
|
|
|
return {
|
|
|
|
|
|
period: t.period,
|
|
|
|
|
|
endDate: t.endDate || null,
|
|
|
|
|
|
epsAvg: num(ee.avg),
|
|
|
|
|
|
epsLow: num(ee.low),
|
|
|
|
|
|
epsHigh: num(ee.high),
|
|
|
|
|
|
epsAnalysts: num(ee.numberOfAnalysts),
|
|
|
|
|
|
epsGrowthPct: growthPct(ee.growth),
|
|
|
|
|
|
revenueAvg: num(rev.avg),
|
|
|
|
|
|
revenueLow: num(rev.low),
|
|
|
|
|
|
revenueHigh: num(rev.high),
|
|
|
|
|
|
revenueAnalysts: num(rev.numberOfAnalysts),
|
|
|
|
|
|
revenueGrowthPct: growthPct(rev.growth),
|
|
|
|
|
|
ebitdaAvg: num(ebitda.avg),
|
|
|
|
|
|
ebitdaLow: num(ebitda.low),
|
|
|
|
|
|
ebitdaHigh: num(ebitda.high),
|
|
|
|
|
|
ebitdaAnalysts: num(ebitda.numberOfAnalysts),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
function parseYahooEstimates(r) {
|
|
|
|
|
|
const trends = r.earningsTrend?.trend || [];
|
|
|
|
|
|
const byPeriod = {};
|
|
|
|
|
|
for (const t of trends) if (t?.period) byPeriod[t.period] = t;
|
|
|
|
|
|
const fd = r.financialData || {};
|
|
|
|
|
|
const sd = r.summaryDetail || {};
|
|
|
|
|
|
const dks = r.defaultKeyStatistics || {};
|
|
|
|
|
|
const currentYear = mapEarningsTrendPeriod(byPeriod['0y'] || byPeriod['+0y']);
|
|
|
|
|
|
const nextYear = mapEarningsTrendPeriod(byPeriod['+1y'] || byPeriod['1y']);
|
|
|
|
|
|
const hasConsensus = !!(currentYear?.epsAvg != null || currentYear?.revenueAvg != null
|
|
|
|
|
|
|| nextYear?.epsAvg != null || nextYear?.revenueAvg != null);
|
|
|
|
|
|
return {
|
|
|
|
|
|
source: 'Yahoo Finance',
|
|
|
|
|
|
endpoint: 'quoteSummary modules: earningsTrend, financialData, defaultKeyStatistics',
|
|
|
|
|
|
fetchedAt: new Date().toISOString(),
|
|
|
|
|
|
forwardEps: num(dks.forwardEps) ?? num(fd.forwardEps),
|
|
|
|
|
|
currentYear,
|
|
|
|
|
|
nextYear,
|
|
|
|
|
|
currentQuarter: mapEarningsTrendPeriod(byPeriod['0q']),
|
|
|
|
|
|
nextQuarter: mapEarningsTrendPeriod(byPeriod['+1q'] || byPeriod['1q']),
|
|
|
|
|
|
targetMean: num(fd.targetMeanPrice) ?? num(sd.targetMeanPrice),
|
|
|
|
|
|
targetLow: num(fd.targetLowPrice) ?? num(sd.targetLowPrice),
|
|
|
|
|
|
targetHigh: num(fd.targetHighPrice) ?? num(sd.targetHighPrice),
|
|
|
|
|
|
targetAnalysts: num(fd.numberOfAnalystOpinions),
|
|
|
|
|
|
hasConsensus,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 09:21:58 +00:00
|
|
|
|
async function fetchYahoo(symbol) {
|
|
|
|
|
|
const { cookie, crumb } = await yahooAuth();
|
|
|
|
|
|
const mods = [
|
2026-06-04 09:32:28 +00:00
|
|
|
|
'price', 'summaryDetail', 'defaultKeyStatistics', 'financialData', 'earningsTrend',
|
2026-06-03 09:21:58 +00:00
|
|
|
|
'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,
|
2026-06-03 16:42:07 +00:00
|
|
|
|
totalEquity: num(bs.totalStockholderEquity),
|
|
|
|
|
|
currentAssets: num(bs.totalCurrentAssets),
|
|
|
|
|
|
currentLiabilities: num(bs.totalCurrentLiabilities),
|
2026-06-03 09:21:58 +00:00
|
|
|
|
cash: num(bs.cash),
|
|
|
|
|
|
totalDebt: (num(bs.shortLongTermDebt) || 0) + (num(bs.longTermDebt) || 0) || null,
|
|
|
|
|
|
debtToAssets: pct(totalLiabilities, totalAssets),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-04 09:32:28 +00:00
|
|
|
|
const estimates = parseYahooEstimates(r);
|
2026-06-03 09:21:58 +00:00
|
|
|
|
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),
|
2026-06-03 16:42:07 +00:00
|
|
|
|
sharesOutstanding: shares,
|
2026-06-03 09:21:58 +00:00
|
|
|
|
quarters, annual, balance,
|
2026-06-04 09:32:28 +00:00
|
|
|
|
estimates,
|
|
|
|
|
|
targetPrice: estimates.targetMean ?? num(r.financialData?.targetMeanPrice) ?? null,
|
2026-06-03 09:21:58 +00:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 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']));
|
2026-06-03 16:42:07 +00:00
|
|
|
|
const equityE = instantLatest(mergeUnits(g, ['StockholdersEquity', 'StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest']));
|
|
|
|
|
|
const curAssetsE = instantLatest(mergeUnits(g, ['AssetsCurrent']));
|
|
|
|
|
|
const curLiabE = instantLatest(mergeUnits(g, ['LiabilitiesCurrent']));
|
2026-06-03 09:21:58 +00:00
|
|
|
|
const cashE = instantLatest(mergeUnits(g, ['CashAndCashEquivalentsAtCarryingValue', 'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents']));
|
2026-06-03 16:42:07 +00:00
|
|
|
|
const sharesE = instantLatest(pickShares(g, ['EntityCommonStockSharesOutstanding', 'CommonStocksIncludingAdditionalPaidInCapital']));
|
2026-06-03 09:21:58 +00:00
|
|
|
|
const balance = {
|
|
|
|
|
|
end: assetsE?.end || null,
|
|
|
|
|
|
totalAssets: assetsE?.val ?? null,
|
|
|
|
|
|
totalLiabilities: liabE?.val ?? null,
|
2026-06-03 16:42:07 +00:00
|
|
|
|
totalEquity: equityE?.val ?? ((assetsE?.val != null && liabE?.val != null) ? assetsE.val - liabE.val : null),
|
|
|
|
|
|
currentAssets: curAssetsE?.val ?? null,
|
|
|
|
|
|
currentLiabilities: curLiabE?.val ?? null,
|
2026-06-03 09:21:58 +00:00
|
|
|
|
cash: cashE?.val ?? null,
|
|
|
|
|
|
totalDebt: null,
|
|
|
|
|
|
debtToAssets: pct(liabE?.val, assetsE?.val),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (!quarters.length && !annual.length) throw new Error('EDGAR 無 us-gaap 財報資料(可能為以 IFRS 申報的外國發行人,建議改查其美股同業)');
|
2026-06-03 16:42:07 +00:00
|
|
|
|
return { source: 'SEC EDGAR', name: cf.entityName || hit.name || symbol, currency: 'USD', peTrailing: null, marketCap: null, sharesOutstanding: sharesE?.val ?? null, quarters, annual, balance };
|
2026-06-03 09:21:58 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ─── 輕量「是否有新財報」探針(美股;只抓 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;
|
2026-06-04 09:32:28 +00:00
|
|
|
|
const targetPrice = priceInfo.targetPrice ?? data.targetPrice ?? data.estimates?.targetMean ?? null;
|
2026-06-03 09:21:58 +00:00
|
|
|
|
return {
|
|
|
|
|
|
symbol,
|
|
|
|
|
|
name: data.name || priceInfo.name || symbol,
|
|
|
|
|
|
currency: data.currency || priceInfo.currency || 'USD',
|
|
|
|
|
|
source: data.source,
|
|
|
|
|
|
asOf,
|
|
|
|
|
|
price: priceInfo.price,
|
2026-06-03 16:42:07 +00:00
|
|
|
|
peTrailing: priceInfo.peTrailing ?? data.peTrailing ?? null,
|
|
|
|
|
|
marketCap: priceInfo.marketCap ?? data.marketCap ?? null,
|
|
|
|
|
|
sharesOutstanding: priceInfo.sharesOutstanding ?? data.sharesOutstanding ?? ((priceInfo.marketCap && priceInfo.price) ? priceInfo.marketCap / priceInfo.price : null),
|
2026-06-04 09:32:28 +00:00
|
|
|
|
targetPrice,
|
|
|
|
|
|
targetMeta: data.estimates ? {
|
|
|
|
|
|
mean: data.estimates.targetMean,
|
|
|
|
|
|
low: data.estimates.targetLow,
|
|
|
|
|
|
high: data.estimates.targetHigh,
|
|
|
|
|
|
analysts: data.estimates.targetAnalysts,
|
|
|
|
|
|
source: data.estimates.source,
|
|
|
|
|
|
endpoint: data.estimates.endpoint,
|
|
|
|
|
|
} : (priceInfo.targetPrice != null ? { source: priceInfo.source || 'Nasdaq summary', endpoint: 'api.nasdaq.com/.../summary' } : null),
|
2026-06-03 16:42:07 +00:00
|
|
|
|
dividendYield: priceInfo.dividendYield ?? null,
|
2026-06-03 09:21:58 +00:00
|
|
|
|
quarters: data.quarters || [],
|
|
|
|
|
|
annual: data.annual || [],
|
|
|
|
|
|
balance: data.balance || {},
|
2026-06-04 09:32:28 +00:00
|
|
|
|
estimates: data.estimates || null,
|
2026-06-03 09:21:58 +00:00
|
|
|
|
};
|
|
|
|
|
|
}
|