Compare commits

...

25 commits

Author SHA1 Message Date
Gabriel Augendre a4eb23280f quiet pip install output 2023-06-15 22:49:13 +02:00
Gabriel Augendre 85c36bc14f fix: retry dismiss welcome + update to bookworm 2023-06-15 22:45:30 +02:00
Gabriel Augendre 7494f4e51b fix: add sleep before dismissing welcome screen 2023-06-15 22:37:31 +02:00
Gabriel Augendre c36030856d fix: get screenshot dir using config file 2023-06-14 08:58:12 +02:00
Gabriel Augendre 3df3d1059c lcl: screenshot when error 2023-06-14 08:54:03 +02:00
Gabriel Augendre 14499eab65 fix: sleep 2023-06-14 08:40:09 +02:00
Gabriel Augendre 3290134835 lcl: sleep between welcome screen and burger menu 2023-06-14 08:35:53 +02:00
Gabriel Augendre fb55fa4d1a Update dockerfile 2023-06-14 08:18:42 +02:00
Gabriel Augendre 2e468f69f1 Bump version 2023-06-14 08:11:15 +02:00
Gabriel Augendre 0a4e7985cc update deps 2023-06-14 08:10:48 +02:00
Gabriel Augendre 01b486031b Dismiss LCL welcome screen 2023-06-14 08:02:30 +02:00
Gabriel Augendre 2e0d5dc419 Bump version 2022-11-05 20:35:05 +01:00
Gabriel Augendre 30f1e1c990 Improve logging 2022-11-05 20:34:16 +01:00
Gabriel Augendre a0395fc296 Fix lcl return to legacy 2022-11-05 20:34:03 +01:00
Gabriel Augendre 57cdb596c5 Cleanup dockerfile 2022-10-10 22:41:47 +02:00
Gabriel Augendre a54906c6d1 Bump version 2022-10-10 22:41:47 +02:00
Gabriel Augendre 3185a7d103 Update deps 2022-10-10 22:41:47 +02:00
Gabriel Augendre e0006873f9 Refactor senders and add home_assistant send method 2022-10-10 22:41:41 +02:00
Gabriel Augendre 1a94bd6d36 Add error message if send method is unknown 2022-09-22 21:16:02 +01:00
Gabriel Augendre 80a15d6825 Bump version 2022-09-22 21:11:31 +01:00
Gabriel Augendre 36f74681b2 Add command to get chat id 2022-09-22 21:11:07 +01:00
Gabriel Augendre b190913e6e Implement send via telegram 2022-09-22 20:54:57 +01:00
Gabriel Augendre 9eef4a66a6 Fix legacy 2022-09-22 20:24:02 +01:00
Gabriel Augendre 6bfb5ca29d Bump version 2022-09-22 20:03:50 +01:00
Gabriel Augendre b831f2be5d LCL downloader go back to legacy version 2022-09-22 20:03:03 +01:00
17 changed files with 1112 additions and 872 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
layout python3

1
.gitignore vendored
View file

@ -228,3 +228,4 @@ dmypy.json
.idea .idea
.vscode .vscode
.direnv

1
.tool-versions Normal file
View file

@ -0,0 +1 @@
python 3.11.2

View file

@ -1,13 +1,15 @@
FROM debian:bullseye AS downloader FROM debian:bookworm AS downloader
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y wget RUN apt-get update && apt-get install -y wget
ARG GECKODRIVER_VERSION="v0.30.0" ARG GECKODRIVER_VERSION="v0.33.0"
ARG GECKODRIVER_FILENAME="geckodriver-$GECKODRIVER_VERSION-linux64" ARG GECKODRIVER_FILENAME="geckodriver-$GECKODRIVER_VERSION-linux64.tar.gz"
RUN wget -q https://github.com/mozilla/geckodriver/releases/download/$GECKODRIVER_VERSION/$GECKODRIVER_FILENAME.tar.gz && tar xvf $GECKODRIVER_FILENAME.tar.gz && rm $GECKODRIVER_FILENAME.tar.gz RUN wget -q https://github.com/mozilla/geckodriver/releases/download/$GECKODRIVER_VERSION/$GECKODRIVER_FILENAME \
&& tar xvf $GECKODRIVER_FILENAME \
&& rm $GECKODRIVER_FILENAME
FROM python:3.10-slim-bullseye as final FROM python:3.11-slim-bookworm as final
RUN apt-get update && apt-get install -y firefox-esr RUN apt-get update && apt-get install -y firefox-esr
COPY --from=downloader /app/geckodriver /usr/local/bin/geckodriver COPY --from=downloader /app/geckodriver /usr/local/bin/geckodriver
ARG OFX_VERSION ARG OFX_VERSION
RUN pip install ofx-processor==$OFX_VERSION RUN pip --disable-pip-version-check install --quiet ofx-processor==$OFX_VERSION
CMD ["ynab", "lcl", "--download"] CMD ["ynab", "lcl", "--download"]

