From 2fc837a42f86ccddb1276bac667989fa131bec8e Mon Sep 17 00:00:00 2001 From: Oleg Plakhotniuk Date: Sun, 28 Dec 2014 22:07:05 -0600 Subject: [PATCH] Amazon Store Card banking module. Closes #1698 --- modules/amazonstorecard/__init__.py | 23 +++ modules/amazonstorecard/browser.py | 74 ++++++++++ modules/amazonstorecard/favicon.png | Bin 0 -> 4966 bytes modules/amazonstorecard/module.py | 55 ++++++++ modules/amazonstorecard/pages.py | 212 ++++++++++++++++++++++++++++ modules/amazonstorecard/test.py | 34 +++++ 6 files changed, 398 insertions(+) create mode 100644 modules/amazonstorecard/__init__.py create mode 100644 modules/amazonstorecard/browser.py create mode 100644 modules/amazonstorecard/favicon.png create mode 100644 modules/amazonstorecard/module.py create mode 100644 modules/amazonstorecard/pages.py create mode 100644 modules/amazonstorecard/test.py diff --git a/modules/amazonstorecard/__init__.py b/modules/amazonstorecard/__init__.py new file mode 100644 index 00000000..3754cbca --- /dev/null +++ b/modules/amazonstorecard/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2014-2015 Oleg Plakhotniuk +# +# This file is part of weboob. +# +# weboob is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# weboob is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with weboob. If not, see . + + +from .module import AmazonStoreCardModule + +__all__ = ['AmazonStoreCardModule'] diff --git a/modules/amazonstorecard/browser.py b/modules/amazonstorecard/browser.py new file mode 100644 index 00000000..8cfb6fdc --- /dev/null +++ b/modules/amazonstorecard/browser.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2014-2015 Oleg Plakhotniuk +# +# This file is part of weboob. +# +# weboob is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# weboob is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with weboob. If not, see . + + +from weboob.capabilities.bank import AccountNotFound +from weboob.browser import LoginBrowser, URL, need_login +from weboob.exceptions import BrowserIncorrectPassword + +from .pages import SomePage, LoginPage, RecentPage, StatementsPage, \ + StatementPage, SummaryPage + + +__all__ = ['AmazonStoreCard'] + + +class AmazonStoreCard(LoginBrowser): + BASEURL = 'https://www.onlinecreditcenter6.com' + login = URL('/consumergen2/login.do\?accountType=plcc&clientId=amazon' + '&langId=en&subActionId=1000$', + '/consumergen2/consumerlogin.do.*$', + LoginPage) + stmts = URL('/consumergen2/ebill.do$', StatementsPage) + recent = URL('/consumergen2/recentActivity.do$', RecentPage) + statement = URL('/consumergen2/ebillViewPDF.do.*$', StatementPage) + summary = URL('/consumergen2/accountSummary.do$', SummaryPage) + unknown = URL('.*', SomePage) + + def __init__(self, config, *args, **kwargs): + super(AmazonStoreCard, self).__init__(config['userid'].get(), + config['password'].get(), *args, **kwargs) + self.config = config + + def do_login(self): + self.session.cookies.clear() + self.login.go() + while self.login.is_here(): + self.page.proceed(self.config) + if not self.page.logged: + raise BrowserIncorrectPassword() + + @need_login + def get_account(self, id_): + a = next(self.iter_accounts()) + if (a.id != id_): + raise AccountNotFound() + return a + + @need_login + def iter_accounts(self): + yield self.summary.go(data=SummaryPage.DATA).account() + + @need_login + def iter_history(self, account): + for t in self.recent.go(data=RecentPage.DATA).iter_transactions(): + yield t + for s in self.stmts.go(data=StatementsPage.DATA).iter_statements(): + for t in s.iter_transactions(): + yield t diff --git a/modules/amazonstorecard/favicon.png b/modules/amazonstorecard/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..11142291eab47d0cd08c85f9b939ec7deab126df GIT binary patch literal 4966 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

