戻る

サイト検索プログラム

2025/04/18

概要

15年以上にわたって、プライベートサイトを運用していると、以前に掲載した記事を参照する必要にせまられることがある。

サイト容量が70GB,html数が2700以上になり探すのも時間がかかる。

WordPressで構築したサイトでは便利なサイト内「検索」機能を簡単に設置できるが、css/htmlで作ったサイトでは自前で構築しなくてはならない。だいぶ前にGoogleがツールを提供していたが使えなくなった、と思っていたがChatGPTによると今でも使えるらしいが広告が表示されるらしい。

今回はChatGPTのサポートで自前でpythonとjavascriptで作ることにした。

プログラムの概要

対象となるフォルダー内の全てのhtmlページから検索用のJSONファイルを作成する。検索するページでは検索用キーワードを入力しJSONファイルから出力する。

JSONファイルは、データをプログラムで扱いやすく整理できるファイル形式。検索スピードを考慮しhtmlの最初から200文字までを格納する。追加機能として、対象となるhtmlは指定した更新日以降のものとする。

JSONファイル作成プログラム

option


# generate_search_data.py # 2025.04.25 最終版 wildcard 付き import os import json import time import datetime import fnmatch from bs4 import BeautifulSoup import chardet BASE_DIR = os.path.dirname(os.path.abspath(__file__)) OUTPUT_JSON = os.path.join(BASE_DIR, "search-data.json1") EXCERPT_LENGTH = 200 # ✅ 対象日付(この日付以降の更新ファイルのみ処理) TARGET_DATE = "2025-04-16" MIN_TIMESTAMP = time.mktime(datetime.datetime.strptime(TARGET_DATE, "%Y-%m-%d").timetuple()) # ✅ 除外フォルダとファイル(ワイルドカード対応) EXCLUDED_DIR_PATTERNS = ['postmail', 'private', 'backup', 'music*', 'test*', 'akasozai*'] EXCLUDED_FILE_PATTERNS = ['index*.html', 'sitemap.html', '*.bak.html', 'search_cons.html', 'aka_archives*.html'] def read_file_with_encoding_detection(filepath): try: with open(filepath, 'rb') as f: raw_data = f.read() result = chardet.detect(raw_data) encoding = result['encoding'] try: return raw_data.decode(encoding or 'utf-8', errors='replace') except Exception as e: print("⚠️ デコード失敗: {} ({}) → {}".format(filepath, encoding, e)) return "" except Exception as e: print("⚠️ ファイルを読み込めませんでした: {} → {}".format(filepath, e)) return "" def extract_info(filepath): html = read_file_with_encoding_detection(filepath) if not html: return None, None soup = BeautifulSoup(html, 'html.parser') title = soup.title.string.strip() if soup.title and soup.title.string else "(タイトルなし)" body = soup.body.get_text(separator=' ', strip=True) if soup.body else "" # ✅ 「戻る」で始まる場合、その語を除外 if body.startswith("戻る"): body = body[len("戻る"):].lstrip() excerpt = body[:EXCERPT_LENGTH] return title, excerpt def is_excluded(name, patterns): return any(fnmatch.fnmatch(name, pattern) for pattern in patterns) def generate_search_data(): first_entry = True count = 0 with open(OUTPUT_JSON, 'w', encoding='utf-8') as out: out.write('[\n') for root, dirs, files in os.walk(BASE_DIR): # ✅ 除外フォルダ名パターンをチェック dirs[:] = [d for d in dirs if not is_excluded(d, EXCLUDED_DIR_PATTERNS)] for filename in files: if not filename.endswith(".html"): continue if is_excluded(filename, EXCLUDED_FILE_PATTERNS): continue filepath = os.path.join(root, filename) if os.path.getmtime(filepath) < MIN_TIMESTAMP: continue rel_path = os.path.relpath(filepath, BASE_DIR).replace(os.sep, "/") title, content = extract_info(filepath) if not title and not content: continue entry = { "title": title, "url": rel_path, "content": content } json_str = json.dumps(entry, ensure_ascii=False, indent=2).encode('utf-8', 'replace').decode('utf-8') if not first_entry: out.write(',\n') out.write(json_str) first_entry = False count += 1 out.write('\n]\n') print("✅ search-data.json を作成しました({} 件)".format(count)) if __name__ == "__main__": generate_search_data()

  • __file__ : Pythonで実行中のスクリプトファイル.pyの場所(パス)を取得する。
  • 記事から抜粋する文字数は200文字まで、
  • open(filepath, 'rb'):'rb' は「読み込みモード(r)かつ バイナリモード(b)」の意味。バイナリファイルを読み込む」という指定でテキストではなく、画像やPDF、エンコーディング未確定のファイルなどを扱う場合に使う。
  • soup = BeautifulSoup(html, 'html.parser') : BeautifulSoup は HTMLやXMLファイルから、特定の要素(タグ、クラス、ID、テキストなど)を効率よく抽出するために使う。
    例 :html 内の特定タグ(例:<a> や <nav>)を除外して本文抽出


