Skip to content

Commit f71f5d2

Browse files
authored
Story #2435: Webpage Integration: Posts Ranking Algorithm (#2464)
1 parent 7b04b62 commit f71f5d2

14 files changed

Lines changed: 262 additions & 11 deletions

File tree

ak/views.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django.core.exceptions import PermissionDenied
55
from django.http import Http404, HttpResponse
66
from django.shortcuts import render
7+
from django.urls import reverse
78
from django.views import View
89
from django.views.generic import TemplateView
910

@@ -137,13 +138,33 @@ def get_v3_context_data(self, **kwargs):
137138
"primary_button_url": "www.example.com",
138139
"primary_button_label": "Explore Our History",
139140
}
141+
tag_display = {"blogpost": "Blog"}
142+
popular_entries = (
143+
Entry.objects.ranked()
144+
.filter(deleted_at__isnull=True, published=True)
145+
.select_related("author")[:5]
146+
)
140147
ctx["posts_from_the_boost_community"] = {
141148
"heading": "Posts from the Boost Community",
142149
"primary_cta_label": "View all posts",
143-
"primary_cta_url": "#",
150+
"primary_cta_url": reverse("news"),
144151
"variant": "card",
145152
"theme": "teal",
146-
"items": SharedResources.demo_posts[:5],
153+
"items": [
154+
{
155+
"title": entry.title,
156+
"url": entry.get_absolute_url(),
157+
"date": entry.publish_at,
158+
"category": (
159+
tag_display.get(str(entry.tag).lower(), entry.tag.capitalize())
160+
if entry.tag
161+
else ""
162+
),
163+
"tag": "",
164+
"author": entry.author.to_v3_profile_dict(),
165+
}
166+
for entry in popular_entries
167+
],
147168
}
148169
ctx["join_developers_building_the_future_of_cpp"] = {
149170
"items": SharedResources.demo_join_community_links[:5]

config/celery.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,9 @@ def setup_periodic_tasks(sender, **kwargs):
118118
crontab(day_of_week="sun", hour=2, minute=0),
119119
app.signature("asciidoctor_sandbox.tasks.cleanup_old_sandbox_documents"),
120120
)
121+
122+
# Sync per-post page views from Plausible. Executes daily at 6:00 AM.
123+
sender.add_periodic_task(
124+
crontab(hour=6, minute=0),
125+
app.signature("news.tasks.sync_post_views_from_plausible"),
126+
)

config/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,7 @@
627627
BOOST_CALENDAR = "5rorfm42nvmpt77ac0vult9iig@group.calendar.google.com"
628628
CALENDAR_API_KEY = env("CALENDAR_API_KEY", default="changeme")
629629
PLAUSIBLE_STATS_KEY = env("PLAUSIBLE_STATS_KEY", default="changeme")
630+
POSTS_RANKING_GRAVITY = env.float("POSTS_RANKING_GRAVITY", default=2.0)
630631
EVENTS_CACHE_KEY = "homepage_events"
631632
EVENTS_CACHE_TIMEOUT = 300 # 5 min
632633

docs/env_vars.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,16 @@ This project uses environment variables to configure certain aspects of the appl
9494
- For **local development**, set this in your `.env` file. Note: docker compose only loads `env_file` at container creation, so after adding the variable run `docker compose up -d --force-recreate web celery-worker celery-beat` to pick it up.
9595
- In **deployed environments**, set as a kube secret in `kube/boost/values.yaml` (or the environment-specific yaml file).
9696
- Without this variable set, OpenRouter responds with `401 No cookie auth credentials found` and Celery retries the task up to 3 times before giving up.
97+
98+
### `PLAUSIBLE_STATS_KEY`
99+
100+
- API key for the Plausible Analytics API, used to sync per-post page view counts into `Entry.page_views`.
101+
- For **local development**, obtain a valid value from the Boost team.
102+
- In **deployed environments**, the valid value is set in the environment-specific secrets.
103+
- If not set (or set to `changeme`), the sync task is silently skipped.
104+
105+
### `POSTS_RANKING_GRAVITY`
106+
107+
- Controls the decay rate of the posts ranking algorithm (`score = views / (age_hours + 2) ^ gravity`).
108+
- Higher values cause recent posts to decay faster and drop in ranking sooner.
109+
- Defaults to `2.0` if not set.

