เปลี่ยนชื่อไฟล์รูป 1,707 ไฟล์เป็นภาษาไทยเพื่อ SEO โดยไม่ทำลิงก์พัง
ชื่อไฟล์รูปเป็นข้อความที่เครื่องมือค้นหาอ่าน และภาษาไทยในชื่อไฟล์ใช้ได้จริงบนเว็บสมัยนี้ ปัญหาอยู่ที่ขั้นตอนการเปลี่ยนชื่อ ซึ่งต้องแตะทั้งไฟล์บนดิสก์ ทั้งคอลัมน์ในฐานข้อมูล และทั้งไฟล์ที่ส่งออกไปให้หน้าเว็บใช้ พร้อมกันทีเดียว
สรุปใน 1 นาที
- อาการ/ปัญหา
- รูปแกลเลอรีทั้งเว็บชื่อเป็นรหัสสุ่ม เครื่องมือค้นหาอ่านไม่ออกสักไฟล์
- ใครควรอ่าน
- คนดูแลเว็บที่มีรูปจำนวนมากและอยากได้ชื่อไฟล์ภาษาไทยที่มีความหมาย
- ทางแก้แบบสั้น
- เปลี่ยนชื่อไฟล์ 1,707 ไฟล์พร้อมอัปเดตทุกที่ที่อ้างถึงในทรานแซกชันเดียว และระวังกับดัก curl ที่ยิงพาธไทยดิบได้ 400
พอดีผมดูรายงานการค้นหาของเว็บไดเรกทอรีสถานที่ที่ดูแลอยู่ แล้วเห็นว่าทราฟฟิกจากการค้นรูปเกือบเป็นศูนย์ ทั้งที่มีรูปอยู่ในระบบพันกว่าใบ พอไปเปิดดูถึงเข้าใจ เพราะรูปทุกใบชื่อเป็นรหัสสุ่มยาว ๆ ที่เครื่องอัปโหลดตั้งให้ตอนนำเข้าครั้งแรก แปลว่าไม่มีคำใบ้อะไรเลยสักตัวให้เครื่องมือค้นหาเดาว่ารูปนี้คือรูปอะไร งานที่ต้องทำเลยเป็นการเปลี่ยนชื่อไฟล์ทั้ง 1,707 ไฟล์ให้เป็นชื่อภาษาไทยที่มีความหมาย โดยห้ามมีลิงก์ไหนพังสักเส้นเดียว
ชื่อไฟล์ภาษาไทยใช้ได้จริงไหม
ตอบก่อนเลยว่าได้ และใช้ได้มานานแล้ว เบราว์เซอร์ทุกตัวแปลงอักขระนอก ASCII ในพาธให้เป็นรูป percent-encoded ตามมาตรฐานก่อนส่งออกไปเอง ฝั่งเว็บเซิร์ฟเวอร์ก็ถอดกลับมาเป็นชื่อไฟล์จริงบนดิสก์ได้ปกติ ตราบใดที่ระบบไฟล์เก็บชื่อเป็น UTF-8
ที่ต้องระวังไม่ใช่ตัวภาษาไทย แต่เป็นอักขระอื่นที่ติดมากับชื่อสถานที่ พวกวงเล็บ ทับ จุด เครื่องหมายคำถาม พวกนี้ทำพาธพังหรือทำให้ระบบไฟล์บางตัวไม่รับ ผมเลยเขียนตัวแปลงชื่อไว้ตัวเดียวแล้วบังคับให้ทุกที่เรียกผ่านตัวนี้เท่านั้น
function thaiSlug(string $name, int $cap = 45): string
{
$s = preg_replace('/[()\[\]\/\\\\:*?"<>|.,]/u', '', $name); // อักขระที่ทำพาธพัง
$s = preg_replace('/\s+/u', '-', trim($s)); // ช่องว่างเป็นขีด
$s = preg_replace('/-+/u', '-', $s); // ขีดซ้อนเหลือตัวเดียว
$s = trim($s, '-');
return mb_substr($s, 0, $cap, 'UTF-8'); // ตัดด้วย mb_ เท่านั้น
}
// "ศูนย์ตัวอย่าง (สาขาที่ 2)" -> "ศูนย์ตัวอย่าง-สาขาที่-2"
อักษรไทยหนึ่งตัวกินสามไบต์ใน UTF-8 ถ้าไปตัดด้วยฟังก์ชันที่นับเป็นไบต์ จะได้ไบต์ครึ่งตัวติดมาท้ายชื่อ ผลคือชื่อไฟล์ที่เปิดไม่ได้และอ่านไม่ออก ผมกำหนดเพดานไว้ที่ 45 อักษรเพราะยาวกว่านั้นเริ่มเสี่ยงชนเพดานความยาวพาธรวมเวลาซ้อนโฟลเดอร์ลึก ๆ
ชื่อไฟล์ถูกอ้างถึงอยู่หลายที่กว่าที่คิด
อันนี้คือจุดที่ทำให้งานนี้ไม่ใช่แค่รันคำสั่งเปลี่ยนชื่อแล้วจบ เพราะชื่อไฟล์เดียวกันถูกเก็บซ้ำอยู่สามที่ในระบบของผม
ตัวที่ลืมง่ายสุดคือคอลัมน์รูปแบบ JSON ที่เก็บรายการรูปทั้งแกลเลอรีไว้ในฟิลด์เดียว ถ้าไปเปลี่ยนแค่ชื่อไฟล์บนดิสก์ หน้าเว็บจะขึ้นรูปแตกทันทีทั้งเว็บ
-- ดูก่อนว่าชื่อไฟล์ไปโผล่อยู่ที่ไหนบ้าง
SELECT id, img, JSON_LENGTH(gallery) AS n_gallery
FROM places
WHERE img LIKE '%\_%' OR JSON_SEARCH(gallery, 'one', '%.jpg') IS NOT NULL
LIMIT 20;
เปลี่ยนชื่อ อัปเดต แล้วส่งออก ในทรานแซกชันเดียว
หลักที่ผมยึดคือ ถ้าขั้นไหนล้ม ต้องย้อนกลับได้ทั้งชุด ไม่ใช่เปลี่ยนชื่อไฟล์ไปครึ่งทางแล้วค้างคาไว้แบบนั้น ตัวสคริปต์ผมเลยเก็บแผนการเปลี่ยนชื่อทั้งหมดไว้ก่อน ตรวจว่าไม่มีชื่อซ้ำ แล้วค่อยลงมือ
$plan = []; // เก็บแผนทั้งหมดก่อน ยังไม่แตะไฟล์
foreach ($places as $p) {
$base = thaiSlug($p->name_th);
foreach (json_decode($p->gallery, true) as $i => $old) {
$ext = pathinfo($old, PATHINFO_EXTENSION);
$new = sprintf('%s-%02d.%s', $base, $i, $ext);
$plan[] = ['id' => $p->id, 'idx' => $i, 'old' => $old, 'new' => $new];
}
}
$dupes = array_diff_assoc($n = array_column($plan, 'new'), array_unique($n));
if ($dupes) { fwrite(STDERR, "ชื่อซ้ำ: " . implode(', ', $dupes) . PHP_EOL); exit(1); }
DB::transaction(function () use ($plan, $root) {
$renamed = [];
try {
foreach ($plan as $row) {
$from = "$root/{$row['id']}/{$row['old']}";
$to = "$root/{$row['id']}/{$row['new']}";
if (!@rename($from, $to)) {
throw new RuntimeException("rename ล้มที่ {$row['old']}");
}
$renamed[] = [$to, $from]; // เก็บไว้ย้อนกลับ
}
foreach (group_by_id($plan) as $id => $rows) {
Place::where('id', $id)->update([
'gallery' => json_encode(array_column($rows, 'new'), JSON_UNESCAPED_UNICODE),
'img' => $rows[0]['new'],
]);
}
Publisher::export(); // สร้างไฟล์ที่หน้าเว็บโหลดใหม่
} catch (Throwable $e) {
foreach (array_reverse($renamed) as [$to, $from]) { @rename($to, $from); }
throw $e; // DB ย้อนเอง ไฟล์ย้อนด้วยมือข้างบน
}
});
จุดที่ต้องระวังคือทรานแซกชันของฐานข้อมูลไม่ครอบการเปลี่ยนชื่อไฟล์บนดิสก์ ผมเลยต้องเก็บรายการที่เปลี่ยนไปแล้วไว้เอง แล้วย้อนกลับด้วยมือใน catch ก่อนโยน exception ออกไปให้ฐานข้อมูลย้อนของมันเอง
สคริปต์ตัวนี้แตะไฟล์จริงที่ docroot ผมเลยให้มีโหมดไม่เขียนจริงเป็นค่าปริยาย ต้องพิมพ์ธงยืนยันถึงจะลงมือ และก่อนรันจริงครั้งแรก ให้สำรองทั้งโฟลเดอร์รูปกับข้อมูลคอลัมน์ที่เกี่ยวข้องไว้ก่อน อย่าไว้ใจว่าตัวย้อนกลับที่เขียนเองจะทำงานครบทุกเคส
กับดักที่กินเวลาผมหนึ่งวัน
พอทำเสร็จผมเปิดเทสด้วยคำสั่งบรรทัดคำสั่งตามนิสัย ยิงไปที่รูปสักใบแล้วได้ 400 กลับมา ลองใบอื่นก็ 400 เหมือนกันหมด ตอนนั้นผมสรุปทันทีว่าชื่อไฟล์ไทยใช้ไม่ได้บนเว็บเซิร์ฟเวอร์ตัวนี้ แล้วเริ่มวางแผนถอยกลับไปใช้ชื่ออังกฤษทั้งหมด
curl -I 'https://example.com/img/ศูนย์ตัวอย่าง-01.jpg'
# HTTP/1.1 400 Bad Request
ความจริงคือ เบราว์เซอร์เข้าได้ปกติทุกใบ ผมรู้ตอนเปิดหน้าเว็บดูเองหลังจากนั้นหลายชั่วโมง สาเหตุคือคำสั่ง curl ส่งไบต์ UTF-8 ดิบลงไปในบรรทัดคำขอตามที่เราพิมพ์ ส่วนเบราว์เซอร์แปลงเป็นรูป percent-encoded ให้ก่อนส่ง เว็บเซิร์ฟเวอร์ปฏิเสธอันแรกเพราะผิดรูปแบบของบรรทัดคำขอ ไม่ใช่เพราะหาไฟล์ไม่เจอ
ส่งไบต์ดิบ ได้ 400
- สิ่งที่พิมพ์คือสิ่งที่ส่งออกไปทั้งดุ้น
- เว็บเซิร์ฟเวอร์ปฏิเสธที่ชั้นรูปแบบคำขอ
- ไม่ได้แปลว่าไฟล์ไม่มีหรือชื่อใช้ไม่ได้
เข้ารหัสให้เอง ได้ 200
- แปลงอักขระนอก ASCII เป็นรูป percent-encoded ก่อนส่ง
- ตรงกับพฤติกรรมของผู้ใช้จริงและของบอตค้นหา
- เป็นตัวชี้ขาดว่าลิงก์ใช้ได้จริงหรือไม่
ถ้าจะเทสด้วยบรรทัดคำสั่งจริง ๆ ต้องเข้ารหัสพาธเองก่อน
P=$(python -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" 'ศูนย์ตัวอย่าง-01.jpg')
curl -I "https://example.com/img/$P"
# HTTP/1.1 200 OK
เทสด้วยตัวที่ทำงานเหมือนผู้ใช้จริง
บทเรียนจากข้อที่แล้วคือ เครื่องมือที่ใช้ตรวจต้องมีพฤติกรรมเหมือนตัวที่จะมาโหลดจริง ผมเลยเลิกใช้คำสั่งบรรทัดคำสั่งเป็นตัวตัดสิน แล้วเขียนสคริปต์ที่ขับเบราว์เซอร์จริงไล่เปิดทุกหน้าที่มีแกลเลอรี แล้วเก็บรูปที่โหลดไม่ผ่าน
from playwright.sync_api import sync_playwright
bad = []
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_page()
pg.on('response', lambda r: bad.append((r.status, r.url))
if r.request.resource_type == 'image' and r.status >= 400 else None)
for pid in place_ids:
pg.goto(f'https://example.com/place/{pid}', wait_until='networkidle')
b.close()
print(f'รูปที่โหลดไม่ผ่าน: {len(bad)}')
for st, u in bad[:20]:
print(st, u)
รันรอบแรกได้ศูนย์รายการ ซึ่งยืนยันว่าชื่อไฟล์ไทยไม่มีปัญหาอะไรเลยตั้งแต่แรก ที่เสียเวลาไปคือผมเชื่อเครื่องมือที่ไม่ได้จำลองพฤติกรรมจริง
งานพ่วงที่มาพร้อมกัน: เลขไทยในข้อความ
ระหว่างไล่ข้อมูลชุดนี้ผมเจอว่าในฟิลด์ข้อความมีเลขไทยปนอยู่เยอะมาก ทั้งเลขที่ตั้ง ทั้งปี ทั้งเบอร์ติดต่อ ปัญหาคือคนค้นหาพิมพ์เลขอารบิก แล้วการค้นหาข้อความก็ไม่ได้จับคู่ข้ามชุดตัวเลขให้
function th2ar(string $s): string
{
return strtr($s, ['๐'=>'0','๑'=>'1','๒'=>'2','๓'=>'3','๔'=>'4',
'๕'=>'5','๖'=>'6','๗'=>'7','๘'=>'8','๙'=>'9']);
}
ผมแปลงตอนส่งออกข้อมูลไปหน้าเว็บ ไม่ได้ไปเขียนทับข้อมูลต้นทาง เพราะบางที่เลขไทยเป็นส่วนหนึ่งของชื่อเฉพาะที่ไม่ควรแตะ และที่แน่ ๆ คือเลขที่ฝังอยู่ในรูปภาพแปลงไม่ได้อยู่แล้ว อันนั้นต้องปล่อยไป
รายการตรวจก่อนลงมือกับของจริง
- สำรองโฟลเดอร์รูปทั้งก้อน และดัมป์คอลัมน์ที่เก็บชื่อไฟล์ไว้ก่อน
- สร้างแผนการเปลี่ยนชื่อทั้งหมดก่อนแตะไฟล์ แล้วตรวจชื่อซ้ำให้ผ่านก่อนเสมอ
- ตรวจความยาวพาธรวมหลังเปลี่ยนชื่อ ไม่ให้ชนเพดานของระบบไฟล์ปลายทาง
- ทำการเปลี่ยนชื่อ การอัปเดตฐานข้อมูล และการส่งออกไฟล์ให้หน้าเว็บ เป็นชุดเดียวที่ย้อนกลับได้
- ล้างแคชฝั่งหน้าเว็บและฝั่ง CDN ให้ครบ ไม่งั้นจะเห็นรูปแตกทั้งที่ฝั่งเซิร์ฟเวอร์ถูกแล้ว
- ตรวจผลด้วยเบราว์เซอร์จริงที่ขับด้วยสคริปต์ ไม่ใช่ด้วยคำสั่งที่ส่งไบต์ดิบ
- ส่ง sitemap รูปใหม่ แล้วรอดูรายงานการค้นหาอีกสองสามสัปดาห์ค่อยสรุปผล
สรุปคือชื่อไฟล์ภาษาไทยทำได้และคุ้มที่จะทำ งานหนักไม่ได้อยู่ที่ตัวภาษา แต่อยู่ที่การไล่ให้ครบว่าชื่อไฟล์ถูกอ้างถึงอยู่กี่ที่ แล้วเปลี่ยนทั้งหมดพร้อมกันแบบย้อนกลับได้ ส่วนเรื่องการไล่ตรวจข้อความไทยที่เพี้ยนตอนย้ายข้อมูล ผมแยกเขียนไว้อีกตอนที่ ตรวจข้อความไทยหลังย้ายข้อมูล ตอนนั้นไม่ได้แตะเรื่องชื่อไฟล์กับการเข้ารหัส URL เลย ใครจะเอาไปปรับใช้กับระบบตัวเองก็ตามสะดวกครับ ตรงไหนผมอธิบายตกหล่นหรือเจอเคสที่ตัวย้อนกลับเอาไม่อยู่ เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



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