Пейвол: точный подсчёт страниц (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.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."""
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user