diff --git a/website/build.py b/website/build.py
index 9d278f7d..5fd1b596 100644
--- a/website/build.py
+++ b/website/build.py
@@ -118,14 +118,11 @@ def build_robots_txt() -> str:
return f"User-agent: *\nContent-Signal: search=yes, ai-input=yes, ai-train=yes\nAllow: /\n\nSitemap: {SITEMAP_URL}\n"
-def build_homepage_json_ld(entries: Sequence[TemplateEntry], total_categories: int) -> dict:
- description = (
- "An opinionated guide to the best Python frameworks, libraries, and tools. "
- f"Explore {len(entries)} curated projects across {total_categories} categories, "
- "from AI and agents to data science and web development."
- )
- website_id = f"{SITE_URL}#website"
- item_list = {
+WEBSITE_ID = f"{SITE_URL}#website"
+
+
+def _item_list_payload(entries: Sequence[TemplateEntry]) -> dict:
+ return {
"@type": "ItemList",
"numberOfItems": len(entries),
"itemListElement": [
@@ -138,12 +135,20 @@ def build_homepage_json_ld(entries: Sequence[TemplateEntry], total_categories: i
for i, entry in enumerate(entries, start=1)
],
}
+
+
+def build_homepage_json_ld(entries: Sequence[TemplateEntry], total_categories: int) -> dict:
+ description = (
+ "An opinionated guide to the best Python frameworks, libraries, and tools. "
+ f"Explore {len(entries)} curated projects across {total_categories} categories, "
+ "from AI and agents to data science and web development."
+ )
return {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
- "@id": website_id,
+ "@id": WEBSITE_ID,
"name": "Awesome Python",
"url": SITE_URL,
},
@@ -153,13 +158,30 @@ def build_homepage_json_ld(entries: Sequence[TemplateEntry], total_categories: i
"name": "Awesome Python",
"url": SITE_URL,
"description": description,
- "isPartOf": {"@id": website_id},
- "mainEntity": item_list,
+ "isPartOf": {"@id": WEBSITE_ID},
+ "mainEntity": _item_list_payload(entries),
},
],
}
+def category_meta_description(name: str, entry_count: int, description: str) -> str:
+ suffix = description if description else "Part of the Awesome Python catalog."
+ return f"Explore {entry_count} curated Python projects in {name}. {suffix}"
+
+
+def build_category_json_ld(name: str, url: str, description: str, entries: Sequence[TemplateEntry]) -> dict:
+ return {
+ "@context": "https://schema.org",
+ "@type": "CollectionPage",
+ "name": f"{name} Python Libraries",
+ "url": url,
+ "description": description,
+ "isPartOf": {"@id": WEBSITE_ID},
+ "mainEntity": _item_list_payload(entries),
+ }
+
+
def category_path(category: ParsedSection) -> str:
return f"/categories/{category['slug']}/"
@@ -458,6 +480,15 @@ def build(repo_root: Path) -> None:
group_categories: Sequence[ParsedSection] | None = None,
) -> None:
page_dir.mkdir(parents=True, exist_ok=True)
+ category_description = category_meta_description(
+ category["name"], len(entries), category["description"]
+ )
+ category_json_ld = json.dumps(
+ build_category_json_ld(
+ category["name"], category_url, category_description, entries
+ ),
+ ensure_ascii=False,
+ ).replace("", "<\\/")
(page_dir / "index.html").write_text(
tpl_category.render(
category=category,
@@ -470,6 +501,7 @@ def build(repo_root: Path) -> None:
filter_urls_json=filter_urls_json,
parent_category=parent_category,
group_categories=group_categories,
+ category_json_ld=category_json_ld,
),
encoding="utf-8",
)
diff --git a/website/templates/category.html b/website/templates/category.html
index 43da9a97..9ef8e1ce 100644
--- a/website/templates/category.html
+++ b/website/templates/category.html
@@ -3,6 +3,9 @@
{% block description %}Explore {{ entries | length }} curated Python projects in {{ category.name }}. {% if category.description %}{{ category.description }}{% else %}Part of the Awesome Python catalog.{% endif %}{% endblock %}
{% block canonical_url %}{{ category_url }}{% endblock %}
{% block alternate_links %}{% endblock %}
+{% block extra_head %}
+
+{% endblock %}
{% block header %}
diff --git a/website/tests/test_build.py b/website/tests/test_build.py
index 841ea414..864a8fdf 100644
--- a/website/tests/test_build.py
+++ b/website/tests/test_build.py
@@ -518,6 +518,88 @@ class TestBuild:
assert rendered_names == {e["name"] for e in entries}
assert rendered_urls == {e["url"] for e in entries}
+ def test_category_page_contains_json_ld(self, tmp_path):
+ readme = textwrap.dedent("""\
+ # Awesome Python
+
+ Intro.
+
+ ---
+
+ **Tools**
+
+ ## Widgets
+
+ _Widget libraries._
+
+ - [w1](https://example.com/w1) - A widget.
+ - [w2](https://github.com/owner/w2) - A starred widget.
+
+ # Contributing
+
+ Help!
+ """)
+ (tmp_path / "README.md").write_text(readme, encoding="utf-8")
+ self._copy_real_templates(tmp_path)
+ build(tmp_path)
+
+ category_html = (tmp_path / "website" / "output" / "categories" / "widgets" / "index.html").read_text(encoding="utf-8")
+ marker = '", start)
+ block = category_html[start:end]
+ assert "" not in block
+ data = json.loads(block)
+
+ assert data["@context"] == "https://schema.org"
+ assert data["@type"] == "CollectionPage"
+ assert data["name"] == "Widgets Python Libraries"
+ assert data["url"] == "https://awesome-python.com/categories/widgets/"
+ assert data["description"] == "Explore 2 curated Python projects in Widgets. Widget libraries."
+ assert data["isPartOf"] == {"@id": "https://awesome-python.com/#website"}
+
+ item_list = data["mainEntity"]
+ assert item_list["@type"] == "ItemList"
+ assert item_list["numberOfItems"] == 2
+ names = {item["name"] for item in item_list["itemListElement"]}
+ urls = {item["url"] for item in item_list["itemListElement"]}
+ assert names == {"w1", "w2"}
+ assert urls == {"https://example.com/w1", "https://github.com/owner/w2"}
+ positions = sorted(item["position"] for item in item_list["itemListElement"])
+ assert positions == [1, 2]
+
+ def test_group_page_falls_back_to_default_description_in_json_ld(self, tmp_path):
+ readme = textwrap.dedent("""\
+ # T
+
+ ---
+
+ **AI & ML**
+
+ ## Deep Learning
+
+ - [dl1](https://example.com/dl1) - DL.
+
+ # Contributing
+
+ Done.
+ """)
+ self._copy_real_templates(tmp_path)
+ (tmp_path / "README.md").write_text(readme, encoding="utf-8")
+ build(tmp_path)
+
+ group_html = (tmp_path / "website" / "output" / "categories" / "ai-ml" / "index.html").read_text(encoding="utf-8")
+ marker = '", start)
+ data = json.loads(group_html[start:end])
+
+ assert data["@type"] == "CollectionPage"
+ assert data["name"] == "AI & ML Python Libraries"
+ assert data["url"] == "https://awesome-python.com/categories/ai-ml/"
+ assert data["description"] == "Explore 1 curated Python projects in AI & ML. Part of the Awesome Python catalog."
+
def test_build_creates_subcategory_pages(self, tmp_path):
readme = textwrap.dedent("""\
# T