「JSONファイル」 [ { "title": "Hitoshi diary I/T info Top", "url": "itinfotop.html", "content": "Top 囲碁日記 名所 博物館 イベント I/T Misc Hitoshi Diary What's new 2025/04/18 ChatGPT - サイト検索プログラム 2025/04/16 Google Search Console・「セキュリティの問題」 2025/04/12 ChatGPT - 「パスワードガイドライン」 2025/04/04 WordPress - トップスライドショー" }, { "title": "ChatGPT - 公園で遊ぶ元気な子供たち", "url": "chatgpt/chatgpt_park.html", "content": "公園で遊ぶ元気な子供たち 2025/04/18 (click:拡大/縮小) 朝の静かな公園 子供たちが集まってきた 一緒に遊ぼう 初めての男の子はこわごわ 夕方お姉さんが迎えにきた お姉さんと一緒に帰る 遊び疲れてお母さんの胸で眠ってしまった子もいる 戻る " }, { "title": "サイト検索", "url": "chatgpt/chatgpt_sitesearch.html", "content": "サイト検索プログラム 2025/04/18 ~ index ~ 概要 プログラムの概要 JSONファイル作成プログラム 検索プログラム 検索結果 感想 概要 WordPressで構築したサイトでは便利なサイト内「検索」機能を簡単に設置できるが、css/htmlで作ったサイトでは自前で構築しなくてはならない。だいぶ前にGoogleがツールを提供していたが使えなくなった、と思っていたがChatGPTに" }, { "title": "Google Search Console セキュリティの問題", "url": "google_report/gsconsole.html", "content": "Google Search Console・ 「セキュリティの問題」 2025/04/14 ~ index ~ 概要 Google Search Console Google からの回答 感想 概要 自分の管理しているウェブサイトがGoogle検索にどのように表示されているかを確認・改善するためのサービスツール Google Search Console にサイトのなかに「セキュリティの問題」があ" }, { "title": "美術館訪問記 - 691 トゥエンテ国立美術館、Enschede", "url": "naganoart/nagano_art_691.html", "content": "美術館訪問記 - 691 トゥエンテ国立美術館、Enschede (* 長野一隆氏メールより。写真画像クリックで原寸表示されます。) 添付1:トゥエンテ国立美術館正面 添付2:トゥエンテ国立美術館前の教会 添付3:トゥエンテ国立美術館内部 添付4:ヤン・トーロップ作 「3人の花嫁」 1893年 クレラー=ミュラー美術館蔵 添付5:ヤン・トーロップ作 「憩う農民の娘」1904年 添付6:ヤン・トー" }, .............................. ]

検索プログラム

