0a97324f1b
R56–R59: agent.ts 2731→1529줄 god-file 분해 (25 modules) · attrParsers + LLM 메서드 8개 (callNonStreaming, streamChatOnce 등) · executeActions 415줄 → 8 handler 그룹 (file/run/list/brain/calendar/sheets/tasks) · handlePrompt 1100줄 → 7 phase 모듈 (system prompt + budget + autoContinue 등) R50–R55: extension.ts 1145→349줄 (telegram/settings/provider commands 분리) Stocks feature 신규: /stocks slash command (v2.2.152~158) · .astra/stocks.json 저장소 + Yahoo Finance 현재가 갱신 · 8 키워드 필터 (ROE/성장성/유동성/수익성/영업효율/기술력/안정성/PBR) · Naver 시가총액 페이지 JSON API (m.stock.naver.com) 발굴 · LLM Top 5 매력도 분석 + Telegram 자동 보고서 · KST 09:00/15:00 watcher 자동 모니터링 대화 연속성 (v2.2.150~157): · [PRIOR TURN CONCLUSION] block 으로 직전 결론 anchor · thin follow-up 분류 → boilerplate 헤더 suppression · slash 명령 결과 chatHistory mirror (capture wrapper) · echo/parrot 금지 system prompt rule 기타: /stocks 슬래시 자동완성 dropdown UI, Naver JSON API 전환 (cheerio 제거) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
152 lines
6.3 KiB
TypeScript
152 lines
6.3 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';
|
||
|
||
export type Market = 'kospi' | 'kosdaq';
|
||
|
||
export interface ScreenerEntry {
|
||
/** 6자리 종목코드. */
|
||
symbol: string;
|
||
/** 종목명. */
|
||
name: string;
|
||
/** 시가총액 (억원 단위 정수). marketValueHangeul 텍스트에서 파싱. */
|
||
marketCapEok: number;
|
||
/** 종가 (옵션). */
|
||
closePrice?: number;
|
||
market: Market;
|
||
}
|
||
|
||
/**
|
||
* "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;
|
||
}
|