note.nkmk.me

PythonでMarkdownファイルからリンクのURLとアンカーテキストを抽出

Date: 2019-08-04 / tags: Python, 正規表現, 自動化, Beautiful Soup

PythonでMarkdownのファイルからリンクのURLとアンカーテキストを抽出しリスト化、CSVで保存する方法について、MarkdownファイルをHTMLに変換し<a>タグを抽出する方法と、Markdownファイルの文字列から正規表現で抽出する方法を説明する。

  • MarkdownファイルをHTMLに変換し<a>タグを抽出
    • markdown2とBeautiful Soup
    • コード例
    • HTMLパーサーをlxmlにするときの注意点
  • 正規表現でリンクを抽出
    • コード例
    • 注意点
    • titleを含むリンクの場合
  • 複数のファイルから一括で抽出
    • ファイルパスのリストを指定
    • フォルダパスを指定(サブフォルダも含むすべてのファイルを対象)
  • 抽出結果をCSV保存
    • 標準ライブラリcsvモジュールを使用
    • pandasを使用

HTMLに変換するほうが確実だが、外部ライブラリを必要とし、処理に時間がかかる。正規表現を使うと標準ライブラリだけでOKかつ速いが、括弧()を含むURLなどのリンクの取りこぼしがあり得る。

環境およびMarkdownファイルの大きさにもよるが、手元の環境(MacBook Pro)で400個のMarkdownファイルのリンクを抽出すると、HTML変換だと3分程度かかったが、正規表現だと1秒で終わった。参考まで。

スポンサーリンク

MarkdownファイルをHTMLに変換しタグを抽出

markdown2とBeautiful Soup

ここでは、MarkdownファイルをHTMLに変換するのに「markdown2」、HTMLから<a>タグを抽出するのに「Beautiful Soup」を使う。

いずれもpip(環境によってはpip3)でインストール可能。

$ pip install markdown2
$ pip install beautifulsoup4

コード例

Markdownファイルのパスを指定しリンクのURLとアンカーテキストを抽出する関数の例は以下の通り。ここでは後の説明で使用するライブラリもインポートしている。

import csv
import glob
import os
import pprint

from bs4 import BeautifulSoup
import markdown2
import pandas as pd

def get_links_from_md(file_path, markdowner=markdown2.Markdown()):
    with open(file_path) as f:
        md = f.read()
    html = markdowner.convert(md)
    soup = BeautifulSoup(html, 'html.parser')
    l = [[file_path, a.text, a.attrs.get('href')] for a in soup.find_all('a')]
    return l

以下のような中身のMarkdownファイルを例とする。

with open('data/src/md/test1.md') as f:
    print(f.read())
# [Instagram](https://www.instagram.com/) and [Twitter](https://twitter.com)
# 
# - [[Py] Python.org](https://www.python.org/)
# - [relative link](../test/)
# 

上のサンプルコードの関数の結果は以下の通り。返り値は二次元リスト(リストのリスト)。pprintで見やすく出力している。

pprint.pprint(get_links_from_md('data/src/md/test1.md'))
# [['data/src/md/test1.md', 'Instagram', 'https://www.instagram.com/'],
#  ['data/src/md/test1.md', 'Twitter', 'https://twitter.com'],
#  ['data/src/md/test1.md', '[Py] Python.org', 'https://www.python.org/'],
#  ['data/src/md/test1.md', 'relative link', '../test/']]

関数の処理の流れを簡単に説明する。

まずMarkdownファイルを読みこみ、HTML形式の文字列に変換する。

with open('data/src/md/test1.md') as f:
    md = f.read()

markdowner = markdown2.Markdown()
html = markdowner.convert(md)
print(html)
# <p><a href="https://www.instagram.com/">Instagram</a> and <a href="https://twitter.com">Twitter</a></p>
# 
# <ul>
# <li><a href="https://www.python.org/">[Py] Python.org</a></li>
# <li><a href="../test/">relative link</a></li>
# </ul>
# 

markdown2.markdown(文字列)としてHTMLに変換することもできるが、その場合、その都度markdown2.Markdownインスタンスが生成される。

関数では引数markdownerとしてmarkdown2.Markdownインスタンスを渡すようにしている。デフォルト引数は関数の定義時に実行されるので、省略した場合は定義時に生成されたオブジェクトが繰り返し使われる。

