sitemap.xml の ping 送信は既にこちら で、仕込んでいるのですが、
即時(のような感覚) ではindexされることはなく、結構なタイムラグがあります。

何かいいのがないか、まさぐっていたところ、rss、atom feed も ping で送信しなさい。
とか、
pubsubhubbub で rss、atom feed を送信しなさい というのが、出てきましたので、
pubsubhubbub の pub を 実装してみました。


pubsubhubbub とは?

以下のサイトが参考になりました。


mezzanine での pubsubhubbub

mezzanine の blog モジュール は、 rssv2.0、atom feed をサポートしていますが、
pubsubhubbub 形式 での feed 出力 と 通知を行う機能はありません。1,2
なので、その部分は自前で実装する必要があります。

標準で blog モジュール のfeed 機能提供されていて、
rss、 atom が出力できます。

※1. blog モジュールを使うと、 blog ページに rssv2.0 atom feed へのリンクが付与されます。
※2. カテゴリごとの feed と 全ての投稿に対する feed の2種類があります。


どう実装したか?

作ったもの一式

作ったもの一式はこちらに上げました。

Pypi にも登録を済ましたので、pip 実行でもダウンロード可能です。

pip install mezzanine_pubsubhubbub_pub

フォルダ配下の init.py、defaults.py、feeds.py、models.py、urls.py、views.py について、
以下説明を記載します。

init.py

あまり、関数を書いたりするところではないのかと勝手に考えていましたが、
参考にしていたdjango-push結構処理が記述してあり、
そのままあまり考えもなく踏襲しています。
package配下の代表する関数を置くとかそう言ったレベルの処理を書くには実はちょうど良い場所なのかと思います。

  • get_feed_url_patterns
    feed url pattern を返します。
    mezzanine.blog に依存し、そのfeed url を上書きするため、
    インストールされていれば、url を includeし、
    末インストールなら、空のurl pattren を返すようにしました。
    from django.conf import settings ではなく、
    from mezzanine.conf import settings使うようにしていますが、
    これは、mezzanine の settings でないと、defaluts.py で登録した設定値が見えなかったりしたためです。
    mezzanine の plugin 書く且つ、defaluts.py を使う場合は、
    mezzanine の settings を使ったほうがトラブルがなくてよさそうです。

    def get_feed_url_patterns():
        """
        Returns feed url patterns if mezzanine.blog is installed.
        You must call this method before include("mezzanine.urls")
        """
        from mezzanine.conf import settings
        from django.conf.urls import include, url
        blog_installed = "mezzanine.blog" in settings.INSTALLED_APPS
        if blog_installed:
            BLOG_SLUG = settings.BLOG_SLUG.rstrip("/")
            if BLOG_SLUG:
                BLOG_SLUG += "/"
            feed_url_patterns = [
                url("^%s" % BLOG_SLUG, include("mezzanine_pubsubhubbub_pub.urls")),
            ]
            return feed_url_patterns
        else:
            return []
    

  • ping_hub
    django-push から拝借しました。
    ほぼ、 from django.conf import settings
    from mezzanine.conf import settings変えただけです。

def ping_hub(feed_url, hub_url=None):
    """
    Makes a POST request to the hub. If no hub_url is provided, the
    value is fetched from the PUSH_HUB setting.
    Returns a dictionary with `requests.models.Response` object to the value
    """
    from mezzanine.conf import settings
    if hub_url is None:
        hub_url = getattr(settings, 'PUSH_HUB', None)
    if hub_url is None:
        raise ValueError("Specify hub_url or set the PUSH_HUB setting.")
    params = {
        'hub.mode': 'publish',
        'hub.url': feed_url,
    }
    results = {}
    for elem in hub_url:
        result = requests.post(elem, data=params, headers={'User-Agent': UA})
        results.update({elem: result})

    return results

defaults.py

設定値のみ記載します。

  • PUSH_HUB
    通知先のHUBサーバーのURLを指定します。
    Tapleで複数指定可能です。
    複数指定時は、rss feed 、 atom feed に hub url が複数追加されます。
    当初は、editable=True指定していましたが、
    管理画面からTapleの編集がうまくできなかったため、
    editable=Falseしています。
    何かうまいやり方があるのかもしれませんが、見つけられませんでした。

  • PUSH_URL_PROTOCOL
    HUBサーバーに通知する Feed URL のプロトロルを指定します。
    サイトによってはHTTP/HTTPS双方で送るケースもあるかもしれないので、
    BOTH設定可能です。
    choices=PROTOCOL_TYPE_CHOICES,Tapleを設定可能で、
    editable=True,しておくと、管理画面上以下のように、出力されます。

  • 管理画面
    管理画面

