add app files

This commit is contained in:
2026-03-09 00:52:51 +08:00
parent 9b4c7cfeda
commit 423d7a573c
10 changed files with 1906 additions and 0 deletions

1
app/.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

17
app/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM python:3.11-slim
# 安裝 uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
# 複製 uv 設定檔
COPY pyproject.toml .
COPY uv.lock* .
# 安裝依賴
RUN uv sync --frozen
COPY . .
CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

0
app/README.md Normal file
View File

100
app/create_rich_menu.py Normal file
View File

@@ -0,0 +1,100 @@
import os
import requests
from PIL import Image, ImageDraw, ImageFont
from dotenv import load_dotenv
load_dotenv("../.env")
ACCESS_TOKEN = os.getenv("LINE_CHANNEL_ACCESS_TOKEN")
HEADERS = {"Authorization": f"Bearer {ACCESS_TOKEN}"}
def create_image():
W, H = 2500, 843
img = Image.new("RGB", (W, H), color="#FFFFFF")
draw = ImageDraw.Draw(img)
# 分隔線
draw.line([(833, 0), (833, H)], fill="#DDDDDD", width=4)
draw.line([(1666, 0), (1666, H)], fill="#DDDDDD", width=4)
# 背景色
draw.rectangle([0, 0, 833, H], fill="#F0F9FF")
draw.rectangle([834, 0, 1666, H], fill="#F0FFF4")
draw.rectangle([1667, 0, W, H], fill="#FFF9F0")
sections = [
("記帳", "類別 金額", 416),
("查今天", "今日明細", 1249),
("查本月", "本月統計", 2083),
]
try:
# Windows 支援中文的字型
font_large = ImageFont.truetype("C:/Windows/Fonts/msjh.ttc", 120)
font_mid = ImageFont.truetype("C:/Windows/Fonts/msjh.ttc", 80)
font_small = ImageFont.truetype("C:/Windows/Fonts/msjh.ttc", 55)
except:
font_large = ImageFont.load_default()
font_mid = font_large
font_small = font_large
for title, subtitle, x in sections:
draw.text((x, 280), title, font=font_mid, anchor="mm", fill="#222222")
draw.text((x, 450), subtitle, font=font_small, anchor="mm", fill="#888888")
img.save("rich_menu.png")
print("✅ 圖片建立完成")
def create_rich_menu():
data = {
"size": {"width": 2500, "height": 843},
"selected": True,
"name": "Finance Menu",
"chatBarText": "選單",
"areas": [
{
"bounds": {"x": 0, "y": 0, "width": 833, "height": 843},
"action": {"type": "message", "text": "記帳說明"}
},
{
"bounds": {"x": 833, "y": 0, "width": 833, "height": 843},
"action": {"type": "message", "text": "查今天"}
},
{
"bounds": {"x": 1666, "y": 0, "width": 834, "height": 843},
"action": {"type": "message", "text": "查本月"}
}
]
}
res = requests.post(
"https://api.line.me/v2/bot/richmenu",
headers={**HEADERS, "Content-Type": "application/json"},
json=data
)
rich_menu_id = res.json()["richMenuId"]
print(f"✅ Rich Menu 建立:{rich_menu_id}")
return rich_menu_id
def upload_image(rich_menu_id):
with open("rich_menu.png", "rb") as f:
res = requests.post(
f"https://api-data.line.me/v2/bot/richmenu/{rich_menu_id}/content",
headers={**HEADERS, "Content-Type": "image/png"},
data=f
)
print(f"✅ 圖片上傳:{res.status_code}")
def set_default(rich_menu_id):
res = requests.post(
f"https://api.line.me/v2/bot/user/all/richmenu/{rich_menu_id}",
headers=HEADERS
)
print(f"✅ 設為預設選單:{res.status_code} {res.text}")
if __name__ == "__main__":
create_image()
rich_menu_id = create_rich_menu()
upload_image(rich_menu_id)
set_default(rich_menu_id)
print("\n🎉 Rich Menu 設定完成!重新開啟 LINE Bot 查看效果")

216
app/invoice_fetcher.py Normal file
View File

