diff --git a/README.md b/README.md index a45d89d..ec0f006 100644 --- a/README.md +++ b/README.md @@ -267,7 +267,7 @@ Image: https://www.example.com/article-image.jpg #### Open Graph -Based on [Open Graph protocol](https://ogp.me), the SEO plugin implements required properties and some aditionnals ones: +Based on [Open Graph protocol](https://ogp.me), the SEO plugin implements required properties and some additional ones: ``` @@ -305,6 +305,52 @@ If `Description` is not defined, a plain text version of `Summary` will be used ``` `:language:`: The site language as defined in `LOCALE` Pelican setting. If not filled, it will try to get the default system locale. +If the content is an article, Open Graph tags for article-specific properties are added: + +``` + +``` +`:publication_date:`: The article publication date, from the `date` metadata field. + +``` + +``` +`:modification_date:`: The date from the `modified` metadata field, if it exists. + +``` + +``` +`:category:`: The article category, as determined by Pelican (either from the file location or from the `category` metadata field). + +``` + + +... + +``` +`:tag1:`, `:tag2:`, ..., `:tagN:`: The article tags, from the `tags` metadata field. + +``` + +``` +`:author:`: The article author, from the `fb_profile` or `author` metadata field. + +The `article:author` should contain a link to the Facebook account of the article author. SEO Enhancer +will use the `fb_profile` metadata from the article to fill its value. If `fb_profile` +is not defined, SEO Enhancer will use the `SEO_ENHANCER_AUTHOR_FACEBOOK_PROFILES` dictionary +from the Pelican configuration file to map the `author` of the article to their Facebook account. +The dictionary should be defined as follows: + +```python +SEO_ENHANCER_AUTHOR_FACEBOOK_PROFILES = { + "Author Name 1": "https://www.facebook.com/author1profile", + "Author Name 2": "https://www.facebook.com/author2profile", +} +``` + +If neither `fb_profile` metadata nor `SEO_ENHANCER_AUTHOR_FACEBOOK_PROFILES` mapping is defined, the `article:author` tag will not be added. + + #### Twitter Cards The Twitter Cards feature requires Open Graph feature to be functional. To avoid the duplication of similar tags, [Twitter falls back on some Open Graph tags](https://developer.twitter.com/en/docs/twitter-for-websites/cards/guides/getting-started#opengraph) if Twitter's are not present. @@ -320,6 +366,21 @@ Based on [Twitter guide](https://developer.twitter.com/en/docs/twitter-for-websi ``` `:tw_account:`: The Twitter @account to link to the card. You can fill in the file metadata, but it's not a required property. +This tags should contain a link to Twitter/X account of the article author. SEO Enhancer +will use the `tw_account` metadata from the article to fill its value. +If `tw_account` is not defined, SEO Enhancer will use the `SEO_ENHANCER_AUTHOR_TWITTER_PROFILES` dictionary +from the Pelican configuration file to map the `author` of the article to its Twitter/X account. +The dictionary should be defined as follows: + +```python +SEO_ENHANCER_AUTHOR_TWITTER_PROFILES = { + "Author Name 1": "https://x.com/author1profile", + "Author Name 2": "https://x.com/author2profile", +} +``` + +If neither `tw_account` metadata nor `SEO_ENHANCER_AUTHOR_TWITTER_PROFILES` mapping is defined, the `twitter:site` tag will not be added. + The other properties required by Twitter are created thanks to Open Graph feature. ## Contributing diff --git a/pelican/plugins/seo/seo_enhancer/__init__.py b/pelican/plugins/seo/seo_enhancer/__init__.py index 8f9baba..7036b01 100644 --- a/pelican/plugins/seo/seo_enhancer/__init__.py +++ b/pelican/plugins/seo/seo_enhancer/__init__.py @@ -43,6 +43,11 @@ def launch_html_enhancer( if open_graph: html_enhancements["open_graph"] = html_enhancer.open_graph.create_tags() + if open_graph and hasattr(html_enhancer, "open_graph_article"): + html_enhancements["open_graph_article"] = ( + html_enhancer.open_graph_article.create_tags() + ) + if twitter_cards: html_enhancements["twitter_cards"] = ( html_enhancer.twitter_cards.create_tags() @@ -125,6 +130,18 @@ def add_html_to_file(self, enhancements, path): for og_property, og_content in enhancements["open_graph"].items(): self._add_meta_tag(soup, "property", "og", og_property, og_content) + if "open_graph_article" in enhancements: + for og_property, og_content in enhancements["open_graph_article"].items(): + if isinstance(og_content, str): + self._add_meta_tag( + soup, "property", "article", og_property, og_content + ) + elif isinstance(og_content, list): + for element in og_content: + self._add_meta_tag( + soup, "property", "article", og_property, element + ) + with open(path, "w", encoding="utf8") as html_file: html_file.write(str(soup)) diff --git a/pelican/plugins/seo/seo_enhancer/html_enhancer/__init__.py b/pelican/plugins/seo/seo_enhancer/html_enhancer/__init__.py index 6580aef..de83697 100644 --- a/pelican/plugins/seo/seo_enhancer/html_enhancer/__init__.py +++ b/pelican/plugins/seo/seo_enhancer/html_enhancer/__init__.py @@ -7,7 +7,7 @@ from .article_schema_creator import ArticleSchemaCreator from .breadcrumb_schema_creator import BreadcrumbSchemaCreator from .canonical_url_creator import CanonicalURLCreator -from .open_graph import OpenGraph +from .open_graph import OpenGraph, OpenGraphArticle from .twitter_cards import TwitterCards @@ -88,7 +88,25 @@ def __init__(self, file, output_path, path, open_graph=False, twitter_cards=Fals locale=_settings.get("LOCALE"), ) + if isinstance(file, Article): + _modified = getattr(file, "modified", None) + _author_profiles = _settings.get( + "SEO_ENHANCER_AUTHOR_FACEBOOK_PROFILES", {} + ) + _author_profile = _author_profiles.get(_author.name, None) + self.open_graph_article = OpenGraphArticle( + date=_date, + modified=_modified, + category=_category.name, + tags=getattr(file, "tags", []), + author=_metadata.get("fb_profile") or _author_profile, + ) + if twitter_cards: + _author_profiles = _settings.get( + "SEO_ENHANCER_AUTHOR_TWITTER_PROFILES", {} + ) + _author_profile = _author_profiles.get(_author.name, None) self.twitter_cards = TwitterCards( - tw_account=_metadata.get("tw_account"), + tw_account=_metadata.get("tw_account") or _author_profile, ) diff --git a/pelican/plugins/seo/seo_enhancer/html_enhancer/open_graph.py b/pelican/plugins/seo/seo_enhancer/html_enhancer/open_graph.py index 322879b..1919e5e 100644 --- a/pelican/plugins/seo/seo_enhancer/html_enhancer/open_graph.py +++ b/pelican/plugins/seo/seo_enhancer/html_enhancer/open_graph.py @@ -1,6 +1,8 @@ import locale from urllib import parse +from pelican.utils import strftime + class OpenGraph: """ @@ -78,3 +80,34 @@ def create_tags(self) -> dict: open_graph_tags["locale"] = locale return open_graph_tags + + +class OpenGraphArticle: + """ + Get all Open Graph elements for an Article according to + https://ogp.me/?#no_vertical. + """ + + def __init__(self, date, modified, category, tags, author) -> None: + self.date = date + self.modified = modified + self.category = category + self.tags = tags + self.author = author + + def create_tags(self) -> dict: + """ + Compute all Open Graph elements for Article and return them in a ready-to-use + dict. + """ + tags = {} + tags["published_time"] = strftime(self.date, "%Y-%m-%d") + if self.modified is not None: + tags["modified_time"] = strftime(self.modified, "%Y-%m-%d") + if self.category is not None: + tags["section"] = self.category + if self.tags is not None and self.tags: + tags["tags"] = self.tags + if self.author is not None: + tags["author"] = self.author + return tags diff --git a/pelican/plugins/seo/tests/conftest.py b/pelican/plugins/seo/tests/conftest.py index c0aa1aa..1f5cec9 100644 --- a/pelican/plugins/seo/tests/conftest.py +++ b/pelican/plugins/seo/tests/conftest.py @@ -1,7 +1,12 @@ """Mocks Pelican objects required for the units tests.""" +from datetime import datetime + import pytest +from pelican.contents import Article +from pelican.settings import DEFAULT_CONFIG + from seo.seo_enhancer import SEOEnhancer from seo.seo_report import SEOReport @@ -57,6 +62,60 @@ def __init__(self, name): self.name = name +@pytest.fixture() +def pelican_article(): + """Create a Pelican article.""" + + settings = DEFAULT_CONFIG.copy() + settings.update( + { + "SITEURL": "https://www.fakesite.com", + "SITENAME": "Fake Site Name", + "LOGO": "https://www.fakesite.com/fake-logo.jpg", + "LOCALE": ["fr_FR"], + } + ) + + metadata = { + "noindex": True, + "disallow": True, + "image": "https://www.fakesite.com/fake-image.jpg", + "og_title": "OG Title", + "og_description": "OG Description", + "og_image": "https://www.fakesite.com/og-image.jpg", + "tw_account": "@TestTWCards", + "summary": "Fake summary", + "modified": datetime(2019, 7, 3, 23, 49), + "tags": ["Fake tag 1", "Fake tag 2"], + "title": "Fake Title", + "description": "Fake description", + "url": "fake-title.html", + "date": datetime(2019, 4, 3, 23, 49), + "author": FakeAuthor(name="Fake author"), + "category": FakeCategory(name="Fake category"), + } + + content = """ + + Fake Title + + + +

