"""
概要: ウェブサイトの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()