Бойтесь lxml, html парсящий

Недавно на форуме случился топик посвященный извечной проблеме всех питонистов -- кодировкам. Человек жаловался на то, что у него в программе получаются строчки вида:

u'\xd0\x9a\xd1\x83\xd1\x80\xd1\x83\xd0\xbc\xd0\xbe\xd1\x87'

Вы заметили что что-то не так? И я вот. Строчки как бы уникодные, но внутри них закодированные utf-8 байты. Что-то pдесь не так. Разбираясь дальше и потребовав скрипт, которые такое генерирует, становится понятно, что данные берутся из веба. Вполне обычным способом через urllib и потом скармливаются в lxml.html для разбора. Поскольку urllib оперирует только байтовыми строками, то он не мог их так превратить в уникод, а значит во всем виноват lxml.

Вообще lxml очень крутая библиотека - и быстрая, и функциональная, и умеет мимикрировать интерфейсом под ElementTree, и взаимодействовать с BeatifulSoup. Она давно уже пользуется популярностью у питонистов, когда надо как-то удобно работать с xml.

Но тут немного другой случай. Тут используется парсер html. И именно в нем происходят эти неприятные метаморфозы со строками.

Я решил понять в чем же всё-таки и дело и как побороть такое поведение.

Для начала, я сходил на http://yandex.ru/ и посмотрел что за html там отдается. Кодировка контента utf8. Сразу что бросилось в глаза -- это отсутствие декларации кодировки Он не обязателен, но всё-же довольно часто используется. Сделав похожий снипет html:

data = """<html>
<head>
</head>
<body>Привет мир</body>
</html>"""
html = lxml.html.document_fromstring(data)

и засунув его в lxml.html, получил, увы, уже ожидаемый результат:

>>> s
u'\xd0\x9f\xd1\x80\xd0\xb8\xd0\xb2\xd0\xb5\xd1\x82 \xd0\xbc\xd0\xb8\xd1\x80'
>>> print s
Привет м

s -- это как раз и есть строчка "Привет мир", выдранная через xpath. Как видно, она в нераскодированном виде. По большому счету это проблему можно решить на месте. Есть такой специальный кодек raw-unicode-escape, который из такой строчки сделайт байтовую но тоже без конвертации:

>>> print s.encode('raw-unicode-escape')
Привет мир

Но такое решение плохое. Надо как-то заставить lxml.html не издеваться над не-ASCII символами.

Что будет если указать кодировку в нелюбимом мною мета-заголовке html?

<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
</head>
<body>Привет мир</body>
</html>

Всё сразу встаёт на свои места:

>>> print s
Привет мир

Конечно логичней было бы брать информацию о кодировке из http заголовков, но для lxml.html протокол по которому пришли данные загадка и он не может на него опираться.

Ещё один способ решения -- это уже на вход lxml.html давать не байтовую строчку, а уникод (если вы конечно точно знаете кодировку сами):

>>> html = lxml.html.document_fromstring(data.decode('utf-8'))
...
>>> print s
Привет мир

На мой взгляд было бы правильней, чтобы lxml.html не пытался "выжить любой ценой" и портить контент, а явным образом сообщать о том что не задана кодировка -- как кстати он же и поступает в случае разбора xml. Но в любом случае обходные пути есть.

Будьте бдительны.

Комментарии 14

  1. Иван Сагалаев написал:

    Ещё правильней было бы принять utf-8 в качестве дефолта при незаданной кодировке. Просто, ascii-люди этого почему-то боятся всегда.

    Ждём распространения 3-го Питона, в котором это решение уже принято "выше".

    Оставлен 04 Ноябрь 2009 в 18:25
  2. adw0rd написал:

    Спасибо, полезная заметка, буду иметь ввиду.

    Оставлен 05 Ноябрь 2009 в 01:16
  3. ods написал:

    В HTML кодировка по умолчанию как раз ISO8859-1 (a.k.a. Latin-1), так что lxml всё правильно сделал. Другое дело, как ему кошерно передать кодировку из заголовка HTTP?

    Оставлен 05 Ноябрь 2009 в 13:21
  4. Александр Кошелев написал:

    Заранее прошу не судить строго, так как только начал разбираться с Python и Django.

    Я так легитимного способа и не нашел.

    Оставлен 06 Ноябрь 2009 в 00:22
  5. buriy.com написал:

    вытаскиваешь из http заголовки и добавляешь в начало документа в виде meta. вполне нормальный хак.

    проблема в том, что тебе приходится использовать lxml.html.document_fromstring(), а не удобный специальный враппер. где-то я такой видел, но не для lxml, он как раз из Response документ порождал. наверное, у mechanize. кстати, тот же lxml.html даёт тебе lxml.html.parse(filename_url_or_file) он умеет делать то, что нужно, если дать ему URL?

    есть и ещё один cheat. lxml.html.document_fromstring(content.decode('utf-8')), или, в общем случае, content.decode(get_encoding(response)) ;)

    Оставлен 07 Ноябрь 2009 в 10:33
  6. Александр Кошелев написал:

    он умеет делать то, что нужно, если дать ему URL?

    Урл он вытянет, но кодировку всё равно не поймет.

    есть и ещё один cheat. lxml.html.document_fromstring(content.decode('utf-8'))

    Ну это я как раз как решение предложил.

    Оставлен 07 Ноябрь 2009 в 19:54
  7. superbobry написал:

    собственно еще одна форма записи решения выше:

    parser = lxml.html.HTMLParser(encoding="utf-8")
    tree = lxml.html.parse(urllib2.urlopen(url), parser)
    
    Оставлен 12 Март 2010 в 14:49
  8. indu написал:

    спасибо :) в очередной раз спасло от переодически возникающих мучений..

    Оставлен 08 Май 2010 в 17:32
  9. seriyps.ru написал:

    Ух, спасибо тем, кто посоветовал scrapy. Прям то что мне надо было!

    Оставлен 04 Август 2010 в 19:02
  10. Дмытро написал:

    Спасибо за запись. Как раз столкнулся с этой проблемой. Указание кодировки помогло.

    Оставлен 03 Август 2011 в 16:34
  11. indeyets.ru написал:
        document = urllib2.urlopen(url)
        enc = 'utf-8'
        for part in document.info().getplist():
            if part.startswith('charset='):
                enc = part[8:]
    
        doc_str = document.read().decode(enc)
    
        tree = lxml.html.document_fromstring(doc_str)
    
    Оставлен 11 Август 2011 в 14:59
  12. Sergey написал:

    lxml.html.document_fromstring(content.decode(charset_from_response)) - это, конечно, в большинстве случаев спасает, но что делать, когда на странице встречаются ДВЕ кодировки?

    Оставлен 16 Август 2011 в 17:04
  13. Sergey написал:
    if part.startswith('charset='):
            enc = part[8:]
    

    :) встречал такое "charset=charset=utf-8" и такое "charset=cp-1251"

    Оставлен 16 Август 2011 в 17:08
  14. Kast написал:

    http://habrahabr.ru/blogs/webdev/128381/

    Оставлен 10 Октябрь 2011 в 22:07