UTMパラメータの付け方を点検するスクリプト。ビルド後のHTMLの全リンクから utm_* を集め、命名ルール(小文字、決めた source/medium の一覧、社内リンクにUTMを付けない、utm_campaign の必須など)に反するものを出す
ビルド後の
このutm_*をutm_sourceとutm_mediumがutm_source・utm_medium・utm_campaignの#の
この
UTMの乱れは、公開の前にHTMLから見つける
UTMの
GA4は、utm_source=Instagramとutm_source=instagramは
UTMの
export const ctaUtm = (url: string, kind: string, slug: string) => {
const u = new URL(url);
u.searchParams.set('utm_source', 'yamayamabloglink');
u.searchParams.set('utm_medium', 'referral');
u.searchParams.set('utm_campaign', 'blog_cta');
u.searchParams.set('utm_content', `${kind}_${slug}`.slice(0, 100));
return u.toString();
};作る
命名ルールと、その根拠
ルールは、
| ルール | 区分 | 根拠 |
|---|---|---|
| 値と |
エラー | ヘルプに、googleとGoogleは |
utm_source・utm_medium・utm_campaignをそろえる |
エラー | ヘルプで、 |
| 名前は |
エラー | utm_cotentのような |
#の |
エラー | #より |
| 自分の |
エラー | 自社の |
| sourceと |
エラー | 自社の |
値に_・-・.だけ |
注意 | 自社の |
| mediumだけで |
注意 | 既定の |
同じutm_contentを |
注意 | 自社の |
utm_mediumの
| mediumの |
振り分けられる |
|---|---|
referral、app、link |
Referral |
email、e-mail、e_mail、e mail |
Email |
social、social-network、social-media、sm、social network、social media |
Organic Social |
display、banner、expandable、interstitial、cpm |
Display |
organic |
Organic Search |
正規表現^(.*cp.*|ppc|retargeting|paid.*)$に |
参照元に |
たとえばutm_medium=snsは、facebook.comのようにutm_source=lineのように
自分のサイトへのリンクにUTMを付けない理由
UTMは、
GA4の
サイトの
一方、ownHostsと
点検のスクリプト
スクリプトは、node utm-audit.mjs <distのフォルダ> <サイトのURL>でRULESに
// utm-audit.mjs — ビルド後の dist のHTMLから、リンクに付いた utm_* を集めて命名ルールに照らす
// 使い方: node utm-audit.mjs <distのフォルダ> <サイトのURL>
// 例: node utm-audit.mjs ./dist https://www.example.com
// 依存: cheerio(npm i -D cheerio)
import fs from 'node:fs';
import path from 'node:path';
import * as cheerio from 'cheerio';
const DIST = path.resolve(process.argv[2] ?? 'dist');
const SITE = new URL(process.argv[3] ?? 'https://www.example.com');
// ---- 命名ルール(自社で決めるもの。ここを書き換えて使う) ----
const RULES = {
// このサイト自身のホスト。ここへのリンクにUTMを付けたらエラー
internalHosts: [SITE.host, SITE.host.replace(/^www\./, '')],
// 自社の別サイト。UTMを付けるかどうかは自由だが、付けるならルールに従う。付いていない数を報告する
ownHosts: ['www.bundlyze.co.jp', 'bundlyze.co.jp'],
// 付けてよい utm_* の名前(GA4のヘルプにある9つ)。これ以外は綴り間違いとして出す
knownKeys: ['utm_id', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_source_platform', 'utm_term', 'utm_content', 'utm_creative_format', 'utm_marketing_tactic'],
// 1つでも utm_* を付けたら、必ず付ける
required: ['utm_source', 'utm_medium', 'utm_campaign'],
// 決めた値の一覧
sources: ['yamayamabloglink', 'newsletter', 'line', 'instagram', 'google', 'meta'],
mediums: ['referral', 'email', 'social', 'cpc', 'display'],
// 値に使ってよい文字(小文字の英数字と _ - .)。content は記事のスラッグが入るので長さも見る
valuePattern: /^[a-z0-9_.-]+$/,
maxLength: 100,
};
// GA4の既定のチャネルグループで、メディアだけで決まるものの早見(ヘルプの条件の一部。大文字小文字は区別しない)
// 参照元の一覧(検索サイト・SNS など)に当たるかどうかで決まるものは、ここでは判定しない
const channelByMedium = (m) => {
if (/^(referral|app|link)$/.test(m)) return 'Referral';
if (/^(email|e-mail|e_mail|e mail)$/.test(m)) return 'Email';
if (/^(display|banner|expandable|interstitial|cpm)$/.test(m)) return 'Display';
if (/^(social|social-network|social-media|sm|social network|social media)$/.test(m)) return 'Organic Social';
if (/^(.*cp.*|ppc|retargeting|paid.*)$/.test(m)) return '有料系(参照元しだい。どれにも当たらなければ Paid Other)';
if (/^(organic)$/.test(m)) return 'Organic Search';
return null; // メディアだけでは決まらない(参照元の一覧に当たらなければ Unassigned)
};
const issues = []; // { level, kind, page, href, detail }
const add = (level, kind, page, href, detail = '') => issues.push({ level, kind, page, href, detail });
function walk(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = path.join(dir, e.name);
return e.isDirectory() ? walk(p) : p.endsWith('.html') ? [p] : [];
});
}
const fileToPath = (file) => '/' + path.relative(DIST, file).split(path.sep).join('/').replace(/(^|\/)index\.html$/, '$1');
let pageCount = 0;
let linkCount = 0;
const utmLinks = []; // { page, href, params }
const ownWithoutUtm = new Map(); // 自社の別サイトへの、UTMの無いリンク: 行き先 → 件数
const combos = new Map(); // source / medium / campaign の組 → 件数
for (const file of walk(DIST)) {
const html = fs.readFileSync(file, 'utf8');
if (!/<html[\s>]/i.test(html.slice(0, 2000))) continue; // RSS などHTMLでないものは飛ばす
pageCount++;
const page = fileToPath(file);
const $ = cheerio.load(html);
$('a[href], area[href]').each((_, el) => {
const raw = $(el).attr('href').trim();
let url;
try { url = new URL(raw, SITE); } catch { return; }
if (!/^https?:$/.test(url.protocol)) return;
linkCount++;
// # の後ろに utm_ を書いても、URLの問い合わせ文字列にならない
if (/[#&?]utm_/i.test(url.hash)) add('error', '#の後ろにUTMがある', page, raw);
// 名前は大文字小文字を問わず拾う(UTM_Source のような書き間違いも見つけるため)
const params = [...url.searchParams].filter(([k]) => /^utm_/i.test(k));
if (params.length === 0) {
if (RULES.ownHosts.includes(url.host)) {
const key = url.host + url.pathname;
ownWithoutUtm.set(key, (ownWithoutUtm.get(key) ?? 0) + 1);
}
return;
}
utmLinks.push({ page, href: raw, params });
if (RULES.internalHosts.includes(url.host)) {
add('error', '自サイトへのリンクにUTMがある', page, raw);
}
const seen = new Map();
for (const [k, v] of params) {
if (seen.has(k)) add('error', '同じパラメータが2回ある', page, raw, k);
seen.set(k, v);
if (k !== k.toLowerCase()) add('error', 'パラメータ名に大文字がある', page, raw, k);
if (!RULES.knownKeys.includes(k.toLowerCase())) add('error', '知らないパラメータ名(綴り間違い?)', page, raw, k);
if (v === '') { add('error', '値が空', page, raw, k); continue; }
if (v !== v.toLowerCase()) add('error', '値に大文字がある', page, raw, `${k}=${v}`);
else if (!RULES.valuePattern.test(v)) add('warn', '値に決めた文字以外がある', page, raw, `${k}=${v}`);
if ([...v].length > RULES.maxLength) add('warn', '値が長い', page, raw, `${k}(${[...v].length}字)`);
}
for (const k of RULES.required) if (!seen.has(k)) add('error', `${k} が無い`, page, raw);
const source = (seen.get('utm_source') ?? '').toLowerCase();
const medium = (seen.get('utm_medium') ?? '').toLowerCase();
const campaign = (seen.get('utm_campaign') ?? '').toLowerCase();
if (source && !RULES.sources.includes(source)) add('error', '一覧に無い utm_source', page, raw, source);
if (medium && !RULES.mediums.includes(medium)) add('error', '一覧に無い utm_medium', page, raw, medium);
if (medium && !channelByMedium(medium)) add('warn', 'メディアだけでは既定のチャネルが決まらない', page, raw, medium);
const combo = `${source} / ${medium} / ${campaign} → ${medium ? channelByMedium(medium) ?? '?' : '?'}`;
combos.set(combo, (combos.get(combo) ?? 0) + 1);
});
}
// ---- 同じ utm_content が、違う行き先に使われていないか ----
const contentDest = new Map();
for (const { href, params } of utmLinks) {
const c = params.find(([k]) => k.toLowerCase() === 'utm_content')?.[1];
if (!c) continue;
const u = new URL(href, SITE);
const dest = u.host + u.pathname;
if (!contentDest.has(c)) contentDest.set(c, new Set());
contentDest.get(c).add(dest);
}
for (const [c, dests] of contentDest) {
if (dests.size > 1) add('warn', '同じ utm_content が別の行き先にある', '-', [...dests].join(' , '), c);
}
// ---- 結果を出す ----
const errors = issues.filter((i) => i.level === 'error');
const warns = issues.filter((i) => i.level === 'warn');
console.log(`点検したページ: ${pageCount} / http(s)のリンク: ${linkCount} / UTM付きのリンク: ${utmLinks.length}`);
console.log('source / medium / campaign の組(→ メディアから見た既定のチャネル):');
for (const [k, n] of [...combos].sort((a, b) => b[1] - a[1])) console.log(` ${k}: ${n}件`);
const ownTotal = [...ownWithoutUtm.values()].reduce((s, n) => s + n, 0);
console.log(`自社の別サイトへの、UTMの無いリンク: ${ownTotal}件(行き先 ${ownWithoutUtm.size}種類)`);
console.log(`エラー: ${errors.length} / 注意: ${warns.length}`);
const groups = new Map();
for (const i of issues) {
const key = `[${i.level === 'error' ? 'エラー' : '注意'}] ${i.kind}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(i);
}
for (const [key, list] of [...groups].sort()) {
console.log(`\n${key}: ${list.length}件`);
for (const i of list.slice(0, 5)) console.log(` ${i.page} ${i.href}${i.detail ? ` (${i.detail})` : ''}`);
if (list.length > 5) console.log(` ……ほか${list.length - 5}件`);
}
if (process.argv.includes('--own')) {
console.log('\n自社の別サイトへの、UTMの無いリンク(行き先ごと):');
for (const [k, n] of [...ownWithoutUtm].sort((a, b) => b[1] - a[1])) console.log(` ${k}: ${n}件`);
}
process.exitCode = errors.length > 0 ? 1 : 0;書く
- パラメータ名は
大文字小文字を 問わず 拾う。 UTM_Sourceのような書き間違いは、 小文字の utm_だけを探すと 見落と すため、 拾ったうえで エラーに しています。 - 値の
%の変換は、 URLの 読み取りに 任せる。 URLのsearchParamsは、%20や+を空白に、%E3...を日本語に 戻してから 値を 返すので、 見た目の 値で 判定できます。 - mediumからの
チャネルは 既定の早見に と どめる。 チャネルグループには、 参照元が 検索サイトや SNSの 一覧に 当たるか どうかで 決まる 条件も あります。 一覧は Googleが 配っている 表で、 スクリプトには 持たせていないため、 「mediumだけでは 決まらない」を 注意と して 出し、 最後は 人が 見ます。 - 自社の
別サイトへの 付けるかUTMの 無いリンクは、 エラーに せず 数える。 どうかは、 そのリンクを 相手側で どう 数えたいかで 決まるからです (結果の 節で 扱います)。
このブログで流した結果:エラー0件、注意2件
2026年10月4日23時ごろにdistの
$ node utm-audit.mjs ./dist https://www.yamayamabloglink.com
点検したページ: 270 / http(s)のリンク: 17974 / UTM付きのリンク: 319
source / medium / campaign の組(→ メディアから見た既定のチャネル):
yamayamabloglink / referral / blog_cta → Referral: 319件
自社の別サイトへの、UTMの無いリンク: 1083件(行き先 27種類)
エラー: 0 / 注意: 2
[注意] 値に決めた文字以外がある: 2件
/【stripe】next-js-15とprismaで実装するサブスクリプション機能/ https://www.bundlyze.co.jp/service/system-development?utm_source=yamayamabloglink&utm_medium=referral&utm_campaign=blog_cta&utm_content=system_%E3%80%90stripe... (utm_content=system_【stripe】next-js-15とprismaで実装するサブスクリプション機能)
/【stripe】next-js-15とprismaで実装するサブスクリプション機能/ https://www.bundlyze.co.jp/company?utm_source=yamayamabloglink&utm_medium=referral&utm_campaign=blog_cta&utm_content=system-contact_%E3%80%90stripe...#contact (utm_content=system-contact_【stripe】next-js-15とprismaで実装するサブスクリプション機能)(長い
| 見つかった |
件数 | 扱い |
|---|---|---|
utm_contentに日本語と【】が |
2件 |
今はutm_contentにutm_contentだけ別の |
| 会社サイトへの、 |
1,083本 |
数え方を |
ほかの#のutm_contentのutm_contentの頭をsystem_とsystem-contact_のように
会社サイトへの、UTMの無いリンクが1,083本あった
UTMが--ownを
$ node utm-audit.mjs ./dist https://www.yamayamabloglink.com --own
(前半は同じなので省略)
自社の別サイトへの、UTMの無いリンク(行き先ごと):
www.bundlyze.co.jp/: 720件
www.bundlyze.co.jp/column/author/tsukayama: 182件
www.bundlyze.co.jp/service/system-development: 61件
www.bundlyze.co.jp/service/website: 23件
www.bundlyze.co.jp/service/web-strategy: 22件
www.bundlyze.co.jp/service/seo-llmo: 17件
www.bundlyze.co.jp/column/requirements-definition-for-clients: 12件
www.bundlyze.co.jp/service/ai-workflow: 9件
(以下、19種類で各1〜8件)会社サイトの
これらのreferrer-policy: strict-origin-when-cross-originがcurl -Iで
| リンク | 参照元 | メディア |
|---|---|---|
| 記事末の |
yamayamabloglink |
referral |
| フッター・著者欄・本文 |
この |
referral |
どちらも
GA4の
わざと崩したリンクで、見落とさないかを確かめた
エラーが
<!doctype html><html lang="ja"><head><title>t</title></head><body>
<a href="/about/?utm_source=yamayamabloglink&utm_medium=referral&utm_campaign=blog_cta">1</a>
<a href="https://www.bundlyze.co.jp/service?utm_source=Yamayamabloglink&utm_medium=referral&utm_campaign=blog_cta">2</a>
<a href="https://www.bundlyze.co.jp/service?utm_source=yamayamabloglink&utm_medium=referral">3</a>
<a href="https://www.bundlyze.co.jp/service?utm_source=facebook.com&utm_medium=sns&utm_campaign=blog_cta">4</a>
<a href="https://www.bundlyze.co.jp/service?utm_source=yamayamabloglink&utm_medium=referral&utm_campaign=blog_cta&utm_cotent=x">5</a>
<a href="https://www.bundlyze.co.jp/service#utm_source=yamayamabloglink&utm_medium=referral&utm_campaign=blog_cta">6</a>
<a href="https://www.bundlyze.co.jp/service?utm_source=yamayamabloglink&utm_medium=referral&utm_campaign=&utm_content=a">7</a>
<a href="https://www.bundlyze.co.jp/service/website?utm_source=yamayamabloglink&utm_medium=referral&utm_campaign=blog_cta&utm_content=a">8</a>
<a href="https://www.bundlyze.co.jp/service?utm_source=yamayamabloglink&utm_medium=referral&utm_campaign=blog%20cta">9</a>
</body></html>$ node utm-audit.mjs ./fixture https://www.yamayamabloglink.com
点検したページ: 1 / http(s)のリンク: 9 / UTM付きのリンク: 8
(組の一覧は省略)
エラー: 8 / 注意: 3
[エラー] #の後ろにUTMがある: 1件
[エラー] utm_campaign が無い: 1件
[エラー] 一覧に無い utm_medium: 1件
[エラー] 一覧に無い utm_source: 1件
[エラー] 値が空: 1件
[エラー] 値に大文字がある: 1件
[エラー] 知らないパラメータ名(綴り間違い?): 1件
[エラー] 自サイトへのリンクにUTMがある: 1件
[注意] メディアだけでは既定のチャネルが決まらない: 1件
[注意] 値に決めた文字以外がある: 1件
[注意] 同じ utm_content が別の行き先にある: 1件入れたutm_campaignの#のutm_contentを
4本目のutm_medium=snsは、facebook.comなので、
最初にutm_campaignが
ビルドのたびに流すなら、注意の2件を先に片付ける
ビルドの
入れるutm_contentを
ビルド後の
株式会社bundlyzeでは、
動作確認した環境
確認日は
| 項目 | バージョン・内容 |
|---|---|
| OS | macOS 26 |
| Node.js | 22.23.2 |
| cheerio | 1.2.0 |
| 点検した |
このdistの |
| 確かめた |
実際のdistでの |
会社サイトのRULESで
参照した公式ドキュメント(確認日:2026年10月4日)
- アナリティクス ヘルプ「URL 生成ツール: カスタム URL で
キャンペーン データを :収集する」 9つの UTMパラメータ、 utm_source・utm_medium・utm_campaignを 使う 必要が ある こと、 utm_idと utm_source_platformを 強く 勧めている こと、 値は 大文字と 小文字が 区別される こと、 付けない ものは 「(not set)」に なる こと、 URL生成ツール - アナリティクス ヘルプ「[GA4]
デフォルト チャネル グループ」 :Referral・Email・Organic Social・Display・Organic Search・Paid Otherなどの条件、 条件は 大文字と 小文字を 区別しない こと、 参照元の カテゴリの 一覧 - アナリティクス ヘルプ「アナリティクスの
セッションに :セッションのついて」 始まりに、 UTMパラメータや 参照元の URLなど、 経路を 決める 情報が 含まれる こと
よくある質問
utm_source=Instagram と utm_source=instagram は、GA4で同じものとして数えられますか?
別の
UTMパラメータは、どれが必須ですか?
アナリティクスの
自分のサイトの中のリンクに UTM を付けてはいけないのですか?
この
このブログからのリンクに UTM が付いていないと、会社サイトのGA4ではどう見えますか?
ブラウザが