268 lines
9.7 KiB
Python
268 lines
9.7 KiB
Python
"""
|
||
Copyright 2024-2026 ReplyPier
|
||
|
||
Licensed under the Apache License, Version 2.0 (the "License");
|
||
you may not use this file except in compliance with the License.
|
||
You may obtain a copy of the License at
|
||
|
||
http://www.apache.org/licenses/LICENSE-2.0
|
||
|
||
Unless required by applicable law or agreed to in writing, software
|
||
distributed under the License is distributed on an "AS IS" BASIS,
|
||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
See the License for the specific language governing permissions and
|
||
limitations under the License.
|
||
|
||
SEO surface of the public help center: the <head> values (title, description,
|
||
canonical, Open Graph) and the schema.org JSON-LD graph. Kept out of the route
|
||
module so every page derives its metadata by the same rules.
|
||
|
||
Two URL spaces are in play and must not be mixed up:
|
||
- ABSOLUTE (live_url-based) for canonical, og:*, sitemap and every JSON-LD
|
||
@id/url — crawlers and social scrapers need a fetchable address.
|
||
- RELATIVE (base_path-prefixed) for links the page actually renders, so the
|
||
same markup works on a subdomain, a custom domain and /help/{slug}.
|
||
Breadcrumbs carry both, which is how the BreadcrumbList markup is guaranteed to
|
||
match the visible trail (a Google requirement).
|
||
"""
|
||
|
||
from datetime import datetime
|
||
from typing import List, Optional, Sequence
|
||
from urllib.parse import quote, urlsplit
|
||
|
||
from app.models.faq import FAQ
|
||
from app.models.help_center import HelpCenterSettings
|
||
from app.services.help_center_content import excerpt, to_plain_text
|
||
from app.services.help_center_settings import live_url
|
||
|
||
HOME_CRUMB = "Home"
|
||
# Meta descriptions get truncated by search engines around here; the excerpt
|
||
# helper adds an ellipsis rather than cutting mid-word.
|
||
META_DESCRIPTION_CHARS = 200
|
||
|
||
|
||
def site_url(row: HelpCenterSettings) -> str:
|
||
"""The help center's public base URL, never trailing-slashed."""
|
||
return live_url(row).rstrip("/")
|
||
|
||
|
||
def asset_origin(site: str) -> str:
|
||
"""Scheme + host the site's uploads are served from. Same origin as the
|
||
site in every mode — but NOT the same prefix in path mode, where the site
|
||
is {backend}/help/{slug} while uploads stay at {backend}/api/v1/uploads/…"""
|
||
parts = urlsplit(site)
|
||
return f"{parts.scheme}://{parts.netloc}"
|
||
|
||
|
||
def absolute_asset_url(stored_url: Optional[str], site: str) -> Optional[str]:
|
||
"""A stored logo/image URL made absolute for og:image and JSON-LD. S3 URLs
|
||
are already absolute and pass through; local uploads are relative and get
|
||
anchored to the serving origin."""
|
||
if not stored_url:
|
||
return None
|
||
if stored_url.startswith("http://") or stored_url.startswith("https://"):
|
||
return stored_url
|
||
return f"{asset_origin(site)}{stored_url}"
|
||
|
||
|
||
def _isoformat(value: Optional[datetime]) -> Optional[str]:
|
||
return value.isoformat() if value else None
|
||
|
||
|
||
def article_title(row: HelpCenterSettings, faq: FAQ) -> str:
|
||
"""The article's <title>: the org's override, else question + site name."""
|
||
return faq.meta_title or f"{faq.question} · {row.organization.name} Help Center"
|
||
|
||
|
||
def article_description(faq: FAQ) -> str:
|
||
"""The article's meta description: the org's override, else an excerpt of
|
||
the answer (falling back to the question for answers with no prose)."""
|
||
return faq.meta_description or excerpt(faq.answer, META_DESCRIPTION_CHARS) or faq.question
|
||
|
||
|
||
def index_title(row: HelpCenterSettings) -> str:
|
||
return row.title or f"{row.organization.name} Help Center"
|
||
|
||
|
||
def index_description(row: HelpCenterSettings) -> str:
|
||
return row.description or f"Answers to common questions about {row.organization.name}."
|
||
|
||
|
||
def category_url(site: str, category: str) -> str:
|
||
"""Absolute URL of the index filtered to one category — the middle
|
||
breadcrumb. Mirrors the ?topic= link the templates render."""
|
||
return f"{site}/?topic={quote(category)}"
|
||
|
||
|
||
def article_path(faq: FAQ) -> str:
|
||
"""The root-relative path an article is served at, percent-DECODED.
|
||
|
||
An article migrated in with "preserve original URLs" keeps the path it had
|
||
on the old help center; everything else lives at /a/{slug}. Exactly one of
|
||
the two is canonical — /a/{slug} 301s to the preserved path when there is
|
||
one — so this is the single answer to "where does this article live?" and
|
||
every URL the site emits derives from it."""
|
||
return faq.url_path or f"/a/{faq.slug}"
|
||
|
||
|
||
def article_href(faq: FAQ, base_path: str = "") -> str:
|
||
"""What a page renders in href=: the serving prefix plus the ENCODED path."""
|
||
return f"{base_path}{quote(article_path(faq), safe='/')}"
|
||
|
||
|
||
def article_url(site: str, faq: FAQ) -> str:
|
||
"""The article's ABSOLUTE URL, for canonical/og:url/sitemap/JSON-LD."""
|
||
return f"{site}{quote(article_path(faq), safe='/')}"
|
||
|
||
|
||
def breadcrumbs(
|
||
row: HelpCenterSettings, faq: FAQ, base_path: str, site: str
|
||
) -> List[dict]:
|
||
"""Home › Category › Article. Each crumb carries `href` (what the page
|
||
links to) and `url` (what BreadcrumbList advertises); the last crumb is the
|
||
current page and is rendered as text, so it has no href."""
|
||
return [
|
||
{"label": HOME_CRUMB, "href": f"{base_path}/", "url": f"{site}/"},
|
||
{
|
||
"label": faq.category,
|
||
"href": f"{base_path}/?topic={quote(faq.category)}",
|
||
"url": category_url(site, faq.category),
|
||
},
|
||
{"label": faq.question, "href": None, "url": article_url(site, faq)},
|
||
]
|
||
|
||
|
||
def _organization_node(row: HelpCenterSettings, site: str, logo_url: Optional[str]) -> dict:
|
||
node = {
|
||
"@type": "Organization",
|
||
"@id": f"{site}/#organization",
|
||
"name": row.organization.name,
|
||
"url": f"{site}/",
|
||
}
|
||
if logo_url:
|
||
node["logo"] = {"@type": "ImageObject", "url": logo_url}
|
||
return node
|
||
|
||
|
||
def _website_node(row: HelpCenterSettings, site: str) -> dict:
|
||
return {
|
||
"@type": "WebSite",
|
||
"@id": f"{site}/#website",
|
||
"url": f"{site}/",
|
||
"name": index_title(row),
|
||
"publisher": {"@id": f"{site}/#organization"},
|
||
}
|
||
|
||
|
||
def _question_entity(faq: FAQ) -> dict:
|
||
"""One schema.org Question + acceptedAnswer, from the plain-text form of the
|
||
(Markdown) answer."""
|
||
return {
|
||
"@type": "Question",
|
||
"name": faq.question,
|
||
"acceptedAnswer": {"@type": "Answer", "text": to_plain_text(faq.answer)},
|
||
}
|
||
|
||
|
||
def _breadcrumb_node(crumbs: Sequence[dict], page_url: str) -> dict:
|
||
return {
|
||
"@type": "BreadcrumbList",
|
||
"@id": f"{page_url}#breadcrumb",
|
||
"itemListElement": [
|
||
{"@type": "ListItem", "position": i, "name": crumb["label"], "item": crumb["url"]}
|
||
for i, crumb in enumerate(crumbs, start=1)
|
||
],
|
||
}
|
||
|
||
|
||
def index_json_ld(
|
||
row: HelpCenterSettings, faqs: Sequence[FAQ], site: str, logo_url: Optional[str]
|
||
) -> dict:
|
||
"""The landing page graph: Organization + WebSite + a CollectionPage that is
|
||
also the FAQPage carrying every published Q&A.
|
||
|
||
FAQPage - not QAPage - is the correct type for these owner-authored answers.
|
||
QAPage models community/forum pages where users submit competing answers and
|
||
requires answerCount/upvoteCount, which don't apply here. Google retired FAQ
|
||
rich results, but the markup still describes the page for other consumers.
|
||
"""
|
||
page_url = f"{site}/"
|
||
page: dict = {
|
||
"@type": ["CollectionPage", "FAQPage"],
|
||
"@id": f"{page_url}#webpage",
|
||
"url": page_url,
|
||
"name": index_title(row),
|
||
"description": index_description(row),
|
||
"isPartOf": {"@id": f"{site}/#website"},
|
||
"about": {"@id": f"{site}/#organization"},
|
||
}
|
||
if faqs:
|
||
page["mainEntity"] = [_question_entity(faq) for faq in faqs]
|
||
return {
|
||
"@context": "https://schema.org",
|
||
"@graph": [
|
||
_organization_node(row, site, logo_url),
|
||
_website_node(row, site),
|
||
page,
|
||
],
|
||
}
|
||
|
||
|
||
def article_json_ld(
|
||
row: HelpCenterSettings,
|
||
faq: FAQ,
|
||
site: str,
|
||
crumbs: Sequence[dict],
|
||
logo_url: Optional[str],
|
||
image_url: Optional[str],
|
||
) -> dict:
|
||
"""The article page graph: Organization + WebSite + WebPage + BreadcrumbList
|
||
+ TechArticle, wired together by @id.
|
||
|
||
TechArticle (not FAQPage) is what a single how-to/help article is: Google
|
||
dropped FAQ rich results, while BreadcrumbList still renders in search.
|
||
"""
|
||
page_url = article_url(site, faq)
|
||
description = article_description(faq)
|
||
article: dict = {
|
||
"@type": "TechArticle",
|
||
"@id": f"{page_url}#article",
|
||
"headline": faq.question,
|
||
"description": description,
|
||
"articleSection": faq.category,
|
||
"inLanguage": "en",
|
||
"mainEntityOfPage": {"@id": f"{page_url}#webpage"},
|
||
"isPartOf": {"@id": f"{page_url}#webpage"},
|
||
"author": {"@id": f"{site}/#organization"},
|
||
"publisher": {"@id": f"{site}/#organization"},
|
||
}
|
||
published_at = _isoformat(faq.created_at)
|
||
# updated_at stays NULL until the first edit, so fall back to the creation
|
||
# date rather than claiming a modification date older than publication.
|
||
modified_at = _isoformat(faq.updated_at) or published_at
|
||
if published_at:
|
||
article["datePublished"] = published_at
|
||
if modified_at:
|
||
article["dateModified"] = modified_at
|
||
if image_url:
|
||
article["image"] = image_url
|
||
web_page = {
|
||
"@type": "WebPage",
|
||
"@id": f"{page_url}#webpage",
|
||
"url": page_url,
|
||
"name": article_title(row, faq),
|
||
"description": description,
|
||
"isPartOf": {"@id": f"{site}/#website"},
|
||
"breadcrumb": {"@id": f"{page_url}#breadcrumb"},
|
||
}
|
||
return {
|
||
"@context": "https://schema.org",
|
||
"@graph": [
|
||
_organization_node(row, site, logo_url),
|
||
_website_node(row, site),
|
||
web_page,
|
||
_breadcrumb_node(crumbs, page_url),
|
||
article,
|
||
],
|
||
}
|