XVk1vQ>Iq zMl71e3JMDT>|WbFMu;f(LMUlrDtw_Q`>V0m>x|5Tx`P)ZH>0ZIZ6-QJt%7eUi!R_* z{7{2^-anslkMb@9N3IETE9gP#y6wK&|!MH9~E52sbek_`%^m3_4s_q42CdISytQpK|*J zzipStjkNx|l+57h0O9CX5A~MW;loYBMvE(Z_)u3UuJP0X%*i*-34 zOvx|Aip25=WJTC04AH~Z$_4TN!gDbg8e}O;`NKp&szc1>U|<&We&@nbtlwts$KuQ$ z+s`HwL$$agY8|JB#dh4gRQk|U>2_}68GKFGUemi-#D3&8e(eoq5fKp&S%gkHigicQxWg5}2a#se>LrjVuT!swDNQ-lD;lrl+25m1FqAj1*i9@6Y%x*3%vKQs z0y$hlqN`0d+}POI=i)3rNvUVH*K9YdBj#c`Hp*L#?-QGQWZXnD6mY{1QDGWRZ`MYf zTgqfWtpnFNu|5Eu6Q-^w_6E_rLXx5fJc2U%!=ulEL8iqL4)1JR2kt6x{9r!zzu^0k z`*>42-lVgx_0KqTp5f)miJVZ6c$-GveiYhD9K9==$!#im;Az_!fX&Zmvi1ZN zCHH#u_AzC2GVRS8Yb=Iwo6$mZ(xRFqQEEYpXNh;Z(MR~FcD<~i1$aF`5(5xUyMRE6 z*@1id4n%lv`!vFd@Szwb57rhyzl^E?_ElG>k0-=zpR&*fBTN0R7syf!XJ3s1_NCjc z>wGg~R+T|p4k!Y=SPu67f$$s{9)@Jxqk>UJv~_aS7!}`{G}-@8eh6zjnSFZXC{viT zyPy-A^8!t({7k*jkvMg37VrK#>YnY1VewCL3pE%CdjTX+<*Z{+9+9zQJ0K+E={xIv zmx;9eEwDJn6v~1N-;#ob6~46XBbKf)p(-PT9+AshH``n^NqXdnVu|lRuGIHZ8&0oi z)?QnFVDe7nXzcTFj|sAF+U>nPNer(|+oj>}ARj=KXw1%+*XtYp<#fDra~3 z&yXW241TRCGFGe~Mp@>Y<;^_(`Lxz~$Z6NN-Zsg#%AnwfgS$IBzu?%KSUduAugmf2 z7OhR~Q`g{nMCCgXaM+U?s`mDFSx$eM4A}~jYfzmwo`8_glP-GhNWTt$stLI8b zz_IM4B2xxf$-gF&)_~jo-zA*QiJEh0ewY&RnCtKrP;(OU)3Nk(arAqv?CkRx0FaE7 zw5+(ajJS-Vh4dX|87XBsX>loeWhtoxQD2~@_5T^5y<9w8ga2=XIR8FDzyM^RW1?Mw HaESST#U+cL literal 0 HcmV?d00001 diff --git a/modules/amazonstorecard/module.py b/modules/amazonstorecard/module.py new file mode 100644 index 00000000..fad1d10b --- /dev/null +++ b/modules/amazonstorecard/module.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2014-2015 Oleg Plakhotniuk +# +# This file is part of weboob. +# +# weboob is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# weboob is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with weboob. If not, see . + + +from weboob.capabilities.bank import CapBank +from weboob.tools.backend import Module, BackendConfig +from weboob.tools.value import ValueBackendPassword + +from .browser import AmazonStoreCard + + +__all__ = ['AmazonStoreCardModule'] + + +class AmazonStoreCardModule(Module, CapBank): + NAME = 'amazonstorecard' + MAINTAINER = u'Oleg Plakhotniuk' + EMAIL = 'olegus8@gmail.com' + VERSION = '1.1' + LICENSE = 'AGPLv3+' + DESCRIPTION = u'Amazon Store Card' + CONFIG = BackendConfig( + ValueBackendPassword('userid', label='User ID', masked=False), + ValueBackendPassword('password', label='Password'), + ValueBackendPassword('challengeanswer1', + label='Challenge answer 1', masked=False)) + BROWSER = AmazonStoreCard + + def create_default_browser(self): + return self.create_browser(config = self.config) + + def iter_accounts(self): + return self.browser.iter_accounts() + + def get_account(self, id_): + return self.browser.get_account(id_) + + def iter_history(self, account): + return self.browser.iter_history(account) diff --git a/modules/amazonstorecard/pages.py b/modules/amazonstorecard/pages.py new file mode 100644 index 00000000..85d48914 --- /dev/null +++ b/modules/amazonstorecard/pages.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2014-2015 Oleg Plakhotniuk +# +# This file is part of weboob. +# +# weboob is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# weboob is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with weboob. If not, see . + +from weboob.capabilities.bank import Account, Transaction +from weboob.browser.pages import HTMLPage, RawPage, XMLPage +from weboob.tools.capabilities.bank.transactions import \ + AmericanTransaction as AmTr +from weboob.tools.date import closest_date +from weboob.tools.pdf import decompress_pdf +from weboob.tools.tokenizer import ReTokenizer +from datetime import datetime, timedelta + + +class SomePage(HTMLPage): + @property + def logged(self): + return bool(self.doc.xpath('//span[@class="logoutBtn"]')) + + +class LoginPage(SomePage): + INPUTS = ['userId', 'password', 'challengeAnswer1'] + + is_here = '//form[@name="consumerLoginForm"]' + + def proceed(self, config): + form = self.get_form(name='consumerLoginForm') + for inp in (i for i in self.INPUTS if i in form): + form[inp] = config[inp.lower()].get() + form.submit() + return self.browser.page + + +class SummaryPage(SomePage): + DATA = {'subActionId': '1201', 'clientId': 'amazon', + 'accountType': 'plcc', 'langId': 'en'} + + def account(self): + label = u' '.join(u''.join(self.doc.xpath( + u'//text()[contains(.,"Account ending in")]')).split()) + balance = self.doc.xpath( + '//span[@id="currentBalance"]/..')[0].text_content() + a = Account() + a.id = label[-4:] + a.label = label + a.currency = Account.get_currency(balance) + a.balance = -AmTr.decimal_amount(balance) + a.type = Account.TYPE_CARD + return a + + +class RecentPage(XMLPage): + DATA = {'subActionId': '1300', 'requestType': 'ajaxReq'} + + def iter_transactions(self): + for ntrans in reversed(self.doc.xpath('//TRANSACTION')): + desc = u' '.join(ntrans.xpath( + 'TRANSDESCRIPTION/text()')[0].split()) + t = Transaction() + t.date = datetime.strptime(ntrans.xpath( + 'TRANSACTIONDATE/text()')[0], '%m/%d/%Y') + t.rdate = datetime.strptime(ntrans.xpath( + 'POSTDATE/text()')[0], '%m/%d/%Y') + t.type = Transaction.TYPE_UNKNOWN + t.raw = desc + t.label = desc + t.amount = -AmTr.decimal_amount(ntrans.xpath('AMOUNT/text()')[0]) + yield t + + +class StatementsPage(SomePage): + DATA = {'subActionId': '8168', 'clientId': 'amazon', + 'accountType': 'plcc', 'langId': 'en'} + + def iter_statements(self): + for url in self.doc.xpath('//a[contains(@href,"ebillViewPDF")]/@href'): + if url.endswith('inline=false'): + self.browser.location(url) + yield self.browser.page + + +class StatementPage(RawPage): + LEX = [ + ('charge_amount', r'^\(\$([0-9\.]+)\) Tj$'), + ('payment_amount', r'^\(\\\(\$([0-9\.]+)\\\)\) Tj$'), + ('date', r'^\((\d+/\d+)\) Tj$'), + ('full_date', r'^\((\d+/\d+/\d+)\) Tj$'), + ('layout_td', r'^([-0-9]+ [-0-9]+) Td$'), + ('ref', r'^\(([A-Z0-9]{17})\) Tj$'), + ('text', r'^\((.*)\) Tj$') + ] + + def __init__(self, *args, **kwArgs): + RawPage.__init__(self, *args, **kwArgs) + assert self.doc[:4] == '%PDF' + self._pdf = decompress_pdf(self.doc) + self._tok = ReTokenizer(self._pdf, '\n', self.LEX) + + def iter_transactions(self): + return sorted(self.read_transactions(), + cmp=lambda t1, t2: cmp(t2.date, t1.date) or + cmp(t1.label, t2.label) or + cmp(t1.amount, t2.amount)) + + def read_transactions(self): + # Statement typically cover one month. + # Do 60 days, just to be on a safe side. + date_to = self.read_closing_date() + date_from = date_to - timedelta(days=60) + + pos = 0 + while not self._tok.tok(pos).is_eof(): + pos, trans = self.read_transaction(pos, date_from, date_to) + if trans: + yield trans + else: + pos += 1 + + def read_transaction(self, pos, date_from, date_to): + startPos = pos + pos, tdate = self.read_date(pos) + pos, pdate_layout = self.read_layout_td(pos) + pos, pdate = self.read_date(pos) + pos, ref_layout = self.read_layout_td(pos) + pos, ref = self.read_ref(pos) + pos, desc_layout = self.read_layout_td(pos) + pos, desc = self.read_text(pos) + pos, amount_layout = self.read_layout_td(pos) + pos, amount = self.read_amount(pos) + if tdate is None or pdate is None \ + or desc is None or amount is None or amount == 0: + return startPos, None + else: + tdate = closest_date(tdate, date_from, date_to) + pdate = closest_date(pdate, date_from, date_to) + desc = u' '.join(desc.split()) + + trans = Transaction(ref or u'') + trans.date = tdate + trans.rdate = pdate + trans.type = Transaction.TYPE_UNKNOWN + trans.raw = desc + trans.label = desc + trans.amount = amount + return pos, trans + + def read_amount(self, pos): + pos, ampay = self.read_payment_amount(pos) + if ampay is not None: + return pos, ampay + return self.read_charge_amount(pos) + + def read_charge_amount(self, pos): + t = self._tok.tok(pos) + return (pos+1, -AmTr.decimal_amount(t.value())) \ + if t.is_charge_amount() else (pos, None) + + def read_payment_amount(self, pos): + t = self._tok.tok(pos) + return (pos+1, AmTr.decimal_amount(t.value())) \ + if t.is_payment_amount() else (pos, None) + + def read_closing_date(self): + pos = 0 + while not self._tok.tok(pos).is_eof(): + pos, text = self.read_text(pos) + if text == u'Statement Closing Date': + break + pos += 1 + while not self._tok.tok(pos).is_eof(): + pos, date = self.read_full_date(pos) + if date is not None: + return date + pos += 1 + + def read_text(self, pos): + t = self._tok.tok(pos) + return (pos+1, unicode(t.value())) \ + if t.is_text() else (pos, None) + + def read_full_date(self, pos): + t = self._tok.tok(pos) + return (pos+1, datetime.strptime(t.value(), '%m/%d/%Y')) \ + if t.is_full_date() else (pos, None) + + def read_date(self, pos): + t = self._tok.tok(pos) + return (pos+1, datetime.strptime(t.value(), '%m/%d')) \ + if t.is_date() else (pos, None) + + def read_ref(self, pos): + t = self._tok.tok(pos) + return (pos+1, t.value()) if t.is_ref() else (pos, None) + + def read_layout_td(self, pos): + t = self._tok.tok(pos) + return (pos+1, t.value()) if t.is_layout_td() else (pos, None) diff --git a/modules/amazonstorecard/test.py b/modules/amazonstorecard/test.py new file mode 100644 index 00000000..33f5f896 --- /dev/null +++ b/modules/amazonstorecard/test.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- + +# Copyright(C) 2014-2015 Oleg Plakhotniuk +# +# This file is part of weboob. +# +# weboob is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# weboob is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with weboob. If not, see . + +from weboob.tools.test import BackendTest +from itertools import chain + + +class AmazonStoreCardTest(BackendTest): + MODULE = 'amazonstorecard' + + def test_history(self): + """ + Test that there's at least one transaction in the whole history. + """ + b = self.backend + ts = chain(*[b.iter_history(a) for a in b.iter_accounts()]) + t = next(ts, None) + self.assertNotEqual(t, None)