แบนเนอร์คุกกี้ที่กั้นได้จริง โหลด GTM กับ GA4 หลังกดยอมรับเท่านั้น
แบนเนอร์คุกกี้ที่ขึ้นมาตอนโหลดหน้าเสร็จ ทั้งที่สคริปต์เก็บข้อมูลวิ่งไปหมดแล้วตั้งแต่บรรทัดแรก มีค่าเท่ากับป้ายประกาศ ไม่ใช่ระบบขอความยินยอม ความต่างของสองอย่างนี้อยู่ที่ลำดับการโหลดไม่กี่บรรทัด แต่เป็นไม่กี่บรรทัดที่ตัดสินว่าเว็บเราทำตามที่เขียนไว้ในหน้านโยบายหรือเปล่า
พอดีผมทำเว็บที่มีทั้ง Google Tag Manager, GA4 และ AdSense อยู่ด้วยกัน แล้วต้องขึ้นแบนเนอร์คุกกี้ตามที่เขียนไว้ในหน้านโยบาย ระหว่างไล่ดูของที่ทำไว้เดิมก็เจอว่าแบนเนอร์มันขึ้นจริง ปุ่มกดได้จริง เก็บค่าไว้จริง แต่ถ้าเปิดแท็บ Network ดูตอนที่แบนเนอร์ยังค้างอยู่บนจอ จะเห็นว่า container ของ GTM โหลดไปเรียบร้อยแล้ว GA4 ยิง collect ไปแล้วหนึ่งนัด ทั้งที่ผู้ใช้ยังไม่ได้แตะปุ่มอะไรเลย ผมเลยรื้อใหม่ทั้งชุด
ค่า ID ทุกตัวในบทความเป็นค่าตัวอย่าง ให้เปลี่ยนเป็นของตัวเองก่อนใช้งาน ส่วนโครงกับลำดับการโหลดคือของจริงที่ใช้อยู่
ปัญหา: แบนเนอร์ที่ไม่ได้กั้นอะไรเลย
โค้ดที่เจอบ่อยที่สุดคือแบบนี้ คือเอาสคริปต์มาตรฐานที่ Google ให้มาวางไว้บนสุดของ <head> ตามคู่มือ แล้วค่อยเขียนแบนเนอร์แยกไว้อีกไฟล์
<!-- แบบที่เจอบ่อย และไม่ได้กั้นอะไรเลย -->
<head>
<!-- GTM โหลดทันทีที่หน้าเริ่มพาร์ส -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});
var f=d.getElementsByTagName(s)[0],j=d.createElement(s);j.async=true;
j.src='https://www.googletagmanager.com/gtm.js?id='+i;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXXX');</script>
<!-- GA4 โหลดตรงอีกตัว ซ้ำกับที่ตั้งไว้ใน GTM -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
</head>
<body>
<div id="cookie-bar">เว็บนี้ใช้คุกกี้ <button>ยอมรับ</button></div>
</body>
ตรงนี้มีปัญหาสองชั้นซ้อนกัน ชั้นแรกคือความยินยอมถูกขอหลังจากเก็บข้อมูลไปแล้ว ซึ่งขัดกับหลักที่เรียกว่า prior consent ตรง ๆ ชั้นที่สองคือ GA4 ถูกโหลดสองทาง ทั้งจาก tag ที่ตั้งไว้ใน GTM และจากบรรทัด gtag/js ที่วางไว้เอง ผลคือยอดผู้เข้าชมถูกนับซ้ำโดยที่ไม่มีใครสังเกต เพราะตัวเลขมันดูสวยขึ้น
สิ่งที่ตรวจสอบได้จริงมีอย่างเดียวคือ จำนวน request ที่ออกไปหาปลายทาง ก่อนที่ผู้ใช้จะกดยอมรับ ถ้าเลขนั้นไม่ใช่ศูนย์ แบนเนอร์สวยแค่ไหน ข้อความยาวแค่ไหน ก็ไม่มีความหมาย ตัวเลขนี้ใครก็เปิด DevTools ดูได้ในสิบวินาที รวมถึงคนที่ตั้งใจจะมาหาเรื่องเราด้วย
เลือกทางก่อนลงมือ: กั้นทั้ง container หรือใช้ consent mode
มีสองทางที่ใช้กันจริงและทั้งคู่ใช้ได้ ต่างกันที่ระดับความเข้ม
ทางแรกคือ consent mode คือยังโหลด GTM ตามปกติ แต่ประกาศสถานะเริ่มต้นเป็นปฏิเสธไว้ก่อน แล้วอัปเดตเมื่อผู้ใช้กดยอมรับ ข้อดีคือได้ข้อมูลแบบไม่ระบุตัวตนตั้งแต่ก่อนกดยอมรับ และแท็กหลายตัวรองรับอยู่แล้ว
// ทางที่หนึ่ง: consent mode — ยังโหลด GTM แต่ประกาศสถานะเริ่มต้นไว้ก่อน
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
wait_for_update: 500,
});
// ...เมื่อผู้ใช้กดยอมรับ
gtag('consent', 'update', { ad_storage: 'granted', analytics_storage: 'granted' });
ทางที่สองคือไม่โหลด container เลยจนกว่าจะกดยอมรับ ข้อเสียคือเสียข้อมูลช่วงก่อนกด และเสียความสามารถบางอย่างของ consent mode ไป ข้อดีคือมันตรวจสอบได้ด้วยตาเปล่า เปิด Network ดู ถ้าไม่มี request ออกไปเลยก็จบ ไม่ต้องไปเชื่อว่าสคริปต์ปลายทางเคารพธงที่เราตั้งไว้จริงหรือเปล่า
ส่วนตัวผมเลือกทางที่สองเป็นหลักครับ เพราะเว็บที่ผมดูแลไม่ได้ทำการตลาดแบบยิงแอดที่ต้องพึ่งข้อมูลช่วงก่อนยินยอม การเสียข้อมูลไปนิดหน่อยแลกกับข้อพิสูจน์ที่ชัดเจนว่าไม่มีอะไรออกไปนั้นคุ้มกว่ามาก ใครที่ต้องพึ่งข้อมูลแอดจริงจังก็เลือกทางแรกได้ตามสะดวก แต่ต้องยอมรับว่าต้องไล่ตรวจแต่ละแท็กเองว่าเคารพธงหรือเปล่า
โหลดก่อน แล้วบอกว่าห้ามเก็บ
- ได้ข้อมูลแบบไม่ระบุตัวตนก่อนกด
- แท็กในระบบเดียวกันรองรับเอง
- พิสูจน์ยาก ต้องเชื่อปลายทาง
- เหมาะกับเว็บที่พึ่งข้อมูลแอด
ไม่โหลดจนกว่าจะกด
- ก่อนกดไม่มี request ออกเลย
- พิสูจน์ได้ด้วย Network หรือสคริปต์
- เสียข้อมูลช่วงก่อนกดทั้งหมด
- เหมาะกับเว็บเนื้อหาที่รัน AdSense
สามสถานะที่ต้องออกแบบให้ครบ
คนมักออกแบบแค่สองสถานะคือยังไม่กด กับกดยอมรับแล้ว แล้วลืมสถานะที่สาม คือ กดปฏิเสธไปแล้ว ซึ่งต้องไม่ถามซ้ำทุกครั้งที่เปลี่ยนหน้า ไม่งั้นผู้ใช้จะรำคาญจนไปกดยอมรับเพื่อให้มันหายไป ซึ่งนั่นไม่ใช่ความยินยอมที่ได้มาโดยสมัครใจ
แบนเนอร์: ปุ่มสองปุ่มต้องเท่ากัน
เรื่องหน้าตามีข้อเดียวที่ผมถือว่าสำคัญคือปุ่มยอมรับกับปฏิเสธต้องมีน้ำหนักเท่ากัน ขนาดเท่ากัน อ่านง่ายเท่ากัน การทำปุ่มปฏิเสธให้เป็นตัวหนังสือจาง ๆ เล็ก ๆ มุมล่าง คือการออกแบบให้คนกดผิด และมันทำให้ความยินยอมที่ได้มาไม่มีน้ำหนัก
<!-- วางท้าย body — ไม่ต้องรีบ ไม่ต้องบล็อกการเรนเดอร์ -->
<div id="ck-bar" class="ck-bar" hidden role="dialog" aria-label="การใช้คุกกี้">
<p class="ck-txt">เว็บนี้ใช้คุกกี้เพื่อวัดผลการใช้งานและแสดงโฆษณา
ถ้ายังไม่ยอมรับ เว็บยังใช้งานได้ตามปกติทุกส่วน
<a href="/privacy">อ่านนโยบายความเป็นส่วนตัว</a></p>
<div class="ck-act">
<button type="button" id="ck-no" class="ck-btn">ปฏิเสธ</button>
<button type="button" id="ck-yes" class="ck-btn ck-primary">ยอมรับ</button>
</div>
</div>
.ck-bar{position:fixed;left:0;right:0;bottom:0;z-index:9999;display:flex;gap:16px;
align-items:center;flex-wrap:wrap;padding:16px 20px;
background:#17130e;color:#f1ece0;font-size:.92rem;line-height:1.7}
.ck-txt{margin:0;flex:1 1 320px}
.ck-txt a{color:#f3713f}
.ck-act{display:flex;gap:10px;flex:0 0 auto}
/* ปุ่มสองปุ่มขนาดเท่ากัน อ่านง่ายเท่ากัน */
.ck-btn{min-width:128px;padding:11px 18px;border:1px solid #f1ece0;border-radius:2px;
background:transparent;color:#f1ece0;font-size:.92rem;cursor:pointer}
.ck-primary{background:#e5330b;border-color:#e5330b;color:#fff}
@media (max-width:480px){.ck-act{width:100%}.ck-btn{flex:1 1 0}}
แกนกลาง: ตัวโหลด GTM ที่เรียกซ้ำไม่ได้
หัวใจอยู่ที่ตรงนี้ครับ สคริปต์ของ GTM ต้องอยู่ในฟังก์ชัน และฟังก์ชันนั้นต้องถูกเรียกจากทางเดียวคือเมื่อสถานะเป็นยอมรับเท่านั้น พร้อมตัวกันเรียกซ้ำ เพราะถ้าโหลด container สองรอบ dataLayer จะถูก process ซ้ำและยอดจะเพี้ยน
// consent.js — ทั้งหมดของการกั้นอยู่ในไฟล์นี้ไฟล์เดียว
(function () {
'use strict';
var KEY = 'ck_consent'; // accepted | declined | (ไม่มีค่า = ยังไม่เลือก)
var GTM_ID = 'GTM-XXXXXXX'; // เปลี่ยนเป็นของตัวเอง
var loaded = false;
function readState() {
try { return localStorage.getItem(KEY); } catch (e) { return null; }
}
function writeState(v) {
try { localStorage.setItem(KEY, v); } catch (e) { /* โหมดส่วนตัว เขียนไม่ได้ ไม่ต้องพัง */ }
}
function loadGTM() {
if (loaded) return; // กันเรียกซ้ำ สำคัญมาก
loaded = true;
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ 'gtm.start': Date.now(), event: 'gtm.js' });
var j = document.createElement('script');
j.async = true;
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + GTM_ID;
document.head.appendChild(j);
}
function accept() {
writeState('accepted');
hideBar();
document.documentElement.classList.remove('ck-noads');
loadGTM();
showAds();
}
function decline() {
writeState('declined');
hideBar(); // ไม่โหลดอะไรทั้งนั้น ช่องโฆษณายังซ่อนอยู่
}
function hideBar() { var b = document.getElementById('ck-bar'); if (b) b.hidden = true; }
function showBar() {
var b = document.getElementById('ck-bar');
if (!b) return;
b.hidden = false;
document.getElementById('ck-yes').addEventListener('click', accept);
document.getElementById('ck-no').addEventListener('click', decline);
}
var state = readState();
if (state !== 'accepted') document.documentElement.classList.add('ck-noads');
if (state === 'accepted') { loadGTM(); showAds(); }
else if (state === null) { document.addEventListener('DOMContentLoaded', showBar); }
window.ckManage = function () { // ให้ปุ่มท้ายเว็บเรียกเพื่อเปลี่ยนใจ
try { localStorage.removeItem(KEY); } catch (e) {}
location.reload();
};
})();
ในหน้าต่างส่วนตัวของบางเบราว์เซอร์ หรือเมื่อผู้ใช้ตั้งค่าบล็อกข้อมูลเว็บไซต์ไว้ คำสั่ง localStorage.setItem จะโยน error ออกมา ไม่ได้คืนค่า false ถ้าไม่ครอบ try ไว้ ปุ่มยอมรับจะกดแล้วไม่มีอะไรเกิดขึ้นเลยเพราะสคริปต์ตายก่อนถึงบรรทัดโหลด GTM และเส้นทางที่จำค่าไม่ได้ต้องยังใช้งานได้ คือถามใหม่รอบหน้า ไม่ใช่หน้าพัง
GA4 ต้องเป็นแท็กข้างใน GTM และตั้งใจไม่ใส่ noscript
ถ้าจะใช้ GTM แล้ว ให้ตั้ง GA4 เป็น tag ข้างใน container ไปเลย อย่าไปวาง gtag/js คู่ไว้ในหน้าอีกตัว เพราะมันคือการยิงสองทางไปหาปลายทางเดียวกัน ยอดจะเกินจริงและหาสาเหตุยากมากเวลาตัวเลขไม่ตรงกับความรู้สึก
// ถ้ามีอีเวนต์ของเราเอง ให้ push เข้า dataLayer แล้วให้ GTM เป็นคนตัดสินใจส่งต่อ
function track(name, params) {
if (!window.dataLayer) return; // ยังไม่ยอมรับ ก็ไม่มี dataLayer ให้ push
window.dataLayer.push(Object.assign({ event: name }, params || {}));
}
// ตัวอย่าง: track('form_submit', { form_id: 'contact' });
// GA4 config อยู่ใน GTM ตัวเดียว ไม่มี gtag/js ในหน้าอีกแล้ว
อีกเรื่องที่ผมตั้งใจไม่ใส่คือ noscript iframe ที่คู่มือของ GTM ให้มาวางไว้ต้น <body>
<!-- ตัวนี้ผมตั้งใจไม่ใส่ เพราะมันทำงานนอกเหนือการควบคุมของ JS ทั้งหมด -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
เหตุผลตรงไปตรงมาครับ ตัวกั้นทั้งหมดของเราเป็น JavaScript ถ้าผู้ใช้ปิด JavaScript แบนเนอร์ก็ไม่ขึ้น ปุ่มก็กดไม่ได้ แต่ iframe ตัวนี้ยังโหลดตามปกติ กลายเป็นว่าคนที่ระวังตัวที่สุดคือคนที่โดนเก็บข้อมูลโดยไม่มีโอกาสปฏิเสธเลย ที่เสียไปคือข้อมูลของผู้ใช้กลุ่มที่ปิด JavaScript ซึ่งน้อยมากอยู่แล้ว เทียบกับการเปิดช่องโหว่ที่ตรวจแล้วอธิบายไม่ได้ ผมเลือกไม่ใส่
AdSense: ซ่อนช่องไว้ก่อน แล้วค่อย push ตอนยอมรับ
เว็บที่รันโฆษณามีเงื่อนไขเพิ่มอีกชั้น คือช่อง <ins class="adsbygoogle"> ถูกวางไว้ในหน้าตั้งแต่แรก ถ้าไม่ทำอะไรเลย มันจะเป็นกล่องว่างค้างอยู่กลางเนื้อหาระหว่างที่ยังไม่ยอมรับ วิธีที่ผมใช้คือซ่อนด้วย CSS ที่ผูกกับ class บน <html> แล้วปลดตอนยอมรับ
/* ซ่อนช่องโฆษณาตราบใดที่ยังไม่ได้รับความยินยอม — ไม่ให้เหลือช่องว่างในเนื้อหา */
html.ck-noads .adsbygoogle,
html.ck-noads .ad-slot { display: none !important; }
// ส่วนของ AdSense ใน consent.js — เรียกจาก accept() เท่านั้น
var ADS_CLIENT = 'ca-pub-XXXXXXXXXXXXXXXX'; // เปลี่ยนเป็นของตัวเอง
var adsLoaded = false;
function showAds() {
if (adsLoaded) return;
adsLoaded = true;
var s = document.createElement('script');
s.async = true;
s.crossOrigin = 'anonymous';
s.src = 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=' + ADS_CLIENT;
s.onload = function () {
// สั่งเรนเดอร์ช่องที่มีอยู่แล้วในหน้า หนึ่ง push ต่อหนึ่งช่อง
var slots = document.querySelectorAll('.adsbygoogle:not([data-adsbygoogle-status])');
for (var i = 0; i < slots.length; i++) {
try { (window.adsbygoogle = window.adsbygoogle || []).push({}); } catch (e) {}
}
};
document.head.appendChild(s);
}
ตัวเลือก .adsbygoogle:not([data-adsbygoogle-status]) สำคัญกว่าที่คิด เพราะช่องที่สคริปต์เรนเดอร์ไปแล้วจะมีแอตทริบิวต์นั้นติดอยู่ ถ้าไปนับรวมแล้ว push เกินจำนวนช่องจริง จะได้ error ว่า All ins elements already have ads in them ในคอนโซล ซึ่งไม่ทำให้หน้าพัง แต่มันกลบ error ตัวอื่นที่สำคัญกว่าจนอ่านคอนโซลไม่รู้เรื่อง
ตรวจด้วยเบราว์เซอร์จริง: request ปลายทางต้องเป็นศูนย์
ทั้งหมดที่เขียนมาจะเชื่อถือได้ก็ต่อเมื่อมีตัวเลขยืนยัน ผมเขียนตัวตรวจที่เปิดหน้าเว็บ ดักทุก request แล้วนับเฉพาะที่วิ่งไปหาโดเมนปลายทางของ GTM, GA4 และ AdSense ต้องได้ศูนย์ก่อนกด และต้องมากกว่าศูนย์หลังกดยอมรับ
// tools/consent-check.cjs — ด่านที่ตอบว่าแบนเนอร์กั้นได้จริงหรือเปล่า
const { chromium } = require('playwright');
const WATCH = ['googletagmanager.com', 'google-analytics.com', 'analytics.google.com',
'googlesyndication.com', 'doubleclick.net'];
const hits = [];
(async () => {
const b = await chromium.launch();
const p = await b.newPage();
p.on('request', r => { if (WATCH.some(d => r.url().includes(d))) hits.push(r.url()); });
await p.goto('https://example.com/?v=' + Date.now(), { waitUntil: 'networkidle' });
await p.waitForTimeout(2500);
const before = hits.length;
console.log(`[${before === 0 ? 'OK' : 'FAIL'}] ก่อนกดยอมรับ: ${before} request`);
hits.forEach(u => console.log(' ', u));
const bar = await p.$('#ck-bar:not([hidden])');
console.log(`[${bar ? 'OK' : 'FAIL'}] แบนเนอร์แสดงอยู่`);
await p.click('#ck-yes');
await p.waitForTimeout(3000);
const after = hits.length - before;
console.log(`[${after > 0 ? 'OK' : 'FAIL'}] หลังกดยอมรับ: ${after} request`);
// กดยอมรับแล้วโหลดใหม่ ต้องไม่ถามซ้ำ และต้องไม่โหลด container ซ้ำซ้อน
const mark = hits.length;
await p.reload({ waitUntil: 'networkidle' });
await p.waitForTimeout(2000);
const gtm = hits.slice(mark).filter(u => u.includes('gtm.js')).length;
console.log(`[${gtm === 1 ? 'OK' : 'FAIL'}] โหลดซ้ำหน้าเดิม: gtm.js = ${gtm} ครั้ง`);
await b.close();
process.exit(before === 0 && after > 0 && gtm === 1 ? 0 : 1);
})();
$ node tools/consent-check.cjs
[OK] ก่อนกดยอมรับ: 0 request
[OK] แบนเนอร์แสดงอยู่
[OK] หลังกดยอมรับ: 7 request
[OK] โหลดซ้ำหน้าเดิม: gtm.js = 1 ครั้ง
บรรทัดสุดท้ายคือตัวที่ผมเพิ่มทีหลังหลังจากเผลอเรียก loadGTM() สองที่ในโค้ดเวอร์ชันก่อน แล้วยอดผู้เข้าชมสูงเป็นสองเท่าอยู่สามวัน ตัวกันเรียกซ้ำในฟังก์ชันช่วยได้ก็จริง แต่ตัวตรวจที่นับให้เห็นเป็นตัวเลขดีกว่าการเชื่อว่าเราเขียนกันไว้แล้ว
ถอนความยินยอมได้ และสรุปเป็นขั้นตอน
ข้อสุดท้ายที่คนลืมกันบ่อยคือคนที่กดไปแล้วต้องเปลี่ยนใจได้ ถ้าไม่มีทางกลับ ก็เท่ากับถามครั้งเดียวตลอดชีวิต ผมวางปุ่มเล็ก ๆ ไว้ที่ท้ายเว็บทุกหน้า
<!-- ท้ายเว็บ ข้าง ๆ ลิงก์นโยบาย -->
<button type="button" class="foot-link" onclick="ckManage()">จัดการคุกกี้</button>
- เอาสคริปต์ GTM และ GA4 ออกจาก
<head>ให้หมด ย้ายเข้าไปอยู่ในฟังก์ชันloadGTM() - ตั้ง GA4 เป็น tag ข้างใน container ตัวเดียว อย่าวาง
gtag/jsคู่ไว้ในหน้าอีก - ออกแบบให้ครบสามสถานะ และอย่าถามซ้ำกับคนที่กดปฏิเสธไปแล้ว
- ทำปุ่มยอมรับกับปฏิเสธให้มีน้ำหนักเท่ากัน ทั้งขนาดและความชัด
- ซ่อนช่องโฆษณาด้วย class บน
<html>ระหว่างที่ยังไม่ยอมรับ แล้ว push ตามจำนวนช่องที่ยังไม่ถูกเรนเดอร์ - ครอบทุกการอ่านเขียน
localStorageด้วย try และให้เส้นทางที่จำค่าไม่ได้ยังใช้งานเว็บได้ปกติ - รันตัวตรวจที่นับ request ปลายทาง ต้องได้ศูนย์ก่อนกด และ
gtm.jsต้องเท่ากับหนึ่งต่อหนึ่งหน้า - วางปุ่มจัดการคุกกี้ไว้ท้ายเว็บทุกหน้า ให้คนเปลี่ยนใจได้เอง
เรื่องนี้ผมมองว่าไม่ใช่งานตามกฎอย่างเดียวนะครับ มันเป็นเรื่องที่ตรวจสอบได้ด้วยเครื่องมือที่ใครก็มี ใครจะเปิด DevTools ดูเว็บเราตอนไหนก็ได้ และสิ่งที่เขาเห็นจะตรงกับที่เราเขียนไว้ในหน้านโยบายหรือไม่ตรง มันมีคำตอบเดียว ส่วนจะเลือกทาง consent mode หรือกั้นทั้ง container ก็ดูจากว่าเว็บตัวเองพึ่งข้อมูลช่วงก่อนยินยอมแค่ไหน ตามสะดวกครับ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย