Python
lxml

pythonでhtml中の相対リンクを絶対リンクに書き換える(lxml)

More than 5 years have passed since last update.

pythonでhtml中の相対リンクを絶対リンクに書き換える(lxml)

lxml

html(xml)扱うときの便利ライブラリです。

lxml - Processing XML and HTML with Python(http://lxml.de/)

lxml is the most feature-rich and easy-to-use library for processing XML and HTML in the Python language.

lxmlを使うとhtml中の全相対リンクを絶対リンクに書き換える処理を楽に書けます。

すべてのリンクを絶対リンクに書き換えるときにはmake_links_absolute()が良い

lxml.html.make_links_absolute()を使いましょう。
例えば以下のようなhtml(a.html)があるとします。

a.html

<html>
  <head>
    <style type="text/css">
      .download {background-image:url(./images/download.png);}
    </style>
    <script src="./js/lib.js" type="text/javascript"/>
    <script src="./js/app.js" type="text/javascript"/>
  </head>
  <body>
    <img src="images/icon.png" alt="image"/>
    <a class="download "href="./download">download</a>
  </body>
</html>

以下のようなコードを実行します.
base_urlに基準となるurlを与えてください。

from lxml import html

with open("./a.html", "r") as rf:
    doc = html.parse(rf).getroot()
    html.make_links_absolute(doc, base_url="http://example.net/foo/bar")
    print html.tostring(doc, pretty_print=True)

以下のように絶対リンクに書き換えられます。

<html>
<head>
<style type="text/css">
      .download {background-image:url(http://example.net/foo/images/download.png);}
    </style>
<script src="http://example.net/foo/js/lib.js" type="text/javascript"></script><script src="http://example.net/foo/js/app.js" type="text/javascript"></script>
</head>
<body>
    <img src="http://example.net/foo/images/icon.png" alt="image"><a class="download " href="http://example.net/foo/download">download</a>
  </body>
</html>

以下の3つもリンクとして解釈してくれるのが素晴らしいです。

  • scriptタグのsrc属性
  • imgタグのsrc属性
  • ページ内に書いたcssのbackground-img:url

もう少し複雑なことをしたい場合には、rewrite_links()を使う

例えば、すべてのリンクを絶対リンクに書き換えたいがjsファイルだけは相対リンクのままにして欲しいということがあるかもしれません。

make_links_absolute()の実装を見てみましょう。

## lxml-3.2.1-py2.7-linux-x86_64.egg/lxml/html/__init__.py

class HtmlMixin(object):
#...
    def make_links_absolute(self, base_url=None, resolve_base_href=True):
        """
        Make all links in the document absolute, given the
        ``base_url`` for the document (the full URL where the document
        came from), or if no ``base_url`` is given, then the ``.base_url`` of the document.

        If ``resolve_base_href`` is true, then any ``<base href>``
        tags in the document are used *and* removed from the document.
        If it is false then any such tag is ignored.
        """
        if base_url is None:
            base_url = self.base_url
            if base_url is None:
                raise TypeError(
                    "No base_url given, and the document has no base_url")
        if resolve_base_href:
            self.resolve_base_href()
        def link_repl(href):
            return urljoin(base_url, href)
        self.rewrite_links(link_repl)

rewrite_links()が使われています。つまりこれを使えば良いですね。

先程のhtmlの内、jsを参照するパス以外のリンクを絶対リンクに変えたいとします。

from lxml import html
from urlparse import urljoin
import functools

def repl(base_url, href):
    if href.endswith(".js"):
       return href
    else:
        return urljoin(base_url, href)

with open("./a.html", "r") as rf:
    doc = html.parse(rf).getroot()   
    base_url="http://example.net/foo/bar"
    doc.rewrite_links(functools.partial(repl, base_url))
    print html.tostring(doc, pretty_print=True)

jsへのリンク(scriptタグのsrc属性)は相対リンクのままです。

<html>
<head>
<style type="text/css">
      .download {background-image:url(http://example.net/foo/images/download.png);}
    </style>
<script src="./js/lib.js" type="text/javascript"></script><script src="./js/app.js" type="text/javascript"></script>
</head>
<body>
    <img src="http://example.net/foo/images/icon.png" alt="image"><a class="download " href="http://example.net/foo/download">download</a>
  </body>
</html>

マルチバイト文字を含んだhtmlを書き換える際の注意

上記の通りlxmlを使うと、相対リンクから絶対リンクへの書き換えが手軽に行えて便利ですが。日本語などのマルチバイト文字を含んだhtmlを変換しようとする際には少し注意が必要です。

例えば以下のようなhtml(b.html)があるとします。

b.html

<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
  </head>
  <body>
    日本語の文字列
    <p>あいうえお</p>
  </body>
</html>

これをlxmlで変換してみましょう。

# -*- coding:utf-8 -*-
from lxml import html

with open("./b.html", "r") as rf:
    doc = html.parse(rf).getroot()
    print html.tostring(doc, pretty_print=True)

出力結果は以下の通り。

<html>
<head></head>
<body>
    &#26085;&#26412;&#35486;&#12398;&#25991;&#23383;&#21015;
    <p>&#12354;&#12356;&#12358;&#12360;&#12362;</p>
  </body>
</html>

生成されたhtmlをブラウザで閲覧した場合には意図した通りの文字列として表示されます。しかし、このhtmlをエディタなどで開くと複数の数字と記号の羅列に変換されてしまっています。htmlでは「文字実体参照」と「数値文字参照」の2種類の文字の表し方があるのですが。lxmlに渡した際に、前者で渡された文字列が後者に変換されてしまっているためです。(詳しくはhttp://www.asahi-net.or.jp/~sd5a-ucd/rec-html401j/charset.html#h-5.3.1)

これはちょうど、pythonで以下のようにしてunicode文字列をstr型の値に変換した時と同じような形式です。

print(u"あいうえお".encode("ascii", "xmlcharrefreplace"))
## &#12354;&#12356;&#12358;&#12360;&#12362;

実際、encode()のヘルプにもそのことが触れられています。

$ python
Python 2.7.3 (default, Sep 26 2012, 21:51:14) 
[GCC 4.7.2] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> help("".encode)
Help on built-in function encode:

encode(...)
    S.encode([encoding[,errors]]) -> object

    Encodes S using the codec registered for encoding. encoding defaults
    to the default encoding. errors may be given to set a different error
    handling scheme. Default is 'strict' meaning that encoding errors raise
    a UnicodeEncodeError. Other possible values are 'ignore', 'replace' and
    'xmlcharrefreplace' as well as any other name registered with
    codecs.register_error that is able to handle UnicodeEncodeErrors.

話は元に戻りますが。マルチバイト文字を含むhtmlをlxmlに渡したい場合には、
tostringにencodingを与えてください。

# -*- coding:utf-8 -*-
from lxml import html

with open("./b.html", "r") as rf:
    doc = html.parse(rf).getroot()
    print html.tostring(doc, pretty_print=True, encoding="utf-8")

人間にも読める形式で出力されました。

<html>
<head></head>
<body>
    日本語の文字列
    <p>あいうえお</p>
  </body>
</html>

もうちょっとだけ続きます。

続き,マルチバイト文字を含んだhtmlを書き換える際の注意

ネット上を見回した限りでは、lxml.html.tostringにencodingを渡せで終わっているところが多いのですが。もう少しだけ罠があります。

先程の例では、htmlにencodingの指定が付加されていました。しかし、これがないhtmlを渡された時にlxmlはおかしな出力を返します。(もっとも、encoding指定の無いhtmlが存在すること自体が悪という見方もできますが。主義主張は現実の前に無力です)

encoding指定のないhtml(d.html)

<html>
  <head>
  </head>
  <body>
    日本語の文字列
    <p>あいうえお</p>
  </body>
</html>

結果は以下のようになります。

<html>
<head></head>
<body>
    日本語の文字列
    <p>あいうえお</p>
  </body>
</html>

原因を調べましょう。lxml.html.parse,lxml.html.tostringの実装を見てみます。
先程encodingを渡したということで、tostringの方は対応済みと考えても良いと言う事にして、parseの方を見ることにします。

## lxml-3.2.1-py2.7-linux-x86_64.egg/lxml/html/__init__.py

def parse(filename_or_url, parser=None, base_url=None, **kw):
    """
    Parse a filename, URL, or file-like object into an HTML document
    tree.  Note: this returns a tree, not an element.  Use
    ``parse(...).getroot()`` to get the document root.

    You can override the base URL with the ``base_url`` keyword.  This
    is most useful when parsing from a file-like object.
    """
    if parser is None:
        parser = html_parser
    return etree.parse(filename_or_url, parser, base_url=base_url, **kw)

何やらparserという引数を受け取りデフォルトではhtml_parserというparserが渡されているようです。

## lxml-3.2.1-py2.7-linux-x86_64.egg/lxml/html/__init__.py

from lxml import etree

# ..snip..

class HTMLParser(etree.HTMLParser):
    """An HTML parser that is configured to return lxml.html Element
    objects.
    """
    def __init__(self, **kwargs):
        super(HTMLParser, self).__init__(**kwargs)
        self.set_element_class_lookup(HtmlElementClassLookup())

# ..snip..

html_parser = HTMLParser()
xhtml_parser = XHTMLParser()

lxml.etree.HTMLParserを継承したクラスがHTMLParserとして定義されています。defaultのparserはこのインスタンスのようです。
ちなみに、etree.HTMLParserはCで書かれたクラスのようです。

## lxml-3.2.1-py2.7-linux-x86_64.egg/lxml/etree/__init__.py

def __bootstrap__():
   global __bootstrap__, __loader__, __file__
   import sys, pkg_resources, imp
   __file__ = pkg_resources.resource_filename(__name__,'etree.so')
   __loader__ = None; del __bootstrap__, __loader__
   imp.load_dynamic(__name__,__file__)
__bootstrap__()

てきとうにドキュメントを見ましょう(lxml.etree.HTMLParse: http://lxml.de/3.1/api/lxml.etree.HTMLParser-class.html)

Method Details  [hide private]
__init__(self, encoding=None, remove_blank_text=False, remove_comments=False, remove_pis=False, strip_cdata=True, no_network=True, target=None, XMLSchema schema=None, recover=True, compact=True)
(Constructor)

x.__init__(...) initializes x; see help(type(x)) for signature

Overrides: object.__init__ 

HTMLParserもまたencodingが指定できるようです。おそらくデフォルトではNoneになりこの時の振る舞いはhtmlのmetaタグを見てencodingを設定するという感じなのでしょう。
もし、そのmetaタグが無いということであれば、利用しているシステム環境のデフォルトのエンコーディングが設定されるのでしょう。

html_parserを真似てインスタンスを作ることにします。

# -*- coding:utf-8 -*-
from lxml import html

html_parser = html.HTMLParser(encoding="utf-8")

with open("./d.html", "r") as rf:
    doc = html.parse(rf, parser=html_parser).getroot()
    print html.tostring(doc, pretty_print=True, encoding="utf-8")

うまく出力できたようです。

<html>
<head></head>
<body>
    日本語の文字列
    <p>あいうえお</p>
  </body>
</html>

適切なエンコーディングがわからない場合にはchardet

適切なエンコーディングがわからないという場合にはchardet(cchardet)を使うと良さそうです。

# -*- coding:utf-8 -*-
import chardet #if not installed. pip install chardet

print chardet.detect(u"あいうえお".encode("utf-8"))
# {'confidence': 0.9690625, 'encoding': 'utf-8'}

## warning
print chardet.detect(u"abcdefgあ".encode("utf-8")[:3])
# {'confidence': 1.0, 'encoding': 'ascii'}

def detect(fname, size = 4096 << 2):
    with open(fname, "rb") as rf:
        return chardet.detect(rf.read(size)).get("encoding")

print detect("b.html") # utf-8
print detect("a.html") # ascii

おしまい。