続いて、BeautifulSoupインスタンスを生成し、find_all()メソッドですべての<a>タグを抽出する。BeautifulSoupインスタンス生成時のパーサーについては後述。

l = BeautifulSoup(html, 'html.parser').find_all('a')
pprint.pprint(l)
# [<a href="https://www.instagram.com/">Instagram</a>,
#  <a href="https://twitter.com">Twitter</a>,
#  <a href="https://www.python.org/">[Py] Python.org</a>,
#  <a href="../test/">relative link</a>]

find_all()メソッドが返すリストの要素はTagオブジェクトで、属性やリンクテキストを取得できる。

a = l[0]
print(type(a))
# <class 'bs4.element.Tag'>

print(a.attrs)
# {'href': 'https://www.instagram.com/'}

print(a.attrs.get('href'))
# https://www.instagram.com/

print(a.text)
# Instagram

なお、例では[text](URL)という形式のMarkdownのリンクのみだが、[text](URL "title")という形式でtitle属性を付与することもできる。その場合はa.attrs.get('href')で取得すればよい。

関数では、抽出した値のリストを要素とするリスト(二次元リスト)をリスト内包表記で生成して、返り値としている。

HTMLパーサーをlxmlにするときの注意点

上のサンプルコードではコンストラクタBeautifulSoup()の第二引数として'html.parser'を指定しているが、より高速なパーサーとして外部ライブラリlxmlをインストールして指定することも可能。

$ pip install lxml

lxmlをパーサーとして使用する場合、日本語から始まるMarkdownファイルを対象とするときには注意が必要。

英語から始まる場合、html.parserでもlxmlでもBeautifulSoupインスタンスが生成される。解釈の違いにより<html><body>が追加されているがここでは特に気にしない。

html_en = markdowner.convert('abcde')
print(html_en)
# <p>abcde</p>
# 

print(BeautifulSoup(html_en, 'html.parser'))
# <p>abcde</p>
# 

print(BeautifulSoup(html_en, 'lxml'))
# <html><body><p>abcde</p>
# </body></html>

日本語から始まる場合、lxmlだと空のBeautifulSoupインスタンスが生成されてしまう。

html_jp = markdowner.convert('あいうえお')
print(html_jp)
# <p>あいうえお</p>
# 

print(BeautifulSoup(html_jp, 'html.parser'))
# <p>あいうえお</p>
# 

print(BeautifulSoup(html_jp, 'lxml'))
# 

HTML文字列をencode()でバイト列に変換して引数に指定するとOK。

print(BeautifulSoup(html_jp.encode(), 'lxml'))
# <html><body><p>あいうえお</p>
# </body></html>

この現象は、文字列の先頭数文字を元にエンコーディングの判定が行われていることに起因する(たぶん)。日本語が含まれていても文字列の先頭が英字であれば問題ない。

print(BeautifulSoup('<p>abcdeあいうえお</p>', 'lxml'))
# <html><body><p>abcdeあいうえお</p></body></html>

HTMLのタグ部分もエンコーディングの判定に使われるようなので、適当なタグを追加してもOK。閉じタグはあってもなくてもいい。結果に余計な部分が追加されてしまうが、タグではなく適当な英字の文字列を追加してもいい。リンクを抽出したいだけなら後者でも問題ない。

print(BeautifulSoup('<html>' + html_jp + '</html>', 'lxml'))
# <html><body><p>あいうえお</p>
# </body></html>

print(BeautifulSoup('abcde' + html_jp, 'lxml'))
# <html><body><p>abcde</p><p>あいうえお</p>
# </body></html>

通常のHTMLファイルの文字列は先頭に<html><meta>などがあり問題にならないが、Markdownから変換すると先頭は<p>タグだけなのでエンコーディングで問題が起こってしまう。注意。

正規表現でリンクを抽出

コード例

Markdownのファイルパスを指定してそのリンクのURLとアンカーテキストを正規表現で抽出する関数の例は以下の通り。Markdownファイルにおける行番号も合わせて取得している。

open()で開いたファイルオブジェクトをfor文で回すと、ファイルの中身の文字列が1行ずつ得られ、それを正規表現で処理している。

import re

def get_links_from_md_regex(file_path, p=re.compile(r'\[(.+?)\]\((.+?)\)')):
    l = []
    with open(file_path) as f:
        for i, line in enumerate(f):
            for result in p.findall(line):
                l.append([file_path, i + 1, result[0], result[1]])
    return l

結果の例は以下の通り。

pprint.pprint(get_links_from_md_regex('data/src/md/test1.md'))
# [['data/src/md/test1.md', 1, 'Instagram', 'https://www.instagram.com/'],
#  ['data/src/md/test1.md', 1, 'Twitter', 'https://twitter.com'],
#  ['data/src/md/test1.md', 3, '[Py] Python.org', 'https://www.python.org/'],
#  ['data/src/md/test1.md', 4, 'relative link', '../test/']]

注意点

上の例で使用している正規表現パターンはごくごくシンプルなもの。

例えば、以下のような括弧()を含むURLだとうまくいかない。閉じ括弧が抽出されない。

s = '[text](URL_with())'

p1 = re.compile(r'\[(.+?)\]\((.+?)\)')
print(p1.findall(s))
# [('text', 'URL_with(')]

非貪欲マッチを示す?を外せばOKだが、1行の中に複数のリンクがある場合などにはうまくいかない。

p2 = re.compile(r'\[(.+?)\]\((.+)\)')
print(p2.findall(s))
# [('text', 'URL_with()')]

s_inline = '[text](URL_with()) and [text2](URL2)'

print(p2.findall(s_inline))
# [('text', 'URL_with()) and [text2](URL2')]

任意の文字を表す.ではなくURLに使われる文字のみを対象とするパターンだとどちらもOK。

p3 = re.compile(r"\[(.+?)\]\(([a-zA-Z0-9-._~:/?#@!$&'()*+,;=%]+)\)")
print(p3.findall(s))
# [('text', 'URL_with()')]

print(p3.findall(s_inline))
# [('text', 'URL_with()'), ('text2', 'URL2')]

ただし、日本語を含むURLだとうまくいかない(パーセントエンコーディングされていれば問題ない)。

s_jp = '[text](日本語URL)'

print(p1.findall(s_jp))
# [('text', '日本語URL')]

print(p2.findall(s_jp))
# [('text', '日本語URL')]

print(p3.findall(s_jp))
# []

単語文字を表す\wを加えるとOK。

p4 = re.compile(r"\[(.+?)\]\(([a-zA-Z0-9-._~:/?#@!$&'()*+,;=%\w]+)\)")
print(p4.findall(s_jp))
# [('text', '日本語URL')]

しかし、文中にリンクを含み、さらにリンクのうしろの文字列中に括弧()が含まれているとうまくいかない。

s_jp_inline = '[text](日本語URL)と括弧(xxx)。'

print(p1.findall(s_jp_inline))
# [('text', '日本語URL')]

print(p2.findall(s_jp_inline))
# [('text', '日本語URL)と括弧(xxx')]

print(p3.findall(s_jp_inline))
# []

print(p4.findall(s_jp_inline))
# [('text', '日本語URL)と括弧(xxx')]

このように、正規表現を利用する場合、ここで挙げた例以外でも、リンクを取りこぼしたり誤って抽出したりする可能性がある。複雑な正規表現パターン使えばいいのかもしれないが、上記のmarkdown2などのMarkdown変換ライブラリのソースコードを見る限り、括弧の対応を確認するなどの処理が必要な模様。

対象とするMarkdownファイルに括弧を含むURLや日本語を含むURLがなければ正規表現でも問題ない。上述のように、正規表現だとリンクがもとのMarkdownファイルの何行目にあるかを抽出するのも簡単。冒頭に書いたように、HTMLに変換するよりも正規表現を使うほうが遥かに高速なので、目的によって使い分ければよいだろう。

titleを含むリンクの場合

[text](URL "title")形式のtitleを含むリンクの場合、以下のような正規表現パターンが考えられる。

p_title = re.compile(r'\[(.+?)\]\(([a-zA-Z0-9-._~:/?#@!$&\'()*+,;=%\w]+)( "(.+)")?\)')

print(p_title.findall('[text](URL "title")'))
# [('text', 'URL', ' "title"', 'title')]

print(p_title.findall('[text](URL)'))
# [('text', 'URL', '', '')]

結果の[0]text[1]URL[3]titleに対応する。

複数のファイルから一括で抽出

ここまでは個別のMarkdownファイルからリンクを抽出する関数を説明した。ここからは複数のファイルを一括処理する例を説明する。