env.template

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ CELERY_BROKER=redis://redis:6379/0
5151
CELERY_BACKEND=redis://redis:6379/0
5252

5353
CALENDAR_API_KEY=changeme
54+
PLAUSIBLE_STATS_KEY=changeme
55+
POSTS_RANKING_GRAVITY=2.0
5456

5557
# terraform vars for google cloud oauth credential creation for local dev use
5658
TF_VAR_google_cloud_email=

justfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,9 @@ alias shell := console
182182
@manage +args:
183183
docker compose run --rm web python manage.py {{ args }}
184184

185+
@sync_post_views *args: ## sync per-post page view counts from Plausible (pass --dry-run to preview)
186+
docker compose run --rm web python manage.py sync_post_views {{ args }}
187+
185188
# Static File Management
186189
@down_sync_images:
187190
scripts/sync-large-static-images.sh --down-sync;
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import djclick as click
2+
import requests
3+
4+
from news.plausible import fetch_post_views, update_page_views
5+
from news.models import Entry
6+
7+
8+
@click.command()
9+
@click.option(
10+
"--dry-run",
11+
is_flag=True,
12+
help="Show what would be updated without writing to the database",
13+
)
14+
def command(dry_run):
15+
"""Sync per-post page view counts from Plausible into Entry.page_views."""
16+
17+
try:
18+
slug_views = fetch_post_views()
19+
except (requests.HTTPError, ValueError) as e:
20+
raise click.ClickException(str(e)) from e
21+
22+
if not slug_views:
23+
click.echo("No matching post URLs returned by Plausible.")
24+
return
25+
26+
entries = list(Entry.objects.filter(slug__in=slug_views.keys()))
27+
matched = {e.slug: e for e in entries}
28+
29+
if dry_run:
30+
click.echo(f"Would update {len(matched)} entries (dry run):")
31+
for slug, entry in matched.items():
32+
click.echo(f" {slug}: {entry.page_views} -> {slug_views[slug]}")
33+
return
34+
35+
updated = update_page_views(slug_views, entries=entries)
36+
click.echo(f"Updated page_views for {updated} entries.")
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
6+
dependencies = [
7+
("news", "0013_video_thumbnail"),
8+
]
9+
10+
operations = [
11+
migrations.AddField(
12+
model_name="entry",
13+
name="page_views",
14+
field=models.PositiveIntegerField(default=0),
15+
),
16+
]

news/models.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from pathlib import Path
22

33
from structlog import get_logger
4+
from django.conf import settings
45
from django.contrib.auth import get_user_model
56
from django.db import models
6-
from django.db.models import Case, Value, When
7+
from django.db.models import Case, ExpressionWrapper, FloatField, F, Func, Value, When
8+
from django.db.models.functions import Greatest, Now, Power
79
from django.urls import reverse
810
from django.utils.functional import cached_property
911
from django.utils.text import slugify
@@ -26,6 +28,12 @@
2628
logger = get_logger(__name__)
2729

2830

