นากการ์ตูนใส่ฮู้ดสีเขียวบรรจุตัวอักษรหลากสีลงซองปิดผนึกบนสายพานที่ต่อเป็นโซ่ลิงก์ยาว มีหมุดแผนที่และรูปชามก๋วยเตี๋ยวติดผนัง

สร้างลิงก์ภาษาไทยด้วย urllib.parse พร้อมเทสต์กัน URL พัง

น้องกายEngineering

ลิงก์จากเมนู LINE OA หรือโพสต์ Facebook อาจดูถูกต้องในโค้ด แต่พอชื่อร้านมี & หรือ # ค่าที่หน้าเว็บอ่านได้กลับขาดไปครึ่งหนึ่ง งานที่ควรเป็นแค่ส่งลูกค้าไปหน้าค้นหาจึงกลายเป็นงานไล่แก้ลิงก์หลายจุด ผมจะพาสร้าง URL ภาษาไทยด้วย urllib.parse ใน standard library หรือชุดเครื่องมือที่มากับ Python พร้อมเทสต์ให้เห็นว่าข้อมูลไปครบและอ่านกลับได้ครับ

WHY: ลิงก์ที่ถูกต้องเริ่มจากแยกข้อมูลออกจากโครงสร้าง

URL มีอักขระที่ทำหน้าที่เป็นตัวคั่น เช่น ? เปิดส่วน query ซึ่งเป็นชุดชื่อกับค่า, & คั่นแต่ละคู่ และ # เปิดส่วน fragment ซึ่งระบุตำแหน่งย่อย ถ้านำชื่อร้านมาต่อท้าย ?q= ตรง ๆ ตัวคั่นที่อยู่ในชื่อร้านอาจถูกตีความเป็นโครงสร้างแทนข้อมูล

  • ข้อความ ข้าวมันไก่ & ชาเย็น ต้องเป็นค่าเดียว ไม่หลุดเป็นหลายพารามิเตอร์
  • แท็กสินค้าหลายรายการควรส่งเป็นชื่อซ้ำ เช่น tag=...&tag=... เพื่ออ่านกลับเป็นรายการ
  • URL ที่มี query และ fragment อยู่แล้วต้องเติมค่าในตำแหน่งที่ถูกต้อง

การเข้ารหัสช่วยรักษาโครงสร้าง แต่ urlsplit() ไม่ได้ตรวจสอบความถูกต้องของอินพุตทั้งหมด ตาม เอกสาร urllib.parse หากรับ URL จากผู้ใช้ต้องตรวจ scheme และ hostname ตามเงื่อนไขของงานเพิ่มเอง

ทำงานยังไง

โครงสร้างหลักคือ scheme://host/path?query#fragment โดย path เป็นเส้นทาง ส่วน query เป็นข้อมูลประกอบ เหมือนซองพัสดุครับ ที่อยู่กับข้อความบนของข้างในต้องแยกกัน คนส่งจะได้ไม่สับสน

Percent-encoding คือการแทนไบต์ด้วย % ตามด้วยเลขฐานสิบหกสองหลัก ข้อความไทยในตัวอย่างใช้ UTF-8 จึงเห็นหลายชุด %XX ต่อหนึ่งตัวอักษร quote() แปลงช่องว่างเป็น %20 ส่วน quote_plus() แปลงเป็น + เหมาะกับรูปแบบ query ของฟอร์ม ทั้งสองฟังก์ชันจะแปลงเครื่องหมายบวกที่เป็นข้อมูลเป็น %2B มีเทสต์ยืนยันด้านล่าง

พารามิเตอร์ safe ระบุอักขระเพิ่มเติมที่ยอมให้คงไว้ quote() คง / ไว้โดยปริยาย แต่ถ้ากำลังเข้ารหัสชื่อร้านให้เป็น path เพียงหนึ่งส่วน ให้ใช้ safe="" เพื่อเปลี่ยน / ในชื่อเป็น %2F ด้วย ตัวอักษรพื้นฐานอย่าง A-Z, ตัวเลข และ _.-~ ยังไม่ถูกเข้ารหัส แม้กำหนด safe=""

สำหรับ query ใช้ urlencode() กับ dict ได้เลย ค่าเริ่มต้นใช้ quote_plus() และถ้าค่าเป็นรายการให้เปิด doseq=True เพื่อสร้างคู่ชื่อซ้ำ ตอนอ่านกลับ parse_qs() ให้ dict ที่แต่ละค่าเป็น list ส่วน parse_qsl() ให้ list ของคู่ชื่อกับค่า จึงเหมาะกับงานที่ต้องเก็บลำดับและชื่อซ้ำ ก่อนเติม query ลง URL เดิม เราจะแยกด้วย urlsplit() แล้วประกอบด้วย urlunsplit() ตามรูปแบบใน เอกสาร Python

โค้ดตัวอย่างพร้อมเทสต์

