PythonでMarkdownファイルからリンクのURLとアンカーテキストを抽出
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」を使う。
- trentm/python-markdown2: markdown2: A fast and complete implementation of Markdown in Python
- Beautiful Soup Documentation — Beautiful Soup 4.4.0 documentation
- 関連記事: Python, Beautiful Soupでスクレイピング、Yahooのヘッドライン抽出
いずれも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')
で取得すればよい。
関数では、抽出した値のリストを要素とするリスト(二次元リスト)をリスト内包表記で生成して、返り値としている。
- 関連記事: Pythonリスト内包表記の使い方
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行ずつ得られ、それを正規表現で処理している。
- 関連記事: Pythonでファイルの読み込み、書き込み(作成・追記)
- 関連記事: Python, enumerateの使い方: リストの要素とインデックスを取得
- 関連記事: Pythonの正規表現モジュールreの使い方(match、search、subなど)
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モジュールの場合と同様に、抽出した項目に応じて設定する必要がある。列数と一致していないとエラーになるので注意。