Пейвол: точный подсчёт страниц (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:
57
app/main.py
57
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 = """<!doctype html>
|
||||
<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>
|
||||
<div class="muted" style="font-size:12px;margin-top:6px">«Пейвол показан» — открытия карточки с ценой (в т.ч. повторные и пакетные, без файла). Пофайловый разбор — в журнале «Показы пейвола» ниже.</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -3394,7 +3422,8 @@ _ADMIN_HTML = """<!doctype 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."""
|
||||
|
||||
Reference in New Issue
Block a user