53953fb5f8
[버그] 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>
236 lines
10 KiB
TypeScript
236 lines
10 KiB
TypeScript
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;
|
||
}
|