Mezzanine AMP 対応のテーマを作成してみました。


AMP Start という AMP のテーマがリリースされたのを知り、
Blog の AMP テーマを作成してみました。 なかなか長い道のりだったので、実施したことを備忘録として記載します。


前提

以下の環境で実行しています。

  • OS
    CentOS release 6.9 (Final)

  • Python Version
    Python 2.7.8

  • Package (必要そうなものだけ抜粋)
    Django (1.10.6)
    Mezzanine (4.2.3)


参考サイト


AMP テーマを作成しようと思う背景

  • Google の検索順位が上がることを期待している。
    現状、直接的に検索順位には影響しないそうですが、関節的にはいいかとがあるのではという気持ちがあります。

  • 流行っている感がある。
    流行っている感があり、仕事で使う可能性はないことはなく、
    この辺で一通り覚えておこうかという気持ちはあります。


テーマ作成の方針

  1. AMP Start の Blog Post をなるべくそのままで作成する。
    そのままでいい感じに思えましたので、問題が発生しない限りはそのまま使います。

  2. AMP HTML は、別 URL に置くではなく、同一 URL として、Mobile アクセスの場合、AMP HTML を返すようにする。
    これは、Mezzanine に Mobile 判定機能が内蔵されている。
    別 URL にした場合、Mezzanine の include ファイルが紐づけられている template タグ類が使えなくなりそうだったためです。
    同一 URL で切り替えを行なっても、Google Search Console で認識されましたので、
    同一 URL で、デバイスにより HTML を切り替えるでもうまくいくようです。


テーマ作成時に実施したこと

以下、思い出した順ですが、実施したことを記載します。
Mezzanine の テンプレート というか Django のテンプレートの観点での記載となります。

Mezzanine への 設定追加

