web.MakeSiteMap のソースコード

"""
概要: ウェブサイトのHTMLサイトマップツリービューを生成するスクリプト。

詳細説明:
このスクリプトは、指定されたルートURLからウェブサイトを再帰的にクロールし、発見されたリンクから
HTML形式の階層的なサイトマップツリービューを生成します。
コマンドライン引数でルートURL、出力ファイル名、ファイル除外設定、および特定のパスパターンに基づいて
URLを除外する機能も持ちます。検出された各ページのタイトルと最終更新日もサイトマップに含められます。

関連リンク: :doc:`MakeSiteMap_usage`
"""
import sys
import re
import requests
from urllib.parse import urljoin, urlparse, urlunparse
import charset_normalizer
from datetime import datetime

# デフォルト設定値
RootURL = "http://d2mate.mdxes.iir.isct.ac.jp/D2MatE/D2MatE_programs.html?page=statistcs"
outpath = "sitemap.html" # 出力ファイルをHTMLに変更
EXCLUDE_FILES = True 
# 一般的なファイル拡張子のリスト(必要に応じて追加/調整可能)
# .html / .shtml は上記で既に許可されている
exclude_file_extensions = [
            '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', 
            '.zip', '.rar', '.tar', '.gz', 
            '.jpg', '.jpeg', '.png', '.gif', '.svg', '.ico', '.webp',
            '.mp4', '.mp3', '.mov', '.avi', '.wav',
            '.xml', '.txt', '.md', '.json', '.csv', '.ini', '.list', '.prm', '.cfg',
            '.cif',
            '.py', '.pm', '.pl', '.c', '.cpp', '.js', '.f', '.f90', '.f77', '.bas', '.sh', '.csh', '.bat', '.lib',
        ]

# 新規追加: 除外したいパスパターンのリスト (正規表現)
EXCLUDE_PATHS = [
    r".*/__pycache__/",
    r".*/download/",
    r".*/database/",
    r".*/db/",
]

# EXCLUDE_PATHSを正規表現オブジェクトにコンパイルしておく(効率化のため)
COMPILED_EXCLUDE_PATHS = [re.compile(p) for p in EXCLUDE_PATHS]


argv = sys.argv
nargs = len(argv)
if nargs > 1:
    RootURL = argv[1]
if nargs > 2:
    outpath = argv[2]
if nargs > 3:
    EXCLUDE_FILES = argv[3].lower() in ('true', '1', 't')

base_domain = urlparse(RootURL).netloc

print()
print("Crawling and generating HTML Sitemap Tree View...")
print()
print(f"usage: python {argv[0]} [RootURL] [OutputFile] [ExcludeFiles(True/False)]")
print()
print(f"RootURL: {RootURL}")
print(f"Output File: {outpath}")
print(f"Base Domain: {base_domain}")
print(f"Exclude Files: {EXCLUDE_FILES}")
print(f"Exclude Path Patterns: {EXCLUDE_PATHS}") # 新しい除外パターンを表示
print()

