แยกที่อยู่ภาษาไทยเป็นส่วนๆด้วย Javascript

Cover image

ข้อมูลที่อยู่หลายๆครั้งถูกเก็บเป็น string ไม่ว่าจะเป็นไฟล์ word หรืออยู่ใน chat log ต่างๆ ใช้ประโยชน์ต่อยากเพราะไม่สามารถ query อะไรได้แบบ database เลย วันนี้ผมเลยอยากเสนอหนทางในการแยกมันออกเป็นส่วนๆเพื่อนำไปใช้ในต่อกันครับ

ปัญหา

ผมมีร้านขายเครื่องจักรกลการเกษตรอยู่ มีขายทั้งหน้าร้านแบบ offline และ online ซึ่งก็ดูจะปกติดีใครๆก็ทำกัน แต่ปัญหาก็เกิดเมื่อจากสถิติแล้วลูกค้าของผมส่วนมากจะค่อนข้าง low-technology การจะบอกให้เขาเข้า web มากรอก form เลือกสินค้าแล้วกรอกที่อยู่จัดส่งเองเป็นเรื่องที่ยากมาก แค่ลูกค้ามี LINE หรือ Facebook Messenger ก็ถือว่าดีมากแล้ว

ทีนี้เวลาเขาจะสั่งซื้อเขาก็ต้องพิมพ์ทุกอย่างมาใน chat ใช่ไหมครับ แปลว่าข้อมูลที่อยู่อันมีค่าเหล่านี้จะไม่ได้ถูกจัด format เหมือนการกรอก form เข้ามา การที่เราจะเอาข้อมูลลูกค้าไปเก็บลง database ก็เลยต้องเป็นระบบมือซะ 100% ตรงนี้ทำให้เสียเวลาพอสมควร ผมเลยต้องมองหาวิธีที่จะกรอกข้อมูลลูกค้าให้เร็วที่สุดโดยที่ไม่ต้อง copy & paste แบบเดิมๆ

วิธีแก้ปัญหา

หลังจากได้ string ที่อยู่มา ผมก็จะเขียน script ขึ้นมาเพื่อแยก string ชุดนี้ออกเป็นส่วนๆ โดยพึ่งพา Regular Expression แบบโง่ๆ แต่เพื่อให้เกิดความผิดพลาดน้อยที่สุด เราต้องมีข้อมูลที่อยู่หลักๆอย่าง ตำบล/แขวง อำเภอ/เขต จังหวัด และรหัสไปรษณีย์ซะก่อน

หารายชื่อ ตำบล อำเภอ จังหวัด รหัสไปรษณีย์

ด้วยความที่เป็นพ่อค้าออนไลน์อยู่ก็ได้ใช้บริการเว็บ EasyShip ของ Kerry Express เป็นประจำผมก็ไปสะดุดตากับตัว auto-complete ที่มีขึ้นมาให้ตลอด ผมเลยลองไป inspect ดูก็พบว่า "อ้าว!! ส่งมาทั้งประเทศเลยนี่หน่า" ดังนั้นเรื่องไฟล์ที่อยู่ก็จบไปครับ เราได้ "ตำบล อำเภอ จังหวัด รหัสไปรษณีย์" ของทั้งประเทศมาแล้ว แถมเชื่อถือได้ด้วยเพราะถูกเอามาใช้จริง รออะไรล่ะครับดาวน์โหลด json file นี้มาเลยสิ

หาแหล่งข้อมูลจากเว็บ EasyShip

หาว่าที่อยู่นี้อยู่ใน "ตำบล อำเภอ จังหวัด รหัสไปรษณีย์" ไหน

เพื่อให้รู้ว่าอยู่ที่ไหนผมจะทำแบบโง่ๆเลย คือการซอย string นี้ออกมาเป็นคำๆแล้วโยนเข้าไป search ใน list ของทั้งประเทศครับ พอวนครบทุกคำแล้ว เราจะมาดูว่าตำบลไหนตรงกับคำที่เราซอยออกไปมากที่สุด ตำบลนั้นก็น่าจะใช่แล้วล่ะครับ

  1. เริ่มจากสร้างไฟล์ index.js เลยครับ โดยมี function split() เป็นตัวหลักของวันนี้ครับ แล้วเราก็จะรับ text จาก command line ด้วย ซึ่งตอนแรกที่ run ใน log ต้องได้ address: ก็จะยังไม่มีอะไร เพราะเรายังไม่ได้ทำอะไร
const fs = require('fs');
const { promisify } = require('util');
const readFile = promisify(fs.readFile);

const split = async (text) => {
    try {
        console.log('input text:', text);
        const result = '';
        console.log('address :', result)
    } catch (error) {
        console.error(error);
    }
};

const arguments = process.argv;
if (arguments.length > 2) {
    const input = arguments.slice(2)[0];
    split(input);
} else {
    console.log('no input');
}
  1. เราจะลบคำนำหน้าอย่าง ตำบล | แขวง | อำเภอ | เขต | จังหวัด ออกไปก่อนครับ เวลา search มันจะได้ไม่เอาคำนำหน้าไปด้วย อย่าลืมนะครับว่าผู้ใช้จำพิมพ์มายังไงก็ได้ เช่น "อำเภอ" เขาอาจจะพิมพ์แค่ "อ." ก็ได้ เดี๋ยวเวลาไปหาใน list มันจะไม่เจอ ผมสร้าง function ชื่อ removePrefix(text) ขึ้นมาเพื่อการนี้โดยเฉพาะ
const removePrefix = (text) => {
    const prefixPattern = /(เขต|แขวง|จังหวัด|อำเภอ|ตำบล|\.|\.|\.)/g;
    let string = text.replace(/\s+/g, ' '); //มี space เยอะก็ลดเหลือ 1 พอ
    string = string.replace(prefixPattern, '');
    return string;
}

แล้วก็เรียกใช้ใน split() ซะ พร้อมกับสร้าง wordlist ด้วยการหั่น string ออกด้วยช่องว่าง แล้วยัง filter เอาเฉพาะคำที่มีความยาวมากกว่าหรือเท่ากับ 3 อักขระขึ้นไป เพราะคงไม่มีชื่อตำบลหรืออำเภอที่สั้นกว่า 3 ตัวแล้วมั้ง จะได้ลดจำนวนรอบในการวนหาต่อไป

const split = async (text) => {
    try {
        console.log('input text:', text);
        const cleanText = removePrefix(text);
        const wordlist = cleanText.split(' ').filter(word => word.length >= 3);
        const result = '';
        console.log('address :', result)
    } catch (error) {
        console.error(error);
    }
};
  1. เอา wordlist ที่ได้ไปวนหาในรายชื่อตำบลทั้งประเทศ ใน findSubdistrcit(wordlist) โดยอ่านมาจาก json file ที่โหลดมาตอนแรกนั่นแหละครับ
const findSubdistrict = async (wordlist) => {
    const content = await readFile('subdistricts.json', 'utf-8');
    const subdistricts = JSON.parse(content);
    let results = [];

    //วนหาแล้วต่อ result ให้ยาวไปเรื่อยๆ ซ้ำก็ไม่เป็นไรเดี๋ยวไปนับทีหลัง
    for (let word of wordlist) {
        const filtered = subdistricts.filter(item => {
            return item.name.includes(word)
        });
        results = results.concat(filtered);
    }

    //เปลี่ยน format ให่้เหลือแต่ string ใน array อย่างเดียว
    const matches = results.map(item => item.name);

    //หาตัวที่ซ้ำบ่อยที่สุด
    const bestMatched = findBestMatched(matches).name.split(', ');

    return {
        subdistrict: bestMatched[0],
        district: removePrefix(bestMatched[1]),
        province: removePrefix(bestMatched[2]),
        zipcode: bestMatched[3]
    };
};

const findBestMatched = (matches) => {
    let group = {};

    //จับเข้ากลุ่่มกันพร้อมกับนับด้วย
    matches.forEach((i) => {
        group[i] = (group[i] || 0) + 1;
    });

    //เปลี่ยนเป็น array เพราะจะได้ sort ง่ายๆ
    let results = Object.keys(group).map(key => {
        return {
            name: key,
            count: group[key]
        }
    });

    //ส่งเอาเฉพาะตัวที่ซ้ำเยอะที่สุดกลับไป
    return results.sort((a, b) => {
        if (a.count > b.count) {
            return -1;
        }
        if (a.count > b.count) {
            return 1;
        }
        return 0;
    })[0];
}
  1. ได้ที่อยู่หลัก (ตำบล, อำเภอ, จังหวัด, รหัสไปรษณีย์) แล้วนะ
const split = async (text) => {
    try {
        console.log('input text:', text);
        const cleanText = removePrefix(text);
        const wordlist = cleanText.split(' ').filter(word => word.length >= 3);
        
        //ได้มาแล้ว
        const mainAddress = await findSubdistrict(wordlist); 
        
        console.log('address :', result)
    } catch (error) {
        console.error(error);
    }
};

หาชื่อ เบอร์โทร และที่อยู่ย่อย ที่เหลือ

หลังจากได้ ตำบล, อำเภอ, จังหวัด, รหัสไปรษณีย์ งานของเรายังไม่จบแค่นั้นครับ เรายังเหลืออีกหลายส่วน แถมยากด้วย แต่ผมก็จะใช้วิธีโง่ๆด้วยการตัดตำที่มั่นใจออกไปเรื่อยๆครับ จนสุดท้ายเราจะได้ก้อนที่อยู่ย่อยกระจุกกันอยู่ เราก็จะเอาไอ้ตัวนั้นแหละมาปิดงานของเรา โดยทั้งหมดนี้ผมจะทำใน finalResult(text, mainAddress) นะครับ

const finalResult = (text, mainAddress) => {
    const namePattern = /(เด็กชาย|เด็กหญิง|\.\.|\.\.|นาย|นาง|นางสาว|\.\.|ดร\.)([-]+\s[-]+)/;
    const phonePattern = /(08\d{1}-\d{3}-\d{4}|08\d{1}-\d{7}|08\d{8})/;

    let remainingTxt = text;

    //ตัดชื่อ ตำบล แขวง เขต จังหวัด รหัสไปรษณีย์ ที่เราได้มาแล้วออกไปก่อน
    const keyPattern = Object.values(mainAddress);
    keyPattern.forEach(key => {
        remainingTxt = remainingTxt.replace(key, '').trim();
    });

    //หาชื่อจาก pattern ที่มีคำนำหน้าและภาษาไทย 2 ก้อน แล้วก็เก็บลงไปในตัวแปร 
    const nameMatched = remainingTxt.match(namePattern);
    let name = '';
    if (nameMatched) {
        [name] = nameMatched
    }
    //เสร็จแล้วก็ลบออกจาก text ด้วย
    remainingTxt = remainingTxt.replace(name, '').trim();

    //หาเบอร์โทร อันนี้น่่าจะง่ายกว่าหาชื่อครับตัวเลขล้วนๆขึ้นต้นด้วย 08
    const phoneMatched = remainingTxt.match(phonePattern);
    let phone = '';
    if (phoneMatched) {
        [phone] = phoneMatched
    }
    //อย่าลืมลบออกเหมือนกันนะ
    remainingTxt = remainingTxt.replace(phone, '').trim();
    //เอาพวก "-" ออกไปเพราะบางคนอาจจะใส่หรือไม่ใส่เราก็ไม่รู้ เอาออกเลยดีกว่า
    phone = phone.replace(/-/g, '');

    //บางครั้งคนเราจะชอบใส่เบอร์ในวงเล็บ (081-222-3333) มันจะเหลือแต่ () เราก็ลบทิ้งไป
    remainingTxt = remainingTxt.replace('()', '').trim();

    //ก้อนสุดท้ายขอทึกทักเอาเองเลยว่ามันคือ ที่อยู่ย่อยๆ ซึ่งผมจะไม่เอาไปแยกนะครับ ผมพอใจแล้ว
    const address = remainingTxt.replace(/\s+/g, ' ').trim();

    return {
        name,
        phone,
        address,
        ...mainAddress
    }
}

ลองจับใส่ split() แล้ว run

const split = async (text) => {
    try {
        console.log('input text:', text);
        const cleanText = removePrefix(text);
        const wordlist = cleanText.split(' ').filter(word => word.length >= 3);
        const mainAddress = await findSubdistrict(wordlist);
        const result = finalResult(cleanText, mainAddress);
        console.log('address :', result)
    } catch (error) {
        console.error(error);
    }
};

ลอง run ด้วยคำสั่งบน terminal

node index "นายดราก้อน ตันเด้อ   อาคารเอ ชั้น  10    (081-234-5678) ห้อง 3  เขตพญาไท กรุงเทพมหานคร แขวงสามเสนใน 10400"

ลอง run

ตัวอย่าง code

Github: thai-address-splitter

เรียบร้อยครับ กับการแยกที่อยู่คนไทย ภาษาไทยแบบคร่าวๆ ถึงแม้มันจะยังไม่สามารถทำงานได้ 100% อีกอย่างถ้าเอาไปใช้บน web จริงๆก็ควรจะลดขนาด subdistrict.json ลงไปด้วย แต่ตอนนี้มันก็ช่วยลดเวลาการ copy paste ไปได้มากพอสมควรเลยครับ สำหรับท่านไหนที่มีข้อเสนอแนะสามารถบอกผมได้เลยครับ ผมยินดีมากที่จะได้ปรับปรุงครับ สำหรับท่านใดที่มีคำถามสามารถสอบถามเข้ามาที่ Inbox ของ Facebook Page ได้เลยนะครับ

บทความใกล้เคียง