บันทึกบล็อกนี้เป็น test_links.py แล้วรัน python3 -I -B test_links.py ได้ทันที ใช้เฉพาะเครื่องมือที่มากับ Python add_query() เพิ่มคู่ใหม่และเก็บชื่อซ้ำไว้ หากต้องการแทนค่าเดิมต้องปรับนโยบายก่อนใช้

import unittest
from urllib.parse import (
    parse_qs, parse_qsl, quote, quote_plus, unquote,
    urlencode, urlsplit, urlunsplit,
)


def add_query(url, params):
    parts = urlsplit(url)
    existing = parse_qsl(parts.query, keep_blank_values=True)
    added = parse_qsl(
        urlencode(params, doseq=True), keep_blank_values=True
    )
    query = urlencode(existing + added)
    return urlunsplit(parts._replace(query=query))


class ThaiLinkTests(unittest.TestCase):
    def test_quote_and_safe(self):
        shop = "ข้าวมันไก่ ประตูน้ำ"
        self.assertIn("%20", quote(shop))
        self.assertIn("+", quote_plus(shop))
        self.assertEqual(unquote(quote(shop)), shop)
        self.assertTrue(quote(shop).startswith("%E0%B8%82"))
        self.assertEqual(quote("A/B C+"), "A/B%20C%2B")
        self.assertEqual(quote("A/B", safe=""), "A%2FB")
        self.assertEqual(quote_plus("A/B C+"), "A%2FB+C%2B")
        self.assertEqual(quote("AZ09_.-~", safe=""), "AZ09_.-~")

    def test_query_round_trip(self):
        params = {
            "q": "ข้าวมันไก่ ประตูน้ำ & ชาเย็น #1",
            "tag": ["อาหารไทย", "ไม่เกิน 100 บาท"],
            "budget": "100",
            "note": "",
        }
        query = urlencode(params, doseq=True)
        self.assertIn("%26", query)
        self.assertIn("%23", query)
        decoded = parse_qs(query, keep_blank_values=True)
        self.assertEqual(decoded["q"], [params["q"]])
        self.assertEqual(decoded["tag"], params["tag"])
        self.assertEqual(decoded["budget"], ["100"])
        self.assertEqual(decoded["note"], [""])
        self.assertNotIn("note", parse_qs(query))
        self.assertEqual(
            parse_qsl("tag=rice&tag=tea"),
            [("tag", "rice"), ("tag", "tea")],
        )

    def test_add_query_preserves_parts(self):
        base = "https://shop.example/search?tag=เดิม&note=#menu"
        link = add_query(base, {
            "q": "ข้าวมันไก่ ประตูน้ำ",
            "tag": ["อาหารไทย", "มื้อเที่ยง"],
            "utm_source": "facebook",
        })
        parts = urlsplit(link)
        self.assertEqual(parts.scheme, "https")
        self.assertEqual(parts.netloc, "shop.example")
        self.assertEqual(parts.path, "/search")
        self.assertEqual(parts.fragment, "menu")
        values = parse_qs(parts.query, keep_blank_values=True)
        self.assertEqual(values["tag"], ["เดิม", "อาหารไทย", "มื้อเที่ยง"])
        self.assertEqual(values["note"], [""])
        self.assertEqual(values["q"], ["ข้าวมันไก่ ประตูน้ำ"])
        self.assertEqual(values["utm_source"], ["facebook"])

    def test_common_mistakes(self):
        text = "ชา & กาแฟ #1"
        bad = urlsplit("https://shop.example/search?q=" + text)
        self.assertNotEqual(parse_qs(bad.query).get("q"), [text])
        self.assertEqual(bad.fragment, "1")
        self.assertEqual(quote(quote(" ")), "%2520")
        self.assertEqual(
            parse_qs(urlencode({"q": quote(text)}))["q"],
            [quote(text)],
        )
        self.assertEqual(unquote("A+B"), "A+B")
        self.assertEqual(parse_qs("q=A+B")["q"], ["A B"])
        self.assertEqual(parse_qs("q=A%2BB")["q"], ["A+B"])
        self.assertEqual(urlencode({"q": "A B"}, quote_via=quote), "q=A%20B")
        self.assertEqual(
            urlsplit(quote("https://shop.example/search", safe="")).scheme,
            "",
        )


if __name__ == "__main__": unittest.main()

ผมรันด้วย Python 3.14.7 แล้ว เทสต์ทั้ง 4 รายการผ่านครับ

