Воронка пейвола, «Ожидают оплаты», публичная оферта, внешние алерты
- 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 <noreply@anthropic.com>
This commit is contained in:
219
app/main.py
219
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 = """<!DOCTYPE html>
|
||||
</html>"""
|
||||
|
||||
|
||||
_OFFER_HTML = """<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Публичная оферта — pdf2scan.online</title>
|
||||
<meta name="robots" content="noindex, follow">
|
||||
<style>
|
||||
body{ margin:0; background:#f2f2f7; color:#1c1c1e; font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif; }
|
||||
.wrap{ max-width:760px; margin:0 auto; padding:24px 16px 48px; }
|
||||
.card{ background:#fff; border-radius:16px; padding:28px 26px; }
|
||||
h1{ font-size:22px; margin:0 0 4px; }
|
||||
.upd{ color:#8e8e93; font-size:13px; margin-bottom:20px; }
|
||||
h2{ font-size:16px; margin:22px 0 8px; }
|
||||
p, li{ font-size:14.5px; color:#3a3a3c; }
|
||||
ul{ padding-left:20px; }
|
||||
a{ color:#007AFF; text-decoration:none; }
|
||||
.back{ display:inline-block; margin-bottom:14px; font-size:14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<a class="back" href="/">← pdf2scan.online</a>
|
||||
<div class="card">
|
||||
<h1>Публичная оферта</h1>
|
||||
<div class="upd">Редакция от 7 июля 2026 г.</div>
|
||||
|
||||
<p>Настоящий документ является публичной офертой (ст. 437 ГК РФ) — предложением
|
||||
ИП Букреева Александра Игоревича (ИНН 575208141840, далее — «Исполнитель»)
|
||||
заключить договор об оказании услуг сервиса pdf2scan (сайт pdf2scan.online и
|
||||
Telegram-бот @pdf2scan_bot, далее — «Сервис») на условиях ниже.
|
||||
Оплата услуг или использование Сервиса означает полное принятие условий (акцепт).</p>
|
||||
|
||||
<h2>1. Услуги</h2>
|
||||
<p>Сервис выполняет автоматизированную обработку электронных документов (PDF, DOC/DOCX,
|
||||
изображения): создание растрового PDF с эффектом сканирования, с дополнительными
|
||||
опциями (сшивка, нумерация, титульный лист, PDF/A, распознавание текста).</p>
|
||||
|
||||
<h2>2. Бесплатное и платное использование</h2>
|
||||
<ul>
|
||||
<li>Бесплатно обрабатываются одиночные файлы размером до 50 МБ и до 100 страниц.</li>
|
||||
<li><b>Разовая обработка файла сверх лимита</b> — {{FILE_PRICE}} ₽ за файл.</li>
|
||||
<li><b>Подписка</b> — {{SUB_PRICE}} ₽ за 30 дней: файлы без ограничений лимита и пакетная обработка на сайте.</li>
|
||||
<li><b>Тариф API «Business»</b> — {{API_PRICE}} ₽ за 30 дней (для интеграций, см. /developers).</li>
|
||||
</ul>
|
||||
<p>Цены указаны в рублях РФ, НДС не облагается. Действующие цены отображаются на Сервисе в момент оплаты.</p>
|
||||
|
||||
<h2>3. Оплата и чеки</h2>
|
||||
<p>Оплата проводится через сервис ЮKassa банковской картой или иными доступными способами.
|
||||
Кассовый чек (54-ФЗ) направляется на электронную почту, указанную при оплате.</p>
|
||||
|
||||
<h2>4. Автопродление подписки</h2>
|
||||
<ul>
|
||||
<li>Если при оплате подписки включена опция «Продлевать автоматически», способ оплаты
|
||||
сохраняется, и стоимость подписки списывается автоматически каждые 30 дней
|
||||
(списание выполняется незадолго до окончания оплаченного периода).</li>
|
||||
<li>Автопродление можно отключить в любой момент на сайте (карточка подписки после входа
|
||||
в аккаунт). Отключение действует со следующего периода; уже оплаченный период сохраняется.</li>
|
||||
<li>При невозможности списания подписка приостанавливается после окончания оплаченного периода.</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Порядок оказания услуги</h2>
|
||||
<ul>
|
||||
<li>Услуга по разовой оплате считается оказанной с момента готовности обработанного файла к скачиванию (на сайте) или его отправки в Telegram.</li>
|
||||
<li>Результат хранится ограниченное время (до 6 часов на сайте), после чего удаляется.</li>
|
||||
<li>Загруженные файлы хранятся не более 24 часов и удаляются автоматически; содержимое файлов не передаётся третьим лицам.</li>
|
||||
</ul>
|
||||
|
||||
<h2>6. Возвраты</h2>
|
||||
<ul>
|
||||
<li>Если оплаченный файл не был обработан по вине Сервиса — стоимость возвращается по обращению.</li>
|
||||
<li>От подписки можно отказаться; по обращению возвращается стоимость пропорционально неиспользованному периоду.</li>
|
||||
<li>Обращения — на контакты из раздела 9; срок рассмотрения — до 10 рабочих дней, возврат — тем же способом, которым была произведена оплата.</li>
|
||||
</ul>
|
||||
|
||||
<h2>7. Ограничения и ответственность</h2>
|
||||
<ul>
|
||||
<li>Сервис предоставляется «как есть»; Исполнитель не гарантирует пригодность результата для конкретной цели и корректность обработки повреждённых файлов.</li>
|
||||
<li>Запрещается загружать материалы, нарушающие законодательство РФ или права третьих лиц.</li>
|
||||
<li>Совокупная ответственность Исполнителя ограничена суммой соответствующего платежа.</li>
|
||||
</ul>
|
||||
|
||||
<h2>8. Прочие условия</h2>
|
||||
<p>Исполнитель вправе изменять условия оферты, публикуя новую редакцию на этой странице;
|
||||
новая редакция действует для оплат, совершённых после публикации. К отношениям сторон
|
||||
применяется право Российской Федерации.</p>
|
||||
|
||||
<h2>9. Исполнитель и контакты</h2>
|
||||
<p>ИП Букреев Александр Игоревич, ИНН 575208141840<br>
|
||||
Telegram: <a href="https://t.me/berkio">@berkio</a> · E-mail: <a href="mailto:b3rkut@yandex.ru">b3rkut@yandex.ru</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@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 = """<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
@@ -2795,6 +2916,18 @@ _ADMIN_HTML = """<!doctype html>
|
||||
<tbody id="t-sources"></tbody></table></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Воронка пейвола <span class="muted" id="funnel-note"></span></h2>
|
||||
<div class="scroll"><table><thead><tr><th>Шаг</th><th class="num">За 30 дней</th><th class="num">Всего</th><th class="num">Конверсия шага</th></tr></thead>
|
||||
<tbody id="t-funnel"></tbody></table></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Ожидают оплаты <span class="muted" id="held-note"></span></h2>
|
||||
<div class="scroll"><table><thead><tr><th>Дата</th><th>Файл</th><th class="num">Размер</th><th>Пользователь</th></tr></thead>
|
||||
<tbody id="t-held"></tbody></table></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>По дням</h2>
|
||||
<div class="scroll"><table><thead><tr><th>Дата</th><th class="num">Заходы</th><th class="num">Уник.</th><th class="num">Файлов</th><th class="num">Страниц</th><th class="num">Ср/стр</th><th class="num">Исходник</th><th class="num">Результат</th></tr></thead>
|
||||
@@ -2884,6 +3017,29 @@ _ADMIN_HTML = """<!doctype html>
|
||||
'</td><td class="num">'+Math.round((x.c||0)/stot*100)+'%</td></tr>';
|
||||
}).join('') : '<tr><td colspan="3" class="muted">Пока нет данных — собирается с этого момента</td></tr>';
|
||||
|
||||
// Воронка пейвола
|
||||
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 '<tr><td>'+s[0]+'</td><td class="num">'+s[1]+'</td><td class="num">'+s[2]+'</td><td class="num">'+s[3]+'</td></tr>';
|
||||
}).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 '<tr><td>'+esc((h.date||'')+' '+String(h.time||'').slice(0,5))+'</td><td class="fname" title="'+esc(h.name)+'">'+esc(h.name)+
|
||||
'</td><td class="num">'+size(h.source_size_mb)+'</td><td>'+esc(who)+'</td></tr>';
|
||||
}).join('') : '<tr><td colspan="4" class="muted">Нет файлов, ожидающих оплаты</td></tr>';
|
||||
|
||||
// По дням (свежие сверху)
|
||||
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)"
|
||||
|
||||
Reference in New Issue
Block a user