PNA Digital · SEO Change Report

รายงานการปรับ SEO รอบล่าสุด

เอกสารนี้สรุป "เปลี่ยนอะไรไปบ้าง" ในการปรับ SEO ของเว็บ PNA Digital — ทั้งแบบเข้าใจง่ายสำหรับทุกคน และแบบลงรายละเอียดโค้ดสำหรับทีมพัฒนา พร้อมให้ดาวน์โหลดไฟล์ที่แก้ทั้งหมด

เว็บไซต์: pna.co.th สแตก: Vue 3 SPA + PHP SSR ไฟล์ที่แก้: 28 ไฟล์ อัปเดต: 18 มิ.ย. 2569

เกิดอะไรขึ้น พูดแบบเข้าใจง่าย

เราปรับให้ 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:

1
กำหนดค่า defaultpna_default_meta($path) — กันกรณีไม่เข้าเงื่อนไขใด ๆ
2
เช็กว่าเป็น path ที่รู้จักpna_is_known_spa_path() — ถ้าใช่ตอบ 200, ถ้าไม่ใช่ตกไป 404 (noindex)
3
ลองดึงจาก Databasepna_connect()pna_service_from_path() / pna_blog_post_from_path() — ได้แถวก็ใช้ seo_* จาก DB
↓ (DB ไม่ติด หรือไม่เจอแถว)
4
Static map + JSON fallback$staticMeta (หน้าหลัก/about/5 หน้า hard-code) → pna_json_service_from_path() / pna_page_meta_from_json() อ่าน public/data/services.json
5
ฉีดเข้า HTML แล้วส่ง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:

  1. SSR static metapublic/index.php $staticMeta (ค่าที่บอทเห็นตอน crawl แรก)
  2. Router title mapsrc/router/index.js PUBLIC_ROUTE_TITLES (เซ็ต document.title ทุกครั้งที่เปลี่ยน route)
  3. View applySeoConfigHomeView.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

0
สำรองข้อมูลก่อนเสมอdump DB (mysqldump) + โปรเจกต์ — deploy.js มี backup โปรเจกต์อัตโนมัติ (เก็บ 3 ชุดล่าสุดบนเซิร์ฟเวอร์) แต่ DB ต้อง dump เอง
1
อัปเดต Database — รัน SEO_KEYWORD_A4-A6.sqlรันบน MySQL ของ production (อัปเดต services.seo_* ของ service detail หมวด creative/media/production) — ดูคำสั่ง ด้านล่าง
2
ตรวจ public/api/.env ให้ชี้ DB ที่เพิ่งอัปเดตDB_HOST/PORT/NAME/USER/PASSWORD ต้องเป็นตัวที่รัน SQL ไป — เพราะ exporter อ่าน .env นี้ตอน build
3
build + deploy — npm run buildexport services.json (จาก DB ใหม่) → vite build → ส่งขึ้นเซิร์ฟเวอร์ผ่าน deploy.js อัตโนมัติ
4
verify บน productionเช็ก SSR title/meta + sitemap + robots ด้วย 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 หน้าใหม่