Fake content title

+

Fake content 🙃

+ Fake internal link +

Fake content with inline code

+

Fake content with "Fake inline internal link"

+ + """ + + return Article( + content=content, + metadata=metadata, + settings=settings, + ) + + @pytest.fixture() def fake_article(): """Create a fake article.""" @@ -76,6 +135,8 @@ def fake_article(): "og_image": "https://www.fakesite.com/og-image.jpg", "tw_account": "@TestTWCards", "summary": "Fake summary", + "modified": FakeDate("2019", "07", "03", "23", "49"), + "tags": ["Fake tag 1", "Fake tag 2"], } title = "Fake Title" description = "Fake description" diff --git a/pelican/plugins/seo/tests/test_open_graph.py b/pelican/plugins/seo/tests/test_open_graph.py index cefc13c..415cbba 100644 --- a/pelican/plugins/seo/tests/test_open_graph.py +++ b/pelican/plugins/seo/tests/test_open_graph.py @@ -1,10 +1,11 @@ """Units tests for OpenGraph.""" import locale +from datetime import datetime import pytest -from seo.seo_enhancer.html_enhancer import OpenGraph +from seo.seo_enhancer.html_enhancer import OpenGraph, OpenGraphArticle class TestOpenGraph: @@ -109,6 +110,37 @@ def test_create_tags(self, fake_article): assert og_tags["image"] == "https://www.fakesite.com/og-image.jpg" assert og_tags["locale"] == "fr_FR" + def test_create_article_tags(self, fake_article): + """ + Test that create_tags() returns all OG tags for an Article + if all elements are filled. + """ + + def fake_date_to_date(fake_date): + return datetime( + fake_date.year, + fake_date.month, + fake_date.day, + fake_date.hour, + fake_date.minute, + ) + + og = OpenGraphArticle( + date=fake_date_to_date(fake_article.date), + modified=fake_date_to_date(fake_article.metadata["modified"]), + category=fake_article.category.name, + tags=fake_article.metadata["tags"], + author=fake_article.author.name, + ) + + og_tags = og.create_tags() + + assert og_tags["published_time"] == "2019-04-03" + assert og_tags["modified_time"] == "2019-07-03" + assert og_tags["section"] == "Fake category" + assert og_tags["tags"] == ["Fake tag 1", "Fake tag 2"] + assert og_tags["author"] == "Fake author" + def test_create_tags_missing_elements(self, fake_article_missing_elements): """ Test that create_tags() without specific elements diff --git a/pelican/plugins/seo/tests/test_seo_enhancer.py b/pelican/plugins/seo/tests/test_seo_enhancer.py index 5687eae..8136e47 100644 --- a/pelican/plugins/seo/tests/test_seo_enhancer.py +++ b/pelican/plugins/seo/tests/test_seo_enhancer.py @@ -197,7 +197,7 @@ def test_add_html_enhancements_to_file_with_open_graph( self, fake_article, fake_seo_enhancer ): """ - Test if add_html_to_file with open_graph setting + Test if SEOEnhancer.add_html_to_file() with open_graph setting adds Open Graph tags to HTML files. """ @@ -255,12 +255,13 @@ def test_add_html_enhancements_to_file_with_open_graph_using_summary_for_descrip self, fake_article, fake_seo_enhancer ): """ - Test if add_html_to_file with open_graph setting - adds Open Graph tags to HTML files. + Test if SEOEnhancer.add_html_to_file() with open_graph settings correctly uses + the article summary for the og:description tag + when no higher priority metadata are set. """ # Remove higher priority values for the og:description tag to force the use of - # the summary from article description. + # the summary from article metadata. del fake_article.metadata["og_description"] del fake_article.description @@ -315,8 +316,148 @@ def test_add_html_enhancements_to_file_with_open_graph_using_summary_for_descrip """ ) + def test_add_html_enhancements_to_pelican_article_with_open_graph( + self, pelican_article, fake_seo_enhancer + ): + """ + Test if SEOEnhancer.add_html_to_file() with open_graph settings correctly + adds Open Graph tags to HTML files for Article content type. + """ + + path = "fake_output/fake_file.html" + fake_html_enhancements = fake_seo_enhancer.launch_html_enhancer( + file=pelican_article, + output_path="fake_output", + path=path, + open_graph=True, + ) + + with patch( + "seo.seo_enhancer.open", mock_open(read_data=pelican_article.content) + ) as mocked_open: + mocked_file_handle = mocked_open.return_value + + fake_seo_enhancer.add_html_to_file( + enhancements=fake_html_enhancements, path=path + ) + assert len(mocked_open.call_args_list) == 2 + mocked_file_handle.read.assert_called_once() + mocked_file_handle.write.assert_called_once() + + write_args, _ = mocked_file_handle.write.call_args_list[0] + fake_html_content = write_args[0] + + assert ( + fake_html_content + == """ + + Fake Title + + + + + + + + + + + + + + + + + + +

