Files
kis_bot/remove/legacy_root/carrot_region_sel.py
Your Name 6d2a706a48 커밋 1 — 실매 가격 TTL 구멍 (본체)
왜: 체결이 없어도 마지막가는 유지인데, TTL로 None 만들고 매도/EOD를 건너뛰어 8/5 돌파·금요일 leftover가 남음. 호가필터 TTL 구멍과 같은 병.

넣을 파일

신규: kis_trader/engine/live_sell_price.py
kis_trader/strategies/base.py (_ws_last_quote, _resolve_sell_price)
전략: momentum.py scalping.py tail_catch.py breakout.py range_break.py dart_strategy.py updow_strategy.py updown_feed.py us_momentum.py
WS: ws_manager.py kis_ws.py kiwoom_ws.py ls_ws.py kis_ws_overseas.py
kis_trader/web/live_config_schema.py (WS_PRICE_MAX_AGE_SEC 기본 0)
database.py (키 주석 + legacy/ sys.path)
kis_trader/execution/order_manager.py (잔고 있는데 40240000 ghost_purge 금지 — 같은 EOD 사고)
EOD가 min_hold에 안 막히게 손본 momentum_hts_logic.py / scalping_engine.py / tail_engine.py (이 대화에서 손본 부분만 확인 후)
문서: docs/like_mcp.md/db_erd.md code_architecture.md (가격 TTL 문구)
메시지 초안

fix: 매수·매도 현재가를 TTL로 버리지 않음 (마지막 RAM)
횡보·체결 공백을 죽은 캐시로 오인해 None 처리하면 손절·EOD가 스킵된다.
호가필터와 같이 나이는 무시하고 마지막 체결가를 유지한다. EOD는 매수가 폴백.
영향: 실매 O / 백테·옵투나 봉 경로 거의 무관 (엔진 식 변경 아님)

커밋 2 — 루트 정리 (remove/ vs legacy/)
왜: 루트 단독봇·테스트는 지울 보관함으로. 웹·알람이 아직 쓰는 모듈은 remove에 두면 나중에 폴더째 삭제 때 깨짐.

넣을 파일

이동: 미사용 → remove/legacy_root/ (래퍼, ETF/키움 옛봇, 테스트, kiwoom_rest_api 등)
이동: 사용 중 → legacy/ (holding_bot kis_holding_ver1 news_analyzer kis_long_ver1/2)
신규: kis_trader/utils/legacy_root.py legacy/README.md remove/README.md
import 경로: backtest_web.py mm_butler.py mm_remote.py updow_holding_cfg.py dbband_stock_cfg.py param_search_updow*.py dbband_param_search.py param_search_apply_snapshot.py verify_three_paths.py
docs/like_mcp.md/code_architecture.md 수동 노트
메시지 초안

chore: 미사용 루트는 remove/, 웹·알람 구모듈은 legacy/
remove는 나중에 통째 삭제 예정. holding_bot·news_analyzer·kis_long은
ensure_legacy_root로 legacy/만 본다.
빼기: scratch/set_ws_price_max_age_zero.py (일회성)
2026-08-18 00:13:17 +09:00

366 lines
16 KiB
Python