BIN
error_download_lcl.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View file

@ -1,8 +1,10 @@
import os
import time import time
from pathlib import Path from pathlib import Path
import click import click
from selenium import webdriver from selenium import webdriver
from selenium.common.exceptions import NoSuchElementException
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.support.select import Select from selenium.webdriver.support.select import Select
@ -34,16 +36,24 @@ class LclDownloader:
self.selenium.implicitly_wait(30) self.selenium.implicitly_wait(30)
def download(self) -> str: def download(self) -> str:
try:
return self._download()
except Exception:
screenshot = Path(self.config.screenshot_dir) / "error_download_lcl.png"
self.selenium.save_screenshot(screenshot)
raise
def _download(self) -> str:
selenium = self.selenium selenium = self.selenium
click.secho("Logging in to LCL...", fg="blue") click.secho("Logging in to LCL...", fg="blue")
selenium.get("https://monespace.lcl.fr/connexion") selenium.get("https://monespace.lcl.fr/connexion")
try: try:
self._click(By.ID, "popin_tc_privacy_button_2") self._click(By.ID, "popin_tc_privacy_button_2")
except: click.secho("Accepting privacy policy...", fg="blue")
# If the user has already accepted the privacy policy, except NoSuchElementException:
# the button is not present click.secho("Privacy policy already accepted", fg="blue")
pass
login_input = selenium.find_element(By.ID, "identifier") login_input = selenium.find_element(By.ID, "identifier")
login_input.send_keys(self.config.bank_identifier) login_input.send_keys(self.config.bank_identifier)
self._click(By.CLASS_NAME, "app-cta-button") self._click(By.CLASS_NAME, "app-cta-button")
@ -52,6 +62,23 @@ class LclDownloader:
self._click(By.CLASS_NAME, "app-cta-button") self._click(By.CLASS_NAME, "app-cta-button")
click.secho("Logged in!", fg="green") click.secho("Logged in!", fg="green")
retry = True
while retry:
try:
self._click(By.CSS_SELECTOR, ".app-cta-button--primary")
click.secho("Dismissing welcome screen...", fg="blue")
time.sleep(1)
except NoSuchElementException:
click.secho("No welcome screen found.", fg="blue")
retry = False
try:
self._click(By.CSS_SELECTOR, ".burger-menu-content")
self._click(By.CSS_SELECTOR, ".return-legacy-button")
click.secho("Going back to legacy version...", fg="blue")
except NoSuchElementException:
click.secho("Probably already on legacy version.", fg="blue")
click.secho("Navigating through archives...", fg="blue") click.secho("Navigating through archives...", fg="blue")
self._click(By.ID, "linkSynthese") self._click(By.ID, "linkSynthese")
self._click(By.CLASS_NAME, "picDl") self._click(By.CLASS_NAME, "picDl")

View file

@ -0,0 +1,8 @@
from ofx_processor.senders import sms, email, telegram, home_assistant
SENDERS = {
"sms": sms.send,
"email": email.send,
"telegram": telegram.send,
"home_assistant": home_assistant.send,
}

View file

@ -0,0 +1,24 @@
from decimal import Decimal
import click
import requests
from ofx_processor.utils.config import Config
def send(config: Config, amount: Decimal) -> None:
if not config.email_setup:
click.secho("Email is not properly setup", fg="yellow")
return
res = requests.post(
f"https://api.mailgun.net/v3/{config.mailgun_domain}/messages",
auth=("api", config.mailgun_api_key),
data={
"from": config.mailgun_from,
"to": [config.email_recipient],
"subject": f"Reconciled balance: {amount}",
"text": f"Here's your reconciled balance: {amount}",
},
)
if res.status_code >= 400:
click.secho("Error while sending email", fg="yellow")

