Mercurial > hg > bnpparibas
comparison bnpparibas.py @ 7:90f4e0bd0c2d
Almost complete rewrite to adapt to the new BNP Paribas website
author | Daniele Nicolodi <daniele@grinta.net> |
---|---|
date | Mon, 11 Jan 2016 19:01:00 +0100 |
parents | 13a8bc43bc09 |
children | 225885e803b4 |
comparison
equal
deleted
inserted
replaced
6:13a8bc43bc09 | 7:90f4e0bd0c2d |
---|---|
1 import email | 1 import cgi |
2 import imp | 2 import imp |
3 import itertools | |
4 import json | |
3 import os.path | 5 import os.path |
4 import re | 6 import requests |
5 import smtplib | 7 import smtplib |
6 import sqlite3 | 8 import sqlite3 |
7 import subprocess | |
8 import textwrap | 9 import textwrap |
9 | 10 import time |
10 from collections import namedtuple, defaultdict | 11 |
11 from contextlib import contextmanager | 12 from contextlib import contextmanager |
12 from datetime import datetime | |
13 from decimal import Decimal | |
14 from email.mime.text import MIMEText | 13 from email.mime.text import MIMEText |
15 from email.utils import format_datetime, localtime, parseaddr | 14 from email.utils import format_datetime, localtime, parseaddr |
16 from io import BytesIO | 15 from io import BytesIO |
17 from itertools import product, islice | 16 from pprint import pprint |
18 from urllib.parse import urljoin | 17 from urllib.parse import urljoin |
19 | 18 |
20 import bs4 | 19 from bs4 import BeautifulSoup |
21 import click | 20 from html2text import HTML2Text |
22 import requests | |
23 | |
24 from PIL import Image | 21 from PIL import Image |
25 | 22 |
23 | |
24 URL = 'https://mabanque.bnpparibas/' | |
26 | 25 |
27 # message template | 26 # message template |
28 MESSAGE = """\ | 27 MESSAGE = """\ |
29 From: {sender:} | 28 From: {message.sender:} |
30 Subject: {subject:} | 29 Subject: {message.subject:} |
31 Date: {date:} | 30 Date: {message.date:} |
32 Message-Id: {id:} | 31 |
33 | 32 {message.body:} |
34 {body:} | |
35 """ | 33 """ |
36 | 34 |
37 # transaction template | 35 # transactions table row template |
38 HEADER = '{:14s} {:10s} {:59s} {:>8s}'.format('Id', 'Date', 'Description', 'Amount') | 36 TRANSACTION = """{xact.id:14d} {xact.date:10s} {xact.description:54s} {xact.amount:+8.2f}""" |
39 TRANSACTION = '{id:} {date:%d/%m/%Y} {descr:59s} {amount:>8s}' | 37 |
40 | 38 # transactions table header |
41 # as defined in bnpbaribas web app | 39 HEADER = """{:14s} {:10s} {:54s} {:>8s}""".format('Id', 'Date', 'Description', 'Amount') |
42 CATEGORIES = { | 40 |
43 '1': 'Alimentation', | 41 # transactions table footer |
44 '7': 'Logement', | 42 FOOTER = """{:14s} {:10s} {:54s} {{balance:8.2f}}""".format('', '', 'BALANCE') |
45 '8': 'Loisirs', | 43 |
46 '9': 'Transport', | 44 # transactions table horizontal separator |
47 '12': 'Opérations bancaires', | 45 SEP = """-""" * len(HEADER) |
48 '13': 'Non défini', | 46 |
49 '14': 'Multimédia', | 47 |
50 '20': 'Energies', | 48 def loadconf(filename): |
51 '22': 'Retrait', | 49 module = imp.new_module('conf') |
52 '23': 'Sorties', | |
53 'R58': 'Non défini', | |
54 } | |
55 | |
56 # euro symbol | |
57 EURO = b'\xe2\x82\xac'.decode('utf-8') | |
58 | |
59 | |
60 # load configuration | |
61 def loadconfig(filename): | |
62 module = imp.new_module('config') | |
63 module.__file__ = filename | 50 module.__file__ = filename |
64 try: | 51 with open(filename) as fd: |
65 with open(filename) as fd: | 52 exec(compile(fd.read(), filename, 'exec'), module.__dict__) |
66 exec(compile(fd.read(), filename, 'exec'), module.__dict__) | 53 conf = {} |
67 except IOError as e: | |
68 e.strerror = 'Unable to load configuration file (%s)' % e.strerror | |
69 raise | |
70 config = {} | |
71 for key in dir(module): | 54 for key in dir(module): |
72 if key.isupper(): | 55 if key.isupper(): |
73 config[key] = getattr(module, key) | 56 conf[key] = getattr(module, key) |
74 return config | 57 return conf |
75 | 58 |
76 | 59 |
77 # GPG encrypted text is ascii and as such does not require encoding | 60 def wrap(p, indent): |
78 # but its decrypted form is utf-8 and therefore the charset header | 61 return textwrap.fill(p, 72, initial_indent=indent, subsequent_indent=indent) |
79 # must be set accordingly. define an appropriate charset object | 62 |
80 email.charset.add_charset('utf8 7bit', header_enc=email.charset.SHORTEST, | 63 |
81 body_enc=None, output_charset='utf-8') | 64 def html2text(html): |
82 | 65 # the html2text module does an ok job, but it can be improved in |
83 | 66 # the quality of the transformation and in the coding style |
84 Message = namedtuple('Message', 'id read icon sender subject date validity'.split()) | 67 conv = HTML2Text() |
85 | 68 conv.ignore_links = True |
86 | 69 conv.ignore_images = True |
87 class Transaction: | 70 conv.ignore_emphasis = True |
88 def __init__(self, tid, date, descr, debit, credit, category): | 71 return conv.handle(html) |
89 self.id = tid | |
90 self.date = date | |
91 self.descr = descr | |
92 self.debit = debit | |
93 self.credit = credit | |
94 self.category = category | |
95 | |
96 def __str__(self): | |
97 # there does not seem to be an easy way to format Decimal | |
98 # objects with a leading sign in both the positive and | |
99 # negative value cases so do it manually | |
100 d = vars(self) | |
101 if d['debit']: | |
102 d['amount'] = '-' + str(d['debit']) | |
103 if d['credit']: | |
104 d['amount'] = '+' + str(d['credit']) | |
105 return TRANSACTION.format(**d) | |
106 | |
107 | |
108 def imslice(image): | |
109 for y, x in product(range(0, 5), range(0, 5)): | |
110 yield image.crop((27 * x + 1, 27 * y + 1, 27 * (x + 1), 27 * (y + 1))) | |
111 | |
112 | |
113 def imdecode(image): | |
114 # load reference keypad | |
115 keypad = Image.open(os.path.join(os.path.dirname(__file__), 'keypad.png')).convert('L') | |
116 keypad = [ keypad.crop((26 * i, 0, 26 * (i + 1), 26)) for i in range(10) ] | |
117 immap = {} | |
118 for n, tile in enumerate(imslice(image)): | |
119 # skip tiles with background only | |
120 if tile.getextrema()[0] > 0: | |
121 continue | |
122 # compare to reference tiles | |
123 for d in range(0, 10): | |
124 if tile == keypad[d]: | |
125 immap[d] = n + 1 | |
126 break | |
127 if sorted(immap.keys()) != list(range(10)): | |
128 raise ValueError('keypad decode failed') | |
129 return immap | |
130 | |
131 | |
132 def amountparse(value): | |
133 # empty | |
134 if value == '\xa0': | |
135 return None | |
136 m = re.match(r'\s+((?:\d+\.)?\d+,\d+)\s+([^\s]+)\s+$', value, re.U|re.S) | |
137 if m is None: | |
138 raise ValueError(repr(value)) | |
139 # euro | |
140 currency = m.group(2) | |
141 if currency != EURO: | |
142 raise ValueError(repr(currency)) | |
143 return Decimal(m.group(1).replace('.', '').replace(',', '.')) | |
144 | |
145 | |
146 class Site: | |
147 def __init__(self): | |
148 self.url = 'https://www.secure.bnpparibas.net' | |
149 self.req = requests.Session() | |
150 | |
151 def login(self, user, passwd): | |
152 # login page | |
153 url = urljoin(self.url, '/banque/portail/particulier/HomeConnexion') | |
154 r = self.req.get(url, params={'type': 'homeconnex'}) | |
155 r.raise_for_status() | |
156 # login form | |
157 soup = bs4.BeautifulSoup(r.text) | |
158 form = soup.find('form', attrs={'name': 'logincanalnet'}) | |
159 # extract relevant data | |
160 action = form['action'] | |
161 data = { field['name']: field['value'] for field in form('input') } | |
162 | |
163 # keyboard image url | |
164 tag = soup.find(attrs={'id': 'secret-nbr-keyboard'}) | |
165 for prop in tag['style'].split(';'): | |
166 match = re.match(r'background-image:\s+url\(\'(.*)\'\)\s*', prop) | |
167 if match: | |
168 src = match.group(1) | |
169 break | |
170 # download keyboard image | |
171 r = self.req.get(urljoin(self.url, src)) | |
172 image = Image.open(BytesIO(r.content)).convert('L') | |
173 # decode digits position | |
174 passwdmap = imdecode(image) | |
175 | |
176 # encode password | |
177 passwdenc = ''.join('%02d' % passwdmap[d] for d in map(int, passwd)) | |
178 | |
179 # username and password | |
180 data['ch1'] = user | |
181 data['ch5'] = passwdenc | |
182 | |
183 # post | |
184 r = self.req.post(urljoin(self.url, action), data=data) | |
185 r.raise_for_status() | |
186 # redirection | |
187 m = re.search(r'document\.location\.replace\(\"(.+)\"\)', r.text) | |
188 dest = m.group(1) | |
189 r = self.req.get(dest) | |
190 r.raise_for_status() | |
191 | |
192 # check for errors | |
193 soup = bs4.BeautifulSoup(r.text) | |
194 err = soup.find(attrs={'class': 'TitreErreur'}) | |
195 if err: | |
196 raise ValueError(err.text) | |
197 | |
198 | |
199 def recent(self, contract): | |
200 data = { | |
201 'BeginDate': '', | |
202 'Categs': '', | |
203 'Contracts': '', | |
204 'EndDate': '', | |
205 'OpTypes': '', | |
206 'cboFlowName': 'flow/iastatement', | |
207 'contractId': contract, | |
208 'contractIds': '', | |
209 'entryDashboard': '', | |
210 'execution': 'e6s1', | |
211 'externalIAId': 'IAStatements', | |
212 'g1Style': 'expand', | |
213 'g1Type': '', | |
214 'g2Style': 'collapse', | |
215 'g2Type': '', | |
216 'g3Style': 'collapse', | |
217 'g3Type': '', | |
218 'g4Style': 'collapse', | |
219 'g4Type': '', | |
220 'groupId': '-2', | |
221 'groupSelected': '-2', | |
222 'gt': 'homepage:basic-theme', | |
223 'pageId': 'releveoperations', | |
224 'pastOrPendingOperations': '1', | |
225 'sendEUD': 'true', | |
226 'step': 'STAMENTS', } | |
227 | |
228 url = urljoin(self.url, '/banque/portail/particulier/FicheA') | |
229 r = self.req.post(url, data=data) | |
230 r.raise_for_status() | |
231 text = r.text | |
232 | |
233 # the html is so broken beautifulsoup does not understand it | |
234 text = text.replace( | |
235 '<th class="thTitre" style="width:7%">Pointage </td>', | |
236 '<th class="thTitre" style="width:7%">Pointage </th>') | |
237 s = bs4.BeautifulSoup(text) | |
238 | |
239 # extract transactions | |
240 table = s.find('table', id='tableCompte') | |
241 rows = table.find_all('tr') | |
242 for row in rows: | |
243 fields = row.find_all('td') | |
244 if not fields: | |
245 # skip headers row | |
246 continue | |
247 id = int(fields[0].input['id'].lstrip('_')) | |
248 date = datetime.strptime(fields[1].text, '%d/%m/%Y') | |
249 descr = fields[2].text.strip() | |
250 debit = amountparse(fields[3].text) | |
251 credit = amountparse(fields[4].text) | |
252 category = fields[5].text.strip() | |
253 categoryid = fields[6].span['class'][2][4:] | |
254 yield Transaction(id, date, descr, debit, credit, categoryid) | |
255 | |
256 | |
257 def messages(self): | |
258 data = { | |
259 'identifiant': 'BmmFicheListerMessagesRecus_20100607022434', | |
260 'type': 'fiche', } | |
261 | |
262 url = urljoin(self.url, '/banque/portail/particulier/Fiche') | |
263 r = self.req.post(url, data=data) | |
264 r.raise_for_status() | |
265 s = bs4.BeautifulSoup(r.text) | |
266 | |
267 # messages list | |
268 table = s.find('table', id='listeMessages') | |
269 for row in table.find_all('tr', recursive=False): | |
270 # skip headers and separators | |
271 if 'entete' in row['class']: | |
272 continue | |
273 # skip separators | |
274 if 'sep' in row['class']: | |
275 continue | |
276 # skip footer | |
277 if 'actions_bas' in row['class']: | |
278 continue | |
279 fields = row.find_all('td') | |
280 icon = fields[1].img['src'] | |
281 sender = fields[2].text.strip() | |
282 subject = fields[4].a.text.strip() | |
283 date = datetime.strptime(fields[5]['data'], '%Y/%m/%d:%Hh%Mmin%Ssec') | |
284 validity = datetime.strptime(fields[6]['data'], '%Y/%m/%d:%Hh%Mmin%Ssec') | |
285 m = re.match(r'''validerFormulaire\('BmmFicheLireMessage_20100607022346','(.+)','(true|false)'\);$''', fields[4].a['onclick']) | |
286 mid = m.group(1) | |
287 read = m.group(2) == 'false' | |
288 yield Message(mid, read, icon, sender, subject, date, validity) | |
289 | |
290 | |
291 def message(self, mid): | |
292 data = { | |
293 'etape': 'boiteReception', | |
294 'idMessage': mid, | |
295 'identifiant': 'BmmFicheLireMessage_20100607022346', | |
296 'maxPagination': 2, | |
297 'minPagination': 1, | |
298 'nbElementParPage': 20, | |
299 'nbEltPagination': 5, | |
300 'nbPages': 2, | |
301 'newMsg': 'false', | |
302 'pagination': 1, | |
303 'type': 'fiche', | |
304 'typeAction': '', } | |
305 | |
306 url = urljoin(self.url, '/banque/portail/particulier/Fiche') | |
307 r = self.req.post(url, data=data) | |
308 r.raise_for_status() | |
309 # fix badly broken html | |
310 text = r.text.replace('<br>', '<br/>').replace('</br>', '') | |
311 s = bs4.BeautifulSoup(text) | |
312 | |
313 envelope = s.find('div', attrs={'class': 'enveloppe'}) | |
314 rows = envelope.find_all('tr') | |
315 fields = rows[1].find_all('td') | |
316 # the messages list present a truncated sender | |
317 sender = fields[0].text.strip() | |
318 # not used | |
319 subject = fields[1].text.strip() | |
320 date = fields[2].text.strip() | |
321 | |
322 content = s.find('div', attrs={'class': 'txtMessage'}) | |
323 # clean up text | |
324 for t in content.find_all('style'): | |
325 t.extract() | |
326 for t in content.find_all('script'): | |
327 t.extract() | |
328 for t in content.find_all(id='info_pro'): | |
329 t.extract() | |
330 for t in content.find_all('br'): | |
331 t.replace_with('\n\n') | |
332 for t in content.find_all('b'): | |
333 if t.string: | |
334 t.replace_with('*%s*' % t.string.strip()) | |
335 for t in content.find_all('li'): | |
336 t.replace_with('- %s\n\n' % t.text.strip()) | |
337 # format nicely | |
338 text = re.sub(' +', ' ', content.text) | |
339 text = re.sub(r'\s+([\.:])', r'\1', text) | |
340 pars = [] | |
341 for p in re.split('\n\n+', text): | |
342 p = p.strip() | |
343 if p: | |
344 pars.append('\n'.join(textwrap.wrap(p, 72))) | |
345 body = '\n\n'.join(pars) | |
346 return sender, body | |
347 | |
348 | |
349 def transactions(self): | |
350 data = {'ch_memo': 'NON', | |
351 'ch_rop_cpt_0': 'FR7630004001640000242975804', | |
352 'ch_rop_dat': 'tous', | |
353 'ch_rop_dat_deb': '', | |
354 'ch_rop_dat_fin': '', | |
355 'ch_rop_fmt_dat': 'JJMMAAAA', | |
356 'ch_rop_fmt_fic': 'RTEXC', | |
357 'ch_rop_fmt_sep': 'PT', | |
358 'ch_rop_mon': 'EUR', | |
359 'x': '55', | |
360 'y': '7'} | |
361 r = self.req.post(urljoin(self.url, '/SAF_TLC_CNF'), data=data) | |
362 r.raise_for_status() | |
363 s = bs4.BeautifulSoup(r.text) | |
364 path = s.find('a')['href'] | |
365 r = self.req.get(urljoin(self.url, path)) | |
366 r.raise_for_status() | |
367 return r.text | |
368 | 72 |
369 | 73 |
370 class Mailer: | 74 class Mailer: |
371 def __init__(self, config): | 75 def __init__(self, host='localhost', port=25, starttls=True, username=None, password=None): |
372 self.server = config.get('SMTPSERVER', 'localhost') | 76 self.host = host |
373 self.port = config.get('SMTPPORT', 25) | 77 self.port = port |
374 self.starttls = config.get('SMTPSTARTTLS', False) | 78 self.starttls = starttls |
375 self.username = config.get('SMTPUSER', '') | 79 self.username = username |
376 self.password = config.get('SMTPPASSWD', '') | 80 self.password = password |
377 | 81 |
378 @contextmanager | 82 @contextmanager |
379 def connect(self): | 83 def connect(self): |
380 smtp = smtplib.SMTP(self.server, self.port) | 84 smtp = smtplib.SMTP(self.host, self.port) |
381 if self.starttls: | 85 if self.starttls: |
382 smtp.starttls() | 86 smtp.starttls() |
383 if self.username: | 87 if self.username: |
384 smtp.login(self.username, self.password) | 88 smtp.login(self.username, self.password) |
385 yield smtp | 89 yield smtp |
395 | 99 |
396 | 100 |
397 class GPG: | 101 class GPG: |
398 def __init__(self, homedir): | 102 def __init__(self, homedir): |
399 self.homedir = homedir | 103 self.homedir = homedir |
400 | 104 |
401 def encrypt(self, message, sender, recipient): | 105 def encrypt(self, message, sender, recipient): |
402 sender = parseaddr(sender)[1] | 106 sender = parseaddr(sender)[1] |
403 recipient = parseaddr(recipient)[1] | 107 recipient = parseaddr(recipient)[1] |
404 cmd = [ "gpg", "--homedir", self.homedir, "--batch", "--yes", "--no-options", "--armor", | 108 cmd = [ "gpg", "--homedir", self.homedir, "--sign", "--encrypt" |
405 "--local-user", sender, "--recipient", recipient, "--sign", "--encrypt"] | 109 "--batch", "--no-options", "--yes", "--armor", |
110 "--local-user", sender, "--recipient", recipient, ] | |
406 p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) | 111 p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
407 encdata, err = p.communicate(input=message.encode('utf-8')) | 112 encdata, err = p.communicate(input=message.encode('utf-8')) |
408 if p.returncode: | 113 if p.returncode: |
409 raise RuntimeError(p.returncode, err) | 114 raise RuntimeError(p.returncode, err) |
410 return encdata.decode('ascii') | 115 return encdata.decode('ascii') |
411 | 116 |
412 | 117 |
413 @click.command() | 118 class Transaction: |
414 @click.argument('filename') | 119 __slots__ = 'id', 'date', 'category', 'description', 'amount', 'currency' |
415 def main(filename): | 120 |
416 # load configuration | 121 def __init__(self, id, date, category, description, amount, currency): |
417 config = loadconfig(filename) | 122 self.id = id |
418 | 123 self.date = date |
419 bnp = Site() | 124 self.category = category |
420 bnp.login(config['USERNAME'], config['PASSWORD']) | 125 self.description = description |
421 | 126 self.amount = amount |
422 db = sqlite3.connect(config['DATABASE']) | 127 self.currency = currency |
128 | |
129 @classmethod | |
130 def fromjson(cls, x): | |
131 data = {'id': int(x['idOperation']), | |
132 'date': x['dateOperation'], | |
133 'category': int(x['idCategorie']), | |
134 'description': x['libelleOperation'].strip().replace('VIREMENT', 'VIR'), | |
135 'amount': x['montant']['montant'], | |
136 'currency': x['montant']['currency'], } | |
137 return cls(**data) | |
138 | |
139 def __str__(self): | |
140 return TRANSACTION.format(xact=self) | |
141 | |
142 | |
143 class Message: | |
144 __slots__ = 'id', 'date', 'subject', 'sender', 'content', 'quoted' | |
145 | |
146 def __init__(self, id, date, subject, sender, content, quoted): | |
147 self.id = id | |
148 self.date = date | |
149 self.subject = subject | |
150 self.sender = sender | |
151 self.content = content | |
152 self.quoted = quoted | |
153 | |
154 @classmethod | |
155 def fromjson(cls, x): | |
156 data = {'id': x['msg']['id'], | |
157 'date': x['msg']['id'], | |
158 'subject': x['msg']['objet'], | |
159 'sender': x['msg']['emetteur']['nom'], | |
160 'content': x['msg']['contenu'], | |
161 'quoted': None, } | |
162 quoted = x.get('msgAttache') | |
163 if quoted: | |
164 data['quoted'] = quoted['contenu'] | |
165 return cls(**data) | |
166 | |
167 @staticmethod | |
168 def normalize(txt, indent=''): | |
169 if '<div' in txt: | |
170 txt = txt.replace('<br></br>','<br/><br/>') | |
171 return html2text(txt) | |
172 | |
173 txt = txt.replace(r'<br/>', '\n') | |
174 parts = [] | |
175 for p in txt.split('\n'): | |
176 p = p.strip() | |
177 if p: | |
178 p = wrap(p, indent) | |
179 else: | |
180 p = indent | |
181 parts.append(p) | |
182 return '\n'.join(parts) | |
183 | |
184 @property | |
185 def body(self): | |
186 body = self.normalize(self.content) | |
187 if self.quoted is not None: | |
188 body = body + '\n\n' + self.normalize(self.quoted, '> ') | |
189 return body | |
190 | |
191 def __str__(self): | |
192 return MESSAGE.format(message=self) | |
193 | |
194 | |
195 class Keypad: | |
196 def __init__(self, data): | |
197 # reference keypad | |
198 fname = os.path.join(os.path.dirname(__file__), 'keypad.jpeg') | |
199 reference = Image.open(fname).convert('L') | |
200 symbols = [ 8, 4, 1, 6, 3, 7, 9, 0, 5, 2 ] | |
201 self.keypad = dict(zip(symbols, self.imslice(reference))) | |
202 | |
203 # decode keypad | |
204 image = Image.open(BytesIO(data)).convert('L') | |
205 self.keymap = {} | |
206 for n, tile in enumerate(self.imslice(image), 1): | |
207 # compare to reference tiles | |
208 for sym, key in self.keypad.items(): | |
209 if tile == key: | |
210 self.keymap[sym] = n | |
211 break | |
212 | |
213 # verify | |
214 if sorted(self.keymap.keys()) != list(range(10)): | |
215 raise ValueError('keypad decode failed') | |
216 | |
217 @staticmethod | |
218 def imslice(image): | |
219 for j, i in itertools.product(range(2), range(5)): | |
220 yield image.crop((83 * i, 80 * j, 83 * (i + 1), 80 * (j + 1))) | |
221 | |
222 def encode(self, passwd): | |
223 return ''.join('%02d' % self.keymap[d] for d in map(int, passwd)) | |
224 | |
225 | |
226 class BNPParibas: | |
227 def __init__(self): | |
228 self.session = requests.Session() | |
229 self.session.headers.update({'X-Requested-With': 'XMLHttpRequest'}) | |
230 | |
231 @staticmethod | |
232 def validate(response): | |
233 response.raise_for_status() | |
234 ctype, params = cgi.parse_header(response.headers.get('content-type')) | |
235 if ctype == 'application/json': | |
236 data = response.json() | |
237 # the status code may sometime be represented as string not int | |
238 code = data.get('codeRetour', -1) | |
239 if int(code) != 0: | |
240 raise requests.HTTPError() | |
241 return data | |
242 | |
243 def login(self, username, password): | |
244 url = urljoin(URL, 'identification-wspl-pres/identification') | |
245 r = self.session.get(url, params={'timestamp': int(time.time())}) | |
246 v = self.validate(r) | |
247 | |
248 keypadid = v['data']['grille']['idGrille'] | |
249 authtemplate = v['data']['authTemplate'] | |
250 | |
251 url = urljoin(URL, 'identification-wspl-pres/grille/' + keypadid) | |
252 r = self.session.get(url) | |
253 r.raise_for_status() | |
254 | |
255 keypad = Keypad(r.content) | |
256 | |
257 # fill authentication template | |
258 auth = authtemplate | |
259 auth = auth.replace('{{ idTelematique }}', username) | |
260 auth = auth.replace('{{ password }}', keypad.encode(password)) | |
261 auth = auth.replace('{{ clientele }}', '') | |
262 | |
263 url = urljoin(URL, 'SEEA-pa01/devServer/seeaserver') | |
264 r = self.session.post(url, data={'AUTH': auth}) | |
265 v = self.validate(r) | |
266 return v['data'] | |
267 | |
268 def info(self): | |
269 url = urljoin(URL, 'serviceinfosclient-wspl/rpc/InfosClient') | |
270 r = self.session.get(url, params={'modeAppel': 0}) | |
271 v = self.validate(r) | |
272 return v['data'] | |
273 | |
274 def recent(self): | |
275 url = urljoin(URL, 'udc-wspl/rest/getlstcpt') | |
276 r = self.session.get(url) | |
277 v = self.validate(r) | |
278 account = v['data']['infoUdc']['familleCompte'][0]['compte'][0] | |
279 | |
280 url = urljoin(URL, 'rop-wspl/rest/releveOp') | |
281 data = json.dumps({'ibanCrypte': account['key'], | |
282 'pastOrPending': 1, 'triAV': 0, | |
283 'startDate': None, # ddmmyyyy | |
284 'endDate': None}) | |
285 headers = {'Content-Type': 'application/json'} | |
286 r = self.session.post(url, headers=headers, data=data) | |
287 v = self.validate(r) | |
288 return v['data'] | |
289 | |
290 def messages(self): | |
291 url = urljoin(URL, 'bmm-wspl/recupMsgRecu') | |
292 r = self.session.get(url, params={'nbMessagesParPage': 200, 'index': 0}) | |
293 v = self.validate(r) | |
294 return v['data'] | |
295 | |
296 def message(self, mid): | |
297 # required to set some cookies required by the next call | |
298 url = urljoin(URL, 'fr/connexion/mes-outils/messagerie') | |
299 r = self.session.get(url) | |
300 self.validate(r) | |
301 | |
302 url = urljoin(URL, 'bmm-wspl/recupMsg') | |
303 r = self.session.get(url, params={'identifiant': mid}) | |
304 v = self.validate(r) | |
305 return v['data'] | |
306 | |
307 | |
308 def main(conffile): | |
309 conf = loadconf(conffile) | |
310 | |
311 db = sqlite3.connect(conf['DATABASE']) | |
423 db.execute('''CREATE TABLE IF NOT EXISTS messages (id TEXT PRIMARY KEY)''') | 312 db.execute('''CREATE TABLE IF NOT EXISTS messages (id TEXT PRIMARY KEY)''') |
424 db.execute('''CREATE TABLE IF NOT EXISTS transactions (id INTEGER PRIMARY KEY)''') | 313 db.execute('''CREATE TABLE IF NOT EXISTS transactions (id INTEGER PRIMARY KEY)''') |
425 | 314 |
426 mailer = Mailer(config) | 315 sendmail = Mailer(host=conf['SMTPHOST'], |
427 encrypt = GPG(config['GNUPGHOME']).encrypt | 316 port=conf['SMTPPORT'], |
428 | 317 starttls=conf['SMTPSTARTTLS'], |
429 ## unread messages | 318 username=conf['SMTPUSER'], |
430 messages = filter(lambda x: not x.read, bnp.messages()) | 319 password=conf['SMTPPASSWD']).send |
431 for m in sorted(messages, key=lambda x: x.date): | 320 |
321 encrypt = GPG(conf['GNUPGHOME']).encrypt | |
322 | |
323 remote = BNPParibas() | |
324 remote.login(conf['USERNAME'], conf['PASSWORD']) | |
325 | |
326 ## transactions | |
327 recent = remote.recent() | |
328 data = recent['listerOperations']['compte'] | |
329 transactions = [ Transaction.fromjson(x) for x in data['operationPassee'] ] | |
330 balance = data['soldeDispo'] | |
331 | |
332 curs = db.cursor() | |
333 unseen = [] | |
334 for t in transactions: | |
335 curs.execute('''SELECT COUNT(*) FROM transactions WHERE id = ?''', (t.id, )) | |
336 if not curs.fetchone()[0]: | |
337 # not seen before | |
338 unseen.append(t) | |
339 | |
340 lines = [] | |
341 lines.append(HEADER) | |
342 lines.append(SEP) | |
343 for t in unseen: | |
344 lines.append(str(t)) | |
345 lines.append(SEP) | |
346 lines.append(FOOTER.format(balance=balance)) | |
347 body = '\n'.join(lines) | |
348 | |
349 message = MIMEText(encrypt(body, conf['MAILFROM'], conf['MAILTO'])) | |
350 message['Subject'] = 'BNP Paribas Account update' | |
351 message['From'] = conf['MAILFROM'] | |
352 message['To'] = conf['MAILTO'] | |
353 message['Date'] = format_datetime(localtime()) | |
354 | |
355 sendmail(message) | |
356 | |
357 curs.executemany('''INSERT INTO transactions (id) VALUES (?)''', ((x.id, ) for x in unseen)) | |
358 db.commit() | |
359 | |
360 ## messages | |
361 data = remote.info() | |
362 info = data['abonnement'] | |
363 nnew = info['nombreMessageBMMNonLus'] + info['nombreMessageBilatNonLus'] | |
364 | |
365 data = remote.messages() | |
366 for m in data['messages']: | |
367 | |
432 curs = db.cursor() | 368 curs = db.cursor() |
433 curs.execute('''SELECT IFNULL((SELECT id FROM messages WHERE id = ?), 0)''', (m.id, )) | 369 curs.execute('''SELECT COUNT(*) FROM messages WHERE id = ?), 0)''', (m['id'], )) |
434 if curs.fetchone()[0]: | 370 if curs.fetchone()[0]: |
435 # already handled | 371 # already handled |
436 continue | 372 continue |
437 | 373 |
438 # retrieve complete sender and message body | 374 body = Message.fromjson(remote.message(m['id'])) |
439 sender, body = bnp.message(m.id) | 375 |
440 | 376 message = MIMEText(encrypt(str(body), conf['MAILFROM'], conf['MAILTO'])) |
441 # compose and send message | 377 message['Subject'] = 'BNP Paribas Message' |
442 body = MESSAGE.format(id=m.id, sender=sender, date=m.date, subject=m.subject, body=body) | 378 message['From'] = conf['MAILFROM'] |
443 message = MIMEText(encrypt(body, config['MAILFROM'], config['MAILTO']), _charset='utf8 7bit') | 379 message['To'] = conf['MAILTO'] |
444 message['Subject'] = 'BNP Paribas message' | 380 message['Date'] = format_datetime(localtime()) |
445 message['From'] = config['MAILFROM'] | 381 |
446 message['To'] = config['MAILTO'] | 382 sendmail(message) |
447 message['Date'] = format_datetime(localtime(m.date)) | 383 |
448 mailer.send(message) | 384 curs.execute('''INSERT INTO messages (id) VALUES (?)''', (m['id'], )) |
449 | |
450 curs.execute('''INSERT INTO messages (id) VALUES (?)''', (m.id, )) | |
451 db.commit() | 385 db.commit() |
452 | 386 |
453 | |
454 ## transactions | |
455 transactions = bnp.recent(config['CONTRACT']) | |
456 curs = db.cursor() | |
457 lines = [] | |
458 for t in transactions: | |
459 curs.execute('''SELECT IFNULL((SELECT id FROM transactions WHERE id = ?), 0)''', (t.id, )) | |
460 if curs.fetchone()[0]: | |
461 # already handled | |
462 continue | |
463 lines.append(str(t)) | |
464 curs.execute('''INSERT INTO transactions (id) VALUES (?)''', (t.id, )) | |
465 | |
466 if lines: | |
467 lines.insert(0, HEADER) | |
468 lines.insert(1, '-' * len(HEADER)) | |
469 body = '\n'.join(lines) | |
470 message = MIMEText(encrypt(body, config['MAILFROM'], config['MAILTO']), _charset='utf8 7bit') | |
471 message['Subject'] = 'BNP Paribas update' | |
472 message['From'] = config['MAILFROM'] | |
473 message['To'] = config['MAILTO'] | |
474 message['Date'] = format_datetime(localtime()) | |
475 mailer.send(message) | |
476 | |
477 db.commit() | |
478 | |
479 | 387 |
480 if __name__ == '__main__': | 388 if __name__ == '__main__': |
481 main() | 389 import sys |
390 main(sys.argv[1]) |