Админка: источники трафика (referrer + UTM)
- Маячок /api/hit шлёт {ref: document.referrer, url} → сервер определяет источник
- _source_from: приоритет utm_source, иначе домен referrer (Google/Yandex/Telegram/VK/…),
переходы внутри сайта и пустой referrer → «Прямые заходы»
- Таблица WEB_SOURCES(day,source,count); в админке раздел «Источники трафика» (источник/заходов/доля)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
83
app/main.py
83
app/main.py
@@ -321,6 +321,15 @@ def _db_ensure_auth_table():
|
||||
PRIMARY KEY (day, vid)
|
||||
)
|
||||
""")
|
||||
# Источники трафика: (день, источник) → сколько заходов.
|
||||
con.execute("""
|
||||
CREATE TABLE IF NOT EXISTS WEB_SOURCES (
|
||||
day TEXT,
|
||||
source TEXT,
|
||||
count INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (day, source)
|
||||
)
|
||||
""")
|
||||
# Платежи ЮKassa. status: pending/succeeded/canceled. purpose: donate/api_pro/file/sub.
|
||||
con.execute("""
|
||||
CREATE TABLE IF NOT EXISTS WEB_PAYMENTS (
|
||||
@@ -2780,6 +2789,12 @@ _ADMIN_HTML = """<!doctype html>
|
||||
<div class="bars" id="bars"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Источники трафика</h2>
|
||||
<div class="scroll"><table><thead><tr><th>Источник</th><th class="num">Заходов</th><th class="num">Доля</th></tr></thead>
|
||||
<tbody id="t-sources"></tbody></table></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>По дням</h2>
|
||||
<div class="scroll"><table><thead><tr><th>Дата</th><th class="num">Заходы</th><th class="num">Уник.</th><th class="num">Файлов</th><th class="num">Страниц</th><th class="num">Ср/стр</th><th class="num">Исходник</th><th class="num">Результат</th></tr></thead>
|
||||
@@ -2861,6 +2876,14 @@ _ADMIN_HTML = """<!doctype html>
|
||||
document.getElementById('vis-total').textContent = '· всего ' + (d.visits.total||0).toLocaleString('ru-RU') +
|
||||
' (уник. ' + (d.visits.unique_total||0).toLocaleString('ru-RU') + ')';
|
||||
|
||||
// Источники трафика
|
||||
var sources = d.sources || [];
|
||||
var stot = sources.reduce(function(a,x){ return a + (x.c||0); }, 0) || 1;
|
||||
document.getElementById('t-sources').innerHTML = sources.length ? sources.map(function(x){
|
||||
return '<tr><td>'+esc(x.source)+'</td><td class="num">'+(x.c||0).toLocaleString('ru-RU')+
|
||||
'</td><td class="num">'+Math.round((x.c||0)/stot*100)+'%</td></tr>';
|
||||
}).join('') : '<tr><td colspan="3" class="muted">Пока нет данных — собирается с этого момента</td></tr>';
|
||||
|
||||
// По дням (свежие сверху)
|
||||
var daily = (d.daily||[]).slice().reverse();
|
||||
document.getElementById('t-daily').innerHTML = daily.length ? daily.map(function(x){
|
||||
@@ -2925,8 +2948,45 @@ _ADMIN_HTML = """<!doctype html>
|
||||
|
||||
|
||||
# ── Админ-панель ──────────────────────────────────────────────────────────────
|
||||
def _bump_visit(vid: str | None = None):
|
||||
"""+1 к счётчику заходов за сегодня; vid — id браузера для подсчёта уникальных."""
|
||||
_SOURCE_MAP = (
|
||||
("google", "Google"), ("yandex", "Yandex"), ("ya.ru", "Yandex"),
|
||||
("t.me", "Telegram"), ("telegram", "Telegram"), ("tgraph", "Telegram"),
|
||||
("vk.com", "VK"), ("vk.ru", "VK"), ("bing", "Bing"), ("duckduckgo", "DuckDuckGo"),
|
||||
("mail.ru", "Mail.ru"), ("rambler", "Rambler"), ("facebook", "Facebook"),
|
||||
("instagram", "Instagram"), ("youtube", "YouTube"), ("t.co", "Twitter/X"), ("x.com", "Twitter/X"),
|
||||
("dzen", "Дзен"), ("whatsapp", "WhatsApp"),
|
||||
)
|
||||
|
||||
|
||||
def _source_from(ref: str, url: str) -> str:
|
||||
"""Определяет источник захода: приоритет utm_source из URL, иначе домен referrer."""
|
||||
# 1) UTM-метка в адресе страницы
|
||||
try:
|
||||
params = _urlparse.parse_qs(_urlparse.urlparse(url or "").query)
|
||||
utm = (params.get("utm_source") or [""])[0].strip()
|
||||
if utm:
|
||||
return utm[:40]
|
||||
except Exception:
|
||||
pass
|
||||
# 2) Referrer
|
||||
if not ref:
|
||||
return "Прямые заходы"
|
||||
try:
|
||||
host = (_urlparse.urlparse(ref).hostname or "").lower()
|
||||
except Exception:
|
||||
host = ""
|
||||
if host.startswith("www."):
|
||||
host = host[4:]
|
||||
if not host or "pdf2scan" in host: # переходы внутри сайта → прямые
|
||||
return "Прямые заходы"
|
||||
for key, label in _SOURCE_MAP:
|
||||
if key in host:
|
||||
return label
|
||||
return host[:40]
|
||||
|
||||
|
||||
def _bump_visit(vid: str | None = None, source: str | None = None):
|
||||
"""+1 к счётчику заходов за сегодня; vid — id браузера (уникальные), source — источник."""
|
||||
try:
|
||||
day = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
with sqlite3.connect(DB_PATH, timeout=10) as con:
|
||||
@@ -2937,6 +2997,11 @@ def _bump_visit(vid: str | None = None):
|
||||
)
|
||||
if vid:
|
||||
con.execute("INSERT OR IGNORE INTO WEB_VISIT_IDS (day, vid) VALUES (?, ?)", (day, vid))
|
||||
if source:
|
||||
con.execute(
|
||||
"INSERT INTO WEB_SOURCES (day, source, count) VALUES (?, ?, 1) "
|
||||
"ON CONFLICT(day, source) DO UPDATE SET count = count + 1",
|
||||
(day, source))
|
||||
con.commit()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -2945,13 +3010,20 @@ def _bump_visit(vid: str | None = None):
|
||||
@app.post("/api/hit")
|
||||
async def api_hit(request: Request):
|
||||
"""Маячок посещения: JS вызывает раз в сессию браузера. Боты/health-check без JS не считаются.
|
||||
Cookie p2s_vid (2 года) даёт подсчёт уникальных посетителей."""
|
||||
Cookie p2s_vid (2 года) даёт подсчёт уникальных посетителей. Тело {ref,url} → источник."""
|
||||
vid = request.cookies.get("p2s_vid") or ""
|
||||
new_vid = False
|
||||
if len(vid) < 8:
|
||||
vid = secrets.token_urlsafe(12)
|
||||
new_vid = True
|
||||
await asyncio.to_thread(_bump_visit, vid)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
if not isinstance(body, dict):
|
||||
body = {}
|
||||
source = _source_from(str(body.get("ref") or "")[:500], str(body.get("url") or "")[:500])
|
||||
await asyncio.to_thread(_bump_visit, vid, source)
|
||||
resp = JSONResponse({"ok": True})
|
||||
if new_vid:
|
||||
resp.set_cookie("p2s_vid", vid, max_age=60 * 60 * 24 * 730,
|
||||
@@ -3019,6 +3091,9 @@ def _admin_collect_stats() -> dict:
|
||||
uniq_last7 = sum(udays.get(d["day"], 0) for d in days[:7])
|
||||
out["visits"] = {"today": today_c, "last7": last7, "total": total, "days": days,
|
||||
"unique_today": uniq_today, "unique_last7": uniq_last7, "unique_total": uniq_total}
|
||||
# Источники трафика
|
||||
out["sources"] = [dict(r) for r in con.execute(
|
||||
"SELECT source, SUM(count) c FROM WEB_SOURCES GROUP BY source ORDER BY c DESC LIMIT 30")]
|
||||
# Документы
|
||||
cols = {r[1] for r in con.execute("PRAGMA table_info(FILES)")}
|
||||
pages_expr = "COALESCE(NULLIF(pages_total,0), num_pages, 0)" if "pages_total" in cols else "COALESCE(num_pages,0)"
|
||||
|
||||
@@ -2839,7 +2839,8 @@
|
||||
try {
|
||||
if (!sessionStorage.getItem('p2s_hit')) {
|
||||
sessionStorage.setItem('p2s_hit', '1');
|
||||
fetch('/api/hit', { method: 'POST', keepalive: true }).catch(function () {});
|
||||
fetch('/api/hit', { method: 'POST', keepalive: true, headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ref: document.referrer || '', url: location.href || '' }) }).catch(function () {});
|
||||
}
|
||||
} catch (_) {}
|
||||
// Возврат после OAuth: чистим ?login и показываем ошибку при неудаче
|
||||
|
||||
Reference in New Issue
Block a user