Skip to content
This repository was archived by the owner on Jul 6, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 52 additions & 5 deletions backend/app/api/admin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,41 @@ def _get_stats() -> dict:
stats["unique_ips"] = 0
stats["recent_usage"] = []

try:
cur.execute("SELECT COUNT(*), COUNT(DISTINCT visitor_hash) FROM visit_log")
row = cur.fetchone()
stats["total_pv"] = row[0]
stats["total_uv"] = row[1]

cur.execute("""
SELECT COUNT(*), COUNT(DISTINCT visitor_hash)
FROM visit_log WHERE date(created_at)=date('now')
""")
row = cur.fetchone()
stats["today_pv"] = row[0]
stats["today_uv"] = row[1]

cur.execute("""
SELECT path, COUNT(*) as c
FROM visit_log
GROUP BY path ORDER BY c DESC LIMIT 20
""")
stats["visits_by_path"] = {r[0]: r[1] for r in cur.fetchall()}

cur.execute("""
SELECT strftime('%H', created_at) as hour, COUNT(*) as c
FROM visit_log WHERE created_at > datetime('now', '-24 hours')
GROUP BY hour ORDER BY hour
""")
stats["visit_hourly_24h"] = {r[0]: r[1] for r in cur.fetchall()}
except Exception:
stats["total_pv"] = 0
stats["total_uv"] = 0
stats["today_pv"] = 0
stats["today_uv"] = 0
stats["visits_by_path"] = {}
stats["visit_hourly_24h"] = {}

# Engagement
cur.execute("""
SELECT category, metric_name, metric_value FROM baseline_stats
Expand Down Expand Up @@ -170,22 +205,34 @@ def _get_stats() -> dict:
function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function showDash(d){
const hrs=d.hourly_24h||{};const maxH=Math.max(...Object.values(hrs),1);
const visitHrs=d.visit_hourly_24h||{};const maxVisitH=Math.max(...Object.values(visitHrs),1);
const topIps=d.top_ips||[];const maxIp=topIps[0]?.count||1;
const usage=d.recent_usage||[];
const cats=Object.entries(d.usage_by_category||{});const maxCat=Math.max(...cats.map(c=>c[1]),1);
const paths=Object.entries(d.visits_by_path||{});const maxPath=Math.max(...paths.map(c=>c[1]),1);
app.innerHTML=`<div class="d">
<h1>薯医 Admin</h1><div class="sub">${d.timestamp} · uptime ${Math.round(d.uptime_seconds/60)}min</div>
<div class="g">
<div class="c"><div class="l">今日诊断</div><div class="v">${d.today_requests||0}</div></div>
<div class="c"><div class="l">今日 UV</div><div class="v b">${d.today_ips||0}</div></div>
<div class="c"><div class="l">总诊断</div><div class="v g2">${d.total_requests||0}</div></div>
<div class="c"><div class="l">总 UV</div><div class="v o">${d.unique_ips||0}</div></div>
<div class="c"><div class="l">今日访问 UV</div><div class="v b">${d.today_uv||0}</div></div>
<div class="c"><div class="l">今日访问 PV</div><div class="v">${d.today_pv||0}</div></div>
<div class="c"><div class="l">总访问 UV</div><div class="v o">${d.total_uv||0}</div></div>
<div class="c"><div class="l">总访问 PV</div><div class="v g2">${d.total_pv||0}</div></div>
<div class="c"><div class="l">今日诊断</div><div class="v sm">${d.today_requests||0}</div></div>
<div class="c"><div class="l">诊断 IP</div><div class="v sm">${d.today_ips||0}</div></div>
<div class="c"><div class="l">总诊断</div><div class="v sm">${d.total_requests||0}</div></div>
<div class="c"><div class="l">总 Token</div><div class="v sm">${(d.total_tokens||0).toLocaleString()}</div></div>
<div class="c"><div class="l">平均耗时</div><div class="v sm">${d.avg_duration_sec||0}s</div></div>
<div class="c"><div class="l">训练笔记</div><div class="v sm">${d.total_notes||0}</div></div>
<div class="c"><div class="l">内存</div><div class="v sm">${d.system?.memory_used_mb||'?'}/${d.system?.memory_total_mb||'?'}</div></div>
</div>
<div class="s">24h 请求分布</div>
<div class="s">24h 访问分布</div>
<div style="display:flex;gap:3px;align-items:end;height:60px;margin-bottom:16px">
${Array.from({length:24},(_,i)=>{const h=String(i).padStart(2,'0');const v=visitHrs[h]||0;
return `<div title="${h}:00 → ${v}次" style="flex:1;background:${v?'#3b82f6':'#f0f0f0'};height:${Math.max(v/maxVisitH*100,2)}%;border-radius:2px;cursor:pointer"></div>`;}).join('')}
</div>
<div class="s">访问路径分布</div>
${paths.map(([p,n])=>`<div class="bar"><div class="n" title="${esc(p)}">${esc(p).slice(0,18)}</div><div class="t"><div class="f" style="width:${n/maxPath*100}%;background:#3b82f6"></div></div><div class="num">${n}</div></div>`).join('')}
<div class="s">24h 诊断请求分布</div>
<div style="display:flex;gap:3px;align-items:end;height:60px;margin-bottom:16px">
${Array.from({length:24},(_,i)=>{const h=String(i).padStart(2,'0');const v=hrs[h]||0;
return `<div title="${h}:00 → ${v}次" style="flex:1;background:${v?'#ff2442':'#f0f0f0'};height:${Math.max(v/maxH*100,2)}%;border-radius:2px;cursor:pointer"></div>`;}).join('')}
Expand Down
2 changes: 2 additions & 0 deletions backend/app/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from app.api.history_api import router as history_router
from app.api.screenshot_api import router as screenshot_router
from app.api.optimize_api import router as optimize_router
from app.api.visit_api import router as visit_router

router = APIRouter()

Expand All @@ -26,3 +27,4 @@ async def api_health():
# router.include_router(history_router, tags=["history"])
router.include_router(screenshot_router, tags=["screenshot"])
router.include_router(optimize_router, tags=["optimize"])
router.include_router(visit_router, tags=["visit"])
78 changes: 78 additions & 0 deletions backend/app/api/visit_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""
Page visit tracking for anonymous PV/UV statistics.
"""
from __future__ import annotations

import hashlib
import json
import logging
import os
import sqlite3

from fastapi import APIRouter, Request
from pydantic import BaseModel

from app.api.usage_tracker import get_client_ip

logger = logging.getLogger("noterx.visit")
DB_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "data", "baseline.db")
VISITOR_SALT = os.getenv("VISITOR_HASH_SALT", "noterx-visitor")

router = APIRouter()


class VisitPayload(BaseModel):
path: str = "/"
referrer: str = ""


async def _read_visit_payload(request: Request) -> VisitPayload:
try:
data = await request.json()
except json.JSONDecodeError:
body = (await request.body()).decode("utf-8", errors="ignore")
try:
data = json.loads(body) if body else {}
except json.JSONDecodeError:
data = {}
if not isinstance(data, dict):
data = {}
return VisitPayload.model_validate(data)


def _visitor_hash(ip: str, user_agent: str) -> str:
raw = f"{VISITOR_SALT}:{ip}:{user_agent}"
return hashlib.sha256(raw.encode()).hexdigest()[:32]


def _user_agent_hash(user_agent: str) -> str:
raw = f"{VISITOR_SALT}:{user_agent}"
return hashlib.sha256(raw.encode()).hexdigest()[:32]


def log_visit(ip: str, user_agent: str, path: str, referrer: str = "") -> str:
visitor_hash = _visitor_hash(ip, user_agent)
safe_path = (path or "/")[:200]
try:
conn = sqlite3.connect(DB_PATH)
conn.execute(
"""
INSERT INTO visit_log (visitor_hash, user_agent_hash, path)
VALUES (?, ?, ?)
""",
(visitor_hash, _user_agent_hash(user_agent or ""), safe_path),
)
conn.commit()
conn.close()
except Exception as e:
logger.warning("Failed to log visit: %s", e)
return visitor_hash


