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])