stocks: 미국 주식 지원(개별 종목) + datacollect LLM 직접 호출 + 백엔드 기본 NAS
미국 주식 (개별 종목 조회 — add/check/judge/analysis/position): - marketUtils: KR(6자리)/US(티커) 시장 판별, 통화·시총 포매팅($/원, B·T/억·조) - yahooClient: US는 raw 티커(.KQ/.KS 미부착), 심볼 라우팅 분리 - yahooFundamentals: Yahoo quoteSummary(무키) cookie+crumb 핸드셰이크로 ROE/PER/PBR/마진/성장/부채 fetch (429·crumb 하드닝) - fundamentalsRouter: 심볼별 Naver(KR)/Yahoo(US) 분기 - criteriaEval: market-aware — US는 유보율 미보고라 유동성·안정성을 부채비율(D/E)·시총($B)으로 대체 - types: Stock.market 필드; slashStocks/llmJudge: 통화·라우팅·프롬프트 US 대응 - 테스트: stocksMarket + criteriaEval US 케이스 (전체 통과) datacollect/llm.ts: LLM을 확장(PC)에서 직접 호출(과거 bridge /api/lm 프록시 제거) → 백엔드 NAS 이전 시 PC→NAS→PC 실패 제거, 스크래핑=NAS·LLM 추론=PC 분리 package.json: datacollectBridge 기본값 nas + https://dc.koritips.com; 버전 2.2.261 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,9 @@ import * as vscode from 'vscode';
|
||||
import { logInfo } from '../../utils';
|
||||
import { readStocksStore, addStock, removeStock, getStocksFilePath } from './stocksStore';
|
||||
import { fetchAllPrices, fetchYahooPrice, fetchYahooHistory, evalMa224Recovery, evalDropRecovery, evalMaAlignment, evalRsi14, type Ma224RecoveryResult, type DropRecoveryResult, type MaAlignmentResult, type Rsi14Result } from './yahooClient';
|
||||
import { fetchAllFundamentals, type Fundamentals } from './naverFundamentals';
|
||||
import type { Fundamentals } from './naverFundamentals';
|
||||
import { fetchFundamentalsRouted } from './fundamentalsRouter';
|
||||
import { detectMarket, formatMoney, formatMarketCapKR, formatMarketCapUS, type StockMarket } from './marketUtils';
|
||||
import { AIService } from '../../core/services';
|
||||
import { classifyAll } from './signalClassifier';
|
||||
import { writeStocksStore } from './stocksStore';
|
||||
@@ -41,15 +43,12 @@ function chunk(view: Webview | undefined, value: string) {
|
||||
view?.postMessage({ type: 'streamChunk', value });
|
||||
}
|
||||
|
||||
function formatPrice(n: number | undefined): string {
|
||||
if (typeof n !== 'number' || !Number.isFinite(n)) return '-';
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
function renderListLine(s: ClassifiedStock): string {
|
||||
const cur = formatPrice(s.현재가);
|
||||
const mkt: StockMarket = s.market ?? detectMarket(s.심볼);
|
||||
const cur = typeof s.현재가 === 'number' ? formatMoney(s.현재가, mkt) : '-';
|
||||
const rec = s.매수권장가 ?? '-';
|
||||
return ` · ${s.이름} (${s.심볼}): ${cur} / 권장 ${rec} → ${s.signalText}`;
|
||||
const flag = mkt === 'US' ? '🇺🇸 ' : '';
|
||||
return ` · ${flag}${s.이름} (${s.심볼}): ${cur} / 권장 ${rec} → ${s.signalText}`;
|
||||
}
|
||||
|
||||
async function cmdList(view: Webview | undefined): Promise<void> {
|
||||
@@ -82,7 +81,7 @@ async function cmdCheck(view: Webview | undefined): Promise<void> {
|
||||
const symbols = store.map(s => s.심볼).filter(Boolean);
|
||||
const prices = await fetchAllPrices(symbols, (sym, p) => {
|
||||
const name = store.find(s => s.심볼 === sym)?.이름 ?? sym;
|
||||
chunk(view, ` · ${name}: ${p !== null ? p.toLocaleString() + '원' : '조회 실패'}\n`);
|
||||
chunk(view, ` · ${name}: ${p !== null ? formatMoney(p, detectMarket(sym)) : '조회 실패'}\n`);
|
||||
});
|
||||
for (const s of store) {
|
||||
const p = prices.get(s.심볼);
|
||||
@@ -123,10 +122,13 @@ async function cmdAdd(arg: string, view: Webview | undefined): Promise<void> {
|
||||
chunk(view, '\n사용법: `/stocks add <심볼> <이름> [투자성향]`\n 투자성향: 스윙/중기 | 장기투자 | 저평가우량주 (기본: 스윙/중기)\n');
|
||||
return;
|
||||
}
|
||||
const [symbol, name, profileRaw] = parts;
|
||||
const [symbolRaw, name, profileRaw] = parts;
|
||||
// 미국 티커는 대문자 정규화(aapl→AAPL), 한국 코드는 그대로. 시장은 심볼로 자동 판별해 저장.
|
||||
const market = detectMarket(symbolRaw);
|
||||
const symbol = market === 'US' ? symbolRaw.toUpperCase() : symbolRaw;
|
||||
const profile = (profileRaw as Stock['투자성향']) || '스윙/중기';
|
||||
const r = addStock({ 이름: name, 심볼: symbol, 투자성향: profile });
|
||||
chunk(view, r.ok ? `\n✅ 추가: ${name} (${symbol}, ${profile})\n` : `\n❌ ${r.reason}\n`);
|
||||
const r = addStock({ 이름: name, 심볼: symbol, market, 투자성향: profile });
|
||||
chunk(view, r.ok ? `\n✅ 추가: ${market === 'US' ? '🇺🇸 ' : ''}${name} (${symbol}, ${profile})\n` : `\n❌ ${r.reason}\n`);
|
||||
}
|
||||
|
||||
async function cmdRemove(arg: string, view: Webview | undefined): Promise<void> {
|
||||
@@ -138,12 +140,13 @@ async function cmdRemove(arg: string, view: Webview | undefined): Promise<void>
|
||||
async function cmdJudge(arg: string, view: Webview | undefined): Promise<void> {
|
||||
if (!arg.trim()) { chunk(view, '\n사용법: `/stocks judge <심볼>`\n'); return; }
|
||||
const symbol = arg.trim();
|
||||
// 저장값은 분기 실적 이후 stale 할 수 있어 판정 전에 Naver 실시간 수치를 시도.
|
||||
const srcName = detectMarket(symbol) === 'US' ? 'Yahoo' : 'Naver';
|
||||
// 저장값은 분기 실적 이후 stale 할 수 있어 판정 전에 실시간 수치를 시도(한국=Naver, 미국=Yahoo).
|
||||
// 실패하면 stocks.json 저장값으로 폴백(결과에 데이터 출처 표기).
|
||||
chunk(view, `\n📡 Naver 펀더멘털 갱신 중: ${symbol}...\n`);
|
||||
chunk(view, `\n📡 ${srcName} 펀더멘털 갱신 중: ${symbol}...\n`);
|
||||
let fresh: Fundamentals | undefined;
|
||||
try {
|
||||
fresh = (await fetchAllFundamentals([symbol])).get(symbol) ?? undefined;
|
||||
fresh = (await fetchFundamentalsRouted(symbol)) ?? undefined;
|
||||
} catch { /* 폴백 — 저장값 사용 */ }
|
||||
chunk(view, fresh ? '✅ 실시간 수치 확보\n' : '⚠️ 실시간 조회 실패 — 저장값으로 평가\n');
|
||||
chunk(view, `🤖 필터 평가 중 (수치 판정=코드, 근거 서술=LLM)...\n`);
|
||||
@@ -162,10 +165,15 @@ async function cmdJudge(arg: string, view: Webview | undefined): Promise<void> {
|
||||
// + LLM 종합 평가 (권장도/근거/리스크). stocks.json 에 종목이 없어도 동작.
|
||||
|
||||
const ANALYSIS_SYSTEM_PROMPT = [
|
||||
'당신은 한국 주식 종합 평가 보조 도구다. 사용자가 제공한 한 종목의 *Naver 펀더멘털*',
|
||||
'+ *Yahoo 1년 시세 기반 기술 지표* 를 보고 종합 평가를 내린다.',
|
||||
'당신은 한국·미국 주식 종합 평가 보조 도구다. 사용자가 제공한 한 종목의 *펀더멘털*',
|
||||
'(한국=Naver, 미국=Yahoo quoteSummary) + *Yahoo 1년 시세 기반 기술 지표* 를 보고 종합 평가를 내린다.',
|
||||
'외부 정보 추측 금지 — 주어진 데이터에서만 판단.',
|
||||
'',
|
||||
'**통화·시장 규칙:** 컨텍스트 첫 줄의 (시장/통화) 표기를 따른다. 한국=원, 미국=달러($).',
|
||||
'가격·타점을 쓸 때 그 통화 기호를 그대로 사용하고 단위를 섞지 말 것.',
|
||||
'미국 종목은 **유보율(retention ratio)을 보고하지 않는다 — "N/A"는 부정 신호가 아니다.**',
|
||||
'미국의 재무 안정성은 부채비율(D/E)·시가총액으로만 판단하고, 유보율 부재를 약점으로 적지 말 것.',
|
||||
'',
|
||||
'**평가 6차원:**',
|
||||
' 1. 가치 — PBR / PER (저평가 여부)',
|
||||
' 2. 수익성 — ROE / 영업이익률 절대 수준',
|
||||
@@ -213,7 +221,7 @@ const ANALYSIS_SYSTEM_PROMPT = [
|
||||
'',
|
||||
'### 익절 타점',
|
||||
'- **1차**: 1년 최고가 × 0.98 (가격 X원), 보유 비중 30~50% 축소',
|
||||
'- **2차**: 다음 심리적 라운드 넘버(현재가 위 가장 가까운 10,000/50,000/100,000원 단위, X원), 잔량 추가 축소',
|
||||
'- **2차**: 다음 심리적 라운드 넘버(한국: 현재가 위 가장 가까운 10,000/50,000/100,000원 단위 / 미국: $10/$50/$100/$1000 단위), 잔량 추가 축소',
|
||||
'- **3차**: 동종 업종 평균 PER 대비 20% 초과 시 전량 익절 고려. *업종 평균 PER 데이터는 현재 미수집 — 사용자가 수동 비교 필요.*',
|
||||
'',
|
||||
'### 관망 해제 트리거',
|
||||
@@ -262,25 +270,31 @@ function buildAnalysisContext(
|
||||
rsi: Rsi14Result | null,
|
||||
preferred: PreferredStockInfo | null,
|
||||
): string {
|
||||
const mkt: StockMarket = f.market ?? detectMarket(symbol);
|
||||
const money = (n?: number) => n !== undefined ? formatMoney(n, mkt) : '-';
|
||||
const capText = mkt === 'US' ? formatMarketCapUS(f.marketCapNative) : formatMarketCapKR(f.marketCapEok);
|
||||
const lines: string[] = [
|
||||
`종목 심볼: ${symbol}`,
|
||||
`종목 심볼: ${symbol} (시장: ${mkt === 'US' ? '미국 🇺🇸' : '한국'}, 통화: ${f.currency ?? (mkt === 'US' ? 'USD' : 'KRW')})`,
|
||||
`섹터: ${f.sectorHint ?? '-'}`,
|
||||
'',
|
||||
'── Naver 펀더멘털 ──',
|
||||
`시가총액: ${f.marketCapEok !== undefined ? Math.round(f.marketCapEok).toLocaleString() + '억' : '-'}`,
|
||||
`── ${f.source ?? (mkt === 'US' ? 'Yahoo quoteSummary' : 'Naver')} 펀더멘털 ──`,
|
||||
`시가총액: ${capText}`,
|
||||
`ROE: ${f.roe !== undefined ? f.roe.toFixed(2) + '%' : '-'}`,
|
||||
`영업이익률: ${f.operatingMargin !== undefined ? f.operatingMargin.toFixed(1) + '%' : '-'}`,
|
||||
`유보율: ${f.retentionRatio !== undefined ? Math.round(f.retentionRatio).toLocaleString() + '%' : '-'}`,
|
||||
`부채비율: ${f.debtRatio !== undefined ? f.debtRatio.toFixed(1) + '%' : '-'}`,
|
||||
// 유보율은 한국 전용 지표 — 미국은 미보고(부정 신호 아님). 부채비율(D/E)로 안정성 판단.
|
||||
mkt === 'US'
|
||||
? '유보율: N/A (미국 미보고 — 부정 신호 아님. 안정성은 부채비율로 판단)'
|
||||
: `유보율: ${f.retentionRatio !== undefined ? Math.round(f.retentionRatio).toLocaleString() + '%' : '-'}`,
|
||||
`부채비율${mkt === 'US' ? '(D/E)' : ''}: ${f.debtRatio !== undefined ? f.debtRatio.toFixed(1) + '%' : '-'}`,
|
||||
`PER: ${f.per !== undefined ? f.per.toFixed(1) : '-'}`,
|
||||
`PBR: ${f.pbr !== undefined ? f.pbr.toFixed(2) : '-'}`,
|
||||
`현재가: ${f.currentPrice !== undefined ? f.currentPrice.toLocaleString() + '원' : '-'}`,
|
||||
`현재가: ${money(f.currentPrice)}`,
|
||||
'',
|
||||
'── Yahoo 1년 기술 지표 ──',
|
||||
];
|
||||
if (ma224) {
|
||||
const ma = ma224.ma224Today !== undefined ? Math.round(ma224.ma224Today).toLocaleString() : '-';
|
||||
const cp = ma224.currentPrice !== undefined ? ma224.currentPrice.toLocaleString() : '-';
|
||||
const ma = money(ma224.ma224Today);
|
||||
const cp = money(ma224.currentPrice);
|
||||
if (ma224.notApplicable) {
|
||||
// N/A 상태 — passed:false 와 시각적으로 구분 (⚪ vs ❌). LLM 도 이걸 보고 음성 신호 아님으로 인지.
|
||||
lines.push(
|
||||
@@ -300,9 +314,9 @@ function buildAnalysisContext(
|
||||
lines.push('224일선 분석: 시세 데이터 부족 (224일 미만)');
|
||||
}
|
||||
if (drop) {
|
||||
const cp = drop.currentPrice !== undefined ? drop.currentPrice.toLocaleString() : '-';
|
||||
const hi = drop.high1y !== undefined ? drop.high1y.toLocaleString() : '-';
|
||||
const lo = drop.low60d !== undefined ? drop.low60d.toLocaleString() : '-';
|
||||
const cp = money(drop.currentPrice);
|
||||
const hi = money(drop.high1y);
|
||||
const lo = money(drop.low60d);
|
||||
const fromHigh = drop.fromHighRatio !== undefined ? `${(drop.fromHighRatio * 100).toFixed(1)}% 수준` : '-';
|
||||
const fromLow = drop.fromLowRatio !== undefined ? `+${((drop.fromLowRatio - 1) * 100).toFixed(1)}% 반등` : '-';
|
||||
const verdict = drop.passed
|
||||
@@ -317,10 +331,9 @@ function buildAnalysisContext(
|
||||
lines.push('낙폭과대 분석: 시세 데이터 부족 (60일 미만)');
|
||||
}
|
||||
if (align) {
|
||||
const fmt = (n?: number) => n !== undefined ? Math.round(n).toLocaleString() : '-';
|
||||
lines.push(
|
||||
`MA 배열: ${align.alignment === '정배열' ? '✅ 정배열' : align.alignment === '역배열' ? '❌ 역배열' : '⚠️ 혼조'}`,
|
||||
` · MA5 ${fmt(align.ma5)} / MA20 ${fmt(align.ma20)} / MA60 ${fmt(align.ma60)} / MA120 ${fmt(align.ma120)}`,
|
||||
` · MA5 ${money(align.ma5)} / MA20 ${money(align.ma20)} / MA60 ${money(align.ma60)} / MA120 ${money(align.ma120)}`,
|
||||
);
|
||||
} else {
|
||||
lines.push('MA 배열: 시세 데이터 부족 (120일 미만)');
|
||||
@@ -356,16 +369,19 @@ async function cmdAnalysis(arg: string, view: Webview | undefined): Promise<void
|
||||
chunk(view, '\n사용법: `/stocks analysis <심볼>` — 펀더멘털 + 1년 차트 패턴 종합 분석 (judge보다 깊음). stocks.json 미등록 종목도 가능.\n');
|
||||
return;
|
||||
}
|
||||
chunk(view, `\n🔍 **${symbol} 심층 분석** — Naver 펀더멘털 + Yahoo 1년 시세 + LLM 종합...\n`);
|
||||
const mkt: StockMarket = detectMarket(symbol);
|
||||
const fundSrc = mkt === 'US' ? 'Yahoo quoteSummary' : 'Naver';
|
||||
chunk(view, `\n🔍 **${mkt === 'US' ? '🇺🇸 ' : ''}${symbol} 심층 분석** — ${fundSrc} 펀더멘털 + Yahoo 1년 시세 + LLM 종합...\n`);
|
||||
|
||||
// 1. Naver 펀더멘털
|
||||
const fundsMap = await fetchAllFundamentals([symbol]);
|
||||
const f = fundsMap.get(symbol);
|
||||
// 1. 펀더멘털 (한국=Naver, 미국=Yahoo quoteSummary)
|
||||
const f = await fetchFundamentalsRouted(symbol);
|
||||
if (!f) {
|
||||
chunk(view, '\n❌ Naver 에서 펀더멘털 데이터를 못 가져왔습니다. 심볼을 확인해주세요(코스피/코스닥 6자리).\n');
|
||||
chunk(view, mkt === 'US'
|
||||
? '\n❌ Yahoo 에서 펀더멘털 데이터를 못 가져왔습니다. 티커를 확인해주세요(예: AAPL, MSFT). Yahoo crumb 일시 차단일 수도 있으니 잠시 후 재시도.\n'
|
||||
: '\n❌ Naver 에서 펀더멘털 데이터를 못 가져왔습니다. 심볼을 확인해주세요(코스피/코스닥 6자리).\n');
|
||||
return;
|
||||
}
|
||||
chunk(view, ' · Naver 펀더멘털 OK\n');
|
||||
chunk(view, ` · ${fundSrc} 펀더멘털 OK\n`);
|
||||
|
||||
// 2. Yahoo 1년 시세 + 기술 지표
|
||||
const history = await fetchYahooHistory(symbol);
|
||||
@@ -433,20 +449,22 @@ async function cmdPosition(arg: string, view: Webview | undefined): Promise<void
|
||||
' `/stocks position <심볼> <총자산> <리스크%> <손절%>` — + 현재가로 매수 가능 주수',
|
||||
'',
|
||||
'예: `/stocks position 50000000 2 5` → 50M × 2% ÷ 5% = 20M',
|
||||
'예: `/stocks position 019010 50000000 2 5` → 20M / 현재가 = N주',
|
||||
'예: `/stocks position 019010 50000000 2 5` → 20M / 현재가 = N주 (한국)',
|
||||
'예: `/stocks position AAPL 100000 2 5` → 미국 티커 ($ 표기)',
|
||||
'',
|
||||
].join('\n'));
|
||||
return;
|
||||
}
|
||||
// 첫 인자가 6자리 숫자면 심볼, 아니면 곧바로 숫자 인자.
|
||||
// 첫 인자가 종목 심볼이면(6자리 코드 또는 알파벳 티커) 분리, 아니면 곧바로 숫자 인자.
|
||||
let symbol: string | undefined;
|
||||
let nums: string[];
|
||||
if (/^[0-9]{6}$/.test(parts[0])) {
|
||||
symbol = parts[0];
|
||||
if (/^[0-9]{6}$/.test(parts[0]) || /^[A-Za-z][A-Za-z.\-]*$/.test(parts[0])) {
|
||||
symbol = /^[A-Za-z]/.test(parts[0]) ? parts[0].toUpperCase() : parts[0];
|
||||
nums = parts.slice(1);
|
||||
} else {
|
||||
nums = parts;
|
||||
}
|
||||
const mkt: StockMarket = symbol ? detectMarket(symbol) : 'KR';
|
||||
if (nums.length < 3) {
|
||||
chunk(view, '\n❌ 인자 부족 — 총자산, 리스크%, 손절% 3개 필요.\n');
|
||||
return;
|
||||
@@ -468,10 +486,10 @@ async function cmdPosition(arg: string, view: Webview | undefined): Promise<void
|
||||
const positionRatio = positionWon / total * 100;
|
||||
|
||||
chunk(view, '\n💰 **포지션 사이징 계산**\n');
|
||||
chunk(view, ` · 총 자산: ${total.toLocaleString()}원\n`);
|
||||
chunk(view, ` · 리스크 허용: ${riskPct}% (= 최대 손실 ${Math.round(maxLoss).toLocaleString()}원)\n`);
|
||||
chunk(view, ` · 총 자산: ${formatMoney(total, mkt)}\n`);
|
||||
chunk(view, ` · 리스크 허용: ${riskPct}% (= 최대 손실 ${formatMoney(maxLoss, mkt)})\n`);
|
||||
chunk(view, ` · 손절폭: ${stopPct}%\n`);
|
||||
chunk(view, `\n → **권장 투자금: ${Math.round(positionWon).toLocaleString()}원** (총자산의 ${positionRatio.toFixed(1)}%)\n`);
|
||||
chunk(view, `\n → **권장 투자금: ${formatMoney(positionWon, mkt)}** (총자산의 ${positionRatio.toFixed(1)}%)\n`);
|
||||
|
||||
if (positionRatio > 50) {
|
||||
chunk(view, '\n ⚠️ 권장 투자금이 총자산의 50%를 초과 — 손절폭이 너무 좁거나 리스크 허용이 너무 큽니다. 입력값 재검토 권장.\n');
|
||||
@@ -483,8 +501,8 @@ async function cmdPosition(arg: string, view: Webview | undefined): Promise<void
|
||||
if (typeof price === 'number') {
|
||||
const shares = Math.floor(positionWon / price);
|
||||
const actualWon = shares * price;
|
||||
chunk(view, ` · 현재가: ${price.toLocaleString()}원\n`);
|
||||
chunk(view, ` → **매수 가능 주수: ${shares.toLocaleString()}주** (실제 투자금 ${actualWon.toLocaleString()}원)\n`);
|
||||
chunk(view, ` · 현재가: ${formatMoney(price, mkt)}\n`);
|
||||
chunk(view, ` → **매수 가능 주수: ${shares.toLocaleString()}주** (실제 투자금 ${formatMoney(actualWon, mkt)})\n`);
|
||||
} else {
|
||||
chunk(view, ' ⚠️ Yahoo 현재가 fetch 실패 — 주수 계산 skip\n');
|
||||
}
|
||||
@@ -594,18 +612,18 @@ function cmdPath(view: Webview | undefined): void {
|
||||
|
||||
function cmdHelp(view: Webview | undefined): void {
|
||||
chunk(view, [
|
||||
'\n📈 **Stocks 명령**',
|
||||
'\n📈 **Stocks 명령** (한국 6자리 코드 + 미국 티커 🇺🇸 지원)',
|
||||
'',
|
||||
' `/stocks list` — 종목 + 신호',
|
||||
' `/stocks check` — 현재가 갱신',
|
||||
' `/stocks signal` — 매수사정권 종목만',
|
||||
' `/stocks sync` — Google Sheets 동기화',
|
||||
' `/stocks add <심볼> <이름>` — 종목 추가',
|
||||
' `/stocks add <심볼> <이름>` — 종목 추가 (예: `add 005930 삼성전자` / `add AAPL Apple`)',
|
||||
' `/stocks remove <심볼>` — 종목 제거',
|
||||
' `/stocks judge <심볼>` — LLM 4-criteria 평가 (stocks.json 등록 종목)',
|
||||
' `/stocks analysis <심볼>` — 심층 분석 (펀더멘털 + MA 정배열 + RSI + LLM 종합)',
|
||||
' `/stocks analysis <심볼>` — 심층 분석 (펀더멘털 + MA 정배열 + RSI + LLM 종합, 한·미)',
|
||||
' `/stocks position [심볼] <총자산> <리스크%> <손절%>` — 포지션 사이징 (적정 투자금)',
|
||||
' `/stocks discover [min] [max]` — Naver 크롤 발굴 (시총 억 단위, default 1000-5000)',
|
||||
' `/stocks discover [min] [max]` — Naver 크롤 발굴 (한국 전용, 시총 억 단위, default 1000-5000)',
|
||||
' `/stocks discover sector <섹터> [min] [max]` — 섹터별 발굴 (예: `discover sector 반도체`)',
|
||||
' `/stocks discover sectors` — 발굴 가능한 섹터 목록',
|
||||
' `/stocks report` — 텔레그램 보고서 즉시 발송',
|
||||
|
||||
Reference in New Issue
Block a user