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>
This commit is contained in:
2026-06-15 13:55:18 +09:00
parent ae021a8c16
commit 53953fb5f8
8 changed files with 308 additions and 26 deletions
+84
View File
@@ -23,6 +23,7 @@ import { logError, logInfo } from '../../utils';
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';
@@ -36,6 +37,8 @@ export interface ScreenerEntry {
/** 종가 (옵션). */
closePrice?: number;
market: Market;
/** 업종명 — 업종별 조회(screenIndustry)로 들어온 경우 채워짐. */
sectorName?: string;
}
/**
@@ -149,3 +152,84 @@ export async function screenMarket(opts: {
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;
}