#6 insta-lab Chromium Browser Pool — Playwright/Chromium 인스턴스를 모듈 레벨에서 보관하고 매 슬레이트마다 reuse. 카드 10장 렌더의 launch 비용 (~3초/회)이 사라짐. startup/shutdown lifecycle hook 추가. crashed/disconnected 시 lazy 재초기화. #8 realestate-lab 수집 병렬화 — collect_all과 delete_old_completed가 서로 다른 데이터 영역이라 ThreadPoolExecutor(2)로 병렬. asyncio.gather 대신 thread executor를 쓴 이유는 BackgroundScheduler+동기 함수 환경 에서 자연스럽고 추가 의존성 없기 때문. 매칭은 일관성 유지로 순차. #7 stock async — 보류. 재진단 결과 stock은 BackgroundScheduler 사용 중이라 main loop 블로킹 없음. fetch 4회는 network I/O wait가 대부분이라 to_thread도 의미 없음. 진짜 효과를 보려면 AsyncIOScheduler 전환 + aiohttp 병렬이라 큰 리팩토링. 박재오 판단 대기. CHECK_POINT.md 갱신. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
218 lines
7.6 KiB
Python
218 lines
7.6 KiB
Python
import os
|
|
import logging
|
|
import threading
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextlib import asynccontextmanager
|
|
from fastapi import BackgroundTasks, FastAPI, Query, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from apscheduler.schedulers.background import BackgroundScheduler
|
|
|
|
from .db import (
|
|
init_db, get_announcements, get_announcement, create_announcement,
|
|
update_announcement, delete_announcement, delete_closed_announcements, toggle_bookmark,
|
|
update_all_statuses,
|
|
get_profile, upsert_profile, get_matches, mark_match_read,
|
|
get_last_collect_log, get_dashboard,
|
|
delete_old_completed_announcements,
|
|
)
|
|
from .collector import collect_all
|
|
from .matcher import run_matching
|
|
from .notifier import notify_new_matches
|
|
from .models import AnnouncementCreate, AnnouncementUpdate, ProfileUpdate
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
|
logger = logging.getLogger("realestate-lab")
|
|
|
|
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
|
|
|
|
|
def scheduled_collect():
|
|
"""매일 09:15 — 수집 + 정리 (병렬) → 매칭 → 알림 push.
|
|
|
|
collect_all과 delete_old_completed_announcements는 서로 다른 데이터
|
|
영역을 건드리므로 thread 둘로 병렬화. 매칭은 두 작업 완료 후 순차
|
|
실행 (DB 일관성). CHECK_POINT 중기-8 — env이 BackgroundScheduler+
|
|
동기 함수 조합이라 asyncio.gather 대신 ThreadPoolExecutor 사용.
|
|
"""
|
|
logger.info("스케줄 수집 시작")
|
|
with ThreadPoolExecutor(max_workers=2) as ex:
|
|
collect_future = ex.submit(collect_all)
|
|
delete_future = ex.submit(delete_old_completed_announcements, 90)
|
|
collect_future.result()
|
|
deleted = delete_future.result()
|
|
if deleted:
|
|
logger.info("정리: %d건 삭제", deleted)
|
|
run_matching()
|
|
notify_new_matches()
|
|
logger.info("스케줄 수집 + 매칭 + 알림 완료")
|
|
|
|
|
|
def scheduled_status_update():
|
|
"""매일 00:00 — 상태 갱신 + 재매칭"""
|
|
logger.info("상태 갱신 시작")
|
|
update_all_statuses()
|
|
run_matching()
|
|
logger.info("상태 갱신 + 재매칭 완료")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
init_db()
|
|
# 09:00 cron 스태거링 — agent-office 09:00/05/10 이후 (CHECK_POINT FU-A)
|
|
scheduler.add_job(scheduled_collect, "cron", hour=9, minute=15, id="collect")
|
|
scheduler.add_job(scheduled_status_update, "cron", hour=0, minute=0, id="status_update")
|
|
scheduler.start()
|
|
logger.info("realestate-lab 시작")
|
|
yield
|
|
scheduler.shutdown()
|
|
|
|
|
|
app = FastAPI(lifespan=lifespan)
|
|
|
|
_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",")
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=[o.strip() for o in _cors_origins],
|
|
allow_credentials=False,
|
|
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
allow_headers=["Content-Type"],
|
|
)
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return {"status": "ok"}
|
|
|
|
|
|
# ── 공고 API ─────────────────────────────────────────────────────────────────
|
|
|
|
@app.get("/api/realestate/announcements")
|
|
def api_announcements(
|
|
region: str = None,
|
|
status: str = None,
|
|
house_type: str = None,
|
|
matched_only: bool = False,
|
|
bookmarked: bool = False,
|
|
sort: str = "date",
|
|
page: int = Query(1, ge=1),
|
|
size: int = Query(20, ge=1, le=100),
|
|
):
|
|
return get_announcements(region, status, house_type, matched_only, bookmarked, sort, page, size)
|
|
|
|
|
|
@app.get("/api/realestate/announcements/{ann_id}")
|
|
def api_announcement_detail(ann_id: int):
|
|
ann = get_announcement(ann_id)
|
|
if not ann:
|
|
raise HTTPException(status_code=404, detail="Announcement not found")
|
|
return ann
|
|
|
|
|
|
@app.post("/api/realestate/announcements", status_code=201)
|
|
def api_announcement_create(body: AnnouncementCreate):
|
|
return create_announcement(body.model_dump())
|
|
|
|
|
|
@app.put("/api/realestate/announcements/{ann_id}")
|
|
def api_announcement_update(ann_id: int, body: AnnouncementUpdate):
|
|
updated = update_announcement(ann_id, body.model_dump(exclude_none=True))
|
|
if not updated:
|
|
raise HTTPException(status_code=404, detail="Announcement not found")
|
|
return updated
|
|
|
|
|
|
@app.patch("/api/realestate/announcements/{ann_id}/bookmark")
|
|
def api_announcement_bookmark(ann_id: int):
|
|
result = toggle_bookmark(ann_id)
|
|
if result is None:
|
|
raise HTTPException(status_code=404, detail="Announcement not found")
|
|
return result
|
|
|
|
|
|
@app.delete("/api/realestate/announcements/closed")
|
|
def api_announcement_delete_closed():
|
|
"""status='완료' 공고 일괄 삭제."""
|
|
count = delete_closed_announcements()
|
|
return {"ok": True, "deleted": count}
|
|
|
|
|
|
@app.delete("/api/realestate/announcements/{ann_id}")
|
|
def api_announcement_delete(ann_id: int):
|
|
if not delete_announcement(ann_id):
|
|
raise HTTPException(status_code=404, detail="Announcement not found")
|
|
return {"ok": True}
|
|
|
|
|
|
# ── 수집 API ─────────────────────────────────────────────────────────────────
|
|
|
|
_collect_lock = threading.Lock()
|
|
|
|
|
|
def _run_collect_and_match():
|
|
if not _collect_lock.acquire(blocking=False):
|
|
logger.info("수집 이미 진행 중 — 건너뜀")
|
|
return
|
|
try:
|
|
collect_all()
|
|
delete_old_completed_announcements(grace_days=90)
|
|
run_matching()
|
|
notify_new_matches()
|
|
finally:
|
|
_collect_lock.release()
|
|
|
|
|
|
@app.post("/api/realestate/collect")
|
|
def api_collect(background_tasks: BackgroundTasks):
|
|
background_tasks.add_task(_run_collect_and_match)
|
|
return {"ok": True, "message": "수집 시작됨"}
|
|
|
|
|
|
@app.get("/api/realestate/collect/status")
|
|
def api_collect_status():
|
|
log = get_last_collect_log()
|
|
return log if log else {"collected_at": None, "new_count": 0, "total_count": 0, "error": None}
|
|
|
|
|
|
# ── 프로필 API ───────────────────────────────────────────────────────────────
|
|
|
|
@app.get("/api/realestate/profile")
|
|
def api_profile_get():
|
|
profile = get_profile()
|
|
return profile if profile else {}
|
|
|
|
|
|
@app.put("/api/realestate/profile")
|
|
def api_profile_update(body: ProfileUpdate):
|
|
return upsert_profile(body.model_dump(exclude_none=True))
|
|
|
|
|
|
# ── 매칭 API ─────────────────────────────────────────────────────────────────
|
|
|
|
@app.get("/api/realestate/matches")
|
|
def api_matches(page: int = Query(1, ge=1), size: int = Query(20, ge=1, le=100)):
|
|
return get_matches(page, size)
|
|
|
|
|
|
@app.post("/api/realestate/matches/refresh")
|
|
def api_matches_refresh():
|
|
try:
|
|
run_matching()
|
|
except Exception as e:
|
|
logger.exception("매칭 실행 실패")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
return {"ok": True}
|
|
|
|
|
|
@app.patch("/api/realestate/matches/{match_id}/read")
|
|
def api_match_read(match_id: int):
|
|
if not mark_match_read(match_id):
|
|
raise HTTPException(status_code=404, detail="Match not found")
|
|
return {"ok": True}
|
|
|
|
|
|
# ── 대시보드 API ─────────────────────────────────────────────────────────────
|
|
|
|
@app.get("/api/realestate/dashboard")
|
|
def api_dashboard():
|
|
return get_dashboard()
|