やってみる

アウトプットすべく己を導くためのブログ。その試行錯誤すらたれ流す。

ValueError: Headers indicate a formencoded body but body was not decodable.

前回のつづき。今回は表題のエラーが解決できなかったログ。

Error

問題のエラー。

python3 main.py 
Traceback (most recent call last):
  File "main.py", line 73, in <module>
    'ytyaru', 'blogmake.hatenablog.com', '新しい記事だよ', '本文です。', draft=True)
  File "main.py", line 27, in post_entries
    res = self.client.post(url, data=xml)
  File "/usr/lib/python3/dist-packages/requests/sessions.py", line 497, in post
    return self.request('POST', url, data=data, **kwargs)
  File "/usr/lib/python3/dist-packages/requests/sessions.py", line 421, in request
    prep = self.prepare_request(req)
  File "/usr/lib/python3/dist-packages/requests/sessions.py", line 359, in prepare_request
    hooks=merge_hooks(request.hooks, self.hooks),
  File "/usr/lib/python3/dist-packages/requests/models.py", line 291, in prepare
    self.prepare_auth(auth, url)
  File "/usr/lib/python3/dist-packages/requests/models.py", line 470, in prepare_auth
    r = auth(self)
  File "/usr/local/lib/python3.4/dist-packages/requests_oauthlib/oauth1_auth.py", line 80, in __call__
    unicode(r.url), unicode(r.method), r.body or '', r.headers)
  File "/usr/local/lib/python3.4/dist-packages/oauthlib/oauth1/rfc5849/__init__.py", line 276, in sign
    "Headers indicate a formencoded body but body was not decodable.")
ValueError: Headers indicate a formencoded body but body was not decodable.

分析

HeaderのContent-Typeapplication/x-www-form-urlencodedになっているけど、送信するデータがURLエンコードされていないという意味だろうか?

以下のコードでurllib.parse.urlencodeしてみたが違うエラーがでた。

#!python3
#encoding:utf-8
import xmltodict
from collections import OrderedDict
from requests_oauthlib import OAuth1Session
from bs4 import BeautifulSoup
import urllib

CREDENTIALS = {
    'client_key': 'aaaaaaaaaaaaaaaa',
    'client_secret': 'bbbbbbbbbbbbbbbbb',
    'resource_owner_key': 'ccccccccccccccc',
    'resource_owner_secret': 'dddddddddddddddddddddd'
}


class HatenaClient(object):
    ENDPOINT = ('https://blog.hatena.ne.jp/'
                '{user}/{blog}.hatenablog.com/atom/entry')

    def __init__(self, **args):
        self.set_client(**args)

    def set_client(self, **args):
        self.client = OAuth1Session(**args)

    def post_entries(self, user, blog, title, body, categries=[], draft=True):
        url = self.ENDPOINT.format(user=user, blog=blog)
        xml = self._create_body_xml(title, body, categries, draft)
        res = self.client.post(url, data=xml)
        self._check_response(res)
        return self._parse_to_url(res)

    def _create_body_xml(self, title, body, categories, draft):
        body = OrderedDict([
            ('entry', OrderedDict([
                ('@xmlns', 'http://www.w3.org/2005/Atom'),
                ('@xmlns:app', 'http://www.w3.org/2007/app'),
                ('title', title),
                ('author', OrderedDict([('name', 'name')])),
                ('content', OrderedDict([
                    ('@type', 'text/plain'),
#                    ('#text', body)
                    ('#text', urllib.parse.urlencode(body).encode(encoding='utf-8'))
                ])),
                ('category', self._create_categories(categories)),
                ('app:control', OrderedDict([
                    ('app:draft', self._is_draft(draft))
                ]))
            ]))])
        return xmltodict.unparse(body).encode('utf-8')

    def _is_draft(self, draft):
        if draft:
            return 'yes'
        return 'no'

    def _create_categories(self, categories):
        if not categories:
            return None
        return [OrderedDict([('@term', c)]) for c in categories]

    def _check_response(self, response):
        if not response.ok:
            response.raise_for_status()
        print('update hatena blog')
        print('status code: {}'.format(response.status_code))

    def _parse_to_url(self, response):
        soup = BeautifulSoup(response.text, 'lxml')
        return soup.find('link', rel='alternate').get('href')


if __name__ == '__main__':
    client = HatenaClient(**CREDENTIALS)
    client.post_entries(
        'ytyaru', 'blogmake.hatenablog.com', '新しい記事だよ', '本文です。', draft=True)
$ python3 main.py 
Traceback (most recent call last):
  File "/usr/lib/python3.4/urllib/parse.py", line 760, in urlencode
    raise TypeError
TypeError

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "main.py", line 78, in <module>
    'ytyaru', 'blogmake.hatenablog.com', '新しい記事だよ', '本文です。', draft=True)
  File "main.py", line 29, in post_entries
    xml = self._create_body_xml(title, body, categries, draft)
  File "main.py", line 44, in _create_body_xml
    ('#text', urllib.parse.urlencode(body).encode(encoding='utf-8'))
  File "/usr/lib/python3.4/urllib/parse.py", line 768, in urlencode
    "or mapping object").with_traceback(tb)
  File "/usr/lib/python3.4/urllib/parse.py", line 760, in urlencode
    raise TypeError
TypeError: not a valid non-string sequence or mapping object

文字列として認識されていないという意味か?

整理

  • ヘッダの設定が見当たらない
  • 本当にContent-Type: application/x-www-form-urlencodedか?
    • 根拠はValueError: Headers indicate a formencoded body but body was not decodable.
  • 仮にそうだとして、どうすればいいか?

ヘッダの設定はコードにはない。デフォルト設定なのだろう。 そもそもContent-Typeapplication/x-www-form-urlencoded送信することが間違っているのでは?

次に、ライブラリでヘッダ情報を設定する方法を調べる。

  • OAuth1Session

はてなブログAPI仕様を調べる

はてなブログAtomPub - Hatena Developer Center

「ブログエントリの投稿」の項をみてもヘッダについての仕様はなかった。Content-Typeは何にすればいいんだ?

たぶんxmlなので以下でいいのだろう。

Content-Type: application/xml; charset="UTF-8"

xmlのContent-Typeはapplication/xmlか、それともtext/xml? - toricor’s memo

OAuth1Sessionのヘッダ設定方法を調べる

ソースコードをみてみる。

requests-oauthlib/oauth1_auth.py at master · requests/requests-oauthlib · GitHub

...
CONTENT_TYPE_FORM_URLENCODED = 'application/x-www-form-urlencoded'
...
content_type = r.headers.get('Content-Type', '')
...
content_type = CONTENT_TYPE_FORM_URLENCODED
...

Content-Typeが未設定の場合はapplication/x-www-form-urlencodedを設定するらしい。rが何なのかわからないが、requestsライブラリのリクエストまたはレスポンスオブジェクトかもしれない。以下のようにrequestsライブラリを使っているようだから。

from requests.auth import AuthBase

さて、どうやってヘッダを設定するのやら。

OAuth1Session header setで検索すると以下がヒット。

python - Setting HTTP User-Agent header with requests-oauthlib - Stack Overflow

oauthlib.oauth1.Clientを継承したクラスで設定できるらしい。たかがヘッダを編集するだけで大げさすぎないか?

class CustomClient(Client):
    def _render(self, request, formencode=False, realm=None):
        request.headers['User-Agent'] = "FooClient/1.0"
        return super()._render(request, formencode, realm)
oauth = OAuth1Session(CONSUMER_KEY, client_secret=CONSUMER_SECRET,
                          signature_method=SIGNATURE_HMAC,
                          signature_type=SIGNATURE_TYPE_AUTH_HEADER, client_class=CustomClient)

以下のようにコードを修正して実行。

#!python3
#encoding:utf-8
import xmltodict
from collections import OrderedDict
from requests_oauthlib import OAuth1Session
from bs4 import BeautifulSoup
import urllib
#import oauthlib.oauth1.Client
from oauthlib.oauth1 import Client


#class CustomClient(oauthlib.oauth1.Client):
class CustomClient(Client):
    def _render(self, request, formencode=False, realm=None):
        request.headers['Content-Type'] = 'application/xml; charset="UTF-8"'
        return super()._render(request, formencode, realm)

CREDENTIALS = {
    'client_key': 'aaaaaaaaaaaaaaaa',
    'client_secret': 'bbbbbbbbbbbbbbbbb',
    'resource_owner_key': 'ccccccccccccccc',
    'resource_owner_secret': 'dddddddddddddddddddddd'
    'client_class': CustomClient
}

}