View file

@ -0,0 +1,20 @@
from decimal import Decimal
import click
import requests
from ofx_processor.utils.config import Config
def send(config: Config, amount: Decimal) -> None:
if not config.home_assistant_setup:
click.secho("Home Assistant is not properly setup", fg="yellow")
return
res = requests.post(
config.home_assistant_webhook_url,
json={
"reconciled": str(amount),
},
)
if res.status_code >= 400:
click.secho("Error while calling Home Assistant", fg="yellow")

View file

@ -0,0 +1,22 @@
from decimal import Decimal
import click
import requests
from ofx_processor.utils.config import Config
def send(config: Config, amount: Decimal) -> None:
if not config.sms_setup:
click.secho("SMS is not properly setup", fg="yellow")
return
res = requests.post(
f"https://smsapi.free-mobile.fr/sendmsg",
json={
"user": config.sms_user,
"pass": config.sms_key,
"msg": f"Reconciled balance: {amount}",
},
)
if res.status_code >= 400:
click.secho("Error while sending SMS", fg="yellow")

View file

@ -0,0 +1,24 @@
import asyncio
from decimal import Decimal
import click
import telegram
from ofx_processor.utils.config import Config
def send(config: Config, amount: Decimal) -> None:
if not config.telegram_setup:
click.secho("Telegram is not properly setup", fg="yellow")
return
try:
asyncio.run(_send_telegram_message(config.telegram_bot_token, config.telegram_bot_chat_id, f"Reconciled balance: {amount}"))
except Exception as e:
click.secho(f"Error while sending Telegram message. {type(e).__name__}: {e}", fg="yellow")
async def _send_telegram_message(bot_token: str, chat_id: str, message: str) -> None:
bot = telegram.Bot(bot_token)
async with bot:
await bot.send_message(chat_id=chat_id, text=message)

View file

@ -2,11 +2,11 @@ import sys
from decimal import Decimal from decimal import Decimal
import click import click
import requests
from ofxtools import OFXTree from ofxtools import OFXTree
from ofxtools.header import OFXHeaderError from ofxtools.header import OFXHeaderError
from ofxtools.models import Aggregate from ofxtools.models import Aggregate
from ofx_processor.senders import SENDERS
from ofx_processor.utils.base_processor import BaseLine, BaseProcessor from ofx_processor.utils.base_processor import BaseLine, BaseProcessor
from ofx_processor.utils.config import get_config from ofx_processor.utils.config import get_config
@ -36,10 +36,12 @@ class OfxBaseProcessor(BaseProcessor):
def send_reconciled_amount(self, method): def send_reconciled_amount(self, method):
amount = self._get_reconciled_amount() amount = self._get_reconciled_amount()
click.secho(f"Reconciled balance: {amount}. Sending via {method}...", fg="blue") click.secho(f"Reconciled balance: {amount}. Sending via {method}...", fg="blue")
if method == "email": config = get_config(self.account_name)
self._send_mail(amount) sender = SENDERS.get(method)
elif method == "sms": if sender:
self._send_sms(amount) sender(config, amount)
else:
click.secho(f"Method not implemented: {method}.", fg="red", bold=True)
def _get_reconciled_amount(self) -> Decimal: def _get_reconciled_amount(self) -> Decimal:
ofx = self._parse_file() ofx = self._parse_file()
@ -55,36 +57,3 @@ class OfxBaseProcessor(BaseProcessor):
ofx = parser.convert() ofx = parser.convert()
return ofx return ofx
def _send_mail(self, amount: Decimal):
config = get_config(self.account_name)
if not config.email_setup:
click.secho("Email is not properly setup", fg="yellow")
return
res = requests.post(
f"https://api.mailgun.net/v3/{config.mailgun_domain}/messages",
auth=("api", config.mailgun_api_key),
data={
"from": config.mailgun_from,
"to": [config.email_recipient],
"subject": f"Reconciled balance: {amount}",
"text": f"Here's your reconciled balance: {amount}",
},
)
if res.status_code >= 400:
click.secho("Error while sending email", fg="yellow")
def _send_sms(self, amount: Decimal):
config = get_config(self.account_name)
if not config.sms_setup:
click.secho("SMS is not properly setup", fg="yellow")
return
res = requests.post(
f"https://smsapi.free-mobile.fr/sendmsg",
json={
"user": config.sms_user,
"pass": config.sms_key,
"msg": f"Reconciled balance: {amount}",
},
)
if res.status_code >= 400:
click.secho("Error while sending SMS", fg="yellow")

