はてな記法で書かれたテキストファイルを Markdown に変換するPHPスクリプト

Web

目的・背景

  • 今まで WordPress の投稿本文で「はてな記法」を使っていたが、今後は「Markdown」で書いていくことにした
  • それにあたり、既存の記事も「Markdown」に変換して中身を置き換えたい
    • が、いい感じのパーサーが見つからず
    • 手作業はイヤなので、自家製ゴリ押し置換スクリプトを作ることにした

前提条件

用意するもの

はてな記法で書かれた .txt ファイル

作成するもの

Markdown記法で書かれた .md ファイル

実行環境

VirtualBox 上に立てた仮想サーバー(本サイトのテスト環境)

  • CentOS 7
  • PHP8

免責事項

  • 本スクリプトの作成にあたっては、以下のマインドで臨んでいます。
    • 1回きりしか使わないので、とにかく変換できりゃいい
    • セキュリティとか動作の負荷とか考えない
    • PHP の書き方イマイチかもしれんけどエラー吐いてなきゃええやろ
      • 今回初めて Class 宣言を書きました
  • よって、本スクリプトについては一切の動作保証をしません。
  • また、本スクリプトの使用に伴い不利益・不都合等が生じた場合も、一切の責任を負いません。

対応範囲

自動置換するもの

