148 lines
5.9 KiB
JavaScript
148 lines
5.9 KiB
JavaScript
// 公司研究:直接連結(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];
|
||
} |