feat: add line charts, keyword tracking, and referrer domains to analytics

- Add SVG line charts for session duration, engagement, and bounce rate trends (28 days)
- Track referrer_domain and referrer_query on PageSession (new migration)
- Refactor classify_referrer() to extract domain and search engine query params
- Surface internal search keywords (ShopSearchRequest) on shop analytics
- Show top referrer domains and search engine queries on both analytics pages
- Add wide bar row CSS modifier for longer domain labels
This commit is contained in:
russell@unturf.com 2026-02-26 17:28:05 -05:00
parent 6acebd8835
commit 1c6bcbe94d
8 changed files with 597 additions and 39 deletions

View file

@ -1,6 +1,6 @@
import uuid
from sqlalchemy import Column, Integer, BigInteger, Boolean, Float, SmallInteger
from sqlalchemy import Column, Integer, BigInteger, Boolean, Float, SmallInteger, Unicode
from .meta import Base, RBase, UUIDType, now_timestamp, foreign_key
@ -41,6 +41,8 @@ class PageSession(RBase, Base):
is_ring_entry = Column(Boolean, nullable=True)
ring_position = Column(SmallInteger, nullable=True)
referrer_class = Column(SmallInteger, nullable=True) # 0-4
referrer_domain = Column(Unicode(128), nullable=True) # e.g. "google.com"
referrer_query = Column(Unicode(256), nullable=True) # search engine query
device_class = Column(SmallInteger, nullable=True) # 0-2
is_owner = Column(Boolean, nullable=True) # shop owner/editor viewing own product

View file

