import { logError, logInfo } from '../../utils'; /** * Naver Finance *비공식 JSON API* 로 시가총액 순위 fetch. * * - 코스피: `https://m.stock.naver.com/api/stocks/marketValue/KOSPI?page=N&pageSize=50` * - 코스닥: `https://m.stock.naver.com/api/stocks/marketValue/KOSDAQ?page=N&pageSize=50` * * 응답: * { stocks: [ { itemCode, stockName, marketValue, marketValueHangeul, closePrice, ... }, ... ] } * * `marketValueHangeul` 은 "2,787억" / "1,710조 365억" 같은 사람 친화 텍스트. * 우리는 이걸 *억 단위 정수* 로 파싱 — 시가총액 범위 필터 (사용자 옵션) 와 일관성. * * Why JSON over HTML: * - 페이지 디자인 변경 무관 (스키마는 더 안정적) * - EUC-KR 디코딩 불필요 (JSON 은 UTF-8) * - cheerio 의존성 제거 * - 더 빠름 (HTML 전체 다운로드 X, JSON 만) * * Caveat: *비공식* — Naver 가 막을 수 있음. 정식 ToS 보장 X. 개인 학습용 가정. */ 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 BASE_URL = 'https://m.stock.naver.com/api/stocks/marketValue'; const INDUSTRY_URL = 'https://m.stock.naver.com/api/stocks/industry'; export type Market = 'kospi' | 'kosdaq'; export interface ScreenerEntry { /** 6자리 종목코드. */ symbol: string; /** 종목명. */ name: string; /** 시가총액 (억원 단위 정수). marketValueHangeul 텍스트에서 파싱. */ marketCapEok: number; /** 종가 (옵션). */ closePrice?: number; market: Market; /** 업종명 — 업종별 조회(screenIndustry)로 들어온 경우 채워짐. */ sectorName?: string; } /** * "1,710조 365억" / "2,787억" / "17조" 같은 텍스트를 *억 단위 정수* 로 환산. * - 조 단위: ×10,000 * - 억 단위: ×1 */ function parseMarketCapHangeul(text: string | undefined): number { if (!text) return 0; 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; // "1,710조 365억" → jo=1710, eok=365 → 17,103,650 안 됨. 1,710조 365 = 17,103,650 억? 아니다. // 1,710조 = 17,100,000 억. + 365억 = 17,100,365 억. return jo * 10000 + eok; } interface NaverStockListItem { itemCode: string; stockName: string; marketValue?: string; // 백만원 단위 (단위 모호, 사용 안 함) marketValueHangeul?: string; // "2,787억원" — 사용 closePrice?: string; // "12,090" } interface NaverMarketValueResponse { stocks: NaverStockListItem[]; totalCount?: number; pageSize?: number; page?: number; } async function fetchPage(market: Market, page: number): Promise { const category = market === 'kospi' ? 'KOSPI' : 'KOSDAQ'; const url = `${BASE_URL}/${category}?page=${page}&pageSize=50`; const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000), }); if (!res.ok) throw new Error(`Naver screener HTTP ${res.status} (${market} p${page})`); const data = await res.json() as NaverMarketValueResponse; if (!Array.isArray(data.stocks)) { throw new Error(`Naver screener: stocks 필드 누락 (${market} p${page})`); } return data.stocks; } function toEntry(item: NaverStockListItem, market: Market): ScreenerEntry { const marketCapEok = parseMarketCapHangeul(item.marketValueHangeul); const closePriceNum = item.closePrice ? parseFloat(item.closePrice.replace(/,/g, '')) : undefined; return { symbol: item.itemCode, name: item.stockName, marketCapEok, closePrice: Number.isFinite(closePriceNum as number) ? closePriceNum : undefined, market, }; } /** * 한 시장 전체 (또는 maxPages) 의 후보 풀 fetch. 시가총액 범위 (억) 로 1차 필터. * - throttle: 300ms (HTML 크롤 500ms 보다 빠르게 — JSON 이라 가벼움). * - Naver 가 시총 큰 순으로 정렬해 반환하므로, *시총 maxCap 보다 큰 페이지* 는 일찍 종료 가능. */ export async function screenMarket(opts: { market: Market; maxPages?: number; minMarketCapEok?: number; maxMarketCapEok?: number; onProgress?: (page: number, totalSoFar: number) => void; }): Promise { const maxPages = opts.maxPages ?? 20; const minCap = opts.minMarketCapEok ?? 0; const maxCap = opts.maxMarketCapEok ?? Number.MAX_SAFE_INTEGER; const collected: ScreenerEntry[] = []; for (let page = 1; page <= maxPages; page++) { try { const items = await fetchPage(opts.market, page); if (items.length === 0) { logInfo(`Naver screener p${page} ${opts.market}: 0 rows — 종료.`); break; } const entries = items.map(it => toEntry(it, opts.market)); // 시총 큰 순 → 모든 종목 시총이 maxCap 보다 작아진 페이지부터는 maxCap 위 종목 없음 (정렬 보장). // minCap 보다 작아진 페이지는 그 뒤 모든 종목이 더 작음 → early exit. let pageBelowMin = true; for (const e of entries) { if (e.marketCapEok > maxCap) continue; if (e.marketCapEok < minCap) continue; collected.push(e); pageBelowMin = false; } // 이 페이지의 *모든* 종목이 minCap 미만이면 더 작은 종목들만 남음 → early exit. if (entries.every(e => e.marketCapEok < minCap)) { logInfo(`Naver screener ${opts.market}: p${page} 전부 minCap(${minCap}억) 미만 — early exit.`); opts.onProgress?.(page, collected.length); break; } opts.onProgress?.(page, collected.length); await new Promise(r => setTimeout(r, 300)); } catch (e: any) { logError(`Naver screener p${page} ${opts.market} 실패.`, { error: e?.message ?? String(e) }); // partial 결과라도 반환 — 한 페이지 실패해도 continue. } } logInfo(`Naver screener ${opts.market}: ${collected.length}개 (${minCap}억 ~ ${maxCap}억).`); return collected; } interface NaverIndustryStock { itemCode: string; stockName: string; closePrice?: string; /** 시가총액 — 억 단위 (콤마 포함 문자열). marketValueHangeul 와 달리 숫자만. 예: "449,667" = 44.97조. */ marketValue?: string; stockExchangeType?: { nameEng?: string }; } interface NaverIndustryResponse { stocks?: NaverIndustryStock[]; } /** 업종 API 의 marketValue("449,667") → 억 단위 정수. */ function parseIndustryMarketCap(text: string | undefined): number { if (!text) return 0; const n = parseInt(text.replace(/[^\d]/g, ''), 10); return Number.isFinite(n) ? n : 0; } async function fetchIndustryPage(code: number, page: number): Promise { const url = `${INDUSTRY_URL}/${code}?page=${page}&pageSize=100`; const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000), }); if (!res.ok) throw new Error(`Naver industry HTTP ${res.status} (code ${code} p${page})`); const data = await res.json() as NaverIndustryResponse; return Array.isArray(data.stocks) ? data.stocks : []; } /** * 업종 코드 묶음으로 종목을 *직접* 조회 (시총 범위 1차 필터). 시총 스크리너 대신 * 섹터 모드 discover 가 사용 — 전 종목 크롤 없이 해당 섹터 종목만 받는다. * 같은 종목이 여러 코드에 중복되면 dedup. sectorName 채워서 반환. */ export async function screenIndustry(opts: { codes: number[]; sectorName: string; minMarketCapEok?: number; maxMarketCapEok?: number; maxPagesPerCode?: number; onProgress?: (msg: string) => void; }): Promise { const minCap = opts.minMarketCapEok ?? 0; const maxCap = opts.maxMarketCapEok ?? Number.MAX_SAFE_INTEGER; const maxPages = opts.maxPagesPerCode ?? 5; // 코드당 최대 500종목 const bySymbol = new Map(); for (const code of opts.codes) { for (let page = 1; page <= maxPages; page++) { let items: NaverIndustryStock[]; try { items = await fetchIndustryPage(code, page); } catch (e: any) { logError(`Naver industry code ${code} p${page} 실패.`, { error: e?.message ?? String(e) }); break; } if (items.length === 0) break; for (const it of items) { const cap = parseIndustryMarketCap(it.marketValue); if (cap < minCap || cap > maxCap) continue; if (bySymbol.has(it.itemCode)) continue; const exch = (it.stockExchangeType?.nameEng || '').toUpperCase(); bySymbol.set(it.itemCode, { symbol: it.itemCode, name: it.stockName, marketCapEok: cap, closePrice: it.closePrice ? parseFloat(it.closePrice.replace(/,/g, '')) : undefined, market: exch === 'KOSDAQ' ? 'kosdaq' : 'kospi', sectorName: opts.sectorName, }); } opts.onProgress?.(` 업종 ${code} p${page} → 누적 ${bySymbol.size}개`); if (items.length < 100) break; // 마지막 페이지 await new Promise(r => setTimeout(r, 250)); } } const out = Array.from(bySymbol.values()); logInfo(`Naver industry [${opts.codes.join(',')}] (${opts.sectorName}): ${out.length}개 (${minCap}~${maxCap}억).`); return out; }