Files
connectai/src/features/stocks/naverScreener.ts
T
koriweb 53953fb5f8 feat(stocks): discover sector — 네이버 업종 코드 직접 조회로 재설계 (v2.2.250)
[버그] discover sector 가 항상 0개 반환. 원인: sectorHint 를 통합 API 의
없는 필드(industryInfo.name)에서 읽어 전 종목이 업종 미상 → 필터 전멸.

[근본 수정] "전 종목 시총 크롤 후 sectorHint 필터" → "네이버 업종 코드로
해당 섹터 종목 직접 조회". 실측: 2차전지 1000-5000억 0개 → 36개.
- stockSectors: 친화 섹터키 → 네이버 업종코드 묶음 (업종 79개 코드표). 17개 그룹.
- naverScreener.screenIndustry(): /api/stocks/industry/{code} 직접 수집 + 시총 필터 + dedup.
- naverFundamentals: sectorHint 를 industryCode→이름 매핑으로 수정 (기술력 키워드·judge 복구).
- stockDiscovery: 섹터 모드 3키워드 게이트 완화(≥1, "섹터 내 상대 추천").
- CLI: discover sector <섹터> [min] [max] / discover sectors.

테스트 8건. 라이브 e2e 확인.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:55:18 +09:00

236 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<NaverStockListItem[]> {
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<ScreenerEntry[]> {
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<NaverIndustryStock[]> {
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<ScreenerEntry[]> {
const minCap = opts.minMarketCapEok ?? 0;
const maxCap = opts.maxMarketCapEok ?? Number.MAX_SAFE_INTEGER;
const maxPages = opts.maxPagesPerCode ?? 5; // 코드당 최대 500종목
const bySymbol = new Map<string, ScreenerEntry>();
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;
}