Pythonだけで作る!WordPressサイト構造の自動取得ツール

はじめに

ウェブサイトの構造を把握したいと思ったことはありませんか?特にWordPressサイトでは、トップページからどのようなページが繋がっているのか、その階層構造を知ることが作業効率化のカギとなります。この記事では、外部ライブラリに頼らず、Pythonの標準ライブラリだけでWordPressサイトの構造を自動的に取得する方法を紹介します。

解決したい課題

WordPressサイトを管理していると、「トップページから固定ページへの接続がどうなっているのか」を知りたい場面があります。サイトが大きくなるほど、全体像を把握することは難しくなります。手動で確認するのは時間がかかり、ミスも生じやすいものです。

これは家の間取り図がないまま模様替えをするようなもの。全体像を把握できないと、改善すべき点も見えてきません。

Python標準ライブラリでの挑戦

最初に思いつくのは、「Requests」や「BeautifulSoup」などの外部ライブラリを使う方法です。しかし、環境によってはこれらのライブラリをインストールできない場合もあります。そこで今回は、Pythonに最初から備わっている標準ライブラリだけで挑戦してみました。

基本的な考え方

サイト構造を取得する基本的な流れは次の通りです:

  1. トップページのHTMLを取得する
  2. HTML内のメニューリンクを抽出する
  3. 各リンク先のページも同様に処理する(再帰的に)
  4. 結果を階層構造として整理する

これは、未知の迷路を探索するように、一つの部屋から繋がる全ての部屋を調べていく作業に似ています。

実装のポイント

1. SSL証明書の問題への対応

最初に直面したのがSSL証明書の検証エラーでした。これは、「https://」で始まるサイトにアクセスする際、セキュリティ証明書が信頼できるものかを確認する仕組みです。開発環境では次のようにして証明書検証をバイパスしました:

context = ssl._create_unverified_context()
with urllib.request.urlopen(req, context=context, timeout=10) as response:
    # 処理
Code language: PHP (php)

2. HTMLからメニューリンクを抽出する工夫

WordPressサイトのナビゲーションメニューは様々な形で実装されています。代表的なパターンを正規表現で検索することで対応しました:

nav_patterns = [
    r'<nav[^>]*>(.*?)</nav>',
    r'<div[^>]*class=["\']menu["\'][^>]*>(.*?)</div>',
    # その他のパターン
]
Code language: HTML, XML (xml)

これは、暗い部屋の中で懐中電灯を持ちながら、壁のスイッチを探すようなものです。どんな形のスイッチがあるか分からないけれど、よくありそうな場所を順に照らしていきます。

3. 不要なリンクの除外

ブログ記事や管理ページなど、固定ページ以外のリンクを除外する処理も重要です:

if (link and title and
    '/category/' not in link and '/tag/' not in link and
    '/20' not in link and '#' not in link and
    '/wp-admin/' not in link):
    # 処理
Code language: PHP (php)

4. 重複リンクの管理

サイト内の同じページが複数の場所からリンクされていることは珍しくありません。重複して処理しないよう、訪問済みのURLと構造に追加済みのURLを別々に管理します:

if link not in structure_links and link.startswith(base_url):
    # 処理
    structure_links.add(link)  # 構造に追加したリンクを記録
Code language: CSS (css)

完成したスクリプト

いくつかの試行錯誤を経て、次のようなスクリプトが完成しました。以下は主な機能の概要です:

  1. URLを入力として受け取る
  2. 最大階層の深さを指定できる
  3. SSL証明書の検証エラーを自動的に処理
  4. 重複リンクを除外
  5. 結果をファイルに保存する

このスクリプトはIDLEなどの標準的なPython環境で簡単に実行できます。

import urllib.request
import urllib.parse
import re
import html
import time
import os
import ssl

def get_page_content(url):
    """URLからページの内容を取得する(SSL検証エラーをバイパス)"""
    try:
        # SSL証明書の検証を無効にする
        context = ssl._create_unverified_context()
        
        headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}
        req = urllib.request.Request(url, headers=headers)
        
        with urllib.request.urlopen(req, context=context, timeout=10) as response:
            return response.read().decode('utf-8', errors='ignore')
    except Exception as e:
        print(f"エラー: {url} - {e}")
        return ""

