Ignore GOCR error message when image is empty
[banquepostale.git] / go.py
1 #!/usr/bin/env python3
2
3 import os
4 import time
5 import re
6 import logging
7 from datetime import datetime
8 import urllib.request
9 from http.cookiejar import CookieJar
10 from subprocess import Popen, PIPE, call, DEVNULL
11
12 import html_parser
13 import htmlentities
14
15 BASE_URL = 'https://voscomptesenligne.labanquepostale.fr'
16 WSOST_PREFIX = '/wsost/OstBrokerWeb/loginform?imgid='
17
18 LOCAL_DIR = '/home/nirgal/banquepostale/'
19 CSV_HEADER = 'Date;LibellĂ©;Montant(EUROS);Montant(FRANCS)'
20
21 # Message returned by gocr when given an empty blue image:
22 EMPTY_GOCR_MSG = 'NOT NORMAL, thresholdValue = 160\n# thresholdValue out of range 0..47, reset to 24\n# no boxes found - stopped\n'
23
24 def get_login_password():
25     config=open(LOCAL_DIR + 'config').read()
26     login=None
27     password=None
28     for line in config.splitlines():
29         if line.startswith('login'):
30             login = line[len('login'):].strip()
31         elif line.startswith('password'):
32             password = line[len('password'):].strip()
33     return login, password
34
35 __opener__ = None
36 def httpopen(url, post_data=None, headers={}):
37     if post_data:
38         logging.debug('HTTP POST %s %s', url, post_data)
39     else:
40         logging.debug('HTTP GET %s', url)
41     global __opener__
42     if __opener__ is None:
43         cookiejar = CookieJar()
44         __opener__ = urllib.request.build_opener()
45         __opener__.add_handler(urllib.request.HTTPCookieProcessor(cookiejar))
46         __opener__.addheaders = [('User-Agent', 'Mozilla/5.0')]
47     if isinstance(post_data, str):
48         post_data = post_data.encode('utf-8')
49     req = urllib.request.Request(url, post_data, headers)
50     http_response = __opener__.open(req)
51     return http_response
52
53 def sleep(seconds):
54     logging.debug('Waiting %s seconds', seconds)
55     time.sleep(seconds)
56
57 def download():
58     '''
59     Download all the accounts csv data and store them in LOCAL_DIR
60     Return a list of filenames
61     '''
62     result = []
63
64     logging.info('Downloading initial request')
65     httpresponse = httpopen(BASE_URL + '/voscomptes/canalXHTML/identif.ea?origin=particuliers')
66     #logging.debug(httpresponse.info())
67     html = httpresponse.read().decode('utf-8')
68
69
70     logging.info('Downloading password form')
71     urllogin = BASE_URL + '/wsost/OstBrokerWeb/loginform?TAM_OP=login&ERROR_CODE=0x00000000&URL=/voscomptes/canalXHTML/identif.ea?origin=particuliers'
72     httpresponse = httpopen(urllogin)
73     html = httpresponse.read().decode('utf-8')
74     #logging.debug(httpresponse.info())
75     open('login.html', 'w', encoding='utf-8').write(html)
76
77     #html = open('login.html', encoding='utf-8').read()
78     
79     match = re.search('(loginform\?imgid=allunifie2&[^)"]*)', html, re.MULTILINE)
80     if match is None:
81         logging.critical('Login form image not found!')
82         return []
83
84     url = BASE_URL + '/wsost/OstBrokerWeb/' + match.group(1)
85     httpresponse = httpopen(url)
86     #logging.debug(httpresponse.info())
87     img = httpresponse.read()
88     open('loginform.gif', 'wb').write(img)
89
90     xlt_password = {}
91     for choice in range(16):
92         column = choice % 4
93         row = choice // 4
94         proc_convert=Popen('convert loginform.gif -crop 60x60+%s+%s pnm:-' % (column*64, row*64),
95             shell=True, stdout=PIPE)
96         proc_gocr=Popen('gocr -C 0-9 -i -',
97             shell=True, stdin=proc_convert.stdout, stdout=PIPE, stderr=PIPE)
98         output, errmsg = proc_gocr.communicate()
99         output = output.decode('utf-8')
100         output = output.strip()
101         errmsg = errmsg.decode('utf-8')
102         if errmsg and errmsg != EMPTY_GOCR_MSG:
103             logging.warning('gocr reports error on column %s, row %s: %s', column, row, errmsg)
104         #print("choice #%s is %s" % (choice, output))
105         xlt_password[output] = choice
106     #for i in 0 1 2 3 4 5 6 7 8 9; do convert $i.gif -crop 20x20+5+5 pnm:- | gocr -C 0-9 -i -; done
107
108     LOGIN, PASSWORD = get_login_password()
109
110     shuffled_password = ''
111     for c in PASSWORD:
112         shuffled_password += '%02d' % xlt_password[c]
113     logging.info("shuffled_password: %s", shuffled_password)
114
115     sleep(10) # We are not supermen
116
117     post_data='urlbackend=%2Fvoscomptes%2FcanalXHTML%2Fidentif.ea%3Forigin%3Dparticuliers&origin=particuliers&password=' + shuffled_password + '&cv=true&cvvs=&username=' + LOGIN
118     httpresponse = httpopen(BASE_URL + '/wsost/OstBrokerWeb/auth', post_data)
119     html = httpresponse.read().decode('iso8859-1')
120     #print(httpresponse.info())
121     open('welcome.html', 'w', encoding='iso8859-1').write(html)
122
123     assert 'initialiser-identif.ea' in html
124     httpresponse = httpopen(BASE_URL + '/voscomptes/canalXHTML/securite/authentification/initialiser-identif.ea')
125     html = httpresponse.read().decode('iso8859-1')
126     #print(httpresponse.info())
127     open('welcome2.html', 'w', encoding='iso8859-1').write(html)
128
129     assert 'verifierMotDePasse-identif.ea' in html
130     httpresponse = httpopen(BASE_URL + '/voscomptes/canalXHTML/securite/authentification/verifierMotDePasse-identif.ea')
131     html = httpresponse.read().decode('iso8859-1')
132     #print(httpresponse.info())
133     open('welcome3.html', 'w', encoding='iso8859-1').write(html)
134     
135     assert 'init-aiguillagePersonnalisation.ea' in html
136     httpresponse = httpopen(BASE_URL + '/voscomptes/canalXHTML/donneesPersonnelles/aiguillage_personnalisation/init-aiguillagePersonnalisation.ea')
137     html = httpresponse.read().decode('iso8859-1')
138     #print(httpresponse.info())
139     open('welcome4.html', 'w', encoding='iso8859-1').write(html)
140
141     sleep(3)
142
143     root = html_parser.html_parse(html)
144     for a in html_parser.get_elem(root, 'a'):
145         href = a.attributes.get('href', '')
146         href = htmlentities.resolve(href)
147         match = re.match('https://voscomptesenligne.labanquepostale.fr/voscomptes/canalXHTML/(...)/.*compte.numero=(.*)&typeRecherche=(.*)', href)
148         if match:
149             logging.debug(href)
150             #../../CCP/releves_ccp/menuReleve-releve_ccp.ea?compte.numero=*******&typeRecherche=1
151             # https://voscomptesenligne.labanquepostale.fr/voscomptes/canalXHTML/CCP/releves_ccp/menuReleve-releve_ccp.ea?compte.numero=*******&typeRecherche=1
152             cpttype, cptnum, searchtype = match.group(1), match.group(2), match.group(3)
153     
154             logging.info('Found account type %s: %s' % (cpttype, cptnum))
155             result.append(cptnum)
156
157             httpresponse = httpopen(BASE_URL + '/voscomptes/canalXHTML/' + href[len('https://voscomptesenligne.labanquepostale.fr/voscomptes/canalXHTML/'):])
158             html = httpresponse.read().decode('iso8859-1')
159             open(cptnum+'-init.html', 'w', encoding='iso8859-1').write(html)
160             sleep(4)
161
162             # https://voscomptesenligne.labanquepostale.fr/voscomptes/canalXHTML/comptesCommun/telechargementMouvement/init-telechargementMouvements.ea?compte.numero=*********&typeRecherche=1&typeMouvements=CCP
163             httpresponse = httpopen(BASE_URL + '/voscomptes/canalXHTML/comptesCommun/telechargementMouvement/init-telechargementMouvements.ea?compte.numero=' + cptnum + '&typeRecherche='+ searchtype +'&typeMouvements=' + cpttype)
164             html = httpresponse.read().decode('iso8859-1')
165             #print(httpresponse.info())
166             open(cptnum+'-init2.html', 'w', encoding='iso8859-1').write(html)
167             sleep(4)
168
169             httpresponse = httpopen(BASE_URL + '/voscomptes/canalXHTML/comptesCommun/telechargementMouvement/detailCompte2-telechargementMouvements.ea')
170             html = httpresponse.read().decode('iso8859-1')
171             #print(httpresponse.info())
172             open(cptnum+'-confirm.html', 'w', encoding='iso8859-1').write(html)
173             sleep(9)
174
175             root = html_parser.html_parse(html)
176             #html_parser.print_idented_tree(root)
177             for form in html_parser.get_elem(root, 'form'):
178                 if form.attributes.get('id', None) == 'formConfirmAgain':
179                     url = form.attributes['action']
180                 if not url:
181                     logging.critical("Can't find link to download csv")
182                     continue
183
184             # /voscomptes/canalXHTML/comptesCommun/telechargementMouvement/preparerRecherche-telechargementMouvements.ea?ts=1304816124318 POST 'format=CSV&duree='
185             httpresponse = httpopen(BASE_URL + '/voscomptes/canalXHTML/comptesCommun/telechargementMouvement/' + url, 'format=CSV&duree=')
186             filename= LOCAL_DIR + cptnum + '.' + datetime.now().strftime('%Y%m%dT%H%M%S') + '.csv'
187             csvdata = httpresponse.read().decode('iso8859-1')
188             logging.info('Save CSV data to %s', filename)
189             open(filename, 'w', encoding='utf-8').write(csvdata)
190             sleep(9)
191
192             lastfilename = LOCAL_DIR + cptnum + '.last.csv'
193             try:
194                 os.unlink(lastfilename)
195             except OSError as err:
196                 if err.errno == 2: #No such file or directory
197                     logging.warning('Could not find last csv link. Running for the first time?')
198                 else:
199                     raise
200             os.symlink(filename, lastfilename)
201
202     logging.info('Disconnecting')
203     httpresponse = httpopen(BASE_URL + '/voscomptes/canalXHTML/securite/deconnexion/init-deconnexion.ea')
204     html = httpresponse.read().decode('iso8859-1')
205     open('bye.html', 'w', encoding='iso8859-1').write(html)
206
207     logging.info('Disconnected')
208     return result
209
210
211 def agregate(csv_last_names):
212     # If a specific list of file was given, process these
213     if csv_last_names:
214         for name in csv_last_names:
215             merge_csv(name)
216         return
217
218     # Else process *.last.csv files
219     account_files = os.listdir(LOCAL_DIR)
220     for account_file in account_files:
221         if not account_file.endswith('.last.csv'):
222             continue
223      
224         merge_csv(account_file)   
225
226
227 def myexec(cmd):
228     proc = Popen(cmd, stderr=PIPE)
229     errmsg = str(proc.communicate()[1], encoding='utf-8')
230     errcode = proc.wait()
231     if errcode:
232         logging.error("Can't run %s: %s", cmd, errmsg)
233         return False
234     return True
235
236
237 def remove_headers(filein, fileout, delimline, keepdelim=False):
238     """
239     Copies filein in fileout, without the headers.
240     Look for a line containing "delimline" in filein.
241     Everything before is not copied.
242     The delimline itself is not copied unless keepdelim is True.
243     returns the headers, excluding delimline
244     """
245     headers = []
246     past_headers = False
247     with open(filein) as fin:
248         with open(fileout, 'w', encoding='utf-8') as fout:
249             for line in fin.read().split('\n'):
250                 if not line:
251                     continue
252                 if past_headers:
253                     fout.write(line + '\n')
254                 else:
255                     if line == delimline:
256                         past_headers = True
257                         if keepdelim:
258                             fout.write(line + '\n')
259                     else:
260                         headers.append(line)
261     return headers
262                         
263     
264 def merge_csv(filename_last):
265     dotpos = filename_last.find('.')
266     if dotpos == -1:
267         logging.critical('File name %s must contain a dot.', filename_last)
268         return
269     if filename_last.find('/') >= 0:
270         logging.critical('File name %s must not contain '/'.' % filename_last)
271         return
272
273     account = filename_last[:dotpos]
274
275     logging.debug('Agregating %s', account)
276  
277     oldmastername = LOCAL_DIR + account + '.csv'
278     newmastername = LOCAL_DIR + account + '.csv.new'
279
280     if not os.access(oldmastername, os.F_OK):
281         logging.warning('Master csv file not found for %s: creating', account)
282         remove_headers(LOCAL_DIR + filename_last, oldmastername, CSV_HEADER, keepdelim=True)
283         return
284         
285     remove_headers(LOCAL_DIR + filename_last,
286                    LOCAL_DIR + 'tmp/last.csv',
287                    CSV_HEADER)
288     remove_headers(oldmastername,
289                    LOCAL_DIR + 'tmp/master.csv',
290                    CSV_HEADER)
291     cmd = ['diff', '-Nau', LOCAL_DIR + 'tmp/master.csv', LOCAL_DIR + 'tmp/last.csv']
292     proc = Popen(cmd, stdout=PIPE, stderr=PIPE)
293     out, errormsg = proc.communicate()
294     proc.wait()
295     out = str(out, encoding='utf-8')
296     errormsg = str(errormsg, encoding='utf-8')
297     if errormsg:
298         logging.critical("Can't run %s: %s", cmd, errormsg)
299         return
300
301     with open(newmastername, 'w', encoding='utf-8') as newmasterfile:
302         newmasterfile.write(CSV_HEADER + '\n')
303
304         for diffline in out.split('\n'):
305             if diffline.startswith('+') and not diffline.startswith('+++'):
306                 diffline = diffline[1:]  # Remove staring '+'
307                 logging.info(diffline)
308                 newmasterfile.write(diffline + '\n')
309
310         with open(LOCAL_DIR + 'tmp/master.csv') as oldmasterfile:
311             newmasterfile.write(oldmasterfile.read())
312
313     myexec(['mv', newmastername, oldmastername])
314
315
316
317 if __name__ == '__main__':
318     from optparse import OptionParser
319     parser = OptionParser()
320     parser.add_option('-d', '--debug',
321         action='store_true', dest='debug', default=False,
322         help="debug mode")
323     parser.add_option('--no-download',
324         action='store_true', dest='no_download', default=False,
325         help="don't download. Only agregate.")
326     parser.add_option('--csvlast',
327         action='append', dest='csv_last_names', default=[],
328         metavar='file.csv',
329         help="Process this file rather than *.last.csv. "
330              "That option can be used multiple times.")
331     (options, args) = parser.parse_args()
332
333     if options.debug:
334         loglevel = logging.DEBUG
335     else:
336         loglevel = logging.INFO
337     logging.basicConfig(level=loglevel, format='%(asctime)s %(levelname)s %(message)s')
338
339     os.umask(0o077) # this is really private
340
341     TMP_DIR = LOCAL_DIR + 'tmp/'
342     try:
343         os.mkdir(TMP_DIR)
344     except OSError as err:
345         if err.errno != 17: # File exists
346             raise
347     os.chdir(TMP_DIR)
348
349     if not options.no_download:
350         download()
351     agregate(options.csv_last_names)