Пейвол: точный подсчёт страниц (PyMuPDF) + журнал предпоказов + чистка воронки

- _count_pdf_pages/_read_and_count_pdf: сначала fitz (видит страницы в сжатых
  object streams), regex — фоллбэк. Закрывает дыру: 1842-стр. PDF на 7.4 МБ
  считался как 0 стр. и обрабатывался бесплатно (тест: regex 0 / fitz 150).
- /api/paywall/check: показы до загрузки пишутся в WEB_PAYWALL_EVENTS
  (имя файла с фронта, статус «показан», дедуп имя+размер 24ч).
- showPaywall(renew) больше не считается показом пейвола в воронке.
- Пояснение под воронкой: чем «показан» отличается от журнала.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
berkio_admin_gitea
2026-07-16 15:11:41 +00:00
parent 99dad2f343
commit 945a62cc5e
2 changed files with 59 additions and 7 deletions

View File

@@ -26,6 +26,11 @@ from starlette.middleware.base import BaseHTTPMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
try:
import fitz # PyMuPDF — точный подсчёт страниц (regex слеп к сжатым object streams)
except Exception:
fitz = None
# ── Config ──────────────────────────────────────────────────────────────────── # ── Config ────────────────────────────────────────────────────────────────────
BOT_TOKEN = os.getenv("TOKEN", "") BOT_TOKEN = os.getenv("TOKEN", "")
BOT_USERNAME = os.getenv("BOT_USERNAME", "pdf2scan_pybot") 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: 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: try:
matches = re.findall(rb'/Type\s*/Page[^s]', content) matches = re.findall(rb'/Type\s*/Page[^s]', content)
return len(matches) return len(matches)
@@ -1015,7 +1030,15 @@ def _write_bytes(path: str, data: bytes) -> None:
def _read_and_count_pdf(path: str) -> int: 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: try:
with open(path, "rb") as f_in: with open(path, "rb") as f_in:
return _count_pdf_pages(f_in.read()) 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) sub_active = _db_sub_active(account)
over = size_mb > AUTH_REQUIRED_MB or pages > AUTH_REQUIRED_PAGES over = size_mb > AUTH_REQUIRED_MB or pages > AUTH_REQUIRED_PAGES
requires_payment = bool(PAYWALL_ENABLED and over and not sub_active) 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 { return {
"paywall_enabled": bool(PAYWALL_ENABLED), "paywall_enabled": bool(PAYWALL_ENABLED),
"sub_active": sub_active, "sub_active": sub_active,
@@ -3181,6 +3208,7 @@ _ADMIN_HTML = """<!doctype html>
<h2>Воронка пейвола <span class="muted" id="funnel-note"></span></h2> <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> <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> <tbody id="t-funnel"></tbody></table></div>
<div class="muted" style="font-size:12px;margin-top:6px">«Пейвол показан» — открытия карточки с ценой (в т.ч. повторные и пакетные, без файла). Пофайловый разбор — в журнале «Показы пейвола» ниже.</div>
</section> </section>
<section> <section>
@@ -3394,7 +3422,8 @@ _ADMIN_HTML = """<!doctype html>
// Показы пейвола (журнал: кто + файл + статус) // Показы пейвола (журнал: кто + файл + статус)
var pw = d.paywall_events || []; var pw = d.paywall_events || [];
document.getElementById('pw-note').textContent = '· ' + pw.length; 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){ document.getElementById('t-paywall').innerHTML = pw.length ? pw.map(function(e){
var who = (e.account && e.account!=='') ? e.account : (e.source||''); var who = (e.account && e.account!=='') ? e.account : (e.source||'');
var st = PWST[e.status] || ['pend', e.status||'']; 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}") 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): def _mark_paywall_resolved(job_id, status):
"""Отметить показ пейвола разрешённым (оплачен / по подписке). Если записи нет """Отметить показ пейвола разрешённым (оплачен / по подписке). Если записи нет
(напр. холд пришёл из бота и не логировался) — создаём из строки FILES.""" (напр. холд пришёл из бота и не логировался) — создаём из строки FILES."""

View File

@@ -2424,9 +2424,9 @@
let paywallJob = null, paywallFilePrice = 99, paywallSubPrice = 299, paywallMode = 'file'; let paywallJob = null, paywallFilePrice = 99, paywallSubPrice = 299, paywallMode = 'file';
// Предпроверка на сервере БЕЗ загрузки файла // Предпроверка на сервере БЕЗ загрузки файла
async function checkPaywall(sizeMb, pages) { async function checkPaywall(sizeMb, pages, fname) {
try { 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; 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) }); const r = await fetch('/api/paywall/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
return await r.json(); return await r.json();
@@ -2438,7 +2438,8 @@
} }
function showPaywall(jobId, filePrice, subPrice, mode) { function showPaywall(jobId, filePrice, subPrice, mode) {
track('paywall_shown'); // «Продлить» подписку — не пейвол, в воронке не считаем
if ((mode || 'file') !== 'renew') track('paywall_shown');
paywallMode = mode || 'file'; paywallMode = mode || 'file';
paywallJob = jobId; paywallJob = jobId;
paywallFilePrice = filePrice || 99; paywallFilePrice = filePrice || 99;
@@ -3420,7 +3421,7 @@
// Пейволл БЕЗ загрузки: только для потенциально больших файлов (мелкие не дёргают сервер) // Пейволл БЕЗ загрузки: только для потенциально больших файлов (мелкие не дёргают сервер)
if (fileSizeMb > AUTH_MAX_MB || filePages > AUTH_MAX_PAGES) { if (fileSizeMb > AUTH_MAX_MB || filePages > AUTH_MAX_PAGES) {
submitBtn.disabled = true; submitBtn.disabled = true;
const chk = await checkPaywall(fileSizeMb, filePages); const chk = await checkPaywall(fileSizeMb, filePages, selectedFile.name || '');
submitBtn.disabled = false; submitBtn.disabled = false;
if (chk.requires_payment) { showPaywall(null, chk.file_price, chk.sub_price, 'file'); return; } if (chk.requires_payment) { showPaywall(null, chk.file_price, chk.sub_price, 'file'); return; }
} }