def extract_menu_links(html_content, base_url):
    """HTMLからメニューリンクを抽出する"""
    links = []
    
    # ナビゲーションメニューを探す(一般的なパターン)
    nav_patterns = [
        r'<nav[^>]*>(.*?)</nav>',
        r'<div[^>]*class=["\']menu["\'][^>]*>(.*?)</div>',
        r'<ul[^>]*class=["\']menu["\'][^>]*>(.*?)</ul>',
        r'<div[^>]*id=["\']menu["\'][^>]*>(.*?)</div>',
        r'<header[^>]*>(.*?)</header>'
    ]
    
    menu_content = ""
    for pattern in nav_patterns:
        matches = re.findall(pattern, html_content, re.DOTALL)
        menu_content += ' '.join(matches)
    
    # リンクを抽出
    link_pattern = r'<a[^>]*href=["\']([^"\']*)["\'][^>]*>(.*?)</a>'
    raw_links = re.findall(link_pattern, html_content, re.DOTALL)
    
    for link, title_html in raw_links:
        # タイトルからHTMLタグを削除
        title = re.sub(r'<[^>]*>', '', title_html).strip()
        title = html.unescape(title)
        
        # 相対URLを絶対URLに変換
        if link and not link.startswith(('http://', 'https://')):
            if link.startswith('/'):
                link = base_url.rstrip('/') + link
            else:
                link = base_url.rstrip('/') + '/' + link
        
        # 不要なリンクを除外
        if (link and title and
            '/category/' not in link and '/tag/' not in link and
            '/20' not in link and '#' not in link and
            '/wp-admin/' not in link and '/feed/' not in link and
            '/wp-content/' not in link):
            # 外部リンクかどうかをチェック
            if link.startswith(base_url):
                links.append((title, link))
    
    # 重複を削除
    unique_links = []
    seen_links = set()
    for title, link in links:
        if link not in seen_links:
            seen_links.add(link)
            unique_links.append((title, link))
    
    return unique_links

def get_page_structure(url, base_url, visited=None, structure_links=None, depth=0, max_depth=2):
    """再帰的にページ構造を取得する"""
    if visited is None:
        visited = set()
    if structure_links is None:
        structure_links = set()  # 構造リストに追加されたリンクを記録
    
    if url in visited or not url.startswith(base_url) or depth > max_depth:
        return []
    
    print(f"チェック中: {url}")
    visited.add(url)
    structure = []
    
    html_content = get_page_content(url)
    if not html_content:
        return []
    
    links = extract_menu_links(html_content, base_url)
    
    for title, link in links:
        # チェック済みのURLと外部URL、すでに構造に追加されたリンクを除外
        if link not in structure_links and link.startswith(base_url):
            page_info = {'title': title, 'url': link, 'children': []}
            structure_links.add(link)  # 構造に追加したリンクを記録
            
            # 再帰的に子ページを取得
            if depth < max_depth:
                # 連続アクセスを避けるための短い待機
                time.sleep(1)
                children = get_page_structure(link, base_url, visited, structure_links, depth + 1, max_depth)
                if children:
                    page_info['children'] = children
            
            structure.append(page_info)
    
    return structure

def print_structure(structure, indent=0):
    """構造を整形して表示する"""
    for page in structure:
        print(" " * indent + "- " + page['title'] + f" ({page['url']})")
        print_structure(page['children'], indent + 2)

def save_structure_to_file(structure, filename, indent=0):
    """構造をファイルに保存する"""
    with open(filename, 'w', encoding='utf-8') as f:
        def write_structure(structure, indent):
            for page in structure:
                f.write(" " * indent + "- " + page['title'] + f" ({page['url']})\n")
                write_structure(page['children'], indent + 2)
        write_structure(structure, indent)

