변경 사항 ---- - _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가 - _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가 - _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가 영향 ---- - 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임 - 기존 기능에 대한 영향 없음 Co-authored-by: Cursor <cursoragent@cursor.com>
366 lines
16 KiB
Python
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}'>‍</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"
|
|
|
|
# 텔레그램 전송
|
|
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()) |