View file

@ -1,3 +1,4 @@
import asyncio
import configparser import configparser
import os import os
import sys import sys
@ -5,6 +6,7 @@ from dataclasses import dataclass
from typing import Optional from typing import Optional
import click import click
import telegram
DEFAULT_CONFIG_DIR = click.get_app_dir("ofx_processor") DEFAULT_CONFIG_DIR = click.get_app_dir("ofx_processor")
DEFAULT_CONFIG_FILENAME = "config.ini" DEFAULT_CONFIG_FILENAME = "config.ini"
@ -15,6 +17,7 @@ def get_default_config():
default_config["DEFAULT"] = { default_config["DEFAULT"] = {
"token": "<YOUR API TOKEN>", "token": "<YOUR API TOKEN>",
"budget": "<YOUR BUDGET ID>", "budget": "<YOUR BUDGET ID>",
"screenshot_dir": "/tmp",
"mailgun_api_key": "", "mailgun_api_key": "",
"mailgun_domain": "", "mailgun_domain": "",
"mailgun_from": "", "mailgun_from": "",
@ -54,11 +57,27 @@ def show_file_name():
click.echo(config_file) click.echo(config_file)
@config.command("telegram", help="Display the bot's chat ID.")
def print_telegram_chat_id():
config = get_config("DEFAULT")
click.pause("Please start a conversation with your bot, then press any key...")
asyncio.run(print_chat_id(config))
async def print_chat_id(config: "Config"):
bot = telegram.Bot(config.telegram_bot_token)
async with bot:
update = (await bot.get_updates())[-1]
chat_id = update.message.chat_id
click.secho(f"Your chat ID is: {chat_id}", fg="green", bold=True)
@dataclass(frozen=True) @dataclass(frozen=True)
class Config: class Config:
account: str account: str
budget_id: str budget_id: str
token: str token: str
screenshot_dir: str
bank_identifier: Optional[str] = None bank_identifier: Optional[str] = None
bank_password: Optional[str] = None bank_password: Optional[str] = None
mailgun_api_key: Optional[str] = None mailgun_api_key: Optional[str] = None
@ -67,6 +86,9 @@ class Config:
email_recipient: Optional[str] = None email_recipient: Optional[str] = None
sms_user: Optional[str] = None sms_user: Optional[str] = None
sms_key: Optional[str] = None sms_key: Optional[str] = None
telegram_bot_token: Optional[str] = None
telegram_bot_chat_id: Optional[str] = None
home_assistant_webhook_url: Optional[str] = None
@property @property
def email_setup(self) -> bool: def email_setup(self) -> bool:
@ -90,6 +112,25 @@ class Config:
] ]
) )
@property
def telegram_setup(self) -> bool:
"""Return true if all fields are setup for telegram."""
return all(
[
self.telegram_bot_token,
self.telegram_bot_chat_id,
]
)
@property
def home_assistant_setup(self):
"""Return true if all fields are setup for home assistant."""
return all(
[
self.home_assistant_webhook_url,
]
)
def get_config(account: str) -> Config: def get_config(account: str) -> Config:
config = configparser.ConfigParser() config = configparser.ConfigParser()
@ -113,7 +154,11 @@ def get_config(account: str) -> Config:
section = config[account] section = config[account]
budget_id = section["budget"] budget_id = section["budget"]
token = section["token"] token = section["token"]
account = section["account"] screenshot_dir = section.get("screenshot_dir")
if account == "DEFAULT":
ynab_account_id = ""
else:
ynab_account_id = section["account"]
bank_identifier = section.get("bank_identifier") bank_identifier = section.get("bank_identifier")
bank_password = section.get("bank_password") bank_password = section.get("bank_password")
mailgun_api_key = section.get("mailgun_api_key") mailgun_api_key = section.get("mailgun_api_key")
@ -122,13 +167,17 @@ def get_config(account: str) -> Config:
email_recipient = section.get("email_recipient") email_recipient = section.get("email_recipient")
sms_user = section.get("sms_user") sms_user = section.get("sms_user")
sms_key = section.get("sms_key") sms_key = section.get("sms_key")
telegram_bot_token = section.get("telegram_bot_token")
telegram_bot_chat_id = section.get("telegram_bot_chat_id")
home_assistant_webhook_url = section.get("home_assistant_webhook_url")
except KeyError as e: except KeyError as e:
return handle_config_file_error(config_file, e) return handle_config_file_error(config_file, e)
return Config( return Config(
account, ynab_account_id,
budget_id, budget_id,
token, token,
screenshot_dir,
bank_identifier, bank_identifier,
bank_password, bank_password,
mailgun_api_key, mailgun_api_key,
@ -137,6 +186,9 @@ def get_config(account: str) -> Config:
email_recipient, email_recipient,
sms_user, sms_user,
sms_key, sms_key,
telegram_bot_token,
telegram_bot_chat_id,
home_assistant_webhook_url,
) )

