มีหลายวิธีที่ใช้ป้องกันการกรอกข้อมูลเล่นๆ หรือสแปมในฟอร์มได้ครับ แนะนำให้ใช้ร่วมกันหลายวิธี เพราะแต่ละวิธีป้องกันคนละแบบ:
1. CAPTCHA / reCAPTCHA
- Google reCAPTCHA v3 (ทำงานเงียบๆ ไม่ต้องให้ผู้ใช้คลิกยืนยัน) หรือ v2 (ติ๊ก "ฉันไม่ใช่โปรแกรมอัตโนมัติ")
- hCaptcha หรือ Cloudflare Turnstile เป็นทางเลือกที่โหลดเร็วกว่าและเป็นมิตรกับความเป็นส่วนตัวมากกว่า
2. Honeypot field
- ใส่ input field ที่ซ่อนไว้ด้วย CSS (เช่น
display:none) คนจริงจะมองไม่เห็นและไม่กรอก แต่บอทที่กรอกทุกช่องอัตโนมัติจะติดกับ ถ้าฟิลด์นี้มีค่า = ปฏิเสธการส่งทันที - ข้อดีคือไม่รบกวนผู้ใช้เลย
3. Rate limiting / Throttling
- จำกัดจำนวนครั้งที่ IP หรือ session เดียวกันส่งฟอร์มได้ในช่วงเวลาหนึ่ง (เช่น ส่งได้ 1 ครั้งต่อ 1 นาที)
4. ตรวจสอบเวลาในการกรอกฟอร์ม (Time-based check)
- ถ้าฟอร์มถูกส่งเร็วเกินไป (เช่นภายใน 1-2 วินาทีหลังโหลดหน้า) แสดงว่าน่าจะเป็นบอท ไม่ใช่คนจริง
5. ยืนยันตัวตนผ่านอีเมล/เบอร์โทร
- ส่งลิงก์ยืนยัน (double opt-in) หรือ OTP ก่อนที่ข้อมูลจะถูกบันทึกจริง
6. ตรวจสอบข้อมูลฝั่ง Server (Server-side validation)
- อย่าเชื่อ validation ฝั่ง client อย่างเดียว ต้องตรวจซ้ำที่ server เช่น รูปแบบอีเมล เบอร์โทร ความยาวข้อความ
7. บล็อกคำ/รูปแบบที่น่าสงสัย
- กรองคำหยาบ ลิงก์สแปม หรือรูปแบบข้อความซ้ำๆ (เช่นใช้ Akismet ถ้าเป็น WordPress)
8. จำกัดสิทธิ์ตาม IP/ประเทศ (ถ้าจำเป็น)
- ถ้าธุรกิจมีกลุ่มเป้าหมายเฉพาะพื้นที่ อาจบล็อก IP จากประเทศที่ไม่เกี่ยวข้อง
Code.gs
/**
* Code.gs
* รับข้อมูลจากฟอร์ม HTML แล้วตรวจสอบสแปมก่อนบันทึกลง Google Sheet
*
* วิธีติดตั้ง:
* 1. เปิด Google Sheet ที่จะใช้เก็บข้อมูล -> Extensions > Apps Script
* 2. วางโค้ดนี้แทนไฟล์ Code.gs เดิม
* 3. สร้างชีทชื่อ "Responses" (หรือแก้ชื่อในโค้ดให้ตรงกับชีทที่มี)
* 4. Deploy > New deployment > Web app
* - Execute as: Me
* - Who has access: Anyone
* 5. คัดลอก URL ที่ได้ไปใส่ในตัวแปร GAS_URL ของไฟล์ index.html
*/
const SHEET_NAME = 'Responses';
const RATE_LIMIT_SECONDS = 60; // 1 client id ส่งได้ 1 ครั้งต่อช่วงเวลานี้
const MIN_FILL_SECONDS = 3; // ต้องใช้เวลากรอกฟอร์มอย่างน้อยเท่านี้ (วินาที)
const MAX_MESSAGE_LENGTH = 2000;
function doPost(e) {
const output = ContentService.createTextOutput();
output.setMimeType(ContentService.MimeType.JSON);
try {
const data = JSON.parse(e.postData.contents);
// ---- 1) เช็ค Honeypot ----
if (data.website && data.website.trim() !== '') {
// เป็นบอท: ปฏิเสธเงียบๆ (ไม่ต้องบอกเหตุผลตรงๆ ให้บอทรู้)
return output.setContent(JSON.stringify({ status: 'success' }));
}
// ---- 2) เช็คเวลากรอกฟอร์ม ----
const elapsed = Number(data.elapsedSeconds || 0);
if (elapsed < MIN_FILL_SECONDS) {
return output.setContent(JSON.stringify({ status: 'rejected' }));
}
// ---- 3) ตรวจสอบข้อมูลพื้นฐาน ----
const name = sanitize(data.name);
const email = sanitize(data.email);
const message = sanitize(data.message);
if (!name || !email || !message) {
return output.setContent(JSON.stringify({ status: 'invalid_data' }));
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(email)) {
return output.setContent(JSON.stringify({ status: 'invalid_email' }));
}
if (message.length > MAX_MESSAGE_LENGTH) {
return output.setContent(JSON.stringify({ status: 'message_too_long' }));
}
// ---- 4) Rate limiting ด้วย CacheService ----
const clientId = String(data.clientId || 'unknown');
const cache = CacheService.getScriptCache();
const cacheKey = 'submit_' + clientId;
if (cache.get(cacheKey)) {
return output.setContent(JSON.stringify({ status: 'too_many_requests' }));
}
cache.put(cacheKey, '1', RATE_LIMIT_SECONDS);
// ---- 5) กรองคำ/ลิงก์สแปมเบื้องต้น (ปรับ list ได้ตามต้องการ) ----
if (containsSpamPattern(message) || containsSpamPattern(name)) {
return output.setContent(JSON.stringify({ status: 'rejected' }));
}
// ---- 6) บันทึกลง Sheet ----
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
if (!sheet) {
throw new Error('ไม่พบชีทชื่อ ' + SHEET_NAME);
}
sheet.appendRow([new Date(), name, email, message]);
return output.setContent(JSON.stringify({ status: 'success' }));
} catch (err) {
return output.setContent(JSON.stringify({ status: 'error', message: err.message }));
}
}
/**
* ทำความสะอาดข้อความเบื้องต้น: ตัดช่องว่างหัวท้าย, จำกัดความยาว, ตัด tag HTML พื้นฐาน
*/
function sanitize(value) {
if (!value) return '';
return String(value)
.trim()
.replace(/<[^>]*>/g, '') // ตัด HTML tag ป้องกัน injection
.slice(0, 5000);
}
/**
* ตรวจจับรูปแบบที่มักพบในสแปม เช่น จำนวนลิงก์เยอะผิดปกติ
* ปรับแต่ง/เพิ่มคำต้องห้ามได้ตามการใช้งานจริง
*/
function containsSpamPattern(text) {
if (!text) return false;
// นับจำนวนลิงก์ในข้อความ ถ้ามากกว่า 2 ลิงก์ ถือว่าน่าสงสัย
const linkMatches = text.match(/https?:\/\/\S+/g) || [];
if (linkMatches.length > 2) return true;
// ตัวอย่างคำต้องห้าม (ปรับตามบริบทจริง)
const bannedWords = ['viagra', 'casino', 'crypto airdrop', 'bit.ly'];
const lower = text.toLowerCase();
return bannedWords.some(function (w) { return lower.indexOf(w) !== -1; });
}
index.html
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>แบบฟอร์มติดต่อ</title>
<style>
* { box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, sans-serif;
background: #f4f6f8;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
padding: 20px;
}
.form-container {
background: #fff;
padding: 32px;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
width: 100%;
max-width: 420px;
}
h2 {
margin-top: 0;
color: #222;
font-size: 22px;
}
label {
display: block;
margin-top: 16px;
margin-bottom: 6px;
font-size: 14px;
color: #444;
font-weight: 600;
}
input, textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid #d5d9de;
border-radius: 8px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s;
}
input:focus, textarea:focus {
outline: none;
border-color: #4a7dff;
}
textarea { resize: vertical; min-height: 90px; }
/* Honeypot: ซ่อนจากสายตาคน แต่บอทที่กรอกทุกช่องจะยังเห็นและกรอก */
.hp-field {
position: absolute;
left: -9999px;
top: -9999px;
height: 0;
width: 0;
overflow: hidden;
}
button {
margin-top: 22px;
width: 100%;
padding: 12px;
background: #4a7dff;
color: #fff;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
button:hover { background: #3a67e0; }
button:disabled { background: #a9bce8; cursor: not-allowed; }
#statusMsg {
margin-top: 14px;
font-size: 14px;
text-align: center;
min-height: 20px;
}
.success { color: #1a8b3f; }
.error { color: #d92d20; }
</style>
</head>
<body>
<div class="form-container">
<h2>ติดต่อเรา</h2>
<form id="contactForm">
<label for="name">ชื่อ</label>
<input type="text" id="name" name="name" required>
<label for="email">อีเมล</label>
<input type="email" id="email" name="email" required>
<label for="message">ข้อความ</label>
<textarea id="message" name="message" required></textarea>
<!-- Honeypot field: ห้ามใส่ label ให้เห็น, บอทมักกรอกทุกช่องที่เจอ -->
<div class="hp-field" aria-hidden="true">
<label for="website">Website</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</div>
<button type="submit" id="submitBtn">ส่งข้อความ</button>
<div id="statusMsg"></div>
</form>
</div>
<script>
// ===== ตั้งค่า =====
const GAS_URL = 'https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec'; // เปลี่ยนเป็น URL ของคุณ
const MIN_FILL_SECONDS = 3; // เวลาขั้นต่ำที่ต้องใช้กรอกฟอร์ม (กันบอทที่ submit เร็วผิดปกติ)
const form = document.getElementById('contactForm');
const statusMsg = document.getElementById('statusMsg');
const submitBtn = document.getElementById('submitBtn');
const formLoadTime = Date.now();
// สร้าง client id แบบง่ายๆ เก็บใน sessionStorage เพื่อใช้ประกอบการ rate-limit ฝั่ง server
function getClientId() {
let id = sessionStorage.getItem('cid');
if (!id) {
id = 'c_' + Date.now() + '_' + Math.random().toString(36).slice(2);
sessionStorage.setItem('cid', id);
}
return id;
}
function showStatus(text, type) {
statusMsg.textContent = text;
statusMsg.className = type;
}
form.addEventListener('submit', async function (e) {
e.preventDefault();
showStatus('', '');
// --- 1) เช็ค honeypot ---
const honeypotValue = form.website.value.trim();
if (honeypotValue) {
// เป็นบอทแน่นอน: แกล้งแสดงว่าสำเร็จ เพื่อไม่ให้บอทรู้ว่าโดนจับได้ แต่ไม่ส่งข้อมูลจริง
showStatus('ส่งข้อความเรียบร้อยแล้ว ขอบคุณครับ', 'success');
form.reset();
return;
}
// --- 2) เช็คเวลาในการกรอกฟอร์ม ---
const elapsedSeconds = (Date.now() - formLoadTime) / 1000;
if (elapsedSeconds < MIN_FILL_SECONDS) {
showStatus('กรุณาลองใหม่อีกครั้ง', 'error');
return;
}
// --- 3) เช็ครูปแบบข้อมูลเบื้องต้นฝั่ง client ---
const name = form.name.value.trim();
const email = form.email.value.trim();
const message = form.message.value.trim();
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!name || !email || !message) {
showStatus('กรุณากรอกข้อมูลให้ครบทุกช่อง', 'error');
return;
}
if (!emailPattern.test(email)) {
showStatus('รูปแบบอีเมลไม่ถูกต้อง', 'error');
return;
}
if (message.length > 2000) {
showStatus('ข้อความยาวเกินไป', 'error');
return;
}
// --- 4) ส่งข้อมูลไปยัง GAS ---
submitBtn.disabled = true;
submitBtn.textContent = 'กำลังส่ง...';
const payload = {
name: name,
email: email,
message: message,
website: honeypotValue, // ส่งไปด้วยเพื่อให้ server เช็คซ้ำ
clientId: getClientId(),
elapsedSeconds: elapsedSeconds
};
try {
const response = await fetch(GAS_URL, {
method: 'POST',
headers: { 'Content-Type': 'text/plain;charset=utf-8' }, // ใช้ text/plain เพื่อเลี่ยงปัญหา CORS preflight กับ GAS
body: JSON.stringify(payload)
});
const result = await response.json();
if (result.status === 'success') {
showStatus('ส่งข้อความเรียบร้อยแล้ว ขอบคุณครับ', 'success');
form.reset();
} else if (result.status === 'too_many_requests') {
showStatus('กรุณารอสักครู่ก่อนส่งข้อความใหม่', 'error');
} else if (result.status === 'invalid_email') {
showStatus('รูปแบบอีเมลไม่ถูกต้อง', 'error');
} else {
showStatus('เกิดข้อผิดพลาด กรุณาลองใหม่อีกครั้ง', 'error');
}
} catch (err) {
showStatus('ไม่สามารถส่งข้อมูลได้ กรุณาลองใหม่', 'error');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'ส่งข้อความ';
}
});
</script>
</body>
</html>