diff --git a/Dockerfile b/Dockerfile
index 166d935..19f556b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,10 @@
FROM python:3.10-slim
-COPY . C:\app
-WORKDIR C:\app
+# Корректные Linux-пути для slim-образа
+WORKDIR /app
+COPY . /app
+
+ENV PYTHONUNBUFFERED=1
RUN python -m pip install --upgrade pip && \
python setup.py
diff --git a/tg_bot/bot.py b/tg_bot/bot.py
index d08faee..1272b9c 100644
--- a/tg_bot/bot.py
+++ b/tg_bot/bot.py
@@ -320,13 +320,14 @@ def send_profile(self, m: Message):
except:
self.bot.edit_message_text(_("profile_updating_error"), new_msg.chat.id, new_msg.id)
logger.debug("TRACEBACK", exc_info=True)
- self.bot.answer_callback_query(m.id)
return
def update_profile(self, c: CallbackQuery):
"""
Обновляет статистику аккаунта.
"""
+ # Подтверждаем callback сразу, чтобы убрать спиннер у пользователя
+ self.bot.answer_callback_query(c.id)
new_msg = self.bot.send_message(c.message.chat.id, _("updating_profile"))
try:
self.vertex.account.get()
@@ -341,7 +342,6 @@ def update_profile(self, c: CallbackQuery):
except:
self.bot.edit_message_text(_("profile_updating_error"), new_msg.chat.id, new_msg.id)
logger.debug("TRACEBACK", exc_info=True)
- self.bot.answer_callback_query(c.id)
return
def change_cookie(self, m: telebot.types.Message):
@@ -361,6 +361,8 @@ def update_adv_profile(self, c: CallbackQuery):
"""
Обновляет дополнительную статистику аккаунта.
"""
+ # Подтверждаем callback сразу, чтобы убрать спиннер у пользователя
+ self.bot.answer_callback_query(c.id)
new_msg = self.bot.send_message(c.message.chat.id, _("updating_profile"))
try:
self.vertex.account.get()
@@ -375,7 +377,6 @@ def update_adv_profile(self, c: CallbackQuery):
except:
self.bot.edit_message_text(_("profile_updating_error"), new_msg.chat.id, new_msg.id)
logger.debug("TRACEBACK", exc_info=True)
- self.bot.answer_callback_query(c.id)
return
def send_orders(self, m: telebot.types.Message):
@@ -789,6 +790,8 @@ def open_order_menu(self, c: CallbackQuery):
node_id, username, order_id, no_refund = int(split[1]), split[2], split[3], bool(int(split[4]))
self.bot.edit_message_reply_markup(c.message.chat.id, c.message.id,
reply_markup=kb.new_order(order_id, username, node_id, no_refund=no_refund))
+ # Подтверждаем callback, чтобы не показывался тайм-аут в клиенте Telegram
+ self.bot.answer_callback_query(c.id)
# Панель управления
def open_cp(self, c: CallbackQuery):
diff --git a/tg_bot/utils.py b/tg_bot/utils.py
index 2f0dc36..533df27 100644
--- a/tg_bot/utils.py
+++ b/tg_bot/utils.py
@@ -21,6 +21,7 @@
from bs4 import BeautifulSoup
from FunPayAPI.account import Account
from FunPayAPI.types import OrderStatuses
+import FunPayAPI.types as types
from FunPayAPI.updater.events import *
localizer = Localizer()
_ = localizer.translate
@@ -253,10 +254,32 @@ def message_hook(vertex: Vertex, event: NewMessageEvent):
with open("storage/cache/advProfileStat.json", "w", encoding="UTF-8") as f:
f.write(json.dumps(ORDER_CONFIRMED, indent=4, ensure_ascii=False))
-def extract_float(text):
- cleaned_text = re.sub(r'[^\d.,]', '', text)
- cleaned_text = cleaned_text.replace(',', '')
- return float(cleaned_text)
+def extract_float(text: str) -> float:
+ """
+ Преобразует строку с ценой в число с плавающей точкой.
+ Поддерживает форматы с пробелами/неразрывными пробелами и запятой/точкой как разделителем.
+ """
+ # Удаляем все, кроме цифр и разделителей, а также пробелы/неразрывные пробелы
+ s = re.sub(r"[^\d,\.]", "", text).replace("\u00A0", "").replace(" ", "")
+ if not s:
+ return 0.0
+ # Если присутствуют и точка, и запятая — считаем правый разделитель десятичным, остальные убираем
+ if "," in s and "." in s:
+ if s.rfind(",") > s.rfind("."):
+ # десятичный разделитель — запятая
+ s = s.replace(".", "")
+ s = s.replace(",", ".")
+ else:
+ # десятичный разделитель — точка
+ s = s.replace(",", "")
+ else:
+ # Только запятая — заменяем на точку
+ if "," in s:
+ s = s.replace(",", ".")
+ try:
+ return float(s)
+ except ValueError:
+ return 0.0
def get_sales(account: Account, start_from: str | None = None, include_paid: bool = True, include_closed: bool = True,
include_refunded: bool = True, exclude_ids: list[str] | None = None,
@@ -321,20 +344,23 @@ def get_sales(account: Account, start_from: str | None = None, include_paid: boo
return next_order_id, sells
def generate_adv_profile(vertex: Vertex) -> str:
+ """
+ Генерирует расширенную статистику профиля.
+ Защита от ошибок парсинга: при сбое возвращает частичные данные без падения.
+ """
account = vertex.account
balance = vertex.balance
- if balance.total_eur != 0:
- currency = "€"
- balance.balance.total_eur
- elif balance.total_eur != 0:
- currency = "$"
- balance.balance.total_eur
- elif balance.total_eur != 0:
- currency = "₽"
- balance.balance.total_eur
+
+ # Выбираем валюту и базовое числовое значение из объекта баланса
+ # (без сетевых запросов и без перезаписи переменной balance)
+ if balance.total_eur:
+ currency, balance_value = "€", balance.total_eur
+ elif balance.total_usd:
+ currency, balance_value = "$", balance.total_usd
+ elif balance.total_rub:
+ currency, balance_value = "₽", balance.total_rub
else:
- balance = 0
- currency = "₽"
+ currency, balance_value = "₽", 0.0
if exists("storage/cache/advProfileStat.json"):
with open("storage/cache/advProfileStat.json", "r", encoding="utf-8") as f:
global ORDER_CONFIRMED
@@ -345,7 +371,7 @@ def generate_adv_profile(vertex: Vertex) -> str:
refundsPrice = {"day": 0.0, "week": 0.0, "month": 0.0, "all": 0.0}
canWithdraw = {"now": 0.0, "hour": 0.0, "day": 0.0, "2day": 0.0}
- account.get()
+ # Лишний сетевой запрос не выполняем: данные обновляются перед вызовом
for order in ORDER_CONFIRMED.copy():
if time.time() - ORDER_CONFIRMED[order]["time"] > 172800:
@@ -358,76 +384,135 @@ def generate_adv_profile(vertex: Vertex) -> str:
else:
canWithdraw["2day"] += ORDER_CONFIRMED[order]["price"]
- randomLotPageLink = bs(account.method("get", "https://funpay.com/lots/693/", {}, {}).text, "html.parser").find("a", {"class": "tc-item"})["href"]
- randomLotPageParse = bs(account.method("get", randomLotPageLink, {}, {}).text, "html.parser")
-
- balance = randomLotPageParse.select_one(".badge-balance").text.split(" ")[0]
- currency = randomLotPageParse.select_one(".badge-balance").text.split(" ")[1]
-
- canWithdraw["now"] = randomLotPageParse.find("select", {"class": "form-control input-lg selectpicker"})["data-balance-rub"]
- if currency == "$":
- canWithdraw["now"] = randomLotPageParse.find("select", {"class": "form-control input-lg selectpicker"})["data-balance-usd"]
- elif currency == "€":
- canWithdraw["now"] = randomLotPageParse.find("select", {"class": "form-control input-lg selectpicker"})["data-balance-eur"]
-
- next_order_id, all_sales = get_sales(account)
-
- while next_order_id != None:
- time.sleep(1)
- next_order_id, new_sales = get_sales(account, start_from=next_order_id)
- all_sales += new_sales
+ # Пытаемся получить актуальный выводимый баланс и валюту из страницы лота.
+ # Если верстка поменялась — используем fallback из объекта баланса.
+ try:
+ randomLotPageLink = bs(account.method("get", "https://funpay.com/lots/693/", {}, {}).text, "html.parser").find("a", {"class": "tc-item"})["href"]
+ randomLotPageParse = bs(account.method("get", randomLotPageLink, {}, {}).text, "html.parser")
+
+ parsed_balance_text = randomLotPageParse.select_one(".badge-balance").text.split(" ")
+ balance_text_value, balance_text_currency = parsed_balance_text[0], parsed_balance_text[1]
+
+ # Обновляем валюту и отображаемый баланс из страницы, если получилось распарсить
+ currency = balance_text_currency
+ balance_display = balance_text_value
+
+ selectpicker = randomLotPageParse.find("select", {"class": "form-control input-lg selectpicker"})
+ if currency == "₽":
+ canWithdraw["now"] = str(selectpicker.get("data-balance-rub", 0) or 0)
+ elif currency == "$":
+ canWithdraw["now"] = str(selectpicker.get("data-balance-usd", 0) or 0)
+ elif currency == "€":
+ canWithdraw["now"] = str(selectpicker.get("data-balance-eur", 0) or 0)
+ else:
+ canWithdraw["now"] = "0"
+ except Exception:
+ # Fallback: используем доступный баланс по выбранной валюте из объекта баланса
+ if currency == "₽":
+ canWithdraw["now"] = str(balance.available_rub)
+ elif currency == "$":
+ canWithdraw["now"] = str(balance.available_usd)
+ elif currency == "€":
+ canWithdraw["now"] = str(balance.available_eur)
+ balance_display = f"{balance_value}"
+
+ # Получаем продажи через встроенный метод API с пагинацией
+ try:
+ next_order_id, all_sales = account.get_sells()
+ while next_order_id is not None:
+ time.sleep(1)
+ next_order_id, new_sales = account.get_sells(start_from=next_order_id)
+ all_sales += new_sales
+ except Exception:
+ all_sales = []
+
+ # Инициализируем агрегаты по валютам
+ currencies = ["USD", "RUB", "EUR"]
+ sym = {"USD": "$", "RUB": "₽", "EUR": "€"}
+ stats = {
+ cur: {
+ "sales": {"day": 0, "week": 0, "month": 0, "all": 0},
+ "salesPrice": {"day": 0.0, "week": 0.0, "month": 0.0, "all": 0.0},
+ "refunds": {"day": 0, "week": 0, "month": 0, "all": 0},
+ "refundsPrice": {"day": 0.0, "week": 0.0, "month": 0.0, "all": 0.0},
+ }
+ for cur in currencies
+ }
+ now_dt = datetime.datetime.now()
for sale in all_sales:
- if sale.status == OrderStatuses.REFUNDED:
- refunds["all"] += 1
- refundsPrice["all"] += sale.price
+ cur = getattr(sale, "currency", None)
+ if cur not in stats:
+ # неизвестная валюта — пропускаем
+ continue
+ is_refund = sale.status == OrderStatuses.REFUNDED
+ delta = now_dt - sale.date if hasattr(sale, "date") else datetime.timedelta.max
+
+ if is_refund:
+ stats[cur]["refunds"]["all"] += 1
+ stats[cur]["refundsPrice"]["all"] += sale.price
else:
- sales["all"] += 1
- salesPrice["all"] += sale.price
-
- upperDate = bs(sale.html, "html.parser").find("div", {"class": "tc-date-time"}).text
- date = bs(sale.html, "html.parser").find("div", {"class": "tc-date-left"}).text
-
- if "сегодня" in upperDate or "сьогодні" in upperDate or "today" in upperDate:
- if sale.status == OrderStatuses.REFUNDED:
- refunds["day"] += 1
- refunds["week"] += 1
- refunds["month"] += 1
- refundsPrice["day"] += sale.price
- refundsPrice["week"] += sale.price
- refundsPrice["month"] += sale.price
+ stats[cur]["sales"]["all"] += 1
+ stats[cur]["salesPrice"]["all"] += sale.price
+
+ # За сутки / неделю / месяц считаем по относительному времени
+ if delta <= datetime.timedelta(days=1):
+ if is_refund:
+ stats[cur]["refunds"]["day"] += 1
+ stats[cur]["refundsPrice"]["day"] += sale.price
else:
- sales["day"] += 1
- sales["week"] += 1
- sales["month"] += 1
- salesPrice["day"] += sale.price
- salesPrice["week"] += sale.price
- salesPrice["month"] += sale.price
- elif "день" in date or "дня" in date or "дней" in date or "дні" in date or "day" in date or "час" in date or "hour" in date or "годин" in date:
- if sale.status == OrderStatuses.REFUNDED:
- refunds["week"] += 1
- refunds["month"] += 1
- refundsPrice["week"] += sale.price
- refundsPrice["month"] += sale.price
+ stats[cur]["sales"]["day"] += 1
+ stats[cur]["salesPrice"]["day"] += sale.price
+ if delta <= datetime.timedelta(days=7):
+ if is_refund:
+ stats[cur]["refunds"]["week"] += 1
+ stats[cur]["refundsPrice"]["week"] += sale.price
else:
- sales["week"] += 1
- sales["month"] += 1
- salesPrice["week"] += sale.price
- salesPrice["month"] += sale.price
- elif "недел" in date or "тижд" in date or "week" in date:
- if sale.status == OrderStatuses.REFUNDED:
- refunds["month"] += 1
- refundsPrice["month"] += sale.price
+ stats[cur]["sales"]["week"] += 1
+ stats[cur]["salesPrice"]["week"] += sale.price
+ if delta <= datetime.timedelta(days=30):
+ if is_refund:
+ stats[cur]["refunds"]["month"] += 1
+ stats[cur]["refundsPrice"]["month"] += sale.price
else:
- sales["month"] += 1
- salesPrice["month"] += sale.price
-
-
+ stats[cur]["sales"]["month"] += 1
+ stats[cur]["salesPrice"]["month"] += sale.price
+
+
+
+ # Формируем блоки статистики по валютам
+ sales_blocks = []
+ refunds_blocks = []
+ for cur in currencies:
+ # Показываем только если есть данные по всем времени
+ if stats[cur]["sales"]["all"] or stats[cur]["refunds"]["all"]:
+ s = stats[cur]["sales"]
+ sp = stats[cur]["salesPrice"]
+ r = stats[cur]["refunds"]
+ rp = stats[cur]["refundsPrice"]
+ curr_sym = sym[cur]
+ sales_blocks.append(
+ f"{curr_sym}\n"
+ f"За день: {s['day']} ({sp['day']:.1f} {curr_sym})\n"
+ f"За неделю: {s['week']} ({sp['week']:.1f} {curr_sym})\n"
+ f"За месяц: {s['month']} ({sp['month']:.1f} {curr_sym})\n"
+ f"За всё время: {s['all']} ({sp['all']:.1f} {curr_sym})"
+ )
+ refunds_blocks.append(
+ f"{curr_sym}\n"
+ f"За день: {r['day']} ({rp['day']:.1f} {curr_sym})\n"
+ f"За неделю: {r['week']} ({rp['week']:.1f} {curr_sym})\n"
+ f"За месяц: {r['month']} ({rp['month']:.1f} {curr_sym})\n"
+ f"За всё время: {r['all']} ({rp['all']:.1f} {curr_sym})"
+ )
+
+ sales_text = "\n\n".join(sales_blocks) if sales_blocks else "Нет данных"
+ refunds_text = "\n\n".join(refunds_blocks) if refunds_blocks else "Нет данных"
return f"""Статистика аккаунта {account.username}
ID: {account.id}
-Баланс: {balance} {currency}
+Баланс: {balance_display} {currency}
Незавершенных заказов: {account.active_sales}
Доступно для вывода
@@ -436,17 +521,11 @@ def generate_adv_profile(vertex: Vertex) -> str:
Через день: +{"{:.1f}".format(canWithdraw["day"])} {currency}
Через 2 дня: +{"{:.1f}".format(canWithdraw["2day"])} {currency}
-Товаров продано
-За день: {sales["day"]} ({"{:.1f}".format(salesPrice["day"])} {currency})
-За неделю: {sales["week"]} ({"{:.1f}".format(salesPrice["week"])} {currency})
-За месяц: {sales["month"]} ({"{:.1f}".format(salesPrice["month"])} {currency})
-За всё время: {sales["all"]} ({"{:.1f}".format(salesPrice["all"])} {currency})
-
-Товаров возвращено
-За день: {refunds["day"]} ({"{:.1f}".format(refundsPrice["day"])} {currency})
-За неделю: {refunds["week"]} ({"{:.1f}".format(refundsPrice["week"])} {currency})
-За месяц: {refunds["month"]} ({"{:.1f}".format(refundsPrice["month"])} {currency})
-За всё время: {refunds["all"]} ({"{:.1f}".format(refundsPrice["all"])} {currency})
+Товаров продано (по валютам)
+{sales_text}
+
+Товаров возвращено (по валютам)
+{refunds_text}
Обновлено: {time.strftime('%H:%M:%S', time.localtime(account.last_update))}"""
diff --git a/vertex.py b/vertex.py
index 34329ad..717d4c8 100644
--- a/vertex.py
+++ b/vertex.py
@@ -274,17 +274,44 @@ def __init_telegram(self) -> None:
self.telegram.init()
def get_balance(self, attempts: int = 3) -> FunPayAPI.types.Balance:
+ """
+ Возвращает баланс аккаунта. Безопасно обрабатывает отсутствие лотов.
+
+ Алгоритм:
+ - Пытаемся получить публичные лоты из случайной подкатегории несколько раз.
+ - Если лотов нет вовсе, пробуем запросить баланс на offer?id=0.
+ - Если и это не удалось — возвращаем нулевой баланс (чтобы не падать в /profile).
+ """
subcategories = self.account.get_sorted_subcategories()[FunPayAPI.enums.SubCategoryTypes.COMMON]
- lots = []
- while not lots and attempts:
+ lots: list[FunPayAPI.types.LotShortcut] = []
+ tried_subcats = set()
+ while attempts and len(tried_subcats) < len(subcategories):
attempts -= 1
- subcat_id = random.choice(list(subcategories.keys()))
- lots = self.account.get_subcategory_public_lots(FunPayAPI.enums.SubCategoryTypes.COMMON, subcat_id)
- break
- else:
- raise Exception(...)
- balance = self.account.get_balance(random.choice(lots).id)
- return balance
+ try:
+ subcat_id = random.choice([i for i in subcategories.keys() if i not in tried_subcats])
+ except IndexError:
+ break
+ tried_subcats.add(subcat_id)
+ try:
+ lots = self.account.get_subcategory_public_lots(FunPayAPI.enums.SubCategoryTypes.COMMON, subcat_id)
+ if lots:
+ break
+ except Exception:
+ continue
+
+ # Если нашли лоты — используем любой для получения баланса
+ if lots:
+ try:
+ return self.account.get_balance(random.choice(lots).id)
+ except Exception:
+ pass
+
+ # Фоллбек: пробуем offer?id=0 (часто достаточно для получения баланса)
+ try:
+ return self.account.get_balance(0)
+ except Exception:
+ # Возвращаем нулевой баланс как безопасный дефолт
+ return FunPayAPI.types.Balance(0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
# Прочее
def raise_lots(self) -> int: