From 39b45d3a64f38b7ef4c8bd9098999bbb8c185010 Mon Sep 17 00:00:00 2001 From: berkio_admin_gitea Date: Tue, 7 Jul 2026 14:25:15 +0000 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=BE=D1=80=D0=BE=D0=BD=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=B5=D0=B9=D0=B2=D0=BE=D0=BB=D0=B0,=20=C2=AB=D0=9E?= =?UTF-8?q?=D0=B6=D0=B8=D0=B4=D0=B0=D1=8E=D1=82=20=D0=BE=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D1=82=D1=8B=C2=BB,=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D0=BE=D1=84=D0=B5=D1=80=D1=82=D0=B0,=20?= =?UTF-8?q?=D0=B2=D0=BD=D0=B5=D1=88=D0=BD=D0=B8=D0=B5=20=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D1=80=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WEB_FUNNEL: paywall_shown/pay_click (фронт, /api/track) + pay_success (серверно); в админке раздел «Воронка пейвола» с конверсией шагов (30 дн / всего) - Раздел «Ожидают оплаты» — придержанные файлы (awaiting_payment) как лиды - /offer|/oferta: публичная оферта (подписка, автопродление, возвраты); ссылки в футере (7 языков) и в пейволе («Оплачивая, вы принимаете условия…») - POST /api/alert (ALERT_TOKEN, антифлуд 60с) → Telegram владельцу; используется скриптом лечения туннеля на vds178 Co-Authored-By: Claude Opus 4.8 --- app/main.py | 219 ++++++++++++++++++++++++++++++++++++++++++ app/static/index.html | 30 +++++- 2 files changed, 248 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index ab2d930..bd39d72 100644 --- a/app/main.py +++ b/app/main.py @@ -76,6 +76,9 @@ SUB_LINK_SECRET = os.getenv("SUB_LINK_SECRET", "") # общий секрет BOT_TOKEN = os.getenv("TOKEN", "") PAYMENT_NOTIFY_CHAT_ID = int(os.getenv("PAYMENT_NOTIFY_CHAT_ID", "156921693")) +# ── Внешние алерты (vds178: рестарт туннеля и т.п.) ─────────────────────────── +ALERT_TOKEN = os.getenv("ALERT_TOKEN", "") # низкопривилегированный токен только для /api/alert + # ── Вход по почте (magic-code через SMTP) ───────────────────────────────────── SMTP_HOST = os.getenv("SMTP_HOST", "") SMTP_PORT = int(os.getenv("SMTP_PORT", "465")) @@ -330,6 +333,16 @@ def _db_ensure_auth_table(): PRIMARY KEY (day, source) ) """) + # Воронка конверсии: (день, событие) → счётчик. + # События: paywall_shown / pay_click / pay_success. + con.execute(""" + CREATE TABLE IF NOT EXISTS WEB_FUNNEL ( + day TEXT, + event TEXT, + count INTEGER DEFAULT 0, + PRIMARY KEY (day, event) + ) + """) # Платежи ЮKassa. status: pending/succeeded/canceled. purpose: donate/api_pro/file/sub. con.execute(""" CREATE TABLE IF NOT EXISTS WEB_PAYMENTS ( @@ -1836,6 +1849,9 @@ def _db_mark_payment_paid(payment_id, purpose, api_key, extra=None, payment_meth job_id = extra.get("job_id") if job_id and _db_get_url_res_f(job_id) == "awaiting_payment": _db_set_url_res_f(job_id, "-") + # Воронка: успешная оплата пейвольных покупок (файл/подписка) + if purpose in ("file", "sub"): + _bump_funnel("pay_success") # Уведомление владельцу в Telegram (в отдельном потоке — не блокируем event loop) threading.Thread(target=_notify_owner_payment, args=(payment_id,), daemon=True).start() except Exception as e: @@ -2699,6 +2715,111 @@ _API_DOCS_HTML = """ """ +_OFFER_HTML = """ + + + + +Публичная оферта — pdf2scan.online + + + + +
+ ← pdf2scan.online +
+

Публичная оферта

+
Редакция от 7 июля 2026 г.
+ +

Настоящий документ является публичной офертой (ст. 437 ГК РФ) — предложением + ИП Букреева Александра Игоревича (ИНН 575208141840, далее — «Исполнитель») + заключить договор об оказании услуг сервиса pdf2scan (сайт pdf2scan.online и + Telegram-бот @pdf2scan_bot, далее — «Сервис») на условиях ниже. + Оплата услуг или использование Сервиса означает полное принятие условий (акцепт).

+ +

1. Услуги

+

Сервис выполняет автоматизированную обработку электронных документов (PDF, DOC/DOCX, + изображения): создание растрового PDF с эффектом сканирования, с дополнительными + опциями (сшивка, нумерация, титульный лист, PDF/A, распознавание текста).

+ +

2. Бесплатное и платное использование

+
    +
  • Бесплатно обрабатываются одиночные файлы размером до 50 МБ и до 100 страниц.
  • +
  • Разовая обработка файла сверх лимита — {{FILE_PRICE}} ₽ за файл.
  • +
  • Подписка — {{SUB_PRICE}} ₽ за 30 дней: файлы без ограничений лимита и пакетная обработка на сайте.
  • +
  • Тариф API «Business» — {{API_PRICE}} ₽ за 30 дней (для интеграций, см. /developers).
  • +
+

Цены указаны в рублях РФ, НДС не облагается. Действующие цены отображаются на Сервисе в момент оплаты.

+ +

3. Оплата и чеки

+

Оплата проводится через сервис ЮKassa банковской картой или иными доступными способами. + Кассовый чек (54-ФЗ) направляется на электронную почту, указанную при оплате.

+ +

4. Автопродление подписки

+
    +
  • Если при оплате подписки включена опция «Продлевать автоматически», способ оплаты + сохраняется, и стоимость подписки списывается автоматически каждые 30 дней + (списание выполняется незадолго до окончания оплаченного периода).
  • +
  • Автопродление можно отключить в любой момент на сайте (карточка подписки после входа + в аккаунт). Отключение действует со следующего периода; уже оплаченный период сохраняется.
  • +
  • При невозможности списания подписка приостанавливается после окончания оплаченного периода.
  • +
+ +

5. Порядок оказания услуги

+
    +
  • Услуга по разовой оплате считается оказанной с момента готовности обработанного файла к скачиванию (на сайте) или его отправки в Telegram.
  • +
  • Результат хранится ограниченное время (до 6 часов на сайте), после чего удаляется.
  • +
  • Загруженные файлы хранятся не более 24 часов и удаляются автоматически; содержимое файлов не передаётся третьим лицам.
  • +
+ +

6. Возвраты

+
    +
  • Если оплаченный файл не был обработан по вине Сервиса — стоимость возвращается по обращению.
  • +
  • От подписки можно отказаться; по обращению возвращается стоимость пропорционально неиспользованному периоду.
  • +
  • Обращения — на контакты из раздела 9; срок рассмотрения — до 10 рабочих дней, возврат — тем же способом, которым была произведена оплата.
  • +
+ +

7. Ограничения и ответственность

+
    +
  • Сервис предоставляется «как есть»; Исполнитель не гарантирует пригодность результата для конкретной цели и корректность обработки повреждённых файлов.
  • +
  • Запрещается загружать материалы, нарушающие законодательство РФ или права третьих лиц.
  • +
  • Совокупная ответственность Исполнителя ограничена суммой соответствующего платежа.
  • +
+ +

8. Прочие условия

+

Исполнитель вправе изменять условия оферты, публикуя новую редакцию на этой странице; + новая редакция действует для оплат, совершённых после публикации. К отношениям сторон + применяется право Российской Федерации.

+ +

9. Исполнитель и контакты

+

ИП Букреев Александр Игоревич, ИНН 575208141840
+ Telegram: @berkio · E-mail: b3rkut@yandex.ru

+
+
+ +""" + + +@app.get("/offer", response_class=HTMLResponse) +@app.get("/oferta", response_class=HTMLResponse) +async def offer_page(): + return (_OFFER_HTML + .replace("{{FILE_PRICE}}", str(int(FILE_PRICE_RUB))) + .replace("{{SUB_PRICE}}", str(int(SUB_PRICE_RUB))) + .replace("{{API_PRICE}}", str(int(API_PRO_PRICE_RUB)))) + + _ADMIN_HTML = """ @@ -2795,6 +2916,18 @@ _ADMIN_HTML = """ +
+

Воронка пейвола

+
+
ШагЗа 30 днейВсегоКонверсия шага
+
+ +
+

Ожидают оплаты

+
+
ДатаФайлРазмерПользователь
+
+

По дням

@@ -2884,6 +3017,29 @@ _ADMIN_HTML = """ ''; }).join('') : ''; + // Воронка пейвола + var fnT = (d.funnel && d.funnel.total) || {}, fn30 = (d.funnel && d.funnel.last30) || {}; + function conv(a,b){ return (b>0) ? Math.round(a/b*100)+'%' : '—'; } + var steps = [ + ['Пейвол показан', fn30.paywall_shown||0, fnT.paywall_shown||0, '—'], + ['Клик по оплате', fn30.pay_click||0, fnT.pay_click||0, conv(fn30.pay_click||0, fn30.paywall_shown||0)], + ['Оплата успешна', fn30.pay_success||0, fnT.pay_success||0, conv(fn30.pay_success||0, fn30.pay_click||0)] + ]; + document.getElementById('t-funnel').innerHTML = steps.map(function(s){ + return ''; + }).join(''); + document.getElementById('funnel-note').textContent = '· конверсия пейвол→оплата за 30 дн: ' + + conv(fn30.pay_success||0, fn30.paywall_shown||0); + + // Ожидают оплаты (придержанные файлы) + var held = d.held || []; + document.getElementById('held-note').textContent = '· ' + held.length; + document.getElementById('t-held').innerHTML = held.length ? held.map(function(h){ + var who = (h.user_id && h.user_id !== d.web_user_id) ? ('tg:'+h.user_id) : 'сайт'; + return ''; + }).join('') : ''; + // По дням (свежие сверху) var daily = (d.daily||[]).slice().reverse(); document.getElementById('t-daily').innerHTML = daily.length ? daily.map(function(x){ @@ -3031,6 +3187,53 @@ async def api_hit(request: Request): return resp +def _bump_funnel(event: str): + try: + day = datetime.datetime.now().strftime("%Y-%m-%d") + with sqlite3.connect(DB_PATH, timeout=10) as con: + con.execute( + "INSERT INTO WEB_FUNNEL (day, event, count) VALUES (?, ?, 1) " + "ON CONFLICT(day, event) DO UPDATE SET count = count + 1", + (day, event)) + con.commit() + except Exception: + pass + + +_FUNNEL_EVENTS = {"paywall_shown", "pay_click"} + + +@app.post("/api/track") +async def api_track(payload: dict): + """Событие воронки с фронта (paywall_shown/pay_click). pay_success считается серверно.""" + ev = str((payload or {}).get("event") or "")[:32] + if ev in _FUNNEL_EVENTS: + await asyncio.to_thread(_bump_funnel, ev) + return {"ok": True} + + +_last_ext_alert = 0.0 + + +@app.post("/api/alert") +async def api_alert(payload: dict): + """Внешний алерт (напр. с vds178 при рестарте туннеля) → сообщение владельцу в Telegram. + Авторизация — ALERT_TOKEN (низкопривилегированный, умеет только это).""" + global _last_ext_alert + if not ALERT_TOKEN: + raise HTTPException(status_code=503, detail="alerts not configured") + tok = str((payload or {}).get("token") or "") + if not hmac.compare_digest(tok, ALERT_TOKEN): + raise HTTPException(status_code=401, detail="unauthorized") + now = time.time() + if now - _last_ext_alert < 60: # антифлуд: не чаще раза в минуту + return {"ok": True, "throttled": True} + _last_ext_alert = now + text = "🚨 " + str((payload or {}).get("text") or "alert")[:300] + await asyncio.to_thread(_tg_send, PAYMENT_NOTIFY_CHAT_ID, text) + return {"ok": True} + + def _require_admin(request: Request) -> bool: if not ADMIN_TOKEN: return False @@ -3094,6 +3297,22 @@ def _admin_collect_stats() -> dict: # Источники трафика out["sources"] = [dict(r) for r in con.execute( "SELECT source, SUM(count) c FROM WEB_SOURCES GROUP BY source ORDER BY c DESC LIMIT 30")] + # Воронка пейвола: показы → клики по оплате → успешные оплаты (всего и за 30 дней) + fun = {"paywall_shown": 0, "pay_click": 0, "pay_success": 0} + fun30 = dict(fun) + cutoff30 = (datetime.date.today() - datetime.timedelta(days=29)).strftime("%Y-%m-%d") + for r in con.execute("SELECT event, SUM(count) c FROM WEB_FUNNEL GROUP BY event"): + if r["event"] in fun: + fun[r["event"]] = r["c"] or 0 + for r in con.execute("SELECT event, SUM(count) c FROM WEB_FUNNEL WHERE day >= ? GROUP BY event", (cutoff30,)): + if r["event"] in fun30: + fun30[r["event"]] = r["c"] or 0 + out["funnel"] = {"total": fun, "last30": fun30} + # Придержанные файлы (ждут оплаты) — это тёплые лиды + out["held"] = [dict(r) for r in con.execute( + "SELECT date, time, COALESCE(NULLIF(original_name,''), name_or_f) AS name, " + "source_size_mb, user_id FROM FILES WHERE url_res_f='awaiting_payment' " + "ORDER BY rowid DESC LIMIT 50")] # Документы cols = {r[1] for r in con.execute("PRAGMA table_info(FILES)")} pages_expr = "COALESCE(NULLIF(pages_total,0), num_pages, 0)" if "pages_total" in cols else "COALESCE(num_pages,0)" diff --git a/app/static/index.html b/app/static/index.html index 583b143..3d83e95 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -1113,6 +1113,7 @@ Продлевать автоматически каждый месяц +
Оплачивая, вы принимаете условия публичной оферты
@@ -1223,7 +1224,7 @@
ДатаЗаходыУник.ФайловСтраницСр/стрИсходникРезультат
'+Math.round((x.c||0)/stot*100)+'%
Пока нет данных — собирается с этого момента
'+s[0]+''+s[1]+''+s[2]+''+s[3]+'
'+esc((h.date||'')+' '+String(h.time||'').slice(0,5))+''+esc(h.name)+ + ''+size(h.source_size_mb)+''+esc(who)+'
Нет файлов, ожидающих оплаты