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/__init__.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
notify-utils
|
|
3
|
+
|
|
4
|
+
Biblioteca para parsing de preços, cálculo de descontos e análise estatística de histórico de preços.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .models import (
|
|
8
|
+
Product,
|
|
9
|
+
Price,
|
|
10
|
+
PriceHistory,
|
|
11
|
+
DiscountResult,
|
|
12
|
+
HistoryDiscountResult,
|
|
13
|
+
DiscountInfo,
|
|
14
|
+
PriceTrend,
|
|
15
|
+
PriceComparisonStatus,
|
|
16
|
+
PriceAction,
|
|
17
|
+
PriceAdditionStrategy,
|
|
18
|
+
PriceComparisonResult
|
|
19
|
+
)
|
|
20
|
+
from .parser import parse_price
|
|
21
|
+
from .discount import (
|
|
22
|
+
calculate_discount_percentage,
|
|
23
|
+
calculate_discount_absolute,
|
|
24
|
+
is_real_discount,
|
|
25
|
+
calculate_discount_from_history,
|
|
26
|
+
get_discount_info,
|
|
27
|
+
get_best_discount_from_history
|
|
28
|
+
)
|
|
29
|
+
from .statistics import (
|
|
30
|
+
calculate_mean,
|
|
31
|
+
calculate_median,
|
|
32
|
+
get_min_max,
|
|
33
|
+
filter_by_period,
|
|
34
|
+
filter_by_period_range,
|
|
35
|
+
calculate_volatility,
|
|
36
|
+
calculate_price_trend,
|
|
37
|
+
calculate_price_statistics,
|
|
38
|
+
days_since_most_recent_price,
|
|
39
|
+
get_recommended_period
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Import de notifiers
|
|
43
|
+
from .notifiers import (
|
|
44
|
+
DiscordEmbedBuilder,
|
|
45
|
+
format_price,
|
|
46
|
+
format_price_history,
|
|
47
|
+
get_unique_prices
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__version__ = "0.0.1"
|
|
51
|
+
__all__ = [
|
|
52
|
+
# Models
|
|
53
|
+
"Product",
|
|
54
|
+
"Price",
|
|
55
|
+
"PriceHistory",
|
|
56
|
+
"DiscountResult",
|
|
57
|
+
"HistoryDiscountResult",
|
|
58
|
+
"DiscountInfo",
|
|
59
|
+
"PriceTrend",
|
|
60
|
+
# Price Comparison
|
|
61
|
+
"PriceComparisonStatus",
|
|
62
|
+
"PriceAction",
|
|
63
|
+
"PriceAdditionStrategy",
|
|
64
|
+
"PriceComparisonResult",
|
|
65
|
+
# Parser
|
|
66
|
+
"parse_price",
|
|
67
|
+
# Discount functions
|
|
68
|
+
"calculate_discount_percentage",
|
|
69
|
+
"calculate_discount_absolute",
|
|
70
|
+
"is_real_discount",
|
|
71
|
+
"calculate_discount_from_history",
|
|
72
|
+
"get_discount_info",
|
|
73
|
+
"get_best_discount_from_history",
|
|
74
|
+
# Statistics
|
|
75
|
+
"calculate_mean",
|
|
76
|
+
"calculate_median",
|
|
77
|
+
"get_min_max",
|
|
78
|
+
"filter_by_period",
|
|
79
|
+
"filter_by_period_range",
|
|
80
|
+
"calculate_volatility",
|
|
81
|
+
"calculate_price_trend",
|
|
82
|
+
"calculate_price_statistics",
|
|
83
|
+
"days_since_most_recent_price",
|
|
84
|
+
"get_recommended_period",
|
|
85
|
+
# Notifiers (opcionais)
|
|
86
|
+
"DiscordEmbedBuilder",
|
|
87
|
+
"format_price",
|
|
88
|
+
"format_price_history",
|
|
89
|
+
"get_unique_prices",
|
|
90
|
+
]
|
notify_utils/discount.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cálculos de desconto e validação de promoções.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Optional, List
|
|
6
|
+
from .models import Price, PriceHistory, HistoryDiscountResult, DiscountInfo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def calculate_discount_percentage(old_price: float, new_price: float) -> float:
|
|
10
|
+
"""
|
|
11
|
+
Calcula o percentual de desconto entre dois preços.
|
|
12
|
+
|
|
13
|
+
Formula: ((preço_antigo - preço_novo) / preço_antigo) * 100
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
old_price: Preço anterior (original)
|
|
17
|
+
new_price: Preço atual (com desconto)
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Percentual de desconto (valores positivos indicam desconto, negativos indicam aumento)
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
ValueError: Se algum preço for negativo ou preço antigo for zero
|
|
24
|
+
"""
|
|
25
|
+
if old_price < 0 or new_price < 0:
|
|
26
|
+
raise ValueError("Preços não podem ser negativos")
|
|
27
|
+
|
|
28
|
+
if old_price == 0:
|
|
29
|
+
raise ValueError("Preço antigo não pode ser zero")
|
|
30
|
+
|
|
31
|
+
discount = ((old_price - new_price) / old_price) * 100
|
|
32
|
+
return round(discount, 2)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def calculate_discount_absolute(old_price: float, new_price: float) -> float:
|
|
36
|
+
"""
|
|
37
|
+
Calcula o valor absoluto do desconto.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
old_price: Preço anterior
|
|
41
|
+
new_price: Preço atual
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Diferença entre os preços (positivo = desconto, negativo = aumento)
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ValueError: Se algum preço for negativo
|
|
48
|
+
"""
|
|
49
|
+
if old_price < 0 or new_price < 0:
|
|
50
|
+
raise ValueError("Preços não podem ser negativos")
|
|
51
|
+
|
|
52
|
+
return round(old_price - new_price, 2)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_real_discount(
|
|
56
|
+
current_price: float,
|
|
57
|
+
advertised_old_price: Optional[float] = None,
|
|
58
|
+
price_history: Optional[List[Price]] = None,
|
|
59
|
+
threshold_percentage: float = 5.0
|
|
60
|
+
) -> bool:
|
|
61
|
+
"""
|
|
62
|
+
Verifica se um desconto anunciado é real.
|
|
63
|
+
|
|
64
|
+
Estratégia:
|
|
65
|
+
- COM histórico: Ignora advertised_old_price, compara preço atual com média histórica
|
|
66
|
+
- SEM histórico: Usa advertised_old_price como referência (primeira vez)
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
current_price: Preço atual anunciado
|
|
70
|
+
advertised_old_price: Preço "de" anunciado pela loja (usado apenas sem histórico)
|
|
71
|
+
price_history: Lista de preços históricos (opcional, mas prioritário)
|
|
72
|
+
threshold_percentage: Percentual mínimo para considerar desconto real (padrão: 5%)
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
True se o desconto for considerado real, False caso contrário
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ValueError: Se não houver histórico nem advertised_old_price
|
|
79
|
+
"""
|
|
80
|
+
# Se houver histórico, IGNORA advertised_old_price e usa apenas histórico
|
|
81
|
+
if price_history and len(price_history) > 0:
|
|
82
|
+
# Calcula a média dos preços históricos
|
|
83
|
+
avg_price = sum(p.value for p in price_history) / len(price_history)
|
|
84
|
+
|
|
85
|
+
# Calcula o desconto em relação à média histórica
|
|
86
|
+
discount_pct = ((avg_price - current_price) / avg_price) * 100
|
|
87
|
+
|
|
88
|
+
# Considera desconto real se for >= threshold_percentage
|
|
89
|
+
return discount_pct >= threshold_percentage
|
|
90
|
+
|
|
91
|
+
# Sem histórico: usa advertised_old_price (fallback para primeira vez)
|
|
92
|
+
if advertised_old_price is None:
|
|
93
|
+
raise ValueError("É necessário fornecer advertised_old_price ou price_history")
|
|
94
|
+
|
|
95
|
+
# Verifica se há desconto básico
|
|
96
|
+
return current_price < advertised_old_price
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def calculate_discount_from_history(
|
|
100
|
+
current_price: float,
|
|
101
|
+
price_history: List[Price],
|
|
102
|
+
period_days: Optional[int] = 30,
|
|
103
|
+
use_median: bool = False,
|
|
104
|
+
skip_recent_days: int = 0
|
|
105
|
+
) -> Optional[HistoryDiscountResult]:
|
|
106
|
+
"""
|
|
107
|
+
Calcula desconto baseado no histórico de preços (ignora preço "de" anunciado).
|
|
108
|
+
|
|
109
|
+
Compara o preço atual com a média (ou mediana) do histórico de um período.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
current_price: Preço atual do produto
|
|
113
|
+
price_history: Lista de preços históricos
|
|
114
|
+
period_days: Período em dias para calcular a referência (None = todos os preços)
|
|
115
|
+
use_median: Se True usa mediana, se False usa média (padrão)
|
|
116
|
+
skip_recent_days: Ignora os N dias mais recentes (útil para evitar ruído, padrão: 0)
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
HistoryDiscountResult com informações do desconto ou None se não houver histórico suficiente
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
>>> # Ignora preços de 0-2 dias, usa apenas 3-30 dias
|
|
123
|
+
>>> result = calculate_discount_from_history(
|
|
124
|
+
... current_price=899.90,
|
|
125
|
+
... price_history=prices,
|
|
126
|
+
... period_days=30,
|
|
127
|
+
... skip_recent_days=3
|
|
128
|
+
... )
|
|
129
|
+
"""
|
|
130
|
+
if not price_history or len(price_history) == 0:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
# Importa funções de statistics
|
|
134
|
+
from .statistics import filter_by_period, filter_by_period_range, calculate_mean, calculate_median
|
|
135
|
+
|
|
136
|
+
# Filtra por período
|
|
137
|
+
if skip_recent_days > 0 and period_days:
|
|
138
|
+
# Usa intervalo: ignora dias recentes
|
|
139
|
+
filtered_prices = filter_by_period_range(price_history, skip_recent_days, period_days)
|
|
140
|
+
elif period_days:
|
|
141
|
+
# Comportamento padrão: de hoje até period_days
|
|
142
|
+
filtered_prices = filter_by_period(price_history, period_days)
|
|
143
|
+
else:
|
|
144
|
+
# Sem filtro de período
|
|
145
|
+
filtered_prices = price_history
|
|
146
|
+
|
|
147
|
+
if not filtered_prices:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
# Calcula preço de referência (média ou mediana)
|
|
151
|
+
if use_median:
|
|
152
|
+
reference_price = calculate_median(filtered_prices)
|
|
153
|
+
method = 'median'
|
|
154
|
+
else:
|
|
155
|
+
reference_price = calculate_mean(filtered_prices)
|
|
156
|
+
method = 'mean'
|
|
157
|
+
|
|
158
|
+
if reference_price is None:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
# Calcula desconto
|
|
162
|
+
discount_pct = calculate_discount_percentage(reference_price, current_price)
|
|
163
|
+
discount_abs = calculate_discount_absolute(reference_price, current_price)
|
|
164
|
+
|
|
165
|
+
return HistoryDiscountResult(
|
|
166
|
+
current_price=current_price,
|
|
167
|
+
reference_price=reference_price,
|
|
168
|
+
discount_percentage=discount_pct,
|
|
169
|
+
discount_absolute=discount_abs,
|
|
170
|
+
is_real_discount=discount_pct > 0, # Positivo = desconto real
|
|
171
|
+
calculation_method=method,
|
|
172
|
+
period_days=period_days,
|
|
173
|
+
samples_count=len(filtered_prices)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_discount_info(
|
|
178
|
+
current_price: float,
|
|
179
|
+
price_history: Optional[List[Price]] = None,
|
|
180
|
+
advertised_old_price: Optional[float] = None,
|
|
181
|
+
period_days: int = 30,
|
|
182
|
+
use_median: bool = False,
|
|
183
|
+
auto_adjust_period: bool = True,
|
|
184
|
+
skip_recent_days: int = 0
|
|
185
|
+
) -> DiscountInfo:
|
|
186
|
+
"""
|
|
187
|
+
Função inteligente que calcula desconto automaticamente escolhendo a melhor estratégia.
|
|
188
|
+
|
|
189
|
+
Estratégia:
|
|
190
|
+
- COM histórico: Usa média/mediana do histórico (ignora advertised_old_price)
|
|
191
|
+
- SEM histórico: Usa advertised_old_price (primeira vez)
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
current_price: Preço atual do produto
|
|
195
|
+
price_history: Lista de preços históricos (opcional, mas prioritário)
|
|
196
|
+
advertised_old_price: Preço "de" anunciado (usado apenas sem histórico)
|
|
197
|
+
period_days: Período para cálculo da média histórica (padrão: 30 dias)
|
|
198
|
+
use_median: Se True usa mediana, se False usa média
|
|
199
|
+
auto_adjust_period: Se True, ajusta period_days automaticamente para incluir
|
|
200
|
+
o histórico mais recente (recomendado: True)
|
|
201
|
+
skip_recent_days: Ignora os N dias mais recentes do histórico (padrão: 0)
|
|
202
|
+
Útil para evitar ruído de dados muito recentes
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
DiscountInfo com informações completas do desconto
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
ValueError: Se não houver histórico nem advertised_old_price
|
|
209
|
+
|
|
210
|
+
Example:
|
|
211
|
+
>>> # Histórico de 33 dias atrás, period_days=30
|
|
212
|
+
>>> prices = [Price(1299.90, datetime.now() - timedelta(days=33))]
|
|
213
|
+
>>> info = get_discount_info(899.90, prices, period_days=30, auto_adjust_period=True)
|
|
214
|
+
>>> info.period_days # 30 (solicitado)
|
|
215
|
+
>>> info.adjusted_period_days # 33 (ajustado)
|
|
216
|
+
>>> info.days_since_most_recent # 33
|
|
217
|
+
|
|
218
|
+
>>> # Ignorar preços de 0-2 dias, usar apenas 3-30 dias
|
|
219
|
+
>>> info = get_discount_info(899.90, prices, period_days=30, skip_recent_days=3)
|
|
220
|
+
>>> info.skip_recent_days # 3
|
|
221
|
+
"""
|
|
222
|
+
# Estratégia 1: COM histórico - usa apenas histórico
|
|
223
|
+
if price_history and len(price_history) > 0:
|
|
224
|
+
# Importa funções de statistics
|
|
225
|
+
from .statistics import days_since_most_recent_price, get_recommended_period
|
|
226
|
+
|
|
227
|
+
# Calcula dias desde o mais recente
|
|
228
|
+
days_since = days_since_most_recent_price(price_history)
|
|
229
|
+
|
|
230
|
+
# Ajusta período se necessário
|
|
231
|
+
actual_period = period_days
|
|
232
|
+
if auto_adjust_period:
|
|
233
|
+
actual_period = get_recommended_period(price_history, period_days)
|
|
234
|
+
|
|
235
|
+
history_discount = calculate_discount_from_history(
|
|
236
|
+
current_price=current_price,
|
|
237
|
+
price_history=price_history,
|
|
238
|
+
period_days=actual_period,
|
|
239
|
+
use_median=use_median,
|
|
240
|
+
skip_recent_days=skip_recent_days
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if history_discount:
|
|
244
|
+
return DiscountInfo(
|
|
245
|
+
current_price=history_discount.current_price,
|
|
246
|
+
reference_price=history_discount.reference_price,
|
|
247
|
+
discount_percentage=history_discount.discount_percentage,
|
|
248
|
+
discount_absolute=history_discount.discount_absolute,
|
|
249
|
+
is_real_discount=history_discount.is_real_discount,
|
|
250
|
+
strategy='history',
|
|
251
|
+
has_history=True,
|
|
252
|
+
calculation_method=history_discount.calculation_method,
|
|
253
|
+
period_days=period_days, # Período solicitado
|
|
254
|
+
samples_count=history_discount.samples_count,
|
|
255
|
+
adjusted_period_days=actual_period if auto_adjust_period else None,
|
|
256
|
+
days_since_most_recent=days_since,
|
|
257
|
+
skip_recent_days=skip_recent_days if skip_recent_days > 0 else None
|
|
258
|
+
)
|
|
259
|
+
else:
|
|
260
|
+
# Se nenhum preço no período, mas temos histórico, use todos os preços
|
|
261
|
+
history_discount = calculate_discount_from_history(
|
|
262
|
+
current_price=current_price,
|
|
263
|
+
price_history=price_history,
|
|
264
|
+
period_days=None, # Usa todos os preços disponíveis
|
|
265
|
+
use_median=use_median,
|
|
266
|
+
skip_recent_days=0 # Não pula dias quando usa todos
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if history_discount:
|
|
270
|
+
return DiscountInfo(
|
|
271
|
+
current_price=history_discount.current_price,
|
|
272
|
+
reference_price=history_discount.reference_price,
|
|
273
|
+
discount_percentage=history_discount.discount_percentage,
|
|
274
|
+
discount_absolute=history_discount.discount_absolute,
|
|
275
|
+
is_real_discount=history_discount.is_real_discount,
|
|
276
|
+
strategy='history',
|
|
277
|
+
has_history=True,
|
|
278
|
+
calculation_method=history_discount.calculation_method,
|
|
279
|
+
period_days=period_days, # Período solicitado originalmente
|
|
280
|
+
samples_count=history_discount.samples_count,
|
|
281
|
+
adjusted_period_days=None, # Usou todos os preços, não apenas período
|
|
282
|
+
days_since_most_recent=days_since,
|
|
283
|
+
skip_recent_days=None # Fallback não usa skip
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# Estratégia 2: SEM histórico - usa advertised_old_price
|
|
287
|
+
if advertised_old_price is None:
|
|
288
|
+
raise ValueError("É necessário fornecer price_history ou advertised_old_price")
|
|
289
|
+
|
|
290
|
+
discount_pct = calculate_discount_percentage(advertised_old_price, current_price)
|
|
291
|
+
discount_abs = calculate_discount_absolute(advertised_old_price, current_price)
|
|
292
|
+
|
|
293
|
+
return DiscountInfo(
|
|
294
|
+
current_price=current_price,
|
|
295
|
+
reference_price=advertised_old_price,
|
|
296
|
+
discount_percentage=discount_pct,
|
|
297
|
+
discount_absolute=discount_abs,
|
|
298
|
+
is_real_discount=discount_pct > 0, # Assume verdadeiro na primeira vez
|
|
299
|
+
strategy='advertised',
|
|
300
|
+
has_history=False,
|
|
301
|
+
calculation_method='advertised',
|
|
302
|
+
period_days=None,
|
|
303
|
+
samples_count=None,
|
|
304
|
+
adjusted_period_days=None,
|
|
305
|
+
days_since_most_recent=None,
|
|
306
|
+
skip_recent_days=None
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def get_best_discount_from_history(price_history: PriceHistory) -> Optional[HistoryDiscountResult]:
|
|
311
|
+
"""
|
|
312
|
+
Encontra o maior desconto válido comparando o preço atual com a média histórica.
|
|
313
|
+
|
|
314
|
+
DEPRECATED: Use calculate_discount_from_history() ou get_discount_info()
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
price_history: Histórico de preços do produto
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
HistoryDiscountResult com informações do melhor desconto ou None se não houver desconto
|
|
321
|
+
"""
|
|
322
|
+
current = price_history.get_current_price()
|
|
323
|
+
|
|
324
|
+
if not current or len(price_history.prices) < 2:
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
# Usa calculate_discount_from_history internamente
|
|
328
|
+
historical_prices = price_history.prices[1:] # Exclui o preço atual
|
|
329
|
+
|
|
330
|
+
return calculate_discount_from_history(
|
|
331
|
+
current_price=current.value,
|
|
332
|
+
price_history=historical_prices,
|
|
333
|
+
period_days=None, # Usa todos os preços históricos
|
|
334
|
+
use_median=False # Usa média
|
|
335
|
+
)
|