diff --git a/make_post_sell/models/page_session.py b/make_post_sell/models/page_session.py
index 3dab7f3..1dbc91b 100644
--- a/make_post_sell/models/page_session.py
+++ b/make_post_sell/models/page_session.py
@@ -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
diff --git a/make_post_sell/scripts/alembic/versions/f3086e09b052_add_referrer_domain_and_referrer_query_.py b/make_post_sell/scripts/alembic/versions/f3086e09b052_add_referrer_domain_and_referrer_query_.py
new file mode 100644
index 0000000..b63cb97
--- /dev/null
+++ b/make_post_sell/scripts/alembic/versions/f3086e09b052_add_referrer_domain_and_referrer_query_.py
@@ -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")
diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css
index 45c492b..2d560a1 100644
--- a/make_post_sell/static/css/common.css
+++ b/make_post_sell/static/css/common.css
@@ -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;
}
diff --git a/make_post_sell/templates/analytics.j2 b/make_post_sell/templates/analytics.j2
index 0da40ce..79c0e30 100644
--- a/make_post_sell/templates/analytics.j2
+++ b/make_post_sell/templates/analytics.j2
@@ -6,6 +6,37 @@
{% block content -%}
+{% macro line_chart(data, max_value, label, color) %}
+{% if data and max_value > 0 %}
+
+
+
+{% endif %}
+{% endmacro %}
+
Analytics
@@ -68,6 +99,25 @@
{% endif %}
+ {# --- Trend Line Charts (28 days) --- #}
+ {% if daily_session_duration_max > 0 %}
+ Session Duration Trend (last 28 days)
+ Average time visitors spend per session.
+ {{ line_chart(daily_session_duration, daily_session_duration_max, "Session duration trend", "var(--blue-color, #98b6fa)") }}
+ {% endif %}
+
+ {% if daily_engagement %}
+ Engagement Trend (last 28 days)
+ Active interaction time as a fraction of total session time.
+ {{ line_chart(daily_engagement, 1.0, "Engagement trend", "var(--green-color, #a3c765)") }}
+ {% endif %}
+
+ {% if daily_bounce %}
+ Bounce Rate Trend (last 28 days)
+ Sessions under 7 seconds visible. Lower is better.
+ {{ line_chart(daily_bounce, 1.0, "Bounce rate trend", "var(--red-color, #bc2131)") }}
+ {% endif %}
+
{# --- Ring Consumed breakdown --- #}
Ring Consumed
Unique products viewed via the ring as a percentage of all {{ ring_size }} ring products.
@@ -375,6 +425,73 @@
{% endif %}
+ {# --- Top Referrer Domains (21d) --- #}
+ {% if top_referrer_domains %}
+ Top Referrer Domains (last 21 days)
+ External sites sending visitors to your shop.
+
+ {% for d in top_referrer_domains %}
+
+ {{ d.domain }}
+
+
+
+ {{ d.count }} ({{ d.pct }})
+
+ {% endfor %}
+
+ {% endif %}
+
+ {# --- Search Engine Queries (21d) --- #}
+ {% if top_referrer_queries %}
+ Search Engine Queries (last 21 days)
+ What people searched before finding your shop.
+
+
+
+
+ | Query |
+ Visits |
+
+
+
+ {% for q in top_referrer_queries %}
+
+ | {{ q.query }} |
+ {{ q.count }} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+ {# --- Internal Search Keywords (21d) --- #}
+ {% if top_keywords %}
+ Top Search Keywords (last 21 days)
+ What visitors search for inside your shop.
+
+
+
+
+ | Keywords |
+ Searches |
+ Avg Hits |
+
+
+
+ {% for k in top_keywords %}
+
+ | {{ k.keywords }} |
+ {{ k.search_count }} |
+ {{ k.avg_hits }} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
{% endif %}{# end overview.total_views check #}
{# --- Comment Sentiment (21d) --- #}
diff --git a/make_post_sell/templates/analytics_product.j2 b/make_post_sell/templates/analytics_product.j2
index 30d15c6..7c8b645 100644
--- a/make_post_sell/templates/analytics_product.j2
+++ b/make_post_sell/templates/analytics_product.j2
@@ -6,6 +6,37 @@
{% block content -%}
+{% macro line_chart(data, max_value, label, color) %}
+{% if data and max_value > 0 %}
+
+
+
+{% endif %}
+{% endmacro %}
+
← Shop Analytics · View Product
@@ -64,6 +95,25 @@
{% endif %}
+ {# --- Trend Line Charts (28 days) --- #}
+ {% if daily_session_duration_max > 0 %}
+ Session Duration Trend (last 28 days)
+ Average time visitors spend per session.
+ {{ line_chart(daily_session_duration, daily_session_duration_max, "Session duration trend", "var(--blue-color, #98b6fa)") }}
+ {% endif %}
+
+ {% if daily_engagement %}
+ Engagement Trend (last 28 days)
+ Active interaction time as a fraction of total session time.
+ {{ line_chart(daily_engagement, 1.0, "Engagement trend", "var(--green-color, #a3c765)") }}
+ {% endif %}
+
+ {% if daily_bounce %}
+ Bounce Rate Trend (last 28 days)
+ Sessions under 7 seconds visible. Lower is better.
+ {{ line_chart(daily_bounce, 1.0, "Bounce rate trend", "var(--red-color, #bc2131)") }}
+ {% endif %}
+
{# --- Views Over Time --- #}
Views Over Time
@@ -168,6 +218,47 @@
{% endif %}
+ {# --- Top Referrer Domains (21d) --- #}
+ {% if top_referrer_domains %}
+ Top Referrer Domains (last 21 days)
+ External sites sending visitors to this product.
+
+ {% for d in top_referrer_domains %}
+
+ {{ d.domain }}
+
+
+
+ {{ d.count }} ({{ d.pct }})
+
+ {% endfor %}
+
+ {% endif %}
+
+ {# --- Search Engine Queries (21d) --- #}
+ {% if top_referrer_queries %}
+ Search Engine Queries (last 21 days)
+ What people searched before finding this product.
+
+
+
+
+ | Query |
+ Visits |
+
+
+
+ {% for q in top_referrer_queries %}
+
+ | {{ q.query }} |
+ {{ q.count }} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
{# --- Ring Entries --- #}
{% if ring_entry_count > 0 %}
Ring Entry Sessions (last 21 days)
diff --git a/make_post_sell/tests/test_models.py b/make_post_sell/tests/test_models.py
index 009993f..608d870 100644
--- a/make_post_sell/tests/test_models.py
+++ b/make_post_sell/tests/test_models.py
@@ -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 ---
diff --git a/make_post_sell/views/analytics.py b/make_post_sell/views/analytics.py
index 13fcfe9..d565c1a 100644
--- a/make_post_sell/views/analytics.py
+++ b/make_post_sell/views/analytics.py
@@ -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,
}
diff --git a/make_post_sell/views/signals.py b/make_post_sell/views/signals.py
index 05cad95..27661e5 100644
--- a/make_post_sell/views/signals.py
+++ b/make_post_sell/views/signals.py
@@ -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)