<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>サイト内検索</title> <style type="text/css"> <!-- body { font-family: sans-serif; margin: 2em; max-width: 800px; background-color: lavender; } h1 { margin: 0px; } #input_box { margin-left: 3rem; } #searchBox { width: 100%; padding: 0.5em; font-size: 16px; margin-bottom: 1em; } .result { border-bottom: 1px solid #ccc; padding: 0.5em 0; } .result a { font-weight: bold; color: #007acc; text-decoration: none; } .result p { margin: 0.3em 0 0; color: #333; } --> </style> </head> <body> <h4 id="lastModified">ファイルの更新日を取得中...</h4> <div id="input_box"> <h1>サイト内検索 </h1>  <input type="text" id="searchBox" placeholder="キーワードを入力してください" onkeypress="handleKeyPress(event)"> </div> <div id="results"></div> <script> let searchData = []; function handleKeyPress(event) { if (event.key === "Enter") { const keywordInput = event.target.value.toLowerCase().trim(); // キーワードをスペースで分割(半角・全角空白に対応) const keywords = keywordInput.split(/[\s ]+/); const results = searchData.filter(item => { const haystack = (item.title + ' ' + item.content).toLowerCase(); // AND条件:すべてのキーワードが含まれているか? return keywords.every(word => haystack.includes(word)); }); const html = results.map(item => ` <div class="result"> <a href="${item.url}">${item.title}</a> <p>${item.content.slice(0, 100)}...</p> </div> `).join(''); document.getElementById('results').innerHTML = html || '<p>該当する記事は見つかりませんでした。</p>'; } } // search-data.jsonを取得 fetch('search-data.json') .then(response => { // まずヘッダーからLast-Modifiedを取得 const lastModified = response.headers.get('Last-Modified'); if (lastModified) { const formattedDate = new Date(lastModified).toLocaleString(); // ここで画面に表示する(例としてid="lastModified"に書き込み) const lmElement = document.getElementById('lastModified'); if (lmElement) { lmElement.textContent = `最終更新日: ${formattedDate}`; } } // そのあとJSONデータとして読み込む return response.json(); }) .then(data => { // データを使う処理 searchData = data; }) .catch(error => { console.error('エラー:', error); }); </script> </body> </html>

  • search-data.json ファイルの最終更新日を表示する
  • const keywords = keywordInput.split(/[\s ]+/);
    split(...) : 配列を作る
    正規表現 /[\s ]+/ の意味:
    [...] 文字クラス:中のいずれか1文字にマッチする
     \s  英語の 半角スペース・タブ・改行 などの空白文字全般にマッチ
       全角スペース(全角空白) にマッチ
     +  1回以上の繰り返し(連続する空白も1つの区切りとみなす)
    例 : 検索入力 ==> サイト プログラム    keywords = ['サイト', 'プログラム']
  • fetch : 「非同期通信」リクエストを送信してからレスポンスを受け取るまでの間に、他の処理を行うことができる。

JavaScriptを使い始めた頃非同期処理についてよくわからなかった。少し、詳しく説明してもらった。