feeds.py

feeds.py も django-push からの流用です。
ともとの実装が、rss feed がなかったため、rss feed 向けのclass を作成しました。
rss の場合は、タグの記載がatom feedとは異なるため、以下のように、
タグがatom:linkなるように実装しています。

  • HubRss201rev2Feed

class HubRss201rev2Feed(Rss201rev2Feed):
    def add_root_elements(self, handler):
        super(Rss201rev2Feed, self).add_root_elements(handler)
        hub = self.feed.get('hub')
        if hub is not None:
            for elem in hub:
                handler.addQuickElement('atom:link', '', {'rel': 'hub',
                                                          'href': elem})

models.py

models.py といってもmodel の記述はあまりなく、signal 登録を行うようにしました。
mezzanine で blog 投稿した際、公開されている投稿であれば、hubサーバーに通知する
signal を作成しました。

HubBlogPost で ModelMixin を継承して、BlogPost を拡張しています。
signal 登録しているのは、HubBlogPost に対してですが、
ModelMixin で 拡張したクラスは、mezzanine の標準の model クラスと置きかわるため、
管理画面からBlog 投稿した場合、hubサーバーに通知されます。
extra_model_fields でも拡張は行えますが、ModelMixin を使うとより細かい制御が行えるのかと思います。
今回の使い方だと、直接BlogPostに登録してしまえば良いのかもしれませんが。。

class HubBlogPost(ModelMixin):
    class Meta:
        mixin_for = BlogPost


def notify_blog_post(sender, instance, **kwargs):
    if instance.status == CONTENT_STATUS_PUBLISHED:
        site = Site.objects.get(id=current_site_id())
        protocol_type = getattr(settings, 'PUSH_URL_PROTOCOL', None)
        if protocol_type == PROTOCOL_TYPE_HTTP:
            __ping_hub_http(site)
        elif protocol_type == PROTOCOL_TYPE_HTTPS:
            __ping_hub_https(site)
        elif protocol_type == PROTOCOL_TYPE_BOTH:
            __ping_hub_http(site)
            __ping_hub_https(site)
        else:
            raise ValueError("PUSH_URL_PROTOCOL is None...")


post_save.connect(notify_blog_post, sender=HubBlogPost)

urls.py

mezzanine/urls.py at master · stephenmcd/mezzanine から、
feed url の部分のみ抜粋しています。
mezzanine の デフォルトのfeed を上書きしたいためです。

# Trailing slahes for urlpatterns based on setup.
_slash = "/" if settings.APPEND_SLASH else ""

# Blog patterns.
urlpatterns = [
    url("^feeds/(?P<format>.*)%s$" % _slash,
        views.blog_post_feed, name="blog_post_feed"),
    url("^tag/(?P<tag>.*)/feeds/(?P<format>.*)%s$" % _slash,
        views.blog_post_feed, name="blog_post_feed_tag"),
]

views.py

こちらも、
mezzanine/views.py at master · stephenmcd/mezzanine から、
部分抜粋して作成しました。
views.py の blog_post_feed を コピペして、Feedのクラスのみ変更しています。

from mezzanine_pubsubhubbub_pub.feeds import HubPostsRSS, HubPostsAtom

def blog_post_feed(request, format, **kwargs):
    """
    Blog posts feeds - maps format to the correct feed view.
    """
    try:
        return {"rss": HubPostsRSS, "atom": HubPostsAtom}[format](**kwargs)(request)
    except KeyError:
        raise Http404()


クイックスタート

Github上の、README.jp.md参照してください。
※ 是非、お願いします。


実際に効果があるのか?

いや、これは少し微妙です。
そもそもサイトの評価が高い、低いがあるのかもしれません。
投稿して公開の際、通知はしているけど、1日経ってもindexされていない、はあります。
Sitemap送信よりはマシ くらいに思ってもらった方が良いのかもしれません。
Pypi にUpload した package は 本当に即時でindexされていたので、
送ってるにしろそもそもの評価はあるんだろうなと思います。
Google Admin Console で Fetch するのが1番良いのは間違いない気がする。。