Theme を INSTALL_APPS に追加、
Tempalte を読み込むための記載、
Mobile Device の判定の追加をしました。

  • Theme を INSTALL_APPS に追加 settings.py の INSTALLED_APPS に amp_start_blog_post を追加しました。

    ################
    # APPLICATIONS #
    ################
    INSTALLED_APPS = (
        ....
        "mezzanine.twitter",
        "mezzanine_pubsubhubbub_pub",
        "mezzanine_extentions",
        "amp_start_blog_post",
        ...
    )
    

  • Tempalte を読み込むための記載
    settings.py の DIRS に テーマの Template パス os.path.join(BASE_DIR, 'amp_start_blog_post/templates')
    を追加しました。

    TEMPLATES = [{
                  'BACKEND': 'django.template.backends.django.DjangoTemplates',
                  'DIRS': (os.path.join(BASE_DIR, 'templates'),
                           os.path.join(BASE_DIR, 'clean_blog/templates'),
                           os.path.join(BASE_DIR, 'amp_start_blog_post/templates'),
                           os.path.join(BASE_DIR, 'mezzanine_extentions/templates')),
                  'OPTIONS': {'builtins': ['mezzanine.template.loader_tags'],
                              'context_processors': (
    

  • Mobile Device の判定の追加
    settings.py の DEVICE_USER_AGENTS に テーマディレクトリと、
    ユーザーエージェントを追加しました。
    これで、Mobile ユーザーエージェントの場合は、amp_start_blog_post ディレクトリ配下の、
    template が使用されるようになります。

    #############################
    # Device Settings
    #############################
    DEVICE_USER_AGENTS = ( 
        ("amp_start_blog_post", ("Android", "BlackBerry", "iPhone" 
            )),
        )
    

  • 補足1. Mobile デバイスの場合の、include HTML を保持する template タグの動作について
    Mezzanine の menu タグなどは、include HTML を保持しています。
    include HTML の読み込みには、@register.inclusion_tag というデコレータが使われています。
    以下、editable_loader の実装抜粋です。

    @register.inclusion_tag("includes/editable_loader.html", takes_context=True)
    def editable_loader(context):
        """
        Set up the required JS/CSS for the in-line editing toolbar and controls.
        """
        user = context["request"].user
        template_vars = {
            "has_site_permission": has_site_permission(user),
            "request": context["request"],
        }
        if (settings.INLINE_EDITING_ENABLED and
                template_vars["has_site_permission"]):
            t = get_template("includes/editable_toolbar.html")
            template_vars["REDIRECT_FIELD_NAME"] = REDIRECT_FIELD_NAME
            template_vars["editable_obj"] = context.get("editable_obj",
                                            context.get("page", None))
            template_vars["accounts_logout_url"] = context.get(
                "accounts_logout_url", None)
            template_vars["toolbar"] = t.render(template_vars)
            template_vars["richtext_media"] = RichTextField().formfield(
                ).widget.media
        return template_vars
    
    この@register.inclusion_tag ですが、mezzanine/init.py at master · stephenmcd/mezzanine · GitHub で定義されています。
        def inclusion_tag(self, name, context_class=Context, takes_context=False):
            """
            Replacement for Django's ``inclusion_tag`` which looks up device
            specific templates at render time.
            """
            def tag_decorator(tag_func):
    
                @wraps(tag_func)
                def tag_wrapper(parser, token):
    
                    class InclusionTagNode(template.Node):
    
                        def render(self, context):
                            if not getattr(self, "nodelist", False):
                                try:
                                    request = context["request"]
                                except KeyError:
                                    t = get_template(name)
                                else:
                                    ts = templates_for_device(request, name)
                                    t = select_template(ts)
    
                                self.template = t
                            parts = [template.Variable(part).resolve(context)
                                     for part in token.split_contents()[1:]]
                            if takes_context:
                                parts.insert(0, context)
                            result = tag_func(*parts)
                            if context.autoescape:
                                result = conditional_escape(result)fsup
                            return self.template.render(context.flatten())
    
                    return InclusionTagNode()
                return self.tag(tag_wrapper)
            return tag_decorator
    
    このタグですが、内部で、templates_for_device でMoblie か PC かで、template を切り替えています。
    逆に言うと、mezzanine の標準のタグは、Device Handling 機能を使わないと、レイアウトの切り替えができません。1
    1. モジュールの関数を書き換えれば上手くいきそうには思います。Pythonによる黒魔術入門

  • 補足2. Device Handring 機能が削除される
    Mezzanine の 新しいVersionでは、Device Handring 機能が削除されるようです。
    mezzanine/device-handling.rst at master · stephenmcd/mezzanine
    このため、Device Handring を使用しない実装に書き換えました。
    修正した結果はDjango で AMP ページ と 通常ページを振り分ける | Monotalk に記載しましたので、そちらもご確認ください。

Google Tag Manger タグの AMP 対応

amp-analyticsタグで、Google Tag Manger の js を読み込むようにしました。
記載は規定テンプレート base.html に追加しています。

  • base.html
    {% if settings.AMP_GOOGLE_TAG_MANGER_ID %}
    <amp-analytics config="https://www.googletagmanager.com/amp.json?id={{settings.AMP_GOOGLE_TAG_MANGER_ID}}&gtm.url=SOURCE_URL"data-credentials="include"></amp-analytics>
    {% endif %}
    

Google analytics タグの AMP 対応

Google Tag Manager 経由で読み込んでいるため、
Template 上に特に記載は現れません。
設定方法は以下の記事が参考になりました。

Google adsense タグの AMP 対応

画面上部、下部で2つのinclude ファイルを作成しました。
上部は、fixed-height、下部は、responsive レイアウトとしました。

  • 画面上部 (google_ads_top.html)

    {% if settings.AMP_GOOGLE_ADS_CLIENT_ID_TOP and settings.AMP_GOOGLE_ADS_SLOT_ID_TOP %}
    <section class="{{ section_class }}">
        <amp-ad layout="fixed-height"
        height=100 
        type="adsense" 
        data-ad-client="{{settings.AMP_GOOGLE_ADS_CLIENT_ID_TOP}}"  
        data-ad-slot="{{settings.AMP_GOOGLE_ADS_SLOT_ID_TOP}}">
        </amp-ad>
    </section>
    {% endif %}
    

  • 画面下部 (google_ads_bottom.html)

    {% if settings.AMP_GOOGLE_ADS_CLIENT_ID_BOTTOM and settings.AMP_GOOGLE_ADS_SLOT_ID_BOTTOM %}
    <section class="{{ section_class }}">
        <amp-ad
        layout="responsive"
        width=300
        height=250
        type="adsense"
        data-ad-client="{{settings.AMP_GOOGLE_ADS_CLIENT_ID_BOTTOM}}"
        data-ad-slot="{{settings.AMP_GOOGLE_ADS_SLOT_ID_BOTTOM}}">
        </amp-ad>
    </section>
    {% endif %}
    

Disqus の AMP 対応

以下に記載しました。
別ドメインにiframe を設置して、そのiframe経由で読み込みます。
* AMP の HTML に disqus を組み込む | Monotalk

JSON-LD の 構造化マークアップの追加

過去に作成したテンプレートタグを、移植してtemplate 内に埋め込みました。

Mezzanine に JSON-LD 形式の構造化データ を埋め込む | Monotalk

本文HTMLの AMP 対応

本文HTMLの、AMPへの変換を実施しました。
現在、mezzanine-pagedown 1.0 : Python Package Index という、
plugin を使っていて、本文はMarkDown 形式で設定されており、MarkDown > HTML 変換が行われて、画面表示されています。
filter を新規で作成して、MarkDown > HTML 変換 > AMP HTML 変換 するようにしました。
以下、参考にした記事になります。

作成した filter は以下の通りです。
* amp_start_blog_post_tags.py

rom __future__ import unicode_literals
from mezzanine import template
from bs4 import BeautifulSoup
from PIL import Image
from StringIO import StringIO
import requests
import json
import logging

logger = logging.getLogger(__name__)
register = template.Library()


@register.filter
def to_amp_html(html):
    """
    Markdown HTML to AMP HTML
    """
    soup = BeautifulSoup(html, "html5lib")
    # ------------------------------------------------
    # amp id replace to "accelerated-mobile-pages"
    # ------------------------------------------------
    for elem in soup.find_all(True, id=lambda x: x and 'amp' in x):
        elem["id"] = elem.get("id").replace("amp", "accelerated-mobile-pages")

    # h2
    for h2 in soup.find_all('h2'):
        h2['class'] = h2.get('class', []) + ['bold', 'mt2', 'mb2']
    # h3
    for h3 in soup.find_all('h3'):
        h3['class'] = h3.get('class', []) + ['bold', 'mt1', 'mb1']
    # h4
    for h4 in soup.find_all('h4'):
        h4['class'] = h4.get('class', []) + ['bold', 'mt1', 'mb1']

    # ------------------------------------------------
    # img replace to amp-img
    # -----------------------------------------------
    for img in soup.find_all('img'):
        try:
            amp_img = soup.new_tag("amp-img")
            for attr in img.attrs:
                if "style" != attr:
                    amp_img[attr] = img[attr]
            src = str(img.get("src"))
            if src.startswith("//"):
                src = src.replace("//", "https://")
            req = requests.get(src)
            picture_IO = StringIO(req.content)
            picture_IO.seek(0)
            im = Image.open(picture_IO)
            amp_img["width"] = im.size[0]
            amp_img["height"] = im.size[1]
            amp_img["layout"] = "responsive"
            img.replace_with(amp_img)
        except IOError:
            logger.warning("something raised an exception: ", exc_info=True)
            amp_img["width"] = "4"
            amp_img["height"] = "3"
            amp_img["layout"] = "responsive"
            img.replace_with(amp_img)
    soup.body.hidden = True
    return str(soup.body)
  • 説明

    • BeautifulSoup(html, "html5lib") は、BeautifulSoup(html) でも問題なく動作しますが、WARNINGが出力されます。

    • id にampが指定されている箇所があり、それで 妥当性検証エラーが発生していたので、ampaccelerated-mobile-pages に置換する処理を追加しました。

    • h2,h3,h4 タグにcss クラス属性を付与する必要があり、このタイミングで実施するようにしました。

    • Image.open(picture_IO) で、画像を取得しwidth、height を取得していますが何故かJPEGの場合エラーとなり、エラーの原因を解決できなかったため、乱暴ですが例外発生時は、縦横比率 3対4 固定で設定しています。

PC 向けテンプレート に AMP HTMLへのリンクを追加

PC 向けのテンプレートに、AMP HTML へのリンクを追加しました。
PC も Mobile の URL も変わらないので、以下の記載となりました。

<link rel="amphtml" href="{{ request.get_full_path }}"> 

AMP対応するまでに失敗したこと - なりせなるてず
にもそのようなことが書いてありますが、PC / Mobile で URL同一 テンプレートは変える場合、
もしかしたら記述は不要に思います。
書いてても、ampのページがあることは認識してくれたので、問題はなさそうです。

Vary User-Agent の設定

記事作成後の後日対応しました。
動的な配信  |  Google Developers
今回のように、1URL で 複数のHTML を返す場合は、この動的な配信にあたるため、
Vary HTTP ヘッダー に Vary: User-Agent を設定する必要があります。

当ブログだと、既に以下のようなVary HTTP ヘッダーが付与されていました。

Vary:Cookie,Accept-Language,Accept-Encoding
Django’s cache framework | Django documentation | DjangoOrder of MIDDLEWARE を読む限り、

  • SessionMiddleware は、Vary:Cookie を追加する

  • GZipMiddleware は、Vary:Accept-Encoding を追加する

  • LocaleMiddleware は、Vary:Accept-Language を追加する

という動作をするようです。
settings.py で、GZipMiddleware は使用する設定にしていませんでしたが、
Apache の mod_deflate を使用していたため、
そちらの設定で、Vary:Accept-Encoding が付与されていたようです。
そのため、Vary: User-Agent の設定は、mod_deflate に以下の設定を追加して実施しました。
ドキュメントmod_deflate - Apache HTTP サーバ にも
やり方は記載されていたので、よくやる?パターンなのかと思いました。

  • mod_deflate.conf
         # Make sure proxies don't deliver the wrong content
         Header append Vary User-Agent env=!dont-vary
    

作成したテーマ一式

Github に作成したテーマ一式はUPしました。
kemsakurai/mezzanine-theme-amp-start-blog-post: mezzanine theme based by amp start

以上です。

過去に、通常のテーマについても書いてますので、よろしければそちらもご確認ください。
mezzanineのテーマを作成してみました。 | Monotalk

コメント


カテゴリー