e6d780be61
미국 주식 (개별 종목 조회 — add/check/judge/analysis/position): - marketUtils: KR(6자리)/US(티커) 시장 판별, 통화·시총 포매팅($/원, B·T/억·조) - yahooClient: US는 raw 티커(.KQ/.KS 미부착), 심볼 라우팅 분리 - yahooFundamentals: Yahoo quoteSummary(무키) cookie+crumb 핸드셰이크로 ROE/PER/PBR/마진/성장/부채 fetch (429·crumb 하드닝) - fundamentalsRouter: 심볼별 Naver(KR)/Yahoo(US) 분기 - criteriaEval: market-aware — US는 유보율 미보고라 유동성·안정성을 부채비율(D/E)·시총($B)으로 대체 - types: Stock.market 필드; slashStocks/llmJudge: 통화·라우팅·프롬프트 US 대응 - 테스트: stocksMarket + criteriaEval US 케이스 (전체 통과) datacollect/llm.ts: LLM을 확장(PC)에서 직접 호출(과거 bridge /api/lm 프록시 제거) → 백엔드 NAS 이전 시 PC→NAS→PC 실패 제거, 스크래핑=NAS·LLM 추론=PC 분리 package.json: datacollectBridge 기본값 nas + https://dc.koritips.com; 버전 2.2.261 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
193 lines
9.0 KiB
TypeScript
193 lines
9.0 KiB
TypeScript
import { logError, logInfo } from '../../utils';
|
|
import { NAVER_INDUSTRY_NAMES } from './stockSectors';
|
|
|
|
/**
|
|
* Naver Finance 비공식 JSON API 로 개별 종목 펀더멘털 fetch.
|
|
*
|
|
* 두 endpoint 합성:
|
|
* - `/api/stock/<code>/integration` — 시총 텍스트 / PER / PBR / EPS / 현재가
|
|
* - `/api/stock/<code>/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<string, { value: string }> }>;
|
|
};
|
|
}
|
|
|
|
/** "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<NaverIntegrationResponse | null> {
|
|
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<NaverFinanceAnnualResponse | null> {
|
|
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<Fundamentals | null> {
|
|
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<Map<string, Fundamentals>> {
|
|
const out = new Map<string, Fundamentals>();
|
|
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;
|
|
}
|