@@ -0,0 +1,216 @@
import asyncio
import base64
import os
import requests
import anthropic
import urllib3
from datetime import datetime, timedelta
from dotenv import load_dotenv
from playwright.async_api import async_playwright
from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, text
from sqlalchemy.orm import declarative_base, sessionmaker
urllib3.disable_warnings()
load_dotenv("../.env")
EINVOICE_USER = os.getenv("EINVOICE_USER")
EINVOICE_PASS = os.getenv("EINVOICE_PASS")
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
# 本地直接連 localhost
DATABASE_URL = os.getenv("LOCAL_DATABASE_URL")
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine)
Base = declarative_base()
class Transaction(Base):
__tablename__ = "transactions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(String)
category = Column(String)
amount = Column(Float)
note = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.now)
def solve_captcha(img_b64: str) -> str:
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
msg = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=10,
messages=[{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": img_b64
}
},
{
"type": "text",
"text": "這是驗證碼圖片只有5個數字只回傳這5個數字不要其他任何文字"
}
]
}]
)
return msg.content[0].text.strip()
async def login_and_get_token() -> str | None:
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 載入登入頁拿 login_challenge
browser = await p.chromium.launch(headless=False)
page = await browser.new_page()
await page.goto("https://www.einvoice.nat.gov.tw/accounts/login/mw")
await page.wait_for_timeout(8000)
url = page.url
print(f"目前 URL: {url}")
from urllib.parse import parse_qs
fragment = url.split("?")[-1] if "?" in url else ""
params = parse_qs(fragment)
login_challenge = params.get("login_challenge", [None])[0]
print(f"login_challenge: {login_challenge}")
# 拿驗證碼
res = requests.get(
"https://service-mc.einvoice.nat.gov.tw/act/login/api/act002i/captcha",
verify=False
)
captcha_data = res.json()
captcha_token = captcha_data["token"]
captcha_text = solve_captcha(captcha_data["image"])
print(f"驗證碼: {captcha_text}")
# 登入
res = requests.post(
"https://service-mc.einvoice.nat.gov.tw/act/login/api/client/doLogin",
json={
"loginType": "U",
"userType": "MW",
"loginChallenge": login_challenge,
"captchaToken": captcha_token,
"captcha": captcha_text,
"customId": EINVOICE_USER,
"password": EINVOICE_PASS,
},
verify=False
)
data = res.json()
redirect_url = data.get("redirectTo")
print(f"redirectTo: {redirect_url}")
if not redirect_url:
print(f"登入失敗: {data}")
await browser.close()
return None
# 跟隨 redirect 讓 token 存進 localStorage
await page.goto(redirect_url)
await page.wait_for_load_state("domcontentloaded")
await page.wait_for_timeout(8000) # 等久一點
url = page.url
print(f"redirect 後 URL: {url}")
# 印出所有 localStorage
# 同時檢查 localStorage 和 sessionStorage
local_keys = await page.evaluate("Object.keys(localStorage)")
session_keys = await page.evaluate("Object.keys(sessionStorage)")
print("localStorage keys:", local_keys)
print("sessionStorage keys:", session_keys)
await page.wait_for_timeout(3000)
for key in session_keys:
val = await page.evaluate(f"sessionStorage.getItem('{key}')")
print(f" session {key}: {val[:80] if val else None}")
token = await page.evaluate("sessionStorage.getItem('token') || localStorage.getItem('token')")
print(f"token: {token[:30] if token else 'None'}")
await browser.close()
return token
async def fetch_invoices(token: str, days: int = 7) -> list:
end_date = datetime.now()
start_date = end_date - timedelta(days=days)
# 格式要有毫秒
def to_iso(dt):
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
headers = {"authorization": f"Bearer {token}"} # 不去掉 L
res = requests.post(
"https://service-mc.einvoice.nat.gov.tw/btc/cloud/api/btc502w/getSearchCarrierInvoiceListJWT",
headers=headers,
json={
"cardCode": "",
"carrierId2": "",
"searchStartDate": to_iso(start_date),
"searchEndDate": to_iso(end_date),
"invoiceStatus": "all",
"isSearchAll": "true"
},
verify=False
)
print(f"JWT status: {res.status_code}")
print(f"JWT response: {res.text[:200]}")
jwt_token = res.text.strip().strip('"')
res = requests.post(
"https://service-mc.einvoice.nat.gov.tw/btc/cloud/api/btc502w/searchCarrierInvoice",
headers=headers,
json={"token": jwt_token},
verify=False
)
await page.wait_for_timeout(3000)
print(f"Invoice status: {res.status_code}")
print(f"Invoice response: {res.text[:300]}")
print(f"拿到 {len(res.json().get('invoices', []))} 筆發票")
# return res.json().get("content", [])
def save_invoices(invoices: list):
db = SessionLocal()
saved = 0
try:
for inv in invoices:
existing = db.query(Transaction).filter(
Transaction.note == inv["invoiceNumber"]
).first()
if existing:
continue
db.add(Transaction(
user_id="auto_import",
category=inv["sellerName"],
amount=inv["totalAmount"],
note=inv["invoiceNumber"],
created_at=datetime.fromisoformat(
inv["invoiceDate"].replace("Z", "+00:00")
)
))
saved += 1
db.commit()
print(f"✅ 新增 {saved} 筆,略過 {len(invoices) - saved} 筆重複")
finally:
db.close()
async def main():
print("開始抓取發票...")
token = await login_and_get_token()
if not token:
print("登入失敗")
return
invoices = await fetch_invoices(token)
print(f"拿到 {len(invoices)} 筆發票")
for inv in invoices:
print(f" {inv['invoiceDate'][:10]} {inv['sellerName']} ${inv['totalAmount']}")
save_invoices(invoices)
if __name__ == "__main__":
asyncio.run(main())

