取り急ぎ;WordPress/noteの記事をNotebookLMに読ませる方法

こんにちは、如月翔也(@showya_kiss)です。
 取り急ぎ、コードだけできて運用に問題ないのを確認したのでソースの消失を恐れてブログだけ先に書いておきます。

NotebookLMってめっちゃ便利です

GoogleのNotebookLMって、自分でソースを指定してそこから調べたり意見を求めたりできるので非常に便利なんですが、これちょっとだけ問題点があって、「URL」を指定するとクロールして中身を見てくれるイメージなんですが、実はURLを指定した場合はそのURL自体の1ページだけしか参照しないんです。
 そのため、自分の作品群を並べたURLを貼って「私の作品の傾向を教えて」みたいな話をすると、URLの説明だけをもとに傾向を教えてくれるので全然欲しい情報と違うんですよね。
 そういう意味で、例えばWordPressとかnoteで書いた全記事を参照してもらいたい場合ソースとしては全部のURLを登録しないといけないんです。
 僕の場合ガジェットのブログが記事数2000、TRPGのブログが記事数1200を超えるので、ソースは上限50ですし、全部読んで貰うのは不可能なんですよ。

よろしいならば開発だ

つまり、ソース1個の中にブログ1個の記事が全部入っていれば問題ないわけです。じゃあWordPressとnoteから内容をエクスポートしてxmlにし、それを「NotebookLMが理解しやすい形で」再編集したテキストを作ってあげて、それをソースにすれば1ブログソース1個で済むわけです。まあ記事が増えたら地道にソースも増やしていかないといけないんですが、ツールを作れば一発解決です。
 まず記事をxmlから適当に区切って読み込ませ、その上で「これ実はXX件あるんだけど、区切りだけでは判断できないだろうと思うので、NotebookLMが読みやすい形式はどうなる?」と聞いた所「こういう形式が嬉しいです」と素直に答えたので、WordPressが吐く(管理画面からツール→エクスポートで吐き出せます)xmlをそういう形に整形するPythonスクリプトを組みました。一応Macで作っているんですがWindowsでも動くと思います。
 Pythonは今Macに勝手に入っているわけではないのでこれを使うためにはPythonのインストールからガイドしないといけないんですが、それは翔也ガジェットブログに譲るとして、とりあえず記事を書く前にコードが消えると洒落にならんのでコードだけ残しておきます。

コード本体

