finance-dashboard/lib/companyintel.js

205 lines
9.8 KiB
JavaScript
Raw Normal View History

2026-06-03 16:42:07 +00:00
// ═══════════════════════════════════════════════════════════
// companyintel.js — 公司研究資料:管理層、內部人交易、新聞、產業鏈入口
// ═══════════════════════════════════════════════════════════
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 text(url, headers = {}, ms = 12000) {
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.text();
} finally { clearTimeout(timer); }
}
async function json(url, headers = {}, ms = 12000) {
return JSON.parse(await text(url, { Accept: 'application/json,text/plain,*/*', ...headers }, ms));
}
const strip = (s) => String(s || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const num = (s) => {
if (s == null) return null;
const n = Number(String(s).replace(/[$,%\s,]/g, ''));
return Number.isFinite(n) ? n : null;
};
const tag = (src, name) => src.match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, 'i'))?.[1]?.trim() || null;
let _tickerMap = null;
async function tickerToCik(symbol) {
if (!_tickerMap) {
const d = await json('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;
}
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 fetchManagement(symbol) {
try {
const { cookie, crumb } = await yahooAuth();
const d = await json(`https://query2.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(symbol)}?modules=assetProfile&crumb=${encodeURIComponent(crumb)}`, { Cookie: cookie });
const p = d?.quoteSummary?.result?.[0]?.assetProfile || {};
return {
sector: p.sector || null,
industry: p.industry || null,
website: p.website || null,
fullTimeEmployees: p.fullTimeEmployees ?? null,
officers: (p.companyOfficers || []).slice(0, 10).map(o => ({
name: o.name || '',
title: o.title || '',
age: o.age ?? null,
fiscalYear: o.fiscalYear ?? null,
totalPay: o.totalPay?.raw ?? null,
})),
source: 'Yahoo assetProfile',
};
} catch {
return { officers: [], source: null };
}
}
function parseForm4(txt, filing) {
const xml = txt.slice(txt.indexOf('<ownershipDocument'));
const ownerBlock = xml.match(/<reportingOwner>([\s\S]*?)<\/reportingOwner>/i)?.[1] || '';
const issuerBlock = xml.match(/<issuer>([\s\S]*?)<\/issuer>/i)?.[1] || '';
const relBlock = ownerBlock.match(/<reportingOwnerRelationship>([\s\S]*?)<\/reportingOwnerRelationship>/i)?.[1] || '';
const txBlocks = [...xml.matchAll(/<nonDerivativeTransaction>([\s\S]*?)<\/nonDerivativeTransaction>/gi)].map(m => m[1]);
const transactions = txBlocks.slice(0, 8).map(b => ({
date: tag(b, 'transactionDate') ? tag(tag(b, 'transactionDate'), 'value') : null,
code: tag(b, 'transactionCode') || null,
acquiredDisposed: tag(tag(b, 'transactionAcquiredDisposedCode') || '', 'value'),
shares: num(tag(tag(b, 'transactionShares') || '', 'value')),
price: num(tag(tag(b, 'transactionPricePerShare') || '', 'value')),
ownedAfter: num(tag(tag(b, 'sharesOwnedFollowingTransaction') || '', 'value')),
})).filter(t => t.shares != null || t.code);
const acquired = transactions.filter(t => t.acquiredDisposed === 'A').reduce((a, t) => a + (t.shares || 0), 0);
const disposed = transactions.filter(t => t.acquiredDisposed === 'D').reduce((a, t) => a + (t.shares || 0), 0);
return {
filingDate: filing.date,
reportDate: tag(xml, 'periodOfReport'),
owner: tag(ownerBlock, 'rptOwnerName') || strip(txt.match(/COMPANY CONFORMED NAME:\s*([^\n]+)/)?.[1]),
issuer: tag(issuerBlock, 'issuerName'),
title: tag(relBlock, 'officerTitle') || (tag(relBlock, 'isDirector') === '1' ? 'Director' : ''),
isDirector: tag(relBlock, 'isDirector') === '1',
isOfficer: tag(relBlock, 'isOfficer') === '1',
acquired, disposed,
signal: acquired > disposed ? 'acquire' : disposed > acquired ? 'dispose' : 'mixed',
transactions,
url: filing.url,
};
}
async function fetchInsiderTransactions(symbol) {
const hit = await tickerToCik(symbol);
if (!hit) return [];
const sub = await json(`https://data.sec.gov/submissions/CIK${hit.cik}.json`, { 'User-Agent': SEC_UA });
const f = sub.filings?.recent || {};
const filings = [];
for (let i = 0; i < (f.form || []).length && filings.length < 8; i++) {
if (f.form[i] !== '4') continue;
const accn = f.accessionNumber[i];
const accNo = accn.replace(/-/g, '');
filings.push({
date: f.filingDate[i],
accn,
url: `https://www.sec.gov/Archives/edgar/data/${Number(hit.cik)}/${accNo}/${accn}.txt`,
});
}
const out = [];
for (const filing of filings.slice(0, 5)) {
try { out.push(parseForm4(await text(filing.url, { 'User-Agent': SEC_UA }), filing)); }
catch { /* keep going */ }
}
return out;
}
async function fetchNews(symbol) {
const d = await json(`https://api.nasdaq.com/api/news/topic/articlebysymbol?q=${encodeURIComponent(symbol)}|stocks&offset=0&limit=8&fallback=true`, {
Accept: 'application/json', Origin: 'https://www.nasdaq.com', Referer: 'https://www.nasdaq.com/',
}).catch(() => null);
return (d?.data?.rows || []).slice(0, 8).map(r => ({
title: r.title,
publisher: r.publisher,
created: r.created || r.ago,
description: strip(r.description || ''),
url: r.url ? (r.url.startsWith('http') ? r.url : `https://www.nasdaq.com${r.url}`) : null,
relatedSymbols: (r.related_symbols || []).map(x => String(x).split('|')[0].toUpperCase()).filter(Boolean),
}));
}
function industryChain(symbol, profile = {}) {
const industry = `${profile.industry || ''} ${profile.sector || ''}`.toLowerCase();
const maps = [
{
match: /semiconductor|chip|accelerated|technology/,
upstream: ['EDA/IP 軟體', '晶圓代工', '先進封裝', 'HBM/記憶體', '半導體設備', 'ABF/載板'],
peers: ['AMD', 'AVGO', 'QCOM', 'MRVL', 'TSM', 'ASML', 'MU'],
downstream: ['雲端資料中心', 'AI 伺服器 OEM/ODM', '企業 AI 軟體', '自駕車/機器人', '遊戲與工作站'],
},
{
match: /software|internet|communication|media/,
upstream: ['雲端基礎設施', '資料中心', '廣告技術', '內容/資料供應商'],
peers: ['MSFT', 'GOOGL', 'META', 'AMZN', 'CRM', 'ORCL'],
downstream: ['企業客戶', '消費者流量', '開發者生態', '廣告主'],
},
{
match: /consumer|retail|apparel/,
upstream: ['原物料', '製造代工', '物流倉儲', '通路平台'],
peers: ['AMZN', 'WMT', 'COST', 'TGT', 'NKE'],
downstream: ['消費者', '會員訂閱', '門市/電商通路'],
},
];
const hit = maps.find(m => m.match.test(industry)) || {
upstream: ['原物料/零組件', '設備與服務供應商', '物流與通路', '資本支出供應商'],
peers: [],
downstream: ['終端客戶', '企業採購', '通路夥伴', '替代產品'],
};
const q = encodeURIComponent(`${symbol} suppliers customers upstream downstream competitors`);
return {
upstream: hit.upstream,
peers: hit.peers.filter(s => s !== symbol),
downstream: hit.downstream,
searches: [
{ label: '供應商 / 客戶', url: `https://www.google.com/search?q=${q}` },
{ label: '10-K supply chain', url: `https://www.google.com/search?q=${encodeURIComponent(`${symbol} 10-K suppliers customers supply chain`)}` },
{ label: '同業比較', url: `https://www.google.com/search?q=${encodeURIComponent(`${symbol} competitors industry peers`)}` },
],
};
}
export async function getCompanyIntel(symbol, profile = {}) {
symbol = String(symbol || '').trim().toUpperCase();
const [management, insiders, news] = await Promise.all([
fetchManagement(symbol),
fetchInsiderTransactions(symbol).catch(() => []),
fetchNews(symbol).catch(() => []),
]);
return {
symbol,
updatedAt: new Date().toISOString(),
management: {
...management,
searches: [
{ label: '管理層 / Leadership', url: `https://www.google.com/search?q=${encodeURIComponent(`${symbol} executive officers management leadership`)}` },
{ label: 'Proxy / DEF 14A', url: `https://www.google.com/search?q=${encodeURIComponent(`${symbol} DEF 14A executive compensation board directors`)}` },
{ label: 'Investor relations', url: `https://www.google.com/search?q=${encodeURIComponent(`${symbol} investor relations leadership`)}` },
],
},
insiders,
news,
industryChain: industryChain(symbol, { ...profile, ...management }),
sources: ['Yahoo assetProfile', 'SEC Form 4', 'Nasdaq News'],
};
}