diff --git a/app/main.py b/app/main.py index 7c111fe..403ed9b 100644 --- a/app/main.py +++ b/app/main.py @@ -26,6 +26,11 @@ from starlette.middleware.base import BaseHTTPMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles +try: + import fitz # PyMuPDF — точный подсчёт страниц (regex слеп к сжатым object streams) +except Exception: + fitz = None + # ── Config ──────────────────────────────────────────────────────────────────── BOT_TOKEN = os.getenv("TOKEN", "") BOT_USERNAME = os.getenv("BOT_USERNAME", "pdf2scan_pybot") @@ -1000,7 +1005,17 @@ async def verify_auth(data: dict): def _count_pdf_pages(content: bytes) -> int: - """Быстрый подсчёт страниц по байтам PDF без внешних зависимостей.""" + """Подсчёт страниц PDF. Сначала PyMuPDF — он видит страницы в сжатых + object streams, где regex-подсчёт слеп (так 1842-страничный файл на 7.4 МБ + насчитался как 0 стр. и прошёл мимо пейвола). Regex — только фоллбэк.""" + if fitz is not None: + try: + with fitz.open(stream=content, filetype="pdf") as _doc: + n = int(_doc.page_count) + if n > 0: + return n + except Exception: + pass try: matches = re.findall(rb'/Type\s*/Page[^s]', content) return len(matches) @@ -1015,7 +1030,15 @@ def _write_bytes(path: str, data: bytes) -> None: def _read_and_count_pdf(path: str) -> int: - """Читает PDF с диска и считает страницы (для запуска в отдельном потоке).""" + """Считает страницы PDF с диска (для запуска в отдельном потоке).""" + if fitz is not None: + try: + with fitz.open(path) as _doc: + n = int(_doc.page_count) + if n > 0: + return n + except Exception: + pass try: with open(path, "rb") as f_in: return _count_pdf_pages(f_in.read()) @@ -2191,6 +2214,10 @@ async def paywall_check(payload: dict, request: Request): sub_active = _db_sub_active(account) over = size_mb > AUTH_REQUIRED_MB or pages > AUTH_REQUIRED_PAGES requires_payment = bool(PAYWALL_ENABLED and over and not sub_active) + if requires_payment: + # журнал показов: человек увидел цену ещё до загрузки файла + fname = str(payload.get("name") or "").strip()[:200] + await asyncio.to_thread(_log_paywall_precheck, account, fname, size_mb, pages) return { "paywall_enabled": bool(PAYWALL_ENABLED), "sub_active": sub_active, @@ -3181,6 +3208,7 @@ _ADMIN_HTML = """

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

ШагЗа 30 днейВсегоКонверсия шага
+
«Пейвол показан» — открытия карточки с ценой (в т.ч. повторные и пакетные, без файла). Пофайловый разбор — в журнале «Показы пейвола» ниже.
@@ -3394,7 +3422,8 @@ _ADMIN_HTML = """ // Показы пейвола (журнал: кто + файл + статус) var pw = d.paywall_events || []; document.getElementById('pw-note').textContent = '· ' + pw.length; - var PWST = { 'shown':['pend','ждёт оплаты'], 'оплачен':['ok','оплачен'], 'по подписке':['ok','по подписке'] }; + var PWST = { 'shown':['pend','ждёт оплаты'], 'показан':['pend','показан (без загрузки)'], + 'оплачен':['ok','оплачен'], 'по подписке':['ok','по подписке'] }; document.getElementById('t-paywall').innerHTML = pw.length ? pw.map(function(e){ var who = (e.account && e.account!=='') ? e.account : (e.source||'—'); var st = PWST[e.status] || ['pend', e.status||'—']; @@ -3582,6 +3611,28 @@ def _log_paywall_shown(job_id, account, user_id, source, file_name, size_mb, pag print(f"paywall log error: {e}") +def _log_paywall_precheck(account, file_name, size_mb, pages): + """Показ пейвола ДО загрузки (/api/paywall/check): файла ещё нет в БД, + журналим интерес. Дедуп: тот же файл (имя+размер) не чаще раза в сутки.""" + try: + with sqlite3.connect(DB_PATH, timeout=10) as con: + dup = con.execute( + "SELECT 1 FROM WEB_PAYWALL_EVENTS WHERE file_name=? " + "AND ABS(COALESCE(size_mb,0)-?)<0.05 AND ts>? LIMIT 1", + (file_name or "—", size_mb, time.time() - 86400)).fetchone() + if dup: + return + con.execute( + "INSERT OR IGNORE INTO WEB_PAYWALL_EVENTS " + "(job_id, ts, account, user_id, source, file_name, size_mb, pages, status) " + "VALUES (?,?,?,?,?,?,?,?, 'показан')", + (f"chk_{secrets.token_hex(6)}", time.time(), account or "", "", "сайт", + file_name or "—", size_mb, pages)) + con.commit() + except Exception as e: + print(f"paywall precheck log error: {e}") + + def _mark_paywall_resolved(job_id, status): """Отметить показ пейвола разрешённым (оплачен / по подписке). Если записи нет (напр. холд пришёл из бота и не логировался) — создаём из строки FILES.""" diff --git a/app/static/index.html b/app/static/index.html index e775538..78cc815 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -2424,9 +2424,9 @@ let paywallJob = null, paywallFilePrice = 99, paywallSubPrice = 299, paywallMode = 'file'; // Предпроверка на сервере БЕЗ загрузки файла - async function checkPaywall(sizeMb, pages) { + async function checkPaywall(sizeMb, pages, fname) { try { - const body = { size_mb: sizeMb, pages: pages }; + const body = { size_mb: sizeMb, pages: pages, name: fname || '' }; if (tgUser) body.user_id = tgUser.user_id || tgUser.id || 0; const r = await fetch('/api/paywall/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); return await r.json(); @@ -2438,7 +2438,8 @@ } function showPaywall(jobId, filePrice, subPrice, mode) { - track('paywall_shown'); + // «Продлить» подписку — не пейвол, в воронке не считаем + if ((mode || 'file') !== 'renew') track('paywall_shown'); paywallMode = mode || 'file'; paywallJob = jobId; paywallFilePrice = filePrice || 99; @@ -3420,7 +3421,7 @@ // Пейволл БЕЗ загрузки: только для потенциально больших файлов (мелкие не дёргают сервер) if (fileSizeMb > AUTH_MAX_MB || filePages > AUTH_MAX_PAGES) { submitBtn.disabled = true; - const chk = await checkPaywall(fileSizeMb, filePages); + const chk = await checkPaywall(fileSizeMb, filePages, selectedFile.name || ''); submitBtn.disabled = false; if (chk.requires_payment) { showPaywall(null, chk.file_price, chk.sub_price, 'file'); return; } }