feat: v2.2.92 → v2.2.158 — god-file 분해 + Stocks feature + 대화 연속성
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>
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import { logError, logInfo } from '../../utils';
|
||||
|
||||
/**
|
||||
* Naver Finance 비공식 JSON API 로 개별 종목 펀더멘털 fetch.
|
||||
*
|
||||
* 두 endpoint 합성:
|
||||
* - `/api/stock/<code>/integration` — 시총 텍스트 / PER / PBR / EPS / 현재가
|
||||
* - `/api/stock/<code>/finance/annual` — ROE / 영업이익률 / 유보율 / 부채비율
|
||||
* (rowList 안의 row.title 매칭, 최근 연도 컬럼 값 사용)
|
||||
*
|
||||
* JSON 응답이라 selector 깨질 일이 없고, label 매칭도 정확 (rowList[i].title === 'ROE').
|
||||
* 사용자가 별도 인증 / API 키 필요 없음 — Naver Finance 모바일 페이지가 쓰는 그대로.
|
||||
*/
|
||||
|
||||
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 INTEGRATION_URL = 'https://m.stock.naver.com/api/stock';
|
||||
|
||||
export interface Fundamentals {
|
||||
symbol: string;
|
||||
/** 연간 재무제표 (최근 *확정* 연도). */
|
||||
roe?: number; // %
|
||||
operatingMargin?: number; // % (영업이익률)
|
||||
retentionRatio?: number; // % (유보율)
|
||||
debtRatio?: number; // % (부채비율)
|
||||
/** integration API 의 현재가 + 평가지표. */
|
||||
per?: number;
|
||||
pbr?: number;
|
||||
eps?: number;
|
||||
marketCapEok?: number; // 억 단위
|
||||
currentPrice?: number;
|
||||
/** 업종 hint — 사용 가능하면 채움 ("기술력" 키워드 매칭 용). */
|
||||
sectorHint?: string;
|
||||
}
|
||||
|
||||
interface NaverIntegrationResponse {
|
||||
stockName?: string;
|
||||
totalInfos?: Array<{ code: string; key: string; value: string }>;
|
||||
/** 종목 분류 / 업종 정보가 들어있을 수 있음 (옵션). */
|
||||
industryInfo?: { name?: string };
|
||||
}
|
||||
|
||||
interface NaverFinanceAnnualResponse {
|
||||
financeInfo?: {
|
||||
trTitleList?: Array<{ isConsensus: 'Y' | 'N'; title: string; key: string }>;
|
||||
rowList?: Array<{ title: string; columns: Record<string, { value: string }> }>;
|
||||
};
|
||||
}
|
||||
|
||||
/** "12,090" / "23.64배" / "5,800%" / "1,710조 365억" / "-" 텍스트에서 숫자 추출. */
|
||||
function parseNumber(raw: string | undefined): number | undefined {
|
||||
if (!raw) return undefined;
|
||||
const cleaned = raw.replace(/,/g, '').replace(/배|%|원|억|조/g, '').trim();
|
||||
if (!cleaned || cleaned === '-' || cleaned === 'N/A') return undefined;
|
||||
const n = parseFloat(cleaned);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
/** "1,710조 365억" / "2,787억" / "5조" → 억 단위 정수. */
|
||||
function parseMarketCapText(text: string | undefined): number | undefined {
|
||||
if (!text) return undefined;
|
||||
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;
|
||||
const total = jo * 10000 + eok;
|
||||
return total > 0 ? total : undefined;
|
||||
}
|
||||
|
||||
/** trTitleList 에서 *최근 확정* (isConsensus = 'N') 컬럼 키 선택. */
|
||||
function pickLatestConfirmedKey(titles?: Array<{ isConsensus: 'Y' | 'N'; key: string }>): string | null {
|
||||
if (!titles || titles.length === 0) return null;
|
||||
// 'N' 만 필터 → key 내림차순 → 첫 번째.
|
||||
const confirmed = titles.filter(t => t.isConsensus === 'N').map(t => t.key).sort().reverse();
|
||||
return confirmed[0] ?? null;
|
||||
}
|
||||
|
||||
async function fetchIntegration(symbol: string, timeoutMs: number): Promise<NaverIntegrationResponse | null> {
|
||||
try {
|
||||
const res = await fetch(`${INTEGRATION_URL}/${symbol}/integration`, {
|
||||
headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json() as NaverIntegrationResponse;
|
||||
} catch (e: any) {
|
||||
logError('Naver integration fetch 실패.', { symbol, error: e?.message ?? String(e) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFinanceAnnual(symbol: string, timeoutMs: number): Promise<NaverFinanceAnnualResponse | null> {
|
||||
try {
|
||||
const res = await fetch(`${INTEGRATION_URL}/${symbol}/finance/annual`, {
|
||||
headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json() as NaverFinanceAnnualResponse;
|
||||
} catch (e: any) {
|
||||
logError('Naver finance annual fetch 실패.', { symbol, error: e?.message ?? String(e) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchFundamentals(symbol: string, timeoutMs = 10000): Promise<Fundamentals | null> {
|
||||
const [integ, fin] = await Promise.all([
|
||||
fetchIntegration(symbol, timeoutMs),
|
||||
fetchFinanceAnnual(symbol, timeoutMs),
|
||||
]);
|
||||
if (!integ && !fin) return null;
|
||||
|
||||
const out: Fundamentals = { symbol };
|
||||
|
||||
// integration — totalInfos 의 code 로 추출 (key 한글 텍스트보다 안정적).
|
||||
if (integ?.totalInfos) {
|
||||
const map = new Map(integ.totalInfos.map(i => [i.code, i.value]));
|
||||
out.per = parseNumber(map.get('per'));
|
||||
out.pbr = parseNumber(map.get('pbr'));
|
||||
out.eps = parseNumber(map.get('eps'));
|
||||
out.currentPrice = parseNumber(map.get('closePrice') || map.get('lastClosePrice'));
|
||||
out.marketCapEok = parseMarketCapText(map.get('marketValue'));
|
||||
}
|
||||
if (integ?.industryInfo?.name) out.sectorHint = integ.industryInfo.name;
|
||||
|
||||
// finance/annual — rowList 에서 최근 확정 연도 컬럼 값.
|
||||
if (fin?.financeInfo?.rowList && fin.financeInfo.rowList.length > 0) {
|
||||
const latestKey = pickLatestConfirmedKey(fin.financeInfo.trTitleList);
|
||||
if (latestKey) {
|
||||
const rowByTitle = new Map(fin.financeInfo.rowList.map(r => [r.title, r]));
|
||||
const valueOf = (title: string): number | undefined => {
|
||||
const row = rowByTitle.get(title);
|
||||
if (!row) return undefined;
|
||||
return parseNumber(row.columns[latestKey]?.value);
|
||||
};
|
||||
out.roe = valueOf('ROE');
|
||||
out.operatingMargin = valueOf('영업이익률');
|
||||
out.retentionRatio = valueOf('유보율');
|
||||
out.debtRatio = valueOf('부채비율');
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 일괄 fetch — throttle 300ms. JSON API 가 가벼우니 HTML 크롤 500ms 보다 빠르게. */
|
||||
export async function fetchAllFundamentals(
|
||||
symbols: string[],
|
||||
onProgress?: (symbol: string, fund: Fundamentals | null, i: number, total: number) => void,
|
||||
): Promise<Map<string, Fundamentals>> {
|
||||
const out = new Map<string, Fundamentals>();
|
||||
let i = 0;
|
||||
for (const symbol of symbols) {
|
||||
i++;
|
||||
const fund = await fetchFundamentals(symbol);
|
||||
if (fund) out.set(symbol, fund);
|
||||
onProgress?.(symbol, fund, i, symbols.length);
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
}
|
||||
logInfo(`Naver fundamentals 일괄 fetch: ${out.size}/${symbols.length} 성공.`);
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user