notify-utils 0.0.1__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.
- notify_utils/__init__.py +90 -0
- notify_utils/discount.py +335 -0
- notify_utils/models.py +532 -0
- notify_utils/notifiers/__init__.py +13 -0
- notify_utils/notifiers/discord_notifier.py +127 -0
- notify_utils/notifiers/formatters.py +108 -0
- notify_utils/parser.py +109 -0
- notify_utils/statistics.py +362 -0
- notify_utils/validators.py +98 -0
- notify_utils-0.0.1.dist-info/METADATA +133 -0
- notify_utils-0.0.1.dist-info/RECORD +14 -0
- notify_utils-0.0.1.dist-info/WHEEL +5 -0
- notify_utils-0.0.1.dist-info/licenses/LICENSE +21 -0
- notify_utils-0.0.1.dist-info/top_level.txt +1 -0
notify_utils/models.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Modelos de dados para produtos e preços.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from itertools import product
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PriceComparisonStatus(Enum):
|
|
13
|
+
"""Status da comparação entre preços."""
|
|
14
|
+
DECREASED = "decreased" # Preço caiu
|
|
15
|
+
INCREASED = "increased" # Preço subiu
|
|
16
|
+
EQUAL = "equal" # Preço igual
|
|
17
|
+
FIRST_PRICE = "first_price" # Primeiro preço (histórico vazio)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PriceAction(Enum):
|
|
21
|
+
"""Ação realizada ao adicionar preço."""
|
|
22
|
+
ADDED = "added" # Novo preço adicionado
|
|
23
|
+
UPDATED = "updated" # Preço existente atualizado (ex: data)
|
|
24
|
+
REJECTED = "rejected" # Não foi aceito
|
|
25
|
+
NONE = "none" # Nenhuma ação (apenas comparação)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PriceAdditionStrategy(Enum):
|
|
29
|
+
"""Estratégia para adição de preços ao histórico."""
|
|
30
|
+
ALWAYS = "always" # Sempre adiciona
|
|
31
|
+
ONLY_DECREASE = "only_decrease" # Só adiciona se preço caiu
|
|
32
|
+
SMART = "smart" # Inteligente (default)
|
|
33
|
+
UPDATE_ON_EQUAL = "update_on_equal" # Atualiza data se preço igual
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Product:
|
|
38
|
+
"""
|
|
39
|
+
Representa um produto com informações básicas.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
product_id: Identificador único do produto
|
|
43
|
+
name: Nome do produto
|
|
44
|
+
url: URL do produto (opcional, para link no embed)
|
|
45
|
+
image_url: URL da imagem do produto (opcional, para thumbnail)
|
|
46
|
+
"""
|
|
47
|
+
product_id: str
|
|
48
|
+
name: str
|
|
49
|
+
url: Optional[str] = None
|
|
50
|
+
image_url: Optional[str] = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Price:
|
|
55
|
+
"""
|
|
56
|
+
Representa um preço em um determinado momento.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
value: Valor do preço (sempre em formato decimal, ex: 1299.90)
|
|
60
|
+
date: Data e hora do preço
|
|
61
|
+
currency: Código da moeda (ex: "BRL", "USD")
|
|
62
|
+
source: Fonte do preço (ex: "mercadolivre", "amazon")
|
|
63
|
+
"""
|
|
64
|
+
value: float
|
|
65
|
+
date: datetime
|
|
66
|
+
currency: str = "BRL"
|
|
67
|
+
source: Optional[str] = None
|
|
68
|
+
|
|
69
|
+
def __post_init__(self):
|
|
70
|
+
if self.value < 0:
|
|
71
|
+
raise ValueError("Preço não pode ser negativo")
|
|
72
|
+
if not isinstance(self.date, datetime):
|
|
73
|
+
raise TypeError("date deve ser um objeto datetime")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class ProductWithCurrentPriceAndOldPrice:
|
|
78
|
+
"""
|
|
79
|
+
Combina um produto com seu preço atual e antigo.
|
|
80
|
+
Será usado usualmente para ser a entrada e conversões de API.
|
|
81
|
+
"""
|
|
82
|
+
product: Product
|
|
83
|
+
current_price: Price
|
|
84
|
+
old_price: Optional[Price] = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class ProductWithPriceHistory:
|
|
89
|
+
"""
|
|
90
|
+
Combina um produto com seu histórico de preços.
|
|
91
|
+
Usado para análises baseadas em histórico.
|
|
92
|
+
|
|
93
|
+
Attributes:
|
|
94
|
+
product: Objeto Product
|
|
95
|
+
price_history: Objeto PriceHistory
|
|
96
|
+
"""
|
|
97
|
+
product: Product
|
|
98
|
+
price_history: 'PriceHistory'
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class PriceHistory:
|
|
102
|
+
"""
|
|
103
|
+
Representa o histórico de preços de um produto.
|
|
104
|
+
|
|
105
|
+
Attributes:
|
|
106
|
+
product_id: Identificador do produto
|
|
107
|
+
prices: Lista de preços ordenada por data (mais recente primeiro)
|
|
108
|
+
"""
|
|
109
|
+
product_id: str
|
|
110
|
+
prices: List[Price]
|
|
111
|
+
|
|
112
|
+
def __post_init__(self):
|
|
113
|
+
# Cria cópia para não modificar a lista original do usuário
|
|
114
|
+
self.prices = self.prices.copy()
|
|
115
|
+
# Ordena preços por data (mais recente primeiro)
|
|
116
|
+
self.prices.sort(key=lambda p: p.date, reverse=True)
|
|
117
|
+
|
|
118
|
+
def get_current_price(self) -> Optional[Price]:
|
|
119
|
+
"""Retorna o preço mais recente."""
|
|
120
|
+
return self.prices[0] if self.prices else None
|
|
121
|
+
|
|
122
|
+
def get_previous_price(self) -> Optional[Price]:
|
|
123
|
+
"""Retorna o penúltimo preço."""
|
|
124
|
+
return self.prices[1] if len(self.prices) > 1 else None
|
|
125
|
+
|
|
126
|
+
def compare_price(
|
|
127
|
+
self,
|
|
128
|
+
new_price: 'Price',
|
|
129
|
+
min_hours_for_increase: int = 24
|
|
130
|
+
) -> 'PriceComparisonResult':
|
|
131
|
+
"""
|
|
132
|
+
Compara novo preço com histórico SEM modificar.
|
|
133
|
+
|
|
134
|
+
Útil para análise antes de decidir se adiciona.
|
|
135
|
+
Não altera self.prices.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
new_price: Preço a ser comparado
|
|
139
|
+
min_hours_for_increase: Horas mínimas para aceitar aumento
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
PriceComparisonResult com action=NONE
|
|
143
|
+
"""
|
|
144
|
+
current = self.get_current_price()
|
|
145
|
+
|
|
146
|
+
# Caso 1: Histórico vazio
|
|
147
|
+
if not current:
|
|
148
|
+
return PriceComparisonResult(
|
|
149
|
+
status=PriceComparisonStatus.FIRST_PRICE,
|
|
150
|
+
current_price=None,
|
|
151
|
+
new_price=new_price,
|
|
152
|
+
value_difference=0.0,
|
|
153
|
+
percentage_difference=0.0,
|
|
154
|
+
time_difference_hours=None,
|
|
155
|
+
should_add=True,
|
|
156
|
+
reason="first price in history",
|
|
157
|
+
action=PriceAction.NONE
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Calcular diferenças
|
|
161
|
+
value_diff = new_price.value - current.value
|
|
162
|
+
pct_diff = (value_diff / current.value) * 100 if current.value > 0 else 0.0
|
|
163
|
+
time_diff = (new_price.date - current.date).total_seconds() / 3600 # horas
|
|
164
|
+
|
|
165
|
+
# Determinar status
|
|
166
|
+
if new_price.value < current.value:
|
|
167
|
+
status = PriceComparisonStatus.DECREASED
|
|
168
|
+
should_add = True
|
|
169
|
+
reason = "price decreased"
|
|
170
|
+
elif new_price.value > current.value:
|
|
171
|
+
status = PriceComparisonStatus.INCREASED
|
|
172
|
+
if time_diff >= min_hours_for_increase:
|
|
173
|
+
should_add = True
|
|
174
|
+
reason = f"price increased after {time_diff:.1f} hours"
|
|
175
|
+
else:
|
|
176
|
+
should_add = False
|
|
177
|
+
reason = f"recent increase (only {time_diff:.1f} hours, need {min_hours_for_increase})"
|
|
178
|
+
else: # igual
|
|
179
|
+
status = PriceComparisonStatus.EQUAL
|
|
180
|
+
should_add = False
|
|
181
|
+
reason = "price unchanged"
|
|
182
|
+
|
|
183
|
+
return PriceComparisonResult(
|
|
184
|
+
status=status,
|
|
185
|
+
current_price=current,
|
|
186
|
+
new_price=new_price,
|
|
187
|
+
value_difference=round(value_diff, 2),
|
|
188
|
+
percentage_difference=round(pct_diff, 2),
|
|
189
|
+
time_difference_hours=round(time_diff, 2),
|
|
190
|
+
should_add=should_add,
|
|
191
|
+
reason=reason,
|
|
192
|
+
action=PriceAction.NONE
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def add_price(
|
|
196
|
+
self,
|
|
197
|
+
price: 'Price',
|
|
198
|
+
strategy: 'PriceAdditionStrategy' = PriceAdditionStrategy.SMART,
|
|
199
|
+
min_hours_for_increase: int = 24
|
|
200
|
+
) -> 'PriceComparisonResult':
|
|
201
|
+
"""
|
|
202
|
+
Adiciona preço ao histórico baseado na estratégia.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
price: Preço a adicionar
|
|
206
|
+
strategy: Estratégia de adição (padrão: SMART)
|
|
207
|
+
min_hours_for_increase: Horas mínimas para aceitar aumento
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
PriceComparisonResult com action apropriada (ADDED, UPDATED, REJECTED)
|
|
211
|
+
"""
|
|
212
|
+
# Primeiro compara
|
|
213
|
+
result = self.compare_price(price, min_hours_for_increase)
|
|
214
|
+
|
|
215
|
+
# Aplica estratégia
|
|
216
|
+
if strategy == PriceAdditionStrategy.ALWAYS:
|
|
217
|
+
# Sempre adiciona - cria nova lista
|
|
218
|
+
new_prices = self.prices.copy()
|
|
219
|
+
new_prices.append(price)
|
|
220
|
+
new_prices.sort(key=lambda p: p.date, reverse=True)
|
|
221
|
+
self.prices = new_prices
|
|
222
|
+
result.action = PriceAction.ADDED
|
|
223
|
+
result.affected_price = price
|
|
224
|
+
result.reason = "always strategy"
|
|
225
|
+
|
|
226
|
+
elif strategy == PriceAdditionStrategy.ONLY_DECREASE:
|
|
227
|
+
# Só adiciona se caiu
|
|
228
|
+
if result.status == PriceComparisonStatus.DECREASED or result.status == PriceComparisonStatus.FIRST_PRICE:
|
|
229
|
+
new_prices = self.prices.copy()
|
|
230
|
+
new_prices.append(price)
|
|
231
|
+
new_prices.sort(key=lambda p: p.date, reverse=True)
|
|
232
|
+
self.prices = new_prices
|
|
233
|
+
result.action = PriceAction.ADDED
|
|
234
|
+
result.affected_price = price
|
|
235
|
+
else:
|
|
236
|
+
result.action = PriceAction.REJECTED
|
|
237
|
+
result.reason = "only_decrease strategy: price did not decrease"
|
|
238
|
+
|
|
239
|
+
elif strategy == PriceAdditionStrategy.UPDATE_ON_EQUAL:
|
|
240
|
+
# Se igual: atualiza data do último
|
|
241
|
+
if result.status == PriceComparisonStatus.EQUAL:
|
|
242
|
+
result.previous_price = self.prices[0]
|
|
243
|
+
new_prices = self.prices.copy()
|
|
244
|
+
new_prices[0] = price # Substitui (atualiza data)
|
|
245
|
+
self.prices = new_prices
|
|
246
|
+
result.action = PriceAction.UPDATED
|
|
247
|
+
result.affected_price = price
|
|
248
|
+
result.reason = "price equal, date updated"
|
|
249
|
+
elif result.should_add:
|
|
250
|
+
new_prices = self.prices.copy()
|
|
251
|
+
new_prices.append(price)
|
|
252
|
+
new_prices.sort(key=lambda p: p.date, reverse=True)
|
|
253
|
+
self.prices = new_prices
|
|
254
|
+
result.action = PriceAction.ADDED
|
|
255
|
+
result.affected_price = price
|
|
256
|
+
else:
|
|
257
|
+
result.action = PriceAction.REJECTED
|
|
258
|
+
|
|
259
|
+
else: # SMART (default)
|
|
260
|
+
# Adiciona se should_add=True (queda ou aumento após tempo)
|
|
261
|
+
if result.should_add:
|
|
262
|
+
new_prices = self.prices.copy()
|
|
263
|
+
new_prices.append(price)
|
|
264
|
+
new_prices.sort(key=lambda p: p.date, reverse=True)
|
|
265
|
+
self.prices = new_prices
|
|
266
|
+
result.action = PriceAction.ADDED
|
|
267
|
+
result.affected_price = price
|
|
268
|
+
else:
|
|
269
|
+
result.action = PriceAction.REJECTED
|
|
270
|
+
|
|
271
|
+
return result
|
|
272
|
+
|
|
273
|
+
def update_latest_price_date(self, new_date: datetime) -> bool:
|
|
274
|
+
"""
|
|
275
|
+
Atualiza data do preço mais recente sem mudar valor.
|
|
276
|
+
|
|
277
|
+
Útil quando preço está igual mas quer timestamp atualizado.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
new_date: Nova data para o preço mais recente
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
True se atualizado, False se histórico vazio
|
|
284
|
+
"""
|
|
285
|
+
if not self.prices:
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
# Cria novo Price com valor antigo e data nova
|
|
289
|
+
old_price = self.prices[0]
|
|
290
|
+
updated_price = Price(
|
|
291
|
+
value=old_price.value,
|
|
292
|
+
date=new_date,
|
|
293
|
+
currency=old_price.currency,
|
|
294
|
+
source=old_price.source
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Cria nova lista ao invés de modificar
|
|
298
|
+
new_prices = self.prices.copy()
|
|
299
|
+
new_prices[0] = updated_price
|
|
300
|
+
self.prices = new_prices
|
|
301
|
+
return True
|
|
302
|
+
|
|
303
|
+
def get_prices_by_currency(self, currency: str) -> List[Price]:
|
|
304
|
+
"""Retorna apenas preços de uma determinada moeda."""
|
|
305
|
+
return [p for p in self.prices if p.currency == currency]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@dataclass
|
|
309
|
+
class PriceComparisonResult:
|
|
310
|
+
"""
|
|
311
|
+
Resultado completo da comparação de preços.
|
|
312
|
+
|
|
313
|
+
Usado para:
|
|
314
|
+
- Análise de mudança de preço
|
|
315
|
+
- Decisão de adicionar ao histórico
|
|
316
|
+
- Integração com banco de dados
|
|
317
|
+
- Logging e notificações
|
|
318
|
+
|
|
319
|
+
Attributes:
|
|
320
|
+
status: Status da comparação (DECREASED, INCREASED, EQUAL, FIRST_PRICE)
|
|
321
|
+
current_price: Preço atual no histórico (None se vazio)
|
|
322
|
+
new_price: Novo preço sendo comparado
|
|
323
|
+
value_difference: Diferença em valor absoluto (R$)
|
|
324
|
+
percentage_difference: Diferença percentual (%)
|
|
325
|
+
time_difference_hours: Horas desde último preço (None se histórico vazio)
|
|
326
|
+
should_add: Sugestão se deve adicionar ao histórico
|
|
327
|
+
reason: Motivo da decisão (ex: "price decreased", "recent increase")
|
|
328
|
+
action: Ação realizada (ADDED, UPDATED, REJECTED, NONE)
|
|
329
|
+
affected_price: Objeto Price afetado (para persistência no BD)
|
|
330
|
+
previous_price: Estado anterior do preço (se action=UPDATED)
|
|
331
|
+
"""
|
|
332
|
+
status: PriceComparisonStatus
|
|
333
|
+
current_price: Optional['Price']
|
|
334
|
+
new_price: 'Price'
|
|
335
|
+
value_difference: float
|
|
336
|
+
percentage_difference: float
|
|
337
|
+
time_difference_hours: Optional[float]
|
|
338
|
+
should_add: bool
|
|
339
|
+
reason: str
|
|
340
|
+
action: PriceAction
|
|
341
|
+
affected_price: Optional['Price'] = None
|
|
342
|
+
previous_price: Optional['Price'] = None
|
|
343
|
+
|
|
344
|
+
def to_dict(self) -> dict:
|
|
345
|
+
"""Converte para dicionário (útil para logs/JSON)."""
|
|
346
|
+
return {
|
|
347
|
+
'status': self.status.value,
|
|
348
|
+
'current_price_value': self.current_price.value if self.current_price else None,
|
|
349
|
+
'new_price_value': self.new_price.value,
|
|
350
|
+
'value_difference': self.value_difference,
|
|
351
|
+
'percentage_difference': self.percentage_difference,
|
|
352
|
+
'time_difference_hours': self.time_difference_hours,
|
|
353
|
+
'should_add': self.should_add,
|
|
354
|
+
'reason': self.reason,
|
|
355
|
+
'action': self.action.value,
|
|
356
|
+
'affected_price_value': self.affected_price.value if self.affected_price else None,
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@dataclass
|
|
361
|
+
class DiscountResult:
|
|
362
|
+
"""
|
|
363
|
+
Resultado básico de cálculo de desconto.
|
|
364
|
+
|
|
365
|
+
Attributes:
|
|
366
|
+
current_price: Preço atual do produto
|
|
367
|
+
reference_price: Preço de referência usado para comparação
|
|
368
|
+
discount_percentage: Percentual de desconto (positivo = desconto, negativo = aumento)
|
|
369
|
+
discount_absolute: Valor absoluto do desconto
|
|
370
|
+
is_real_discount: Se o desconto é considerado real (percentual > 0)
|
|
371
|
+
"""
|
|
372
|
+
current_price: float
|
|
373
|
+
reference_price: float
|
|
374
|
+
discount_percentage: float
|
|
375
|
+
discount_absolute: float
|
|
376
|
+
is_real_discount: bool
|
|
377
|
+
|
|
378
|
+
def to_dict(self) -> dict:
|
|
379
|
+
"""Converte para dicionário (compatibilidade)."""
|
|
380
|
+
return {
|
|
381
|
+
'current_price': self.current_price,
|
|
382
|
+
'reference_price': self.reference_price,
|
|
383
|
+
'discount_percentage': self.discount_percentage,
|
|
384
|
+
'discount_absolute': self.discount_absolute,
|
|
385
|
+
'is_real_discount': self.is_real_discount
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@dataclass
|
|
390
|
+
class HistoryDiscountResult(DiscountResult):
|
|
391
|
+
"""
|
|
392
|
+
Resultado de desconto calculado baseado em histórico de preços.
|
|
393
|
+
|
|
394
|
+
Extends DiscountResult com informações sobre o cálculo histórico.
|
|
395
|
+
|
|
396
|
+
Attributes:
|
|
397
|
+
calculation_method: Método usado ('mean' ou 'median')
|
|
398
|
+
period_days: Período em dias usado para cálculo (None = todos os preços)
|
|
399
|
+
samples_count: Quantidade de amostras (preços) usadas no cálculo
|
|
400
|
+
"""
|
|
401
|
+
calculation_method: str # 'mean' ou 'median'
|
|
402
|
+
period_days: Optional[int] = None
|
|
403
|
+
samples_count: int = 0
|
|
404
|
+
|
|
405
|
+
def to_dict(self) -> dict:
|
|
406
|
+
"""Converte para dicionário (compatibilidade)."""
|
|
407
|
+
base = super().to_dict()
|
|
408
|
+
base.update({
|
|
409
|
+
'calculation_method': self.calculation_method,
|
|
410
|
+
'period_days': self.period_days,
|
|
411
|
+
'samples_count': self.samples_count
|
|
412
|
+
})
|
|
413
|
+
return base
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@dataclass
|
|
417
|
+
class DiscountInfo:
|
|
418
|
+
"""
|
|
419
|
+
Informações completas de desconto com estratégia de cálculo.
|
|
420
|
+
|
|
421
|
+
Usado pela função get_discount_info() que escolhe automaticamente
|
|
422
|
+
entre usar histórico ou preço anunciado.
|
|
423
|
+
|
|
424
|
+
Attributes:
|
|
425
|
+
current_price: Preço atual do produto
|
|
426
|
+
reference_price: Preço de referência usado (média histórica ou advertised)
|
|
427
|
+
discount_percentage: Percentual de desconto
|
|
428
|
+
discount_absolute: Valor absoluto do desconto
|
|
429
|
+
is_real_discount: Se o desconto é considerado real
|
|
430
|
+
strategy: Estratégia usada ('history' ou 'advertised')
|
|
431
|
+
has_history: Se havia histórico disponível
|
|
432
|
+
calculation_method: Método de cálculo ('mean', 'median' ou 'advertised')
|
|
433
|
+
period_days: Período usado (apenas se strategy='history')
|
|
434
|
+
samples_count: Quantidade de amostras (apenas se strategy='history')
|
|
435
|
+
adjusted_period_days: Período ajustado automaticamente (se auto_adjust_period=True)
|
|
436
|
+
days_since_most_recent: Dias desde o preço mais recente até hoje
|
|
437
|
+
skip_recent_days: Dias mais recentes ignorados (se > 0)
|
|
438
|
+
"""
|
|
439
|
+
current_price: float
|
|
440
|
+
reference_price: float
|
|
441
|
+
discount_percentage: float
|
|
442
|
+
discount_absolute: float
|
|
443
|
+
is_real_discount: bool
|
|
444
|
+
strategy: str # 'history' ou 'advertised'
|
|
445
|
+
has_history: bool
|
|
446
|
+
calculation_method: str # 'mean', 'median' ou 'advertised'
|
|
447
|
+
period_days: Optional[int] = None
|
|
448
|
+
samples_count: Optional[int] = None
|
|
449
|
+
adjusted_period_days: Optional[int] = None
|
|
450
|
+
days_since_most_recent: Optional[int] = None
|
|
451
|
+
skip_recent_days: Optional[int] = None
|
|
452
|
+
|
|
453
|
+
def to_dict(self) -> dict:
|
|
454
|
+
"""Converte para dicionário (compatibilidade)."""
|
|
455
|
+
return {
|
|
456
|
+
'current_price': self.current_price,
|
|
457
|
+
'reference_price': self.reference_price,
|
|
458
|
+
'discount_percentage': self.discount_percentage,
|
|
459
|
+
'discount_absolute': self.discount_absolute,
|
|
460
|
+
'is_real_discount': self.is_real_discount,
|
|
461
|
+
'strategy': self.strategy,
|
|
462
|
+
'has_history': self.has_history,
|
|
463
|
+
'calculation_method': self.calculation_method,
|
|
464
|
+
'period_days': self.period_days,
|
|
465
|
+
'samples_count': self.samples_count,
|
|
466
|
+
'adjusted_period_days': self.adjusted_period_days,
|
|
467
|
+
'days_since_most_recent': self.days_since_most_recent,
|
|
468
|
+
'skip_recent_days': self.skip_recent_days
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@dataclass
|
|
473
|
+
class PriceTrend:
|
|
474
|
+
"""
|
|
475
|
+
Análise detalhada de tendência de preços ao longo do tempo.
|
|
476
|
+
|
|
477
|
+
Compara períodos de tempo para detectar direção, magnitude e
|
|
478
|
+
confiabilidade da tendência.
|
|
479
|
+
|
|
480
|
+
Attributes:
|
|
481
|
+
direction: Direção da tendência ('increasing', 'decreasing', 'stable')
|
|
482
|
+
change_percentage: Percentual de mudança entre períodos (positivo = aumento)
|
|
483
|
+
recent_avg: Preço médio do período recente
|
|
484
|
+
previous_avg: Preço médio do período anterior
|
|
485
|
+
volatility: Volatilidade (desvio padrão) dos preços
|
|
486
|
+
confidence: Nível de confiança ('high', 'medium', 'low') baseado em amostras
|
|
487
|
+
samples_recent: Quantidade de amostras do período recente
|
|
488
|
+
samples_previous: Quantidade de amostras do período anterior
|
|
489
|
+
is_accelerating: Se a tendência está acelerando (mudança > 10%)
|
|
490
|
+
analysis_period_days: Período em dias usado para análise
|
|
491
|
+
"""
|
|
492
|
+
direction: str # 'increasing', 'decreasing', 'stable'
|
|
493
|
+
change_percentage: float
|
|
494
|
+
recent_avg: float
|
|
495
|
+
previous_avg: float
|
|
496
|
+
volatility: float
|
|
497
|
+
confidence: str # 'high', 'medium', 'low'
|
|
498
|
+
samples_recent: int
|
|
499
|
+
samples_previous: int
|
|
500
|
+
is_accelerating: bool
|
|
501
|
+
analysis_period_days: int
|
|
502
|
+
|
|
503
|
+
def to_dict(self) -> dict:
|
|
504
|
+
"""Converte para dicionário (compatibilidade)."""
|
|
505
|
+
return {
|
|
506
|
+
'direction': self.direction,
|
|
507
|
+
'change_percentage': self.change_percentage,
|
|
508
|
+
'recent_avg': self.recent_avg,
|
|
509
|
+
'previous_avg': self.previous_avg,
|
|
510
|
+
'volatility': self.volatility,
|
|
511
|
+
'confidence': self.confidence,
|
|
512
|
+
'samples_recent': self.samples_recent,
|
|
513
|
+
'samples_previous': self.samples_previous,
|
|
514
|
+
'is_accelerating': self.is_accelerating,
|
|
515
|
+
'analysis_period_days': self.analysis_period_days
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
def is_stable(self) -> bool:
|
|
519
|
+
"""Retorna True se a tendência é estável."""
|
|
520
|
+
return self.direction == 'stable'
|
|
521
|
+
|
|
522
|
+
def is_increasing(self) -> bool:
|
|
523
|
+
"""Retorna True se a tendência é de aumento."""
|
|
524
|
+
return self.direction == 'increasing'
|
|
525
|
+
|
|
526
|
+
def is_decreasing(self) -> bool:
|
|
527
|
+
"""Retorna True se a tendência é de queda."""
|
|
528
|
+
return self.direction == 'decreasing'
|
|
529
|
+
|
|
530
|
+
def has_high_confidence(self) -> bool:
|
|
531
|
+
"""Retorna True se a análise tem alta confiança."""
|
|
532
|
+
return self.confidence == 'high'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Notificadores para alertas de preço.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .discord_notifier import DiscordEmbedBuilder
|
|
6
|
+
from .formatters import format_price, format_price_history, get_unique_prices
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"DiscordEmbedBuilder",
|
|
10
|
+
"format_price",
|
|
11
|
+
"format_price_history",
|
|
12
|
+
"get_unique_prices",
|
|
13
|
+
]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Notificador para Discord usando webhooks.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import List, Optional, TYPE_CHECKING
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from discord_webhook import DiscordWebhook, DiscordEmbed
|
|
10
|
+
DISCORD_AVAILABLE = True
|
|
11
|
+
except ImportError:
|
|
12
|
+
DISCORD_AVAILABLE = False
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from discord_webhook import DiscordEmbed
|
|
15
|
+
|
|
16
|
+
from ..models import Product, Price, DiscountInfo
|
|
17
|
+
from .formatters import format_price, format_price_history
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DiscordEmbedBuilder:
|
|
21
|
+
|
|
22
|
+
def build_embed(
|
|
23
|
+
self,
|
|
24
|
+
product: Product,
|
|
25
|
+
discount_info: DiscountInfo,
|
|
26
|
+
price_history: List[Price]
|
|
27
|
+
) -> "DiscordEmbed":
|
|
28
|
+
"""
|
|
29
|
+
Constrói o embed Discord com informações do alerta.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
product: Produto
|
|
33
|
+
discount_info: Informações de desconto
|
|
34
|
+
price_history: Histórico de preços
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
DiscordEmbed configurado
|
|
38
|
+
"""
|
|
39
|
+
# Título
|
|
40
|
+
title = f"{discount_info.discount_percentage:.2f}% - {product.name}"
|
|
41
|
+
|
|
42
|
+
# Cor baseada no desconto
|
|
43
|
+
color = self._get_embed_color(discount_info.discount_percentage)
|
|
44
|
+
|
|
45
|
+
# Cria embed
|
|
46
|
+
embed = DiscordEmbed(
|
|
47
|
+
title=title,
|
|
48
|
+
# color=color
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Adiciona URL se disponível
|
|
52
|
+
if product.url:
|
|
53
|
+
embed.url = product.url
|
|
54
|
+
|
|
55
|
+
# Campo: Preço Atual
|
|
56
|
+
embed.add_embed_field(
|
|
57
|
+
name="Preco Atual",
|
|
58
|
+
value=format_price(discount_info.current_price),
|
|
59
|
+
inline=True
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Campo: Preço de Referência
|
|
63
|
+
ref_label = "mediana" if discount_info.strategy == "history" else "preco de"
|
|
64
|
+
embed.add_embed_field(
|
|
65
|
+
name="Preco de Referencia",
|
|
66
|
+
value=f"{format_price(discount_info.reference_price)} ({ref_label})",
|
|
67
|
+
inline=True
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Campo: Desconto (R$)
|
|
71
|
+
embed.add_embed_field(
|
|
72
|
+
name="Desconto (R$)",
|
|
73
|
+
value=format_price(abs(discount_info.discount_absolute)),
|
|
74
|
+
inline=True
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Campo: Desconto (%)
|
|
78
|
+
embed.add_embed_field(
|
|
79
|
+
name="Desconto (%)",
|
|
80
|
+
value=f"{discount_info.discount_percentage:.2f}%",
|
|
81
|
+
inline=True
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Campo: Histórico (últimos 5)
|
|
85
|
+
embed.add_embed_field(
|
|
86
|
+
name="Historico (ultimos 5)",
|
|
87
|
+
value=format_price_history(price_history, limit=5),
|
|
88
|
+
inline=False
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Thumbnail (imagem do produto)
|
|
92
|
+
if product.image_url:
|
|
93
|
+
embed.set_thumbnail(url=product.image_url)
|
|
94
|
+
|
|
95
|
+
# Footer com data/hora
|
|
96
|
+
# embed.set_footer(text=f"Notificacao gerada em {datetime.now().strftime('%d/%m/%Y as %H:%M')}")
|
|
97
|
+
|
|
98
|
+
# Timestamp
|
|
99
|
+
embed.set_timestamp()
|
|
100
|
+
|
|
101
|
+
return embed
|
|
102
|
+
|
|
103
|
+
def _get_embed_color(self, discount_percentage: float) -> int:
|
|
104
|
+
"""
|
|
105
|
+
Retorna cor do embed baseado no percentual de desconto.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
discount_percentage: Percentual de desconto (positivo = desconto, negativo = aumento)
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Cor em formato hexadecimal (int)
|
|
112
|
+
"""
|
|
113
|
+
if discount_percentage >= 20.0:
|
|
114
|
+
# Verde: desconto >= 20%
|
|
115
|
+
return 0x00FF00
|
|
116
|
+
elif discount_percentage >= 10.0:
|
|
117
|
+
# Amarelo: desconto entre 10-19%
|
|
118
|
+
return 0xFFFF00
|
|
119
|
+
elif discount_percentage >= 1.0:
|
|
120
|
+
# Azul: desconto entre 1-9%
|
|
121
|
+
return 0x0099FF
|
|
122
|
+
elif discount_percentage < 0:
|
|
123
|
+
# Vermelho: aumento de preço
|
|
124
|
+
return 0xFF0000
|
|
125
|
+
else:
|
|
126
|
+
# Cinza: estável (< 1%)
|
|
127
|
+
return 0x808080
|