205 lines
9.8 KiB
JavaScript
205 lines
9.8 KiB
JavaScript
|
|
// ═══════════════════════════════════════════════════════════
|
||
|
|
// 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'],
|
||
|
|
};
|
||
|
|
}
|