"""
XMLサイトマップを生成するためのウェブクローラー
概要:
指定されたルートURLからウェブページを再帰的にクロールし、各ページのタイトル、
最終更新日時(Last-Modifiedヘッダーから)、およびURLを含むXMLサイトマップを作成します。
詳細説明:
このスクリプトは、コマンドライン引数で指定された、またはデフォルトのルートURLからクロールを開始します。
HTTPリクエストを送信し、HTMLコンテンツから関連するリンクを抽出します。
抽出されたリンクは同じドメイン内のものであれば再帰的にクロールされます。
各ページについて、<title>タグからタイトルを抽出し、HTTPレスポンスヘッダーの
'Last-Modified'フィールドから最終更新日時を取得します。
収集されたすべてのURLとそれに対応するメタデータ(タイトル、最終更新日時)は、
Sitemapプロトコルに準拠したXMLサイトマップとして整形され、指定されたファイルに出力されます。
クエリパラメータを含むURLも適切に処理され、対象は.html/.shtmlファイルまたはディレクトリパスに限定されます。
関連リンク:
:doc:`XMLSiteMap_usage`
"""
import sys
import re
import requests
from urllib.parse import urljoin, urlparse, urlunparse
from xml.dom.minidom import parseString
import xml.etree.ElementTree as ET
from datetime import datetime
import charset_normalizer
RootURL = "http://d2mate.mdxes.iir.isct.ac.jp/D2MatE/D2MatE_programs.html?page=statistcs"
outpath = "sitemap.xml"
argv = sys.argv
nargs = len(argv)
if nargs > 1:
RootURL = argv[1]
if nargs > 2:
outpath = argv[2]
base_domain = urlparse(RootURL).netloc
print()
print("Crawling and generating XML Sitemap with metadata...")
print()
print(f"usage: python {argv[0]} [RootURL] [OutputFile]")
print()
print(f"RootURL: {RootURL}")
print(f"Output File: {outpath}")
print(f"Base Domain: {base_domain}")
print()
[ドキュメント]
def clean_html_content(html_content):
"""
HTMLコンテンツから余分な空白文字や改行を削除し、整形します。
概要:
HTMLコンテンツから不要な改行やスペースを削除し、前後のスペースをトリムする関数。
詳細説明:
この関数は、HTMLコンテンツ内の複数の連続する空白文字や改行文字を単一のスペースに変換し、
文字列の前後の空白をトリムすることで、コンテンツをきれいにします。
これは、HTMLの解析やテキスト抽出を容易にするために行われます。
:param html_content: 処理対象のHTML文字列。
:type html_content: str
:returns: 整形されたHTML文字列。
:rtype: str
"""
return " ".join(html_content.split())
[ドキュメント]
def get_page_title(html_content):
"""
HTMLコンテンツからページのタイトルを抽出します。
概要:
HTMLコンテンツ内から<title>を抽出する関数。
詳細説明:
正規表現を使用して`<title>`タグの内容を検索し、そのテキストを返します。
タイトルが見つからない場合は、「No Title」を返します。検索は大文字小文字を区別しません。
:param html_content: タイトルを抽出するHTML文字列。
:type html_content: str
:returns: 抽出されたページのタイトル、または「No Title」。
:rtype: str
"""
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がクロールの対象となるHTMLページまたはディレクトリパスであるかを判定します。
概要:
URLがHTMLファイル(*.html)またはファイル名なし(ディレクトリパス)の場合にTrueを返す。
詳細説明:
この関数はURLを解析し、そのパスが`.html`または`.shtml`で終わるか、
あるいはスラッシュ`/`で終わる(ディレクトリを示唆する)場合にTrueを返します。
これにより、画像、CSS、JavaScriptなどの非HTMLリソースがクロール対象から除外されます。
:param url: 判定するURL。
:type url: str
:returns: URLが対象の場合True、それ以外はFalse。
:rtype: bool
"""
parsed_url = urlparse(url)
path = parsed_url.path
return path.endswith(".html") or path.endswith(".shtml") or path.endswith("/")
[ドキュメント]
def normalize_url(url):
"""
URLを標準化し、フラグメント(#以降の部分)を削除します。
概要:
URLを標準化する(クエリパラメータを保持する)関数。
詳細説明:
クエリパラメータは保持しつつ、URLのフラグメント部分(ページ内の特定の位置を示す部分)を
削除することで、同じコンテンツを指す異なるURLが重複して処理されるのを防ぎます。
これにより、サイトマップのURLの重複を避けることができます。
:param url: 標準化するURL。
:type url: str
:returns: フラグメントが削除された標準化されたURL。
:rtype: str
"""
parsed_url = urlparse(url)
normalized = urlunparse(parsed_url._replace(fragment="")) # フラグメントを削除
return normalized
[ドキュメント]
def find_urls_in_html(html_content, base_url):
"""
HTMLコンテンツ内からすべてのリンク(`href`, `src`, `url`属性)を抽出し、絶対URLに変換します。
概要:
HTMLコンテンツからリンクを抽出し、FQDNを生成する関数。
詳細説明:
この関数は、HTMLコンテンツ内のアンカータグの`href`属性、画像タグの`src`属性、
JavaScript内の`url`プロパティなどを正規表現で検索します。
見つかった相対URLは`urljoin`を使用して絶対URLに変換され、クロール対象のURLのみがセットとして返されます。
:param html_content: リンクを抽出するHTML文字列。
:type html_content: str
:param base_url: 相対URLを解決するための基準URL。
:type base_url: str
:returns: 抽出され、標準化された絶対URLのセット。
:rtype: set[str]
"""
urls = set()
pattern = re.compile(r"url:\s*[\"']([^\"']+)[\"']|href=[\"']([^\"']+)[\"']|src=[\"']([^\"']+)[\"']")
matches = pattern.findall(html_content)
for match in matches:
url = match[0] or match[1] or match[2]
if url.startswith("/"):
full_url = urljoin(base_url, url)
elif not url.startswith("http"):
full_url = urljoin(base_url, url)
else:
full_url = url
if is_target_url(full_url):
urls.add(full_url)
return urls
[ドキュメント]
def detect_encoding(response_content):
"""
HTTPレスポンスのバイトコンテンツから最も適切な文字エンコーディングを検出します。
概要:
文字コードを判別する関数。
詳細説明:
`charset_normalizer`ライブラリを利用して、与えられたバイト列の文字コードを自動的に判別します。
これにより、様々なエンコーディングで提供されるウェブページを正しくデコードできます。
:param response_content: HTTPレスポンスのバイト列コンテンツ。
:type response_content: bytes
:returns: 検出された文字エンコーディングの文字列。
:rtype: str
"""
result = charset_normalizer.detect(response_content)
return result['encoding']
[ドキュメント]
def crawl_recursive(start_url, visited, base_domain, parent = ""):
"""
指定された開始URLから再帰的にリンクをたどり、各ページのタイトルと最終更新日時を収集します。
概要:
再帰的にリンクをたどる関数(クエリパラメータ含めた情報の取得対応)。
詳細説明:
1. URLを標準化し、既に訪問済みのURLはスキップします。
2. `requests`ライブラリを使用してURLからコンテンツを取得します。
3. 取得したコンテンツの文字コードを検出し、デコードします。
4. HTTPヘッダーから`Last-Modified`情報を抽出し、YYYY-MM-DD形式にフォーマットします。
5. HTMLコンテンツからページのタイトルと、同じドメイン内の他のリンクを抽出します。
6. 抽出されたリンク(対象URLのみ)に対して再帰的にこの関数を呼び出し、
全ての関連するページ情報を収集します。
:param start_url: クロールを開始するURL。
:type start_url: str
:param visited: 既に訪問済みの正規化されたURLを格納するセット。
:type visited: set
:param base_domain: クロール対象を制限するベースドメイン。このドメイン外のURLはスキップされます。
:type base_domain: str
:param parent: 現在クロール中のURLの親URL(エラーメッセージ用)。デフォルトは空文字列。
:type parent: str
:returns: クロールされた全てのURLと、それに対応するタイトルおよびLast-Modified日時を含む辞書。
エラーが発生した場合は空の辞書を返します。
:rtype: dict[str, dict[str, str | None]]
"""
normalized_url = normalize_url(start_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}")
return {}
# 文字コード判別
encoding = detect_encoding(response.content)
decoded_content = response.content.decode(encoding)
print(f" Encoding: {encoding}")
# Last-Modifiedヘッダーを取得
lastmod = response.headers.get("Last-Modified")
if lastmod:
lastmod = datetime.strptime(lastmod, "%a, %d %b %Y %H:%M:%S %Z").strftime("%Y-%m-%d")
print(f" lastmod: {lastmod}")
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)
filtered_urls = {url for url in found_urls if urlparse(url).netloc == base_domain}
all_urls = {start_url: {"title": title, "lastmod": lastmod}}
for url in filtered_urls:
all_urls.update(crawl_recursive(url, visited, base_domain, parent = start_url))
return all_urls
[ドキュメント]
def main():
"""
XMLサイトマップ生成スクリプトのメイン実行関数です。
概要:
スクリプトの主処理を実行。
詳細説明:
1. 訪問済みURLを記録するためのセットを初期化します。
2. `RootURL`から`crawl_recursive`関数を呼び出してウェブサイトのクロールを開始します。
3. クロールによって見つかった全てのURLとそのメタデータ(タイトル、最終更新日時)をコンソールに出力します。
4. `generate_xml_sitemap_with_metadata`関数を呼び出して、
収集した情報に基づいてXMLサイトマップを生成し、ファイルに保存します。
:param None: 引数なし。
:returns: なし。サイドエフェクトとしてサイトマップファイルが生成されます。
:rtype: None
"""
visited = set()
print(f"Starting crawl from: {RootURL}")
all_urls_with_metadata = crawl_recursive(RootURL, visited, base_domain)
print(f"Found total URLs: {len(all_urls_with_metadata)}")
for idx, (url, metadata) in enumerate(sorted(all_urls_with_metadata.items()), start=1):
print(f" [{idx}] {url} - Title: {metadata['title']} - LastMod: {metadata['lastmod']}")
generate_xml_sitemap_with_metadata(all_urls_with_metadata)
print("XML Sitemap with metadata has been saved as '{outpath}'")
if __name__ == "__main__":
main()