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:
g1nation
2026-05-25 09:59:32 +09:00
parent 4153f640c2
commit 0a97324f1b
149 changed files with 14628 additions and 6927 deletions
+161
View File
@@ -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;
}