Админка: источники трафика (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)
)
""")
# Источники трафика: (день, источник) → сколько заходов.
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)"