Админка: источники трафика (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:
berkio_admin_gitea
2026-07-07 13:39:15 +00:00
parent 4507c4bad3
commit ee5ef3f08b
2 changed files with 81 additions and 5 deletions

View File

@@ -321,6 +321,15 @@ def _db_ensure_auth_table():
PRIMARY KEY (day, vid) 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. # Платежи ЮKassa. status: pending/succeeded/canceled. purpose: donate/api_pro/file/sub.
con.execute(""" con.execute("""
CREATE TABLE IF NOT EXISTS WEB_PAYMENTS ( CREATE TABLE IF NOT EXISTS WEB_PAYMENTS (
@@ -2780,6 +2789,12 @@ _ADMIN_HTML = """<!doctype html>
<div class="bars" id="bars"></div> <div class="bars" id="bars"></div>
</section> </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> <section>
<h2>По дням</h2> <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> <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') + document.getElementById('vis-total').textContent = '· всего ' + (d.visits.total||0).toLocaleString('ru-RU') +
' (уник. ' + (d.visits.unique_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(); var daily = (d.daily||[]).slice().reverse();
document.getElementById('t-daily').innerHTML = daily.length ? daily.map(function(x){ 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): _SOURCE_MAP = (
"""+1 к счётчику заходов за сегодня; vid — id браузера для подсчёта уникальных.""" ("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: try:
day = datetime.datetime.now().strftime("%Y-%m-%d") day = datetime.datetime.now().strftime("%Y-%m-%d")
with sqlite3.connect(DB_PATH, timeout=10) as con: with sqlite3.connect(DB_PATH, timeout=10) as con:
@@ -2937,6 +2997,11 @@ def _bump_visit(vid: str | None = None):
) )
if vid: if vid:
con.execute("INSERT OR IGNORE INTO WEB_VISIT_IDS (day, vid) VALUES (?, ?)", (day, 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() con.commit()
except Exception: except Exception:
pass pass
@@ -2945,13 +3010,20 @@ def _bump_visit(vid: str | None = None):
@app.post("/api/hit") @app.post("/api/hit")
async def api_hit(request: Request): async def api_hit(request: Request):
"""Маячок посещения: JS вызывает раз в сессию браузера. Боты/health-check без JS не считаются. """Маячок посещения: JS вызывает раз в сессию браузера. Боты/health-check без JS не считаются.
Cookie p2s_vid (2 года) даёт подсчёт уникальных посетителей.""" Cookie p2s_vid (2 года) даёт подсчёт уникальных посетителей. Тело {ref,url} → источник."""
vid = request.cookies.get("p2s_vid") or "" vid = request.cookies.get("p2s_vid") or ""
new_vid = False new_vid = False
if len(vid) < 8: if len(vid) < 8:
vid = secrets.token_urlsafe(12) vid = secrets.token_urlsafe(12)
new_vid = True 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}) resp = JSONResponse({"ok": True})
if new_vid: if new_vid:
resp.set_cookie("p2s_vid", vid, max_age=60 * 60 * 24 * 730, 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]) uniq_last7 = sum(udays.get(d["day"], 0) for d in days[:7])
out["visits"] = {"today": today_c, "last7": last7, "total": total, "days": days, out["visits"] = {"today": today_c, "last7": last7, "total": total, "days": days,
"unique_today": uniq_today, "unique_last7": uniq_last7, "unique_total": uniq_total} "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)")} 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)" pages_expr = "COALESCE(NULLIF(pages_total,0), num_pages, 0)" if "pages_total" in cols else "COALESCE(num_pages,0)"

View File

@@ -2839,7 +2839,8 @@
try { try {
if (!sessionStorage.getItem('p2s_hit')) { if (!sessionStorage.getItem('p2s_hit')) {
sessionStorage.setItem('p2s_hit', '1'); 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 (_) {} } catch (_) {}
// Возврат после OAuth: чистим ?login и показываем ошибку при неудаче // Возврат после OAuth: чистим ?login и показываем ошибку при неудаче