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];
|
|||
|
|
}
|