class HatenaClient(object):
    ENDPOINT = ('https://blog.hatena.ne.jp/'
                '{user}/{blog}.hatenablog.com/atom/entry')

    def __init__(self, **args):
        self.set_client(**args)

    def set_client(self, **args):
        self.client = OAuth1Session(**args)

    def post_entries(self, user, blog, title, body, categries=[], draft=True):
        url = self.ENDPOINT.format(user=user, blog=blog)
        xml = self._create_body_xml(title, body, categries, draft)
        res = self.client.post(url, data=xml)
        self._check_response(res)
        return self._parse_to_url(res)

    def _create_body_xml(self, title, body, categories, draft):
        body = OrderedDict([
            ('entry', OrderedDict([
                ('@xmlns', 'http://www.w3.org/2005/Atom'),
                ('@xmlns:app', 'http://www.w3.org/2007/app'),
                ('title', title),
                ('author', OrderedDict([('name', 'name')])),
                ('content', OrderedDict([
                    ('@type', 'text/plain'),
                    ('#text', body)
#                    ('#text', urllib.parse.urlencode(body).encode(encoding='utf-8'))
                ])),
                ('category', self._create_categories(categories)),
                ('app:control', OrderedDict([
                    ('app:draft', self._is_draft(draft))
                ]))
            ]))])
        return xmltodict.unparse(body).encode('utf-8')

    def _is_draft(self, draft):
        if draft:
            return 'yes'
        return 'no'

    def _create_categories(self, categories):
        if not categories:
            return None
        return [OrderedDict([('@term', c)]) for c in categories]

    def _check_response(self, response):
        if not response.ok:
            response.raise_for_status()
        print('update hatena blog')
        print('status code: {}'.format(response.status_code))

    def _parse_to_url(self, response):
        soup = BeautifulSoup(response.text, 'lxml')
        return soup.find('link', rel='alternate').get('href')


if __name__ == '__main__':
    client = HatenaClient(**CREDENTIALS)
    client.post_entries(
#        'ytyaru', 'blogmake.hatenablog.com', 'new_title', 'contents.', draft=True)
        'ytyaru', 'blogmake.hatenablog.com', '新しい記事だよ', '本文です。', draft=True)

しかし同じエラー。

$ python3 main.py 
Traceback (most recent call last):
  File "main.py", line 87, in <module>
    'ytyaru', 'blogmake.hatenablog.com', '新しい記事だよ', '本文です。', draft=True)
  File "main.py", line 39, in post_entries
    res = self.client.post(url, data=xml)
  File "/usr/lib/python3/dist-packages/requests/sessions.py", line 497, in post
    return self.request('POST', url, data=data, **kwargs)
  File "/usr/lib/python3/dist-packages/requests/sessions.py", line 421, in request
    prep = self.prepare_request(req)
  File "/usr/lib/python3/dist-packages/requests/sessions.py", line 359, in prepare_request
    hooks=merge_hooks(request.hooks, self.hooks),
  File "/usr/lib/python3/dist-packages/requests/models.py", line 291, in prepare
    self.prepare_auth(auth, url)
  File "/usr/lib/python3/dist-packages/requests/models.py", line 470, in prepare_auth
    r = auth(self)
  File "/usr/local/lib/python3.4/dist-packages/requests_oauthlib/oauth1_auth.py", line 80, in __call__
    unicode(r.url), unicode(r.method), r.body or '', r.headers)
  File "/usr/local/lib/python3.4/dist-packages/oauthlib/oauth1/rfc5849/__init__.py", line 276, in sign
    "Headers indicate a formencoded body but body was not decodable.")
ValueError: Headers indicate a formencoded body but body was not decodable.

post(headers={})

コードをよく見ると、post()がrequestsライブラリのものと似ている。ダメ元でheadersを渡してみたら表題のエラーをクリアできた。

#        res = self.client.post(url, data=xml)
        res = self.client.post(url, data=xml, headers={'Content-Type': 'application/xml; charset="UTF-8"'})

しかし、今度は以下のエラーが出た。

$ python3 main.py 
Traceback (most recent call last):
  File "main.py", line 95, in <module>
    'ytyaru', 'blogmake.hatenablog.com', '新しい記事だよ', '本文です。', draft=True)
  File "main.py", line 48, in post_entries
    self._check_response(res)
  File "main.py", line 82, in _check_response
    response.raise_for_status()
  File "/usr/lib/python3/dist-packages/requests/models.py", line 773, in raise_for_status
    raise HTTPError(http_error_msg, response=self)
requests.exceptions.HTTPError: 404 Client Error: Not Found

404エラー?存在しないわけ無いのだが。

所感

作者様のコードがヘッダ指定せずに動作するというのは、たぶんAPI仕様かライブラリの仕様などが変わったせいなのだろう。