コードは以下です。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
WordPress XML to Text Converter (改良版 - より寛容なパーサー)
WordPressやnoteから出力されたXMLファイルを指定の形式のテキストファイルに変換する
"""

import re
import html
import os
import sys
from datetime import datetime
import argparse

class WordPressXMLConverter:
    def __init__(self):
        pass
    
    def detailed_file_analysis(self, file_path):
        """ファイルの詳細分析"""
        print("=== ファイル詳細分析 ===")
        
        # バイナリモードで最初の部分を読み込み
        with open(file_path, 'rb') as f:
            raw_bytes = f.read(1000)
        
        print(f"最初の50バイト (hex): {raw_bytes[:50].hex()}")
        print(f"最初の50バイト (repr): {repr(raw_bytes[:50])}")
        
        # テキストモードで行ごとに分析
        encodings = ['utf-8', 'utf-8-sig', 'latin1']
        
        for encoding in encodings:
            try:
                with open(file_path, 'r', encoding=encoding, errors='replace') as f:
                    lines = []
                    for i in range(10):  # 最初の10行
                        line = f.readline()
                        if not line:
                            break
                        lines.append(line)
                
                print(f"\n--- {encoding} エンコーディングでの最初の10行 ---")
                for i, line in enumerate(lines, 1):
                    print(f"{i:2d}: {repr(line)}")
                
                if encoding == 'utf-8-sig' or not any('\\x' in repr(line) for line in lines):
                    return encoding
                    
            except Exception as e:
                print(f"{encoding} での分析エラー: {e}")
        
        return 'latin1'  # 最後の手段
    
    def clean_xml_file(self, file_path):
        """XMLファイルをクリーニングして新しい一時ファイルを作成"""
        # 最適なエンコーディングを決定
        best_encoding = self.detailed_file_analysis(file_path)
        print(f"\n使用エンコーディング: {best_encoding}")
        
        # ファイルを読み込み
        with open(file_path, 'r', encoding=best_encoding, errors='replace') as f:
            content = f.read()
        
        print(f"元ファイルサイズ: {len(content):,} 文字")
        
        # クリーニング処理
        original_content = content
        
        # 1. BOMを除去
        if content.startswith('\ufeff'):
            content = content[1:]
            print("✓ BOMを除去")
        
        # 2. XML宣言の正規化
        xml_decl_pattern = r'<\?xml[^>]*\?>'
        xml_decl_match = re.search(xml_decl_pattern, content)
        if xml_decl_match:
            content = content.replace(xml_decl_match.group(0), '<?xml version="1.0" encoding="UTF-8"?>')
            print("✓ XML宣言を正規化")
        
        # 3. 改行コードの統一
        content = content.replace('\r\n', '\n').replace('\r', '\n')
        print("✓ 改行コードを統一")
        
        # 4. 制御文字の除去(ただし改行とタブは保持)
        control_chars_removed = 0
        clean_content = ""
        for char in content:
            if ord(char) < 32 and char not in '\n\t':
                control_chars_removed += 1
            else:
                clean_content += char
        
        if control_chars_removed > 0:
            content = clean_content
            print(f"✓ 制御文字を {control_chars_removed} 個除去")
        
        # 5. XMLコメント内の問題文字を修正
        def fix_comment(match):
            comment_content = match.group(1)
            # コメント内の--を修正
            comment_content = comment_content.replace('--', '- - ')
            return f'<!-- {comment_content} -->'
        
        content = re.sub(r'<!--(.*?)-->', fix_comment, content, flags=re.DOTALL)
        
        # 6. 未エスケープの&を修正
        content = re.sub(r'&(?![a-zA-Z0-9#]{1,10};)', '&amp;', content)
        
        # 7. 不正な<>の修正(CDATAとタグ以外)
        lines = content.split('\n')
        fixed_lines = []
        in_cdata = False
        
        for line in lines:
            if '<![CDATA[' in line:
                in_cdata = True
            elif ']]>' in line:
                in_cdata = False
            elif not in_cdata and not re.search(r'<[!?/]?[a-zA-Z]', line):
                # XMLタグでない行の<>を修正
                line = re.sub(r'<(?![!?/a-zA-Z])', '&lt;', line)
                line = re.sub(r'(?<![a-zA-Z/!])>', '&gt;', line)
            
            fixed_lines.append(line)
        
        content = '\n'.join(fixed_lines)
        
        print(f"クリーニング後サイズ: {len(content):,} 文字")
        
        # 一時ファイルに保存
        temp_file = file_path + '.cleaned'
        with open(temp_file, 'w', encoding='utf-8') as f:
            f.write(content)
        
        print(f"✓ クリーニング済みファイル: {temp_file}")
        return temp_file, content
    
    def extract_items_manually(self, content):
        """正規表現を使って記事を手動抽出"""
        print("=== 手動抽出モード ===")
        
        # ブログタイトルを抽出
        blog_title = "不明"
        title_match = re.search(r'<title>(.*?)</title>', content, re.DOTALL)
        if title_match:
            blog_title = html.unescape(title_match.group(1).strip())
        
        # 各記事(item)を抽出
        item_pattern = r'<item>(.*?)</item>'
        items = re.findall(item_pattern, content, re.DOTALL)
        
        print(f"発見された記事数: {len(items)}")
        
        posts = []
        debug_info = {'post_types': {}, 'statuses': {}, 'content_lengths': []}
        
        for i, item_content in enumerate(items):
            try:
                post = {
                    'blog_title': blog_title,
                    'filename': '',
                    'title': '無題',
                    'pub_date': '不明',
                    'post_type': 'post',
                    'status': 'publish',
                    'author': '如月翔也',
                    'content': ''
                }
                
                # タイトル抽出
                title_match = re.search(r'<title>(.*?)</title>', item_content, re.DOTALL)
                if title_match:
                    post['title'] = self.extract_simple_text(title_match.group(1).strip())
                
                # 公開日抽出
                pubdate_match = re.search(r'<pubDate>(.*?)</pubDate>', item_content, re.DOTALL)
                if pubdate_match:
                    post['pub_date'] = self.format_date(pubdate_match.group(1).strip())
                
                # 投稿タイプ抽出
                post_type_match = re.search(r'<wp:post_type>(.*?)</wp:post_type>', item_content, re.DOTALL)
                if post_type_match:
                    raw_type = post_type_match.group(1).strip()
                    post['post_type'] = self.extract_simple_text(raw_type)
                
                # ステータス抽出
                status_match = re.search(r'<wp:status>(.*?)</wp:status>', item_content, re.DOTALL)
                if status_match:
                    raw_status = status_match.group(1).strip()
                    post['status'] = self.extract_simple_text(raw_status)
                
                # コンテンツ抽出
                content_patterns = [
                    r'<content:encoded>(.*?)</content:encoded>',
                    r'<description>(.*?)</description>'
                ]
                
                for pattern in content_patterns:
                    content_match = re.search(pattern, item_content, re.DOTALL)
                    if content_match:
                        raw_content = content_match.group(1)
                        post['content'] = self.extract_content_from_cdata(raw_content)
                        break
                
                # デバッグ情報収集
                debug_info['post_types'][post['post_type']] = debug_info['post_types'].get(post['post_type'], 0) + 1
                debug_info['statuses'][post['status']] = debug_info['statuses'].get(post['status'], 0) + 1
                debug_info['content_lengths'].append(len(post['content']))
                
                # 最初の5記事の詳細を表示
                if i < 5:
                    print(f"記事 {i+1} 詳細:")
                    print(f"  タイトル: {post['title'][:50]}...")
                    print(f"  投稿タイプ: '{post['post_type']}'")
                    print(f"  ステータス: '{post['status']}'")
                    print(f"  コンテンツ長: {len(post['content'])} 文字")
                    print(f"  コンテンツ先頭: {post['content'][:100]}...")
                    print()
                
                # より寛容な条件チェック(デバッグのため)
                is_valid_post = (
                    post['post_type'] in ['post', 'page'] and  # pageも含める
                    post['status'] == 'publish' and 
                    len(post['content'].strip()) > 10  # 10文字以上
                )
                
                if is_valid_post:
                    posts.append(post)
                    print(f"✓ 記事 {len(posts)}: {post['title'][:50]}...")
                
            except Exception as e:
                print(f"記事 {i+1} の処理エラー: {e}")
                continue
        
        # デバッグ情報を表示
        print(f"\n=== デバッグ情報 ===")
        print(f"投稿タイプ別集計: {debug_info['post_types']}")
        print(f"ステータス別集計: {debug_info['statuses']}")
        print(f"コンテンツ長の統計: 最小{min(debug_info['content_lengths'])}, 最大{max(debug_info['content_lengths'])}, 平均{sum(debug_info['content_lengths'])/len(debug_info['content_lengths']):.1f}")
        
        return posts
    
    def extract_content_from_cdata(self, content):
        """CDATAセクションからコンテンツを抽出してHTMLタグを除去"""
        if not content:
            return ""
        
        # CDATAセクションを処理
        if '<![CDATA[' in content and ']]>' in content:
            content = re.sub(r'<!\[CDATA\[(.*?)\]\]>', r'\1', content, flags=re.DOTALL)
        
        # HTMLエンティティをデコード
        try:
            content = html.unescape(content)
        except:
            pass
        
        # HTMLタグを除去
        content = re.sub(r'<[^>]+>', '', content)
        
        # 余分な空白行を削除
        content = re.sub(r'\n\s*\n\s*\n', '\n\n', content)
        content = content.strip()
        
        return content
    
    def extract_simple_text(self, content):
        """単純なテキスト抽出(HTMLタグは除去しない)"""
        if not content:
            return ""
        
        # CDATAセクションを処理
        if '<![CDATA[' in content and ']]>' in content:
            content = re.sub(r'<!\[CDATA\[(.*?)\]\]>', r'\1', content, flags=re.DOTALL)
        
        # HTMLエンティティをデコード
        try:
            content = html.unescape(content)
        except:
            pass
        
        return content.strip()
    
    def format_date(self, date_str):
        """日付フォーマットを統一"""
        if not date_str:
            return "不明"
        
        date_formats = [
            '%a, %d %b %Y %H:%M:%S %z',
            '%a, %d %b %Y %H:%M:%S +0000',
            '%Y-%m-%d %H:%M:%S',
            '%Y-%m-%dT%H:%M:%S%z',
            '%Y-%m-%dT%H:%M:%S',
            '%Y-%m-%d',
        ]
        
        date_str = date_str.strip()
        
        for fmt in date_formats:
            try:
                dt = datetime.strptime(date_str, fmt)
                return dt.strftime('%Y-%m-%dT%H:%M:%S+0000')
            except ValueError:
                continue
        
        return date_str
    
    def generate_output(self, posts, output_file, original_filename):
        """指定形式でテキストファイルを生成"""
        try:
            with open(output_file, 'w', encoding='utf-8') as f:
                for i, post in enumerate(posts):
                    if i > 0:
                        f.write('\n\n')
                    
                    # ファイル名を設定
                    post['filename'] = original_filename
                    
                    f.write("--- [記事開始] ---\n")
                    f.write(f"ブログ名: {post['blog_title']}\n")
                    f.write(f"元ファイル名: {post['filename']}\n")
                    f.write(f"記事タイトル: {post['title']}\n")
                    f.write(f"公開日: {post['pub_date']}\n")
                    f.write(f"投稿タイプ: {post['post_type']}\n")
                    f.write(f"ステータス: {post['status']}\n")
                    f.write(f"著者: {post['author']}\n")
                    f.write("--- [記事本文開始] ---\n")
                    f.write(f"{post['content']}\n")
                    f.write("--- [記事本文終了] ---\n")
            
            print(f"✓ {len(posts)}件の記事を {output_file} に出力しました")
            
        except Exception as e:
            print(f"ファイル出力エラー: {e}")
            raise
    
    def convert(self, input_file, output_file=None):
        """メイン変換処理"""
        if not os.path.exists(input_file):
            raise FileNotFoundError(f"入力ファイルが見つかりません: {input_file}")
        
        if output_file is None:
            base_name = os.path.splitext(os.path.basename(input_file))[0]
            output_file = f"{base_name}_converted.txt"
        
        print(f"XMLファイルを解析中: {input_file}")
        
        try:
            # ファイルをクリーニング
            temp_file, content = self.clean_xml_file(input_file)
            
            # 手動抽出を実行
            posts = self.extract_items_manually(content)
            
            print(f"抽出された記事数: {len(posts)}")
            
            if posts:
                original_filename = os.path.basename(input_file)
                self.generate_output(posts, output_file, original_filename)
                print(f"✓ 変換完了: {output_file}")
            else:
                print("⚠ 公開記事が見つかりませんでした")
            
            # 一時ファイルを削除
            try:
                os.remove(temp_file)
                print("✓ 一時ファイルを削除")
            except:
                pass
                
        except Exception as e:
            print(f"❌ 変換中にエラーが発生しました: {e}")
            import traceback
            print("詳細なエラー情報:")
            traceback.print_exc()

def main():
    parser = argparse.ArgumentParser(description='WordPress XMLファイルをテキストファイルに変換(改良版)')
    parser.add_argument('input_file', help='入力XMLファイルのパス')
    parser.add_argument('-o', '--output', help='出力テキストファイルのパス')
    
    args = parser.parse_args()
    
    converter = WordPressXMLConverter()
    
    try:
        converter.convert(args.input_file, args.output)
    except Exception as e:
        print(f"❌ エラー: {e}")
        sys.exit(1)

if __name__ == "__main__":
    if len(sys.argv) == 1:
        print("=== WordPress XML変換ツール(改良版)===")
        print("XMLパースエラーに対応した寛容なバージョンです")
        print()
        print("使用例:")
        print("python wordpress_converter.py input.xml")
        print("python wordpress_converter.py input.xml -o output.txt")
        print()
        print("対話的モードで実行...")
        
        input_file = input("XMLファイルのパスを入力してください: ").strip()
        if input_file.startswith('"') and input_file.endswith('"'):
            input_file = input_file[1:-1]
            
        output_file = input("出力ファイル名を入力してください(空白でデフォルト): ").strip()
        
        if not output_file:
            output_file = None
            
        converter = WordPressXMLConverter()
        converter.convert(input_file, output_file)
    else:
        main()

一応これを「convert.py」と名付けて保存し、「Python convert.py」または「Python3 convert.py」で対話式でxmlをテキストに変換できます。
 対話式が面倒臭い場合「python3 convert.py input.xml -o output.txt」(input.xmlとoutput.txtは適宜名前を変えて下さい)で一気にテキスト化できます。
 一応僕の環境が吐き出したxmlが30MBを超えたりするので大きなxmlで起こりがちなエラーは自動訂正、noteもWordPress形式でエクスポートって言ってるんですが実はxmlが1行でできているので普通に処理するとエラーを吐くので1行でできているxmlにも対応させています。

これでできたテキストはNotebookLMでブログの記事として扱われるので

これで作られたテキストはNotebookLMでブログの記事として扱われるので、「ブログ全部確認してこんな記事抜き出して」とか「僕のブログに悪い傾向はないですか」とか聞きやすくなります。
 まあ2000件、1200件を読ませる事になるので物凄く時間を食うんですが、トップページだけ見て内容を精査したつもりになられるよりよっぽどましなので、このツールは使い方含めて翔也ガジェットブログに掲載したいと思います。

まあ余裕があればですけどね!

今ちょっと問題の渦中と言うか、サービス提供会社1社、丁寧な対応をしてくれるカード会社、会員の調査依頼権を一蹴してお客様相談室でも対応拒否してきたのでこれから金融監督庁にタレ込みを入れないといけないカード会社、第三者として挟まってくれるはずがこっちの話を聞かずに自分の判断だけで突っ走って結局対応拒否にあいにっちもさっちもいかなくなっている消費生活センターの担当者さんに挟まれて幸せの絶頂なんですが、1件1件ツッコミどころ満載すぎてツッコミが間に合わず、そしてツッコミが的確すぎて初手から警戒されてあっという間に対応拒否になっているのでどうしようもない会社ばかりだな、と思っており、しかし僕は全件わりと「決定的な証拠」を握っているので、まず証拠を突きつけて認めるか、認めないなら弁護士行き、証拠は揃っているので費用倒れのない成功報酬制の弁護士さんを使ってブチのめしてやろうと思っていまして(基本的には僕は温厚なんですが、こちらが誠実に対応している時に不誠実な対応を繰り返したあげくに対応拒否では流石に怒ります)、あともちろん許す気は全く無いので監督省庁には全部通報します。特に悪質案件として。

それはそうと、ブログ内容をまとめてNotebookLMになにか尋ねたい時はこのスクリプトが役に立つと思うので、Pythonのインストールの仕方が分かる人は是非今日からご活用下さい。

ではでは。

この記事を書いた人 Wrote this article

devildaredevil 男性

 ガジェットとAppleとTRPGが大好きな中年男です。文章をとにかく書くのが好きなので毎日のように色々なブログで文章を打ちまくっています。もし何か心に引っかかるものがあれば私のTwitterをフォローして頂けると更新情報が流れます。