View file

@ -6,6 +6,7 @@ import pkgutil
import click import click
from ofx_processor import processors from ofx_processor import processors
from ofx_processor.senders import SENDERS
ARG_TO_OPTION = { ARG_TO_OPTION = {
"keep": click.option( "keep": click.option(
@ -19,10 +20,9 @@ ARG_TO_OPTION = {
"-s", "-s",
"--send", "--send",
help=( help=(
"Send the reconciled amount via the chosen method. " "Send the reconciled amount via the chosen method."
"Accepted methods: sms, email"
), ),
default="", type=click.Choice(list(SENDERS.keys()), case_sensitive=False),
show_default=True, show_default=True,
), ),
"download": click.option( "download": click.option(

1690
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "ofx-processor" name = "ofx-processor"
version = "4.1.0" version = "4.4.6"
description = "Personal ofx processor" description = "Personal ofx processor"
readme = "README.md" readme = "README.md"
authors = ["Gabriel Augendre <gabriel@augendre.info>"] authors = ["Gabriel Augendre <gabriel@augendre.info>"]
@ -20,28 +20,28 @@ classifiers = [
"Operating System :: MacOS :: MacOS X", "Operating System :: MacOS :: MacOS X",
"Operating System :: Microsoft :: Windows", "Operating System :: Microsoft :: Windows",
"Operating System :: Unix", "Operating System :: Unix",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3 :: Only",
"Topic :: Utilities", "Topic :: Utilities",
] ]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = ">=3.8,<4" python = ">=3.10,<4"
ofxtools = "^0.9.4" ofxtools = ">=0.9.4"
click = "^8.0.3" click = ">=8.0.3"
dateparser = "^1.1.0" dateparser = ">=1.1.0"
requests = "^2.24.0" requests = ">=2.24.0"
selenium = "^4.0.0" selenium = ">=4.0.0"
python-telegram-bot = {version = ">=20.0a4", allow-prereleases = true}
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pytest = "^6.0.1" pytest = ">=6.0.1"
pytest-cov = "^3.0.0" pytest-cov = ">=3.0.0"
invoke = "^1.6.0" invoke = ">=2.0.0"
pre-commit = "^2.15.0" pre-commit = ">=2.15.0"
tox = "^3.24.4" tox = ">=3.24.4"
pdbpp = "^0.10.3" pdbpp = ">=0.10.3"
[tool.poetry.scripts] [tool.poetry.scripts]
ynab = 'ofx_processor.main:cli' ynab = 'ofx_processor.main:cli'
@ -56,12 +56,12 @@ profile = "black"
legacy_tox_ini = """ legacy_tox_ini = """
[tox] [tox]
isolated_build = true isolated_build = true
envlist = py38, py39, py310 envlist = py310, py311
[testenv] [testenv]
whitelist_externals = poetry allowlist_externals = poetry
commands = commands =
poetry install --remove-untracked poetry install --sync
poetry run inv test poetry run inv test
""" """

View file

@ -1,6 +1,7 @@
[DEFAULT] [DEFAULT]
token = <YOUR API TOKEN> token = <YOUR API TOKEN>
budget = <YOUR BUDGET ID> budget = <YOUR BUDGET ID>
screenshot_dir = /tmp
[bpvf] [bpvf]
account = <YOUR BPVF ACCOUNT ID> account = <YOUR BPVF ACCOUNT ID>