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 = """
Воронка пейвола
+ «Пейвол показан» — открытия карточки с ценой (в т.ч. повторные и пакетные, без файла). Пофайловый разбор — в журнале «Показы пейвола» ниже.
@@ -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; }
}