以下の例では、内部で個別ファイルからリンクを抽出する関数を呼んでいる。HTMLに変換するものと正規表現を利用するものの2通りを上で説明したが、どちらも返り値は2次元リスト(リストのリスト)なので、どちらを使ってもよい。

以下のような構成のフォルダを例とする。

md/
├── sub_dir
│   └── test_sub.md
├── test1.md
└── test2.md

ファイルパスのリストを指定

ファイルのパスを指定してすべてのファイルのリンクを抽出する関数は以下の通り。内部で上述の個別ファイルからリンクを抽出する関数を呼び出し、結果のリストをextend()で連結している。

def get_links_from_md_in_list(file_path_list, markdowner=markdown2.Markdown()):
    l = []
    for path in file_path_list:
        l.extend(get_links_from_md(path, markdowner))
    return l

結果の例。data/src/md/内のMarkdownファイルのパスの一覧をglobモジュールを使って取得している。

pprint.pprint(get_links_from_md_in_list(glob.glob('data/src/md/*.md')))
# [['data/src/md/test2.md', '[Py] Python.org', 'https://www.python.org/'],
#  ['data/src/md/test2.md', 'relative link', '../test/'],
#  ['data/src/md/test1.md', 'Instagram', 'https://www.instagram.com/'],
#  ['data/src/md/test1.md', 'Twitter', 'https://twitter.com'],
#  ['data/src/md/test1.md', '[Py] Python.org', 'https://www.python.org/'],
#  ['data/src/md/test1.md', 'relative link', '../test/']]

フォルダパスを指定(サブフォルダも含むすべてのファイルを対象)

もうひとつの例として、フォルダのパスを指定し、その配下のサブフォルダも含むすべてのMarkdownファイルのリンクを抽出する関数を示す。

def get_links_from_md_in_dir(dir_path, markdowner=markdown2.Markdown()):
    return get_links_from_md_in_list(
        glob.glob(os.path.join(dir_path, '**', '*.md'), recursive=True),
        markdowner
    )

結果は以下の通り。

pprint.pprint(get_links_from_md_in_dir('data/src/md/'))
# [['data/src/md/test2.md', '[Py] Python.org', 'https://www.python.org/'],
#  ['data/src/md/test2.md', 'relative link', '../test/'],
#  ['data/src/md/test1.md', 'Instagram', 'https://www.instagram.com/'],
#  ['data/src/md/test1.md', 'Twitter', 'https://twitter.com'],
#  ['data/src/md/test1.md', '[Py] Python.org', 'https://www.python.org/'],
#  ['data/src/md/test1.md', 'relative link', '../test/'],
#  ['data/src/md/sub_dir/test_sub.md', 'Instagram', 'https://www.instagram.com/'],
#  ['data/src/md/sub_dir/test_sub.md', 'Twitter', 'https://twitter.com']]

抽出結果をCSV保存

抽出結果の2次元リストをCSVとして保存する方法を説明する。

標準ライブラリcsvモジュールを使用

結果の2次元リストの先頭にヘッダとなるリストを追加してwriterows()で書き込む。

l = get_links_from_md('data/src/md/test1.md')
l.insert(0, ['file', 'anchor text', 'URL'])

with open('data/temp/md_links_csv.csv', 'w') as f:
    writer = csv.writer(f)
    writer.writerows(l)

なお、この例ではヘッダとして['file', 'anchor text', 'URL']を追加しているが、正規表現を利用して列番号も取得している場合や、titleを含むリンクからtitleも抽出している場合は、それに応じてヘッダの要素数も追加する必要がある。

pandasを使用

サードパーティライブラリpandasを利用してもよい。

l = get_links_from_md('data/src/md/test1.md')
df = pd.DataFrame(l, columns=['file', 'anchor text', 'URL'])
print(df)
#                    file      anchor text                         URL
# 0  data/src/md/test1.md        Instagram  https://www.instagram.com/
# 1  data/src/md/test1.md          Twitter         https://twitter.com
# 2  data/src/md/test1.md  [Py] Python.org     https://www.python.org/
# 3  data/src/md/test1.md    relative link                    ../test/

df.to_csv('data/temp/md_links_df.csv', index=False)

ここではDataFrame()の引数columnsにヘッダを指定する。csvモジュールの場合と同様に、抽出した項目に応じて設定する必要がある。列数と一致していないとエラーになるので注意。

スポンサーリンク
シェア
このエントリーをはてなブックマークに追加

関連カテゴリー

関連記事