triangle-api 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,4 @@
1
+ from .client import TriangleClient
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["TriangleClient"]
triangle_api/client.py ADDED
@@ -0,0 +1,218 @@
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
+ # Fazemos um reload forçado para "pescar" o JSON do saldo atualizado
93
+ print("[API] Capturando dados financeiros (retrieveAccount)...")
94
+ try:
95
+ # Esperamos a página estabilizar antes de recarregar
96
+ page.wait_for_load_state("domcontentloaded")
97
+
98
+ with page.expect_response("**/retrieveAccount", timeout=30000) as resp:
99
+ page.goto("https://www.ctfs.com/content/dash/en/private/Summary.html")
100
+
101
+ self.account_data = resp.value.json()
102
+
103
+ # Salva os cookies/sessão somente após confirmar a captura dos dados
104
+ if self.account_data:
105
+ context.storage_state(path=self.session_path)
106
+ print("[API] Sessão e dados financeiros salvos.")
107
+
108
+ except Exception as e:
109
+ print(f"[AVISO] Não foi possível capturar os dados via rede: {e}")
110
+
111
+ browser.close()
112
+ return self.account_data
113
+
114
+ def get_normalized_transactions(self, start_date=None, headless=True):
115
+ """
116
+ Versão ultra-robusta para evitar erros de ordenação (NoneType).
117
+ """
118
+ # 1. Busca as transações brutas
119
+ raw_txs = self.get_transactions(start_date=start_date, headless=headless)
120
+
121
+ normalized = []
122
+ seen_ids = set()
123
+
124
+ for tx in raw_txs:
125
+ # Pegamos a data. Se não existir, ignoramos o item.
126
+ t_date = tx.get("tranDate")
127
+ if not t_date:
128
+ continue
129
+
130
+ payee = tx.get("merchant", "Unknown Merchant")
131
+ amount = float(tx.get("amount", 0.0))
132
+
133
+ # Gerar ID Único para deduplicação
134
+ identifier = f"{t_date}|{payee}|{amount}"
135
+ if identifier in seen_ids:
136
+ continue
137
+ seen_ids.add(identifier)
138
+
139
+ # MD5 para o Lunch Money
140
+ ext_id = hashlib.md5(identifier.encode("utf-8")).hexdigest()
141
+
142
+ normalized.append({
143
+ "date": t_date,
144
+ "payee": payee,
145
+ "amount": amount,
146
+ "currency": "cad",
147
+ "external_id": ext_id,
148
+ "type": tx.get("type", "PURCHASE"),
149
+ "status": "cleared" if tx.get("category") in ["POSTED", "STATEMENTED"] else "uncleared"
150
+ })
151
+
152
+ # Ordenar garantindo que não existam Nones na chave de ordenação
153
+ return sorted(normalized, key=lambda x: x.get("date", ""), reverse=True)
154
+
155
+ def get_transactions(self, start_date=None, headless=True):
156
+ today = datetime.today()
157
+ if not start_date:
158
+ start_date = today.replace(day=1).strftime('%Y-%m-%d')
159
+ end_date = today.strftime('%Y-%m-%d')
160
+
161
+ print(f"[API] Buscando transações de {start_date} até {end_date}...")
162
+
163
+ with sync_playwright() as p:
164
+ browser = p.chromium.launch(headless=headless)
165
+ storage = self.session_path if os.path.exists(self.session_path) else None
166
+ context = browser.new_context(storage_state=storage)
167
+ page = context.new_page()
168
+
169
+ try:
170
+ print("[API] Sincronizando sessão para histórico...")
171
+ # Navega e pesca o retrieveAccount da janela atual
172
+ with page.expect_response("**/retrieveAccount", timeout=30000) as resp_acc:
173
+ page.goto("https://www.ctfs.com/content/dash/en/private/Summary.html", wait_until="domcontentloaded")
174
+
175
+ acc_info = resp_acc.value.json()
176
+ transient = acc_info.get("transientReference")
177
+ acc_id = acc_info.get("accountId")
178
+ last_stmt = acc_info.get("lastStatementDate")
179
+
180
+ cookies = context.cookies()
181
+ csrf = next((c['value'] for c in cookies if c['name'] == 'csrftoken'), "")
182
+
183
+ # Script de busca tripla
184
+ js_script = """
185
+ async (params) => {
186
+ const call = async (cat, stmt = null) => {
187
+ const body = { category: cat, transientReference: params.transient, accountId: params.accId };
188
+ if (stmt) body.statementDate = stmt;
189
+ const r = await fetch('/dash/v1/account/retrieveTransactions', {
190
+ method: 'POST',
191
+ headers: { 'Content-Type': 'application/json', 'CSRF-Token': params.csrf, 'csrfToken': params.csrf },
192
+ body: JSON.stringify(body)
193
+ });
194
+ return r.ok ? await r.json() : { transactions: [] };
195
+ };
196
+ const auth = await call('AUTHORIZED');
197
+ const post = await call('POSTED');
198
+ const stmt = params.lastStmt ? await call('STATEMENTED', params.lastStmt) : { transactions: [] };
199
+ return [...(auth.transactions || []), ...(post.transactions || []), ...(stmt.transactions || [])];
200
+ }
201
+ """
202
+
203
+ raw_txs = page.evaluate(js_script, {
204
+ "transient": transient, "accId": acc_id, "lastStmt": last_stmt, "csrf": csrf
205
+ })
206
+
207
+ # Filtro de data bruto
208
+ filtered = [t for t in raw_txs if t.get("tranDate", "") >= start_date]
209
+ browser.close()
210
+ return filtered
211
+
212
+ except Exception as e:
213
+ print(f"[ERRO] Falha na captura: {e}")
214
+ browser.close()
215
+ return []
216
+
217
+ def get_balance(self):
218
+ return self.account_data.get("currentBalanceAmt", 0.0) if self.account_data else "N/A"
File without changes
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: triangle-api
3
+ Version: 0.1.0
4
+ Summary: Unofficial Python wrapper for Canadian Tire Triangle Mastercard API
5
+ Home-page: https://github.com/diogobas/triangle-api
6
+ Author: Diogo Bastos
7
+ Author-email: seu-email@exemplo.com
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.8
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: playwright>=1.40.0
14
+ Dynamic: author
15
+ Dynamic: author-email
16
+ Dynamic: classifier
17
+ Dynamic: description
18
+ Dynamic: description-content-type
19
+ Dynamic: home-page
20
+ Dynamic: requires-dist
21
+ Dynamic: requires-python
22
+ Dynamic: summary
23
+
24
+ # Triangle Mastercard API (Unofficial) 🇨🇦
25
+
26
+ [![Python Version](https://img.shields.io/badge/python-3.8%2B-blue)](https://www.python.org/downloads/)
27
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
28
+ [![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://github.com/diogobas/triangle-api/graphs/commit-activity)
29
+
30
+ Um wrapper em Python para a API privada do **Triangle Mastercard (Canadian Tire Financial Services)**.
31
+
32
+ Este projeto realiza a engenharia reversa do portal web oficial para extrair informações de saldo, limites e transações, permitindo a integração dos seus dados financeiros em seus próprios aplicativos, bots ou planilhas.
33
+
34
+ ---
35
+
36
+ ## ⚠️ Aviso Legal (Disclaimer)
37
+
38
+ **Este projeto não é afiliado, mantido, autorizado, endossado ou patrocinado pela Canadian Tire Financial Services (CTFS) ou qualquer uma de suas afiliadas.**
39
+
40
+ Este é um software independente para fins educacionais e de uso pessoal. O uso de automação para acessar contas bancárias pode violar os Termos de Serviço da instituição. Use com responsabilidade e por sua conta e risco.
41
+
42
+ ---
43
+
44
+ ## ✨ Funcionalidades
45
+
46
+ - [x] **Autenticação Automatizada**: Realiza o login via Playwright lidando com proteção de bots.
47
+ - [x] **Suporte a MFA**: Detecta e solicita o código de SMS/Segurança via terminal.
48
+ - [x] **Persistência de Sessão**: Salva cookies localmente para evitar logins constantes e reduzir o risco de bloqueio.
49
+ - [x] **Resumo da Conta**: Saldo atual, crédito disponível, limite total e data de vencimento.
50
+ - [ ] **Histórico de Transações**: (Em desenvolvimento)
51
+ - [ ] **Triangle Rewards**: Saldo de Canadian Tire Money (Em desenvolvimento)
52
+
53
+ ---
54
+
55
+ ## 🚀 Instalação
56
+
57
+ 1. Clone o repositório:
58
+ `git clone https://github.com/diogobas/triangle-api.git
59
+ cd triangle-api`
60
+
61
+ 2. Instale as dependências: `pip install -r requirements.txt`
62
+
63
+ 3. Instale o navegador necessário pelo Playwright: `playwright install chromium`
64
+
65
+ 💻 Exemplo de Uso
66
+
67
+ ```
68
+ from triangle_api.client
69
+ import TriangleClient
70
+ import os
71
+
72
+ # Recomenda-se usar variáveis de ambiente para segurança
73
+ USER = "seu_email@exemplo.com"
74
+ PASSWORD = "sua_senha_secreta"
75
+
76
+ # Instancia o cliente
77
+ client = TriangleClient(username=USER, password=PASSWORD)
78
+
79
+ # Realiza o login (abre o navegador na primeira vez, usa cookies nas próximas)
80
+ # headless=False permite ver o navegador operando
81
+ data = client.login(headless=True)
82
+
83
+ print(f"Saldo Atual: $ {client.get_balance()}")
84
+ print(f"Crédito Disponível: $ {data.get('availableCreditAmt')}")
85
+ ```
86
+
87
+ 🔒 Segurança e Persistência
88
+
89
+ Para evitar que o banco suspeite de múltiplos logins, este wrapper utiliza o
90
+ recurso storage_state do Playwright.
91
+
92
+ - Após o primeiro login bem-sucedido, um arquivo auth_state.json é criado.
93
+ - Nas próximas execuções, o script tentará usar esses cookies para acessar o
94
+ Dashboard diretamente.
95
+ - Atenção: Nunca envie o arquivo auth_state.json para o GitHub (ele já está no
96
+ .gitignore).
97
+
98
+ 🛠️ Desenvolvimento
99
+
100
+ Se você quiser contribuir para o projeto mapeando novos endpoints (como
101
+ transações ou ofertas), siga estes passos:
102
+
103
+ 1. Faça o login no site da CTFS com o modo Inspecionar (F12) aberto.
104
+ 2. Filtre por chamadas XHR/Fetch.
105
+ 3. Procure por endpoints como retrieveTransactions ou retrieveLoyalty.
106
+ 4. Mapeie o JSON de resposta na classe TriangleClient.
@@ -0,0 +1,7 @@
1
+ triangle_api/__init__.py,sha256=Hqljw3wq6ZoSGtTxQ5Qal-hZ5xpfi3E9dcESDsPOGWo,86
2
+ triangle_api/client.py,sha256=77C9NNJzJkv8bRPzY8yOe8Tm4_fgAdw44pjJm49uIL0,10142
3
+ triangle_api/exceptions.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ triangle_api-0.1.0.dist-info/METADATA,sha256=yo4BK2E_xQ86ynJOkEKKGugIS-G8dr-mmOWuofETd9Y,4092
5
+ triangle_api-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ triangle_api-0.1.0.dist-info/top_level.txt,sha256=NEiajlC91gYphugqkd5mddaVv1h4y_XldZhRULzWllE,13
7
+ triangle_api-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ triangle_api