auto: 일일 백업 2026-08-05 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""후잉 OpenAPI 잔액(bs.json) 조회.
|
||||
"""후잉 OpenAPI 클라이언트 — 잔액(bs.json) 조회 + 거래(entries.json) 조회·감액.
|
||||
|
||||
Usage:
|
||||
whooing_balance.py # 모든 섹션의 현재 잔액
|
||||
@@ -11,11 +11,13 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
CRED_PATH = Path("/Users/snowoyh/.openclaw/credentials/whooing.json")
|
||||
@@ -41,6 +43,130 @@ def api_get(endpoint: str, api_key: str, params: dict | None = None) -> dict:
|
||||
return data["results"]
|
||||
|
||||
|
||||
def api_key_from_cred() -> str:
|
||||
"""credentials 에서 API 키 문자열 1회 생성."""
|
||||
api_cfg = json.loads(CRED_PATH.read_text())["api"]
|
||||
return build_api_key(api_cfg["app_id"], api_cfg["token"], api_cfg["signature"])
|
||||
|
||||
|
||||
def api_request(method: str, endpoint: str, api_key: str,
|
||||
params: dict | None = None, body: dict | None = None) -> dict:
|
||||
"""후잉 OpenAPI 호출. GET 은 쿼리스트링, PUT 은 본문으로 파라미터를 보낸다.
|
||||
|
||||
⚠️ DELETE 는 쓸 수 없다. 후잉 서버가 DELETE 요청의 파라미터를 쿼리·본문(urlencoded/JSON)·
|
||||
쿠키·경로 어디에서도 읽지 못해 항상 `section_id parameter is required` 로 실패한다
|
||||
(2026-08-04 전수 실측. 같은 본문을 PUT 으로 보내면 정상이라 후잉 쪽 문제다).
|
||||
거래를 지우는 대신 PUT 으로 금액을 줄이는 이유가 이것이다.
|
||||
"""
|
||||
url = f"{BASE}/{endpoint}"
|
||||
if params:
|
||||
url += "?" + urllib.parse.urlencode(params)
|
||||
data = None
|
||||
if body:
|
||||
# 후잉은 '+' 를 공백으로 디코드하지 않는다. quote_via=quote 로 공백을 %20 으로 보낸다.
|
||||
data = urllib.parse.urlencode(body, quote_via=urllib.parse.quote).encode()
|
||||
req = urllib.request.Request(url, data=data, method=method, headers={"X-API-KEY": api_key})
|
||||
if data:
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
parsed = json.loads(resp.read().decode("utf-8"))
|
||||
if parsed.get("code") != 200:
|
||||
raise RuntimeError(
|
||||
f"후잉 API error {parsed.get('code')}: {parsed.get('message')} "
|
||||
f"{parsed.get('error_parameters') or ''} ({method} {endpoint})"
|
||||
)
|
||||
return parsed["results"]
|
||||
|
||||
|
||||
def _section_list(api_key: str) -> list:
|
||||
sections = api_request("GET", "sections.json", api_key)
|
||||
if isinstance(sections, dict):
|
||||
return sections.get("sections") or sections.get("rows") or list(sections.values())
|
||||
return sections
|
||||
|
||||
|
||||
def _name_to_account_id(api_key: str, section_id) -> dict[str, str]:
|
||||
raw = api_request("GET", "accounts.json", api_key, {"section_id": section_id})
|
||||
out: dict[str, str] = {}
|
||||
for acc_list in raw.values():
|
||||
if not isinstance(acc_list, list):
|
||||
continue
|
||||
for a in acc_list:
|
||||
aid = str(a.get("account_id"))
|
||||
out[a.get("title") or aid] = aid
|
||||
return out
|
||||
|
||||
|
||||
def _memo_has_merchant(memo: str, merchant: str) -> bool:
|
||||
"""memo 에 merchant 가 토큰 단위로 들어있는지.
|
||||
|
||||
단순 substring 이면 '쿠팡' 이 '쿠팡페이주' 승인까지 잡아 엉뚱한 거래를 감액한다
|
||||
(2026-08-04 실제로 두 상호가 같은 날 같은 금액으로 있었다). 카드 SMS 는 상호를
|
||||
줄바꿈·공백으로 구분하므로 앞뒤가 공백이거나 문자열 끝일 때만 인정한다.
|
||||
"""
|
||||
if not memo or not merchant:
|
||||
return False
|
||||
for m in re.finditer(re.escape(merchant), memo):
|
||||
before = memo[m.start() - 1] if m.start() > 0 else " "
|
||||
after = memo[m.end()] if m.end() < len(memo) else " "
|
||||
if before.isspace() and after.isspace():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def find_card_entries(card_account: str, money: int, merchant: str,
|
||||
on_date: str, window_days: int = 30) -> list[dict]:
|
||||
"""카드 취소의 원본 승인 거래 후보를 찾는다.
|
||||
|
||||
card_account 로 결제된 거래 중 memo 에 merchant 가 들어있고 금액이 취소액 이상인 것을
|
||||
[on_date - window_days, on_date] 구간에서 찾는다. 금액 일치(전액취소)를 우선 반환하고,
|
||||
없으면 초과분(부분취소 후보)을 반환한다.
|
||||
|
||||
⚠️ item 이 아니라 memo 로 매칭한다 — merchant_map 의 contains 룰이 item 을 재작성해서
|
||||
(동행복권→'복권') item 은 상호와 다를 수 있지만, memo 는 항상 승인 SMS 원문이다.
|
||||
⚠️ 판단은 호출측이 한다. 정확히 1건일 때만 손대는 게 안전하다.
|
||||
"""
|
||||
api_key = api_key_from_cred()
|
||||
start = (datetime.strptime(on_date, "%Y%m%d") - timedelta(days=window_days)).strftime("%Y%m%d")
|
||||
exact, over = [], []
|
||||
for sec in _section_list(api_key):
|
||||
sid = sec.get("section_id") or sec.get("id")
|
||||
card_id = _name_to_account_id(api_key, sid).get(card_account)
|
||||
if not card_id:
|
||||
continue
|
||||
rows = api_request("GET", "entries.json", api_key, {
|
||||
"section_id": sid, "start_date": start, "end_date": on_date,
|
||||
})
|
||||
for row in (rows.get("rows") or []):
|
||||
if str(row.get("r_account_id")) != card_id:
|
||||
continue
|
||||
if not _memo_has_merchant(row.get("memo") or "", merchant):
|
||||
continue
|
||||
row_money = row.get("money") or 0
|
||||
if row_money == money:
|
||||
exact.append({**row, "section_id": sid})
|
||||
elif row_money > money:
|
||||
over.append({**row, "section_id": sid})
|
||||
return exact or over
|
||||
|
||||
|
||||
def set_entry_money(entry: dict, money: int) -> None:
|
||||
"""거래 금액을 money 로 바꾼다(PUT). money=0 도 후잉이 받는다(2026-08-04 실측).
|
||||
|
||||
조회한 나머지 필드를 그대로 되돌려보내 보존한다 — 부분 PUT 의 필드 보존 동작에
|
||||
기대지 않는 편이 안전하다. entry_date 는 조회 시 '20260804.0000' 형태라 소수부를 뗀다.
|
||||
"""
|
||||
api_request("PUT", f"entries/{entry['entry_id']}.json", api_key_from_cred(), body={
|
||||
"section_id": entry["section_id"],
|
||||
"entry_date": str(entry.get("entry_date") or "").split(".")[0],
|
||||
"l_account_id": entry.get("l_account_id"),
|
||||
"r_account_id": entry.get("r_account_id"),
|
||||
"item": entry.get("item") or "",
|
||||
"money": str(money),
|
||||
"memo": entry.get("memo") or "",
|
||||
})
|
||||
|
||||
|
||||
def fmt_won(n: int) -> str:
|
||||
return f"{n:,}원"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user