จุดที่คนพลาดบ่อย

  1. เข้ารหัสซ้ำ — ส่งค่าที่ผ่าน quote() เข้า urlencode() อีกที ทำให้ % กลายเป็น %25 เมื่ออ่าน query กลับหนึ่งรอบจึงยังเหลือข้อความเข้ารหัส วิธีแก้คือส่งข้อความต้นฉบับให้ urlencode() และเข้ารหัสที่จุดประกอบ URL เพียงครั้งเดียว
  2. เข้ารหัสทั้ง URL — การใช้ quote(url, safe="") ทำให้ : และ / ที่เป็นโครงสร้างถูกแปลงด้วย วิธีแก้คือแยกส่วนก่อน แล้วเข้ารหัสเฉพาะข้อมูลใน path หรือ query อย่าแก้ด้วยการใส่ตัวคั่นทุกตัวลง safe เพราะตัวคั่นในข้อมูลจะหลุดตามมาด้วย
  3. คิดว่า + หมายถึงช่องว่างทุกที่ — ใน query แบบฟอร์ม parse_qs() อ่าน + เป็นช่องว่าง แต่ unquote() คง + ไว้ วิธีแก้คือใช้ quote() กับข้อมูลใน path และใช้ตัวอ่าน query สำหรับ query โดยเฉพาะ เครื่องหมายบวกจริงในค่าควรส่งผ่านตัวเข้ารหัสให้เป็น %2B
  4. ต่อ query หลัง # — ถ้า URL เดิมลงท้าย #menu แล้วเติม &q=... ข้อความนั้นจะอยู่ใน fragment วิธีแก้คือเปลี่ยนเฉพาะช่อง query ผ่าน urlsplit() และ urlunsplit() พร้อมเก็บ fragment เดิมไว้
  5. ทำค่าซ้ำหรือค่าว่างหาย — แปลงผล parse_qsl() เป็น dict จะเหลือค่าเดียวต่อชื่อ และตัวอ่าน query ละค่าว่างโดยปริยาย วิธีแก้คือเก็บ list ของคู่ชื่อกับค่า ใช้ keep_blank_values=True เมื่อต้องรักษาค่าว่าง และเปิด doseq=True ตอนส่ง list

ตัวช่วยด้านบนรักษาความหมายของ query เดิม แต่ไม่ได้รักษาตัวสะกดแบบไบต์ต่อไบต์ เช่น %20 อาจกลายเป็น + หลังประกอบใหม่ จึงต้องระวัง URL ที่มีลายเซ็นคำนวณจากข้อความเดิม และตาม เอกสาร urlunsplit ตัวคั่นที่ไม่จำเป็น เช่น ? ที่ไม่มี query อาจหายไปเมื่อประกอบกลับได้

ใช้ตรงไหนในงานจริง

  • LINE OA rich menu: สร้างลิงก์ไปหน้าสินค้าหรือค้นหา โดยส่งชื่อหมวดภาษาไทยผ่าน query และเทสต์การอ่านค่ากลับก่อนใช้งาน
  • ลิงก์ค้นหา Google Maps: เมื่อตกลงรูปแบบ URL ปลายทางแล้ว ให้ตัวเข้ารหัสจัดการชื่อร้านที่มีช่องว่างหรือ & แทนการแทนช่องว่างด้วยมือ
  • UTM ของโพสต์ Facebook: เติมพารามิเตอร์ติดตามแคมเปญลงลิงก์ที่มี query อยู่แล้ว โดยรักษา fragment สำหรับตำแหน่งย่อยของหน้า
  • หน้าค้นหาภายใน: ส่งหลายหมวดหมู่ด้วย doseq=True แล้วอ่านเป็น list ด้วย parse_qs() เพื่อให้ตัวกรองหลายตัวไปครบ

เทสต์ในบทความยืนยันการประกอบและแยก URL ใน Python เท่านั้น พารามิเตอร์ต้องตรงกับระบบปลายทาง ตัวอย่างใช้โดเมนสมมติและไม่ได้เปิดบริการภายนอก

เทียบกับทางเลือกอื่น

approachดีตรงไหนระวังอะไร
ต่อสตริงเองอ่านง่ายเมื่อส่วนที่ต่อเป็นค่าคงที่ข้อมูลผู้ใช้ที่มี & หรือ # ทำให้โครงสร้างเปลี่ยน
quote()เหมาะกับข้อมูลใน path; ช่องว่างเป็น %20เลือก safe ให้ตรงว่าคง / ได้หรือไม่
quote_plus()เข้ารหัสค่าตามรูปแบบ query ของฟอร์มยังต้องประกอบชื่อกับค่าและตัวคั่นเอง
urlencode()รับ dict และรองรับ list ด้วย doseqส่งค่าดิบและอย่าเข้ารหัสซ้ำ
urlsplit() + urlunsplit()แก้ query โดยรักษาส่วนอื่นเป็นช่องแยกไม่ได้ตรวจความน่าเชื่อถือของ URL

Recap

  • แยก path, query และ fragment ก่อนเลือกวิธีเข้ารหัส
  • ใช้ quote() กับข้อมูลใน path และกำหนด safe ให้ตรงงาน
  • ใช้ urlencode() รับข้อความต้นฉบับสำหรับ query
  • เปิด doseq=True เมื่อค่าหนึ่งชื่อมีหลายรายการ
  • อ่านกลับด้วย parse_qs() หรือ parse_qsl() และตัดสินใจเรื่องค่าว่าง
  • เติม query ด้วย urlsplit() และ urlunsplit() เพื่อรักษา fragment
  • เทสต์ข้อความไทย ช่องว่าง &, # และ + ก่อนนำลิงก์ไปใช้