@router.post("/visit")
async def track_visit(request: Request):
payload = await _read_visit_payload(request)
ip = get_client_ip(request)
user_agent = request.headers.get("user-agent", "")
log_visit(ip, user_agent, payload.path, payload.referrer)
return {"ok": True}
13 changes: 13 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ def _ensure_history_table():
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_usage_created ON usage_log(created_at DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_usage_ip ON usage_log(ip)")
conn.execute("""
CREATE TABLE IF NOT EXISTS visit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
visitor_hash TEXT NOT NULL,
user_agent_hash TEXT DEFAULT '',
path TEXT NOT NULL,
referrer TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_visit_created ON visit_log(created_at DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_visit_visitor ON visit_log(visitor_hash)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_visit_path ON visit_log(path)")
conn.commit()
conn.close()
local_memory.ensure_memory_md()
Expand Down
132 changes: 132 additions & 0 deletions backend/tests/test_admin_visit_stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import os
import sqlite3
import sys
import tempfile

from fastapi import FastAPI
from fastapi.testclient import TestClient

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

from app.api import admin_api, visit_api


def _create_admin_test_db():
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
conn = sqlite3.connect(path)
conn.execute("CREATE TABLE notes (id INTEGER PRIMARY KEY, category TEXT)")
conn.execute("INSERT INTO notes (category) VALUES ('food')")
conn.execute("""
CREATE TABLE baseline_stats (
id INTEGER PRIMARY KEY,
category TEXT,
metric_name TEXT,
metric_value REAL,
metric_json TEXT,
updated_at TIMESTAMP
)
""")
conn.execute("""
CREATE TABLE usage_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
action TEXT NOT NULL DEFAULT 'diagnose',
title TEXT DEFAULT '',
category TEXT DEFAULT '',
total_tokens INTEGER DEFAULT 0,
duration_sec REAL DEFAULT 0,
status TEXT DEFAULT 'ok',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("""
CREATE TABLE visit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
visitor_hash TEXT NOT NULL,
user_agent_hash TEXT DEFAULT '',
path TEXT NOT NULL,
referrer TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
conn.close()
return path


def test_admin_stats_reports_real_visit_uv_separate_from_diagnosis_usage():
db = _create_admin_test_db()
original_db = admin_api.DB_PATH
admin_api.DB_PATH = db
try:
conn = sqlite3.connect(db)
conn.execute("INSERT INTO usage_log (ip, action, title, category) VALUES ('1.1.1.1', 'diagnose', 'A', 'food')")
conn.execute("INSERT INTO visit_log (visitor_hash, user_agent_hash, path) VALUES ('visitor-a', 'ua', '/app')")
conn.execute("INSERT INTO visit_log (visitor_hash, user_agent_hash, path) VALUES ('visitor-a', 'ua', '/report')")
conn.execute("INSERT INTO visit_log (visitor_hash, user_agent_hash, path) VALUES ('visitor-b', 'ua', '/privacy')")
conn.commit()
conn.close()

stats = admin_api._get_stats()

assert stats["total_requests"] == 1
assert stats["unique_ips"] == 1
assert stats["total_pv"] == 3
assert stats["total_uv"] == 2
assert stats["today_pv"] == 3
assert stats["today_uv"] == 2
assert stats["visits_by_path"] == {"/app": 1, "/privacy": 1, "/report": 1}
finally:
admin_api.DB_PATH = original_db
os.unlink(db)


def test_visit_endpoint_accepts_text_plain_beacon_payload():
db = _create_admin_test_db()
original_db = visit_api.DB_PATH
visit_api.DB_PATH = db
try:
app = FastAPI()
app.include_router(visit_api.router, prefix="/api")
client = TestClient(app)

response = client.post(
"/api/visit",
content='{"path":"/app","referrer":"https://example.com/from"}',
headers={"content-type": "text/plain;charset=UTF-8", "user-agent": "BeaconBrowser"},
)

assert response.status_code == 200
assert response.json() == {"ok": True}

conn = sqlite3.connect(db)
cur = conn.cursor()
cur.execute("SELECT path FROM visit_log")
assert cur.fetchone() == ("/app",)
conn.close()
finally:
visit_api.DB_PATH = original_db
os.unlink(db)


def test_log_visit_deduplicates_visitor_by_ip_and_user_agent():
db = _create_admin_test_db()
original_db = visit_api.DB_PATH
visit_api.DB_PATH = db
try:
first = visit_api.log_visit("9.9.9.9", "Mozilla/5.0", "/app", "")
second = visit_api.log_visit("9.9.9.9", "Mozilla/5.0", "/report", "")
third = visit_api.log_visit("8.8.8.8", "Mozilla/5.0", "/app", "")

assert first == second
assert third != first

conn = sqlite3.connect(db)
cur = conn.cursor()
cur.execute("SELECT COUNT(*), COUNT(DISTINCT visitor_hash) FROM visit_log")
assert cur.fetchone() == (3, 2)
conn.close()
finally:
visit_api.DB_PATH = original_db
os.unlink(db)
9 changes: 8 additions & 1 deletion docs/privacy.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
<title>隐私政策 — 薯医 NoteRx</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
<script>
(function(){
var payload=JSON.stringify({path:location.pathname,referrer:document.referrer});
if(navigator.sendBeacon){navigator.sendBeacon('/api/visit',new Blob([payload],{type:'application/json'}));return;}
fetch('/api/visit',{method:'POST',headers:{'Content-Type':'application/json'},body:payload,keepalive:true}).catch(function(){});
})();
</script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--red:#ff2442;--red-light:#fff0f2;--ink:#262626;--slate:#666;--muted:#999;--border:#f0f0f0;--bg:#fafafa}
Expand Down Expand Up @@ -57,7 +64,7 @@ <h2>第三方服务</h2>
<p>本平台调用小米 MiMo API 进行AI分析。在诊断过程中,您的笔记内容会被传输至 MiMo API 进行处理,相关数据受小米隐私政策约束。除此之外,我们不会将您的数据分享给其他第三方。</p>

<h2>Cookie</h2>
<p>本平台不使用 Cookie。我们不追踪您的浏览行为,也不使用任何第三方追踪工具。</p>
<p>本平台不使用 Cookie。我们仅通过服务端记录匿名访问统计(如访问路径、访问时间、IP 与浏览器信息生成的不可逆哈希),用于计算 PV/UV 和排查服务质量问题,不记录点击轨迹、输入内容、截图或诊断报告。</p>

<h2>未成年人</h2>
<p>薯医 NoteRx 由未成年人团队(PageOne)开发,我们欢迎所有年龄段的用户使用本平台。本平台不要求注册账号,不收集个人身份信息。</p>
Expand Down
7 changes: 7 additions & 0 deletions docs/research_whitepaper.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
<title>薯医 NoteRx — 用数据科学拆解小红书的内容密码</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Noto+Sans+SC:wght@300;400;500;700;900&display=swap" rel="stylesheet">
<script>
(function(){
var payload=JSON.stringify({path:location.pathname,referrer:document.referrer});
if(navigator.sendBeacon){navigator.sendBeacon('/api/visit',new Blob([payload],{type:'application/json'}));return;}
fetch('/api/visit',{method:'POST',headers:{'Content-Type':'application/json'},body:payload,keepalive:true}).catch(function(){});
})();
</script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--red:#ff2442;--red-light:#fff0f2;--red-dark:#d91a36;--ink:#262626;--slate:#666;--muted:#999;--border:#f0f0f0;--bg:#fafafa;--card:#fff}
Expand Down
7 changes: 7 additions & 0 deletions docs/terms.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
<title>服务条款 — 薯医 NoteRx</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
<script>
(function(){
var payload=JSON.stringify({path:location.pathname,referrer:document.referrer});
if(navigator.sendBeacon){navigator.sendBeacon('/api/visit',new Blob([payload],{type:'application/json'}));return;}
fetch('/api/visit',{method:'POST',headers:{'Content-Type':'application/json'},body:payload,keepalive:true}).catch(function(){});
})();
</script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--red:#ff2442;--red-light:#fff0f2;--ink:#262626;--slate:#666;--muted:#999;--border:#f0f0f0;--bg:#fafafa}
Expand Down
Loading
Loading