なぜ fetch と検索がうまく「同期」されているのか? ポイント1:.then() の中でイベント登録しているから fetch() は非同期ですが、.then(data => {...}) の中は「データの取得が完了した後に実行される処理」です。 つまり searchData に正しいデータが代入された後に、検索のイベント処理が設定されるようになっています。 👉 これにより「検索しようとしたのにデータがまだ無い」という状況を自動的に回避しています。 ポイント2:ユーザーが何かを入力するのは常に「後」 ユーザーが入力を始めるのは通常、ページ読み込み → データ取得 → イベント登録 の後。 もし非常に高速でユーザーが先に入力したとしても、入力イベントはまだ addEventListener されていないため、反応しません(安全)。 🔄 もう少しイメージしやすく 処理ステップ 内容 説明 1 fetch() 開始 データ取得開始(非同期) 2 .then() 内部 データ取得成功時に実行 3 searchData = data ここでグローバル変数に保存 4 .addEventListener() ユーザー入力に反応する検索処理を登録 5 ユーザーが検索語を入力 searchData はすでに取得済み ✅ 注意:fetch の完了前に検索イベントが発動しない理由 イベント登録(検索処理)は fetch(...).then(...) の 中 に書かれているため、fetch が終わっていない限り、検索処理自体が存在しないのです。 なので、明示的な同期処理を書かなくても自然と順番が保証されているという仕組みになっています。 🛡️ さらに安全にするなら? 例えば、イベント登録を最初にしてしまった場合は「データが未読み込みで検索エラー」になる恐れがあります。 その場合は防御コードを入れられます: if (!searchData || searchData.length === 0) { return; // データがまだ読み込まれていない場合は何もしない }

検索結果

最終更新日: 2025/4/18 11:37:24 サイト内検索  

薬師池公園 カワセミ
ルリビタキ(町田市薬師池) 戻る ルリビタキ 薬師池公園のルリビタキ 薬師池公園にカワセミをターゲットにして写真を撮っているが、最近は移築した古民家裏手の雑木林に棲息しているルリビタキもターゲットとしている。 スズメ程の大きさで... 薬師池公園便り・新春初鳥(撮り) 戻る 薬師池公園便り・ 新春初鳥(撮り) 2024/01/07 2024年最初の薬師池公園。前日は最高気温が15度Cだったが、この日は最高気温が11度Cだった。午前9時頃は北風が強く真冬の体感温度。 ... 薬師池公園便り・カワセミ家族

戻る 薬師池公園便り ・カワセミ家族 2024/05/29 まもなく2024年の5月も終わる。昨日は曇りだったが湿度が80%近い梅雨間近と思わせるような天気だった。夜に強い風が吹いて翌日昼前頃から湿度... 薬師池公園便り・秋の気配 戻る 薬師池公園便り 秋の気配 2024/09/03 台風10号が九州に数日間停滞した影響で関東地方は大雨に見舞われた。前日は晴れたがまた雨天になった。午後から雨が上がったので久しぶりに薬師池公園へ向... 薬師池公園便り・秋遊び➁ライトアップ 戻る 薬師池公園便り・秋遊び ➁ライトアップ 2024/11/17 薬師池公園の紅葉ライトアップが11月16日の土曜日から始まった。実際は準備ができた数日前からライトアップが見られる。 前日の土曜日は... ................................................

感想

実際の運用を考慮すると、どこの時点で検索用JSONファイルを更新するかが課題となる。サイト全体は70GBを超える容量でhtml数が2700以上存在する。 ローカルでサーバーと同じ容量のフォルダーで保持するには重いので、JSONファイルの作成プログラムをサーバーで実行させようと考えた。

最初はブラウザからリンクで実行させようと思ったがセキュリティの問題があるとのChatGPTの忠告で、これは諦めてコマンドプロンプトからSSHでサーバーにあるプログラムを個別に実行させる方法を考えた。

結局、サーバーでの実行は様々な障害がでて諦めた。例としてサイトのファイルにはパーミッションで読めなくしてあるものがあってこれがエラーになる。これをbypassしたが、最大の問題は資源を使いすぎるためサーバー側で強制終了(KILLED)されてしまう。プログラムで結果を変数に貯めていくロジックだったがメモリーを使いすぎるためこれを小出しにファイルに書き出すように変更したが、他にもcpuやメモリー使用量の制限にひっかかりKILLEDされた。そのうちサーバーへの接続がFTPも出来なくなってしまった。何度もSSHでlog inしたため制限をオーバーしたという。正常にlogoffしなかったのが原因。

最終的に、サイトにあるファイルすべてをローカルpcにダウンロードし、ベースとなる検索用のJSONファイルを作成。以後は定期的にΔ(差分)を追加するオペレーションにした。

以前は検索など考慮しなかったので思いつくまま記事を書いていたが、最近は最初から200文字までの間に将来検索対象となりそうなテキストを概要などに書き込むようにしている。