mirror of
https://github.com/henry4682/linebot_finance.git
synced 2026-05-16 04:41:52 +00:00
add app files
This commit is contained in:
1
app/.python-version
Normal file
1
app/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.13
|
||||
17
app/Dockerfile
Normal file
17
app/Dockerfile
Normal 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
0
app/README.md
Normal file
100
app/create_rich_menu.py
Normal file
100
app/create_rich_menu.py
Normal 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
216
app/invoice_fetcher.py
Normal 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
208
app/main.py
Normal 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
20
app/pyproject.toml
Normal 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
0
app/requirement.txt
Normal file
BIN
app/rich_menu.png
Normal 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
1344
app/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user