種類 はてな記法 Markdown
見出し *見出し # 見出し
箇条リスト -リスト - リスト
数字リスト +リスト 1. リスト
複数行コード >||||< ``````
単一行コード <code>あいうえお</code> あいうえお
引用 >><< > 1行分の文章
強調 <strong>あいうえお</strong> **あいうえお**
リンク [https://~:title=あいうえお] [あいうえお](https://~)
リンク(URLのみ) [https://~] [https://~](https://~)
続きを読む ==== <!--more-->

手作業で対応するもの

  • 表記法
    • コツコツ直したほうが早いと判断
  • 脚注記法
    • 対応する記法がないので、内容に応じて文中に落としこみ
  • 改行の調整
    • リストの直後に見出しなどが改行なしで続くと li 要素の中に入ってしまうなど、微妙に崩れる部分を適宜調整

成果物

ファイル一覧

  • md ディレクトリ
    • 変換後の .md ファイルが保存される場所
  • txt ディレクトリ
    • 変換前の .txt ファイルを配置する場所
  • index.php
    • 実行ファイル本体
  • txt_import.php
    • .txt ファイルを読み込む
  • hatena_md.php
    • はてな記法からMarkdownへ変換する
  • md_export.php
    • .md ファイルを書き出す

使い方

  1. txt ディレクトリに .txt ファイルを設置
  2. index.php で配置したファイルの名前を配列に格納
  3. ブラウザから index.php にアクセス
  4. md ディレクトリに .md ファイルが出力される

デモ動画

Premiere Pro の習作がてらデモ動画作ってみました。約1分。
音声読み上げは「音読さん」を利用しています。

ソース

index.php
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>2201-はてな記法をMarkdownに変換</title>
    <style>
        pre{
            background-color: #EEF;
            padding: 1em;
        }
    </style>
</head>
<body>
<h1>2201-はてな記法をMarkdownに変換</h1>
<?php
//各ファイルの呼び出し
require_once 'txt_import.php';
require_once 'hatena_md.php';
require_once 'md_export.php';

//インスタンスを生成
$ti = new txt_import();
$hm = new hatena_md();
$me = new md_export();

//入出力するファイルの名前を配列に格納(例:123.txt → 123.md)
$ids=array(123,456,789);

//1ファイルずつ実行
foreach ($ids as $id) {
    //入力元ファイル
    $ti->file_dir='txt/';//ディレクトリ
    $ti->file_name=$id;//名前
    $ti->file_ex='.txt';//拡張子

    //出力先ファイル
    $me->file_dir='md/';//ディレクトリ
    $me->file_name=$id;//名前
    $me->file_ex='.md';//拡張子

    //読み込みを実行
    $htn_obj=$ti->get_txtObj();

    //はてな->Markdown変換を実行
    $md_obj=$hm->get_mdObj($htn_obj);

    //書き出しを実行
    $me->data=$md_obj;
    $me->save();
}

//確認用
echo '<pre>';
var_dump(array_map('htmlspecialchars', $md_obj));
echo '</pre>';
?>
</body>
</html>
txt_import.php
<?php
//テキストファイルを読み込んで配列に変換する
class txt_import{

    //variable-----------------------------------------------------------//

    //読み込むファイル
    public $file='';
    public $file_dir='';//ディレクトリ
    public $file_name='';//名前
    public $file_ex='';//拡張子

    //出力する配列
    private $txt_obj=array();

    //public-----------------------------------------------------------//

    //配列を返す
    public function get_txtObj(){
        //ファイル名を取得
        $this->get_file();

        //ファイルを読み込んで配列に変換
        $this->import();

        //結果を返す
        return $this->txt_obj;
    }

    //private-----------------------------------------------------------//

    /*
    ファイルパスを作成
    */
    private function get_file(){
        $str=$this->file_dir.$this->file_name.$this->file_ex;
        $this->file=$str;
        return;
    }

    /*
    テキストを配列に変換
    */
    private function convert_txtToArr($text,$letter){
        if($letter=='\n'){
            $lines=explode("\n", $text);//行で分割
        }else{
            $lines=explode($letter, $text);//文字で分割
        }
        // $lines=array_map('trim', $lines); // 各行にtrim()をかける
        // $lines=array_filter($lines, 'strlen'); // 文字数が0の行を取り除く
        // $lines=array_values($lines); // これはキーを連番に振りなおしてるだけ
        return $lines;
    }

    /*
    ファイルからテキストを取得&配列に格納
    */
    private function import(){
        $text='';
        if(file_exists($this->file)){
            $text=file_get_contents($this->file);
        }
        $this->txt_obj=$this->convert_txtToArr($text,'\n');
        return;
    }

}
hatena_md.php
<?php
//配列に格納されたはてな記法のテキストをMarkdownに変換
class hatena_md{

    //variable-----------------------------------------------------------//

    //変換前データ
    public $htn_obj=array();

    //変換後データ
    public $md_obj=array();

    //マッチング用正規表現
    private $reg_hl='/^(\*{1,3})([^\*]+.+)$/';//見出し
    private $reg_ul='/^(\-{1,3})([^\-]+.+)$/';//箇条リスト
    private $reg_ol='/^(\+{1,3})([^\+]+.+)$/';//数字リスト
    private $reg_more='/^\={4,}$/';//もっと読む区切り
    private $reg_preStart='/^>\|([a-z]+)?\|$/';//複数行コード開始
    private $reg_preEnd='/^\|{2}<$/';//複数行コード終了
    private $reg_quoteStart='/^>{2}$/';//引用ブロック開始
    private $reg_quoteEnd='/^<{2}$/';//引用ブロック終了

    //マッチング用正規表現(インライン)
    private $reg_th='/[^\|]*(\|\*)[^\|]+\|/';//表見出しセル
    private $reg_strong='/<\/?strong>/';//強調
    private $reg_code='/<\/?code>/';//単一行コード
    private $reg_link='/\[(https?:\/\/)?([^:]+)(:{1,2})title=([^\]]+)\]/';//テキストリンク
    private $reg_link_url='/\[(https?:\/\/[^\]]+)\]$/';//テキストリンク(URLのみ)

    //一時保存情報
    private $tmp_text='';//変換するテキスト
    private $tmp_matches='';//マッチした結果
    private $tmp_flg=0;//正規表現にマッチするかどうか
    private $tmp_tag='';//マッチしたタグの種類
    private $tmp_isPre=0;//preの中身かどうか
    private $tmp_isQuote=0;//blockquoteの中身かどうか

    //public-----------------------------------------------------------//

    //配列を返す
    public function get_mdObj($obj){
        $this->htn_obj=$obj;

        $this->process();

        return $this->md_obj;
    }

    //private-----------------------------------------------------------//

    /*
    はてな記法をMarkdownに変換
    */
    private function process(){
        $result=array();
        foreach ($this->htn_obj as $key => $text) {
            $this->tmp_text=$text;
            $result[$key]=$this->convert($text);
        }
        $this->md_obj=$result;
        return;
    }

    /*
    正規表現パターンリストを作成
    */
    private function set_patterns($num){
        $patterns=array();
        if($num===1){//パターン1
            $patterns=array(
                'hl' => $this->reg_hl,
                'ul' => $this->reg_ul,
                'ol' => $this->reg_ol,
                'more' => $this->reg_more,
                'preStart' => $this->reg_preStart,
                'preEnd' => $this->reg_preEnd,
                'quoteStart' => $this->reg_quoteStart,
                'quoteEnd' => $this->reg_quoteEnd,
            );
        }elseif($num===2){//パターン2
            $patterns=array(
                'th' => $this->reg_th,
                'strong' => $this->reg_strong,
                'code' => $this->reg_code,
                'link' => $this->reg_link,
                'link_url' => $this->reg_link_url,
            );
        }
        return $patterns;
    }

    /*
    1行単位で変換実行
    */
    private function convert(){
        //パターン1でチェック→置換実行
        $this->check_all(1);
        $this->tmp_text=$this->run_replace();

        //パターン2でチェック→置換実行
        //パターン2に関しては1行に対して全部チェッククリアするまで繰り返し処理し続ける
        $this->check_all(2);
        if($this->tmp_flg){
            while($this->tmp_flg==2){
                $this->check_all(2);
                $this->tmp_text=$this->run_replace();
            }
        }

        if($this->tmp_isQuote && !empty($this->tmp_text)){
            $this->tmp_text='> '. $this->tmp_text;
        }

        return $this->tmp_text;
    }

    /*
    正規表現でチェック
    */
    private function check($pattern){
        preg_match($pattern, $this->tmp_text, $matches);
        $this->tmp_matches=$matches;
        return;
    }
    //複数パターンを一気にチェック
    private function check_all($num){
        $patterns=$this->set_patterns($num);
        $this->tmp_flg=0;
        foreach ($patterns as $key => $pattern) {
            if(!$this->tmp_flg){
                $this->check($pattern);
                if($this->tmp_matches){
                    $this->tmp_flg=$num;
                    $this->tmp_tag=$key;
                }
            }
        }
        return;
    }

    /*
    正規表現で置換
    */
    //置換実行
    private function run_replace(){
        $str='';
        if($this->tmp_flg){
            $str=$this->replace($this->tmp_matches);
        }else{
            $str=$this->tmp_text;
        }
        return $str;
    }
    //対応パターン登録
    private function replace($matches){
        if($this->tmp_flg==1){
            if($this->tmp_tag==='hl') return $this->replace_hl($matches[1],$matches[2]);
            if($this->tmp_tag==='ul') return $this->replace_ul($matches[1],$matches[2]);
            if($this->tmp_tag==='ol') return $this->replace_ol($matches[1],$matches[2]);
            if($this->tmp_tag==='more') return $this->replace_more();
            if($this->tmp_tag==='preStart') return $this->replace_preStart($matches);
            if($this->tmp_tag==='preEnd') return $this->replace_preEnd();
            if($this->tmp_tag==='quoteStart') return $this->replace_quoteStart($matches);
            if($this->tmp_tag==='quoteEnd') return $this->replace_quoteEnd();
        }elseif($this->tmp_flg==2){
            if($this->tmp_tag==='th') return $this->replace_th();
            if($this->tmp_tag==='strong') return $this->replace_strong();
            if($this->tmp_tag==='code') return $this->replace_code();
            if($this->tmp_tag==='link') return $this->replace_link($matches);
            if($this->tmp_tag==='link_url') return $this->replace_link_url($matches);
        }
    }
    //見出し
    private function replace_hl($m1,$m2){
        $str=str_replace('*','#',$m1).' '.$m2;
        return $str;
    }
    //箇条リスト
    private function replace_ul($m1,$m2){
        $str='';
        if($m1==='-'){
            $str='- '.$m2;
        }elseif($m1==='--'){
            $str='    - '.$m2;
        }elseif($m1==='---'){
            $str='        - '.$m2;
        }
        return $str;
    }
    //数字リスト
    private function replace_ol($m1,$m2){
        $str='';
        if($m1==='+'){
            $str='1. '.$m2;
        }elseif($m1==='++'){
            $str='    1. '.$m2;
        }elseif($m1==='+++'){
            $str='        1. '.$m2;
        }
        return $str;
    }
    //箇条リスト
    private function replace_more(){
        $str='<!--more-->';
        return $str;
    }
    //複数行コード開始
    private function replace_preStart($m){
        $str='';
        if(!$this->tmp_isPre){
            if(array_key_exists(1,$m)){
                $str='```'.$m[1];
            }else{
                $str='```';
            }
        }else{
            $str=$this->tmp_text;
        }
        $this->tmp_isPre=1;
        return $str;
    }
    //複数行コード終了
    private function replace_preEnd(){
        $str='';
        $this->tmp_isPre=0;
        if(!$this->tmp_isPre){
            $str='```';
        }else{
            $str=$this->tmp_text;
        }
        return $str;
    }
    //引用ブロック開始
    private function replace_quoteStart($m){
        $str='';
        if(!$this->tmp_isQuote){
            $str='';
        }else{
            $str=$this->tmp_text;
        }
        $this->tmp_isQuote=1;
        return $str;
    }
    //引用ブロック終了
    private function replace_quoteEnd(){
        $str='';
        $this->tmp_isQuote=0;
        if(!$this->tmp_isQuote){
            $str='';
        }else{
            $str=$this->tmp_text;
        }
        return $str;
    }
    //表見出しセル
    private function replace_th(){
        $str=$this->tmp_text;
        $str=str_replace('|*','|',$str);
        $str=str_replace('|',' | ',$str);
        $str=preg_replace('/^\s\|/', '|', $str);
        $str=preg_replace('/\|\s$/', '|', $str);
        return $str;
    }
    //強調
    private function replace_strong(){
        $str=$this->tmp_text;
        $str=str_replace('<strong>','**',$str);
        $str=str_replace('</strong>','**',$str);
        return $str;
    }
    //単一行コード
    private function replace_code(){
        $str=$this->tmp_text;
        $str=str_replace('<code>','`',$str);
        $str=str_replace('</code>','`',$str);
        return $str;
    }
    //テキストリンク
    private function replace_link($m){
        $str=$this->tmp_text;
        $str=preg_replace($this->reg_link, '[$4]($1$2)', $str);
        return $str;
    }
    //テキストリンク(URLのみ)
    private function replace_link_url($m){
        $str=$this->tmp_text;
        $str=preg_replace($this->reg_link_url, '[$1]($1)', $str);
        return $str;
    }
}
md_export.php
<?php
//配列を.mdファイルに書き出す
class md_export{
    //variable-----------------------------------------------------------//

    //読み込むデータ
    public $data=array();

    //出力先ファイル
    public $file='';
    public $file_dir='';//ディレクトリ
    public $file_name='';//名前
    public $file_ex='';//拡張子

    //public-----------------------------------------------------------//

    //はてな記法をMarkdown記法に変換する
    public function save(){
        $this->get_file();
        $this->export();
    }

    //private-----------------------------------------------------------//
    /*
    ファイルパスを作成
    */
    private function get_file(){
        $str=$this->file_dir.$this->file_name.$this->file_ex;
        $this->file=$str;
        return;
    }

    /*
    ファイルを書き出す
    */
    private function export(){
        file_put_contents($this->file,implode(PHP_EOL,$this->data));
        echo '<p>'.$this->file.' を保存しました。</p>';
        return;
    }

}

Comments

  • スパム対策のため、コメント本文にURLが含まれている場合は「承認待ち」となり、すぐに投稿が反映されません。ご了承ください。
  • 公序良俗に反する内容、個人が特定できる情報、スパム投稿と思われるコメント等については、予告なく編集・削除する場合があります。