auto: 일일 백업 2026-08-04 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-08-04 02:00:05 +09:00
parent 153ef20ae5
commit 7e7d759b34
167 changed files with 2732 additions and 1306 deletions
@@ -12,7 +12,6 @@ from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
import urllib.error
import urllib.parse
@@ -22,6 +21,7 @@ from pathlib import Path
from zoneinfo import ZoneInfo
import parsers as sms_parsers
import imsg_cli
import notify
import whooing_balance
import gahee_reminder
@@ -209,7 +209,11 @@ MONEY_AMOUNT_RE = re.compile(r"(?:\d[\d,]*\s*원|(?:입금|출금)\s+\d[\d,]*)")
# 주유소 카드 결제는 보통 150,000원 선승인 → 실제 주유금액 승인 → 150,000원 승인취소 순서로 온다.
# 선승인/취소는 후잉에 기록하지 않고, 실제 결제만 차량유지비/주유비로 남긴다.
FUEL_KEYWORDS = ("석유", "주유소", "주유")
# "현대가스"·"한경에너"는 상호에 주유/석유가 안 들어가는 충전소라 직접 등록 (2026-08-03 관리자님 확인).
# 새 주유소를 만나면 여기와 whooing_merchant_map.json contains **양쪽에** 상호를 추가한다 —
# 이 상수는 선승인/취소 스킵, merchant_map 은 실제 주유 결제 분류로 한 쌍이다.
# ⚠️ 한경에너는 선승인이 149,900원이라 FUEL_PREAUTH_AMOUNT 정확일치엔 안 걸린다(분류만 적용).
FUEL_KEYWORDS = ("석유", "주유소", "주유", "현대가스", "한경에너")
FUEL_PREAUTH_AMOUNT = 150_000
@@ -220,7 +224,7 @@ def _is_fuel_text(text: str) -> bool:
def _should_skip_fuel_preauth(parsed: dict | None, text: str) -> bool:
"""주유소 15만원 선승인/취소 스킵.
parsed 가 있으면 구조화 필드 기준으로 판단한다. 현대카드 취소처럼 아직 파서가 없는 형식은
parsed 가 있으면 구조화 필드 기준으로 판단한다. 파서가 못 읽은 형식은 폴백으로
raw 텍스트에서 '취소' + '150,000원' + 주유 키워드 조합만 보수적으로 스킵한다.
"""
if parsed:
@@ -281,39 +285,33 @@ def resolve_chat_ids(carriers):
needed = set(carriers.keys())
if needed.issubset(cached_keys):
return {k: cached[k] for k in needed}
try:
chats_raw = subprocess.run(
["imsg", "chats", "--json"],
capture_output=True, text=True, timeout=IMSG_TIMEOUT,
)
chats = [json.loads(l) for l in chats_raw.stdout.splitlines() if l.strip()]
except FileNotFoundError:
print("❌ imsg CLI가 설치되어 있지 않습니다.", file=sys.stderr)
sys.exit(3)
except Exception as e:
print(f"❌ imsg chats 실행 실패: {e}", file=sys.stderr)
chats, err = imsg_cli.chats(IMSG_TIMEOUT)
if err:
print(f"❌ imsg chats 실행 실패: {err}", file=sys.stderr)
sys.exit(3)
result = {}
for sender in carriers.keys():
ids = [c["id"] for c in chats if c.get("identifier") == sender and c.get("id") is not None]
result[sender] = ids
# 전 carrier 가 빈 리스트면 조회 자체가 실패한 것으로 본다. 이 상태를 캐시에 저장하면
# 이후 모든 실행(launchd 포함)이 캐시에 적중해 조용히 0건 수집을 영구 반복한다.
# 일부만 비는 건 정상 — 메시지 이력이 없는 carrier 가 있을 수 있다.
if not any(result.values()):
print("❌ imsg chats 결과에 매핑된 발신번호가 하나도 없습니다 — 캐시 저장 안 함", file=sys.stderr)
sys.exit(3)
save_json(CHAT_IDS_CACHE, result)
return result
def fetch_messages(chat_id, since):
cmd = ["imsg", "history", "--chat-id", str(chat_id), "--limit", str(HISTORY_LIMIT), "--json"]
args = ["history", "--chat-id", str(chat_id), "--limit", str(HISTORY_LIMIT), "--json"]
if since:
cmd += ["--start", since]
try:
raw = subprocess.run(cmd, capture_output=True, text=True, timeout=IMSG_TIMEOUT)
return [json.loads(l) for l in raw.stdout.splitlines() if l.strip()]
except subprocess.TimeoutExpired:
print(f"⚠️ chat-id {chat_id} history 타임아웃", file=sys.stderr)
return []
except Exception as e:
print(f"⚠️ chat-id {chat_id} history 실패: {e}", file=sys.stderr)
args += ["--start", since]
rows, err = imsg_cli.run_json(args, IMSG_TIMEOUT)
if err:
print(f"⚠️ chat-id {chat_id} history 실패: {err}", file=sys.stderr)
return []
return rows
# --- 룰 엔진 (whooing_overrides.json) -----------------------------------------
@@ -518,7 +516,19 @@ def build_structured(parsed, sender_info, accounts, merchant_map):
return {**base, "left": "기타비용", "right": carrier_acct}
if kind == "card_cancel":
return None # raw 폴백 (후잉이 원거래 찾아 상쇄)
# 승인의 역분개. 승인이 {left: 비용, right: 카드} 이므로 좌우를 뒤집는다.
# 부분취소(취소액 != 승인액)도 취소 문자 금액 그대로 상쇄되어 맞는다.
if rule:
left = rule.get("left")
if left:
out = {**base, "left": rule.get("right") or carrier_acct, "right": left}
if rule.get("item"):
out["item"] = rule["item"]
return out
return None
if cat:
return {**base, "left": carrier_acct, "right": cat}
return {**base, "left": carrier_acct, "right": "기타비용"}
if kind == "withdrawal":
if rule: