📜

jq の HTML 版を作った

2024/01/06に公開

jq というコマンドラインツールがあり、JSON のフィルタや加工に非常に便利だが、似たようなものが HTML にも欲しかったので作った。heqというツールで、github 上に公開してある。この記事ではその使い方を紹介する。

インストール

PyPI に公開してあるので以下でインストールできる。

pip install heq

コンセプト

「jq の HTML 版」とは表現したが、jq の制御構造や諸々の関数を全部再実装する利点はあまりない。HTML を JSON の形に落とし込めればそのあとは jq に pipe していかようにも加工できるので、HTML から短い記述で JSON で構造化されたデータを取り出すことを目指した。XPath を使い要素を取り出し、適切に JSON オブジェクトに配置して出力する、ということに特化した DSL (ドメイン特化言語)を実装した、と言い換えてもよい。

使い方

まずは HTML を用意する。

$ cat << 'EOF' > test.html
<body>
    <div id="header">Welcome to Our Store!</div>
    <div id="announcement">Special Offer: 20% off on all products this week!</div>
    <div class="product">
      <h2 class="name">Widget A</h2>
      <p class="price">$10</p>
      <ul class="features"><li>Durable</li><li>Lightweight</li></ul>
      <a href="/products/widget_a">Details</a>
    </div>
    <div class="product">
      <h2 class="name">Gadget B</h2>
      <p class="price">$20</p>
      <ul class="features"><li>Compact</li><li>Energy Efficient</li></ul>
      <a href="/products/gadget_b">Details</a>
    </div>
</body>
EOF

header を取り出してみる。

$ cat test.html | heq '`//div[@id="header"]`.text'
"Welcome to Our Store!"

//div[@id="header"] は普通の XPath で、これをバッククオートで囲み .text とすると、含まれるテキストを取り出せる。出力をオブジェクトにしてみる。

$ cat test.html | heq '{header: `//div[@id="header"]`.text}'
{
  "header": "Welcome to Our Store!"
}

次は複数の商品を配列として取り出す。

$ cat test.html | heq '`//div[@class="product"]` / {name: `.//h2`.text}'
[
  {
    "name": "Widget A"
  },
  {
    "name": "Gadget B"
  }
]

/ 演算子で、左項の XPath で選択した複数の要素のそれぞれを context DOM tree として、右項を評価する。.//h2 という XPath の .// は「現在見ている要素の子孫」という意味。単に // だと根から見た子孫になる。

次にリンク先を取り出す。

$ cat test.html | heq '`//div[@class="product"]//a` / @href'
[
  "/products/widget_a",
  "/products/gadget_b"
]

@<属性名> で属性の値を取り出せる。

$ cat test.html | heq '`//div[@class="product"]` / {name: `.//h2`.text, url: `.//a`@href}'
[
  {
    "name": "Widget A",
    "url": "/products/widget_a"
  },
  {
    "name": "Gadget B",
    "url": "/products/gadget_b"
  }
]

XPath に続けて @<属性名> とすると、XPath で選択した要素の先頭の要素の属性を取り出せる。

クエリが長くなって来たので、別ファイルにする。

$ cat << 'EOF' > expr.heq
`//div[@class="product"]` / {
    name: `.//h2[@class="name"]`.text,
    price: `.//p[@class="price"]`.text,
    features: `.//li` / text,
    url: `.//a`@href
}
EOF
$ cat test.html | heq -f expr.heq
[
  {
    "name": "Widget A",
    "price": "$10",
    "features": [
      "Durable",
      "Lightweight"
    ],
    "url": "/products/widget_a"
  },
  {
    "name": "Gadget B",
    "price": "$20",
    "features": [
      "Compact",
      "Energy Efficient"
    ],
    "url": "/products/gadget_b"
  }
]

ライブラリとして使う

Python ライブラリでもあるので、HTML のスクレイピングのためのライブラリとしての利用も可能になっている。

from pprint import pprint
from heq import extract, parse
html = '''
<body>
    <div id="header">Welcome to Our Store!</div>
    <div id="announcement">Special Offer: 20% off on all products this week!</div>
    <div class="product">
      <h2 class="name">Widget A</h2>
      <p class="price">$10</p>
      <ul class="features"><li>Durable</li><li>Lightweight</li></ul>
      <a href="/products/widget_a">Details</a>
    </div>
    <div class="product">
      <h2 class="name">Gadget B</h2>
      <p class="price">$20</p>
      <ul class="features"><li>Compact</li><li>Energy Efficient</li></ul>
      <a href="/products/gadget_b">Details</a>
    </div>
</body>
'''

expr = parse('''
`//div[@class="product"]` / {
    name: `.//h2[@class="name"]`.text,
    price: `.//p[@class="price"]`.text,
    features: `.//li` / text,
    url: `.//a`@href
}
''')

pprint(extract(expr, html))

このスクリプトを実行すると以下が出力される。

[{'features': ['Durable', 'Lightweight'],
  'name': 'Widget A',
  'price': '$10',
  'url': '/products/widget_a'},
 {'features': ['Compact', 'Energy Efficient'],
  'name': 'Gadget B',
  'price': '$20',
  'url': '/products/gadget_b'}]

クエリは次のようにパーサーを介さず記述することも出来る。

from pprint import pprint
from heq import extract, xpath, text

expr = xpath('//div[@class="product"]') / {
    'name': xpath('.//h2[@class="name"]').text,
    'price': xpath('.//p[@class="price"]').text,
    'features': xpath('.//li') / text,
    'url': xpath('.//a')@'href'
}

pprint(extract(expr, html))

Discussion