日記CGI nicky! ログ保存計画

nicky!

この記事はnicky!を静的ページにして内容を保存することに挑戦しています。古いホームページ作成サイトが失われ、それとともに消えていった貴重かつ有益な情報遺産。その苦い経験を再び味わいたくないので、nicky!でそれらを築いていた方々に届いたらいいなぁと思っています。

まだnicky.cgi使っている人こんなにいるからな。

nicky!とはなんぞや?

nicky!は2002年公開のWeb日記ブログ的なことができるCGIベースのWebアプリで、これを利用して自分も2003年から2010年まで日記をつけていました。記事内でHTMLタグが使えたり、iモードに対応していたり豊富な種類のプラグインがあったりして界隈は賑わっていた記憶があります。

関係ないですがmixiが流行したのと同時期だったので、RSSで記事一覧を飛ばしていたような気がします。

しかし現在では完全にレガシー化してしまっています。

公式サイトもほぼ崩れ去っているみたいです⋯
DiaryCGI nicky!

現状の問題点

自分の日記やメモを見返す目的でたまに個人的に使っていて、敢えて現ブログトップページからリンクを貼らずに設置を続けていました。
ところが古いプログラムであることが災いしてか深刻な問題にさらされていました。

膨大な量のSPAMコメント

いつの頃からか大量のSPAMコメントが投稿されるようになり大変なことになっていました。放置ブログによくあるパターンのやつです。

SPAMに汚染されたコメント欄

対策としてまずは単純にコメント入力フォームを隠しました。
しかしSPAMは投稿され続けます。
そしてついにサーバーをレンタルしているロリポップ様におこらいてしまいました。

本日、お客様のお使いの
[ /cgi-bin/nicky/nicky.cgi ]におきまして、
スパムコメントによりサーバー上で
著しい負荷をかけておりました。

これにより、該当サーバの安定稼動が見込めなくなったため
大変申し訳ございませんが、現在該当スクリプトのパーミッション
を変更させていただいております。

お手数ですが、不要なコメントを削除し
コメントスパム対策のプラグインを導入するもしくは
ご利用ではない場合は、コンテンツの削除など
ご対応いただきますようお願い申し上げます。

当然ipchk.cgiなどIPスパムフィルタープラグインも導入していたはずなのですが効果はなし。

ところでnicky!の記事は年単位でフォルダ化され、中に記事ファイル(*.nky)とコメント(*.cmnt)ファイルと画像ファイルが格納されています。
↓みたいな感じ。

/cgi-bin
└nicky
 ├2003(yyyy)←年ごとのフォルダ
│├0925A.nky(mmdd)←月日+その日何回目の投稿か(A、B、C⋯)
│├0925A.cmnt(mmdd)←↑の投稿についたコメント
│└XXX.jpg
 ├2004
   ⋮

全コメントファイル(*.cmnt)の属性をr–r–r–(444、呼出のみ)にしました。
にもかかわらずSPAMメッセージは増え続けました。

これはCGI経由で直接ファイル生成されている可能性が大きく、ファイル権限で防げる段階を超えていることを意味していました。

DDoS攻撃に利用されていた可能性

複数の海外企業から「Webマスター、あなたのサイトから膨大なアクセスがあるので対応してほしい」と連絡が来ました。

海外サーバーへのDDoS攻撃の踏み台にされてしまっていた可能性が浮上してきました。nicky.cgi自体が外部通信可能な脆弱性があるスクリプトになっていて攻撃対象になっている!?

脆弱性警告

Open Bug Bountyから警告が来ました。ページ切り替えのボタンを書き換えて押すだけで任意のコードが実行できてしまうとのこと。ホワイトハッカーからのセキュリティ脆弱性の警告です。一定期間のうちに直さないと一般に公開されます。

これはひどい。もう全削除すればいいのに。

あっでもせっかくだからログ残せるかどうか実験してみる?

最悪です。このWebマスターは時間と好奇心だけはあるので、全削除する前に内容だけどっかにバックアップできないか方法を考え始めてしまいました。
残したところでこの記事と同じでたいした内容でもなくどちらかと言えば若い頃書き散らした痛い内容ばかりなのに⋯
成功したところで何も得るものがない⋯

考えた手順

  1. コメントファイル(*.cmnt)の中のSPAM投稿を全部削除
  2. 記事ファイル(*.nky)の内容とコメント(*.cmnt)をくっつけて静的ページにする
  3. タイトル一覧ページを作る

よし、やってみよー!

文字エンコードの壁が立ちはだかる(EUC-JP→UTF-8)

FFFTPやWebDAVを使って全ファイルをローカルPCに保存したら、全部文字化けで読めない!

そうです。nicky!はEUC-JPという日本語文字コードで読み書きされていたのです。90年~2000年代にホームページとか作っていた人はShift_JISとかEUCとかあったあった!って言ってくれるかもしれません。
今だとUnicord(UTF-8)に統一されてる感じだからテキストエンコードで苦労することなんてないものね。

FFFTPのバイナリモードでダウンロードします。🅱️って書いてあるアイコンだよ。アスキーモードじゃないよ!

FFFTPバイナリ転送モード

「転送モード: バイナリ」「コード変換: 無変換」でダウンロードできていますか?

転送モード:バイナリ コード変換:無変換

全部指定でダウンロード中にどうしたわけかエラーが出ました。
エラーは全体で数回しか起こらなかったので、ファイル名をメモして一旦「ダウンロードしない」でスキップ、後で失敗したファイルだけを再度ダウンロードしたら大丈夫でした。

/cgi-bin/nicky/yyyy/mmddX.nky がダウンロードできませんでした。

ダウンロードが終わったらバックアップを取っておきましょう。これから加工していくフォルダと別に置いておけば、失敗した時に戻せますからね。ここからはまずはたくさんある記事のうちの一組だけで変換がうまくいくか実験してみて、後で一括処理するといいと思いです。

ネットワーク用漢字コード変換フィルタnkf.exeをダウンロードしてきて
nkfwin.zip解凍→vc2005/win32(98,Me,NT,2000,XP,Vista,7)ISO-2022-JP/nkf.exeを保存します。

