背景
チラシに載せる QR コードから、どれくらい人が来たのかを知りたいことがあります。
そのために、URL に UTM パラメータを付けて QR コードにするツールを作りました。
UTM パラメータは、utm_source=flyer のように URL のクエリに付ける目印だ。
アクセス解析ツール(Google アナリティクスなど)が読み取って、流入元ごとに集計します。
付けるだけなので url + '?utm_source=flyer' で良さそうに見えます…
ところが試してみると、URL の形によっては静かに壊れます。
結論
パラメータの追加は文字列連結でやらず、new URL() と searchParams.set() に任せます。
# の位置も ? と & の使い分けも、同名パラメータの置き換えも面倒を見てくれます。
ただし searchParams を触ると、クエリ全体が書き直されます。
もともと %20 だった空白が + に変わるので、そこだけは知っておく必要があります。
文字列連結で何が起きるか
URL は、? の後ろがクエリ、# の後ろがフラグメントという順番で決まっています。
フラグメントは、ページ内の位置などを指す部分で、サーバーには送られない。
この順番は RFC 3986 という URL の仕様で決まっています。
https://www.rfc-editor.org/rfc/rfc3986
下記は、ページ内リンクの付いた URL に文字列で足した例です。
raw が元の URL で、Node.js v24.11.1 で実行しました。
const raw = 'https://example.com/lp?a=1#price';
console.log(raw + '?utm_source=flyer');
console.log(raw + '&utm_source=flyer');
const u = new URL(raw + '&utm_source=flyer');
console.log(u.search, u.hash);
出力は下記でした。
https://example.com/lp?a=1#price?utm_source=flyer
https://example.com/lp?a=1#price&utm_source=flyer
?a=1 #price&utm_source=flyer
3行目を見ると、クエリは ?a=1 のままです。
utm_source は # の後ろに入っていて、クエリとしては読めません。
ページは普通に開くので、エラーにならないのが厄介です。
QR コードは印刷してしまうと直せないので、気づいたときには手遅れな気がします…
new URL に任せる
下記は、URL の文字列とパラメータのオブジェクトを受け取って、付け足した URL を返す関数です。
searchParams は、クエリを読み書きするための URL のプロパティだ。
function addParams(raw, params) {
const u = new URL(raw);
for (const [k, v] of Object.entries(params)) {
u.searchParams.set(k, v);
}
return u.toString();
}
console.log(addParams('https://example.com/lp?a=1#price', { utm_source: 'flyer', utm_medium: 'qr' }));
console.log(addParams('https://example.com/lp?utm_source=old', { utm_source: 'flyer' }));
console.log(addParams('https://example.com/#/lp', { utm_source: 'flyer' }));
出力は下記でした。
https://example.com/lp?a=1&utm_source=flyer&utm_medium=qr#price
https://example.com/lp?utm_source=flyer
https://example.com/?utm_source=flyer#/lp
1行目は #price の前に入り、? と & も正しく使い分けられています。
2行目は utm_source が2つにならず、set で値が置き換わっています。
3行目は #/lp のような、フラグメントで画面を切り替える形の URL です。
こちらもクエリは # の前に入ります。
副作用: クエリ全体が書き直される
便利な反面、知らないと驚く点が1つありました。
searchParams.set() を呼ぶと、触っていない既存のパラメータも書き直されます。
下記は、上の addParams をそのまま使った例です。
console.log(addParams('https://example.com/lp?q=a%20b', { utm_source: 'flyer' }));
console.log(addParams('https://example.com/lp', { utm_campaign: '秋 セール' }));
出力は下記でした。
https://example.com/lp?q=a+b&utm_source=flyer
https://example.com/lp?utm_campaign=%E7%A7%8B+%E3%82%BB%E3%83%BC%E3%83%AB
q=a%20b が q=a+b に変わりました。
URLSearchParams は、クエリの形式として空白を + で書く決まりになっているからだ。
仕様は WHATWG の URL Standard にあります。
https://url.spec.whatwg.org/#urlencoded-serializing
受け取る側が URLSearchParams なら、+ は空白に戻ります。
ところが decodeURIComponent で戻している場合は、+ が残ります。
下記はその比較です。
console.log(new URLSearchParams('q=a+b').get('q')); // a b
console.log(decodeURIComponent('a+b')); // a+b
自前のパーサで受けているページに付けるなら、QR コードにする前に一度開いて確かめたほうが良さそうです。
もう1つの手として、utm_campaign の値を英数字とハイフンだけにしておくと、この問題自体を避けられます。
日本語を入れると URL も長くなり、QR コードの模様も細かくなるので、英数字に寄せておくのが無難だと思います。
ツールでの使い方
ツール側もこの方法で組み立てています。
入力欄の値を読んで、値があれば set、空なら delete するだけです。
KEYS は source や medium などの名前の配列で、u は new URL() の結果です。
KEYS.forEach(function (k) {
var v = $('p_' + k).value.trim();
if (v) { u.searchParams.set('utm_' + k, v); }
else { u.searchParams.delete('utm_' + k); }
});
文字列を組み立てるコードが無いので、# のことを考えなくて済みます。
作ったツールはここに置いています。
https://hashitosystem.com/tools/utmqr/
URL にパラメータを足すときは、文字列連結をやめて new URL() を通す。
印刷物に載せる URL なら、できあがった URL の search にパラメータが入っているかを必ず確かめてください。
本記事はAI補助で執筆した、個人開発の紹介記事です。