掲示板HTMLから投稿を構造化して取り出す — 引用の除外と数値文字参照のデコード

HTMLから投稿を取り出す処理は、replace(/<[^>]+>/g, '') の一行で済みそうに見えて、実際にやると2箇所で必ず躓く。ひとつは引用の再掲が本文に混ざること、もうひとつは数値文字参照で書かれた文字が素通りすることである。どちらも「動いているように見えるが中身が間違っている」型の失敗なので、テストを書いていても気づきにくい。ここでは投稿ブロック単位で切り出す実装を、この2点の対処を含めて組み立てる。

投稿ブロックを先に切り出す

最初にやるべきは、ページ全体を1つの文字列として扱うのをやめることである。掲示板のHTMLは、投稿1件が1つの要素にまとまっていることがほとんどで、だいたい次のような形をしている。

1
2
3
4
5
6
7
8
9
<div class="forum-topic-message message" id="msg74534327" data-id="74534327" data-user="Reecey91">
<div class="message-header"><div class="date" data-time="1785334159">Jul 29, 7:09 AM</div></div>
<div class="message-wrapper">
<div class="content">
<table id="message74534327" class="body clearfix"><tr><td>本文がここに入る</td></tr></table>
</div>
</div>
</div>
<!-- forum-topic-message -->

投稿者名が data-user に、投稿IDが id に、時刻が .date に入っている。本文だけを見て投稿者を推測しないのが要点で、属性に入っている値をそのまま使えば、表示名の変更やアイコンの有無に影響されない。

切り出しは、閉じ側の目印まで含めて非貪欲でマッチさせる。

1
2
3
4
5
6
7
8
const RE_POST =
/<div class="forum-topic-message message" id="msg(\d+)" data-id="\d+" data-user="([^"]+)">([\s\S]*?)<!-- forum-topic-message -->/g;

let m;
while ((m = RE_POST.exec(html)) !== null) {
const [, msgId, handle, block] = m;
// block の中だけを見て本文と時刻を取る
}

[\s\S]*? を使うのは、. が改行にマッチしないためである。s フラグ(dotAll)を付けて .*? にしてもよい。

ここで閉じ側にHTMLコメントを使っているのが効く。</div> を目印にすると入れ子の数を数える必要が出るが、多くのテンプレートエンジンは要素の終わりにコメントを吐くので、それを終端として使えば入れ子を無視できる。コメントが無い場合は、次の投稿の開始タグを先読み((?=<div class="forum-topic-message"|$))で終端にする。

引用ブロックを先に除去する

ここが1つ目の落とし穴である。掲示板の投稿には、他人の発言を引用したブロックが含まれる。

1
2
3
4
<td>
<div class="quotetext">kuroneko99 said: 軍のトップ二人の判断が一枚岩ではなかったのは予想外だった。</div>
同意する。あの場面は良かった。
</td>

タグを一括で剥がすと、この投稿の本文は「kuroneko99 said: 軍のトップ二人の…同意する。あの場面は良かった。」になる。他人の文章が、この投稿者が書いたものとして記録される。 引用元の人物の発言が別人に帰属する形で保存されるので、単なるノイズではなく事実の誤りになる。

対処は単純で、タグを剥がす前に引用ブロックを丸ごと消す

1
2
3
4
5
6
7
function extractBody(cellHtml) {
return cellHtml
.replace(/<div class="quotetext">[\s\S]*?<\/div>/gi, ' ') // 引用を先に落とす
.replace(/<img[^>]*>/gi, ' ') // 画像も本文ではない
.replace(/<br\s*\/?>/gi, '\n') // 改行だけは残す
.replace(/<[^>]+>/g, ''); // 残りのタグを剥がす
}

順序が逆だと意味がない。タグを剥がしてしまった後では、どこからどこまでが引用だったかを判定する手掛かりが消えている。構造を使う処理は、構造が残っているうちに済ませる。

引用が入れ子になっている場合、非貪欲マッチだと内側の </div> で止まってしまう。入れ子を正しく扱うならDOMパーサを使うべきで、正規表現で無理をしない判断も要る。ただ、多くの掲示板は引用の入れ子を1段までしか許さないので、実運用では上の形で足りることが多い。

数値文字参照を先にデコードする

2つ目の落とし穴は、HTMLに直接書かれていない文字である。

&amp;&lt; を戻す処理は誰でも書く。見落とされるのは &#128218; のような数値文字参照で、これはブラウザでは 📚 として表示されるが、生のHTMLを正規表現で走査しているコードからは「数字と記号の並び」にしか見えない。

これは実害が出る。絵文字を使わない方針のサイトで絵文字を検出する lint を書いたとき、本番には絵文字が出ているのに検査は0件を返すという状態になった。検査が緑なので誰も気づかない。

1
2
3
4
5
6
7
8
function decodeNumericEntities(s) {
return s.replace(/&#(\d+);/g, (whole, dec) => {
const cp = Number(dec);
// 構造に関わる参照(&#38; = & など)は触らない
if (cp < 0x80) return whole;
return String.fromCodePoint(cp);
});
}

&#x1F4DA; のような16進表記も同じ形で扱う。変換範囲を絞るのが要点で、&#38;&)や &#60;<)まで戻すと、後続のタグ剥がしがHTML構造を誤って認識する。上の実装ではコードポイントが 0x80 未満のものを素通りさせている。

そして順序はデコードが先、構造を使う処理が後である。逆にすると、引用ブロックの中に実体参照で書かれた文字があった場合に、除外の対象から漏れる。

全体を組み立てる

3つを順番に並べると、こうなる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
function decode(s) {
return s
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#0?39;/g, "'")
.replace(/&#(\d+);/g, (w, d) => (Number(d) < 0x80 ? w : String.fromCodePoint(Number(d))))
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}

function parsePosts(html, topicId) {
const posts = [];
const re =
/<div class="forum-topic-message message" id="msg(\d+)" data-id="\d+" data-user="([^"]+)">([\s\S]*?)<!-- forum-topic-message -->/g;
let m;
while ((m = re.exec(html)) !== null) {
const [, msgId, handle, block] = m;
const dm = block.match(/<div class="date"[^>]*>([^<]*)<\/div>/);
const bm = block.match(
/<table id="message\d+" class="body clearfix"><tr><td>([\s\S]*?)<\/td><\/tr><\/table>/
);
if (!bm) continue; // 本文が取れない投稿は捨てる
const body = bm[1]
.replace(/<div class="quotetext">[\s\S]*?<\/div>/gi, ' ')
.replace(/<img[^>]*>/gi, ' ');
const text = decode(body);
if (!text || text.length < 30) continue; // 短すぎるものは実質空
posts.push({
msgId,
handle,
date: dm ? dm[1].trim() : null,
body: text,
permalink: `https://example.invalid/forum/?goto=post&topicid=${topicId}&id=${msgId}`,
});
}
return posts;
}

if (!bm) continue;if (text.length < 30) continue; は、取れなかったものを空文字で埋めないための行である。取得失敗と「本文が空だった」は別の事象なので、片方をもう片方に潰すと、後から件数を見たときに何が起きたか分からなくなる。

permalink を組み立てているのも意図がある。一覧ページのURLではなく、その投稿への直リンクを残しておくと、後から出典を検証できる。取得した時点で検証可能性を確保するのは、あとから足せない性質のものである。

やってみて分かったこと

最初に書いた版は、投稿ブロックの検出に id="msg(\d+)" だけを使っていた。これだと <a href="#msg123"> のようなアンカーにも当たり、投稿0件を返した。属性の並び(iddata-iddata-user)まで含めて書いたら通った。セレクタは緩いほど良いわけではないという当たり前のことだが、0件が返ってきたときに「サイトの構造が変わった」と誤診しやすい部分でもある。

もうひとつ、正規表現でHTMLを扱うことについて。一般論としてはDOMパーサを使うべきである。ただ、投稿ブロックのように構造が浅くて定型な対象については、正規表現のほうが依存も起動時間も小さい。判断の基準は入れ子の深さで、引用の入れ子が2段以上あり得るなら、その時点でパーサに切り替えたほうがよい。


掲示板HTMLから投稿を構造化して取り出す — 引用の除外と数値文字参照のデコード
https://blog.hashito.biz/2026/08/06/extract-forum-posts-from-html-quote-exclusion-numeric-entities/
著者
hashito
作成日
2026年8月6日
著作権