海外の反応を「要約せず原文つき」で載せるためのデータ設計

海外のフォーラムの投稿を翻訳して紹介するサイトを運用していると、最初にサボりたくなるのが原文と出典の保存だ。表示に使うのは翻訳文だけなので、翻訳文だけを持てば動く。動くのだが、あとから「この訳は正しいのか」「これは本当に実在の投稿なのか」を確認する手段が消える。

自分が運用しているアニメ海外の反応では、以前この失敗をしていた。データ 6,000 件すべてに原文も出典URLも無く、投稿者名は「海外視聴者A」のような生成値だった。一方でサイトの説明文と免責ページには「コメントを要約せず個別に翻訳して紹介する」「出典を明記する」と書いてあった。記述と実態がずれていたわけで、これは翻訳の質の問題ではなく誠実さの問題だ。

直し方は2通りある。記述を実態に合わせる(=「作例です」と書く)か、実態を記述に合わせる(=本当に翻訳する)か。後者を選んだので、そのためのデータ設計とビルド時のチェックをここに書く。

1件の反応が持つべきフィールド

要点は、表示に使わないフィールドを必須にすることだ。表示に使わないものは、必須にしておかないと必ず抜ける。

1
2
3
4
5
6
7
8
9
{
"handle": "k11chi",
"lang": "EN",
"emotion": "praise",
"text": "最高の回だった。浦原対アスキンの描き方が見事だった。",
"original": "What a peak episode. Urahara vs Askin was handled in an amazing way.",
"sourceUrl": "https://myanimelist.net/forum/?goto=post&topicid=2269851&id=74545933",
"fetchedAt": "2026-08-07"
}
  • text … 日本語訳。ページ本文に出る
  • original原文そのまま。折りたたみで併記する
  • lang … 原文の言語
  • sourceUrlその投稿への permalink。スレッドの一覧URLでは不可
  • handle … 出典元での表示名。架空名を作らない
  • fetchedAt … 取得日

ここで効くのは sourceUrl の粒度だ。スレッドのURLだけを持つと、あとから「どの投稿だったか」を特定できない。MyAnimeList なら ?goto=post&topicid=…&id=… の形で投稿単位のURLが取れるので、それを保存する。1文字ぶんの手間で、検証可能性が保たれる。

運用ルールとしては「1つでも欠けるならその反応は載せない」にしてある。件数を埋めるために創作しない、というのが唯一の防波堤になる。

「取得できなかった日は書かない」を許す

このスキーマの副作用として、取得に失敗した日は0件になる。ここで「0件では寂しいから」と埋め始めると、元の状態に戻る。

なので運用側の判断基準を明文化した。取得できなかった日は、何を試して何が駄目だったか(試したURL・HTTPステータスまで)を記録して0件で終える。書かないことと、更新しないことは違うという整理で、記録さえ残れば翌日に打開できる。

壊れた参照を黙って落とさない

返信スレッドを表示するために、各反応は任意で id(安定した識別子)と replyTo(親の id)を持てる。ここが厄介で、replyTo が存在しない id を指していても、素直に実装すると返信表示が消えるだけで何も起きない。無言で機能が落ちるので、気づくのは数か月後になる。

対策として、索引を作る段階で重複を検出して警告するようにした。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function buildReplyIndex(reactions, ctxLabel) {
const byId = new Map();
(reactions || []).forEach((r, i) => {
if (r && r.id != null && String(r.id).trim()) {
const key = String(r.id);
if (byId.has(key)) {
console.warn(
`[reactions] 反応 id "${key}" が重複しています(${ctxLabel})。返信リンクは先に定義された反応を指します。`,
);
return;
}
byId.set(key, { r, num: i + 1 });
}
});
return byId;
}

ctxLabel に「どの作品のどの話か」を入れておくのが実務上のポイントだ。警告文だけ出ても、6,000件の中のどこかでは直しようがない。

同じ考え方で、出典が複数あるページには初出順の通し番号を振っている。

1
2
3
4
5
6
7
8
9
10
11
function buildSourceIndex(reactions) {
const byUrl = new Map();
let n = 0;
for (const r of reactions || []) {
const u = r && r.sourceUrl && String(r.sourceUrl).trim();
if (!u || byUrl.has(u)) continue;
n += 1;
byUrl.set(u, { num: n, host: sourceHost(u) });
}
return byUrl;
}

sourceUrl がある反応だけを数えるので、出典の無いデータが混ざっても番号を捏造しない。無いものは無いまま通すのが、この手の索引では正しい。

実際に試す

手元で確認するなら、上のスキーマで JSON を1つ作って、必須フィールドの検査だけ書けば十分だ。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const REQUIRED = ["text", "original", "lang", "sourceUrl", "handle", "fetchedAt"];

function validateReactions(reactions) {
const bad = [];
reactions.forEach((r, i) => {
const missing = REQUIRED.filter((k) => !r[k] || !String(r[k]).trim());
if (missing.length) bad.push({ index: i, handle: r.handle, missing });
if (r.sourceUrl && !/[?&]id=/.test(r.sourceUrl)) {
bad.push({ index: i, handle: r.handle, missing: ["sourceUrl(投稿単位でない)"] });
}
});
if (bad.length) {
console.error(JSON.stringify(bad, null, 2));
process.exit(1);
}
console.log(`ok: ${reactions.length}件すべて必須フィールドを満たしています`);
}

process.exit(1) にしてあるのが肝で、ビルドを止めないと結局そのまま公開される。警告を出すだけの検査は、無人で回している限り誰も読まない。

実際に出来上がったページはアニメ海外の反応で見られる。各コメントに「原文(EN)を表示」のトグルが付いていて、翻訳が気に入らなければ原文を確認できる。この状態にして初めて、サイトの説明文どおりのものになった。

書いていて思うのは、この手の設計は性能でも見た目でもなく、後から自分の書いたものを疑えるかどうかの問題だということだ。原文と permalink を持っていれば、3か月後の自分が検証できる。持っていなければ、信じるしかなくなる。


海外の反応を「要約せず原文つき」で載せるためのデータ設計
https://blog.hashito.biz/2026/08/07/quote-foreign-reactions-with-original-text-data-design/
著者
hashito
作成日
2026年8月7日
著作権