@ -0,0 +1,40 @@
"""add referrer_domain and referrer_query to page_session
Revision ID: f3086e09b052
Revises: b3f7a2c8d1e5
Create Date: 2026-02-26 16:53:40.340946
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f3086e09b052'
down_revision = 'b3f7a2c8d1e5'
branch_labels = None
depends_on = None
def _column_exists(table, column):
conn = op.get_bind()
result = conn.execute(sa.text(f"PRAGMA table_info({table})"))
return any(row[1] == column for row in result.fetchall())
def upgrade():
if not _column_exists("mps_page_session", "referrer_domain"):
op.add_column(
"mps_page_session",
sa.Column("referrer_domain", sa.Unicode(128), nullable=True),
)
if not _column_exists("mps_page_session", "referrer_query"):
op.add_column(
"mps_page_session",
sa.Column("referrer_query", sa.Unicode(256), nullable=True),
)
def downgrade():
op.drop_column("mps_page_session", "referrer_query")
op.drop_column("mps_page_session", "referrer_domain")

View file

@ -3499,6 +3499,16 @@ textarea {
}
}
.analytics-bar-row--wide .analytics-bar-label {
width: 140px;
}
@media (min-width: 960px) {
.analytics-bar-row--wide .analytics-bar-label {
width: 180px;
}
}
.price-history-current td {
font-weight: bold;
}

View file

@ -6,6 +6,37 @@
{% block content -%}
{% macro line_chart(data, max_value, label, color) %}
{% if data and max_value > 0 %}
<div class="analytics-chart-svg">
<svg viewBox="0 0 560 160" role="img" aria-label="{{ label }}">
{% for pct in [25, 50, 75] %}
<line x1="0" y1="{{ 140 - (pct / 100 * 130) }}" x2="556" y2="{{ 140 - (pct / 100 * 130) }}"
stroke="var(--border-light, #e9ecef)" stroke-width="1" />
{% endfor %}
<line x1="0" y1="140" x2="556" y2="140" stroke="var(--border-color, #dee2e6)" stroke-width="1" />
<polygon points="{% for b in data %}{{ loop.index0 * 20 + 8 }},{{ 140 - (b.value / max_value * 130) }} {% endfor %}{{ (data|length - 1) * 20 + 8 }},140 8,140"
fill="{{ color }}" opacity="0.15" />
<polyline
points="{% for b in data %}{{ loop.index0 * 20 + 8 }},{{ 140 - (b.value / max_value * 130) }} {% endfor %}"
fill="none" stroke="{{ color }}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />
{% for b in data %}
<circle cx="{{ loop.index0 * 20 + 8 }}" cy="{{ 140 - (b.value / max_value * 130) }}"
r="3" fill="{{ color }}">
<title>{{ b.label }}: {{ b.tooltip }}</title>
</circle>
{% endfor %}
{% for b in data %}
{% if loop.index0 % 7 == 0 %}
<text x="{{ loop.index0 * 20 + 8 }}" y="155" text-anchor="middle"
font-size="9" fill="var(--text-muted, #999)">{{ b.label }}</text>
{% endif %}
{% endfor %}
</svg>
</div>
{% endif %}
{% endmacro %}
<section class="analytics-page">
<h3>Analytics</h3>
@ -68,6 +99,25 @@
</div>
{% endif %}
{# --- Trend Line Charts (28 days) --- #}
{% if daily_session_duration_max > 0 %}
<h4>Session Duration Trend (last 28 days)</h4>
<p class="analytics-hint">Average time visitors spend per session.</p>
{{ line_chart(daily_session_duration, daily_session_duration_max, "Session duration trend", "var(--blue-color, #98b6fa)") }}
{% endif %}
{% if daily_engagement %}
<h4>Engagement Trend (last 28 days)</h4>
<p class="analytics-hint">Active interaction time as a fraction of total session time.</p>
{{ line_chart(daily_engagement, 1.0, "Engagement trend", "var(--green-color, #a3c765)") }}
{% endif %}
{% if daily_bounce %}
<h4>Bounce Rate Trend (last 28 days)</h4>
<p class="analytics-hint">Sessions under 7 seconds visible. Lower is better.</p>
{{ line_chart(daily_bounce, 1.0, "Bounce rate trend", "var(--red-color, #bc2131)") }}
{% endif %}
{# --- Ring Consumed breakdown --- #}
<h4>Ring Consumed</h4>
<p class="analytics-hint">Unique products viewed via the ring as a percentage of all {{ ring_size }} ring products.</p>
@ -375,6 +425,73 @@
</div>
{% endif %}
{# --- Top Referrer Domains (21d) --- #}
{% if top_referrer_domains %}
<h4>Top Referrer Domains (last 21 days)</h4>
<p class="analytics-hint">External sites sending visitors to your shop.</p>
<div class="analytics-bar-rows">
{% for d in top_referrer_domains %}
<div class="analytics-bar-row analytics-bar-row--wide">
<span class="analytics-bar-label">{{ d.domain }}</span>
<span class="analytics-bar-track">
<span class="analytics-bar-fill" style="width: {{ d.pct_raw | round(1) }}%"></span>
</span>
<span class="analytics-bar-value">{{ d.count }} ({{ d.pct }})</span>
</div>
{% endfor %}
</div>
{% endif %}
{# --- Search Engine Queries (21d) --- #}
{% if top_referrer_queries %}
<h4>Search Engine Queries (last 21 days)</h4>
<p class="analytics-hint">What people searched before finding your shop.</p>
<div class="analytics-table-wrap">
<table class="analytics-table">
<thead>
<tr>
<th>Query</th>
<th class="analytics-num">Visits</th>
</tr>
</thead>
<tbody>
{% for q in top_referrer_queries %}
<tr>
<td>{{ q.query }}</td>
<td class="analytics-num">{{ q.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{# --- Internal Search Keywords (21d) --- #}
{% if top_keywords %}
<h4>Top Search Keywords (last 21 days)</h4>
<p class="analytics-hint">What visitors search for inside your shop.</p>
<div class="analytics-table-wrap">
<table class="analytics-table">
<thead>
<tr>
<th>Keywords</th>
<th class="analytics-num">Searches</th>
<th class="analytics-num">Avg Hits</th>
</tr>
</thead>
<tbody>
{% for k in top_keywords %}
<tr>
<td>{{ k.keywords }}</td>
<td class="analytics-num">{{ k.search_count }}</td>
<td class="analytics-num">{{ k.avg_hits }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endif %}{# end overview.total_views check #}
{# --- Comment Sentiment (21d) --- #}

View file

@ -6,6 +6,37 @@
{% block content -%}
{% macro line_chart(data, max_value, label, color) %}
{% if data and max_value > 0 %}
<div class="analytics-chart-svg">
<svg viewBox="0 0 560 160" role="img" aria-label="{{ label }}">
{% for pct in [25, 50, 75] %}
<line x1="0" y1="{{ 140 - (pct / 100 * 130) }}" x2="556" y2="{{ 140 - (pct / 100 * 130) }}"
stroke="var(--border-light, #e9ecef)" stroke-width="1" />
{% endfor %}
<line x1="0" y1="140" x2="556" y2="140" stroke="var(--border-color, #dee2e6)" stroke-width="1" />
<polygon points="{% for b in data %}{{ loop.index0 * 20 + 8 }},{{ 140 - (b.value / max_value * 130) }} {% endfor %}{{ (data|length - 1) * 20 + 8 }},140 8,140"
fill="{{ color }}" opacity="0.15" />
<polyline
points="{% for b in data %}{{ loop.index0 * 20 + 8 }},{{ 140 - (b.value / max_value * 130) }} {% endfor %}"
fill="none" stroke="{{ color }}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />
{% for b in data %}
<circle cx="{{ loop.index0 * 20 + 8 }}" cy="{{ 140 - (b.value / max_value * 130) }}"
r="3" fill="{{ color }}">
<title>{{ b.label }}: {{ b.tooltip }}</title>
</circle>
{% endfor %}
{% for b in data %}
{% if loop.index0 % 7 == 0 %}
<text x="{{ loop.index0 * 20 + 8 }}" y="155" text-anchor="middle"
font-size="9" fill="var(--text-muted, #999)">{{ b.label }}</text>
{% endif %}
{% endfor %}
</svg>
</div>
{% endif %}
{% endmacro %}
<section class="analytics-page">
<p><a href="/s/{{ request.shop.uuid_str }}/analytics">&larr; Shop Analytics</a> &middot; <a href="{{ product_url }}">View Product</a></p>
@ -64,6 +95,25 @@
</div>
{% endif %}
{# --- Trend Line Charts (28 days) --- #}
{% if daily_session_duration_max > 0 %}
<h4>Session Duration Trend (last 28 days)</h4>
<p class="analytics-hint">Average time visitors spend per session.</p>
{{ line_chart(daily_session_duration, daily_session_duration_max, "Session duration trend", "var(--blue-color, #98b6fa)") }}
{% endif %}
{% if daily_engagement %}
<h4>Engagement Trend (last 28 days)</h4>
<p class="analytics-hint">Active interaction time as a fraction of total session time.</p>
{{ line_chart(daily_engagement, 1.0, "Engagement trend", "var(--green-color, #a3c765)") }}
{% endif %}
{% if daily_bounce %}
<h4>Bounce Rate Trend (last 28 days)</h4>
<p class="analytics-hint">Sessions under 7 seconds visible. Lower is better.</p>
{{ line_chart(daily_bounce, 1.0, "Bounce rate trend", "var(--red-color, #bc2131)") }}
{% endif %}
{# --- Views Over Time --- #}
<h4>Views Over Time</h4>
<div class="analytics-overview well">
@ -168,6 +218,47 @@
</div>
{% endif %}
{# --- Top Referrer Domains (21d) --- #}
{% if top_referrer_domains %}
<h4>Top Referrer Domains (last 21 days)</h4>
<p class="analytics-hint">External sites sending visitors to this product.</p>
<div class="analytics-bar-rows">
{% for d in top_referrer_domains %}
<div class="analytics-bar-row analytics-bar-row--wide">
<span class="analytics-bar-label">{{ d.domain }}</span>
<span class="analytics-bar-track">
<span class="analytics-bar-fill" style="width: {{ d.pct_raw | round(1) }}%"></span>
</span>
<span class="analytics-bar-value">{{ d.count }} ({{ d.pct }})</span>
</div>
{% endfor %}
</div>
{% endif %}
{# --- Search Engine Queries (21d) --- #}
{% if top_referrer_queries %}
<h4>Search Engine Queries (last 21 days)</h4>
<p class="analytics-hint">What people searched before finding this product.</p>
<div class="analytics-table-wrap">
<table class="analytics-table">
<thead>
<tr>
<th>Query</th>
<th class="analytics-num">Visits</th>
</tr>
</thead>
<tbody>
{% for q in top_referrer_queries %}
<tr>
<td>{{ q.query }}</td>
<td class="analytics-num">{{ q.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{# --- Ring Entries --- #}
{% if ring_entry_count > 0 %}
<h4>Ring Entry Sessions (last 21 days)</h4>

View file

@ -3188,43 +3188,76 @@ class TestProductViewCount(unittest.TestCase):
class TestSignalClassifiers(unittest.TestCase):
"""Test classify_referrer and classify_device functions."""
# --- classify_referrer ---
# --- classify_referrer (returns (class, domain, query) tuple) ---
def test_referrer_direct_empty(self):
self.assertEqual(classify_referrer("", "example.com"), 0)
self.assertEqual(classify_referrer("", "example.com"), (0, None, None))
def test_referrer_direct_none(self):
self.assertEqual(classify_referrer(None, "example.com"), 0)
self.assertEqual(classify_referrer(None, "example.com"), (0, None, None))
def test_referrer_search_google(self):
self.assertEqual(
classify_referrer("https://www.google.com/search?q=foo", "example.com"), 1
cls, domain, query = classify_referrer(
"https://www.google.com/search?q=foo", "example.com"
)
self.assertEqual(cls, 1)
self.assertEqual(domain, "www.google.com")
self.assertEqual(query, "foo")
def test_referrer_search_duckduckgo(self):
self.assertEqual(
classify_referrer("https://duckduckgo.com/?q=bar", "example.com"), 1
cls, domain, query = classify_referrer(
"https://duckduckgo.com/?q=bar", "example.com"
)
self.assertEqual(cls, 1)
self.assertEqual(domain, "duckduckgo.com")
self.assertEqual(query, "bar")
def test_referrer_search_no_query(self):
cls, domain, query = classify_referrer(
"https://www.google.com/", "example.com"
)
self.assertEqual(cls, 1)
self.assertEqual(domain, "www.google.com")
self.assertIsNone(query)
def test_referrer_social_twitter(self):
self.assertEqual(
classify_referrer("https://twitter.com/user/status/123", "example.com"), 2
cls, domain, query = classify_referrer(
"https://twitter.com/user/status/123", "example.com"
)
self.assertEqual(cls, 2)
self.assertEqual(domain, "twitter.com")
self.assertIsNone(query)
def test_referrer_social_reddit(self):
self.assertEqual(
classify_referrer("https://www.reddit.com/r/test", "example.com"), 2
cls, domain, query = classify_referrer(
"https://www.reddit.com/r/test", "example.com"
)
self.assertEqual(cls, 2)
self.assertEqual(domain, "www.reddit.com")
self.assertIsNone(query)
def test_referrer_internal(self):
self.assertEqual(
classify_referrer("https://example.com/some/page", "example.com"), 3
cls, domain, query = classify_referrer(
"https://example.com/some/page", "example.com"
)
self.assertEqual(cls, 3)
self.assertEqual(domain, "example.com")
self.assertIsNone(query)
def test_referrer_unknown_external(self):
self.assertEqual(
classify_referrer("https://randomsite.org/page", "example.com"), 4
cls, domain, query = classify_referrer(
"https://randomsite.org/page", "example.com"
)
self.assertEqual(cls, 4)
self.assertEqual(domain, "randomsite.org")
self.assertIsNone(query)
def test_referrer_yahoo_query(self):
cls, domain, query = classify_referrer(
"https://search.yahoo.com/search?p=beats", "example.com"
)
self.assertEqual(cls, 1)
self.assertEqual(query, "beats")
# --- classify_device ---

View file

@ -110,6 +110,209 @@ def _daily_buckets(dbsession, base_filter, days=28):
return buckets
def _daily_avg_duration(dbsession, base_filter, days=28):
"""Bucket page sessions into daily avg wall_clock_ms for a line chart.
Returns oldest-first list of {"day_offset", "label", "value", "tooltip"}.
"""
now = now_timestamp()
cutoff = now - days * DAY_MS
day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)
rows = (
dbsession.query(
day_offset_expr.label("day_offset"),
func.avg(PageSession.wall_clock_ms).label("avg_ms"),
)
.filter(base_filter, PageSession.created_timestamp > cutoff)
.group_by(day_offset_expr)
.all()
)
avgs = {r.day_offset: r.avg_ms or 0 for r in rows}
today = datetime.now(timezone.utc).date()
buckets = []
for i in range(days - 1, -1, -1):
d = today - timedelta(days=i)
val = avgs.get(i, 0)
buckets.append({
"day_offset": i,
"label": d.strftime("%b %-d"),
"value": val,
"tooltip": _fmt_ms(val),
})
return buckets
def _daily_engagement(dbsession, base_filter, days=28):
"""Bucket sessions into daily engagement ratio (active_ms / wall_clock_ms).
Returns oldest-first list with value as 0-1 float.
"""
now = now_timestamp()
cutoff = now - days * DAY_MS
day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)
rows = (
dbsession.query(
day_offset_expr.label("day_offset"),
func.avg(
cast(PageSession.active_ms, Float) / func.nullif(PageSession.wall_clock_ms, 0)
).label("ratio"),
)
.filter(base_filter, PageSession.created_timestamp > cutoff)
.group_by(day_offset_expr)
.all()
)
ratios = {r.day_offset: r.ratio or 0 for r in rows}
today = datetime.now(timezone.utc).date()
buckets = []
for i in range(days - 1, -1, -1):
d = today - timedelta(days=i)
val = ratios.get(i, 0)
buckets.append({
"day_offset": i,
"label": d.strftime("%b %-d"),
"value": val,
"tooltip": _fmt_pct(val),
})
return buckets
def _daily_bounce_rate(dbsession, all_sessions_filter, days=28):
"""Bucket sessions into daily bounce rate (visible < 7s / total).
all_sessions_filter must NOT include the 7s visible floor.
Returns oldest-first list with value as 0-1 float.
"""
now = now_timestamp()
cutoff = now - days * DAY_MS
day_offset_expr = cast((now - PageSession.created_timestamp) / DAY_MS, Integer)
rows = (
dbsession.query(
day_offset_expr.label("day_offset"),
func.count().label("total"),
func.sum(case(
(PageSession.visible_ms < 7000, 1), else_=0
)).label("bounced"),
)
.filter(all_sessions_filter, PageSession.created_timestamp > cutoff)
.group_by(day_offset_expr)
.all()
)
rates = {}
for r in rows:
if r.total and r.total > 0:
rates[r.day_offset] = (r.bounced or 0) / r.total
else:
rates[r.day_offset] = 0
today = datetime.now(timezone.utc).date()
buckets = []
for i in range(days - 1, -1, -1):
d = today - timedelta(days=i)
val = rates.get(i, 0)
buckets.append({
"day_offset": i,
"label": d.strftime("%b %-d"),
"value": val,
"tooltip": _fmt_pct(val),
})
return buckets
def _top_search_keywords(dbsession, shop_id, days=21, limit=21):
"""Top internal search keywords for a shop, last N days."""
from ..models.shop_search_request import ShopSearchRequest
cutoff = now_timestamp() - days * DAY_MS
rows = (
dbsession.query(
ShopSearchRequest.keywords,
func.count().label("search_count"),
func.sum(ShopSearchRequest.hit_count).label("total_hits"),
)
.filter(
ShopSearchRequest.shop_id == shop_id,
ShopSearchRequest.created_timestamp > cutoff,
)
.group_by(ShopSearchRequest.keywords)
.order_by(func.count().desc())
.limit(limit)
.all()
)
return [
{
"keywords": r.keywords,
"search_count": r.search_count,
"total_hits": r.total_hits or 0,
"avg_hits": round((r.total_hits or 0) / max(r.search_count, 1), 1),
}
for r in rows
]
def _top_referrer_domains(dbsession, base_filter, days=21, limit=21):
"""Top referrer domains, excluding internal and direct."""
cutoff = now_timestamp() - days * DAY_MS
rows = (
dbsession.query(
PageSession.referrer_domain,
func.count().label("cnt"),
)
.filter(
base_filter,
PageSession.created_timestamp > cutoff,
PageSession.referrer_domain.isnot(None),
PageSession.referrer_class != 3, # exclude internal
PageSession.referrer_class != 0, # exclude direct
)
.group_by(PageSession.referrer_domain)
.order_by(func.count().desc())
.limit(limit)
.all()
)
total = sum(r.cnt for r in rows) or 1
return [
{
"domain": r.referrer_domain,
"count": r.cnt,
"pct": _fmt_pct(r.cnt / total),
"pct_raw": r.cnt / total * 100,
}
for r in rows
]
def _top_referrer_queries(dbsession, base_filter, days=21, limit=21):
"""Top search queries from external search engines."""
cutoff = now_timestamp() - days * DAY_MS
rows = (
dbsession.query(
PageSession.referrer_query,
func.count().label("cnt"),
)
.filter(
base_filter,
PageSession.created_timestamp > cutoff,
PageSession.referrer_query.isnot(None),
PageSession.referrer_query != "",
)
.group_by(PageSession.referrer_query)
.order_by(func.count().desc())
.limit(limit)
.all()
)
return [
{
"query": r.referrer_query,
"count": r.cnt,
}
for r in rows
]
@view_config(route_name="shop_analytics", renderer="analytics.j2")
@shop_editor_required()
def shop_analytics(request):
@ -149,6 +352,15 @@ def shop_analytics(request):
# --- Daily views (28-day bar chart) ---
daily_views = _daily_buckets(db, _view_base, 28)
# --- Line chart trends (28 days) ---
daily_session_duration = _daily_avg_duration(db, _view_base, 28)
daily_engagement = _daily_engagement(db, _view_base, 28)
_all_sessions_base = and_(
PageSession.shop_id == shop.id,
_not_owner,
)
daily_bounce = _daily_bounce_rate(db, _all_sessions_base, 28)
# --- Section 1: Overview Strip (last 7 days) ---
ov = db.query(
@ -517,11 +729,22 @@ def shop_analytics(request):
daily_views_max = max((b["count"] for b in daily_views), default=0)
# --- Internal Search Keywords (21d) ---
top_keywords = _top_search_keywords(db, shop.id, 21, 21)
# --- Referrer Domains & Search Queries (21d) ---
top_referrer_domains = _top_referrer_domains(db, _view_base, 21, 21)
top_referrer_queries = _top_referrer_queries(db, _view_base, 21, 21)
return {
"overview": overview,
"ring_size": ring_size,
"daily_views": daily_views,
"daily_views_max": daily_views_max,
"daily_session_duration": daily_session_duration,
"daily_session_duration_max": max((b["value"] for b in daily_session_duration), default=0),
"daily_engagement": daily_engagement,
"daily_bounce": daily_bounce,
"top_products": top_products,
"ring_entries": ring_entries,
"engagement": _ranked(eng_raw, _fmt_pct),
@ -532,6 +755,9 @@ def shop_analytics(request):
"video_products": video_products,
"sentiment": sentiment,
"price_changes": price_changes,
"top_keywords": top_keywords,
"top_referrer_domains": top_referrer_domains,
"top_referrer_queries": top_referrer_queries,
"traffic": [
{
"source": REFERRER_LABELS.get(r.referrer_class, "Unknown"),
@ -596,6 +822,15 @@ def product_analytics(request):
)
daily_views = _daily_buckets(db, _pf_base, 28)
# --- Line chart trends (28 days) ---
daily_session_duration = _daily_avg_duration(db, _pf_base, 28)
daily_engagement_data = _daily_engagement(db, _pf_base, 28)
_all_product_sessions = and_(
PageSession.product_id == product.id,
_not_owner,
)
daily_bounce = _daily_bounce_rate(db, _all_product_sessions, 28)
# --- Overview ---
ov = db.query(
func.count().label("views"),
@ -766,6 +1001,10 @@ def product_analytics(request):
daily_views_max = max((b["count"] for b in daily_views), default=0)
# --- Referrer Domains & Search Queries (21d) ---
top_referrer_domains = _top_referrer_domains(db, _pf_base, 21, 21)
top_referrer_queries = _top_referrer_queries(db, _pf_base, 21, 21)
return {
"product": product,
"product_url": product_url,
@ -773,6 +1012,10 @@ def product_analytics(request):
"views_over_time": views_over_time,
"daily_views": daily_views,
"daily_views_max": daily_views_max,
"daily_session_duration": daily_session_duration,
"daily_session_duration_max": max((b["value"] for b in daily_session_duration), default=0),
"daily_engagement": daily_engagement_data,
"daily_bounce": daily_bounce,
"video": video,
"traffic": traffic,
"devices": devices,
@ -780,4 +1023,6 @@ def product_analytics(request):
"engagement": engagement,
"sentiment": sentiment,
"price_changes": price_changes,
"top_referrer_domains": top_referrer_domains,
"top_referrer_queries": top_referrer_queries,
}

View file

@ -29,42 +29,59 @@ _SOCIAL_DOMAINS = {
}
def classify_referrer(referrer, request_host):
"""Classify a Referer header into 0-4.
# Search engine query parameter names by domain root
_SEARCH_QUERY_PARAMS = {
"google": "q", "bing": "q", "yahoo": "p", "duckduckgo": "q",
"baidu": "wd", "yandex": "text", "ecosia": "q", "qwant": "q",
"startpage": "query", "brave": "q",
}
0 = direct (no referrer)
1 = search engine
2 = social media
3 = internal (same host)
4 = unknown (other external)
def classify_referrer(referrer, request_host):
"""Classify a Referer header into (class, domain, query).
class: 0=direct, 1=search, 2=social, 3=internal, 4=unknown
domain: hostname string or None
query: search engine query string or None
"""
if not referrer:
return 0
return 0, None, None
try:
# Extract domain from referrer URL
# e.g. "https://www.google.com/search?q=foo" -> "google"
from urllib.parse import urlparse
from urllib.parse import urlparse, parse_qs
parsed = urlparse(referrer)
host = (parsed.hostname or "").lower()
except Exception:
return 4
return 4, None, None
domain = host or None
# Internal: same host as request
if request_host and host == request_host.lower():
return 3
return 3, domain, None
# Strip www. and extract root domain
if host.startswith("www."):
host = host[4:]
# Get the second-level domain: "search.google.com" -> "google"
parts = host.split(".")
root = parts[-2] if len(parts) >= 2 else host
# Strip www. and extract root domain for classification
clean = host
if clean.startswith("www."):
clean = clean[4:]
parts = clean.split(".")
root = parts[-2] if len(parts) >= 2 else clean
if root in _SEARCH_DOMAINS:
return 1
query = None
param = _SEARCH_QUERY_PARAMS.get(root, "q")
try:
qs = parse_qs(parsed.query)
vals = qs.get(param, [])
if vals:
query = vals[0][:256]
except Exception:
pass
return 1, domain, query
if root in _SOCIAL_DOMAINS:
return 2
return 4
return 2, domain, None
return 4, domain, None
def classify_device(viewport_width):
@ -215,7 +232,10 @@ def beacon_view(request):
request_host = request.host
if ":" in request_host:
request_host = request_host.split(":")[0]
ps.referrer_class = classify_referrer(referrer, request_host)
ref_class, ref_domain, ref_query = classify_referrer(referrer, request_host)
ps.referrer_class = ref_class
ps.referrer_domain = ref_domain[:128] if ref_domain else None
ps.referrer_query = ref_query[:256] if ref_query else None
ps.device_class = classify_device(body.get("viewport_width"))
ps.is_ring_entry = bool(body.get("is_ring_entry")) if body.get("is_ring_entry") is not None else None
ps.ring_position = _clamp_int(body.get("ring_position"), -32768, 32767)