以下のconvert_utf8.batを実行します。「nicky」フォルダ内(2003、2004フォルダなどと同じ階層)にnkf.exeとconvert_utf8.batを置いてから実行して下さい。

ダウンロードできるようにしましたが、ご自身で下記コードをテキストファイルにコピペして「*.bat」に改名していただいても構いません(ただし保存時の文字コードは「Shift_JIS」もしくは「ANSI」で)。

@echo off

echo === cmnt変換中 ===
for /r %%i in (*.cmnt) do nkf -w --overwrite "%%i"

echo === nky変換中 ===
for /r %%i in (*.nky) do nkf -w --overwrite "%%i"

echo === EUC-JP to UTF-8 変換完了 ===
pause
EUC-JP to UTF-8 変換完了

完了したら*.nkyと*.cmntの文字化けが直っているかメモ帳などで開いてチェックした後、成功していたらnkf.exeとconvert_utf8.batは削除しても構いません。(多少「」とかが残っていてもUTF-8で読めれば支障ありません。)

SPAMメッセージを分離する(clean)

PowerShellでSPAMだけ分離してみます。

clean.ps1とrun_clean.batを先程と同じ場所に置きます。clean.ps1の文字コードは「UTF-8(BOMあり)」、run_clean.batは「Shift_JIS」もしくは「ANSI」で保存して下さい。(コードが汚くてすまない。あと2010年までの処理になっているので、それ以降の投稿がある場合は書き換えて下さい。)

Get-ChildItem -Recurse -Filter *.cmnt | ForEach-Object {

    Write-Host "処理中:" $_.FullName

    $lines = Get-Content $_.FullName -Encoding UTF8

    $comments = @()
    $current = ""

    # コメント単位にまとめる
    foreach ($l in $lines) {
        $current += $l + "`n"

        if ($l -match '20\d{2}/\d{2}/\d{2}') {
            $comments += $current
            $current = ""
        }
    }

    $keep = @()
    $spam = @()

    foreach ($c in $comments) {

        $isSpam = $false

        # 年
        if ($c -match '20\d{2}/\d{2}/\d{2}') {
            $year = [int]($matches[0].Substring(0,4))
        } else {
            $year = 0
        }

        $urlCount = ([regex]::Matches($c, "http")).Count
        $len = $c.Length
        $jpChars = ([regex]::Matches($c, '[ぁ-んァ-ン一-龥]')).Count

        # 判定
        if ($year -le 2010) {
            $isSpam = $false
        }
        else {
            $isSpam = $true
        }

        if ($isSpam) {
            $spam += $c
        } else {
            $keep += $c
        }
    }

    if ($keep.Count -gt 0) {
        $keep | Set-Content $_.FullName -Encoding UTF8
    } else {
        Remove-Item $_.FullName
    }

    if ($spam.Count -gt 0) {
        $spam | Set-Content ($_.FullName + ".spam") -Encoding UTF8
    }
}

Write-Host "完了!"
@echo off
powershell -NoProfile -ExecutionPolicy Bypass -File clean.ps1
pause

run_clean.batを実行。

SPAM分離完了!

「*.cmnt」が「*.cmnt」と「*.cmnt.spam」に分かれていると思います。かなりの精度で分離できていると思いますが、心配な場合はいくつかSPAMファイルの先頭をチェックして正規のコメントが混入していないか確認してみて下さい。

大丈夫な場合はclean.ps1とrun_clean.bat、全ての「*.cmnt.spam」を削除しても構いません。

私の場合はほとんど全てのSPAMが2013年以降の投稿だった(それまでは対処できていたor自分で地道に消していた)ため、多少対処が楽になりました。

記事とコメントを一体化した静的ページを作る(ついでにいろいろ機能を追加してみる)

ここからは皆様のnicky!のレイアウトや色、文言など様々な違いがあると思いますので、変換コードは項目の最後に貼っていますが、一例として読んでいただければ幸いです。必要ないと思うのでダウンロードはできないようにしてます。

だいぶChatGTPに手伝ってもらいました。「元のページの画像を貼るのでこんな感じで新しいHTMLファイルを作って」というふうにスクリーンショットを投げたり、曖昧な指定からデザインを創造するのは苦手なようなので、きちんと基準となるお手本フォーマットをHTMLファイルで一つ渡すと良いです。コードを読んでくれて最新のHTML+CSS記述に直してくれました。<TABLE>タグでデザインしてた時代もサヨナラだぜ!

(左)オリジナルデータを一つの記事にした切り貼り自作サンプルページ→これをChatGTPに読ませる
(中央)記事とコメントをくっつけてChatGTPが出力した旧タグそのままのデザイン
(右)2026年最新のHTML+CSS記述方法にしてと頼んだ結果

(左)オリジナルデータを一つの記事にした切り貼り自作サンプルページ→これをChatGTPに読ませる
(中央)記事とコメントをくっつけてChatGTPが出力した旧タグそのままのデザイン
(右)2026年最新のHTML+CSS記述方法にしてと頼んだ結果

一つのHTMLファイルで一記事になったので感動!
ここから調子に乗り始めてちょっと改良してみました。

  • 上部にパンくずリストを追加
  • 下部に「←前の記事」「次の記事→」を付けた
param()

[Console]::OutputEncoding = [System.Text.Encoding]::UTF8

$rootDir = "."

function Read-TextAuto($path) {
    $bytes = [System.IO.File]::ReadAllBytes($path)
    try { return [System.Text.Encoding]::UTF8.GetString($bytes) } catch {}
    try { return [System.Text.Encoding]::GetEncoding("euc-jp").GetString($bytes) } catch {}
    return [System.Text.Encoding]::GetEncoding("shift_jis").GetString($bytes)
}

# =========================
# 全記事リスト
# =========================
$allPosts = Get-ChildItem $rootDir -Recurse -Filter "*.nky" |
    Where-Object { $_.Directory.Name -match '^\d{4}$' } |
    Sort-Object { $_.Directory.Name + $_.BaseName }

