orangain flavor

じっくりコトコト煮込んだみかん2。知らないことを知りたい。

ElementTreeやlxmlで名前空間を含むXMLの要素を取得する

PythonElementTreelxmlを使って名前空間つきのXMLから要素を取得しようとしても、思い通りに取得できないことがあります。これはよくあるハマりどころですが、あまりまとまった情報がないのでまとめておきます。

Python 3.6.0で検証しました。

目次:

解決したい問題

まず前提としてXML名前空間について確認した後、解決したい問題について述べます。

前提知識: XML名前空間

1つのXML文書に複数の語彙を混ぜた場合、タグ名などが衝突してしまう可能性があるので、それらの意味を明確にするのが名前空間です。

XML名前空間URI(ここではhttp://www.w3.org/2005/Atom)で表され、XML文書内では名前空間に属する要素や属性を接頭辞(ここではatom:)をつけて表します。

<atom:feed xmlns:atom="http://www.w3.org/2005/Atom">
...
  <atom:title>orangain flavor</atom:title>
...
</atom:feed>

xmlns属性でデフォルト名前空間として指定した名前空間は接頭辞が不要になるので、以下のようにも書けます。

<feed xmlns="http://www.w3.org/2005/Atom">
...
  <title>orangain flavor</title>
...
</feed>

当ブログのAtomのソースもこのような形になっています。

参考: XML名前空間の簡単な説明

問題: 名前空間を含むXMLから意図した要素を取得できない

さて、このようなXML文書のtitle要素を取得しようとして、以下のように書いても要素が取得できません。

from urllib.request import urlopen
from xml.etree import ElementTree

f = urlopen('http://orangain.hatenablog.com/feed')
xml = f.read()
root = ElementTree.fromstring(xml)

title = root.find('./title')  # title要素を取得するつもりがNoneとなる
print(title)  # None

以降では、この問題への対応方法を見ていきます。(RSSAtomなどのフィードに限って言えば、feedparserのようなライブラリを使えば生のXMLを意識する必要はありませんが、ここでは名前空間を持つXMLの一例としてAtomを取り上げます。)

対応方法

基本的な対応方針としては以下の2つを行います。

  1. XPath名前空間の接頭辞を含める
  2. find()などのメソッドに{名前空間の接頭辞: URI}形式のdict(本記事では名前空間マッピングと呼ぶ)を渡す

注意点は、デフォルト名前空間であっても必ず適当な接頭辞をつけなくてはいけないということです。「名前空間なし」と「デフォルト名前空間」は完全に別のものと認識されます。

ElementTree(標準ライブラリ)の場合

ElementTreeのfind()findall()では以下のようになります。

from urllib.request import urlopen
from xml.etree import ElementTree

f = urlopen('http://orangain.hatenablog.com/feed')
xml = f.read()
root = ElementTree.fromstring(xml)

title = root.find('./atom:title', {'atom': 'http://www.w3.org/2005/Atom'})  # 第2引数に名前空間のマッピングを指定
print(title)  # <Element '{http://www.w3.org/2005/Atom}title' at 0x108e545e8>

ちなみに以下のようにXPath内に直接名前空間URIを書くこともできます。これはElementTreeのAPIで使用できる特殊な書式であり、有効なXPathではないため、後述するlxmlのxpath()では使用できません。(そもそもElementTreeではXPathのサブセットのみ使用できます。)

title = root.find('./{http://www.w3.org/2005/Atom}title')  # 名前空間のURIをXPathに含める

lxml(サードパーティライブラリ)の場合

lxml 3.8.0で検証しました。

lxmlのxpath()では以下のようになります。なおnamespacesはキーワード引数でしか指定できません。

from urllib.request import urlopen
from lxml import etree

f = urlopen('http://orangain.hatenablog.com/feed')
xml = f.read()
root = etree.fromstring(xml)

title = root.xpath('./atom:title', namespaces={'atom': 'http://www.w3.org/2005/Atom'})[0]
print(title)  # <Element {http://www.w3.org/2005/Atom}title at 0x108ab8e08>

(lxmlにもElementTree互換のfind()などがあり、ElementTreeの場合と同様の書き方ができます。が、lxmlで敢えてXPathのサブセットしか使えないこれらのAPIを使うメリットは少ないでしょう。詳しくはあとで触れます。)

XPathにデフォルト名前空間を指定したい

さて、名前空間を指定すれば取得できるのはわかりましたが、XPath名前空間を含めるのは正直面倒です。XMLではデフォルト名前空間によって簡潔に書けるようになっていますが、XPathでも同様にできないのでしょうか。

結論から言えばXPath 1.0ではできません。XPath 1.0の仕様として、名前空間を指定しない要素は常に「名前空間なし」の要素を意味し、デフォルト名前空間のようなものを指定することはできません。lxmlのFAQにもこの旨が書かれています。

XPath 2.0以降ではデフォルト名前空間を指定できるようになっているようですが、XPath 2.0以降は1.0から大きく変わってXQueryという仕様のサブセットになっています。世の中の実装も多くはXPath 1.0のままであり、lxmlで使われているlibxml2でもXPath 2.0をサポートする予定はないそうです。

参考: objective c - Does libxml2 support XPath 2.0 or not? - Stack Overflow

諦めてXPath名前空間を含めましょう、ということなのですが、微妙な感じの対処法を2つ紹介しておきます。

実はlxmlのfind()やfindall()ではデフォルト名前空間を指定できる

実はlxmlのfind()findall()はElementTreeのそれから拡張されており、デフォルト名前空間を指定できます。名前空間マッピングでキーをNoneにすると、それがデフォルト名前空間として使用されます。

title = root.find('./title', {None: 'http://www.w3.org/2005/Atom'})

さらに、lxmlではroot.nsmapでドキュメントの名前空間マッピングを取得できるため、以下のように書けます。

title = root.find('./title', root.nsmap)

また、lxmlでは名前空間URIワイルドカードを指定できるので、以下のようにも書けます。

title = root.find('./{*}title')

これらの書き方だと名前空間URIを意識しなくてよいので嬉しいですが、ElementTreeからの拡張についてドキュメント化されていないこと、lxmlでfind()を使うメリットが少ないことから、微妙な感じです。

よく使うであろうxpath()では、名前空間マッピングのキーをNoneにするとエラーになるので注意が必要です。

名前空間の宣言を消してしまう(雑)

非常に雑ですが、XMLの文字列から名前空間の宣言を消してしまうという方法もあります。以下のようにデフォルト名前空間の宣言を消せば、名前空間を気にせずに要素を取得できるというわけです。

from urllib.request import urlopen
from xml.etree import ElementTree
import re

f = urlopen('http://orangain.hatenablog.com/feed')
xml = f.read()
root = ElementTree.fromstring(re.sub(rb'xmlns=".*?"', b'', xml, count=1))

title = root.find('./title')
print(title)  # <Element 'title' at 0x108308598>

書き捨てのスクリプトであれば、これで十分な場合もあるでしょう。誤って意図しない箇所を置換してしまう可能性もあるので、自己責任でどうぞ。

デフォルト名前空間だけでなく、他の名前空間もある場合は、すべての名前空間を消す必要があります。正規表現では厳しくなってくるので、一度パースしてから書き換えるのが無難です。詳しくは以下のページを参考にしてください。

参考: Python ElementTree module: How to ignore the namespace of XML files to locate matching element when using the method “find”, “findall” - Stack Overflow

ちなみにこのページにも「XMLの文字列を正規表現で書き換えるなんてとんでもない!」というコメントがあります。

まとめ

名前空間を含むXMLに対してXPathを使用する際は、面倒ですが名前空間を1つ1つ指定しましょう。

Pythonクローリング&スクレイピングでElementTreeを扱った箇所では、シンプルさを優先して名前空間のついたAtomの代わりに名前空間のないRSS 2.0からデータを取得しました。よくあるハマりどころなので、名前空間のついたXMLを扱う方法もどこかに書いておけばよかったなと思い、記事にしました。

scraping-book.com

参考