Files
web-page-backend/realestate-lab/app/db.py
gahusb f676116336 fix(realestate): 네이버 매물 dong_code를 dong명에서 유도(cortarNo 부재)→ market_deals 매칭·판정 복구
네이버 article에 cortarNo가 없는 경우 listing.dong_code가 None으로 저장되어
get_market_deals_for가 시세를 못 찾아 모든 매물이 '보류'(판정 불가) 상태였다.
dong명은 항상 정확히 태깅되므로 lawd_code(dong)으로 5자리 시군구코드를 유도해
market_deals와 매칭시킨다.

- internal_router.listings_ingest: dong_code 없으면 batch.dong에서 lawd_code로 보정
- db.upsert_listing: ON CONFLICT UPDATE SET에 dong_code 추가(재유입 시 기존 None행 갱신)
- listing_matcher.run_listing_matching: get_market_deals_for 호출 전 dong_code 없으면
  listing.dong에서 즉시 유도(기존 저장된 None행도 재실행만으로 판정 복구)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
2026-07-10 14:11:51 +09:00

1178 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# realestate-lab/app/db.py
import json
import os
import sqlite3
import logging
from typing import Dict, Any, List, Optional
from datetime import date
logger = logging.getLogger("realestate-lab")
DB_PATH = os.getenv("REALESTATE_DB_PATH", "/app/data/realestate.db")
# listing_matcher.MIN_SAMPLE와 동일 값 유지 — 시세 표본이 이 미만이면 광역 폴백.
_MARKET_SAMPLE_MIN = 3
def _conn():
c = sqlite3.connect(DB_PATH, timeout=120.0)
c.row_factory = sqlite3.Row
c.execute("PRAGMA journal_mode=WAL;")
c.execute("PRAGMA busy_timeout=120000;")
c.execute("PRAGMA foreign_keys=ON;")
return c
def init_db():
with _conn() as conn:
# ── announcements ────────────────────────────────────────────────
conn.execute("""
CREATE TABLE IF NOT EXISTS announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
house_manage_no TEXT NOT NULL,
pblanc_no TEXT NOT NULL,
house_nm TEXT,
house_secd TEXT,
house_dtl_secd TEXT,
rent_secd TEXT,
region_code TEXT,
region_name TEXT,
address TEXT,
total_units INTEGER,
rcrit_date TEXT,
receipt_start TEXT,
receipt_end TEXT,
spsply_start TEXT,
spsply_end TEXT,
gnrl_rank1_start TEXT,
gnrl_rank1_end TEXT,
winner_date TEXT,
contract_start TEXT,
contract_end TEXT,
homepage_url TEXT,
pblanc_url TEXT,
constructor TEXT,
developer TEXT,
move_in_month TEXT,
is_speculative_area TEXT,
is_price_cap TEXT,
contact TEXT,
status TEXT NOT NULL DEFAULT '청약예정',
is_bookmarked INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'manual',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE(house_manage_no, pblanc_no)
);
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_ann_status ON announcements(status);")
conn.execute("CREATE INDEX IF NOT EXISTS idx_ann_region ON announcements(region_name);")
# ── 마이그레이션: district 컬럼 + 인덱스 추가 ──
try:
conn.execute("SELECT district FROM announcements LIMIT 1")
except Exception:
conn.execute("ALTER TABLE announcements ADD COLUMN district TEXT")
conn.execute("CREATE INDEX IF NOT EXISTS idx_ann_district ON announcements(district);")
# ── 마이그레이션: is_bookmarked 컬럼 추가 ──
try:
conn.execute("SELECT is_bookmarked FROM announcements LIMIT 1")
except Exception:
conn.execute("ALTER TABLE announcements ADD COLUMN is_bookmarked INTEGER NOT NULL DEFAULT 0")
# ── announcement_models ──────────────────────────────────────────
conn.execute("""
CREATE TABLE IF NOT EXISTS announcement_models (
id INTEGER PRIMARY KEY AUTOINCREMENT,
house_manage_no TEXT NOT NULL,
pblanc_no TEXT NOT NULL,
model_no TEXT,
house_ty TEXT,
supply_area REAL,
general_units INTEGER DEFAULT 0,
special_units INTEGER DEFAULT 0,
multi_child_units INTEGER DEFAULT 0,
newlywed_units INTEGER DEFAULT 0,
first_life_units INTEGER DEFAULT 0,
old_parent_units INTEGER DEFAULT 0,
institution_units INTEGER DEFAULT 0,
youth_units INTEGER DEFAULT 0,
newborn_units INTEGER DEFAULT 0,
top_amount INTEGER,
UNIQUE(house_manage_no, pblanc_no, model_no)
);
""")
# ── user_profile ─────────────────────────────────────────────────
conn.execute("""
CREATE TABLE IF NOT EXISTS user_profile (
id INTEGER PRIMARY KEY DEFAULT 1,
name TEXT,
age INTEGER,
is_homeless INTEGER,
is_householder INTEGER,
subscription_months INTEGER,
subscription_amount INTEGER,
family_members INTEGER,
has_dependents INTEGER,
children_count INTEGER DEFAULT 0,
is_newlywed INTEGER,
marriage_months INTEGER,
has_newborn INTEGER,
is_first_home INTEGER,
income_level TEXT,
preferred_regions TEXT NOT NULL DEFAULT '[]',
preferred_types TEXT NOT NULL DEFAULT '[]',
min_area REAL,
max_area REAL,
max_price INTEGER,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
""")
# ── 마이그레이션: user_profile 신규 3컬럼 ──
for col, ddl in (
("preferred_districts", "ALTER TABLE user_profile ADD COLUMN preferred_districts TEXT NOT NULL DEFAULT '{}'"),
("min_match_score", "ALTER TABLE user_profile ADD COLUMN min_match_score INTEGER NOT NULL DEFAULT 70"),
("notify_enabled", "ALTER TABLE user_profile ADD COLUMN notify_enabled INTEGER NOT NULL DEFAULT 1"),
):
try:
conn.execute(f"SELECT {col} FROM user_profile LIMIT 1")
except Exception:
conn.execute(ddl)
# ── match_results ────────────────────────────────────────────────
conn.execute("""
CREATE TABLE IF NOT EXISTS match_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
announcement_id INTEGER NOT NULL REFERENCES announcements(id) ON DELETE CASCADE,
model_id INTEGER,
match_score INTEGER NOT NULL DEFAULT 0,
match_reasons TEXT NOT NULL DEFAULT '[]',
eligible_types TEXT NOT NULL DEFAULT '[]',
is_new INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE(announcement_id, model_id)
);
""")
# ── 마이그레이션: notified_at 컬럼 추가 ──
try:
conn.execute("SELECT notified_at FROM match_results LIMIT 1")
except Exception:
conn.execute("ALTER TABLE match_results ADD COLUMN notified_at TEXT")
# ── 마이그레이션: score_breakdown 컬럼 추가 ──
try:
conn.execute("SELECT score_breakdown FROM match_results LIMIT 1")
except Exception:
conn.execute("ALTER TABLE match_results ADD COLUMN score_breakdown TEXT")
# ── collect_log ──────────────────────────────────────────────────
conn.execute("""
CREATE TABLE IF NOT EXISTS collect_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
new_count INTEGER NOT NULL DEFAULT 0,
total_count INTEGER NOT NULL DEFAULT 0,
error TEXT
);
""")
# ── 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);")
# ── 상태 자동 계산 ───────────────────────────────────────────────────────────
def compute_status(receipt_start: str, receipt_end: str, winner_date: str) -> str:
today = date.today().isoformat()
if receipt_start and today < receipt_start:
return "청약예정"
if receipt_start and receipt_end and receipt_start <= today <= receipt_end:
return "청약중"
if receipt_end and winner_date and receipt_end < today <= winner_date:
return "결과발표"
if winner_date and today > winner_date:
return "완료"
return "청약예정"
# ── announcements CRUD ───────────────────────────────────────────────────────
def _ann_row_to_dict(r) -> Dict[str, Any]:
return {c: r[c] for c in r.keys()}
def upsert_announcement(data: Dict[str, Any]) -> tuple:
"""공고 upsert — house_manage_no + pblanc_no 기준. Returns (dict, is_new: bool)."""
data.setdefault("district", None) # 수동 등록 등에서 누락 시 안전 처리
status = compute_status(
data.get("receipt_start", ""),
data.get("receipt_end", ""),
data.get("winner_date", ""),
)
with _conn() as conn:
exists = conn.execute(
"SELECT 1 FROM announcements WHERE house_manage_no = ? AND pblanc_no = ?",
(data["house_manage_no"], data["pblanc_no"]),
).fetchone()
is_new = exists is None
conn.execute("""
INSERT INTO announcements (
house_manage_no, pblanc_no, house_nm, house_secd, house_dtl_secd,
rent_secd, region_code, region_name, district, address, total_units,
rcrit_date, receipt_start, receipt_end, spsply_start, spsply_end,
gnrl_rank1_start, gnrl_rank1_end, winner_date, contract_start,
contract_end, homepage_url, pblanc_url, constructor, developer,
move_in_month, is_speculative_area, is_price_cap, contact,
status, source
) VALUES (
:house_manage_no, :pblanc_no, :house_nm, :house_secd, :house_dtl_secd,
:rent_secd, :region_code, :region_name, :district, :address, :total_units,
:rcrit_date, :receipt_start, :receipt_end, :spsply_start, :spsply_end,
:gnrl_rank1_start, :gnrl_rank1_end, :winner_date, :contract_start,
:contract_end, :homepage_url, :pblanc_url, :constructor, :developer,
:move_in_month, :is_speculative_area, :is_price_cap, :contact,
:status, :source
)
ON CONFLICT(house_manage_no, pblanc_no) DO UPDATE SET
house_nm=excluded.house_nm,
house_secd=excluded.house_secd,
house_dtl_secd=excluded.house_dtl_secd,
rent_secd=excluded.rent_secd,
region_code=excluded.region_code,
region_name=excluded.region_name,
district=excluded.district,
address=excluded.address,
total_units=excluded.total_units,
rcrit_date=excluded.rcrit_date,
receipt_start=excluded.receipt_start,
receipt_end=excluded.receipt_end,
spsply_start=excluded.spsply_start,
spsply_end=excluded.spsply_end,
gnrl_rank1_start=excluded.gnrl_rank1_start,
gnrl_rank1_end=excluded.gnrl_rank1_end,
winner_date=excluded.winner_date,
contract_start=excluded.contract_start,
contract_end=excluded.contract_end,
homepage_url=excluded.homepage_url,
pblanc_url=excluded.pblanc_url,
constructor=excluded.constructor,
developer=excluded.developer,
move_in_month=excluded.move_in_month,
is_speculative_area=excluded.is_speculative_area,
is_price_cap=excluded.is_price_cap,
contact=excluded.contact,
status=excluded.status,
updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
""", {**data, "status": status})
row = conn.execute(
"SELECT * FROM announcements WHERE house_manage_no = ? AND pblanc_no = ?",
(data["house_manage_no"], data["pblanc_no"]),
).fetchone()
return _ann_row_to_dict(row), is_new
def _enrich_items(conn, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""공고 목록에 모델 기반 가격 범위 + 매칭 점수를 추가한다."""
for item in items:
ann_id = item.get("id")
hmno = item.get("house_manage_no")
pno = item.get("pblanc_no")
# 가격 정보
if hmno and pno:
price_row = conn.execute(
"SELECT MIN(top_amount) as min_price, MAX(top_amount) as max_price "
"FROM announcement_models WHERE house_manage_no = ? AND pblanc_no = ? AND top_amount IS NOT NULL",
(hmno, pno),
).fetchone()
if price_row and price_row["min_price"] is not None:
item["min_price"] = price_row["min_price"]
item["max_price_display"] = price_row["max_price"]
# 매칭 점수
if ann_id:
match_row = conn.execute(
"SELECT match_score, match_reasons, eligible_types, score_breakdown FROM match_results WHERE announcement_id = ?",
(ann_id,),
).fetchone()
if match_row:
item["match_score"] = match_row["match_score"]
item["match_reasons"] = json.loads(match_row["match_reasons"]) if match_row["match_reasons"] else []
item["eligible_types"] = json.loads(match_row["eligible_types"]) if match_row["eligible_types"] else []
item["score_breakdown"] = json.loads(match_row["score_breakdown"]) if match_row["score_breakdown"] else None
return items
def get_announcements(
region: str = None,
status: str = None,
house_type: str = None,
matched_only: bool = False,
bookmarked: bool = False,
sort: str = "date",
page: int = 1,
size: int = 20,
) -> Dict[str, Any]:
conditions, params = [], []
if region:
conditions.append("a.region_name LIKE ?")
params.append(f"%{region}%")
if status:
conditions.append("a.status = ?")
params.append(status)
if house_type:
conditions.append("a.house_secd = ?")
params.append(house_type)
if bookmarked:
conditions.append("a.is_bookmarked = 1")
if matched_only:
conditions.append("a.id IN (SELECT announcement_id FROM match_results)")
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
order_map = {"date": "a.rcrit_date DESC", "score": "a.id DESC", "price": "a.id ASC"}
order = order_map.get(sort, "a.rcrit_date DESC")
if matched_only and sort == "score":
order = "(SELECT MAX(match_score) FROM match_results WHERE announcement_id = a.id) DESC"
offset = (page - 1) * size
with _conn() as conn:
total = conn.execute(
f"SELECT COUNT(*) FROM announcements a {where}", params
).fetchone()[0]
rows = conn.execute(
f"SELECT a.* FROM announcements a {where} ORDER BY {order} LIMIT ? OFFSET ?",
params + [size, offset],
).fetchall()
items = [_ann_row_to_dict(r) for r in rows]
items = _enrich_items(conn, items)
return {
"items": items,
"total": total,
"page": page,
"size": size,
}
def get_announcement(ann_id: int) -> Optional[Dict[str, Any]]:
with _conn() as conn:
row = conn.execute("SELECT * FROM announcements WHERE id = ?", (ann_id,)).fetchone()
if not row:
return None
ann = _ann_row_to_dict(row)
models = conn.execute(
"SELECT * FROM announcement_models WHERE house_manage_no = ? AND pblanc_no = ?",
(ann["house_manage_no"], ann["pblanc_no"]),
).fetchall()
ann["models"] = [dict(m) for m in models]
return ann
def create_announcement(data: Dict[str, Any]) -> Dict[str, Any]:
"""수동 공고 등록 (house_manage_no 자동 생성)."""
import uuid
data["house_manage_no"] = data.get("house_manage_no", f"MANUAL-{uuid.uuid4().hex[:8]}")
data["pblanc_no"] = data.get("pblanc_no", "00")
data["source"] = "manual"
result, _ = upsert_announcement(data)
return result
ANNOUNCEMENT_COLUMNS = {
"house_nm", "house_secd", "house_dtl_secd", "rent_secd",
"region_code", "region_name", "district", "address", "total_units",
"rcrit_date", "receipt_start", "receipt_end", "spsply_start", "spsply_end",
"gnrl_rank1_start", "gnrl_rank1_end", "winner_date",
"contract_start", "contract_end", "homepage_url", "pblanc_url",
"constructor", "developer", "move_in_month",
"is_speculative_area", "is_price_cap", "contact",
}
def update_announcement(ann_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
fields = {k: v for k, v in data.items() if v is not None and k in ANNOUNCEMENT_COLUMNS}
if not fields:
return get_announcement(ann_id)
# 날짜 변경 시 status 재계산
with _conn() as conn:
row = conn.execute("SELECT * FROM announcements WHERE id = ?", (ann_id,)).fetchone()
if not row:
return None
current = _ann_row_to_dict(row)
merged = {**current, **fields}
status = compute_status(
merged.get("receipt_start", ""),
merged.get("receipt_end", ""),
merged.get("winner_date", ""),
)
fields["status"] = status
set_clauses = ", ".join(f"{k} = ?" for k in fields)
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
conn.execute(
f"UPDATE announcements SET {set_clauses} WHERE id = ?",
list(fields.values()) + [ann_id],
)
return get_announcement(ann_id)
def toggle_bookmark(ann_id: int) -> Optional[Dict[str, Any]]:
with _conn() as conn:
row = conn.execute("SELECT id, is_bookmarked FROM announcements WHERE id = ?", (ann_id,)).fetchone()
if not row:
return None
new_val = 0 if row["is_bookmarked"] else 1
conn.execute(
"UPDATE announcements SET is_bookmarked = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
(new_val, ann_id),
)
updated = conn.execute("SELECT * FROM announcements WHERE id = ?", (ann_id,)).fetchone()
return _ann_row_to_dict(updated)
def delete_announcement(ann_id: int) -> bool:
with _conn() as conn:
# match_results는 FK CASCADE로 자동 삭제
cur = conn.execute("DELETE FROM announcements WHERE id = ?", (ann_id,))
return cur.rowcount > 0
def delete_closed_announcements() -> int:
"""status='완료' 공고 일괄 삭제. 삭제된 건수 반환."""
with _conn() as conn:
cur = conn.execute("DELETE FROM announcements WHERE status = '완료'")
return cur.rowcount
def delete_old_completed_announcements(grace_days: int = 90) -> int:
"""winner_date + grace_days 경과한 status='완료' 공고를 삭제.
winner_date가 NULL인 행은 안전하게 보존(수동 검토 대상).
match_results는 FK CASCADE로 자동 삭제. 삭제된 건수 반환.
"""
with _conn() as conn:
cur = conn.execute(
"""
DELETE FROM announcements
WHERE status = '완료'
AND winner_date IS NOT NULL
AND date(winner_date) < date('now', ?)
""",
(f"-{grace_days} days",),
)
return cur.rowcount
def update_all_statuses():
"""모든 진행중 공고의 status를 날짜 기반으로 재계산."""
with _conn() as conn:
rows = conn.execute(
"SELECT id, status, receipt_start, receipt_end, winner_date FROM announcements "
"WHERE status != '완료' AND (receipt_start IS NOT NULL OR receipt_end IS NOT NULL OR winner_date IS NOT NULL)"
).fetchall()
for r in rows:
new_status = compute_status(r["receipt_start"], r["receipt_end"], r["winner_date"])
if new_status != r["status"]: # only update if status actually changed
conn.execute(
"UPDATE announcements SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
(new_status, r["id"]),
)
# ── announcement_models CRUD ─────────────────────────────────────────────────
def upsert_model(data: Dict[str, Any]):
with _conn() as conn:
conn.execute("""
INSERT INTO announcement_models (
house_manage_no, pblanc_no, model_no, house_ty, supply_area,
general_units, special_units, multi_child_units, newlywed_units,
first_life_units, old_parent_units, institution_units,
youth_units, newborn_units, top_amount
) VALUES (
:house_manage_no, :pblanc_no, :model_no, :house_ty, :supply_area,
:general_units, :special_units, :multi_child_units, :newlywed_units,
:first_life_units, :old_parent_units, :institution_units,
:youth_units, :newborn_units, :top_amount
)
ON CONFLICT(house_manage_no, pblanc_no, model_no) DO UPDATE SET
house_ty=excluded.house_ty,
supply_area=excluded.supply_area,
general_units=excluded.general_units,
special_units=excluded.special_units,
multi_child_units=excluded.multi_child_units,
newlywed_units=excluded.newlywed_units,
first_life_units=excluded.first_life_units,
old_parent_units=excluded.old_parent_units,
institution_units=excluded.institution_units,
youth_units=excluded.youth_units,
newborn_units=excluded.newborn_units,
top_amount=excluded.top_amount
""", data)
# ── 청약 가점 계산 ───────────────────────────────────────────────────────────
def calculate_subscription_points(profile: Dict[str, Any]) -> Dict[str, Any]:
"""청약 가점제 점수 계산 (총 84점 만점).
1. 무주택기간 (0~32점): 만 30세부터 기산, 연 2점
2. 부양가족 수 (0~35점): 인당 5점, 6명+ 만점
3. 청약통장 가입기간 (0~17점): 6개월 미만 1점 ~ 15년+ 17점
"""
result = {
"homeless_duration": {"score": 0, "max": 32, "detail": ""},
"dependents": {"score": 0, "max": 35, "detail": ""},
"subscription_period": {"score": 0, "max": 17, "detail": ""},
"total": 0,
"max_total": 84,
}
if not profile:
return result
# 1. 무주택기간 (만 30세부터 기산, 연 2점, 최대 32점)
age = profile.get("age") or 0
is_homeless = profile.get("is_homeless", False)
if is_homeless and age >= 30:
homeless_years = age - 30
score = min(homeless_years * 2, 32)
# 1년 미만도 2점
if homeless_years == 0:
score = 2
result["homeless_duration"]["score"] = score
result["homeless_duration"]["detail"] = f"{age}세, 무주택 약 {homeless_years}"
elif is_homeless and age < 30:
result["homeless_duration"]["score"] = 0
result["homeless_duration"]["detail"] = f"{age}세 (30세 미만, 기간 미산정)"
else:
result["homeless_duration"]["detail"] = "유주택자"
# 2. 부양가족 수 (인당 5점, 최대 35점)
family_members = profile.get("family_members") or 0
dependents = max(family_members - 1, 0) # 본인 제외
dep_score = min(dependents * 5, 35)
result["dependents"]["score"] = dep_score
result["dependents"]["detail"] = f"{dependents}" if dependents > 0 else "0명 (본인만)"
# 3. 청약통장 가입기간 (6개월 미만 1점, 이후 1년마다 +1점, 최대 17점)
months = profile.get("subscription_months") or 0
if months <= 0:
sub_score = 0
sub_detail = "미가입"
elif months < 6:
sub_score = 1
sub_detail = f"{months}개월 (6개월 미만)"
else:
years = months / 12
# 6개월~1년 = 2점, 1~2년 = 3점, ..., 14~15년 = 16점, 15년+ = 17점
sub_score = min(int(years) + 2, 17)
if years < 1:
sub_score = 2
if years >= 1:
y = int(years)
sub_detail = f"{y}{months - y*12}개월"
else:
sub_detail = f"{months}개월"
result["subscription_period"]["score"] = sub_score
result["subscription_period"]["detail"] = sub_detail
result["total"] = (
result["homeless_duration"]["score"]
+ result["dependents"]["score"]
+ result["subscription_period"]["score"]
)
return result
# ── user_profile CRUD ────────────────────────────────────────────────────────
def _profile_row_to_dict(r) -> Dict[str, Any]:
d = {}
for c in r.keys():
val = r[c]
if c in ("is_homeless", "is_householder", "has_dependents", "is_newlywed",
"has_newborn", "is_first_home", "notify_enabled"):
d[c] = bool(val) if val is not None else None
elif c in ("preferred_regions", "preferred_types"):
d[c] = json.loads(val) if val else []
elif c == "preferred_districts":
d[c] = json.loads(val) if val else {}
else:
d[c] = val
return d
def get_profile() -> Optional[Dict[str, Any]]:
with _conn() as conn:
r = conn.execute("SELECT * FROM user_profile WHERE id = 1").fetchone()
if not r:
return None
profile = _profile_row_to_dict(r)
profile["subscription_points"] = calculate_subscription_points(profile)
return profile
PROFILE_COLUMNS = {
"name", "age", "is_homeless", "is_householder",
"subscription_months", "subscription_amount", "family_members",
"has_dependents", "children_count", "is_newlywed", "marriage_months",
"has_newborn", "is_first_home", "income_level",
"preferred_regions", "preferred_types", "preferred_districts",
"min_area", "max_area", "max_price",
"min_match_score", "notify_enabled",
}
def upsert_profile(data: Dict[str, Any]) -> Dict[str, Any]:
updates = {}
for k, v in data.items():
if v is None or k not in PROFILE_COLUMNS:
continue
if isinstance(v, bool):
updates[k] = 1 if v else 0
elif isinstance(v, (list, dict)):
updates[k] = json.dumps(v)
else:
updates[k] = v
with _conn() as conn:
existing = conn.execute("SELECT id FROM user_profile WHERE id = 1").fetchone()
if existing:
if updates:
set_clauses = ", ".join(f"{k} = ?" for k in updates)
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
conn.execute(
f"UPDATE user_profile SET {set_clauses} WHERE id = 1",
list(updates.values()),
)
else:
cols = ["id"] + list(updates.keys())
vals = [1] + list(updates.values())
placeholders = ", ".join("?" for _ in vals)
conn.execute(
f"INSERT INTO user_profile ({', '.join(cols)}) VALUES ({placeholders})",
vals,
)
row = conn.execute("SELECT * FROM user_profile WHERE id = 1").fetchone()
profile = _profile_row_to_dict(row)
profile["subscription_points"] = calculate_subscription_points(profile)
return profile
# ── match_results CRUD ───────────────────────────────────────────────────────
def save_match_result(data: Dict[str, Any]):
with _conn() as conn:
conn.execute("""
INSERT INTO match_results (announcement_id, model_id, match_score, match_reasons, eligible_types, is_new)
VALUES (:announcement_id, :model_id, :match_score, :match_reasons, :eligible_types, 1)
ON CONFLICT(announcement_id, model_id) DO UPDATE SET
match_score=excluded.match_score,
match_reasons=excluded.match_reasons,
eligible_types=excluded.eligible_types
""", {
**data,
"match_reasons": json.dumps(data.get("match_reasons", [])),
"eligible_types": json.dumps(data.get("eligible_types", [])),
})
def get_matches(page: int = 1, size: int = 20) -> Dict[str, Any]:
offset = (page - 1) * size
with _conn() as conn:
# 프로필 가점 계산
profile_row = conn.execute("SELECT * FROM user_profile WHERE id = 1").fetchone()
points = None
if profile_row:
profile = _profile_row_to_dict(profile_row)
points = calculate_subscription_points(profile)
total = conn.execute("SELECT COUNT(*) FROM match_results").fetchone()[0]
rows = conn.execute("""
SELECT m.*, a.house_nm, a.region_name, a.address, a.district, a.status as ann_status,
a.receipt_start, a.receipt_end, a.winner_date, a.pblanc_url,
a.house_secd, a.is_speculative_area
FROM match_results m
JOIN announcements a ON a.id = m.announcement_id
ORDER BY m.is_new DESC, m.match_score DESC
LIMIT ? OFFSET ?
""", (size, offset)).fetchall()
items = []
for r in rows:
d = {c: r[c] for c in r.keys()}
d["match_reasons"] = json.loads(d["match_reasons"]) if d["match_reasons"] else []
d["eligible_types"] = json.loads(d["eligible_types"]) if d["eligible_types"] else []
d["score_breakdown"] = json.loads(d["score_breakdown"]) if d.get("score_breakdown") else None
items.append(d)
return {
"items": items,
"total": total,
"page": page,
"size": size,
"my_points": points,
}
def mark_match_read(match_id: int) -> bool:
with _conn() as conn:
cur = conn.execute("UPDATE match_results SET is_new = 0 WHERE id = ?", (match_id,))
return cur.rowcount > 0
def get_unnotified_matches(min_score: int) -> List[Dict[str, Any]]:
"""notified_at IS NULL AND match_score >= min_score 인 매칭과 공고 정보 조인 반환."""
with _conn() as conn:
rows = conn.execute("""
SELECT m.id, m.announcement_id, m.match_score, m.match_reasons, m.eligible_types,
a.house_nm, a.region_name, a.district, a.address, a.status,
a.receipt_start, a.receipt_end, a.winner_date,
a.house_secd, a.is_speculative_area, a.is_price_cap, a.pblanc_url
FROM match_results m
JOIN announcements a ON a.id = m.announcement_id
WHERE m.notified_at IS NULL
AND m.match_score >= ?
ORDER BY m.match_score DESC
""", (min_score,)).fetchall()
items = []
for r in rows:
d = {c: r[c] for c in r.keys()}
d["match_reasons"] = json.loads(d["match_reasons"]) if d["match_reasons"] else []
d["eligible_types"] = json.loads(d["eligible_types"]) if d["eligible_types"] else []
items.append(d)
return items
def mark_matches_notified(match_ids: List[int]) -> None:
"""주어진 match_results IDs의 notified_at을 현재 시각으로 일괄 업데이트."""
if not match_ids:
return
placeholders = ",".join("?" for _ in match_ids)
with _conn() as conn:
conn.execute(
f"UPDATE match_results SET notified_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') "
f"WHERE id IN ({placeholders})",
tuple(match_ids),
)
# ── collect_log CRUD ─────────────────────────────────────────────────────────
def save_collect_log(new_count: int, total_count: int, error: str = None):
with _conn() as conn:
conn.execute(
"INSERT INTO collect_log (new_count, total_count, error) VALUES (?, ?, ?)",
(new_count, total_count, error),
)
def get_last_collect_log() -> Optional[Dict[str, Any]]:
with _conn() as conn:
r = conn.execute("SELECT * FROM collect_log ORDER BY id DESC LIMIT 1").fetchone()
return dict(r) if r else None
# ── 대시보드 ─────────────────────────────────────────────────────────────────
def get_dashboard() -> Dict[str, Any]:
with _conn() as conn:
active = conn.execute(
"SELECT COUNT(*) FROM announcements WHERE status IN ('청약예정', '청약중')"
).fetchone()[0]
new_matches = conn.execute(
"SELECT COUNT(*) FROM match_results WHERE is_new = 1"
).fetchone()[0]
bookmarked_count = conn.execute(
"SELECT COUNT(*) FROM announcements WHERE is_bookmarked = 1"
).fetchone()[0]
profile_row = conn.execute(
"SELECT min_match_score FROM user_profile WHERE id = 1"
).fetchone()
min_score = profile_row["min_match_score"] if profile_row else 70
pass_count = conn.execute(
"SELECT COUNT(*) FROM match_results WHERE match_score >= ?", (min_score,)
).fetchone()[0]
# 다가오는 일정을 개별 이벤트로 분해
upcoming_rows = conn.execute("""
SELECT id, house_nm, receipt_start, receipt_end,
spsply_start, gnrl_rank1_start, winner_date,
contract_start, status
FROM announcements
WHERE status IN ('청약예정', '청약중')
ORDER BY receipt_start ASC
LIMIT 20
""").fetchall()
today = date.today().isoformat()
schedules = []
for r in upcoming_rows:
events = [
("특별공급 접수", r["spsply_start"]),
("1순위 접수", r["gnrl_rank1_start"]),
("청약 접수", r["receipt_start"]),
("당첨자 발표", r["winner_date"]),
("계약 시작", r["contract_start"]),
]
for event, d in events:
if d and d >= today:
schedules.append({
"announcement_id": r["id"],
"house_nm": r["house_nm"],
"event": event,
"date": d,
})
schedules.sort(key=lambda s: s["date"])
schedules = schedules[:10]
# 즐겨찾기 공고
bookmarked_rows = conn.execute("""
SELECT * FROM announcements WHERE is_bookmarked = 1
ORDER BY receipt_start ASC
""").fetchall()
bookmarked_items = [_ann_row_to_dict(r) for r in bookmarked_rows]
bookmarked_items = _enrich_items(conn, bookmarked_items)
return {
"active_count": active,
"new_match_count": new_matches,
"pass_count": pass_count,
"bookmarked_count": bookmarked_count,
"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,
dong_code=excluded.dong_code
""", 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:
if data.get("area") is None:
# area 없는 실거래는 area BETWEEN 조회에서 절대 매칭 안 돼 median 계산에 무용하고,
# dedup 인덱스도 못 걸려(area가 NOT NULL 컬럼이 아니라 조건 매칭 불가) 무한 중복을 유발한다.
return
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 bulk_upsert_market_deals(rows: List[Dict[str, Any]]) -> int:
"""market_deals 다건 INSERT OR IGNORE (단일 connection). area=None은 skip(무용/bloat 방지).
저장시도 건수 반환. 기존 upsert_market_deal과 동일 dedup(ux_market_deals_dedup 인덱스)."""
cols = ("house_type", "dong_code", "dong", "complex_name", "area", "deal_type",
"deposit", "monthly_rent", "sale_amount", "deal_ym", "floor", "source")
payload = []
for data in rows:
if data.get("area") is None:
continue
d = {c: data.get(c) for c in cols}
d["source"] = data.get("source") or "molit"
payload.append(tuple(d[c] for c in cols))
if not payload:
return 0
with _conn() as conn:
conn.executemany(
f"INSERT OR IGNORE INTO market_deals ({','.join(cols)}) VALUES ({','.join(['?'] * len(cols))})",
payload,
)
return len(payload)
def get_market_deals_for(dong_code, complex_name, area, deal_type, months=6, conn=None) -> List[Dict[str, Any]]:
"""conn을 넘기면 재사용(open/close 안 함, 호출부가 소유) — run_listing_matching 단일 conn 배치용.
conn 없으면 기존대로 자체 _conn() 열고 닫음(safety-check 등 단건 호출 경로 불변)."""
own = conn is None
c = conn or _conn()
try:
def _query(cn):
rows = c.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, cn, cn,
(area or 0) - 5, (area or 0) + 5, f"-{int(months)} months"),
).fetchall()
return [dict(r) for r in rows]
deals = _query(complex_name)
if len(deals) < _MARKET_SAMPLE_MIN and complex_name:
# 단지 표본이 부족하면 complex 제약을 풀고 같은 동/거래유형/면적±5/최근성으로
# 광역 재조회한다. 표본을 늘리기만 하므로 안전(잘못된 판정 유발 X).
deals = _query(None)
return deals
finally:
if own:
c.close()
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 bulk_upsert_listing_matches(recs: List[Dict[str, Any]], conn=None) -> None:
"""listing_matches 다건 upsert(단일 connection). conn 주면 재사용(호출부 with가 commit) —
run_listing_matching 269건×3conn 병목 개선(Task 2). ON CONFLICT 절은 upsert_listing_match와 동일
(notified_at 미포함=보존, 재알림 방지)."""
if not recs:
return
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")
own = conn is None
c = conn or _conn()
try:
for data in recs:
d = {col: data.get(col) for col 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)
c.execute(f"""
INSERT INTO listing_matches ({','.join(cols)}) VALUES ({','.join(':'+col for col 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)
if own:
c.commit()
finally:
if own:
c.close()
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()
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)
return out
_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