for ($i = 0; $i -lt $allPosts.Count; $i++) {

    $nky = $allPosts[$i]
    $yearDir = $nky.Directory
    $year = $yearDir.Name

    $baseName = [System.IO.Path]::GetFileNameWithoutExtension($nky.Name)
    $cmntPath = Join-Path $yearDir.FullName ($baseName + ".cmnt")
    $outPath  = Join-Path $yearDir.FullName ($baseName + ".html")

    Write-Host "処理中: $year/$baseName"

    # =========================
    # 前後リンク
    # =========================
    $prevLink = ""
    $nextLink = ""

    if ($i -gt 0) {
        $prevFile = $allPosts[$i-1]
        $prevName = $prevFile.BaseName
        $prevYear = $prevFile.Directory.Name

        if ($prevYear -ne $year) {
            $prevLink = "<a href=`"../$prevYear/$prevName.html`">←前の記事</a>"
        } else {
            $prevLink = "<a href=`"$prevName.html`">←前の記事</a>"
        }
    }

    if ($i -lt ($allPosts.Count - 1)) {
        $nextFile = $allPosts[$i+1]
        $nextName = $nextFile.BaseName
        $nextYear = $nextFile.Directory.Name

        if ($nextYear -ne $year) {
            $nextLink = "<a href=`"../$nextYear/$nextName.html`">次の記事→</a>"
        } else {
            $nextLink = "<a href=`"$nextName.html`">次の記事→</a>"
        }
    }

    $navLinks = @"
<div class="post-nav">
  <div class="prev">$prevLink</div>
  <div class="next">$nextLink</div>
</div>
"@

    # =========================
    # 読み込み
    # =========================
    $nkyRaw  = Read-TextAuto $nky.FullName
    $cmntRaw = ""
    if (Test-Path $cmntPath) {
        $cmntRaw = Read-TextAuto $cmntPath
    }

    # =========================
    # 分解
    # =========================
    $image = ""
    $pos   = ""

    $nkyParts = $nkyRaw -split "[\x01]"

    if ($nkyParts.Count -ge 3) {
        $postDate = $nkyParts[0].Trim()
        $title    = "■" + $nkyParts[1].Trim()
        $body     = $nkyParts[2]

        if ($nkyParts.Count -ge 4) { $image = $nkyParts[3].Trim() }
        if ($nkyParts.Count -ge 5) { $pos   = $nkyParts[4].Trim() }
    } else {
        continue
    }

    if ($body -match "(?s)(.+?)<BR>\s*([^\s]+?\.(jpg|gif|png))\s*(\d)\s*(\d)\s*$") {
        $body  = $matches[1]
        $image = $matches[2]
        $pos   = $matches[4]
    }

    $body = $body -replace "(?i)<br\s*/?>\s*(?=</?(tr|td))", ""
    $body = $body -replace "`r`n", "`n"

    $paragraphs = $body -split "<br><br>"
    $bodyHtml = ""

    foreach ($p in $paragraphs) {

        $p = $p.Trim()

        if ($p -match "<(img|table|ul|ol|div)") {
            $bodyHtml += "$p`n"
            continue
        }

        if ($p -match "^#") {
            $bodyHtml += "<p style=`"color:#770000;`">$p</p>`n"
        }
        elseif ($p -match "^#") {
            $bodyHtml += "<p style=`"color:#007777;`">$p</p>`n"
        }
        elseif ($p -match "^(") {
            $bodyHtml += "<p style=`"color:#000077;`">$p</p>`n"
        }
        elseif ($p -match "^>") {
            $bodyHtml += "<p class=`"quote`">&gt; " + $p.Substring(1) + "</p>`n"
        }
        else {
            $bodyHtml += "<p>$p</p>`n"
        }
    }

    if ($image -ne "") {

        switch ($pos) {
            "0" { $class = "img-left-middle" } # 左中央
            "1" { $class = "img-right-middle" } # 右中央
            "3" { $class = "img-left-bottom" } # 左下
            "4" { $class = "img-left-top" } # 左上
            "5" { $class = "img-right-top" } # 右上
            "6" { $class = "img-center-middle" } # 上中央
            "7" { $class = "img-bottom-center" } # 下中央
            default { $class = "img-left-top" }
        }

        $imgTag = "<img src=`"$image`" class=`"$class`" alt=`"$image`">"

		if ($pos -eq "7" -or $pos -eq "3") {
            $bodyHtml += "`n$imgTag"
        } else {
            $bodyHtml = "$imgTag`n$bodyHtml"
        }
    }

    # =========================
    # コメント
    # =========================
    $commentsHtml = ""
    $hasComments = $false

    if ($cmntRaw -ne "") {

        $cmntRaw = $cmntRaw -replace "`r`n", "`n"

        $entries = [regex]::Split(
            $cmntRaw,
            "(?<=\d{4}/\d{2}/\d{2} \d{2}:\d{2})\n?"
        )

        foreach ($entry in $entries) {

            $entry = $entry.Trim()
            if ($entry -eq "") { continue }

            $parts = $entry -split "[\x01]"
            if ($parts.Count -lt 3) { continue }

            $name  = ($parts[0] -replace "[\x00-\x1F]", "").Trim()
            $text  = ($parts[1] -replace "[\x00-\x1F]", "").Trim()
            $date  = ($parts[-1] -replace "[\x00-\x1F]", "").Trim()

            $text = $text -replace "`n", "<br>"

            $commentsHtml += @"
      <li>
        <span class="comment-meta">$name - $date</span><br>
        <span class="comment-text">$text</span>
      </li>
"@

            $hasComments = $true
        }
    }

    if ($hasComments) {
        $commentsSection = @"
  <section class="comments">
    <ul>
$commentsHtml
    </ul>
  </section>
"@
    } else {
        $commentsSection = ""
    }

    $month = $baseName.Substring(0,2)

    $breadcrumb = @"
<nav class="breadcrumb">
  <a href="../index.html">アーカイブ一覧</a> > 
  <a href="./index.html">${year}年</a> > 
  ${month}月
</nav>
"@

    $backLink = @"
<div class="back-archive">
  <a href="../index.html">アーカイブ一覧に戻る</a>
</div>
"@

    $html = @"
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>オレのページ</title>
<link rel="stylesheet" href="../style.css">
</head>

<body>

<main class="container">

<header class="site-header">
  <div class="header-inner">
    <div></div>

    <div class="header-banner">
      <a href="https://orep.jp/" target="_top">
        <script>
          const Se1 = Math.floor(Math.random() * 3);
          const ga = ["banner1.gif", "banner2.gif", "banner3.gif"];
          document.write('<img src="https://orep.jp/' + ga[Se1] + '" width="200" height="40">');
        </script>
      </a>
    </div>
  </div>
</header>

<hr class="main-hr">

$breadcrumb

  <article class="post">
    <header class="post-header">
      <span class="post-title">$title</span>
      <span class="post-date"> - $postDate</span>
    </header>

    <div class="post-body">
$bodyHtml
    </div>
  </article>

$commentsSection

$navLinks

$backLink

</main>

</body>
</html>
"@

    [System.IO.File]::WriteAllText($outPath, $html, [System.Text.Encoding]::UTF8)
}

