triangle-api 0.1.4__tar.gz → 0.1.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: triangle-api
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Unofficial Python wrapper for Canadian Tire Triangle Mastercard API
5
5
  Home-page: https://github.com/diogobas/triangle-api
6
6
  Author: Diogo Bastos
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
5
5
 
6
6
  setup(
7
7
  name="triangle-api", # Nome que aparecerá no PyPI
8
- version="0.1.4",
8
+ version="0.1.6",
9
9
  author="Diogo Bastos",
10
10
  author_email="seu-email@exemplo.com", # Opcional
11
11
  description="Unofficial Python wrapper for Canadian Tire Triangle Mastercard API",
@@ -0,0 +1,4 @@
1
+ from .client import TriangleClient
2
+
3
+ __version__ = "0.1.6"
4
+ __all__ = ["TriangleClient"]
@@ -0,0 +1,172 @@
1
+ from playwright.sync_api import sync_playwright
2
+ import os
3
+ import time
4
+ import hashlib
5
+ import json
6
+ from datetime import datetime
7
+
8
+ class TriangleClient:
9
+ def __init__(self, username=None, password=None, session_path="auth_state.json"):
10
+ self.username = username
11
+ self.password = password
12
+ self.session_path = session_path
13
+ self.account_data = None
14
+
15
+ def _get_csrf(self, context):
16
+ cookies = context.cookies()
17
+ return next((c['value'] for c in cookies if c['name'] == 'csrftoken'), "")
18
+
19
+ def _fetch_account_data(self, page, csrf_token):
20
+ js_get_account = """
21
+ async (csrf) => {
22
+ const r = await fetch('/dash/v1/account/retrieveAccount', {
23
+ method: 'POST',
24
+ headers: {
25
+ 'Content-Type': 'application/json',
26
+ 'CSRF-Token': csrf,
27
+ 'csrfToken': csrf
28
+ },
29
+ body: JSON.stringify({})
30
+ });
31
+ return r.ok ? await r.json() : null;
32
+ }
33
+ """
34
+ try:
35
+ return page.evaluate(js_get_account, csrf_token)
36
+ except:
37
+ return None
38
+
39
+ def login(self, headless=True):
40
+ with sync_playwright() as p:
41
+ browser = p.chromium.launch(headless=headless)
42
+
43
+ # SEGURANÇA: Só carrega se o arquivo existir
44
+ storage = self.session_path if os.path.exists(self.session_path) else None
45
+
46
+ context = browser.new_context(
47
+ viewport={'width': 1280, 'height': 800},
48
+ storage_state=storage,
49
+ user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
50
+ )
51
+ page = context.new_page()
52
+
53
+ print("[API] Acessando o portal...")
54
+ page.goto("https://www.ctfs.com/content/dash/en/private/Summary.html", timeout=60000)
55
+
56
+ if "login" in page.url.lower():
57
+ if not self.username or not self.password:
58
+ browser.close()
59
+ return "NEEDS_CREDENTIALS"
60
+
61
+ print("[API] Sessão expirada ou inexistente. Autenticando...")
62
+ try:
63
+ if page.is_visible("#onetrust-accept-btn-handler"):
64
+ page.click("#onetrust-accept-btn-handler", timeout=5000)
65
+
66
+ page.wait_for_selector("#username", timeout=30000)
67
+ page.fill("#username", self.username)
68
+ page.fill("#password", self.password)
69
+ page.focus("#password")
70
+ page.keyboard.press("Enter")
71
+
72
+ page.wait_for_function("() => !window.location.href.includes('login')", timeout=45000)
73
+
74
+ if "challenge" in page.url or "otp" in page.url:
75
+ sms_code = input("\n[MFA] Digite o código SMS: ")
76
+ page.get_by_role("textbox").first.fill(sms_code)
77
+ page.keyboard.press("Enter")
78
+ page.wait_for_url("**/Summary.html", timeout=45000)
79
+ except Exception as e:
80
+ print(f"[ERRO] Falha no login: {e}")
81
+ browser.close()
82
+ return None
83
+
84
+ print("[API] Capturando dados financeiros...")
85
+ try:
86
+ page.wait_for_load_state("domcontentloaded")
87
+ csrf = self._get_csrf(context)
88
+ self.account_data = self._fetch_account_data(page, csrf)
89
+
90
+ if self.account_data:
91
+ context.storage_state(path=self.session_path)
92
+ print("[API] Dados capturados e sessão salva.")
93
+ except Exception as e:
94
+ print(f"[AVISO] Erro na captura de saldo: {e}")
95
+
96
+ browser.close()
97
+ return self.account_data
98
+
99
+ def get_transactions(self, start_date=None, headless=True):
100
+ if not start_date:
101
+ start_date = datetime.today().replace(day=1).strftime('%Y-%m-%d')
102
+
103
+ with sync_playwright() as p:
104
+ browser = p.chromium.launch(headless=headless)
105
+
106
+ # SEGURANÇA: Só carrega se o arquivo existir
107
+ storage = self.session_path if os.path.exists(self.session_path) else None
108
+ context = browser.new_context(storage_state=storage)
109
+ page = context.new_page()
110
+
111
+ print("[API] Sincronizando para transações...")
112
+ page.goto("https://www.ctfs.com/content/dash/en/private/Summary.html", wait_until="domcontentloaded")
113
+
114
+ csrf = self._get_csrf(context)
115
+
116
+ if not self.account_data:
117
+ self.account_data = self._fetch_account_data(page, csrf)
118
+
119
+ if not self.account_data:
120
+ print("[ERRO] Não foi possível obter dados da conta para transações.")
121
+ browser.close()
122
+ return []
123
+
124
+ js_tx_script = """
125
+ async (params) => {
126
+ const call = async (cat, stmt = null) => {
127
+ const body = { category: cat, transientReference: params.transient };
128
+ if (stmt) body.statementDate = stmt;
129
+ const r = await fetch('/dash/v1/account/retrieveTransactions', {
130
+ method: 'POST',
131
+ headers: { 'Content-Type': 'application/json', 'CSRF-Token': params.csrf, 'csrfToken': params.csrf },
132
+ body: JSON.stringify(body)
133
+ });
134
+ return r.ok ? await r.json() : { transactions: [] };
135
+ };
136
+ const auth = await call('AUTHORIZED');
137
+ const post = await call('POSTED');
138
+ const stmt = params.lastStmt ? await call('STATEMENTED', params.lastStmt) : { transactions: [] };
139
+ return [...(auth.transactions || []), ...(post.transactions || []), ...(stmt.transactions || [])];
140
+ }
141
+ """
142
+
143
+ raw_txs = page.evaluate(js_tx_script, {
144
+ "transient": self.account_data.get("transientReference"),
145
+ "lastStmt": self.account_data.get("lastStatementDate"),
146
+ "csrf": csrf
147
+ })
148
+ browser.close()
149
+
150
+ unique_txs = {}
151
+ for t in raw_txs:
152
+ d = t.get("tranDate", "")
153
+ if d >= start_date:
154
+ key = f"{d}|{t.get('merchant')}|{t.get('amount')}"
155
+ unique_txs[key] = t
156
+ return list(unique_txs.values())
157
+
158
+ def get_normalized_transactions(self, start_date=None, headless=True):
159
+ txs = self.get_transactions(start_date=start_date, headless=headless)
160
+ normalized = []
161
+ for t in txs:
162
+ date, payee, amount = t.get("tranDate"), t.get("merchant", "Unknown"), float(t.get("amount", 0.0))
163
+ ext_id = hashlib.md5(f"{date}|{payee}|{amount}".encode()).hexdigest()
164
+ normalized.append({
165
+ "date": date, "payee": payee, "amount": amount, "currency": "cad",
166
+ "external_id": ext_id, "status": "cleared" if t.get("category") in ["POSTED", "STATEMENTED"] else "uncleared",
167
+ "notes": f"Type: {t.get('type')} | Imported via Triangle API"
168
+ })
169
+ return sorted(normalized, key=lambda x: x.get("date", ""), reverse=True)
170
+
171
+ def get_balance(self):
172
+ return self.account_data.get("currentBalanceAmt", 0.0) if self.account_data else "N/A"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: triangle-api
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Unofficial Python wrapper for Canadian Tire Triangle Mastercard API
5
5
  Home-page: https://github.com/diogobas/triangle-api
6
6
  Author: Diogo Bastos
@@ -1,4 +0,0 @@
1
- from .client import TriangleClient
2
-
3
- __version__ = "0.1.4"
4
- __all__ = ["TriangleClient"]
@@ -1,232 +0,0 @@
1
- from playwright.sync_api import sync_playwright
2
- import os
3
- import time
4
- import hashlib
5
- from datetime import datetime
6
-
7
- class TriangleClient:
8
- def __init__(self, username=None, password=None, session_path="auth_state.json"):
9
- self.username = username
10
- self.password = password
11
- self.session_path = session_path
12
- self.account_data = None
13
-
14
- def login(self, headless=True):
15
- with sync_playwright() as p:
16
- # 1. Configuração do Navegador
17
- browser = p.chromium.launch(headless=headless)
18
-
19
- # Verifica se existe sessão salva para tentar restaurar
20
- has_session = os.path.exists(self.session_path)
21
- storage = self.session_path if has_session else None
22
-
23
- context = browser.new_context(
24
- viewport={'width': 1280, 'height': 800},
25
- storage_state=storage,
26
- user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
27
- )
28
- page = context.new_page()
29
-
30
- # 2. Definição da URL inicial
31
- if has_session:
32
- print("[API] Tentando restaurar sessão anterior...")
33
- target_url = "https://www.ctfs.com/content/dash/en/private/Summary.html"
34
- else:
35
- print("[API] Iniciando novo login...")
36
- target_url = "https://www.ctfs.com/content/dashpublic/en/login.html"
37
-
38
- page.goto(target_url, wait_until="load", timeout=60000)
39
-
40
- # 3. Lidar com Banner de Cookies (OneTrust)
41
- # Esse banner bloqueia a interação com os campos de texto se não for fechado
42
- try:
43
- cookie_btn = page.locator("#onetrust-accept-btn-handler, text='Accept All'")
44
- if cookie_btn.is_visible(timeout=8000):
45
- cookie_btn.click()
46
- print("[API] Banner de cookies aceito.")
47
- time.sleep(1) # Pausa para animação de fechamento
48
- except:
49
- pass
50
-
51
- # 4. Fluxo de Autenticação (Caso a sessão tenha expirado ou não exista)
52
- if "login" in page.url.lower() or not has_session:
53
- if not self.username or not self.password:
54
- print("[API] Credenciais necessárias para continuar.")
55
- browser.close()
56
- return "NEEDS_CREDENTIALS"
57
-
58
- print("[API] Tela de login detectada. Preenchendo credenciais...")
59
- try:
60
- page.wait_for_selector("#username", timeout=30000)
61
- page.fill("#username", self.username)
62
- page.fill("#password", self.password)
63
-
64
- # Submissão via Teclado (Enter) é mais resiliente contra widgets de chat
65
- page.focus("#password")
66
- page.keyboard.press("Enter")
67
-
68
- # Clique de segurança via JavaScript caso o Enter falhe
69
- page.evaluate("document.querySelector('#signin-form')?.click()")
70
-
71
- # Espera sair da tela de login (mudar URL ou detectar MFA)
72
- print("[API] Formulário enviado. Aguardando resposta do banco...")
73
- page.wait_for_function("() => !window.location.href.includes('login')", timeout=45000)
74
-
75
- # --- Verificação de MFA (SMS/OTP) ---
76
- if "challenge" in page.url or "otp" in page.url or page.is_visible("input[aria-describedby*='otp']"):
77
- print("\n[MFA] Verificação de segurança detectada.")
78
- sms_code = input("Digite o código SMS recebido no celular: ")
79
- page.get_by_role("textbox").first.fill(sms_code)
80
- page.keyboard.press("Enter")
81
- page.wait_for_url("**/Summary.html", timeout=60000)
82
-
83
- print("[API] Login realizado com sucesso!")
84
-
85
- except Exception as e:
86
- print(f"[ERRO] Falha no processo de login: {e}")
87
- page.screenshot(path="debug_login_error.png")
88
- browser.close()
89
- return None
90
-
91
- # 5. CAPTURA FINAL DE DADOS (Saldo e transientReference)
92
- print("[API] Capturando dados financeiros (retrieveAccount)...")
93
- try:
94
- # Esperamos a página carregar o básico
95
- page.wait_for_load_state("domcontentloaded")
96
-
97
- # Em vez de 'esperar' uma resposta, nós 'pedimos' os dados
98
- # Isso evita o erro de "Protocol error"
99
- js_get_account = """
100
- async () => {
101
- const r = await fetch('/dash/v1/account/retrieveAccount', {
102
- method: 'POST',
103
- headers: { 'Content-Type': 'application/json' },
104
- body: JSON.stringify({})
105
- });
106
- return r.ok ? await r.json() : null;
107
- }
108
- """
109
-
110
- # Executa o fetch dentro do contexto do navegador
111
- self.account_data = page.evaluate(js_get_account)
112
-
113
- if self.account_data:
114
- # Agora que temos os dados, salvamos a sessão
115
- context.storage_state(path=self.session_path)
116
- print("[API] Dados financeiros capturados com sucesso.")
117
- else:
118
- print("[AVISO] O servidor retornou vazio para o saldo.")
119
-
120
- except Exception as e:
121
- print(f"[ERRO] Falha ao capturar saldo via JS: {e}")
122
-
123
- browser.close()
124
- return self.account_data
125
-
126
- def get_normalized_transactions(self, start_date=None, headless=True):
127
- """
128
- Versão ultra-robusta para evitar erros de ordenação (NoneType).
129
- """
130
- # 1. Busca as transações brutas
131
- raw_txs = self.get_transactions(start_date=start_date, headless=headless)
132
-
133
- normalized = []
134
- seen_ids = set()
135
-
136
- for tx in raw_txs:
137
- # Pegamos a data. Se não existir, ignoramos o item.
138
- t_date = tx.get("tranDate")
139
- if not t_date:
140
- continue
141
-
142
- payee = tx.get("merchant", "Unknown Merchant")
143
- amount = float(tx.get("amount", 0.0))
144
-
145
- # Gerar ID Único para deduplicação
146
- identifier = f"{t_date}|{payee}|{amount}"
147
- if identifier in seen_ids:
148
- continue
149
- seen_ids.add(identifier)
150
-
151
- # MD5 para o Lunch Money
152
- ext_id = hashlib.md5(identifier.encode("utf-8")).hexdigest()
153
-
154
- # Certifique-se de que o dicionário normalized tenha a chave 'notes'
155
- normalized.append({
156
- "date": t_date,
157
- "payee": payee,
158
- "amount": amount,
159
- "currency": "cad",
160
- "external_id": ext_id,
161
- "type": tx.get("type", "PURCHASE"),
162
- "status": "cleared" if tx.get("category") in ["POSTED", "STATEMENTED"] else "uncleared",
163
- "notes": f"Type: {tx.get('type')} | Imported via Triangle API"
164
- })
165
-
166
- # Ordenar garantindo que não existam Nones na chave de ordenação
167
- return sorted(normalized, key=lambda x: x.get("date", ""), reverse=True)
168
-
169
- def get_transactions(self, start_date=None, headless=True):
170
- today = datetime.today()
171
- if not start_date:
172
- start_date = today.replace(day=1).strftime('%Y-%m-%d')
173
- end_date = today.strftime('%Y-%m-%d')
174
-
175
- print(f"[API] Buscando transações de {start_date} até {end_date}...")
176
-
177
- with sync_playwright() as p:
178
- browser = p.chromium.launch(headless=headless)
179
- storage = self.session_path if os.path.exists(self.session_path) else None
180
- context = browser.new_context(storage_state=storage)
181
- page = context.new_page()
182
-
183
- try:
184
- print("[API] Sincronizando sessão para histórico...")
185
- # Navega e pesca o retrieveAccount da janela atual
186
- with page.expect_response("**/retrieveAccount", timeout=30000) as resp_acc:
187
- page.goto("https://www.ctfs.com/content/dash/en/private/Summary.html", wait_until="domcontentloaded")
188
-
189
- acc_info = resp_acc.value.json()
190
- transient = acc_info.get("transientReference")
191
- acc_id = acc_info.get("accountId")
192
- last_stmt = acc_info.get("lastStatementDate")
193
-
194
- cookies = context.cookies()
195
- csrf = next((c['value'] for c in cookies if c['name'] == 'csrftoken'), "")
196
-
197
- # Script de busca tripla
198
- js_script = """
199
- async (params) => {
200
- const call = async (cat, stmt = null) => {
201
- const body = { category: cat, transientReference: params.transient, accountId: params.accId };
202
- if (stmt) body.statementDate = stmt;
203
- const r = await fetch('/dash/v1/account/retrieveTransactions', {
204
- method: 'POST',
205
- headers: { 'Content-Type': 'application/json', 'CSRF-Token': params.csrf, 'csrfToken': params.csrf },
206
- body: JSON.stringify(body)
207
- });
208
- return r.ok ? await r.json() : { transactions: [] };
209
- };
210
- const auth = await call('AUTHORIZED');
211
- const post = await call('POSTED');
212
- const stmt = params.lastStmt ? await call('STATEMENTED', params.lastStmt) : { transactions: [] };
213
- return [...(auth.transactions || []), ...(post.transactions || []), ...(stmt.transactions || [])];
214
- }
215
- """
216
-
217
- raw_txs = page.evaluate(js_script, {
218
- "transient": transient, "accId": acc_id, "lastStmt": last_stmt, "csrf": csrf
219
- })
220
-
221
- # Filtro de data bruto
222
- filtered = [t for t in raw_txs if t.get("tranDate", "") >= start_date]
223
- browser.close()
224
- return filtered
225
-
226
- except Exception as e:
227
- print(f"[ERRO] Falha na captura: {e}")
228
- browser.close()
229
- return []
230
-
231
- def get_balance(self):
232
- return self.account_data.get("currentBalanceAmt", 0.0) if self.account_data else "N/A"
File without changes
File without changes