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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user