def main():
    print("WordPressサイト構造取得ツール(標準ライブラリ版)")
    print("------------------------------------------")
    site_url = input("WordPressサイトのURLを入力してください(https://を含む): ")
    
    # URLの正規化
    if not site_url.startswith(('http://', 'https://')):
        site_url = 'https://' + site_url
    
    # 末尾のスラッシュを削除
    site_url = site_url.rstrip('/')
    
    max_depth = 2
    try:
        depth_input = input("取得する最大階層を入力してください(1-3、デフォルトは2): ")
        if depth_input.strip():
            max_depth = int(depth_input)
            max_depth = max(1, min(max_depth, 3))  # 1から3の範囲に制限
    except ValueError:
        print("有効な数値が入力されませんでした。デフォルトの2を使用します。")
    
    print(f"\n{site_url} の構造を取得しています(最大階層: {max_depth})...\n")
    
    try:
        structure = get_page_structure(site_url, site_url, max_depth=max_depth)
        
        if not structure:
            print("サイト構造を取得できませんでした。URLが正しいか確認してください。")
            alt_url = site_url.replace('https://', 'http://')
            retry = input(f"HTTPSでアクセスできませんでした。HTTPで試しますか? ({alt_url}) [y/n]: ")
            if retry.lower() == 'y':
                print(f"\n{alt_url} で再試行しています...\n")
                structure = get_page_structure(alt_url, alt_url, max_depth=max_depth)
        
        if structure:
            print("\n取得完了!サイト構造は以下の通りです:\n")
            print_structure(structure)
            
            # 結果をファイルに保存
            save_filename = "site_structure.txt"
            save_structure_to_file(structure, save_filename)
            print(f"\n結果を {save_filename} に保存しました。")
            print(f"ファイルの場所: {os.path.abspath(save_filename)}")
        else:
            print("サイト構造を取得できませんでした。別のURLを試してください。")
            
    except Exception as e:
        print(f"エラーが発生しました: {e}")

if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print("\n処理を中断しました。")
    except Exception as e:
        print(f"予期しないエラーが発生しました: {e}")
        input("Enterキーを押して終了...")
Code language: PHP (php)

使ってみた感想

実際に自分が管理しているWordPressサイトでこのスクリプトを使ってみました。思った以上に速く動作し、数十ページのサイトでも数分で全体構造を取得できました。

- ホーム (https://chiilabo.com/)
  - 通い方と料金 (https://chiilabo.com/plan/)
    - まとめノート (https://chiilabo.com/blog/)
      - よくある質問 (https://chiilabo.com/faq/)
      - お問い合わせ (https://chiilabo.com/contact/)
      - 次のページ (https://chiilabo.com/blog/page/2/)
      - 93 (https://chiilabo.com/blog/page/93/)
      - 冊子テキスト向け (https://chiilabo.com/blog/print-text/)
      - サイトマップ (https://chiilabo.com/sitemap/)
      - 最近更新した記事 (https://chiilabo.com/blog/last-modified/)
      - 急上昇の読みもの (https://chiilabo.com/blog/trend-posts/)
      - 人気の記事 (https://chiilabo.com/blog/popular/)
      - 追記予定の記事 (https://chiilabo.com/blog/need-addtion/)
      - ワンポイント相談 (https://chiilabo.com/plan/remote-rule/)
      - 利用規約・プライバシーポリシー (https://chiilabo.com/contact/privacy-policy/)
      - 特定商取引法に基づく表記 (https://chiilabo.com/contact/trade/)
    - まずは無料体験レッスン。お電話はこちら ▼ 077-572-9078 (https://chiilabo.com/tel:077-572-9078)
    - スマホ教室ちいラボ (https://chiilabo.com)
  - → ちいラボラジオ番組表 (https://chiilabo.com/plan/radio/)
  - サイト内検索 (https://chiilabo.com/search/)Code language: JavaScript (javascript)

生成AIで、このサイトマップから、フローチャートで.SVGにしてみました。

ホーム chiilabo.com/ 通い方と料金 chiilabo.com/plan/ まとめノート chiilabo.com/blog/ サイト内検索 chiilabo.com/search/ よくある質問 chiilabo.com/faq/ お問い合わせ chiilabo.com/contact/ ワンポイント相談 chiilabo.com/plan/remote-rule/ ブログページ ナビゲーション 人気コンテンツ セクション 利用規約・ プライバシーポリシー 特定商取引法に 基づく表記 次のページ 93ページ目 冊子テキスト 人気の記事 急上昇記事 最近の更新 追記予定 ちいラボラジオ番組表 chiilabo.com/plan/radio/ サイトマップ chiilabo.com/sitemap/ スマホ教室ちいラボ chiilabo.com メインページ 主要セクション サブセクション 関連ページ 直接リンク 間接的な関連

サイト設計の見直しに役立ちそうです。

まとめ

Pythonの標準ライブラリだけを使って、WordPressサイトの構造を自動的に取得するツールを開発しました。正規表現を駆使したHTMLの解析、再帰的なリンク探索、重複管理など、Webスクレイピングの基本的なテクニックを組み合わせることで実現しています。

このツールを使うことで、サイト全体の構造が一目で把握でき、コンテンツ管理や改善の参考になるでしょう。標準ライブラリだけでも、意外と多くのことができるものです。