feat(realestate): 매물 DB 4테이블(listings/market_deals/matches/criteria)+헬퍼
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
This commit is contained in:
@@ -177,6 +177,72 @@ def init_db():
|
||||
);
|
||||
""")
|
||||
|
||||
# ── listings (매물 파이프라인) ──────────────────────────────────────
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS listings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
article_no TEXT UNIQUE, source TEXT NOT NULL, deal_type TEXT NOT NULL,
|
||||
deposit INTEGER, monthly_rent INTEGER, sale_price INTEGER,
|
||||
area_exclusive REAL, floor TEXT, complex_name TEXT, address TEXT,
|
||||
dong TEXT, dong_code TEXT, url TEXT, posted_at TEXT, raw_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
notified_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS market_deals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
house_type TEXT, dong_code TEXT, dong TEXT, complex_name TEXT, area REAL,
|
||||
deal_type TEXT, deposit INTEGER, monthly_rent INTEGER, sale_amount INTEGER,
|
||||
deal_ym TEXT, floor TEXT, source TEXT DEFAULT 'molit',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
# 플랜 원안은 table-level UNIQUE(...)였으나 SQLite는 UNIQUE 제약에서 NULL을 서로 다른 값으로
|
||||
# 취급해 deposit/sale_amount(둘 중 하나는 항상 NULL)가 있는 행에서 dedup이 깨진다.
|
||||
# (표현식은 table-level UNIQUE에 사용 불가 → CREATE UNIQUE INDEX + COALESCE로 대체.
|
||||
# 실제 컬럼값은 그대로 NULL 유지 — downstream get_market_deals_for/listing_matcher 영향 없음.)
|
||||
conn.execute("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_market_deals_dedup ON market_deals(
|
||||
dong_code, COALESCE(complex_name,''), area, deal_ym, deal_type,
|
||||
COALESCE(deposit,-1), COALESCE(sale_amount,-1), COALESCE(floor,'')
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS listing_matches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
listing_id INTEGER UNIQUE REFERENCES listings(id) ON DELETE CASCADE,
|
||||
category TEXT, passed INTEGER, match_score INTEGER,
|
||||
jeonse_ratio REAL, safety_tier TEXT, price_ratio REAL, valuation_tier TEXT,
|
||||
market_median INTEGER, sample_size INTEGER, budget_ok INTEGER,
|
||||
regulation_flags TEXT, reasons TEXT, is_new INTEGER,
|
||||
notified_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS listing_criteria (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
dongs TEXT NOT NULL DEFAULT '[]', deal_types TEXT NOT NULL DEFAULT '[]',
|
||||
max_deposit INTEGER, max_sale_price INTEGER, min_area REAL,
|
||||
house_types TEXT NOT NULL DEFAULT '[]', min_safety_tier TEXT,
|
||||
notify_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
equity INTEGER, annual_income INTEGER,
|
||||
is_homeless INTEGER NOT NULL DEFAULT 1, is_householder INTEGER NOT NULL DEFAULT 0,
|
||||
is_first_home INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS listing_collect_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
new_count INTEGER, total_count INTEGER, naver_ok INTEGER,
|
||||
error TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_md_lookup ON market_deals(dong_code, deal_type, area, deal_ym);")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_listing_dong ON listings(dong, deal_type);")
|
||||
|
||||
|
||||
# ── 상태 자동 계산 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -841,3 +907,187 @@ def get_dashboard() -> Dict[str, Any]:
|
||||
"upcoming_schedules": schedules,
|
||||
"bookmarked": bookmarked_items,
|
||||
}
|
||||
|
||||
|
||||
# ── 매물 파이프라인 (listings/market_deals/listing_matches/listing_criteria) ──
|
||||
|
||||
_LISTING_CRITERIA_SEED = {
|
||||
"dongs": ["신대방동", "대방동", "상도동", "봉천동", "대림동", "신길동"],
|
||||
"deal_types": ["전세", "반전세", "매매"],
|
||||
"max_deposit": 32000, "max_sale_price": None, "min_area": 40.0,
|
||||
"house_types": ["아파트", "오피스텔", "빌라"], "min_safety_tier": None,
|
||||
"notify_enabled": 1, "equity": None, "annual_income": None,
|
||||
"is_homeless": 1, "is_householder": 0, "is_first_home": 0,
|
||||
}
|
||||
_LC_JSON_COLS = ("dongs", "deal_types", "house_types")
|
||||
|
||||
|
||||
def get_listing_criteria() -> Dict[str, Any]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM listing_criteria WHERE id=1").fetchone()
|
||||
if row is None:
|
||||
conn.execute(
|
||||
"""INSERT INTO listing_criteria
|
||||
(id,dongs,deal_types,max_deposit,max_sale_price,min_area,house_types,
|
||||
min_safety_tier,notify_enabled,equity,annual_income,is_homeless,is_householder,is_first_home)
|
||||
VALUES (1,:dongs,:deal_types,:max_deposit,:max_sale_price,:min_area,:house_types,
|
||||
:min_safety_tier,:notify_enabled,:equity,:annual_income,:is_homeless,:is_householder,:is_first_home)""",
|
||||
{**_LISTING_CRITERIA_SEED,
|
||||
"dongs": json.dumps(_LISTING_CRITERIA_SEED["dongs"], ensure_ascii=False),
|
||||
"deal_types": json.dumps(_LISTING_CRITERIA_SEED["deal_types"], ensure_ascii=False),
|
||||
"house_types": json.dumps(_LISTING_CRITERIA_SEED["house_types"], ensure_ascii=False)},
|
||||
)
|
||||
row = conn.execute("SELECT * FROM listing_criteria WHERE id=1").fetchone()
|
||||
d = dict(row)
|
||||
for c in _LC_JSON_COLS:
|
||||
d[c] = json.loads(d[c] or "[]")
|
||||
return d
|
||||
|
||||
|
||||
def update_listing_criteria(fields: Dict[str, Any]) -> None:
|
||||
if not fields:
|
||||
return
|
||||
sets, vals = [], []
|
||||
for k, v in fields.items():
|
||||
if v is None and k not in ("max_deposit", "max_sale_price", "equity", "annual_income", "min_safety_tier"):
|
||||
continue
|
||||
sets.append(f"{k}=?")
|
||||
vals.append(json.dumps(v, ensure_ascii=False) if k in _LC_JSON_COLS else v)
|
||||
if not sets:
|
||||
return
|
||||
sets.append("updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')")
|
||||
with _conn() as conn:
|
||||
conn.execute("INSERT OR IGNORE INTO listing_criteria(id) VALUES(1)")
|
||||
conn.execute(f"UPDATE listing_criteria SET {','.join(sets)} WHERE id=1", vals)
|
||||
|
||||
|
||||
def upsert_listing(data: Dict[str, Any]) -> tuple:
|
||||
cols = ("article_no", "source", "deal_type", "deposit", "monthly_rent", "sale_price",
|
||||
"area_exclusive", "floor", "complex_name", "address", "dong", "dong_code",
|
||||
"url", "posted_at", "raw_json")
|
||||
d = {c: data.get(c) for c in cols}
|
||||
with _conn() as conn:
|
||||
exists = conn.execute("SELECT id FROM listings WHERE article_no=?", (d["article_no"],)).fetchone()
|
||||
is_new = exists is None
|
||||
conn.execute(f"""
|
||||
INSERT INTO listings ({','.join(cols)}) VALUES ({','.join(':'+c for c in cols)})
|
||||
ON CONFLICT(article_no) DO UPDATE SET
|
||||
deposit=excluded.deposit, monthly_rent=excluded.monthly_rent, sale_price=excluded.sale_price,
|
||||
floor=excluded.floor, complex_name=excluded.complex_name, address=excluded.address,
|
||||
url=excluded.url, posted_at=excluded.posted_at, raw_json=excluded.raw_json
|
||||
""", d)
|
||||
row = conn.execute("SELECT * FROM listings WHERE article_no=?", (d["article_no"],)).fetchone()
|
||||
return dict(row), is_new
|
||||
|
||||
|
||||
def upsert_market_deal(data: Dict[str, Any]) -> None:
|
||||
cols = ("house_type", "dong_code", "dong", "complex_name", "area", "deal_type",
|
||||
"deposit", "monthly_rent", "sale_amount", "deal_ym", "floor", "source")
|
||||
d = {c: data.get(c) for c in cols}
|
||||
d.setdefault("source", "molit")
|
||||
with _conn() as conn:
|
||||
conn.execute(f"""INSERT OR IGNORE INTO market_deals ({','.join(cols)})
|
||||
VALUES ({','.join(':'+c for c in cols)})""", d)
|
||||
|
||||
|
||||
def get_market_deals_for(dong_code, complex_name, area, deal_type, months=6) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM market_deals
|
||||
WHERE dong_code=? AND deal_type=?
|
||||
AND (complex_name=? OR ? IS NULL OR complex_name IS NULL)
|
||||
AND area BETWEEN ? AND ?
|
||||
AND deal_ym >= strftime('%Y%m', 'now', ?)""",
|
||||
(dong_code, deal_type, complex_name, complex_name,
|
||||
(area or 0) - 5, (area or 0) + 5, f"-{int(months)} months"),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def upsert_listing_match(data: Dict[str, Any]) -> None:
|
||||
cols = ("listing_id", "category", "passed", "match_score", "jeonse_ratio", "safety_tier",
|
||||
"price_ratio", "valuation_tier", "market_median", "sample_size", "budget_ok",
|
||||
"regulation_flags", "reasons", "is_new")
|
||||
d = {c: data.get(c) for c in cols}
|
||||
d["regulation_flags"] = json.dumps(data.get("regulation_flags") or [], ensure_ascii=False)
|
||||
d["reasons"] = json.dumps(data.get("reasons") or [], ensure_ascii=False)
|
||||
with _conn() as conn:
|
||||
conn.execute(f"""
|
||||
INSERT INTO listing_matches ({','.join(cols)}) VALUES ({','.join(':'+c for c in cols)})
|
||||
ON CONFLICT(listing_id) DO UPDATE SET
|
||||
passed=excluded.passed, match_score=excluded.match_score,
|
||||
jeonse_ratio=excluded.jeonse_ratio, safety_tier=excluded.safety_tier,
|
||||
price_ratio=excluded.price_ratio, valuation_tier=excluded.valuation_tier,
|
||||
market_median=excluded.market_median, sample_size=excluded.sample_size,
|
||||
budget_ok=excluded.budget_ok, regulation_flags=excluded.regulation_flags,
|
||||
reasons=excluded.reasons, category=excluded.category
|
||||
""", d)
|
||||
|
||||
|
||||
def get_listings(dong=None, deal_type=None, tier=None, matched_only=False, limit=50, offset=0) -> List[Dict[str, Any]]:
|
||||
sql = ("SELECT l.*, m.safety_tier, m.valuation_tier, m.category, m.match_score, m.passed "
|
||||
"FROM listings l LEFT JOIN listing_matches m ON m.listing_id=l.id WHERE 1=1")
|
||||
p = []
|
||||
if dong: sql += " AND l.dong=?"; p.append(dong)
|
||||
if deal_type: sql += " AND l.deal_type=?"; p.append(deal_type)
|
||||
if tier: sql += " AND (m.safety_tier=? OR m.valuation_tier=?)"; p += [tier, tier]
|
||||
if matched_only: sql += " AND m.passed=1"
|
||||
sql += " ORDER BY l.created_at DESC LIMIT ? OFFSET ?"; p += [limit, offset]
|
||||
with _conn() as conn:
|
||||
return [dict(r) for r in conn.execute(sql, p).fetchall()]
|
||||
|
||||
|
||||
def get_listing_matches() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT m.*, l.complex_name, l.dong, l.deal_type, l.deposit, l.sale_price, l.area_exclusive, l.url "
|
||||
"FROM listing_matches m JOIN listings l ON l.id=m.listing_id ORDER BY m.created_at DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
_TIER_ORDER = {"위험": 0, "고가": 0, "주의": 1, "시세": 1, "안전": 2, "저평가": 2, "보류": -1}
|
||||
|
||||
|
||||
def get_unnotified_listing_matches(min_tier=None) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT m.*, l.complex_name, l.dong, l.deal_type, l.deposit, l.monthly_rent, "
|
||||
"l.sale_price, l.area_exclusive, l.floor, l.url "
|
||||
"FROM listing_matches m JOIN listings l ON l.id=m.listing_id "
|
||||
"WHERE m.passed=1 AND m.notified_at IS NULL"
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["regulation_flags"] = json.loads(d.get("regulation_flags") or "[]")
|
||||
d["reasons"] = json.loads(d.get("reasons") or "[]")
|
||||
out.append(d)
|
||||
if min_tier:
|
||||
floor = _TIER_ORDER.get(min_tier, -1)
|
||||
out = [d for d in out if _TIER_ORDER.get(d.get("safety_tier") or d.get("valuation_tier"), -1) >= floor]
|
||||
return out
|
||||
|
||||
|
||||
def mark_listings_notified(match_ids: List[int]) -> None:
|
||||
if not match_ids:
|
||||
return
|
||||
with _conn() as conn:
|
||||
conn.executemany(
|
||||
"UPDATE listing_matches SET notified_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?",
|
||||
[(i,) for i in match_ids],
|
||||
)
|
||||
|
||||
|
||||
def save_listing_collect_log(new_count, total_count, naver_ok, error=None) -> None:
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO listing_collect_log (new_count,total_count,naver_ok,error) VALUES (?,?,?,?)",
|
||||
(new_count, total_count, 1 if naver_ok else 0, error),
|
||||
)
|
||||
|
||||
|
||||
def get_last_listing_collect_log() -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM listing_collect_log ORDER BY id DESC LIMIT 1").fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
Reference in New Issue
Block a user