import requests
from bs4 import BeautifulSoup
import telegram
import asyncio
import os
import json
import time
import random
import re
from urllib.parse import unquote # URL 디코딩 (한글 변환)
from datetime import datetime
from mm_sender import bot # 매터모스트 봇 가져오기
# ---------------- 설 정 ----------------
TARGET_MODE = 'ALL'
TELEGRAM_TOKEN = '7912820100:AAGw0MWReevw6QyWm3QTSK4Wy7otuq8fxMo'
CHAT_ID = '-5238702679'
# 🌟 [추가됨] 랜덤 User-Agent 리스트
USER_AGENTS = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36'
]
def get_keywords():
try:
with open('carrot_region_sel_one_keywords.json', 'r', encoding='utf-8') as f:
data = json.load(f)
raw_list = data.get('keywords', [])
processed_list = []
for item in raw_list:
if isinstance(item, str):
processed_list.append({"text": item, "min": 0, "max": 0})
else:
processed_list.append(item)
return processed_list
except:
return []
LOG_FILE = 'daangn_sent_sel_one_ids.json'
REGION_FILE = 'regions.json'
# ---------------- 함 수 ----------------
def parse_price(price_str):
if not price_str or "가격없음" in price_str:
return 0
if "나눔" in price_str:
return 0 # 나눔은 0 원으로 처리
# 숫자만 추출 (콤마, 원, 공백 등 제거)
numbers = re.sub(r"[^0-9]", "", price_str)
try:
return int(numbers) if numbers else 0
except Exception:
return 0
def _normalize_record(val):
"""
sent_data에 저장되는 값을 일관된 형태로 맞춘다.
항상 {'price': int, 'time': str|None} 형태로 반환.
"""
if isinstance(val, dict):
price = val.get("price", 0)
time_str = val.get("time")
try:
price = int(price)
except Exception:
price = 0
if time_str is not None and not isinstance(time_str, str):
time_str = str(time_str)
return {"price": price, "time": time_str}
# 과거 데이터: 숫자만 있는 경우
try:
price = int(val)
except Exception:
price = 0
return {"price": price, "time": None}
def get_saved_price(record):
"""
sent_data에서 기존 가격(int)을 꺼낼 때 사용.
구(숫자), 신(dict) 구조를 모두 지원.
"""
if isinstance(record, dict):
record = record.get("price", 0)
try:
return int(record)
except Exception:
return 0
def load_region_data(mode):
if not os.path.exists(REGION_FILE):
print("❌ regions.json 파일이 없습니다!")
return {}
try:
with open(REGION_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
rich = data.get('RICH', {})
vicinity = data.get('VICINITY', {})
jjin = data.get('JJIN_VICINITY', {})
if mode == 'RICH': return rich
elif mode == 'VICINITY': return vicinity
elif mode == 'JJIN': return jjin
else:
merged = {**rich, **vicinity, **jjin}
print(f"🌏 [모드] '전체(ALL)' 지역 로드함 (총 {len(merged)}곳)")
return merged
except Exception as e:
print(f"❌ JSON 파일 읽기 실패: {e}")
return {}
def load_sent_data():
if not os.path.exists(LOG_FILE):
return {}
try:
with open(LOG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
# 아주 예전 리스트 형태라면 -1 가격으로 초기화
if isinstance(data, list):
data = {x: {"price": -1, "time": None} for x in data}
elif isinstance(data, dict):
changed = False
new_data = {}
for k, v in data.items():
norm = _normalize_record(v)
if norm != v:
changed = True
new_data[k] = norm
data = new_data
# 파일에 섞여 있던 옛 형식을 한 번에 정리
if changed:
save_sent_data(data)
return data
except Exception:
return {}
def save_sent_data(data):
# 최신 3000 개만 유지 (메모리 + 속도 최적화)
if len(data) > 3000:
keys_to_keep = list(data.keys())[-3000:]
data = {k: data[k] for k in keys_to_keep}
# 원자적 저장 (파일 깨짐 방지)
temp_file = LOG_FILE + '.tmp'
try:
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(temp_file, LOG_FILE)
except Exception as e:
print(f"파일 저장 오류: {e}")
async def send_telegram(message):
try:
bot = telegram.Bot(token=TELEGRAM_TOKEN)
await bot.send_message(chat_id=CHAT_ID, text=message, parse_mode='HTML')
except Exception as e:
print(f"❌ 텔레그램 전송 실패: {e}")
async def check_daangn_local():
# 1회용이므로 시작 딜레이(time.sleep) 삭제함
sent_data = load_sent_data()
region_codes = load_region_data(TARGET_MODE)
target_dong_names = list(region_codes.keys())
random.shuffle(target_dong_names) # 동네 섞기 (유지)
print(f"🚀 [1회용] 수동 크롤링 시작... 총 {len(target_dong_names)}개 동네", flush=True)
loop_cnt = 0
# 루프 시작 시 키워드 다시 읽기 (JSON 변경 반영)
KEYWORDS = get_keywords()
print(f"📝 [키워드] 총 {len(KEYWORDS)}개 로드: {[k['text'] for k in KEYWORDS]}", flush=True)
for dong_name in target_dong_names:
region_code = region_codes[dong_name]
# 30회마다 휴식 (안전을 위해 유지 권장)
if loop_cnt > 0 and loop_cnt % 30 == 0:
rest_time = random.uniform(5, 10) # 수동이니까 조금 짧게 잡음
print(f"\n☕ [휴식] {rest_time:.1f}초 쉬었다 갑니다...\n")
time.sleep(rest_time)
loop_cnt += 1
for item in KEYWORDS:
keyword = item['text']
min_price = item.get('min', 0)
max_price = item.get('max', 0)
# category_id가 지정돼 있으면 해당 카테고리만, 없으면(0/빈값) 전체 카테고리 검색
category_id = item.get('category_id', 0)
# 제외 단어(exclude): 제목에 이 단어가 포함되면 결과에서 걸러냄 (대소문자 무시)
# JSON에서 문자열 하나만 넣어도 되고, 리스트로 여러 개 넣어도 됨
exclude_raw = item.get('exclude', [])
if isinstance(exclude_raw, str):
exclude_raw = [exclude_raw]
exclude_words = [w.lower() for w in exclude_raw if isinstance(w, str) and w.strip()]
url = f"https://www.daangn.com/kr/buy-sell/?in={dong_name}-{region_code}&search={keyword}"
if category_id:
url += f"&category_id={category_id}"
# 로그에 region_code(ID)도 같이 찍어서 랜덤 여부 확인
print(f"🔎 [{loop_cnt}/{len(target_dong_names)}] {dong_name}({region_code}) - {keyword} ", end='', flush=True)
# 🌟 [수정됨] 헤더를 여기서 랜덤으로 생성
headers = {
'User-Agent': random.choice(USER_AGENTS),
'Referer': 'https://www.daangn.com/', # 메인에서 온 척 위장
'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7'
}
try:
res = requests.get(url, headers=headers, timeout=5)
if res.status_code != 200:
print(f"⚠️ 상태 {res.status_code}")
time.sleep(10)
continue
res.encoding = 'utf-8'
soup = BeautifulSoup(res.text, 'html.parser')
articles = soup.find_all("a", {"data-gtm": "search_article"})
if len(articles) == 0:
print("매물X")
time.sleep(random.uniform(0.5, 0.8)) # 수동이니까 아주 살짝 빠르게
continue
new_cnt = 0
drop_cnt = 0
skip_cnt = 0
for article in articles:
try:
href = article['href']
# URL 디코딩 적용 (한글 키 저장)
raw_id = href.rstrip('/').split('/')[-1]
article_id = unquote(raw_id)
text_lines = list(article.stripped_strings)
# 첫 줄은 제목으로 사용
title = text_lines[0] if len(text_lines) >= 1 else "제목없음"
# [제외 필터] 제목에 제외 단어가 포함되면 알림에서 걸러냄 (대소문자 무시)
if exclude_words:
title_lower = title.lower()
if any(ex in title_lower for ex in exclude_words):
skip_cnt += 1
continue
# 가격 줄은 '원'이 포함된 첫 번째 문자열로 선택 (리스트 구조 변화에도 안전)
price_str = "가격없음"
for s in text_lines[1:]:
if "" in s and any(ch.isdigit() for ch in s):
price_str = s
break
current_price = parse_price(price_str)
if min_price > 0 and current_price < min_price:
skip_cnt += 1; continue
if max_price > 0 and current_price > max_price:
skip_cnt += 1; continue
if "판매완료" in article.text or "예약중" in article.text or "거래완료" in article.text:
# 판매완료/예약중도 일관된 구조로 저장
if article_id not in sent_data:
sent_data[article_id] = {
"price": current_price,
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
continue
is_new = article_id not in sent_data
is_drop = False
old_price = -1
if not is_new:
old_price = get_saved_price(sent_data[article_id])
if old_price > 0 and current_price < old_price:
is_drop = True
else:
sent_data[article_id] = current_price
continue
full_link = f"https://www.daangn.com{href}"
img_tag = article.find('img')
img_url = img_tag['src'].replace('s=300x300', 's=600x600') if img_tag else ""
hidden_link = f"<a href='{img_url}'>&#8205;</a>" if img_url else ""
# 텔레그램 & 매터모스트 메시지 각각 생성
if is_drop:
drop_cnt += 1
msg_tg = f"{hidden_link}🔻 <b>[{dong_name}] 가격인하!</b>\n📉 {format(old_price, ',')}원 ➡️ <b>{format(current_price, ',')}원</b>\n📦 {title}\n🔗 <a href='{full_link}'>바로이동</a>"
msg_mm = f"🔻 **[{dong_name}] 가격인하!**\n📉 {format(old_price, ',')}원 ➡️ **{format(current_price, ',')}원**\n📦 {title}\n🔗 [바로가기]({full_link})"
else:
new_cnt += 1
msg_tg = f"{hidden_link}🥕 <b>[{dong_name}] {keyword}</b>\n📦 <b>{title}</b>\n💰 {price_str}\n🔗 <a href='{full_link}'>바로이동</a>"
msg_mm = f"🥕 **[{dong_name}] {keyword}**\n📦 **{title}**\n💰 {price_str}\n🔗 [바로가기]({full_link})"
# 이미지가 있으면 매터모스트에만 추가
if img_url:
msg_mm += f"\n\n---\n![매물사진]({img_url})"
# 텔레그램 전송
tg_success = await send_telegram(msg_tg)
# 매터모스트 전송
mm_success = False
try:
mm_success = bot.send('danggn3', msg_mm)
if mm_success is None:
mm_success = True
print(f" [MM{'OK' if mm_success else 'FAIL'}]", end='')
except Exception as e_mm:
print(f"[MM실패: {e_mm}]", end='')
# 성공하면 실시간 저장 (시간 포함)
if tg_success or mm_success:
# 기존 키가 있다면 삭제 후 추가 (최신순 정렬)
if article_id in sent_data:
del sent_data[article_id]
# [시간 추가] 현재 시간 기록
from datetime import datetime
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 딕셔너리 값도 dict 로 변경 (가격 + 시간)
sent_data[article_id] = {
'price': current_price,
'time': current_time
}
# 실시간 저장
save_sent_data(sent_data)
print(f" [SAVE:{current_time}]", end='')
time.sleep(1)
except Exception: continue
if skip_cnt > 0:
print(f"{new_cnt} 🔻{drop_cnt} (💸{skip_cnt})")
else:
print(f"{new_cnt} 🔻{drop_cnt}")
# 수동이라도 최소한의 딜레이는 유지 (차단 방지)
time.sleep(random.uniform(1.0, 2.5))
except Exception as e:
print(f"{e}")
time.sleep(5)
save_sent_data(sent_data)
print("\n✅ [완료] 수동 실행 끝.")
if __name__ == "__main__":
asyncio.run(check_daangn_local())