その他Tips

テスト時にぶつかって対処したことを2点記載します。

mezzanine.utils.tests.TestCase について

django.test.TestCase と 似たような存在でmezzanine.utils.tests.TestCase がいて、
mezzanine の mezzanine/tests.py at master · stephenmcd/mezzanine では
以下のような具合に mezzanine.utils.tests.TestCase が使われています。

class PagesTests(TestCase):

    def setUp(self):
        """
        Make sure we have a thread-local request with a site_id attribute set.
        """
        super(PagesTests, self).setUp()
        from mezzanine.core.request import _thread_local
        request = self._request_factory.get('/')
        request.site_id = settings.SITE_ID
        _thread_local.request = request

_thread_local.request = request みたいなことをやっていて、これまずいらなそうだと思い、
tests.py を記述していたのですが、test実行時に以下のエラーで落ちました。

  File "/Library/Python/2.7/site-packages/mezzanine/blog/feeds.py", line 68, in add_domain
    return add_domain(self._site.domain, link, self._request.is_secure())
AttributeError: 'NoneType' object has no attribute 'is_secure'

どうも mezzanine の内部で、current_request()いうメソッドの記述があり、
このメソッドの戻りがなくて、エラーになっていました。
_thread_local.request = requesttestの際に値を設定すると、
current_request()戻りが取得できるようです。

test を書いていて、atom:link タグが取得できず、

    root = ET.fromstringlist(response._container)
    elems = root.findall(".//atom:link")
書いてみたり、

    root = ET.fromstringlist(response._container)
    elems = root.findall(".//link")

と書いてみたりしていましたが、

    root = ET.fromstringlist(response._container)
    elems = root.findall(".//{http://www.w3.org/2005/Atom}link")

で取得できるようです。
:使えず、 名前空間指定で無事取得できました。

@patch.object を使う際は、テストメソッドに第2引数が必要になる。

テストケースを以下のように実装したところ、

    @patch.object(requests, "post", side_effect=mocked_requests_post)
    def test_feed_atom(self):
        resolver_match = resolve('/blog/feeds/atom/')
        blog_post_feed = resolver_match.func
        # Create an instance of a GET request.
        from mezzanine.core.request import _thread_local
        request = self._request_factory.get('/blog/feeds/atom/')
        request.site_id = settings.SITE_ID
        _thread_local.request = request
        response = blog_post_feed(request, format=resolver_match.kwargs['format'])
        import xml.etree.ElementTree as ET
        root = ET.fromstringlist(response._container)
        elem = root.find("{http://www.w3.org/2005/Atom}link")

        self.assertEqual('hub', elem.attrib['rel'])
        self.assertEqual('https://pubsubhubbub.appspot.com/', elem.attrib['href'])

エラーとなりました。

======================================================================
ERROR: test_feed_atom (tests.tests.BlogPostFeedTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Library/Python/2.7/site-packages/mock/mock.py", line 1305, in patched
    return func(*args, **keywargs)
TypeError: test_feed_atom() takes exactly 1 argument (2 given)

@patch.object のように、デコレータでテストを記述する際は、第二引数が必要で、
以下のように記述する必要がありました。

    @patch.object(requests, "post", side_effect=mocked_requests_post)
    def test_feed_atom(self, mock_post):
        resolver_match = resolve('/blog/feeds/atom/')
        blog_post_feed = resolver_match.func
        # Create an instance of a GET request.
        from mezzanine.core.request import _thread_local
        request = self._request_factory.get('/blog/feeds/atom/')
        request.site_id = settings.SITE_ID
        _thread_local.request = request
        response = blog_post_feed(request, format=resolver_match.kwargs['format'])
        import xml.etree.ElementTree as ET
        root = ET.fromstringlist(response._container)
        elem = root.find("{http://www.w3.org/2005/Atom}link")

        self.assertEqual('hub', elem.attrib['rel'])
        self.assertEqual('https://pubsubhubbub.appspot.com/', elem.attrib['href'])

実装時に参考にしたサイト


長くなりましたが、
以上です。

コメント