Админка: источники трафика (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)
|
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)"
|
||||||
|
|||||||
@@ -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 и показываем ошибку при неудаче
|
||||||
|
|||||||
Reference in New Issue
Block a user