เกิดอะไรขึ้น พูดแบบเข้าใจง่าย
เราปรับให้ Google และโซเชียล (Facebook / LINE / X) "อ่านหน้าเว็บของเราได้ถูกต้องตั้งแต่วินาทีแรก" และเพิ่มหน้าบริการใหม่เพื่อจับลูกค้ากลุ่มใหม่ — เป้าหมายคือ ติดอันดับค้นหาดีขึ้น และ เวลามีคนแชร์ลิงก์ หน้าตาตัวอย่างสวยและถูกต้อง
ปัญหาเดิมคืออะไร
เว็บเราเป็นแบบ "Single Page App" (สร้างหน้าด้วย JavaScript ในเบราว์เซอร์) ข้อดีคือเร็วและลื่น แต่ข้อเสียคือ บอทของ Google/Facebook บางตัวอ่าน HTML รอบแรกแล้วไม่เห็นชื่อหน้าและคำอธิบาย เพราะตอนนั้น JavaScript ยังทำงานไม่เสร็จ ทำให้บางหน้า "ขึ้น Google ได้ไม่เต็มที่"
เราแก้ยังไง (4 เรื่องหลัก)
1. เติมข้อมูลให้บอทก่อนส่งหน้า
ให้ฝั่งเซิร์ฟเวอร์ (PHP) เติมชื่อหน้า คำอธิบาย และข้อมูลโครงสร้าง (schema) ลงไปให้ครบ ก่อนส่งหน้าออกไป — บอทเลยเห็นข้อมูลครบทันทีโดยไม่ต้องรอ JavaScript
2. เพิ่มหน้าบริการใหม่ 5 หน้า
กลุ่ม "การสื่อสารองค์กร" (Corporate Communication, PR, Employer Branding, ESG, Internal Communication) เพื่อจับคำค้นหาใหม่ที่เว็บเดิมยังไม่มีหน้ารองรับ
3. ปรับชื่อ/คำอธิบายหน้าเดิม
หน้าแรก, เกี่ยวกับเรา, บริการ และหมวดต่าง ๆ ปรับชื่อให้ตรงคำที่คนค้นหาจริง เช่น "Digital Marketing Agency", "Content Marketing", "Video Production"
4. อัปเดตแผนผังเว็บ + กฎบอท
อัปเดต sitemap.xml ให้รวมหน้าใหม่ และ
robots.txt ให้กันบอท ออกจากหน้าหลังบ้าน
(partner/studio) ที่ไม่ควรขึ้น Google
ได้ประโยชน์อะไร
- โอกาส ติดอันดับ Google ของหน้าหลักและหน้าบริการดีขึ้น เพราะบอทอ่านข้อมูลได้ครบ
- เวลามีคนแชร์ลิงก์ ตัวอย่าง (ชื่อ/รูป/คำอธิบาย) แสดงถูกต้อง ทุกแพลตฟอร์ม
- มี หน้าใหม่ 5 หน้า ไปจับคำค้นกลุ่มสื่อสารองค์กรที่เดิมไม่มี
- หน้าหลังบ้านถูกกันออกจากการค้นหา — ปลอดภัยและดูเป็นมืออาชีพขึ้น
สรุปสั้น ๆ: เนื้อหาและดีไซน์ที่ผู้ใช้เห็น "เหมือนเดิม" — สิ่งที่เปลี่ยนคือข้อมูลเบื้องหลังที่ทำให้ Google เข้าใจเว็บเราได้ดีขึ้น บวกหน้าบริการใหม่อีก 5 หน้า
สิ่งที่ทีมต้องทำต่อ: รัน
SEO_KEYWORD_A4-A6.sql บนฐานข้อมูลจริง (อัปเดต SEO
ของหน้าบริการย่อย) และตรวจ search volume ของ keyword
ด้วยเครื่องมือจริงก่อนจัดลำดับความสำคัญ
รายละเอียดสำหรับทีมพัฒนา
แกนของรอบนี้คือ SSR prerender ฝั่ง PHP ที่เติม meta/JSON-LD
ก่อนคืน index.html และ
หน้า landing แบบ config-driven (mock ในโค้ด ไม่แตะ DB)
1) สถาปัตยกรรม SSR meta — public/index.php
เว็บยังเป็น Vite SPA เหมือนเดิม แต่ทุก request ผ่าน front controller
public/index.php ที่อ่าน index.html (ไฟล์
build) แล้ว ฉีด <title>, meta,
<link rel="canonical">, JSON-LD และบล็อก
noscript เข้า <head>/<body>
ก่อนส่งกลับ ลำดับการ resolve meta:
pna_default_meta($path) —
กันกรณีไม่เข้าเงื่อนไขใด ๆ
pna_is_known_spa_path() — ถ้าใช่ตอบ 200,
ถ้าไม่ใช่ตกไป 404 (noindex)
pna_connect() →
pna_service_from_path() /
pna_blog_post_from_path() — ได้แถวก็ใช้
seo_* จาก DB
$staticMeta (หน้าหลัก/about/5 หน้า hard-code) →
pna_json_service_from_path() /
pna_page_meta_from_json() อ่าน
public/data/services.json
pna_inject_meta() +
pna_inject_noscript() + ตั้ง header
X-Robots-Tag /
Content-Language: th
2) การ SELECT ฐานข้อมูลเข้าไป
การเชื่อม DB อ่านค่าจาก
public/api/.env
(DB_HOST/PORT/NAME/USER/PASSWORD) แล้วสร้าง PDO MySQL
แบบ utf8mb4 + prepared statement จริง ถ้า connect ไม่ได้
คืน null แล้วเว็บยังทำงานต่อด้วย fallback JSON
(ออกแบบให้ DB ล่มแล้ว SEO ไม่พัง):
// public/index.php — pna_connect()
$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $host, $port, $name);
return new PDO($dsn, $user, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // prepared statement จริง กัน SQL injection
]);
หน้า service detail (/services/{cat}/{svc}) ดึง meta
ด้วย prepared statement join สองตาราง คัดเฉพาะที่
visibility = 'public' และดึงคอลัมน์ SEO มาครบ:
// pna_service_from_path() — บอกพารามิเตอร์ผ่าน ? เสมอ
SELECT s.seo_title, s.seo_description, s.seo_keywords, s.seo_h1,
s.seo_canonical_url, s.seo_robots, s.seo_noindex,
s.og_title, s.og_image, s.twitter_card, s.twitter_image,
s.schema_json, s.breadcrumb_json, s.hreflang_json,
c.slug AS category_slug, c.name_th AS category_name
FROM services s
INNER JOIN service_categories c ON c.id = s.category_id
WHERE c.slug = ? AND s.slug = ? AND s.visibility = 'public'
LIMIT 1;
// ->execute([catSlug, serviceSlug])
public/sitemap.php ก็ใช้แนวเดียวกัน:
pna_db_urls() / pna_db_category_urls() /
pna_db_blog_urls() query เฉพาะแถวที่
published + approved + public + ไม่ noindex ถ้าไม่มี DB
ก็ fallback ไป pna_json_urls() (อ่านจาก
services.json)
หลักการสำคัญ: DB คือ source หลัก,
services.json คือ fallback ตอน build/ไม่มี DB — meta
ตัวเดียวกันจึงมาได้จากสองทาง ทำให้หน้า preview/local ที่ไม่มี DB
ยังมี SEO
3) หน้า mock / hard-code — serviceLandingPages.js +
ServiceLandingView.vue
5 หน้ากลุ่ม Corporate Communication ไม่ได้อยู่ใน DB หรือ services.json — เป็น config ในโค้ดล้วน (mock) เพราะเป็นหน้าการตลาดที่อยากคุมเนื้อหาเองโดยไม่ต้องผ่านระบบหลังบ้าน แยกหน้าที่ชัดเจน 4 จุด:
| ไฟล์ | หน้าที่ |
|---|---|
src/data/serviceLandingPages.js |
ข้อมูล 5 หน้า (array ของ object):
path, routeName, h1, lead, title, description, keywords,
pillars[], narrative[], parentHub
|
src/views/service/ServiceLandingView.vue |
เทมเพลตร่วม render จาก props.page +
onMounted เรียก
useSeo().applySeoConfig() ตั้ง title/meta/JSON-LD
(Service + BreadcrumbList) ฝั่ง
client
|
src/router/index.js |
map() config เป็น route — วางก่อน
/services/:catSlug ให้ static path ชนะ dynamic
|
public/index.php ($staticMeta) +
sitemap.php
|
SSR meta ของ 5 หน้า (ให้บอทเห็นตอน crawl แรก) + ลง URL ใน sitemap |
Router map config → route (รูปจริงในโค้ด):
// src/router/index.js
import { serviceLandingPages } from "../data/serviceLandingPages";
...serviceLandingPages.map((page) => ({
path: page.path,
name: page.routeName,
component: () => import("../views/service/ServiceLandingView.vue"),
props: { page }, // ส่ง config ทั้ง object เข้า view
})),
// ↑ ประกาศก่อน { path: "/services/:catSlug" } เสมอ
View ดึง props มา render + ตั้ง SEO ฝั่ง client:
// src/views/service/ServiceLandingView.vue
const props = defineProps({ page: { type: Object, required: true } });
const { applySeoConfig } = useSeo();
onMounted(() => {
const { title, description } = props.page;
applySeoConfig({ title, description, keywords: props.page.keywords,
canonical: props.page.path, openGraph: {...}, structuredData: [Service, BreadcrumbList] });
});
เพิ่มหน้าใหม่ทีหลัง = 3 บรรทัด: +1 object ใน
serviceLandingPages.js · +1 entry ใน
$staticMeta (index.php) · +1 บรรทัดใน
pna_static_urls() (sitemap.php) — route/หน้า/SEO ฝั่ง
client มาเองจาก config
4) SEO หน้า category (A4–A6) มาจาก exporter ไม่ใช่ DB
ตาราง service_categories
ไม่มีคอลัมน์ SEO ดังนั้น meta ของหน้าหมวด (/services/creative · /media · /production) ถูกประกาศใน scripts/export-services-json.mjs ที่คีย์
seo.pages แล้ว generate ลง
public/data/services.json ตอน build — runtime อ่านผ่าน
pna_page_meta_from_json() ฉะนั้น
แก้ค่าหมวดต้องแก้ที่ exporter แล้ว re-export (ไม่ใช่แก้ DB)
ส่วน service detail ในหมวดนั้นแก้ผ่าน
SEO_KEYWORD_A4-A6.sql (ลงใน DB จริง)
5) ⚠️ Title 3 ชั้นต้องตรงกัน
หน้าสาธารณะอย่าง /, /about,
/services มี title/meta อยู่ 3 ที่ ถ้าแก้แค่
index.php ฝั่ง client จะ override กลับหลัง mount
ต้องแก้ให้ตรงทั้ง 3:
-
SSR static meta —
public/index.php$staticMeta(ค่าที่บอทเห็นตอน crawl แรก) -
Router title map —
src/router/index.jsPUBLIC_ROUTE_TITLES(เซ็ตdocument.titleทุกครั้งที่เปลี่ยน route) -
View
applySeoConfig—HomeView.vue,AboutView.vueฯลฯ
รอบนี้ยังรวมตัวคั่น title จาก | PNA →
– PNA ทุก view เพื่อความสม่ำเสมอ และเปลี่ยน
/about จากเดิม redirect: "/" เป็นหน้าจริง
(component AboutView.vue)
6) robots / sitemap
-
public/robots.txt— เพิ่มDisallow/partner/ /studio/ /service/preview/ /api/ /uploads/ให้ทุก user-agent (เป็นสำเนา static ของrobots.phpซึ่งเป็น source จริง) -
public/sitemap.php— รวม static URL + 5 หน้า hard-code + URL จาก DB (หรือ JSON fallback) แล้ว dedupe ตามloc -
หน้า
coming-soonและ/blog?q=…/page>1ถูกตั้งnoindex,followกัน thin/duplicate
ดาวน์โหลดไฟล์ที่แก้
ไฟล์ทั้งหมดถูกคัดลอกมาไว้ที่ report/sound-code/ โดยคงโครงสร้างโฟลเดอร์เดิม
(เช่น sound-code/public/index.php) คลิก "ดาวน์โหลด"
เพื่อโหลดทีละไฟล์ หรือ "ดาวน์โหลดทั้งหมด" เพื่อโหลดเรียงทีละไฟล์
ถ้าเปิดไฟล์นี้แบบ
file:// แล้วบางเบราว์เซอร์บล็อกการดาวน์โหลด
ให้เปิดโฟลเดอร์ report/sound-code/ ตรง ๆ
หรือเสิร์ฟด้วย npx serve report แล้วเปิดผ่าน
http://
ขั้นตอนขึ้น Production (Runbook)
การเปลี่ยนแปลงรอบนี้แบ่งเป็น 2 ฝั่ง: ฝั่งที่ ต้องแตะ Database (A4–A6 — service detail) และฝั่งที่ ไป build เฉย ๆ (Part B, on-page, sitemap/robots) ทำตามลำดับนี้
คำสั่ง build จริงในโปรเจกต์:
npm run build = node scripts/export-services-json.mjs &&
vite build && node deploy.js
— กล่าวคือ build จะ re-export services.json จาก DB เอง →
build SPA → ZIP dist/ ส่งขึ้น
pnaserver.com:httpdocs/ ผ่าน SSH อัตโนมัติ (ไฟล์ PHP
+ services.json ใน public/ ถูก Vite
คัดลอกเข้า dist/ ให้แล้ว)
ลำดับการ deploy
mysqldump) + โปรเจกต์ —
deploy.js มี backup โปรเจกต์อัตโนมัติ (เก็บ 3
ชุดล่าสุดบนเซิร์ฟเวอร์) แต่ DB ต้อง dump เอง
SEO_KEYWORD_A4-A6.sqlรันบน MySQL ของ production (อัปเดต services.seo_*
ของ service detail หมวด creative/media/production) — ดูคำสั่ง
ด้านล่าง
public/api/.env ให้ชี้ DB ที่เพิ่งอัปเดตDB_HOST/PORT/NAME/USER/PASSWORD ต้องเป็นตัวที่รัน
SQL ไป — เพราะ exporter อ่าน .env นี้ตอน build
npm run buildexport services.json (จาก DB ใหม่) → vite build →
ส่งขึ้นเซิร์ฟเวอร์ผ่าน deploy.js อัตโนมัติ
curl (ดูด้านล่าง)
1) รัน SQL บน Database
ไฟล์ SEO_KEYWORD_A4-A6.sql อัปเดตคอลัมน์ SEO ของ service
detail (ไม่ใช่ schema เปลี่ยนโครงสร้าง) แนะนำให้ wrap เป็น
transaction และ backup ก่อน:
# backup ก่อนเสมอ
mysqldump -u USER -p DBNAME services > services_backup_$(date +%F).sql
# รัน SQL (CLI)
mysql -u USER -p DBNAME < SEO_KEYWORD_A4-A6.sql
# หรือผ่าน phpMyAdmin / Adminer: เปิดไฟล์แล้ว Import / รันใน SQL tab
ทำไมต้องรัน SQL บน DB เอง: SEO ของ
service detail อ่านจากตาราง services ตรง ๆ ตอน
SSR (pna_service_from_path()) — ไม่ได้ฝังในโค้ด
จึงต้อง update ที่ DB production คนละชุดกับ DB dev/local
2) Re-generate services.json + build
services.json คือ fallback ตอนไม่มี DB และเป็น
source ของ SEO หน้า category (seo.pages — A4–A6 ฝั่ง
hardcode ใน exporter) ต้อง re-export ให้ตรงกับ DB ล่าสุด —
npm run build ทำให้ในขั้นตอนเดียว:
# A) ทำทั้งหมดในคำสั่งเดียว (export → vite build → deploy ขึ้นเซิร์ฟเวอร์)
npm run build
# B) ถ้าอยากรันทีละขั้น / ตรวจก่อน deploy
npm run export:services # สร้าง public/data/services.json จาก DB
npx vite build # สร้าง dist/ (รวมไฟล์ public/* เข้ามา)
node deploy.js # ZIP dist/ → SSH ขึ้น httpdocs/
ระวัง: deploy.js มี credential SSH ของ
production ฝังอยู่ในไฟล์ — รันเฉพาะตอนตั้งใจ deploy จริง และ
อย่า commit รหัสผ่านนี้ขึ้น repo สาธารณะ
(ควรย้ายไป env var ภายหลัง)
3) หน้า Mockup (Part B) — สำหรับ SEO เท่านั้น
ไฟล์ 2 ตัวนี้เป็น mockup — มีไว้ให้โครง SEO + เนื้อหาตั้งต้น
ขึ้น Google ได้ก่อน ดีไซน์/สไตล์เป็นร่าง ให้ dev ไปปรับเองต่อ
ไม่ต้องแตะ DB (deploy ไปกับ npm run build ปกติ):
| ไฟล์ | บทบาท |
|---|---|
src/data/serviceLandingPages.js |
config ข้อมูล + path ของแต่ละหน้า (แก้ข้อความ/ลิงก์/SEO ที่นี่) |
src/views/service/ServiceLandingView.vue |
เทมเพลต/ดีไซน์ร่วม — dev แก้ style/layout ที่ไฟล์นี้ (เนื้อหามาจาก config) |
path ของแต่ละหน้า mockup (5 หน้า):
| Path | หน้า | Target keyword |
|---|---|---|
/services/corporate-communication |
Hub — Corporate Communication | Corporate Communication, Communication Agency |
/services/pr |
Corporate PR Agency | Corporate PR Agency |
/services/employer-branding |
Employer Branding | Employer Branding |
/services/esg-communication |
ESG Communication | ESG Communication |
/services/internal-communication |
Internal Communication | Internal Communication |
สรุปให้ dev: เนื้อหา/SEO ของ 5 หน้านี้พร้อมขึ้นอันดับแล้ว —
งานที่เหลือคือ ปรับดีไซน์ ServiceLandingView.vue
(hero / การ์ด pillars / narrative) ให้เข้ากับแบรนด์ และเสริม
copy เชิงขายใน serviceLandingPages.js
โดยคง path / title / keywords
ไว้เพื่อไม่ให้ SEO เพี้ยน
4) Verify หลัง deploy
# SSR ส่ง title/meta จริงไหม (ควรเห็น <title> ของแต่ละหน้า)
curl -s https://www.pna.co.th/services/pr | grep -i "<title>"
curl -s https://www.pna.co.th/services/creative | grep -i "description"
# service detail ที่เพิ่ง update SQL — เช็กว่า meta มาจาก DB ใหม่
curl -s https://www.pna.co.th/services/creative/<service-slug> | grep -i "<title>"
# sitemap รวม 5 หน้าใหม่ + robots กันหลังบ้าน
curl -s https://www.pna.co.th/sitemap.xml | grep -E "corporate-communication|/services/pr"
curl -s https://www.pna.co.th/robots.txt | grep -i disallow
- เปิดหน้าใหม่ทั้ง 5 path ด้วยเบราว์เซอร์ — ต้องไม่ขึ้น 404
- เอา URL ไปทดสอบใน Google Rich Results Test / Facebook Sharing Debugger ดูว่า JSON-LD + OG ถูกต้อง
-
Submit
sitemap.xmlใน Google Search Console แล้วขอ index หน้าใหม่