Fake content title

+

Fake content 🙃

+ Fake internal link +

Fake content with inline code

+

Fake content with "Fake inline internal link"

+ + """ + ) + + @pytest.mark.parametrize("use_fb_profile", (True, False)) + def test_add_html_enhancements_to_pelican_article_with_author_with_open_graph( + self, pelican_article, fake_seo_enhancer, use_fb_profile + ): + """ + Test if SEOEnhancer.add_html_to_file() with open_graph settings adds the + article:author Open Graph tag to HTML files when the author's + Facebook profile is set either in the article metadata or + in the SEO_ENHANCER_AUTHOR_FACEBOOK_PROFILES setting. + """ + + if use_fb_profile: + pelican_article.metadata["fb_profile"] = ( + "https://www.fakesite.com/fake-author-profile" + ) + else: + pelican_article.settings["SEO_ENHANCER_AUTHOR_FACEBOOK_PROFILES"] = { + pelican_article.author.name: "https://www.fakesite.com/fake-author-profile" + } + + path = "fake_output/fake_file.html" + fake_html_enhancements = fake_seo_enhancer.launch_html_enhancer( + file=pelican_article, + output_path="fake_output", + path=path, + open_graph=True, + ) + + with patch( + "seo.seo_enhancer.open", mock_open(read_data=pelican_article.content) + ) as mocked_open: + mocked_file_handle = mocked_open.return_value + + fake_seo_enhancer.add_html_to_file( + enhancements=fake_html_enhancements, path=path + ) + assert len(mocked_open.call_args_list) == 2 + mocked_file_handle.read.assert_called_once() + mocked_file_handle.write.assert_called_once() + + write_args, _ = mocked_file_handle.write.call_args_list[0] + fake_html_content = write_args[0] + + assert ( + fake_html_content + == """ + + Fake Title + + + + + + + + + + + + + + + + + + + +

Fake content title

+

Fake content 🙃

+ Fake internal link +

Fake content with inline code

+

Fake content with "Fake inline internal link"

+ + """ + ) + + @pytest.mark.parametrize("use_tw_account", (True, False)) def test_add_html_enhancements_to_file_with_twitter_cards( - self, fake_article, fake_seo_enhancer + self, fake_article, fake_seo_enhancer, use_tw_account ): """ Test if add_html_to_file with twitter_cards setting @@ -325,6 +466,12 @@ def test_add_html_enhancements_to_file_with_twitter_cards( requires them. """ + if not use_tw_account: + del fake_article.metadata["tw_account"] + fake_article.settings["SEO_ENHANCER_AUTHOR_TWITTER_PROFILES"] = { + fake_article.author.name: "@TestTWCards" + } + path = "fake_output/fake_file.html" fake_html_enhancements = fake_seo_enhancer.launch_html_enhancer( file=fake_article,