Markddown内の画像リンクをダウンロードする

日頃のメモや日記、アカウントのまとめなどでMarkdownを使っています。
広く普及しているので、一度書いた記事が腐らないのがいいですね。
かつて、なんとかwikiで書いた記事がZIPで大量に眠っています…

さてメモの中にはインターネット上の画像のリンクを多用しているのですが、
いつなくなるかわからないですね。
画像をダウンロードして、さらにMarkdown内のリンクも書き換えるプログラムを作ってみました。
Markdownを書き換えますので使用前にはバックアップしておいてくださいね。

プログラム

Pythonで作りました。
ライブラリと情報の多さで、こういうミニツールはPythonが効率的ですね。

import os  
import sys  
import re  
import urllib.request  
import urllib.parse  
import socket  
from typing import Tuple, List  
import http  
import traceback  
import shutil  

class ImageDownloader:  
    """  
    markdown内のオンライン画像をローカルにダウンロードする  
    大きい静止画は縮小する  
    markdown内の画像リンクを、オンラインからローカルのファイルに変換する。  
    """  
    def __init__(self):  
        # Markdown内画像URLパターン  
        self.image_url_pattern = r'.*!\[[^\[\]]*\]\((http.+\.(jpe?g|png|bmp|gif|webp)(\?.*)?)\).*'  
        # 除外パターン  
        self.except_image_url_pattern = r'.*pximg.*'  
        # ダウンロード対象画像パスパターン  
        self.download_path_pattern = r'.*\.(jpe?g|png|bmp|webp|gif)(\?.*)?'  
        # 置換画像URLパターン  
        self.replace_url_pattern = r'^(.*)!\[(.*)\]\((http.+\.(jpe?g|png|bmp|gif|webp)(\?.*)?)\)(.*)$'  
        # タイムアウト時間(秒)  
        self.timeout_second = 1  

    def collect_online_image_path_in_markdown(self, markdown_path: str) -> List[str]:  
        if not os.path.isfile(markdown_path):  
            return []  
        with open(markdown_path, encoding='utf-8') as f:  
            try:  
                lines = f.read().splitlines()  
            except UnicodeDecodeError as e:  
                print(markdown_path)  
                raise e  
            mtcs = [re.match(self.image_url_pattern, line, re.RegexFlag.IGNORECASE) for line in lines]  
            if self.except_image_url_pattern != '':  
                mtcs = [mtc for mtc in mtcs if mtc is not None and not re.match(self.except_image_url_pattern, mtc.group(0), re.RegexFlag.IGNORECASE) ]  
            img_urls = [mtc.group(1) for mtc in mtcs if mtc is not None]  
            print(img_urls)  
            return img_urls  

    def get_save_filepath(self, local_path: str, url: str) -> str:  
        # 1.ファイル保存場所取得  
        # urlをパース  
        parsed_url = urllib.parse.urlparse(url)  
        # ホスト名部分取得  
        url_hostname = parsed_url.hostname  
        # パス部分取得(urlデコードもする)  
        url_path = urllib.parse.unquote(parsed_url.path[1:])  
        # ファイル保存パス  
        path = os.path.abspath(os.path.join(local_path, url_hostname, url_path))  
        return path  

    def download_file(self, url: str, local_path: str) -> str:  
        path = self.get_save_filepath(local_path, url)  
        # リクエストヘッダ  
        headers = {  
            "User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:47.0) Gecko/20100101 Firefox/47.0"  
        }  

        if re.match(self.download_path_pattern, path, re.RegexFlag.IGNORECASE) is None:  
            return ''  

        # ファイル保存  
        try:  
            request = urllib.request.Request(url, headers=headers)  
            request.add_header('Host', request.host.encode('idna'))   
            print(f'  downloading : {url}')  
            with urllib.request.urlopen(request, timeout=self.timeout_second) as img_data:  
                img_data :http.client.HTTPResponse  
                # 2.ファイル保存場所存在確認  
                path_dir = os.path.dirname(path)  
                if not os.path.isdir(path_dir):  
                    os.makedirs(path_dir)  
                with open(path, 'bw') as f:  
                    f.write(img_data.read())  
        except urllib.error.HTTPError as e:  
            print(f'{url} : {e}')  
            return ''  
        except urllib.error.URLError as e:  
            print(f'{url} : {e}')  
            return ''  
        except UnicodeEncodeError as e:  
            print(f'{url} : {e}')  
            print(traceback.format_exc())  
            return ''  
        except socket.timeout as e:  
            print(f'{url} : {e}')  
            print(traceback.format_exc())  
            return ''  
        except ConnectionResetError as e:  
            print(f'{url} : {e}')  
            raise e  
        except Exception as e:  
            print(f'{url} : {e}')  
            raise e  
        return path  

    def replace_online_to_offline(self, lines: List[str], replace_list : Tuple[str]):  
        res = []  
        for line in lines:  
            mtc = re.match(self.replace_url_pattern, line, re.RegexFlag.IGNORECASE)  
            if mtc is None:  
                res.append(f'{line}')  
                continue  
            pre_line = mtc.group(1)  
            alt_text = mtc.group(2)  
            image_url = mtc.group(3)  
            # image_ext = mtc(4)  
            post_line = mtc.group(6)  
            for (online_path, offline_path) in replace_list:  
                if mtc is not None and image_url == online_path:  
                    replaced_line = f'{pre_line}[![{alt_text}]({offline_path})]({image_url}){post_line}'  
                    print(f'replace to {replaced_line}')  
                    res.append(replaced_line)  
                    break  
            else:  
                res.append(f'{line}')  
        return res  

    def run(self, file_path :str, download_dir :str):  
        # 2.主処理  
        # 2.1 Markdown内画像ダウンロード  
        # 2.1.1 Markdown内画像URL抽出  
        img_urls = self.collect_online_image_path_in_markdown(file_path)  
        # 2.1.2 ファイルをダウンロードしてその絶対パスを取得  
        dl_file_pathes = [self.download_file(img_url, download_dir) for img_url in img_urls]  

        # 2.2 Markdown内パス置換  
        # 2.2.1 相対パスに変換  
        pathes_rel = []  
        for path in dl_file_pathes:  
            if os.path.isfile(path):  
                pathes_rel.append('./' + os.path.relpath(path, os.path.dirname(file_path)).replace('\\', '/'))  
            else:  
                pathes_rel.append('')  
        # 2.2.2 元の画像URLと変換先のファイルパスをペアにして変換リストを作成  
        replace_pair = [(img_urls[i], pathes_rel[i]) for i in range(len(img_urls)) if pathes_rel[i] != '']  
        # 2.2.3 ファイル読み込み  
        lines = []  
        with open(file_path, encoding='utf-8', mode='r') as f:  
            lines = f.read().splitlines()  
        # 2.2.4 URLをパスに変換  
        write_lines = self.replace_online_to_offline(lines, replace_pair)  
        # 2.2.5 ファイル書き込み  
        with open(file_path, encoding='utf-8', mode='w') as f:  
            f.write('\n'.join(write_lines) + '\n')  



