finance-dashboard/lib/companyintel-links.js

148 lines
5.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 公司研究直接連結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];
}