import { logError, logInfo } from '../../utils'; import { NAVER_INDUSTRY_NAMES } from './stockSectors'; /** * Naver Finance 비공식 JSON API 로 개별 종목 펀더멘털 fetch. * * 두 endpoint 합성: * - `/api/stock//integration` — 시총 텍스트 / PER / PBR / EPS / 현재가 * - `/api/stock//finance/annual` — ROE / 영업이익률 / 유보율 / 부채비율 * (rowList 안의 row.title 매칭, 최근 연도 컬럼 값 사용) * * JSON 응답이라 selector 깨질 일이 없고, label 매칭도 정확 (rowList[i].title === 'ROE'). * 사용자가 별도 인증 / API 키 필요 없음 — Naver Finance 모바일 페이지가 쓰는 그대로. */ const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; const INTEGRATION_URL = 'https://m.stock.naver.com/api/stock'; export interface Fundamentals { symbol: string; /** 연간 재무제표 (최근 *확정* 연도). */ roe?: number; // % operatingMargin?: number; // % (영업이익률) retentionRatio?: number; // % (유보율) debtRatio?: number; // % (부채비율) /** 최근 확정 2개 연도 기준 YoY 성장률 (%). '성장성' 기준의 실측치 — 마진 수준이 아닌 진짜 성장. */ revenueGrowthYoY?: number; // 매출액 YoY % opProfitGrowthYoY?: number; // 영업이익 YoY % /** integration API 의 현재가 + 평가지표. */ per?: number; pbr?: number; eps?: number; marketCapEok?: number; // 억 단위 (한국 경로 전용 — 미국은 marketCapNative 사용) currentPrice?: number; /** 업종 hint — 사용 가능하면 채움 ("기술력" 키워드 매칭 용). */ sectorHint?: string; /** 시장 — 'KR'(Naver) | 'US'(Yahoo). 미지정 시 호출자가 심볼로 판별. */ market?: 'KR' | 'US'; /** 시가총액 native 단위 (US=USD). KR 은 marketCapEok 사용. */ marketCapNative?: number; /** 통화 — currentPrice/marketCapNative 의 단위. */ currency?: 'KRW' | 'USD'; /** 데이터 출처 라벨 — 'Naver' | 'Yahoo quoteSummary'. 정확도/투명성 표기용. */ source?: string; } interface NaverIntegrationResponse { stockName?: string; totalInfos?: Array<{ code: string; key: string; value: string }>; /** 네이버 업종 코드 (숫자). 통합 응답의 실제 필드 — industryInfo 는 존재하지 않음. */ industryCode?: number | string; } interface NaverFinanceAnnualResponse { financeInfo?: { trTitleList?: Array<{ isConsensus: 'Y' | 'N'; title: string; key: string }>; rowList?: Array<{ title: string; columns: Record }>; }; } /** "12,090" / "23.64배" / "5,800%" / "1,710조 365억" / "-" 텍스트에서 숫자 추출. */ function parseNumber(raw: string | undefined): number | undefined { if (!raw) return undefined; const cleaned = raw.replace(/,/g, '').replace(/배|%|원|억|조/g, '').trim(); if (!cleaned || cleaned === '-' || cleaned === 'N/A') return undefined; const n = parseFloat(cleaned); return Number.isFinite(n) ? n : undefined; } /** "1,710조 365억" / "2,787억" / "5조" → 억 단위 정수. */ function parseMarketCapText(text: string | undefined): number | undefined { if (!text) return undefined; const cleaned = text.replace(/원|\s/g, ''); const joMatch = cleaned.match(/([\d,]+)조/); const eokMatch = cleaned.match(/(?:조)?([\d,]+)억/) || cleaned.match(/([\d,]+)$/); const jo = joMatch ? parseInt(joMatch[1].replace(/,/g, ''), 10) : 0; const eok = eokMatch ? parseInt(eokMatch[1].replace(/,/g, ''), 10) : 0; const total = jo * 10000 + eok; return total > 0 ? total : undefined; } /** trTitleList 에서 *최근 확정* (isConsensus = 'N') 컬럼 키 선택. */ function pickLatestConfirmedKey(titles?: Array<{ isConsensus: 'Y' | 'N'; key: string }>): string | null { if (!titles || titles.length === 0) return null; // 'N' 만 필터 → key 내림차순 → 첫 번째. const confirmed = titles.filter(t => t.isConsensus === 'N').map(t => t.key).sort().reverse(); return confirmed[0] ?? null; } async function fetchIntegration(symbol: string, timeoutMs: number): Promise { try { const res = await fetch(`${INTEGRATION_URL}/${symbol}/integration`, { headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' }, signal: AbortSignal.timeout(timeoutMs), }); if (!res.ok) return null; return await res.json() as NaverIntegrationResponse; } catch (e: any) { logError('Naver integration fetch 실패.', { symbol, error: e?.message ?? String(e) }); return null; } } async function fetchFinanceAnnual(symbol: string, timeoutMs: number): Promise { try { const res = await fetch(`${INTEGRATION_URL}/${symbol}/finance/annual`, { headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' }, signal: AbortSignal.timeout(timeoutMs), }); if (!res.ok) return null; return await res.json() as NaverFinanceAnnualResponse; } catch (e: any) { logError('Naver finance annual fetch 실패.', { symbol, error: e?.message ?? String(e) }); return null; } } export async function fetchFundamentals(symbol: string, timeoutMs = 10000): Promise { const [integ, fin] = await Promise.all([ fetchIntegration(symbol, timeoutMs), fetchFinanceAnnual(symbol, timeoutMs), ]); if (!integ && !fin) return null; const out: Fundamentals = { symbol, market: 'KR', currency: 'KRW', source: 'Naver' }; // integration — totalInfos 의 code 로 추출 (key 한글 텍스트보다 안정적). if (integ?.totalInfos) { const map = new Map(integ.totalInfos.map(i => [i.code, i.value])); out.per = parseNumber(map.get('per')); out.pbr = parseNumber(map.get('pbr')); out.eps = parseNumber(map.get('eps')); out.currentPrice = parseNumber(map.get('closePrice') || map.get('lastClosePrice')); out.marketCapEok = parseMarketCapText(map.get('marketValue')); } // 업종 — 통합 응답의 industryCode(숫자) → 업종명 매핑. (구버전은 없는 industryInfo.name // 을 읽어 항상 빈 값이었음 → '기술력' 키워드·섹터 표시가 동작 안 했던 버그 수정.) if (integ?.industryCode !== undefined) { const code = typeof integ.industryCode === 'string' ? parseInt(integ.industryCode, 10) : integ.industryCode; if (Number.isFinite(code) && NAVER_INDUSTRY_NAMES[code]) out.sectorHint = NAVER_INDUSTRY_NAMES[code]; } // finance/annual — rowList 에서 최근 확정 연도 컬럼 값. if (fin?.financeInfo?.rowList && fin.financeInfo.rowList.length > 0) { const confirmedKeys = (fin.financeInfo.trTitleList || []) .filter(t => t.isConsensus === 'N').map(t => t.key).sort().reverse(); const latestKey = confirmedKeys[0] ?? null; const prevKey = confirmedKeys[1] ?? null; if (latestKey) { const rowByTitle = new Map(fin.financeInfo.rowList.map(r => [r.title, r])); const valueOf = (title: string, key: string = latestKey): number | undefined => { const row = rowByTitle.get(title); if (!row) return undefined; return parseNumber(row.columns[key]?.value); }; out.roe = valueOf('ROE'); out.operatingMargin = valueOf('영업이익률'); out.retentionRatio = valueOf('유보율'); out.debtRatio = valueOf('부채비율'); // YoY 성장률 — 최근 확정 2개 연도. '성장성'을 마진 수준이 아닌 실측 성장으로 판정. if (prevKey) { const yoy = (title: string): number | undefined => { const a = valueOf(title, latestKey); const b = valueOf(title, prevKey); if (a === undefined || b === undefined || b === 0) return undefined; return ((a - b) / Math.abs(b)) * 100; }; out.revenueGrowthYoY = yoy('매출액'); out.opProfitGrowthYoY = yoy('영업이익'); } } } return out; } /** 일괄 fetch — throttle 300ms. JSON API 가 가벼우니 HTML 크롤 500ms 보다 빠르게. */ export async function fetchAllFundamentals( symbols: string[], onProgress?: (symbol: string, fund: Fundamentals | null, i: number, total: number) => void, ): Promise> { const out = new Map(); let i = 0; for (const symbol of symbols) { i++; const fund = await fetchFundamentals(symbol); if (fund) out.set(symbol, fund); onProgress?.(symbol, fund, i, symbols.length); await new Promise(r => setTimeout(r, 300)); } logInfo(`Naver fundamentals 일괄 fetch: ${out.size}/${symbols.length} 성공.`); return out; }