|
import json
|
|
import logging
|
|
from xml.etree.ElementTree import Element, tostring
|
|
from xml.dom import minidom
|
|
from devplacepy.config import SITE_URL
|
|
from devplacepy.utils import strip_html
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
SITE_NAME = "DevPlace"
|
|
|
|
|
|
def truncate(text, max_len=160):
|
|
if not text:
|
|
return ""
|
|
text = " ".join(text.split())
|
|
if len(text) <= max_len:
|
|
return text
|
|
text = text[:max_len - 3].rsplit(" ", 1)[0]
|
|
return text + "..."
|
|
|
|
|
|
def site_url(request):
|
|
return SITE_URL or str(request.base_url).rstrip("/")
|
|
|
|
|
|
def website_schema(base_url):
|
|
return {
|
|
"@type": "WebSite",
|
|
"name": SITE_NAME,
|
|
"url": base_url,
|
|
"potentialAction": {
|
|
"@type": "SearchAction",
|
|
"target": {
|
|
"@type": "EntryPoint",
|
|
"urlTemplate": f"{base_url}/feed?search={{query}}"
|
|
},
|
|
"query-input": "required name=query"
|
|
}
|
|
}
|
|
|
|
|
|
def breadcrumb_schema(items, base_url):
|
|
return {
|
|
"@type": "BreadcrumbList",
|
|
"itemListElement": [
|
|
{
|
|
"@type": "ListItem",
|
|
"position": i + 1,
|
|
"item": {
|
|
"@id": item["url"] if item["url"].startswith("http") else f"{base_url}{item['url']}",
|
|
"name": item["name"]
|
|
}
|
|
}
|
|
for i, item in enumerate(items)
|
|
]
|
|
}
|
|
|
|
|
|
def discussion_forum_posting(post, author, comment_count, star_count, base_url):
|
|
schema = {
|
|
"@type": "DiscussionForumPosting",
|
|
"headline": post.get("title") or "Untitled",
|
|
"text": truncate(post.get("content", ""), 500),
|
|
"url": f"{base_url}/posts/{post.get('slug') or post['uid']}",
|
|
"author": {
|
|
"@type": "Person",
|
|
"name": author["username"] if author else "Unknown",
|
|
"url": f"{base_url}/profile/{author['username']}" if author else ""
|
|
},
|
|
"datePublished": post.get("created_at", ""),
|
|
"interactionStatistic": [
|
|
{"@type": "InteractionCounter", "interactionType": "https://schema.org/LikeAction", "userInteractionCount": star_count},
|
|
{"@type": "InteractionCounter", "interactionType": "https://schema.org/CommentAction", "userInteractionCount": comment_count}
|
|
]
|
|
}
|
|
return schema
|
|
|
|
|
|
def profile_page_schema(profile_user, post_count, base_url):
|
|
return {
|
|
"@type": "ProfilePage",
|
|
"mainEntity": {
|
|
"@type": "Person",
|
|
"name": profile_user.get("username", ""),
|
|
"alternateName": profile_user.get("username", ""),
|
|
"description": profile_user.get("bio", "") or f"Developer on {SITE_NAME}",
|
|
"interactionStatistic": [
|
|
{"@type": "InteractionCounter", "interactionType": "https://schema.org/WriteAction", "userInteractionCount": post_count}
|
|
]
|
|
}
|
|
}
|
|
|
|
|
|
def software_application_schema(project, base_url):
|
|
return {
|
|
"@type": "SoftwareApplication",
|
|
"name": project.get("title", "Untitled"),
|
|
"description": truncate(project.get("description", ""), 300),
|
|
"url": f"{base_url}/projects/{project.get('slug') or project['uid']}",
|
|
"applicationCategory": "DeveloperApplication",
|
|
"operatingSystem": project.get("platforms", "Cross-platform"),
|
|
"author": {
|
|
"@type": "Person",
|
|
"name": project.get("author_name", "Unknown")
|
|
},
|
|
"datePublished": project.get("created_at", ""),
|
|
"offers": {
|
|
"@type": "Offer",
|
|
"price": "0",
|
|
"priceCurrency": "USD"
|
|
}
|
|
}
|
|
|
|
|
|
def organization_schema(base_url):
|
|
return {
|
|
"@type": "Organization",
|
|
"name": SITE_NAME,
|
|
"url": base_url,
|
|
"logo": f"{base_url}{DEFAULT_OG_IMAGE}",
|
|
}
|
|
|
|
|
|
def news_article_schema(article, base_url, image_url=""):
|
|
url = f"{base_url}/news/{article.get('slug') or article['uid']}"
|
|
schema = {
|
|
"@type": "NewsArticle",
|
|
"headline": (article.get("title") or "Untitled")[:110],
|
|
"description": truncate(strip_html(article.get("description", "") or ""), 200),
|
|
"url": url,
|
|
"datePublished": article.get("synced_at", "") or article.get("created_at", ""),
|
|
"mainEntityOfPage": {"@type": "WebPage", "@id": url},
|
|
"author": {"@type": "Organization", "name": article.get("source_name") or SITE_NAME},
|
|
"publisher": organization_schema(base_url),
|
|
}
|
|
if image_url:
|
|
schema["image"] = image_url
|
|
return schema
|
|
|
|
|
|
def software_source_code_schema(gist, base_url):
|
|
return {
|
|
"@type": "SoftwareSourceCode",
|
|
"name": gist.get("title") or "Gist",
|
|
"description": truncate(strip_html(gist.get("description", "") or ""), 200),
|
|
"url": f"{base_url}/gists/{gist.get('slug') or gist['uid']}",
|
|
"programmingLanguage": gist.get("language", "") or "text",
|
|
"dateCreated": gist.get("created_at", ""),
|
|
}
|
|
|
|
|
|
def combine(schemas):
|
|
if not schemas:
|
|
return None
|
|
cleaned = []
|
|
for s in schemas:
|
|
if s is not None:
|
|
s.pop("@context", None)
|
|
cleaned.append(s)
|
|
if not cleaned:
|
|
return None
|
|
if len(cleaned) == 1:
|
|
return json.dumps({"@context": "https://schema.org", **cleaned[0]}, ensure_ascii=False)
|
|
return json.dumps({"@context": "https://schema.org", "@graph": cleaned}, ensure_ascii=False)
|
|
|
|
|
|
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):
|
|
base = site_url(request)
|
|
page_title = f"{title} - {SITE_NAME}" if title else SITE_NAME
|
|
canonical = f"{base}{request.url.path}"
|
|
page = request.query_params.get("page")
|
|
if page and page not in ("", "1"):
|
|
canonical = f"{canonical}?page={page}"
|
|
clean_description = truncate(strip_html(description), 160)
|
|
og_img = og_image or f"{base}{DEFAULT_OG_IMAGE}"
|
|
return {
|
|
"page_title": page_title,
|
|
"meta_description": clean_description,
|
|
"meta_robots": robots,
|
|
"canonical_url": canonical,
|
|
"og_title": title or SITE_NAME,
|
|
"og_description": clean_description,
|
|
"og_image": og_img,
|
|
"og_type": og_type,
|
|
"breadcrumbs": breadcrumbs or [],
|
|
"page_schema": combine(schemas or []),
|
|
}
|
|
|
|
|
|
def make_sitemap(base_url):
|
|
from devplacepy.database import get_table, db
|
|
|
|
def url_element(loc, lastmod=None, changefreq=None, priority=None):
|
|
u = Element("url")
|
|
loc_el = Element("loc")
|
|
loc_el.text = loc
|
|
u.append(loc_el)
|
|
if lastmod:
|
|
lm = Element("lastmod")
|
|
lm.text = lastmod
|
|
u.append(lm)
|
|
if changefreq:
|
|
cf = Element("changefreq")
|
|
cf.text = changefreq
|
|
u.append(cf)
|
|
if priority is not None:
|
|
pr = Element("priority")
|
|
pr.text = str(priority)
|
|
u.append(pr)
|
|
return u
|
|
|
|
urlset = Element("urlset")
|
|
urlset.set("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9")
|
|
|
|
urlset.append(url_element(f"{base_url}/", changefreq="daily", priority="1.0"))
|
|
urlset.append(url_element(f"{base_url}/feed", changefreq="hourly", priority="0.9"))
|
|
urlset.append(url_element(f"{base_url}/news", changefreq="hourly", priority="0.9"))
|
|
urlset.append(url_element(f"{base_url}/projects", changefreq="daily", priority="0.8"))
|
|
urlset.append(url_element(f"{base_url}/gists", changefreq="daily", priority="0.8"))
|
|
|
|
if "posts" in db.tables:
|
|
posts = list(get_table("posts").find(order_by=["-created_at"], _limit=500))
|
|
for p in posts:
|
|
urlset.append(url_element(
|
|
f"{base_url}/posts/{p.get('slug') or p['uid']}",
|
|
lastmod=p.get("created_at", ""),
|
|
changefreq="weekly",
|
|
priority="0.7"
|
|
))
|
|
|
|
if "projects" in db.tables:
|
|
projects = list(get_table("projects").find(order_by=["-created_at"], _limit=1000))
|
|
for p in projects:
|
|
urlset.append(url_element(
|
|
f"{base_url}/projects/{p.get('slug') or p['uid']}",
|
|
lastmod=p.get("created_at", ""),
|
|
changefreq="weekly",
|
|
priority="0.6"
|
|
))
|
|
|
|
if "gists" in db.tables:
|
|
gists = list(get_table("gists").find(order_by=["-created_at"], _limit=500))
|
|
for g in gists:
|
|
urlset.append(url_element(
|
|
f"{base_url}/gists/{g.get('slug') or g['uid']}",
|
|
lastmod=g.get("created_at", ""),
|
|
changefreq="weekly",
|
|
priority="0.6"
|
|
))
|
|
|
|
if "news" in db.tables:
|
|
articles = list(get_table("news").find(status="published", order_by=["-synced_at"], _limit=500))
|
|
for a in articles:
|
|
urlset.append(url_element(
|
|
f"{base_url}/news/{a.get('slug') or a['uid']}",
|
|
lastmod=a.get("synced_at", "") or a.get("created_at", ""),
|
|
changefreq="weekly",
|
|
priority="0.7"
|
|
))
|
|
|
|
if "users" in db.tables:
|
|
post_counts = {}
|
|
if "posts" in db.tables:
|
|
for row in db.query("SELECT user_uid, COUNT(*) AS c FROM posts GROUP BY user_uid"):
|
|
post_counts[row["user_uid"]] = row["c"]
|
|
users = list(get_table("users").find(order_by=["-created_at"], _limit=200))
|
|
for u in users:
|
|
if post_counts.get(u["uid"], 0) < 2:
|
|
continue
|
|
urlset.append(url_element(
|
|
f"{base_url}/profile/{u['username']}",
|
|
lastmod=u.get("created_at", ""),
|
|
changefreq="weekly",
|
|
priority="0.4"
|
|
))
|
|
|
|
rough = tostring(urlset, encoding="unicode")
|
|
dom = minidom.parseString(rough)
|
|
return dom.toprettyxml(indent=" ")
|