31+
class ExtractEpoch(Func):
32+
function = "EXTRACT"
33+
template = "%(function)s(EPOCH FROM %(expressions)s)"
34+
output_field = FloatField()
35+
36+
2937
class EntryManager(models.Manager):
3038
def get_queryset(self):
3139
result = (
@@ -55,6 +63,20 @@ def get_queryset(self):
5563
def published(self):
5664
return self.get_queryset().filter(published=True)
5765

66+
def ranked(self):
67+
gravity = float(getattr(settings, "POSTS_RANKING_GRAVITY", 2.0))
68+
age_in_hours = ExpressionWrapper(
69+
Greatest(ExtractEpoch(Now() - F("publish_at")), Value(0.0)) / Value(3600.0),
70+
output_field=FloatField(),
71+
)
72+
score = ExpressionWrapper(
73+
F("page_views") / Power(age_in_hours + Value(2.0), Value(gravity)),
74+
output_field=FloatField(),
75+
)
76+
return (
77+
self.get_queryset().annotate(ranking_score=score).order_by("-ranking_score")
78+
)
79+
5880

5981
class Entry(models.Model):
6082
"""A news entry.
@@ -102,6 +124,7 @@ class AlreadyApprovedError(Exception):
102124
blank=True,
103125
related_name="deleted_entries",
104126
)
127+
page_views = models.PositiveIntegerField(default=0)
105128

106129
objects = EntryManager()
107130

news/plausible.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import requests
2+
import structlog
3+
from django.conf import settings
4+
5+
from news.models import Entry
6+
from reports.constants import WEB_ANALYTICS_API_URL_V2, WEB_ANALYTICS_DOMAIN
7+
8+
logger = structlog.get_logger(__name__)
9+
10+
NEWS_ENTRY_PREFIX = "/news/entry/"
11+
12+
13+
def fetch_post_views() -> dict[str, int]:
14+
"""Return a slug-to-pageviews mapping fetched from Plausible API v2.
15+
16+
Returns an empty dict if the API key is not configured or the response
17+
contains no matching URLs.
18+
Raises requests.HTTPError on a non-2xx response.
19+
"""
20+
if not settings.PLAUSIBLE_STATS_KEY or settings.PLAUSIBLE_STATS_KEY == "changeme":
21+
logger.info(
22+
"fetch_post_views.skipped", reason="PLAUSIBLE_STATS_KEY not configured"
23+
)
24+
return {}
25+
26+
payload = {
27+
"site_id": WEB_ANALYTICS_DOMAIN,
28+
"metrics": ["pageviews"],
29+
"dimensions": ["event:page"],
30+
"filters": [["contains", "event:page", [NEWS_ENTRY_PREFIX]]],
31+
"date_range": "all",
32+
}
33+
headers = {
34+
"Content-Type": "application/json; charset=utf-8",
35+
"Authorization": f"Bearer {settings.PLAUSIBLE_STATS_KEY}",
36+
}
37+
38+
response = requests.post(
39+
url=WEB_ANALYTICS_API_URL_V2, json=payload, headers=headers
40+
)
41+
if not response.ok:
42+
raise requests.HTTPError(
43+
f"Plausible API error {response.status_code}: {response.text}",
44+
response=response,
45+
)
46+
47+
data = response.json()
48+
if not data or "results" not in data:
49+
raise ValueError(f"Unexpected Plausible API response: {data}")
50+
51+
slug_views: dict[str, int] = {}
52+
for result in data["results"]:
53+
path = result["dimensions"][0]
54+
if not path.startswith(NEWS_ENTRY_PREFIX):
55+
continue
56+
slug = path[len(NEWS_ENTRY_PREFIX) :].rstrip("/")
57+
if slug:
58+
slug_views[slug] = int(result["metrics"][0])
59+
60+
return slug_views
61+
62+
63+
def update_page_views(slug_views: dict[str, int], entries: list | None = None) -> int:
64+
"""Bulk-update Entry.page_views from a slug-to-count mapping.
65+
66+
Returns the number of entries updated.
67+
"""
68+
if not slug_views:
69+
return 0
70+
71+
if entries is None:
72+
entries = list(Entry.objects.filter(slug__in=slug_views.keys()))
73+
unmatched = set(slug_views) - {e.slug for e in entries}
74+
if unmatched:
75+
logger.warning("update_page_views.unmatched_slugs", slugs=sorted(unmatched))
76+
77+
for entry in entries:
78+
entry.page_views = slug_views[entry.slug]
79+
80+
Entry.objects.bulk_update(entries, ["page_views"])
81+
return len(entries)

0 commit comments

Comments
 (0)