
สร้างลิงก์ภาษาไทยด้วย urllib.parse พร้อมเทสต์กัน URL พัง
ลิงก์จากเมนู 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=เดิม¬e=#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 รายการผ่านครับ
จุดที่คนพลาดบ่อย
- เข้ารหัสซ้ำ — ส่งค่าที่ผ่าน
quote()เข้าurlencode()อีกที ทำให้%กลายเป็น%25เมื่ออ่าน query กลับหนึ่งรอบจึงยังเหลือข้อความเข้ารหัส วิธีแก้คือส่งข้อความต้นฉบับให้urlencode()และเข้ารหัสที่จุดประกอบ URL เพียงครั้งเดียว - เข้ารหัสทั้ง URL — การใช้
quote(url, safe="")ทำให้:และ/ที่เป็นโครงสร้างถูกแปลงด้วย วิธีแก้คือแยกส่วนก่อน แล้วเข้ารหัสเฉพาะข้อมูลใน path หรือ query อย่าแก้ด้วยการใส่ตัวคั่นทุกตัวลงsafeเพราะตัวคั่นในข้อมูลจะหลุดตามมาด้วย - คิดว่า
+หมายถึงช่องว่างทุกที่ — ใน query แบบฟอร์มparse_qs()อ่าน+เป็นช่องว่าง แต่unquote()คง+ไว้ วิธีแก้คือใช้quote()กับข้อมูลใน path และใช้ตัวอ่าน query สำหรับ query โดยเฉพาะ เครื่องหมายบวกจริงในค่าควรส่งผ่านตัวเข้ารหัสให้เป็น%2B - ต่อ query หลัง
#— ถ้า URL เดิมลงท้าย#menuแล้วเติม&q=...ข้อความนั้นจะอยู่ใน fragment วิธีแก้คือเปลี่ยนเฉพาะช่อง query ผ่านurlsplit()และurlunsplit()พร้อมเก็บ fragment เดิมไว้ - ทำค่าซ้ำหรือค่าว่างหาย — แปลงผล
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 - เทสต์ข้อความไทย ช่องว่าง
&,#และ+ก่อนนำลิงก์ไปใช้