EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Web / เปลี่ยนชื่อไฟล์รูป 1,707 ไฟล์เป็นภาษาไทยเพื่อ SEO โดยไม่ทำลิงก์พัง
Web

เปลี่ยนชื่อไฟล์รูป 1,707 ไฟล์เป็นภาษาไทยเพื่อ SEO โดยไม่ทำลิงก์พัง

โดย Ton-Or ·27 ต.ค. 2026·อ่าน 9 นาที
เปลี่ยนชื่อไฟล์รูป 1,707 ไฟล์เป็นภาษาไทยเพื่อ SEO โดยไม่ทำลิงก์พัง

ชื่อไฟล์รูปเป็นข้อความที่เครื่องมือค้นหาอ่าน และภาษาไทยในชื่อไฟล์ใช้ได้จริงบนเว็บสมัยนี้ ปัญหาอยู่ที่ขั้นตอนการเปลี่ยนชื่อ ซึ่งต้องแตะทั้งไฟล์บนดิสก์ ทั้งคอลัมน์ในฐานข้อมูล และทั้งไฟล์ที่ส่งออกไปให้หน้าเว็บใช้ พร้อมกันทีเดียว

สรุปใน 1 นาที

อาการ/ปัญหา
รูปแกลเลอรีทั้งเว็บชื่อเป็นรหัสสุ่ม เครื่องมือค้นหาอ่านไม่ออกสักไฟล์
ใครควรอ่าน
คนดูแลเว็บที่มีรูปจำนวนมากและอยากได้ชื่อไฟล์ภาษาไทยที่มีความหมาย
ทางแก้แบบสั้น
เปลี่ยนชื่อไฟล์ 1,707 ไฟล์พร้อมอัปเดตทุกที่ที่อ้างถึงในทรานแซกชันเดียว และระวังกับดัก curl ที่ยิงพาธไทยดิบได้ 400
ข้ามไปขั้นตอน

พอดีผมดูรายงานการค้นหาของเว็บไดเรกทอรีสถานที่ที่ดูแลอยู่ แล้วเห็นว่าทราฟฟิกจากการค้นรูปเกือบเป็นศูนย์ ทั้งที่มีรูปอยู่ในระบบพันกว่าใบ พอไปเปิดดูถึงเข้าใจ เพราะรูปทุกใบชื่อเป็นรหัสสุ่มยาว ๆ ที่เครื่องอัปโหลดตั้งให้ตอนนำเข้าครั้งแรก แปลว่าไม่มีคำใบ้อะไรเลยสักตัวให้เครื่องมือค้นหาเดาว่ารูปนี้คือรูปอะไร งานที่ต้องทำเลยเป็นการเปลี่ยนชื่อไฟล์ทั้ง 1,707 ไฟล์ให้เป็นชื่อภาษาไทยที่มีความหมาย โดยห้ามมีลิงก์ไหนพังสักเส้นเดียว

1,707ไฟล์ที่เปลี่ยนชื่อ
45อักษรสูงสุดต่อชื่อ
3 ที่จุดที่อ้างถึงชื่อไฟล์
1ทรานแซกชัน

ชื่อไฟล์ภาษาไทยใช้ได้จริงไหม

ตอบก่อนเลยว่าได้ และใช้ได้มานานแล้ว เบราว์เซอร์ทุกตัวแปลงอักขระนอก 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"
ตัดความยาวด้วยฟังก์ชันที่รู้จัก multibyte เท่านั้น

อักษรไทยหนึ่งตัวกินสามไบต์ใน UTF-8 ถ้าไปตัดด้วยฟังก์ชันที่นับเป็นไบต์ จะได้ไบต์ครึ่งตัวติดมาท้ายชื่อ ผลคือชื่อไฟล์ที่เปิดไม่ได้และอ่านไม่ออก ผมกำหนดเพดานไว้ที่ 45 อักษรเพราะยาวกว่านั้นเริ่มเสี่ยงชนเพดานความยาวพาธรวมเวลาซ้อนโฟลเดอร์ลึก ๆ

ชื่อไฟล์ถูกอ้างถึงอยู่หลายที่กว่าที่คิด

อันนี้คือจุดที่ทำให้งานนี้ไม่ใช่แค่รันคำสั่งเปลี่ยนชื่อแล้วจบ เพราะชื่อไฟล์เดียวกันถูกเก็บซ้ำอยู่สามที่ในระบบของผม

จุดที่ต้องเปลี่ยนพร้อมกันไฟล์บนดิสก์ที่ docroot+คอลัมน์ใน DBgallery เป็น JSON+ไฟล์ส่งออกที่หน้าเว็บโหลด

ตัวที่ลืมง่ายสุดคือคอลัมน์รูปแบบ 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 ออกไปให้ฐานข้อมูลย้อนของมันเอง

เปลี่ยนชื่อ อัปเดต ส่งออก เป็นชุดเดียวที่ย้อนกลับได้ในทรานแซกชันเดียวสร้างแผนold -> new ทั้งชุดตรวจชื่อซ้ำซ้ำ = หยุดทันทีเปลี่ยนชื่อไฟล์จดคู่ไว้ย้อนกลับอัปเดต DBgallery กับ imgส่งออกไฟล์ที่หน้าเว็บโหลดขั้นใดล้ม -> โยน exceptionไฟล์: ย้อนด้วยมือใน catchDB: ย้อนเองก่อนรันจริง: dry-run เป็นค่าปริยายสำรองโฟลเดอร์รูปกับคอลัมน์ก่อน
ทรานแซกชันของฐานข้อมูลไม่ครอบการเปลี่ยนชื่อไฟล์ ผมเลยจดคู่ชื่อไว้เองแล้วย้อนไฟล์ด้วยมือใน catch ก่อนโยน exception ให้ฐานข้อมูลย้อนของมันเอง
รันแบบไม่เขียนจริงก่อนเสมอ