def main():  
    input_path = input('path :')  
    if os.path.isfile(input_path):  
        fpathes = [input_path]  
    elif os.path.isdir(input_path):  
        fpathes = sum([[os.path.join(root, f) for f in files if os.path.splitext(f)[1] == '.md'] for root, dirs, files in os.walk(input_path)], [])  
    else:  
        return  
    fpathes = list(filter(lambda x:'email' not in x, fpathes))  
    print(fpathes)  

    for i, fpath in enumerate(fpathes):  
        print(f'processing({i+1}/{len(fpathes)}) : {fpath}')  
        # 画像フォルダ名  
        img_dir = os.path.join(os.path.dirname(fpath), 'img')  

        downloader = ImageDownloader()  
        downloader.timeout_second = 2  
        downloader.run(fpath, img_dir)  


if __name__ == '__main__':  
    main()  

型あり言語が好きなので型アノテーションつけまくってますが、最近の型インファレンスがよく効いてほとんど型を書かなくてもチェックしてくれる流れに逆流してますね。

結果

これが

# テスト  
![](https://www.yakoi.info/assets/img/20190626_firewall.52bbdd1f.png)  

こうなって

# テスト  
[![](./img/www.yakoi.info/assets/img/20190626_firewall.52bbdd1f.png)](https://www.yakoi.info/assets/img/20190626_firewall.52bbdd1f.png)  

画像がimgフォルダ配下にダウンロードされます。

ウェブの画像のフォルダ構造を保ったままダウンロードするので重複することがない(はず)です。