This commit is contained in:
retoor 2026-06-05 20:05:07 +02:00
parent 9e836ea152
commit 8d4a82965d
14 changed files with 326 additions and 23 deletions

View File

@ -22,6 +22,23 @@ def is_owner(item: dict | None, user: dict | None) -> bool:
return bool(item and user and item["user_uid"] == user["uid"]) return bool(item and user and item["user_uid"] == user["uid"])
def canonical_redirect(area: str, item: dict, requested: str) -> RedirectResponse | None:
canonical = item.get("slug") or item["uid"]
if requested == canonical:
return None
return RedirectResponse(url=f"/{area}/{canonical}", status_code=301)
def first_image_url(item: dict, attachments: list | None) -> str | None:
inline = item.get("image")
if inline:
return f"/static/uploads/{inline}"
for attachment in attachments or []:
if attachment.get("is_image"):
return attachment["url"]
return None
def create_content_item(table_name: str, target_type: str, user: dict, fields: dict, slug_source: str, xp: int, badge: str, mention_text: str, attachment_uids: list | None) -> tuple[str, str]: def create_content_item(table_name: str, target_type: str, user: dict, fields: dict, slug_source: str, xp: int, badge: str, mention_text: str, attachment_uids: list | None) -> tuple[str, str]:
uid = generate_uid() uid = generate_uid()
slug = make_combined_slug(slug_source, uid) slug = make_combined_slug(slug_source, uid)
@ -65,6 +82,7 @@ def edit_content_item(table_name: str, user: dict, slug: str, update_fields: dic
item = resolve_by_slug(table, slug) item = resolve_by_slug(table, slug)
if not is_owner(item, user): if not is_owner(item, user):
return RedirectResponse(url=redirect_fail, status_code=302) return RedirectResponse(url=redirect_fail, status_code=302)
update_fields = {**update_fields, "updated_at": datetime.now(timezone.utc).isoformat()}
table.update({"uid": item["uid"], **update_fields}, ["uid"]) table.update({"uid": item["uid"], **update_fields}, ["uid"])
logger.info(f"{table_name} {item['uid']} edited by {user['username']}") logger.info(f"{table_name} {item['uid']} edited by {user['username']}")
return RedirectResponse(url=f"/{table_name}/{item['slug'] or item['uid']}", status_code=302) return RedirectResponse(url=f"/{table_name}/{item['slug'] or item['uid']}", status_code=302)

View File

@ -6,7 +6,7 @@ from devplacepy.attachments import get_attachments_batch
from devplacepy.content import enrich_items from devplacepy.content import enrich_items
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user from devplacepy.utils import get_current_user
from devplacepy.seo import list_page_seo from devplacepy.seo import list_page_seo, next_page_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -59,6 +59,7 @@ async def feed_page(request: Request, tab: str = "all", topic: str = None, befor
title="Feed", title="Feed",
description="Discover the latest developer discussions, projects, and community activity on DevPlace.", description="Discover the latest developer discussions, projects, and community activity on DevPlace.",
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "Feed", "url": "/feed"}], breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "Feed", "url": "/feed"}],
next_url=next_page_url(request, next_cursor),
) )
return templates.TemplateResponse(request, "feed.html", { return templates.TemplateResponse(request, "feed.html", {
**seo_ctx, **seo_ctx,

View File

@ -4,10 +4,10 @@ from fastapi import APIRouter, Request, Form
from devplacepy.models import GistForm, GistEditForm from devplacepy.models import GistForm, GistEditForm
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, get_users_by_uids, get_gist_languages, paginate from devplacepy.database import get_table, get_users_by_uids, get_gist_languages, paginate
from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items, create_content_item, detail_context from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items, create_content_item, detail_context, canonical_redirect, first_image_url
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, not_found, XP_GIST from devplacepy.utils import get_current_user, require_user, not_found, XP_GIST
from devplacepy.seo import base_seo_context, site_url, website_schema, software_source_code_schema, list_page_seo from devplacepy.seo import base_seo_context, site_url, website_schema, software_source_code_schema, list_page_seo, next_page_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -53,6 +53,7 @@ async def gists_page(request: Request, language: str = None, user_uid: str = Non
{"name": "Home", "url": "/feed"}, {"name": "Home", "url": "/feed"},
{"name": "Gists", "url": "/gists"}, {"name": "Gists", "url": "/gists"},
], ],
next_url=next_page_url(request, next_cursor),
) )
return templates.TemplateResponse(request, "gists.html", { return templates.TemplateResponse(request, "gists.html", {
**seo_ctx, **seo_ctx,
@ -74,6 +75,9 @@ async def gist_detail(request: Request, gist_slug: str):
if not detail: if not detail:
raise not_found("Gist not found") raise not_found("Gist not found")
gist = detail["item"] gist = detail["item"]
redirect = canonical_redirect("gists", gist, gist_slug)
if redirect:
return redirect
base = site_url(request) base = site_url(request)
seo_ctx = base_seo_context( seo_ctx = base_seo_context(
@ -81,6 +85,7 @@ async def gist_detail(request: Request, gist_slug: str):
title=gist.get("title", "Gist"), title=gist.get("title", "Gist"),
description=gist.get("description", "")[:160], description=gist.get("description", "")[:160],
og_type="article", og_type="article",
og_image=first_image_url(gist, detail["attachments"]),
breadcrumbs=[ breadcrumbs=[
{"name": "Home", "url": "/feed"}, {"name": "Home", "url": "/feed"},
{"name": "Gists", "url": "/gists"}, {"name": "Gists", "url": "/gists"},

View File

@ -5,7 +5,8 @@ from fastapi.responses import HTMLResponse
from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids, paginate from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids, paginate
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago, not_found from devplacepy.utils import get_current_user, time_ago, not_found
from devplacepy.seo import base_seo_context, website_schema, site_url, news_article_schema, list_page_seo from devplacepy.content import canonical_redirect
from devplacepy.seo import base_seo_context, website_schema, site_url, news_article_schema, list_page_seo, next_page_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -45,6 +46,7 @@ async def news_page(request: Request, before: str = None):
title="Developer News", title="Developer News",
description="Curated developer news and industry signals. Stay ahead with hand-picked articles.", description="Curated developer news and industry signals. Stay ahead with hand-picked articles.",
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "News", "url": "/news"}], breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "News", "url": "/news"}],
next_url=next_page_url(request, next_cursor),
) )
return templates.TemplateResponse(request, "news.html", { return templates.TemplateResponse(request, "news.html", {
@ -63,6 +65,9 @@ async def news_detail_page(request: Request, news_slug: str):
article = resolve_by_slug(news_table, news_slug) article = resolve_by_slug(news_table, news_slug)
if not article: if not article:
raise not_found("News article not found") raise not_found("News article not found")
redirect = canonical_redirect("news", article, news_slug)
if redirect:
return redirect
image_url = "" image_url = ""
if "news_images" in db.tables: if "news_images" in db.tables:

View File

@ -6,7 +6,7 @@ from devplacepy.constants import TOPICS
from devplacepy.database import db from devplacepy.database import db
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, time_ago, not_found, XP_POST from devplacepy.utils import get_current_user, require_user, time_ago, not_found, XP_POST
from devplacepy.content import load_detail, edit_content_item, delete_content_item, create_content_item, detail_context from devplacepy.content import load_detail, edit_content_item, delete_content_item, create_content_item, detail_context, canonical_redirect, first_image_url
from devplacepy.seo import base_seo_context, site_url, website_schema, discussion_forum_posting, truncate from devplacepy.seo import base_seo_context, site_url, website_schema, discussion_forum_posting, truncate
from devplacepy.attachments import save_inline_image from devplacepy.attachments import save_inline_image
from devplacepy.models import PostForm, PostEditForm from devplacepy.models import PostForm, PostEditForm
@ -49,6 +49,9 @@ async def view_post(request: Request, post_slug: str):
if not detail: if not detail:
raise not_found("Post not found") raise not_found("Post not found")
post = detail["item"] post = detail["item"]
redirect = canonical_redirect("posts", post, post_slug)
if redirect:
return redirect
author = detail["author"] author = detail["author"]
top_level = detail["comments"] top_level = detail["comments"]
@ -69,6 +72,7 @@ async def view_post(request: Request, post_slug: str):
{"name": post.get("title") or "Post", "url": f"/posts/{post['slug'] or post['uid']}"}, {"name": post.get("title") or "Post", "url": f"/posts/{post['slug'] or post['uid']}"},
], ],
og_type="article", og_type="article",
og_image=first_image_url(post, detail["attachments"]),
schemas=[ schemas=[
website_schema(base), website_schema(base),
discussion_forum_posting(post, author, comment_count, detail["star_count"], base), discussion_forum_posting(post, author, comment_count, detail["star_count"], base),

View File

@ -6,7 +6,8 @@ from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, db, get_user_stars, get_user_rank, get_comment_counts_by_post_uids from devplacepy.database import get_table, db, get_user_stars, get_user_rank, get_comment_counts_by_post_uids
from devplacepy.content import enrich_items from devplacepy.content import enrich_items
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, require_user_api, time_ago, clear_user_cache from devplacepy.utils import get_current_user, require_user, require_user_api, time_ago, clear_user_cache, not_found
from devplacepy.avatar import avatar_url
from devplacepy.seo import base_seo_context, site_url, website_schema, profile_page_schema from devplacepy.seo import base_seo_context, site_url, website_schema, profile_page_schema
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -35,7 +36,7 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
users = get_table("users") users = get_table("users")
profile_user = users.find_one(username=username) profile_user = users.find_one(username=username)
if not profile_user: if not profile_user:
return RedirectResponse(url="/feed", status_code=302) raise not_found("Profile not found")
profile_user["stars"] = get_user_stars(profile_user["uid"]) profile_user["stars"] = get_user_stars(profile_user["uid"])
rank = get_user_rank(profile_user["uid"]) rank = get_user_rank(profile_user["uid"])
@ -82,6 +83,7 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
description=desc, description=desc,
robots=robots, robots=robots,
og_type="profile", og_type="profile",
og_image=avatar_url("multiavatar", profile_user["username"], 256),
breadcrumbs=[ breadcrumbs=[
{"name": "Home", "url": "/feed"}, {"name": "Home", "url": "/feed"},
{"name": profile_user['username'], "url": f"/profile/{profile_user['username']}"}, {"name": profile_user['username'], "url": f"/profile/{profile_user['username']}"},

View File

@ -5,10 +5,10 @@ from fastapi import APIRouter, Request, Form
from devplacepy.models import ProjectForm from devplacepy.models import ProjectForm
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table, get_users_by_uids, get_site_stats, get_user_votes, paginate from devplacepy.database import get_table, get_users_by_uids, get_site_stats, get_user_votes, paginate
from devplacepy.content import load_detail, delete_content_item, create_content_item, detail_context from devplacepy.content import load_detail, delete_content_item, create_content_item, detail_context, canonical_redirect, first_image_url
from devplacepy.templating import templates from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, not_found, XP_PROJECT from devplacepy.utils import get_current_user, require_user, not_found, XP_PROJECT
from devplacepy.seo import base_seo_context, site_url, website_schema, software_application_schema, list_page_seo from devplacepy.seo import base_seo_context, site_url, website_schema, software_application_schema, list_page_seo, next_page_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -66,6 +66,7 @@ async def projects_page(
{"name": "Home", "url": "/feed"}, {"name": "Home", "url": "/feed"},
{"name": "Projects", "url": "/projects"}, {"name": "Projects", "url": "/projects"},
], ],
next_url=next_page_url(request, next_cursor),
) )
return templates.TemplateResponse(request, "projects.html", { return templates.TemplateResponse(request, "projects.html", {
**seo_ctx, **seo_ctx,
@ -88,12 +89,16 @@ async def project_detail(request: Request, project_slug: str):
if not detail: if not detail:
raise not_found("Project not found") raise not_found("Project not found")
project = detail["item"] project = detail["item"]
redirect = canonical_redirect("projects", project, project_slug)
if redirect:
return redirect
base = site_url(request) base = site_url(request)
seo_ctx = base_seo_context( seo_ctx = base_seo_context(
request, request,
title=project.get("title", "Project"), title=project.get("title", "Project"),
description=project.get("description", "")[:160], description=project.get("description", "")[:160],
og_image=first_image_url(project, detail["attachments"]),
breadcrumbs=[ breadcrumbs=[
{"name": "Home", "url": "/feed"}, {"name": "Home", "url": "/feed"},
{"name": "Projects", "url": "/projects"}, {"name": "Projects", "url": "/projects"},

View File

@ -1,5 +1,7 @@
import json import json
import logging import logging
import time
from urllib.parse import urlencode
from xml.etree.ElementTree import Element, tostring from xml.etree.ElementTree import Element, tostring
from xml.dom import minidom from xml.dom import minidom
from devplacepy.config import SITE_URL from devplacepy.config import SITE_URL
@ -9,6 +11,9 @@ logger = logging.getLogger(__name__)
SITE_NAME = "DevPlace" SITE_NAME = "DevPlace"
SITEMAP_URL_LIMIT = 5000
SITEMAP_TTL = 3600
_sitemap_cache = {}
def truncate(text, max_len=160): def truncate(text, max_len=160):
@ -70,6 +75,7 @@ def discussion_forum_posting(post, author, comment_count, star_count, base_url):
"url": f"{base_url}/profile/{author['username']}" if author else "" "url": f"{base_url}/profile/{author['username']}" if author else ""
}, },
"datePublished": post.get("created_at", ""), "datePublished": post.get("created_at", ""),
"dateModified": post.get("updated_at") or post.get("created_at", ""),
"interactionStatistic": [ "interactionStatistic": [
{"@type": "InteractionCounter", "interactionType": "https://schema.org/LikeAction", "userInteractionCount": star_count}, {"@type": "InteractionCounter", "interactionType": "https://schema.org/LikeAction", "userInteractionCount": star_count},
{"@type": "InteractionCounter", "interactionType": "https://schema.org/CommentAction", "userInteractionCount": comment_count} {"@type": "InteractionCounter", "interactionType": "https://schema.org/CommentAction", "userInteractionCount": comment_count}
@ -106,6 +112,7 @@ def software_application_schema(project, base_url):
"name": project.get("author_name", "Unknown") "name": project.get("author_name", "Unknown")
}, },
"datePublished": project.get("created_at", ""), "datePublished": project.get("created_at", ""),
"dateModified": project.get("updated_at") or project.get("created_at", ""),
"offers": { "offers": {
"@type": "Offer", "@type": "Offer",
"price": "0", "price": "0",
@ -131,6 +138,7 @@ def news_article_schema(article, base_url, image_url=""):
"description": truncate(strip_html(article.get("description", "") or ""), 200), "description": truncate(strip_html(article.get("description", "") or ""), 200),
"url": url, "url": url,
"datePublished": article.get("synced_at", "") or article.get("created_at", ""), "datePublished": article.get("synced_at", "") or article.get("created_at", ""),
"dateModified": article.get("synced_at", "") or article.get("created_at", ""),
"mainEntityOfPage": {"@type": "WebPage", "@id": url}, "mainEntityOfPage": {"@type": "WebPage", "@id": url},
"author": {"@type": "Organization", "name": article.get("source_name") or SITE_NAME}, "author": {"@type": "Organization", "name": article.get("source_name") or SITE_NAME},
"publisher": organization_schema(base_url), "publisher": organization_schema(base_url),
@ -148,6 +156,7 @@ def software_source_code_schema(gist, base_url):
"url": f"{base_url}/gists/{gist.get('slug') or gist['uid']}", "url": f"{base_url}/gists/{gist.get('slug') or gist['uid']}",
"programmingLanguage": gist.get("language", "") or "text", "programmingLanguage": gist.get("language", "") or "text",
"dateCreated": gist.get("created_at", ""), "dateCreated": gist.get("created_at", ""),
"dateModified": gist.get("updated_at") or gist.get("created_at", ""),
} }
@ -183,7 +192,13 @@ def combine(schemas):
DEFAULT_OG_IMAGE = "/static/og-default.png" DEFAULT_OG_IMAGE = "/static/og-default.png"
def base_seo_context(request, title="", description="", robots="index,follow", og_type="website", og_image=None, breadcrumbs=None, schemas=None): def absolute_url(base, path):
if not path:
return ""
return path if path.startswith("http") else f"{base}{path}"
def base_seo_context(request, title="", description="", robots="index,follow", og_type="website", og_image=None, breadcrumbs=None, schemas=None, prev_url=None, next_url=None):
base = site_url(request) base = site_url(request)
page_title = f"{title} - {SITE_NAME}" if title else SITE_NAME page_title = f"{title} - {SITE_NAME}" if title else SITE_NAME
canonical = f"{base}{request.url.path}" canonical = f"{base}{request.url.path}"
@ -191,7 +206,10 @@ def base_seo_context(request, title="", description="", robots="index,follow", o
if page and page not in ("", "1"): if page and page not in ("", "1"):
canonical = f"{canonical}?page={page}" canonical = f"{canonical}?page={page}"
clean_description = truncate(strip_html(description), 160) clean_description = truncate(strip_html(description), 160)
og_img = og_image or f"{base}{DEFAULT_OG_IMAGE}" og_img = absolute_url(base, og_image) or f"{base}{DEFAULT_OG_IMAGE}"
page_schemas = list(schemas or [])
if breadcrumbs:
page_schemas.append(breadcrumb_schema(breadcrumbs, base))
return { return {
"page_title": page_title, "page_title": page_title,
"meta_description": clean_description, "meta_description": clean_description,
@ -202,11 +220,21 @@ def base_seo_context(request, title="", description="", robots="index,follow", o
"og_image": og_img, "og_image": og_img,
"og_type": og_type, "og_type": og_type,
"breadcrumbs": breadcrumbs or [], "breadcrumbs": breadcrumbs or [],
"page_schema": combine(schemas or []), "page_schema": combine(page_schemas),
"prev_url": absolute_url(base, prev_url),
"next_url": absolute_url(base, next_url),
} }
def list_page_seo(request, title="", description="", breadcrumbs=None): def next_page_url(request, next_cursor):
if not next_cursor:
return None
params = dict(request.query_params)
params["before"] = next_cursor
return f"{request.url.path}?{urlencode(params)}"
def list_page_seo(request, title="", description="", breadcrumbs=None, prev_url=None, next_url=None):
base = site_url(request) base = site_url(request)
return base_seo_context( return base_seo_context(
request, request,
@ -214,10 +242,28 @@ def list_page_seo(request, title="", description="", breadcrumbs=None):
description=description, description=description,
breadcrumbs=breadcrumbs, breadcrumbs=breadcrumbs,
schemas=[website_schema(base)], schemas=[website_schema(base)],
prev_url=prev_url,
next_url=next_url,
) )
def make_sitemap(base_url): def make_sitemap(base_url):
cached = _sitemap_cache.get(base_url)
if cached and time.time() - cached[0] < SITEMAP_TTL:
return cached[1]
xml = _build_sitemap(base_url)
_sitemap_cache[base_url] = (time.time(), xml)
return xml
def _collect(table, limit, label, **query):
rows = list(table.find(_limit=limit, **query))
if len(rows) >= limit:
logger.warning("sitemap: %s truncated at %d entries", label, limit)
return rows
def _build_sitemap(base_url):
from devplacepy.database import get_table, db from devplacepy.database import get_table, db
def url_element(loc, lastmod=None, changefreq=None, priority=None): def url_element(loc, lastmod=None, changefreq=None, priority=None):
@ -250,7 +296,7 @@ def make_sitemap(base_url):
urlset.append(url_element(f"{base_url}/leaderboard", changefreq="daily", priority="0.7")) urlset.append(url_element(f"{base_url}/leaderboard", changefreq="daily", priority="0.7"))
if "posts" in db.tables: if "posts" in db.tables:
posts = list(get_table("posts").find(order_by=["-created_at"], _limit=500)) posts = _collect(get_table("posts"), SITEMAP_URL_LIMIT, "posts", order_by=["-created_at"])
for p in posts: for p in posts:
urlset.append(url_element( urlset.append(url_element(
f"{base_url}/posts/{p.get('slug') or p['uid']}", f"{base_url}/posts/{p.get('slug') or p['uid']}",
@ -260,7 +306,7 @@ def make_sitemap(base_url):
)) ))
if "projects" in db.tables: if "projects" in db.tables:
projects = list(get_table("projects").find(order_by=["-created_at"], _limit=1000)) projects = _collect(get_table("projects"), SITEMAP_URL_LIMIT, "projects", order_by=["-created_at"])
for p in projects: for p in projects:
urlset.append(url_element( urlset.append(url_element(
f"{base_url}/projects/{p.get('slug') or p['uid']}", f"{base_url}/projects/{p.get('slug') or p['uid']}",
@ -270,7 +316,7 @@ def make_sitemap(base_url):
)) ))
if "gists" in db.tables: if "gists" in db.tables:
gists = list(get_table("gists").find(order_by=["-created_at"], _limit=500)) gists = _collect(get_table("gists"), SITEMAP_URL_LIMIT, "gists", order_by=["-created_at"])
for g in gists: for g in gists:
urlset.append(url_element( urlset.append(url_element(
f"{base_url}/gists/{g.get('slug') or g['uid']}", f"{base_url}/gists/{g.get('slug') or g['uid']}",
@ -280,7 +326,7 @@ def make_sitemap(base_url):
)) ))
if "news" in db.tables: if "news" in db.tables:
articles = list(get_table("news").find(status="published", order_by=["-synced_at"], _limit=500)) articles = _collect(get_table("news"), SITEMAP_URL_LIMIT, "news", status="published", order_by=["-synced_at"])
for a in articles: for a in articles:
urlset.append(url_element( urlset.append(url_element(
f"{base_url}/news/{a.get('slug') or a['uid']}", f"{base_url}/news/{a.get('slug') or a['uid']}",
@ -294,7 +340,7 @@ def make_sitemap(base_url):
if "posts" in db.tables: if "posts" in db.tables:
for row in db.query("SELECT user_uid, COUNT(*) AS c FROM posts GROUP BY user_uid"): for row in db.query("SELECT user_uid, COUNT(*) AS c FROM posts GROUP BY user_uid"):
post_counts[row["user_uid"]] = row["c"] post_counts[row["user_uid"]] = row["c"]
users = list(get_table("users").find(order_by=["-created_at"], _limit=200)) users = _collect(get_table("users"), SITEMAP_URL_LIMIT, "users", order_by=["-created_at"])
for u in users: for u in users:
if post_counts.get(u["uid"], 0) < 2: if post_counts.get(u["uid"], 0) < 2:
continue continue

View File

@ -207,8 +207,42 @@ class NewsService(BaseService):
content = strip_html(article.get("content", "") or "")[:1500] content = strip_html(article.get("content", "") or "")[:1500]
prompt = ( prompt = (
"Rate this article's relevance to software developers on a scale of 1-10. " "You are an expert content strategist and editor for DevPlace, a "
"Return only a single integer between 1 and 10, nothing else.\n\n" "server-rendered social network for software developers. Decide how "
"strongly a given article should be published, expressed as a single "
"grade.\n\n"
"Site context:\n"
"- Audience: software developers, hobbyists and professionals, "
"interested in software development, AI agents, open-source tools, "
"systems programming, and self-hosting.\n"
"- Mission: deliver deep technical insight, critical analysis, and "
"practical value; build authority and reader trust; avoid low-effort, "
"rehashed, or clickbait content.\n"
"- Tone: technical, practical, nuanced, skeptical of hype; no "
"sensationalism or unverified claims.\n\n"
"Evaluate the article against these weighted criteria and weigh them "
"internally:\n"
"1. Relevance & alignment to the developer niche and audience needs "
"(20%).\n"
"2. Quality & accuracy: depth, originality, factual correctness, "
"sourcing, technical correctness, clear writing (25%).\n"
"3. Engagement & readability: strong title/intro/flow, useful "
"examples, appropriate length (15%).\n"
"4. Originality & uniqueness: new perspective, data, or analysis; not "
"duplicated generic web content (15%).\n"
"5. SEO & discoverability: keyword opportunity without stuffing, "
"shareable (10%).\n"
"6. Ethics, legality & risk: no misinformation, plagiarism, or "
"brand-safety issues; controversial topics handled with nuance and "
"evidence (10%).\n"
"7. Strategic fit for the site (5%).\n\n"
"Convert your overall judgment to a single integer grade from 1 to "
"10:\n"
"- 9-10: excellent, publish as-is.\n"
"- 7-8: good, worth publishing.\n"
"- 5-6: marginal, weak fit or quality.\n"
"- 1-4: reject, low quality or off-topic.\n\n"
"Return ONLY a single integer from 1 to 10, nothing else.\n\n"
f"Title: {title}\n" f"Title: {title}\n"
f"Description: {description}\n" f"Description: {description}\n"
f"Content: {content}" f"Content: {content}"

View File

@ -7,6 +7,8 @@
<title>{% if page_title %}{{ page_title }}{% else %}DevPlace - The Developer Social Network{% endif %}</title> <title>{% if page_title %}{{ page_title }}{% else %}DevPlace - The Developer Social Network{% endif %}</title>
<meta name="description" content="{% if meta_description %}{{ meta_description }}{% else %}Track industry shifts. Discover bold releases. Share what you're building in an open, uncensored environment.{% endif %}"> <meta name="description" content="{% if meta_description %}{{ meta_description }}{% else %}Track industry shifts. Discover bold releases. Share what you're building in an open, uncensored environment.{% endif %}">
<link rel="canonical" href="{{ canonical_url or request.url }}"> <link rel="canonical" href="{{ canonical_url or request.url }}">
{% if prev_url %}<link rel="prev" href="{{ prev_url }}">{% endif %}
{% if next_url %}<link rel="next" href="{{ next_url }}">{% endif %}
<meta name="robots" content="{{ meta_robots or 'index,follow' }}"> <meta name="robots" content="{{ meta_robots or 'index,follow' }}">
<meta property="og:title" content="{{ og_title or page_title or 'DevPlace' }}"> <meta property="og:title" content="{{ og_title or page_title or 'DevPlace' }}">

View File

@ -83,7 +83,7 @@
<article class="news-card"> <article class="news-card">
{% if item.image_url %} {% if item.image_url %}
<div class="news-card-image"> <div class="news-card-image">
<img src="{{ item.image_url }}" alt="" loading="lazy" class="image-fallback"> <img src="{{ item.image_url }}" alt="{{ item.title }}" loading="lazy" class="image-fallback">
</div> </div>
{% endif %} {% endif %}
<div class="news-card-body"> <div class="news-card-body">

View File

@ -15,7 +15,7 @@
<article class="news-card"> <article class="news-card">
{% if item.image_url %} {% if item.image_url %}
<div class="news-card-image"> <div class="news-card-image">
<img src="{{ item.image_url }}" alt="" loading="lazy" class="image-fallback"> <img src="{{ item.image_url }}" alt="{{ item.article['title'] }}" loading="lazy" class="image-fallback">
</div> </div>
{% endif %} {% endif %}
<div class="news-card-body"> <div class="news-card-body">

View File

@ -8,7 +8,7 @@
<article class="news-detail-card"> <article class="news-detail-card">
{% if image_url %} {% if image_url %}
<div class="news-detail-image"> <div class="news-detail-image">
<img src="{{ image_url }}" alt="" loading="lazy" class="image-fallback"> <img src="{{ image_url }}" alt="{{ article['title'] }}" loading="lazy" class="image-fallback">
</div> </div>
{% endif %} {% endif %}

View File

@ -213,3 +213,184 @@ def test_news_detail_has_newsarticle_schema(page, app_server):
text = " ".join(scripts.nth(i).text_content() for i in range(scripts.count())) text = " ".join(scripts.nth(i).text_content() for i in range(scripts.count()))
assert "NewsArticle" in text assert "NewsArticle" in text
assert page.locator('meta[property="og:type"]').get_attribute("content") == "article" assert page.locator('meta[property="og:type"]').get_attribute("content") == "article"
def _seed_owner():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
uid = str(uuid4())
get_table("users").insert({
"uid": uid,
"username": f"seo_{uid[:8]}",
"email": f"{uid[:8]}@seo.test",
"password_hash": "x",
"role": "Member",
"is_active": True,
"created_at": datetime.now(timezone.utc).isoformat(),
})
return uid
def _seed_post(image=None):
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Post", uid)
get_table("posts").insert({
"uid": uid, "user_uid": owner, "slug": slug,
"title": "SEO Detail Post", "content": "Body text for the SEO detail post.",
"topic": "general", "project_uid": None, "image": image,
"stars": 0, "created_at": datetime.now(timezone.utc).isoformat(),
})
return slug, uid
def _seed_gist():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Gist", uid)
get_table("gists").insert({
"uid": uid, "user_uid": owner, "slug": slug,
"title": "SEO Detail Gist", "description": "Gist description for SEO tests.",
"source_code": "print('seo')", "language": "python",
"stars": 0, "created_at": datetime.now(timezone.utc).isoformat(),
})
return slug, uid
def _seed_project():
from datetime import datetime, timezone
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
uid = str(uuid4())
slug = make_combined_slug("SEO Detail Project", uid)
get_table("projects").insert({
"uid": uid, "user_uid": owner, "slug": slug,
"title": "SEO Detail Project", "description": "Project description for SEO tests.",
"project_type": "software", "platforms": "Linux", "status": "Released",
"stars": 0, "created_at": datetime.now(timezone.utc).isoformat(),
})
return slug, uid
def _seed_news_image(news_uid):
from devplacepy.database import get_table
get_table("news_images").insert({
"news_uid": news_uid,
"url": "https://example.com/seo-news-image.jpg",
})
def _seed_feed_posts(count):
from datetime import datetime, timezone, timedelta
from uuid import uuid4
from devplacepy.database import get_table
from devplacepy.utils import make_combined_slug
owner = _seed_owner()
topic = f"seopag{owner[:8]}"
base = datetime(2026, 1, 1, tzinfo=timezone.utc)
posts = get_table("posts")
for i in range(count):
uid = str(uuid4())
posts.insert({
"uid": uid, "user_uid": owner, "slug": make_combined_slug(f"seo pag {i}", uid),
"title": None, "content": f"seo pag post {i}", "topic": topic, "project_uid": None,
"image": None, "stars": 0,
"created_at": (base - timedelta(seconds=i)).isoformat(),
})
return topic
def test_post_uid_redirects_to_canonical_slug(app_server):
slug, uid = _seed_post()
r = requests.get(f"{BASE_URL}/posts/{uid}", allow_redirects=False)
assert r.status_code == 301
assert r.headers["location"].endswith(f"/posts/{slug}"), r.headers.get("location")
def test_post_slug_served_without_redirect(app_server):
slug, uid = _seed_post()
r = requests.get(f"{BASE_URL}/posts/{slug}", allow_redirects=False)
assert r.status_code == 200
def test_gist_uid_redirects_to_canonical_slug(app_server):
slug, uid = _seed_gist()
r = requests.get(f"{BASE_URL}/gists/{uid}", allow_redirects=False)
assert r.status_code == 301
assert r.headers["location"].endswith(f"/gists/{slug}"), r.headers.get("location")
def test_project_uid_redirects_to_canonical_slug(app_server):
slug, uid = _seed_project()
r = requests.get(f"{BASE_URL}/projects/{uid}", allow_redirects=False)
assert r.status_code == 301
assert r.headers["location"].endswith(f"/projects/{slug}"), r.headers.get("location")
def test_news_uid_redirects_to_canonical_slug(app_server):
slug, uid = _seed_news()
r = requests.get(f"{BASE_URL}/news/{uid}", allow_redirects=False)
assert r.status_code == 301
assert r.headers["location"].endswith(f"/news/{slug}"), r.headers.get("location")
def test_missing_profile_returns_404(app_server):
r = requests.get(f"{BASE_URL}/profile/no-such-user-xyz", allow_redirects=False)
assert r.status_code == 404
def test_post_og_image_uses_content_image(page, app_server):
slug, _ = _seed_post(image="seo-content.png")
page.goto(f"{BASE_URL}/posts/{slug}", wait_until="domcontentloaded")
og = page.locator('meta[property="og:image"]').get_attribute("content")
assert og.endswith("/static/uploads/seo-content.png"), og
def test_post_page_has_breadcrumb_schema(page, app_server):
slug, _ = _seed_post()
page.goto(f"{BASE_URL}/posts/{slug}", wait_until="domcontentloaded")
scripts = page.locator('script[type="application/ld+json"]')
text = " ".join(scripts.nth(i).text_content() for i in range(scripts.count()))
assert "BreadcrumbList" in text
def test_post_schema_has_date_modified(page, app_server):
slug, _ = _seed_post()
page.goto(f"{BASE_URL}/posts/{slug}", wait_until="domcontentloaded")
scripts = page.locator('script[type="application/ld+json"]')
text = " ".join(scripts.nth(i).text_content() for i in range(scripts.count()))
assert "dateModified" in text
def test_news_detail_image_has_alt_text(page, app_server):
slug, uid = _seed_news()
_seed_news_image(uid)
page.goto(f"{BASE_URL}/news/{slug}", wait_until="domcontentloaded")
alt = page.locator(".news-detail-image img").get_attribute("alt")
assert alt and alt.strip()
def test_feed_pagination_emits_rel_next(page, app_server):
topic = _seed_feed_posts(26)
page.goto(f"{BASE_URL}/feed?topic={topic}", wait_until="domcontentloaded")
nxt = page.locator('link[rel="next"]')
assert nxt.count() == 1
href = nxt.get_attribute("href")
assert "before=" in href and f"topic={topic}" in href, href
def test_post_detail_has_no_rel_next(page, app_server):
slug, _ = _seed_post()
page.goto(f"{BASE_URL}/posts/{slug}", wait_until="domcontentloaded")
assert page.locator('link[rel="next"]').count() == 0