สคริปต์ตัวนี้แตะไฟล์จริงที่ docroot ผมเลยให้มีโหมดไม่เขียนจริงเป็นค่าปริยาย ต้องพิมพ์ธงยืนยันถึงจะลงมือ และก่อนรันจริงครั้งแรก ให้สำรองทั้งโฟลเดอร์รูปกับข้อมูลคอลัมน์ที่เกี่ยวข้องไว้ก่อน อย่าไว้ใจว่าตัวย้อนกลับที่เขียนเองจะทำงานครบทุกเคส

กับดักที่กินเวลาผมหนึ่งวัน

พอทำเสร็จผมเปิดเทสด้วยคำสั่งบรรทัดคำสั่งตามนิสัย ยิงไปที่รูปสักใบแล้วได้ 400 กลับมา ลองใบอื่นก็ 400 เหมือนกันหมด ตอนนั้นผมสรุปทันทีว่าชื่อไฟล์ไทยใช้ไม่ได้บนเว็บเซิร์ฟเวอร์ตัวนี้ แล้วเริ่มวางแผนถอยกลับไปใช้ชื่ออังกฤษทั้งหมด

curl -I 'https://example.com/img/ศูนย์ตัวอย่าง-01.jpg'
# HTTP/1.1 400 Bad Request

ความจริงคือ เบราว์เซอร์เข้าได้ปกติทุกใบ ผมรู้ตอนเปิดหน้าเว็บดูเองหลังจากนั้นหลายชั่วโมง สาเหตุคือคำสั่ง curl ส่งไบต์ UTF-8 ดิบลงไปในบรรทัดคำขอตามที่เราพิมพ์ ส่วนเบราว์เซอร์แปลงเป็นรูป percent-encoded ให้ก่อนส่ง เว็บเซิร์ฟเวอร์ปฏิเสธอันแรกเพราะผิดรูปแบบของบรรทัดคำขอ ไม่ใช่เพราะหาไฟล์ไม่เจอ

ยิงด้วย curl ตรง ๆ

ส่งไบต์ดิบ ได้ 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']);
}

ผมแปลงตอนส่งออกข้อมูลไปหน้าเว็บ ไม่ได้ไปเขียนทับข้อมูลต้นทาง เพราะบางที่เลขไทยเป็นส่วนหนึ่งของชื่อเฉพาะที่ไม่ควรแตะ และที่แน่ ๆ คือเลขที่ฝังอยู่ในรูปภาพแปลงไม่ได้อยู่แล้ว อันนั้นต้องปล่อยไป

รายการตรวจก่อนลงมือกับของจริง

  1. สำรองโฟลเดอร์รูปทั้งก้อน และดัมป์คอลัมน์ที่เก็บชื่อไฟล์ไว้ก่อน
  2. สร้างแผนการเปลี่ยนชื่อทั้งหมดก่อนแตะไฟล์ แล้วตรวจชื่อซ้ำให้ผ่านก่อนเสมอ
  3. ตรวจความยาวพาธรวมหลังเปลี่ยนชื่อ ไม่ให้ชนเพดานของระบบไฟล์ปลายทาง
  4. ทำการเปลี่ยนชื่อ การอัปเดตฐานข้อมูล และการส่งออกไฟล์ให้หน้าเว็บ เป็นชุดเดียวที่ย้อนกลับได้
  5. ล้างแคชฝั่งหน้าเว็บและฝั่ง CDN ให้ครบ ไม่งั้นจะเห็นรูปแตกทั้งที่ฝั่งเซิร์ฟเวอร์ถูกแล้ว
  6. ตรวจผลด้วยเบราว์เซอร์จริงที่ขับด้วยสคริปต์ ไม่ใช่ด้วยคำสั่งที่ส่งไบต์ดิบ
  7. ส่ง sitemap รูปใหม่ แล้วรอดูรายงานการค้นหาอีกสองสามสัปดาห์ค่อยสรุปผล

สรุปคือชื่อไฟล์ภาษาไทยทำได้และคุ้มที่จะทำ งานหนักไม่ได้อยู่ที่ตัวภาษา แต่อยู่ที่การไล่ให้ครบว่าชื่อไฟล์ถูกอ้างถึงอยู่กี่ที่ แล้วเปลี่ยนทั้งหมดพร้อมกันแบบย้อนกลับได้ ส่วนเรื่องการไล่ตรวจข้อความไทยที่เพี้ยนตอนย้ายข้อมูล ผมแยกเขียนไว้อีกตอนที่ ตรวจข้อความไทยหลังย้ายข้อมูล ตอนนั้นไม่ได้แตะเรื่องชื่อไฟล์กับการเข้ารหัส URL เลย ใครจะเอาไปปรับใช้กับระบบตัวเองก็ตามสะดวกครับ ตรงไหนผมอธิบายตกหล่นหรือเจอเคสที่ตัวย้อนกลับเอาไม่อยู่ เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

อยากให้เราทำให้ไหม?

ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า

คุยเรื่องงานกับเรา

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

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