スマホの乗り換え診断のような「料金を比べるツール」を作るとき、いちばん怖いのはロジックのバグより 古い料金・確かめていない数字・広告に引っぱられた順位 です。利用者はその数字を見て契約を決めるので、ここがずれると信用をなくします。
静的サイト(GitHub Pages)で作ったスマホ乗り換え診断で、それを仕組みとテストで防いだ方法をまとめます。
方針
- 料金は、各社の公式ページで確かめた 税込 の数字だけを入れる。確かめられない値は入れない(推測で埋めない)
- どの数字にも 出典URLと確認日 を持たせ、画面に確認日を出す
- 広告は順位の計算に一切渡さない。それをテストで確かめる
- 「最安」「キャンペーン中」のような言い切り・期間限定の言葉を画面に出さない
料金データは出典つきのJSONにする
ロジックと料金を分け、料金は data/plans.json に置きます。1プランはこんな形です。
{
"id": "linemo-best",
"kind": "online",
"brand": "LINEMO",
"name": "LINEMOベストプラン",
"tiers": [
{ "gb": 3, "price": 990 },
{ "gb": 10, "price": 2090 }
],
"call": {
"rate": "22円/30秒",
"short": { "name": "通話準定額(5分)", "price": 550 },
"long": { "name": "通話定額(かけ放題)", "price": 1650 }
},
"discounts": [],
"notes": ["通話オプションは契約から7か月目までキャンペーン価格がありますが、ここでは通常の料金で計算しています。"],
"source_url": "https://www.linemo.jp/plan/",
"checked": "2026-10-19"
}
ポイントは次の3つです。
-
checked(確認日)をプランごとに持つ。ファイル先頭にも全体の確認日を置き、画面に「料金は○年○月○日に各社公式サイトで確認」と出す - 確かめられなかった項目は キーごと消す。
nullは「確認できず」として画面で「公式で確認してください」と出す - キャンペーン価格は入れず、
notesに「通常の料金で計算している」と書く
割引は「回答からわかるものだけ」引く
「家族割を全部使うと○円」のような表示は、条件を満たさない人には誤解を生みます。そこで割引を2つに分けました。
- 回答から判断できる割引(家族の人数→家族割、家の回線→光セット割)だけを月額から引く
- カード払い割など、回答からわからない割引は「ほかの割引(条件を満たせば)」として表示するだけで引かない
さらに、同時に使えない割引(家族割と光セット割のどちらか一方、など)は、同じ exclusive を持たせて大きい方だけを残します。
discounts.forEach(function (d) {
var item = { d: d, amount: amountFor(d, answers) };
if (d.exclusive) { // 同じグループは大きい方1つだけ
var prev = ex[d.exclusive];
if (prev && prev.amount >= item.amount) { other.push(d); return; }
if (prev) { likely.splice(likely.indexOf(prev), 1); other.push(prev.d); }
ex[d.exclusive] = item;
}
likely.push(item);
});
広告が順位に影響しないことをテストで確かめる
診断結果の下に広告枠を置いています(アフィリエイトで運営費をまかなうため)。広告は ads.json に分け、画面側だけが読みます。
ここで大事なのは「広告を出している会社が上に来ない」ことです。口で言うだけでなく、Nodeの単体テストで2つの方向から確かめています。
// 1) 判定ロジックのソースに、広告を読むコードがないこと
const src = fs.readFileSync(path.join(ROOT, 'js', 'logic.js'), 'utf8');
ok(!/ads|ADS|sponsor|affiliate/i.test(src), 'logic.js has no ad inputs');
// 2) データに広告の情報を混ぜても、順位が変わらないこと
const R1 = JSON.stringify(SC.recommend(D, { data: 'm' }).plans.map(r => r.plan.id));
const D2 = JSON.parse(JSON.stringify(D));
D2.ads = { slots: [{ tag: 'x', boost: 'linemo-best' }] };
ok(JSON.stringify(SC.recommend(D2, { data: 'm' }).plans.map(r => r.plan.id)) === R1,
'extra ad fields do not change order');
1つ目は雑に見えますが、「将来だれかが判定ロジックに広告の重みを足す」変更を確実に止められます。
言い切りの言葉もテストで止める
料金は毎月のように変わるので、「最安」と書くと、いつかうそになります。全プランの表示テキストに対して、言い切りや期間限定の言葉が入っていないかを確かめています。
ok(!/最安|ポイント還元|キャンペーン中/.test(txt), p.id + ' no 最安/campaign wording');
あわせて、全プランの checked が最新の確認日になっているかもテストします。料金を直したのに日付を直し忘れる、を防ぐためです。
ok(D.checked === '2026-10-19', 'data checked date');
D.plans.forEach(p => ok(p.checked === '2026-10-19', p.id + ' checked'));
予告されている料金改定はREADMEに書いておく
すでに発表されている改定(例:ドコモの2026年12月1日の料金改定)は、READMEの「次回の確認で必ず見ること」に出典URLと一緒に書いておきます。その日が来たら、データを差し替えてテストの期待値と確認日を直す、という手順です。
回答は外に送らない
診断の回答は端末の中だけで判定し、サーバーには送りません。CSPで connect-src 'self' にして、外部への通信をブラウザに止めてもらっています(広告画像だけ img-src で広告配信元を許可)。回答を結果URLに入れるときも # のあと(ハッシュ)に入れるので、サーバーに送られません。
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'self';
img-src 'self' data: https://*.a8.net; connect-src 'self'; form-action 'none'">
まとめ
- 料金は出典URLと確認日つきのJSONに分け、確かめられない値は入れない
- 割引は回答からわかるものだけ引き、同時に使えないものは大きい方だけ
- 「広告が順位に影響しない」「最安と書かない」「確認日が最新」をテストで守る
作ったツールは無料で公開しています(AIを活用して作り、料金は公式ページで確かめています)。