TL;DR
- BDSM向けの16タイプ診断を作り、Xへの共有投稿を実装したところ、共有URLの
?r=パラメータが長すぎることに気づいた - JSONをBase64にした形式(約136文字)から、独自の区切り文字列(約35文字)に変更して、パラメータを削った
- 削るポイントは、①URLのパスと重複する値を持たない、②JSON+Base64をやめる、③小数を整数にする、の3つ
- 見落としやすい点:URLを受け取って解析する別アプリが、旧形式しか読めなかった
- 旧形式も読める状態で、受け取る側→発行する側の順にデプロイして、無事に移行できた
何を作っているの?
私は、SM・BDSM領域に向けたサービス「Kinkysエコシステム」を、個人で開発・運営しています。
マッチングアプリ(Nuxt + Firebase)、SNSアプリ、お店のデータベース、メディアなどがあり、その入口のひとつとして、BDSM向けの16タイプ診断を作りました(Next.jsの静的エクスポート)。
30問に答えると、16種類の「役職」と、スタンス(主導/対等/委ねる)などが出ます。結果は共有URLにでき、SNSでシェアできます。
今回は、このシェア機能を作ったときに、共有URLのパラメータが長すぎることに気づき、パラメータを削って短くするために奮闘した過程を、時系列で書きます。
1. Xシェアを実装したら、なんとURLが長すぎた!?
診断結果画面に「Xでシェア」ボタンを付けました。投稿文は、次のような作りです。
{名前}のBDSM 16役職診断の結果は「{役職名}」でした。{役職の説明文}
{共有URL}
#Kinkyer #BDSM16タイプ診断 #{役職別ハッシュタグ×3}
ボタンを押すと、Xの投稿画面(https://twitter.com/intent/tweet?text=...)が開きます。ローカルで試したところ、文字数の上限をすぐに超えてしまい、投稿できませんでした。
今思えば実装中に気づいてもよかったはずなのに、共有URLだけで文字数超えるって、、、
共有URLは、結果をJSONにしてBase64URLにした ?r=... を持っていて、これが約136文字ありました。URLの大半が、このパラメータです。
2. 何を削れば短くなるのか
Xの投稿は課金した人であれば上限は増えますが、無料で利用している人たちは上限があります。
ただ、これは文字数の問題だけでなく、長いパラメータそのものが無駄だと感じました。LINEなどに貼る場面では、URLの長さがそのまま見た目になりますし、静的サイトなので、サーバー側でURLを短縮する仕組みも持てません。
そこで、投稿文の見直しと並行して、パラメータ自体を削ることにしました。
3. 対策を考える
URLと投稿文を、次のように整理しました。
| 案 | 判断 |
|---|---|
| 外部のURL短縮サービスを使う | 不採用。リダイレクトが1段増え、第三者に依存する。Xでは、URLが長さに関係なく23文字扱いなので、効果も薄い。静的サイトなので、自前の短縮も難しい |
| ハッシュタグを減らす |
採用。5個→2個(#BDSM16タイプ診断 と、役職別の1つ目) |
| 投稿文を短くする |
採用。役職の説明文を外し、ハッシュタグを改行で並べる。末尾の「@アカウント より」(via)も外す |
?r= のパラメータ自体を短くする |
採用。URLの無駄を減らし、LINEなどに貼るときの見た目も短くなる |
最後の、パラメータを短くするのが、今回のメインの作業です。
4. パラメータを短くする
変更前
const payload = {
p: "9", // 主役職
sv: -12.5, // スタンスのスコア
av: 9.68, // 主導のスコア
smv: 0, // SM軸のスコア
rr: [["9", 100], ["1", 98.35], ["13", 91.2], ["2", 88.02], ["7", 80.5]] // 上位5件
};
const r = base64url(JSON.stringify(payload)); // 約136文字
短縮のポイント
-
p(主役職)を削除:URLのパス(/share/{役職}/)に、すでに役職が入っている - JSON+Base64をやめて、区切り文字列にする:Base64は、元のデータの約1.33倍に膨らむ
- 小数を整数に丸める
// 例: -12_10_0_9-100_1-98_13-91_2-88_7-81
// スタンス_主導_SM軸_役職キー-スコア_役職キー-スコア…
export function encodeCompactShare({ sv, av, smv, rr }) {
const head = [sv, av, smv].map((v) => Math.round(Number(v) || 0));
const pairs = rr.map(([key, score]) => `${key}-${Math.round(Number(score) || 0)}`);
return [...head, ...pairs].join("_");
}
結果、?r= は136文字から35文字になりました(上の例)。
整数への丸めと、表示のズレ
小数を整数に丸めると、共有ページの表示が、本人の結果画面(小数2桁)と、1%未満ズレます。最初は、小数を残す形式も検討しました。
しかし、**「最初から、表示も整数に統一すればよいのでは」**という案に至りました。内部のスコア計算は小数2桁のまま、画面の表示とURLに入れる値だけを整数にすると、本人の画面と共有ページの数字が一致し、URLも短いまま済みます。
5. 旧URLも読めるようにする
すでにSNSなどで共有された、旧形式のURLが壊れては困ります。そこで、新旧を確実に判別できることを条件にしました。
- 旧形式(JSONのBase64)は、
{"をBase64にしたeyJで必ず始まる - 新形式は、数字か
-で始まり、正規表現で厳密に判定できる
const COMPACT_SHARE_PATTERN = /^-?\d+_-?\d+_-?\d+(_\d+-\d+)+$/;
export function decodeCompactShare(value) {
if (typeof value !== "string" || !COMPACT_SHARE_PATTERN.test(value)) {
return null; // 新形式ではない
}
const [sv, av, smv, ...pairs] = value.split("_");
return {
sv: Number(sv), av: Number(av), smv: Number(smv),
rr: pairs.map((p) => { const [key, score] = p.split("-"); return [key, Number(score)]; }),
};
}
// 新形式を先に試し、ダメなら旧形式
const payload = decodeCompactShare(raw) || decodeBase64Url(raw, null);
新形式は p を持たないので、主役職はパスから復元します。
6. 見落としやすい点:URLを受け取る側のアプリ
診断サイトのローカル確認が済み、デプロイの準備ができた段階で、確認すべき点に気づきました。
「URLのパラメータが変わるので、連携先のアプリ側も対応が必要では?」
Kinkysエコシステムには、共有URLを、マッチングアプリ(Nuxt + Firebase)のプロフィール画面に貼ると、診断結果を取り込める連携があります。そのアプリは、URLを検証して ?r= を旧形式(Base64のJSON)としてしか読めません。このままデプロイすると、新しく発行されたURLは、「URLの形式を確認してください」と弾かれ、診断結果を取り込めなくなります。
発行する側の実装に集中していると、受け取る側の存在が計画から抜けやすいです。AIに実装を任せる場合は、「指示した範囲」が正確に作られるぶん、影響範囲の洗い出しは、人間が意識して確認する必要があると感じました。
受け取る側(TypeScript)の対応
同じ判定を、受け取る側にも入れました。
const COMPACT_RESULT_PATTERN = /^-?\d+_-?\d+_-?\d+(_\d+-\d+)+$/
const payload = getCompactPayload(rawResult) || getBase64UrlJson(rawResult)
// 新形式は p を持たないので、パスの役職キーから復元する
const role = String(payload.p || getRoleIdByKey(pathMatch[1]))
既存の検証は、そのまま残しました。
- スコアの範囲(-100〜100、0〜100)
- 上位5件まで、重複なし
- パスの役職と、先頭の役職が一致すること(新形式では、取り違えを防ぐ役割も持つ)
テストも追加しました(短縮形式の復元、役職の不一致、範囲外・6件以上・重複・形式違いの拒否)。
7. 検証:2つのリポジトリをまたいだ結合テスト
別々のリポジトリで実装したので、「発行側が作ったURLを、受け取る側が読めるか」を、発行側の実際のスコア計算コードと、受け取る側の解析関数を組み合わせて確認しました。
// 一時的な結合テスト(コミットなし)
for (let i = 0; i < 2000; i++) {
const answers = randomAnswers(); // ランダムな回答
const result = calculateResult(definition, answers); // 発行側の実コード
const url = `.../share/${result.primary}/?r=${encodeCompactShare(payload)}`;
const parsed = parseResultUrl(url); // 受け取る側の実コード
expect(parsed.role).toBe(expectedRoleKey);
expect(parsed.stance).toBe(Math.round(result.stance.score));
}
ランダム回答2,000件で、16役職すべてについて、主役職とスタンスが一致しました。すでに連携済みのユーザーが保存していた旧形式のURLも、更新後の解析で読めることを、保存データ(読み取りのみ)で確認しています。
?r= の長さは、例のペイロード(上位5件)で、136文字から35文字になりました。
8. デプロイの順序
| 順 | 対象 | 理由 |
|---|---|---|
| 1 | 受け取る側(新旧両対応) | 新URLが発行される前に、読める状態にする |
| 2 | 発行する側(新形式に切り替え) | 受け取る側が準備できてから |
逆にすると、新URLが読めない期間ができます。 旧URLは、どちらの順でも読めます。
最後に:16診断と、Kinkysエコシステムの紹介
ここまで読んでくださった方向けに、今回の舞台になったサービスを紹介します。
BDSM向け16タイプ診断
BDSMの世界での自分の傾向を、言葉にするための無料の診断です。
- 30問に、5段階(はい/たぶんそう/どちらでもない/たぶんちがう/いいえ)で答えます
- 結果は、16種類の「役職」(支配者、緊縛師、奴隷志願者、ロープバニー、リトルなど)で出ます
- 役職のほかに、スタンス(Lead/Even/Yield=主導/対等/委ねる)、主導(Top/Middle/Bottom)、SM軸(Sadistic/Masochistic/Versatile)も出ます。1つの肩書きに決めるのではなく、複数の軸で傾向を見る作りです
- 結果は、画像カードの保存、リンクのコピー、Xでのシェアができます(今回の記事の共有URLです)
- 結果の共有URLは、Kinkyerのプロフィールに貼って取り込むこともできます(今回、受け取る側のアプリとして出てきたのが、このKinkyerです)
Kinkysエコシステム
私は、SM・BDSM・フェチなどの「界隈」向けのサービス群を、個人で開発・運営しています。診断は、その入口のひとつです。
Kinkyer(マッチングアプリ)
SM特化のSNS型マッチングアプリです(Nuxt 3 + Firebase)。
- 男女とも基本無料です。有料プランは、データの保存期間を延ばすためのものです
- 年齢確認(本人確認書類の提出)を、男女とも行っています
- 受け取る「いいね」を、年齢などの条件で絞り込めるフィルタリングがあります
- マッチング前でも、相手が許可した場合のみ、メッセージを送れます(送信数に上限あり)。性癖が合っても、話してみると違うことがよくあるため、ミスマッチを減らすための仕組みです
- 行きつけのお店をプロフィールに登録でき、マッチ後のお店探しにも使えます
Kinky'up(界隈向けSNSアプリ)
iOS/Androidの、界隈に特化したSNSアプリです(Flutter + Supabase)。きっかけは、Xの凍結が相次いだことで、凍結リスクの低い場所が欲しいと思ったことでした。
- イベント管理:主催者はイベントを作成でき、参加者はイベントへの参加と同時に、グループチャットへ自動で追加されます
- アイテム管理:道具を作っている人のポートフォリオページです
- 出勤投稿:1つの投稿で、今月の出勤情報をまとめられます
- Web名刺:プロフィールのURLを1つ渡すだけで、投稿やイベントを紐付けて見せられます
そのほか
- KinkySpotGuide(https://database.kinkyer.jp):SMバー・フェティッシュバー・緊縛サロンのデータベース
- KinkyBDSMZine(https://kinkyer.jp/media/):界隈向けの情報メディア
診断で自分の傾向を言葉にして、Kinkyerで相手を探し、Kinky'upで界隈とつながる。そんな流れを目指して、少しずつ作っています。興味を持った方は、まず診断を試してみてください。