finance-dashboard/lib/companyintel-links.js

148 lines
5.9 KiB
JavaScript
Raw Permalink Normal View History

2026-06-04 09:32:28 +00:00
// 公司研究直接連結SEC官網 IR與 10-K 產業鏈合併(不用 Google 搜尋代替資料)
import {
finalizeIndustryChain, isTradableSymbol, appendDetailNames, SECTOR_SUPPLIER_TICKERS,
} from './companyintel-chain.js';
const SEC_UA = 'EmmyInvestDashboard/1.0 (personal learning tool; contact@example.com)';
let _tickerMap = null;
async function json(url, headers = {}, ms = 12000) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
try {
const res = await fetch(url, { headers: { 'User-Agent': SEC_UA, ...headers }, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} finally { clearTimeout(timer); }
}
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'),
cikNum: Number(d[k].cik_str),
name: d[k].title,
};
}
}
return _tickerMap[String(symbol || '').toUpperCase()] || null;
}
function edgarDocUrl(cikNum, accession, primary) {
const accNo = accession.replace(/-/g, '');
return `https://www.sec.gov/Archives/edgar/data/${cikNum}/${accNo}/${primary}`;
}
/** 投資人關係/官網(不經 Google */
export function resolveInvestorRelationsUrl(website) {
const w = String(website || '').trim();
if (!w) return null;
try {
const u = new URL(w.startsWith('http') ? w : `https://${w}`);
const host = u.hostname.replace(/^www\./, '');
const candidates = [
u.href,
`${u.protocol}//${u.host}/investor-relations`,
`${u.protocol}//investor.${host}`,
`${u.protocol}//ir.${host}`,
];
return { url: candidates[0], labelZh: '公司官網', altUrls: candidates.slice(1) };
} catch {
return null;
}
}
/** SEC 直接連結EDGAR、最新 10-K、DEF 14A */
export async function buildSecResourceLinks(symbol) {
const hit = await tickerToCik(symbol);
if (!hit) return [];
const links = [
{ labelZh: 'SEC EDGAR 公司頁', url: `https://www.sec.gov/edgar/browse/?CIK=${hit.cik}`, source: 'SEC' },
];
try {
const sub = await json(`https://data.sec.gov/submissions/CIK${hit.cik}.json`, { 'User-Agent': SEC_UA });
const f = sub.filings?.recent || {};
const found = { '10-K': null, 'DEF 14A': null, '10-Q': null };
for (let i = 0; i < (f.form || []).length; i++) {
const form = f.form[i];
if (!found[form] && found[form] !== undefined) {
const acc = f.accessionNumber[i];
const doc = f.primaryDocument?.[i];
if (acc && doc) {
found[form] = edgarDocUrl(hit.cikNum, acc, doc);
}
}
if (found['10-K'] && found['DEF 14A'] && found['10-Q']) break;
}
if (found['10-K']) links.push({ labelZh: '最新 10-K 年報', url: found['10-K'], source: 'SEC' });
if (found['10-Q']) links.push({ labelZh: '最新 10-Q 季報', url: found['10-Q'], source: 'SEC' });
if (found['DEF 14A']) links.push({ labelZh: '股東會說明書 DEF 14A', url: found['DEF 14A'], source: 'SEC' });
} catch { /* */ }
return links;
}
function entityGroups(names, label, note) {
const list = (names || []).filter(Boolean).slice(0, 10);
if (!list.length) return [];
return [{ label, entities: list, note }];
}
/** 把 10-K 抽出的公司名+產業 fallback 合成上下游結構 */
export function mergeIndustryChainWithHints(symbol, chain, hints = {}, profileExt = {}, profile = {}) {
const base = chain || {};
const industry = `${profileExt.industry || ''} ${profileExt.sector || profile.industry || ''}`.toLowerCase();
let upstreamDetail = base.upstreamDetail?.length ? [...base.upstreamDetail] : [];
let downstreamDetail = base.downstreamDetail?.length ? [...base.downstreamDetail] : [];
let peers = base.peers?.length ? [...base.peers] : [...(profileExt.peers || [])];
if (hints.suppliers?.length) {
upstreamDetail = appendDetailNames(upstreamDetail, hints.suppliers, '供應商10-K', 'SEC 年報提及', symbol);
}
if (hints.customers?.length) {
downstreamDetail = appendDetailNames(downstreamDetail, hints.customers, '客戶10-K', 'SEC 年報提及', symbol);
}
const ind = industry.toLowerCase();
if (/semiconductor|chip/i.test(ind)) {
upstreamDetail = appendDetailNames(
upstreamDetail,
SECTOR_SUPPLIER_TICKERS.semiconductor,
'產業常見供應商',
'半導體鏈',
symbol,
);
}
if (hints.competitors?.length) {
const from10k = hints.competitors.map(c => String(c).replace(/\s+(Inc\.|Corp\.|Corporation|Ltd\.|LLC|Co\.)/i, '').trim().toUpperCase())
.filter(c => isTradableSymbol(c));
peers = [...new Set([...peers, ...from10k])].filter(p => p !== symbol).slice(0, 12);
}
const flatUp = upstreamDetail.flatMap(g => g.entities || [g.label]).filter(Boolean);
const flatDown = downstreamDetail.flatMap(g => g.entities || [g.label]).filter(Boolean);
return finalizeIndustryChain({
...base,
upstream: flatUp.length ? flatUp : base.upstream,
downstream: flatDown.length ? flatDown : base.downstream,
upstreamDetail,
downstreamDetail,
peers,
tenKExcerpt: hints.excerpt ? String(hints.excerpt).slice(0, 480) : base.tenKExcerpt || null,
chainSource: hints.source || base.chainSource || (hints.excerpt ? 'SEC 10-K' : null),
chainSources: [...new Set([...(base.chainSources || []), hints.excerpt ? 'SEC 10-K' : null].filter(Boolean))],
searches: [],
}, symbol);
}
export async function buildCompanyResources(symbol, profile = {}, management = {}) {
const links = [];
const ir = resolveInvestorRelationsUrl(profile.website || management.website);
if (ir) links.push({ labelZh: '投資人關係/官網', url: ir.url, source: '官網' });
const sec = await buildSecResourceLinks(symbol).catch(() => []);
return [...links, ...sec];
}