208
app/main.py Normal file
View File

@@ -0,0 +1,208 @@
import os
import re
from dotenv import load_dotenv
from fastapi import FastAPI, Request, HTTPException
from linebot.v3 import WebhookHandler
from linebot.v3.messaging import (
Configuration,
ApiClient,
MessagingApi,
ReplyMessageRequest,
TextMessage,
)
from linebot.v3.webhooks import MessageEvent, TextMessageContent, FollowEvent
from linebot.v3.exceptions import InvalidSignatureError
from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, text
from sqlalchemy.orm import declarative_base, sessionmaker
from datetime import datetime
load_dotenv()
app = FastAPI()
# LINE 設定
configuration = Configuration(access_token=os.getenv("LINE_CHANNEL_ACCESS_TOKEN"))
handler = WebhookHandler(os.getenv("LINE_CHANNEL_SECRET"))
# DB 設定
DATABASE_URL = os.getenv("DATABASE_URL")
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine)
Base = declarative_base()
# 資料表
class Transaction(Base):
__tablename__ = "transactions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(String)
category = Column(String)
amount = Column(Float)
note = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.now)
Base.metadata.create_all(bind=engine)
# Webhook endpoint
@app.post("/webhook")
async def webhook(request: Request):
signature = request.headers.get("X-Line-Signature", "")
body = await request.body()
try:
handler.handle(body.decode(), signature)
except InvalidSignatureError:
raise HTTPException(status_code=400, detail="Invalid signature")
return "OK"
@handler.add(MessageEvent, message=TextMessageContent)
def handle_message(event):
user_id = event.source.user_id
text = event.message.text.strip()
reply = parse_and_save(user_id, text)
with ApiClient(configuration) as api_client:
line_bot_api = MessagingApi(api_client)
line_bot_api.reply_message(
ReplyMessageRequest(
reply_token=event.reply_token,
messages=[TextMessage(text=reply)]
)
)
@handler.add(FollowEvent)
def handle_follow(event):
with ApiClient(configuration) as api_client:
line_bot_api = MessagingApi(api_client)
line_bot_api.reply_message(
ReplyMessageRequest(
reply_token=event.reply_token,
messages=[TextMessage(text=(
"👋 歡迎使用 Myfinance 記帳 Bot\n\n"
"📝 記帳格式:\n"
"類別 金額\n"
"類別 金額 備註\n\n"
"範例:\n"
"早餐 80\n"
"午餐 120 便當\n"
"交通 50 捷運\n\n"
"📊 查詢指令:\n"
"查今天 → 今日明細\n"
"查本月 → 本月統計\n\n"
"開始記帳吧!💪"
))]
)
)
def parse_and_save(user_id: str, text: str) -> str:
# 查詢指令
if text == "查今天":
return query_today(user_id)
if text == "查本月":
return query_month(user_id)
if text == "記帳說明":
return (
"📝 記帳格式:\n"
"類別 金額\n"
"類別 金額 備註\n\n"
"範例:\n"
"早餐 80\n"
"午餐 120 便當\n"
"交通 50 捷運\n\n"
"輸入後 Bot 會自動記錄 ✅"
)
# 刪除指令:刪除 1 或 刪除 早餐
if text.startswith("刪除 "):
target = text[3:].strip()
return delete_transaction(user_id, target)
# 記帳格式:「早餐 80」或「早餐 80 備註」
match = re.match(r"^(\S+)\s+(\d+(?:\.\d+)?)(?:\s+(.+))?$", text)
if match:
category = match.group(1)
amount = float(match.group(2))
note = match.group(3)
save_transaction(user_id, category, amount, note)
return f"✅ 已記錄:{category} ${amount:.0f}" + (f"{note}" if note else "")
return "格式錯誤 😅\n記帳請輸入:類別 金額\n例如:早餐 80\n\n查詢請輸入:查今天 / 查本月"
def save_transaction(user_id, category, amount, note):
db = SessionLocal()
try:
db.add(Transaction(user_id=user_id, category=category, amount=amount, note=note))
db.commit()
finally:
db.close()
def delete_transaction(user_id: str, target: str) -> str:
db = SessionLocal()
try:
today = datetime.now().date()
rows = db.query(Transaction).filter(
Transaction.user_id == user_id,
text("DATE(created_at) = :today")
).params(today=today).all()
if not rows:
return "今天還沒有記錄 📭"
# 用編號刪除
if target.isdigit():
idx = int(target) - 1
if idx < 0 or idx >= len(rows):
return f"沒有第 {target} 筆記錄,請先輸入「查今天」確認編號"
row = rows[idx]
db.delete(row)
db.commit()
return f"✅ 已刪除:{row.category} ${row.amount:.0f}"
# 用類別刪除(刪最後一筆)
matched = [r for r in rows if r.category == target]
if not matched:
return f"今天沒有「{target}」的記錄"
row = matched[-1]
db.delete(row)
db.commit()
return f"✅ 已刪除:{row.category} ${row.amount:.0f}"
finally:
db.close()
def query_today(user_id):
db = SessionLocal()
try:
today = datetime.now().date()
rows = db.query(Transaction).filter(
Transaction.user_id == user_id,
text("DATE(created_at) = :today")
).params(today=today).all()
if not rows:
return "今天還沒有記錄 📭"
total = sum(r.amount for r in rows)
lines = [
f"{i+1}. {r.category} ${r.amount:.0f}" + (f"{r.note}" if r.note else "")
for i, r in enumerate(rows)
]
return "📋 今日記錄:\n" + "\n".join(lines) + f"\n\n💰 合計:${total:.0f}\n\n🗑 刪除請輸入:刪除 編號\n例如:刪除 1"
finally:
db.close()
def query_month(user_id):
db = SessionLocal()
try:
now = datetime.now()
rows = db.query(Transaction).filter(
Transaction.user_id == user_id,
text("EXTRACT(YEAR FROM created_at) = :year AND EXTRACT(MONTH FROM created_at) = :month")
).params(year=now.year, month=now.month).all()
if not rows:
return "本月還沒有記錄 📭"
total = sum(r.amount for r in rows)
# 依類別統計
summary = {}
for r in rows:
summary[r.category] = summary.get(r.category, 0) + r.amount
lines = [f"{cat}${amt:.0f}" for cat, amt in sorted(summary.items(), key=lambda x: -x[1])]
return f"📊 本月統計({now.month}月):\n" + "\n".join(lines) + f"\n\n💰 總計:${total:.0f}"
finally:
db.close()

20
app/pyproject.toml Normal file
View File

@@ -0,0 +1,20 @@
[project]
name = "app"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"anthropic>=0.84.0",
"ddddocr==1.4.11",
"fastapi>=0.135.1",
"line-bot-sdk>=3.22.0",
"numpy>=2.4.2",
"pillow>=12.1.1",
"playwright>=1.58.0",
"psycopg2-binary>=2.9.11",
"pytesseract>=0.3.13",
"python-dotenv>=1.2.2",
"sqlalchemy>=2.0.48",
"uvicorn>=0.41.0",
]

0
app/requirement.txt Normal file
View File

BIN
app/rich_menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

1344
app/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff