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"" if img_url else "" # 텔레그램 & 매터모스트 메시지 각각 생성 if is_drop: drop_cnt += 1 msg_tg = f"{hidden_link}🔻 [{dong_name}] 가격인하!\n📉 {format(old_price, ',')}원 ➡️ {format(current_price, ',')}원\n📦 {title}\n🔗 바로이동" 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}🥕 [{dong_name}] {keyword}\n📦 {title}\n💰 {price_str}\n🔗 바로이동" 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())