Write-Host "完了"
@echo off
chcp 932 > nul
cd /d %~dp0

echo HTML生成を開始します…
powershell -ExecutionPolicy Bypass -File convert.ps1

echo.
echo === Shape HTML File done. ===
pause

convert.ps1は文字コード「UTF-8(BOMあり)」で保存して下さい。
make_html.batは文字コード「Shift-JIS」で保存して下さい。
その後make_html.batを起動して実行します。

ここから各々サイトデザインが違うので参考程度にコード見て下さい。このまま実行しちゃダメですよ。

細かいところの整形は一旦*.htmlを出力して観察しながら*.nky、*.cmntを調整して再度convert.ps1で処理するといいかも。

本文とコメントの整形

body {
  background-image: url(https://orep.jp/images/cloud_f.jpg);
  background-repeat: repeat-x;
  background-attachment: fixed;
  background-position: left bottom;
  background-color: #ffffff;
  color: #000000;
  font-size: 16px;
}

a {
  color: #8080A0;
}
a:visited {
  color: #8080A0;
}

/* ヘッダー */
.site-header {
  width: 80%;
  margin: 0 auto;
  font-size: 0.8em;
}

.header-inner {
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
}

.header-banner {
  width: 200px;
  text-align: right;
}

/* コンテンツ */
.container {
  width: 80%;
  margin: 0 auto;
  overflow: visible; /* ★追加:はみ出し防止 */
}

.main-hr {
  width: 80%;
}

/* 記事 */
.post {
  width: 80%;
  margin: 0 auto;
  background-color: #4386b1;
  padding: 1px;
}

.post-header {
  background-color: #4386b1;
  padding: 4px;
}

.post-title {
  color: #CCEDFF;
  font-size: 1.2em;
  font-weight: bold;
}

.post-date {
  color: #ffffff;
  font-size: 0.8em;
}

.post-body {
  background-color: #ffffff;
  padding: 12px;
  overflow: visible;
}

/* ★超重要:float解除(縦はみ出し解決) */
.post-body::after {
  content: "";
  display: block;
  clear: both;
}

.quote {
  color: #007700;
}

.image-center {
  text-align: center;
  margin-top: 10px;
}

.img-left-top { float:left; margin:0 10px 10px 0; }
.img-left-middle { float:left; margin:10px 10px 10px 0; }
.img-left-bottom {
  float: left;
  clear: both;
  margin: 20px 10px 0 0;
}

.img-right-top { float:right; margin:0 0 10px 10px; }
.img-right-middle { float:right; margin:10px 0 10px 10px; }
.img-right-bottom { float:right; margin:20px 0 0 10px; }

.img-center-middle { display:block; margin:10px auto; }
.img-bottom-center { display:block; margin:20px auto; clear:both; }

/* ★追加:画像の異常はみ出し防止(保険) */
.post-body img {
  max-width: 100%;
  height: auto;
}

/* コメント */
.comments {
  width: 80%;
  margin: 10px auto;
  background-color: #C5D6EB;
  padding: 1px;
}

.comments ul {
  background-color: #F1F5FA;
  margin: 1px;
  padding: 10px 20px;
  list-style: disc;
}

.comment-meta {
  color: #975580;
  font-size: 0.9em;
}

.comment-text {
  color: #3A75AF;
  font-size: 0.9em;
}

.breadcrumb {
  width: 80%;
  margin: 10px auto;
  font-size: 0.9em;
}

.back-archive {
  text-align: center;
  margin: 30px 0;
  font-size: 1.2em;
}

.back-archive a {
  font-weight: bold;
}

/* 前記事へ・次記事へ */
.post-nav {
  display: flex;
  justify-content: space-between;
  width: 80%;
  margin: 20px auto;
  font-size: 1.0em;
}

/* ===== アーカイブページ専用 ===== */

/* ヘッダー:80%幅に忠実化 */
.archive-header {
  width: 80%;
}

.archive-header-inner {
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
}

.archive-header-spacer {
  flex: 1;
}

.archive-header-banner {
  width: 200px;
  text-align: right;
  flex-shrink: 0;
}

/* 全体 */
.archive-page {
  width: 80%;
  margin: 0 auto;
  overflow: visible;
}

/* 検索フォーム */
.archive-search {
  width: 100%;
  margin: 0 auto 1em auto;
  text-align: right;
}

.button {
  color: #555555;
  border: 1px solid #999999;
  padding: 2px 6px;
  box-sizing: border-box;
}

.archive-search-input {
  width: 20%;
  min-width: 250px;
  font-size: 1.2em;
}

.archive-search-button {
  font-size: 1em;
  cursor: default;
}

/* 年ブロック */
.archive-year-block {
  width: 100%;
  margin: 0 auto 2em auto;
}

.archive-year {
  margin: 0 0 0.5em 0;
  font-size: 2em;
  font-weight: bold;
  line-height: 1.2;
  background: linear-gradient(transparent 30%, #CCEDFF 80%);
}

/* yyyy年→mm月:60px */
.archive-year-inner {
  margin-left: 60px;
}

/* 月ブロック */
.archive-month {
  margin-bottom: 1.2em;
}

.archive-month-header {
  background-color: #4386b1;
  min-height: 36px;
  display: flex;
  align-items: center;
  padding: 0 4px;
}

.archive-month-header h2 {
  margin: 0;
  color: #CCEDFF;
  font-size: 1.2em;
  font-weight: bold;
}

/* mm月→dd日:60px */
.archive-month-body {
  padding: 12px 12px 12px 60px;
}

.archive-list {
  margin: 0;
  padding: 0;
  list-style: none;
}

.archive-list li { margin-bottom: 0.3em; }
.archive-day {
	display: inline-block;
	width: 3em;
	text-align: right;
	margin-right: 0.8em;
}

/* 戻るリンク */
.back-archive {
  font-weight: bold;
}

/* 検索ヒット時ハイライト */
.hit {
  background-color: yellow;
  font-weight: bold;
}

↑今作っているサイトで使っているCSSファイル。

以下はPowerShellでまとめて処理するのに失敗した項目です。量にもよりますが必要であれば一つずつ自分でチクチク直して行く方が早いと思います。

できなかったけど自動化したかったなぁと思う機能

  • 記事内の日記内移動リンク絶対パス→相対パス化
    (”https://xxx.jp/cgi-bin/nicky/nicky.cgi?DT=yyyymmddX#yyyymmddX”→”../yyyy/mmddX.html”)
  • 普通に貼ってあるURLの<a>タグリンク化
  • コメント内の改行反映
  • <br><br>と重ねてしまっている場合勝手に<p>に変換してしまい文字のスタイルが変わってしまうのを防止

インデックス作成

全記事の目次と年別の目次を作成、新→旧を旧→新に並び替え。

これもやっぱりカッチリとしたデザインを投げた方がいいです。それから中身を生成。二段階に分けましょう。

(左)ロートルタグ打ち職人が作った<table>デザイン→これをChatGTPに読ませる
(中央)2026年最新のHTML+CSS記述方法にしてと頼んだ結果
(右)↑に内容を入れて完成させたもの

(左)ロートルタグ打ち職人が作った<table>デザイン→これをChatGTPに読ませる
(中央)2026年最新のHTML+CSS記述方法にしてと頼んだ結果
(右)↑に内容を入れて完成させたもの
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$root = "."

function Get-Title($file) {
    $raw = Get-Content $file -Raw -Encoding UTF8
    if ($raw -match '<span class="post-title">■?(.*?)</span>') {
        return $matches[1].Trim()
    }
    return [System.IO.Path]::GetFileNameWithoutExtension($file)
}

$yearDirs = Get-ChildItem $root -Directory |
    Where-Object { $_.Name -match '^\d{4}$' } |
    Sort-Object Name

$yearBlocks = ""

foreach ($yearDir in $yearDirs) {

    $year = $yearDir.Name

    $files = Get-ChildItem $yearDir.FullName -Filter "*.html" |
        Where-Object { $_.BaseName -match '^\d{4}[A-Z]$' } |
        Sort-Object Name

    if ($files.Count -eq 0) { continue }

    $monthGroups = $files | Group-Object { $_.BaseName.Substring(0,2) }

    $monthHtml = ""

    foreach ($group in $monthGroups) {

        $month = [int]$group.Name
        $listItems = ""

        foreach ($file in ($group.Group | Sort-Object Name)) {

            $base = $file.BaseName
            $day = [int]$base.Substring(2,2)
            $title = Get-Title $file.FullName

			$listItems += '              <li><span class="archive-day">' +
			    $day + '日</span><a href="' +
			    $year + '/' + $base + '.html">' +
			    $title + "</a></li>`r`n"
        }

        $monthHtml += @"
        <section class="archive-month">
          <header class="archive-month-header">
            <h2>${month}月</h2>
          </header>

          <div class="archive-month-body">
            <ul class="archive-list">
$listItems            </ul>
          </div>
        </section>

"@
    }

    $yearBlocks += @"
    <section class="archive-year-block">
      <h1 class="archive-year">
        <a href="${year}/index.html">${year}年</a>
      </h1>

      <div class="archive-year-inner">
$monthHtml      </div>
    </section>

"@
}

$html = @"
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>オレのページ アーカイブス</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>

<header class="site-header archive-header">
  <div class="header-inner archive-header-inner">
    <div class="archive-header-spacer"></div>

    <div class="header-banner archive-header-banner">
      <a href="https://orep.jp" target="_top">
        <script>
          const Se1 = Math.floor(Math.random() * 3);
          const ga = ["banner1.gif", "banner2.gif", "banner3.gif"];
          document.write(
            '<img src="https://orep.jp/' + ga[Se1] + '" width="200" height="40" alt="オレのページ Since 1999">'
          );
        </script>
      </a>
    </div>
  </div>
</header>

<main class="container archive-page">

  <hr>

<div class="archive-search">
  <form action="#" method="get" onsubmit="searchPosts(); return false;">
    <input class="button archive-search-input" type="text" id="searchBox" placeholder="アーカイブ内を検索" />
    <button class="button archive-search-button" type="submit" onclick="searchPosts()">検索</button>
  </form>
</div>

$yearBlocks

  <hr>

  <div class="back-archive">
    <a href="https://orep.jp/2026/03/28/archives/">
      アーカイブ一覧に戻る
    </a>
  </div>

</main>

    <!-- 検索スクリプト↓ -->
<script>
window.addEventListener("DOMContentLoaded", () => {
  const params = new URLSearchParams(window.location.search);
  const q = params.get("q");

  if (q) {
    const box = document.getElementById("searchBox");
    if (box) {
      box.value = q;
      searchPosts();
    }
  }
});

function searchPosts() {
  const keyword = document.getElementById("searchBox").value.trim();

  // ★ここに入れる
  history.replaceState(null, "", "?q=" + encodeURIComponent(keyword));
  
	// 検索ワードがない場合URLをindex.htmlにする
	if (keyword === "") {
	  history.replaceState(null, "", "index.html");
	}

  const lowerKeyword = keyword.toLowerCase();

  const items = document.querySelectorAll(".archive-list li");
  const months = document.querySelectorAll(".archive-month");
  const years = document.querySelectorAll(".archive-year-block");

  if (lowerKeyword === "") {
    items.forEach(li => li.style.display = "");
    months.forEach(m => m.style.display = "");
    years.forEach(y => y.style.display = "");
    return;
  }

  items.forEach(li => {
    const title = li.querySelector("a").textContent.toLowerCase();

    if (title.includes(lowerKeyword)) {
      li.style.display = "";
    } else {
      li.style.display = "none";
    }
  });

  months.forEach(month => {
    const visibleItems = month.querySelectorAll("li:not([style*='display: none'])");
    month.style.display = visibleItems.length === 0 ? "none" : "";
  });

  years.forEach(year => {
    const visibleMonths = year.querySelectorAll(".archive-month:not([style*='display: none'])");
    year.style.display = visibleMonths.length === 0 ? "none" : "";
  });
}
</script>
    <!-- 検索スクリプト↑ -->

</body>
</html>
"@

[System.IO.File]::WriteAllText(
    (Join-Path $root "index.html"),
    $html,
    [System.Text.Encoding]::UTF8
)

Write-Host "index.html を生成しました。"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$root = "."

function Get-Title($file) {
    $raw = Get-Content $file -Raw -Encoding UTF8
    if ($raw -match '<span class="post-title">■?(.*?)</span>') {
        return $matches[1].Trim()
    }
    return [System.IO.Path]::GetFileNameWithoutExtension($file)
}

$yearDirs = Get-ChildItem $root -Directory |
    Where-Object { $_.Name -match '^\d{4}$' } |
    Sort-Object Name

for ($i = 0; $i -lt $yearDirs.Count; $i++) {

    $year = $yearDirs[$i].Name
    $yearPath = $yearDirs[$i].FullName

    $prevYear = if ($i -gt 0) { $yearDirs[$i-1].Name } else { $null }
    $nextYear = if ($i -lt $yearDirs.Count - 1) { $yearDirs[$i+1].Name } else { $null }

    $files = Get-ChildItem $yearPath -Filter "*.html" |
        Where-Object { $_.BaseName -match '^\d{4}[A-Z]$' } |
        Sort-Object Name

    if ($files.Count -eq 0) { continue }

    $monthGroups = $files | Group-Object { $_.BaseName.Substring(0,2) }

    $monthHtml = ""

    foreach ($group in $monthGroups) {

        $month = [int]$group.Name
        $listItems = ""

        foreach ($file in ($group.Group | Sort-Object Name)) {

            $base = $file.BaseName
            $day = [int]$base.Substring(2,2)
            $title = Get-Title $file.FullName

            $listItems += '              <li><span class="archive-day">' +
                $day + '日</span><a href="' +
                $base + '.html">' +
                $title + "</a></li>`r`n"
        }

        $monthHtml += @"
        <section class="archive-month">
          <header class="archive-month-header">
            <h2>${month}月</h2>
          </header>

          <div class="archive-month-body">
            <ul class="archive-list">
$listItems            </ul>
          </div>
        </section>

"@
    }

    # パンくずリスト
    $breadcrumb = @"
<nav class="breadcrumb">
  <a href="../index.html">アーカイブ一覧</a> > ${year}年
</nav>
"@

    # 前後年リンク
	$prevLink = ""
	if ($prevYear) {
	    $prevLink = "<div class=`"prev`"><a href=`"../$prevYear/index.html`">←${prevYear}年の記事</a></div>"
	} else {
	    $prevLink = "<div class=`"prev`"></div>"
	}

	$nextLink = ""
	if ($nextYear) {
	    $nextLink = "<div class=`"next`"><a href=`"../$nextYear/index.html`">${nextYear}年の記事→</a></div>"
	} else {
	    $nextLink = "<div class=`"next`"></div>"
	}

    $postNav = @"
<div class="post-nav">
  $prevLink
  $nextLink
</div>
"@

    # HTML生成
    $html = @"
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>オレのページ アーカイブス ${year}年の記事</title>
  <link rel="stylesheet" href="../style.css">
</head>
<body>

  <!-- ヘッダー -->
  <header class="site-header archive-header">
    <div class="header-inner archive-header-inner">
      <div class="archive-header-spacer"></div>

      <div class="header-banner archive-header-banner">
        <a href="https://orep.jp" target="_top">
          <script>
            const Se1 = Math.floor(Math.random() * 3);
            const ga = ["banner1.gif", "banner2.gif", "banner3.gif"];
            document.write(
              '<img src="https://orep.jp/' + ga[Se1] + '" width="200" height="40" alt="オレのページ Since 1999">'
            );
          </script>
        </a>
      </div>
    </div>
    
    <hr>
    
  </header>

$breadcrumb

  <main class="container archive-page">

    <!-- 年ブロック -->
    <section class="archive-year-block">
      <h1 class="archive-year">${year}年</h1>

      <div class="archive-year-inner">
$monthHtml      </div>
    </section>

    <hr>
    
  </main>

$postNav

    <div class="back-archive">
        <a href="../index.html">アーカイブ一覧に戻る</a>
    </div>

</body>
</html>
"@

    [System.IO.File]::WriteAllText(
        (Join-Path $yearPath "index.html"),
        $html,
        [System.Text.Encoding]::UTF8
    )
}

Write-Host "年別 index.html を生成しました。"
@echo off
chcp 932 > nul
cd /d %~dp0

echo index.html を生成します...
powershell -ExecutionPolicy Bypass -File make_index.ps1

chcp 932 > nul
echo 年別index.html を生成します...
powershell -ExecutionPolicy Bypass -File make_year_index.ps1

chcp 932 > nul
echo.
echo 完了しました!
pause

先程と同じく*.ps1は「UTF-8(BOMあり)」、*.batは「Shift-JIS」もしくは「ANSI」で保存。→*.bat実行。

完成⋯?

記事全文検索化+検索単語マーキング+遷移先の記事でもマーキングのよくばりセットも追加。

[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$root = "."

function Get-Title($file) {
    $raw = Get-Content $file -Raw -Encoding UTF8
    if ($raw -match '<span class="post-title">■?(.*?)</span>') {
        return $matches[1].Trim()
    }
    return [System.IO.Path]::GetFileNameWithoutExtension($file)
}

$yearDirs = Get-ChildItem $root -Directory |
    Where-Object { $_.Name -match '^\d{4}$' } |
    Sort-Object Name

$yearBlocks = ""

foreach ($yearDir in $yearDirs) {

    $year = $yearDir.Name

    $files = Get-ChildItem $yearDir.FullName -Filter "*.html" |
        Where-Object { $_.BaseName -match '^\d{4}[A-Z]$' } |
        Sort-Object Name

    if ($files.Count -eq 0) { continue }

    $monthGroups = $files | Group-Object { $_.BaseName.Substring(0,2) }

    $monthHtml = ""

    foreach ($group in $monthGroups) {

        $month = [int]$group.Name
        $listItems = ""

        foreach ($file in ($group.Group | Sort-Object Name)) {

            $base = $file.BaseName
            $day = [int]$base.Substring(2,2)
            $title = Get-Title $file.FullName

			# --- 本文取得(追加) ---
			$content = Get-Content $file.FullName -Raw -Encoding UTF8

			# 本文取得
			$postBody = ""
			if ($content -match '(?s)<div class="post-body">(.*?)</div>') {
			    $postBody = $matches[1]
			}

			# コメント取得
			$commentBody = ""
			if ($content -match '(?s)<section class="comments">(.*?)</section>') {
			    $commentBody = $matches[1]
			}

			# 結合
			$body = $postBody + " " + $commentBody

			# HTML除去
			$body = $body -replace '<.*?>', ''

			# 空白整理
			$body = $body -replace '\s+', ' '

			# クォート対策
			$body = $body -replace '"', '&quot;'

			# --- li生成(ここを丸ごと置換) ---
			$listItems += @"
              <li data-body="$($body)"><span class="archive-day">$($day)日</span><a href="$($year)/$($base).html">$($title)</a></li>
"@
        }

        $monthHtml += @"
        <section class="archive-month">
          <header class="archive-month-header">
            <h2>${month}月</h2>
          </header>

          <div class="archive-month-body">
            <ul class="archive-list">
$listItems            </ul>
          </div>
        </section>

"@
    }

    $yearBlocks += @"
    <section class="archive-year-block">
      <h1 class="archive-year">
        <a href="${year}/index.html">${year}年</a>
      </h1>

      <div class="archive-year-inner">
$monthHtml      </div>
    </section>

"@
}

$html = @"
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>オレのページ アーカイブス</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>

<header class="site-header archive-header">
  <div class="header-inner archive-header-inner">
    <div class="archive-header-spacer"></div>

    <div class="header-banner archive-header-banner">
      <a href="https://orep.jp/" target="_top">
        <script>
          const Se1 = Math.floor(Math.random() * 3);
          const ga = ["banner1.gif", "banner2.gif", "banner3.gif"];
          document.write(
            '<img src="https://orep.jp/' + ga[Se1] + '" width="200" height="40" alt="オレのページ Since 1999">'
          );
        </script>
      </a>
    </div>
  </div>
</header>

<main class="container archive-page">

  <hr>

<div class="archive-search">
  <form action="#" method="get" onsubmit="searchPosts(); return false;">
    <input class="button archive-search-input" type="text" id="searchBox" placeholder="アーカイブ内を検索" />
    <button class="button archive-search-button" type="submit" onclick="searchPosts()">検索</button>
  </form>
</div>

$yearBlocks

  <hr>

  <div class="back-archive">
    <a href="https://orep.jp/2026/03/28/archives/">
      アーカイブ一覧に戻る
    </a>
  </div>

</main>

    <!-- ↓検索スクリプト -->
    
<script>
function highlight(text, keyword) {
  const escaped = keyword.replace(/[.*+?^$()|[\]\\]/g, '\\$&');
  return text.replace(new RegExp(escaped, "gi"), function(match) {
    return '<span class="hit">' + match + '</span>';
  });
}

function searchPosts() {
  const keyword = document.getElementById("searchBox").value.trim();
  const lowerKeyword = keyword.toLowerCase();

  // URLに検索単語を入れる・検索ワードがない場合URLをindex.htmlにする

  if (keyword === "") {
    history.replaceState(null, "", "index.html");
  } else {
    history.replaceState(null, "", "?q=" + encodeURIComponent(keyword));
  }

  const items = document.querySelectorAll(".archive-list li");
  const months = document.querySelectorAll(".archive-month");
  const years = document.querySelectorAll(".archive-year-block");
  
  // 検索ワードがない場合URLをindex.htmlにする

  if (lowerKeyword === "") {
    items.forEach(li => {
      li.style.display = "";
      const a = li.querySelector("a");
      if (a.dataset.original) {
        a.textContent = a.dataset.original; // ハイライト解除
      }
    });
    months.forEach(m => m.style.display = "");
    years.forEach(y => y.style.display = "");
    return;
  }

  items.forEach(li => {
    const a = li.querySelector("a");

    // 元タイトル保存(初回のみ)
    if (!a.dataset.original) {
      a.dataset.original = a.textContent;
    }

    const title = a.dataset.original;
    const lowerTitle = title.toLowerCase();
    const body = (li.dataset.body || "").toLowerCase();

    if (lowerTitle.includes(lowerKeyword) || body.includes(lowerKeyword)) {
      li.style.display = "";

      // ハイライト適用
      a.innerHTML = highlight(title, keyword);

    } else {
      li.style.display = "none";
    }
  });
  	


  // 月
  months.forEach(month => {
    const visibleItems = month.querySelectorAll("li:not([style*='display: none'])");
    month.style.display = visibleItems.length === 0 ? "none" : "";
  });

  // 年
  years.forEach(year => {
    const visibleMonths = year.querySelectorAll(".archive-month:not([style*='display: none'])");
    year.style.display = visibleMonths.length === 0 ? "none" : "";
  });
}

// ★URLパラメータ対応
window.addEventListener("DOMContentLoaded", () => {
  const params = new URLSearchParams(window.location.search);
  const q = params.get("q");

  if (q) {
    const box = document.getElementById("searchBox");
    if (box) {
      box.value = q;
      searchPosts();
    }
  }
});

document.addEventListener("click", function(e) {
  const link = e.target.closest("a");
  if (!link) return;

  // ★記事リンクだけ対象
  if (!link.closest(".archive-list")) return;

  const box = document.getElementById("searchBox");
  if (!box) return;

  const keyword = box.value.trim();
  if (!keyword) return;

  // すでに?q付きなら何もしない
  if (link.href.includes("?q=")) return;

  e.preventDefault();

  const url = link.getAttribute("href").split("?")[0];
  const newUrl = url + "?q=" + encodeURIComponent(keyword);

  window.location.href = newUrl;
});
</script>
    <!-- ↑検索スクリプト -->

</body>
</html>
"@

[System.IO.File]::WriteAllText(
    (Join-Path $root "index.html"),
    $html,
    [System.Text.Encoding]::UTF8
)

Write-Host "index.html を生成しました。"
@echo off
chcp 932 > nul
cd /d %~dp0

echo index.html を記事別マークアップに対応するよう再生成します...
powershell -ExecutionPolicy Bypass -File remake_index.ps1

chcp 932 > nul
echo.
echo 完了しました!
pause

↓01_InsertHighlight.ps1と02_InsertHighlightcomment.ps1は再度各記事ファイル(mmddX.html)にスクリプトを埋め込むもの。それぞれコード最上段にフォルダ指定ができるようにしています。

# =========================
# 設定
# =========================
$root = "D:\nicky"   # ←記事フォルダに合わせて変更

# =========================
# 挿入するスクリプト
# =========================
$script = @'
<script>
function highlightText(node, keyword) {
  if (node.nodeType === 3) {
    const text = node.nodeValue;
    const lower = text.toLowerCase();
    const key = keyword.toLowerCase();

    if (lower.includes(key)) {
      const span = document.createElement("span");
      span.innerHTML = text.replace(
        new RegExp(keyword, "gi"),
        function(match){ return '<span class="hit">' + match + '</span>'; }
      );
      node.replaceWith(span);
    }
  } else if (node.nodeType === 1 && node.childNodes && !["SCRIPT","STYLE"].includes(node.tagName)) {
    node.childNodes.forEach(child => highlightText(child, keyword));
  }
}

window.addEventListener("DOMContentLoaded", function() {
  const params = new URLSearchParams(window.location.search);
  const q = params.get("q");

  if (!q) return;

  const target = document.querySelector(".post-body");
  if (target) {
    highlightText(target, q);
  }
});
</script>
'@

# =========================
# HTML処理
# =========================
Get-ChildItem $root -Recurse -Filter *.html | Where-Object {
    # index.htmlは除外
    $_.Name -ne "index.html" -and
    # 記事ページのみ対象(例: 0627A.html)
    $_.Name -match '^\d{4}[A-Z]\.html$'
} | ForEach-Object {

    Write-Host "処理中: $($_.FullName)"

    $content = Get-Content $_.FullName -Raw -Encoding UTF8

    # すでに挿入済みならスキップ
    if ($content -match "highlightText") {
        Write-Host "  → スキップ(既に挿入済み)"
        return
    }

    # </body> の直前に挿入(安全)
    if ($content -match "</body>") {
        $newContent = $content -replace "</body>", "$script`r`n</body>"
    } else {
        Write-Host "  → 注意: </body> が見つからないためスキップ"
        return
    }

    # 上書き保存
    Set-Content $_.FullName $newContent -Encoding UTF8

    Write-Host "  → 挿入完了"
}

Write-Host "========================="
Write-Host "全処理が完了しました!"
Write-Host "========================="
@echo off
chcp 932 > nul
cd /d %~dp0

echo 全記事ファイルに検索キーワードハイライトを生成します...
powershell -ExecutionPolicy Bypass -File 01_InsertHighlight.ps1

chcp 932 > nul
echo.
echo 完了しました!
pause
$root = "D:\nicky"

$script = @'
<script>
window.addEventListener("DOMContentLoaded", function() {
  const params = new URLSearchParams(window.location.search);
  const q = params.get("q");

  if (!q) return;

  const comments = document.querySelectorAll(".comments");

  comments.forEach(target => {
    highlightText(target, q);
  });
});
</script>
'@

Get-ChildItem $root -Recurse -Filter *.html | Where-Object {
    $_.Name -match '^\d{4}[A-Z]\.html$'
} | ForEach-Object {

    $content = Get-Content $_.FullName -Raw -Encoding UTF8

    # すでに追加済みならスキップ
    if ($content -match "comments.forEach") {
        return
    }

    if ($content -match "</body>") {
        $newContent = $content -replace "</body>", "$script`r`n</body>"
        Set-Content $_.FullName $newContent -Encoding UTF8
        Write-Host "追加: $($_.Name)"
    }
}
@echo off
chcp 932 > nul
cd /d %~dp0

echo 全記事コメントに検索キーワードハイライトを生成します...
powershell -ExecutionPolicy Bypass -File 02_InsertHighlightcomment.ps1

chcp 932 > nul
echo.
echo 完了しました!
pause

しつこいようだが*.ps1は「UTF-8(BOMあり)」、*.batは「Shift-JIS」もしくは「ANSI」で保存。
→「01_InsertHighlight.bat」→「02_InsertHighlightcomment.bat」の順に実行。

今度こそ完成

今回はPowerShellだけでCGI→HTMLに変換してみました。
完成したものは以下の記事に記録しています。これHTMLとJavaScriptだけで動いてるって信じられないね。

苦労して作ったアーカイブの画面を見てくれ⋯!
そして内容は見なくてよろしい。

nicky.cgi削除

さようなら、nicky.cgi⋯!

コメント

タイトルとURLをコピーしました