[ドキュメント] def clean_html_content(html_content): """ 概要: HTMLコンテンツから改行文字を削除し、前後の空白をトリムする。 詳細説明: 与えられたHTML文字列内のすべての改行文字をスペースに置換し、 結果として生じる文字列の前後の空白を削除します。 :param html_content: str: 処理するHTML文字列。 :returns: str: 改行とトリムが適用されたHTML文字列。 """ return " ".join(html_content.split())
[ドキュメント] def get_page_title(html_content): """ 概要: HTMLコンテンツ内から<title>を抽出する。 詳細説明: 与えられたHTMLコンテンツから`<title>`タグの内容を正規表現で検索し抽出します。 大文字・小文字を区別せず検索し、見つかった場合はその内容をトリムして返します。 `<title>`タグが見つからない場合は「No Title」を返します。 :param html_content: str: タイトルを抽出するHTML文字列。 :returns: str: 抽出されたページのタイトル、または「No Title」。 """ title_match = re.search(r"<title>(.*?)</title>", html_content, re.IGNORECASE) if title_match: return title_match.group(1).strip() return "No Title"
[ドキュメント] def is_target_url(url): """ 概要: URLがサイトマップに含めるべき対象かどうかを判断する。 詳細説明: URLがHTMLファイル(.htmlまたは.shtml)であるか、ファイル名がない(ディレクトリパス)である場合、 またはパスもクエリも存在しない場合はTrueを返します。 `EXCLUDE_FILES`が`True`に設定されている場合、`exclude_file_extensions`リストに定義された 一般的なファイル拡張子を持つURLは対象外とします。 :param url: str: 判定するURL。 :returns: bool: 対象のURLであればTrue、そうでなければFalse。 """ parsed_url = urlparse(url) path = parsed_url.path # 1. 基本的なHTMLとディレクトリパスを許可 if path.endswith(".html") or path.endswith(".shtml") or path.endswith("/"): return True # 2. パスが空(ルートドメインなど)で、クエリがない場合はHTMLと見なす if not path and not parsed_url.query: return True # 3. EXCLUDE_FILESがTrueの場合、ファイル拡張子をチェックして排除 if EXCLUDE_FILES: # パスに拡張子が含まれていないかチェック # パスにドットが含まれていても、最後のセグメントが拡張子リストにマッチしなければOK if '.' in path: last_segment = path.split('/')[-1] if any(last_segment.lower().endswith(ext) for ext in exclude_file_extensions): return False # ファイル拡張子にマッチした場合、除外 # 4. それ以外は許可 return True
[ドキュメント] def is_excluded_path(url): """ 概要: URLパスが定義された除外パターンにマッチするかどうかをチェックする。 詳細説明: `COMPILED_EXCLUDE_PATHS`グローバル変数に定義された正規表現パターンに対して、 与えられたURLのパス部分(クエリパラメータを含む場合あり)がマッチするかどうかを検査します。 パスが'/'で始まっていない場合、正規表現とのマッチングのために先頭に'/'が追加されます。 :param url: str: チェックするURL。 :returns: bool: 除外パターンにマッチすればTrue、そうでなければFalse。 """ parsed_url = urlparse(url) # クエリパラメータも除外対象に含めるため、pathとqueryを結合 path_with_query = parsed_url.path if parsed_url.query: path_with_query += f"?{parsed_url.query}" # パスが/で始まっていない場合は先頭に追加(正規表現とのマッチングのため) if not path_with_query.startswith('/'): path_with_query = '/' + path_with_query for pattern in COMPILED_EXCLUDE_PATHS: if pattern.search(path_with_query): return True return False
[ドキュメント] def normalize_url(url): """ 概要: URLを標準化し、フラグメント(#以降)を削除する。 詳細説明: `urllib.parse`モジュールを使用してURLを解析し、URLのフラグメント部分 ('#'に続く部分)を除去した新しいURLを再構築します。 これにより、同じコンテンツを指すがフラグメントが異なるURLが重複して扱われるのを防ぎます。 クエリパラメータは保持されます。 :param url: str: 標準化するURL。 :returns: str: フラグメントが削除された標準化されたURL。 """ parsed_url = urlparse(url) normalized = urlunparse(parsed_url._replace(fragment="")) # フラグメントを削除 return normalized
[ドキュメント] def find_urls_in_html(html_content, base_url): """ 概要: HTMLコンテンツからリンクを抽出し、絶対URLに変換して返す。 詳細説明: 与えられたHTMLコンテンツから、`href`, `src`, `url()`パターンにマッチするURLを 正規表現を使って抽出します。 抽出された相対URLは`base_url`を基に絶対URLに変換されます。 メーリングリスト (`mailto:`), 電話番号 (`tel:`), ページの内部アンカー (`#`), CSSファイル (`.css`), JavaScriptファイル (`.js`) などの特定の種類のURLは除外されます。 さらに、`is_target_url`関数を使って、サイトマップに含めるべきファイルタイプ (例: HTMLファイルやディレクトリパス)のURLのみを結果に含めます。 :param html_content: str: リンクを抽出するHTML文字列。 :param base_url: str: 相対URLを絶対URLに変換するための基準となるURL。 :returns: set[str]: 抽出され、フィルタリングされた絶対URLのセット。 """ urls = set() # href, src, url()パターンをまとめて検索 pattern = re.compile(r"url:\s*[\"']([^\"']+)[\"']|href=[\"']([^\"']+)[\"']|src=[\"']([^\"']+)[\"']") matches = pattern.findall(html_content) for match in matches: # 見つかったURLのうち、Noneでない最初の要素を取得 url = match[0] or match[1] or match[2] if url.startswith("#") or url.startswith("mailto:") or url.startswith("tel:") or url.endswith(".css") or url.endswith(".js"): continue if url.startswith("/"): full_url = urljoin(base_url, url) elif not url.startswith("http"): full_url = urljoin(base_url, url) else: full_url = url # is_target_urlでファイルの除外をチェック if is_target_url(full_url): urls.add(full_url) return urls
[ドキュメント] def detect_encoding(response_content): """ 概要: レスポンスコンテンツから文字コードを判別する。 詳細説明: `charset_normalizer`ライブラリを使用して、HTTPレスポンスのバイト列コンテンツの 文字コードを自動的に推測します。 :param response_content: bytes: 文字コードを判別するバイト列コンテンツ。 :returns: str: 判別された文字コード名。 """ result = charset_normalizer.detect(response_content) return result['encoding']
[ドキュメント] def crawl_recursive(start_url, visited, base_domain, parent=""): """ 概要: 指定されたURLからウェブサイトを再帰的にクロールし、検出されたページのメタデータを収集する。 詳細説明: この関数は、ウェブサイトを深さ優先で再帰的にクロールします。 - URLを標準化し、すでに訪問済みのURLや`is_excluded_path`で除外対象と判断されたURLはスキップします。 - `requests`ライブラリを使用してURLをフェッチし、HTTPステータスコードが200でない場合はエラーとして扱います。 - `detect_encoding`で文字コードを判別し、コンテンツを適切にデコードします。 - HTTPレスポンスヘッダーの`Last-Modified`からページの最終更新日を取得し、ISOフォーマットに変換します。 - `get_page_title`でページのタイトルを抽出します。 - `find_urls_in_html`で現在のページ内のすべてのリンクを抽出し、`base_domain`と一致する内部リンクのみをフィルタリングします。 - 検出された子URLに対して再帰的に自身を呼び出し、すべてのURLとそのメタデータを収集します。 :param start_url: str: クロールを開始するURL。 :param visited: set[str]: 訪問済みの標準化されたURLを追跡するためのセット。 :param base_domain: str: クロール対象のベースドメイン(異なるドメインへのリンクは追跡しない)。 :param parent: str: (オプション) 現在のURLをリンクしている親URL。デバッグやトレースのために使用されます。 :returns: dict[str, dict[str, str | None]]: URLをキーとし、タイトルと最終更新日を含む辞書。 """ normalized_url = normalize_url(start_url) # 新規追加: 除外パスにマッチする場合、即座にクロールを中断 if is_excluded_path(start_url): print(f"Skipping (Excluded Path): {start_url}") return {} # クエリパラメータを含めた正規化されたURLで訪問済みチェック if normalized_url in visited: return {} print(f"Crawling: {start_url}") visited.add(normalized_url) try: response = requests.get(start_url) if response.status_code != 200: print(f"Failed to fetch: {start_url} in {parent} (Status: {response.status_code})") return {} # 文字コード判別 encoding = detect_encoding(response.content) decoded_content = response.content.decode(encoding, errors='replace') print(f" Encoding: {encoding}") # Last-Modifiedヘッダーを取得 lastmod = response.headers.get("Last-Modified") if lastmod: try: # HTTPの日付フォーマットをISOフォーマットに変換 lastmod = datetime.strptime(lastmod, "%a, %d %b %Y %H:%M:%S %Z").strftime("%Y-%m-%d") print(f" lastmod: {lastmod}") except ValueError: lastmod = None # パースエラーの場合はNone else: lastmod = None except Exception as e: print(f"Error fetching {start_url}: {e}") return {} html_content = clean_html_content(decoded_content) title = get_page_title(html_content) print(f" title: {title}") found_urls = find_urls_in_html(html_content, start_url) # ベースドメインが一致するURLのみにフィルタリング filtered_urls = {url for url in found_urls if urlparse(url).netloc == base_domain} # 現在のURLのメタデータを追加 all_urls = {start_url: {"title": title, "lastmod": lastmod}} # 子URLを再帰的にクロール for url in filtered_urls: all_urls.update(crawl_recursive(url, visited, base_domain, parent=start_url)) return all_urls
[ドキュメント] def build_hierarchy_dict(urls_with_metadata, base_domain): """ 概要: フラットなURLリストから、パスに基づいた階層的な辞書構造を構築する。 詳細説明: クロールによって収集されたURLとそれに対応するメタデータ(タイトル、最終更新日)のフラットな辞書を受け取り、 URLのパスセグメントを基に、ファイルシステムのようなネストされた辞書構造を作成します。 これにより、サイトマップツリービューのレンダリングに適した階層データが準備されます。 クエリパラメータもパスの一部として扱われ、一意のURLがノードとして保持されます。 ルートURLのメタデータは、ツリー構造の最上位ノードとして特別に扱われます。 :param urls_with_metadata: dict[str, dict[str, str | None]]: URLとそれに対応するメタデータ(タイトル、最終更新日)の辞書。 :param base_domain: str: サイトマップを構築する対象のベースドメイン。 :returns: dict: 階層的に整理されたサイトマップデータ構造。 """ # 階層のルートとして、ベースドメインのノードを初期化 root_node = {"__children": {}} # データをURLでソートし、安定したツリーの順序を確保 sorted_urls = sorted(urls_with_metadata.items(), key=lambda x: x[0]) for url, metadata in sorted_urls: parsed = urlparse(url) if parsed.netloc != base_domain: continue path = parsed.path # クエリパラメータもパスの一部として扱う path_with_query = path if parsed.query: path_with_query += f"?{parsed.query}" # パスのセグメントに分割 (先頭/末尾の'/'は無視) segments = [s for s in path_with_query.split('/') if s] current_level = root_node["__children"] # ルートURL (例: http://domain.com/) の処理 if not segments or (not segments and not parsed.query): # ルートURL自体をルートノードのメタデータとして保存 root_node.update({ "url": url, "title": metadata.get("title", "No Title"), "lastmod": metadata.get("lastmod", "N/A"), }) continue for i, segment in enumerate(segments): is_last_segment = (i == len(segments) - 1) if is_last_segment: # 最終セグメントはメタデータ(URL自体)を保持 # キーはURL全体(一意性を確保するため) current_level[url] = { "title": metadata.get("title", "No Title"), "lastmod": metadata.get("lastmod", "N/A"), "url": url } else: # 途中のセグメントはネストされた辞書(子ノード)を保持 current_level = current_level.setdefault(segment, {"__children": {}}) current_level = current_level["__children"] return root_node
[ドキュメント] def render_tree(data, is_root=False): """ 概要: 階層的な辞書構造からHTMLのツリー構造を再帰的に生成する。 詳細説明: `build_hierarchy_dict`関数によって構築された階層的なデータ構造を受け取り、 それをHTMLの`details`/`summary`タグと`ul`/`li`タグを組み合わせたツリービューとしてレンダリングします。 ディレクトリに相当するノードは`<details>`タグでラップされ、初期状態で開いた状態(`open`属性)で表示されます。 ファイル(URL)に相当するノードは`<li>`タグ内にリンク、タイトル、URLパス、最終更新日が表示されます。 出力されるHTMLには、Tailwind CSSクラスが適用され、モダンなデザインのツリービューを提供します。 :param data: dict: 階層的なサイトマップデータ構造。 :param is_root: bool: (オプション) 現在処理中のノードがツリーのルートであるかを示すフラグ。デフォルトはFalse。 :returns: str: 生成されたHTML文字列。 """ html_content = "" # ノードをキー(セグメント名またはURL)でソート sorted_keys = sorted(data.keys()) for key in sorted_keys: item = data[key] if "__children" in item: # これはディレクトリ/パスのノード segment_name = key # ディレクトリノードをdetailsでラップし、open属性で展開 html_content += f'<details open class="group my-1 ml-4 border-l border-indigo-300 pl-3">' html_content += f'<summary class="cursor-pointer font-semibold text-indigo-700 hover:text-indigo-900 list-none flex items-center">' # 展開/折りたたみを示すSVGアイコン html_content += f'<svg class="w-4 h-4 mr-2 group-open:rotate-90 transition-transform text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>' html_content += f'{segment_name}/' html_content += '</summary>' # 子ノードを ul で再帰的に処理 html_content += '<ul class="mt-1">' html_content += render_tree(item["__children"], is_root=False) html_content += '</ul>' html_content += '</details>' else: # これはURL(リーフ)ノード url_data = item display_url = url_data['url'] title = url_data['title'] lastmod = url_data['lastmod'] # URLとメタデータを li で表示 html_content += f'<li class="mb-2 ml-4 p-2 bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow">' html_content += f'<a href="{display_url}" target="_blank" class="text-blue-600 hover:text-blue-800 font-medium break-all">' html_content += f'{title}' # クエリパラメータを含む場合、URLのパス部分を表示 path_part = urlparse(display_url).path query_part = urlparse(display_url).query if query_part: path_part += f"?{query_part}" html_content += f'<span class="text-sm text-gray-500 ml-2">({path_part})</span>' html_content += '</a>' if lastmod and lastmod != "N/A": html_content += f'<p class="text-xs text-gray-400 mt-1">最終更新日: {lastmod}</p>' html_content += '</li>' return html_content
[ドキュメント] def generate_html_sitemap_tree(urls_with_metadata, root_url, output_file): """ 概要: クローリング結果から階層的なHTMLサイトマップファイルを生成し、指定されたファイルパスに保存する。 詳細説明: `crawl_recursive`関数によって収集されたURLとメタデータの辞書を受け取り、 `build_hierarchy_dict`関数でこのデータを階層的な構造に変換します。 次に、`render_tree`関数を呼び出して、その階層構造をHTMLツリービューとしてレンダリングします。 生成されたHTMLコンテンツは、Tailwind CSSを含む事前定義されたHTMLテンプレートに埋め込まれ、 最終的なHTMLサイトマップファイルとして`output_file`に保存されます。 :param urls_with_metadata: dict[str, dict[str, str | None]]: クロールによって収集されたURLとメタデータの辞書。 :param root_url: str: クロールの起点となったルートURL。サイトマップの基点として表示されます。 :param output_file: str: サイトマップを保存するHTMLファイルのパス。 :returns: None """ base_domain = urlparse(root_url).netloc # 階層構造を構築 tree_data = build_hierarchy_dict(urls_with_metadata, base_domain) # ルートURL(ドメイン)のメタデータを取得 root_title = tree_data.get("title", f"Root: {base_domain}") root_lastmod = tree_data.get("lastmod", "N/A") root_url_link = tree_data.get("url", f"http://{base_domain}/") # ツリーのHTMLコンテンツをレンダリング tree_html = render_tree(tree_data["__children"], is_root=True) html_template = f""" <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>サイトマップツリービュー - {base_domain}</title> <script src="https://cdn.tailwindcss.com"></script> <style> body {{ font-family: 'Inter', sans-serif; background-color: #f4f7f9; }} /* detailsの展開アイコンをカスタマイズするための非表示 */ summary::-webkit-details-marker, summary::marker {{ display: none; }} </style> </head> <body class="p-4 md:p-8"> <div class="max-w-4xl mx-auto bg-white p-6 md:p-10 rounded-xl shadow-2xl"> <h1 class="text-4xl font-extrabold text-indigo-800 mb-2">サイトマップツリービュー</h1> <p class="text-gray-600 mb-6 border-b pb-4"> クローリングルート: <code class="bg-gray-100 p-1 rounded text-sm text-indigo-600">{root_url}</code> </p> <p class="text-sm text-gray-500 mb-2"> <span class="font-semibold text-gray-700">ファイル除外モード:</span> {'有効' if EXCLUDE_FILES else '無効'} </p> <p class="text-sm text-gray-500 mb-6"> <span class="font-semibold text-gray-700">パス除外パターン:</span> {', '.join(EXCLUDE_PATHS)} </p> <div class="space-y-4 text-gray-800"> <!-- Root Node --> <div class="p-3 bg-indigo-50 border border-indigo-200 rounded-lg shadow-inner"> <a href="{root_url_link}" target="_blank" class="text-lg font-bold text-indigo-700 hover:text-indigo-900"> {root_title} </a> <p class="text-xs text-gray-400 mt-1"> 最終更新日: {root_lastmod} </p> </div> <!-- Tree Structure --> <div id="sitemap-tree" class="pl-2"> <ul class="list-none p-0 m-0"> {tree_html} </ul> </div> </div> </div> </body> </html> """ with open(output_file, "w", encoding="utf-8") as f: f.write(html_template) print(f"HTML Sitemap Tree View has been saved as '{output_file}'")
[ドキュメント] def main(): """ 概要: スクリプトのメイン実行関数。 詳細説明: この関数は、コマンドライン引数を処理し、`RootURL`, `outpath`, `EXCLUDE_FILES` といったグローバル変数を設定します。 次に、`crawl_recursive`関数を呼び出してウェブサイトを再帰的にクロールし、 すべての発見されたURLとそのメタデータを収集します。 最後に、`generate_html_sitemap_tree`関数を呼び出して、 収集したデータから階層的なHTMLサイトマップファイルを生成します。 :returns: None """ visited = set() print(f"Starting crawl from: {RootURL}") all_urls_with_metadata = crawl_recursive(RootURL, visited, base_domain) print("-" * 50) print(f"Found total URLs: {len(all_urls_with_metadata)}") generate_html_sitemap_tree(all_urls_with_metadata, RootURL, outpath) print("-" * 50)
if __name__ == "__main__": main()