หาตั๋วเครื่องบินราคาถูกล่วงหน้าด้วย Javascript และ Puppeteer

Cover image

เราจะมีวิธีหาราคาตั๋วเครื่องบินดีๆแบบล่วงหน้าหลายเดือนได้ไหม วันนี้ผมขอทดลองใช้ javascript แล้วก็ Chrome headless อย่าง Puppeteer มาช่วยแก้ปัญหานี้ดูครับ

เริ่มจากหาแหล่งข้อมูล

สำหรับข้อมูลผมหาได้ไม่ยากครับ ในหน้าเว็บของสายการบินมีเป็นปฏิทินให้อยู่แล้ว ผมแค่เข้าไปเลือกว่าจะเดินทางจากไหนไปไหน เท่านี้ก็จะมีราคาคร่าวๆให้เราได้ใช้กันแล้ว โดยในบทความนี้ผมจะเลือกเดินทางจาก "สกลนคร SNO" ไปลงที่ "ดอนเมือง DMK" เที่ยวเดียวนะครับ ปฏิทินราคาสายการบินสีเหลือง ปฏิทินราคาสายการบินสีแดง เห็นตัวเลขแสดงอยู่บนปฏิทินชัดขนาดนี้ก็ง่ายแล้วล่ะครับ แปลว่ามันต้องมีการ load ข้อมูลมาก่อนแน่เลยเพราะเมื่อไหร่ที่เราเปลี่ยนปลายทางราคาก็ต้องเปลี่ยนใช่ไหมล่ะ

สายการบินสีเหลือง

Inspector ดูปฏิทินราคาสายการบินสีเหลือง สำหรับสายการบินเหลืองนะครับ ผมก็ลองเปิด Developer Tools ขึ้นมาดูว่าใน Network มีการยิง request ไปที่ไหนบ้างโดยเฉพาะช่วงเวลาที่กดเรียกปฏิทิน ซึ่งผมก็ได้เจอกับ request นี้ครับ จะเห็นได้ว่ามี array เก็บ Object หน้าตาแบบนี้

{dateKey: "YYYYMMDD", amount: "X,XXX.XX"}

ใช่แล้วครับ มันคือวันที่และราคาค่าตั๋วที่ถูกที่สุดของวันนั้นนั่นเอง ถ้าเกิดลอง copy link address ไปเปิดใน tab ใหม่ก็จะได้สิ่งที่เราต้องการ ข้อมูลราคาพร้อมวันที่ของสายการบินสีเหลือง จบไปสำหรับแหล่งข้อมูลของสายการบินนี้นะครับ

สายการบินสีแดง

Inspector ดูปฏิทินราคาสายการบินสีแดง ไม่รอช้าครับผมรีบทำตาม step เดียวกับที่ลองในเว็บสายการบินก่อนหน้า แล้วผมก็เจอ request ที่น่าสนใจ แต่ตัวนี้เป็น Javascript Object ครับไม่ใช่ Array

"YYYY-MM-DD": X,XXX.XX

ไม่พูดพร่ำทำเพลงอะไรมากครับ copy link ไปลองเปิดใน tab ใหม่ดูเหมือนเดิม แต่รอบนี้ไม่เหมือนเดิม...!! ทดลองเปิดที่ Tab ใหม่ไม่ได้ข้อมูลจาก อ่าวเห้ย!! {"message": "Unauthorized"} ก็ได้ด้วยว่ะ แต่ด้วยความขี้เกียจเพราะเกรงว่าบทความนี้จะยาวเกินไป ผมจะไม่ไปงมหา apiKey หรือ Token อะไรให้เสียเวลา ผมจะใช้ Puppeteer นี่แหละดึงมันออกมาเอง เอาเป็นว่าเห็นละกันว่ามันอยู่ตรงไหนจบไปสำหรับแหล่งข้อมูลของสายการบินนี้ครับ

Coding

เอาล่ะครับ ถึงเวลาที่จะต้องลงไม้ลงมือจัดการเอาข้อมูลมาจากแหล่งที่เราได้ไปสำรวจมาแล้วซะทีนะครับ โดยในบทความนี้ผมมีแผนการคร่าวๆดังนี้นะครับ

  1. ข้อมูลจากสายการบินเหลืองด้วยการยิง request ไปขอตรงๆ ซึ่งผมจะเลือกใช้ตัวช่วยก็คือ axios เพื่อมาช่วยยิง request แล้วก็จัดการ response นะครับ
  2. ข้อมูลจากสายการบินสีแดงอันนี้ยากขึ้นมาหน่อยเพราะจะยิงไปขอดุ่มๆก็ไม่ได้ แต่ผมเองก็ขี้เกียจไป Reverse Engineering API ของเว็บเขาอีกครับเพราะคิดว่าน่าจะแยกได้เป็นอีกหนึ่งบทความเลย ผมเลยจะใช้ความสามารถของ Puppeteer ครับ โดยเข้าไปที่หน้าเว็บแล้วก็ทำตัวเสมือนคนใช้ทั่วไปคือเลือกสนามบินแล้วค่อยมากดดูฏิทิน ใช้ความสามารถของ Puppeteer ในการดัก response ที่เกิดขึ้นใน page เพื่อหาชุดข้อมูลราคาออกมา
  3. เอาข้อมูลจากทั้ง 2 ที่มาจัด format ให้เหมือนกันแล้วก็ merge กันให้เรียบร้อย โดยส่วนของวันที่ผมจะใช้ dayjs มาช่วยจัด format แล้วก็ใช้เปรียบเทียบวันที่เวลาที่ต้องการ filter ราคาครับ

ดึงข้อมูลจากสายการบินสีเหลือง

ตัวนี้ไม่ซับซ้อนครับ ผมเขียนเป็น function ที่ return promise ด้านในเรียกใช้ axios ไปที่ url แหล่งข้อมูลที่ดัดแปลงเรียบร้อยแล้ว

const getYellowAirlineFares = () => {
    return new Promise(async (resolve, reject) => {
        console.log('- Get flight from Yellow Airline');
        const fromDate = dayjs().startOf('month').format('MM/DD/YYYY');
        const toDate = dayjs().add(1, 'year').format('MM/DD/YYYY');
        const mainUrl = 'https://www.nokair.com/Flight/GetCalendarFare';
        const url = `${mainUrl}?from=${departure}&to=${destination}&fromDate=${fromDate}&toDate=${toDate}&currency=THB`;
        try {
            const res = await axios.get(url);
            const result = res.data.map(item => {
                const itemDate = dayjs(item.dateKey, 'YYYYMMDD');
                return {
                    date: itemDate.format('DD/MM/YYYY'),
                    day: itemDate.format('ddd'),
                    fare: parseFloat(item.amount.replace(',', '')),
                    brand: 'YELLOW'
                };
            })
            resolve(result);
        } catch (error) {
            console.error(error);
            reject(error);
        }
    });
};

จะเห็นว่าหลังจากคำสั่ง axios.get() ผมเอา response ที่ได้มาทำการ map ใหม่เพื่อให้ได้หน้าตา object ตามต้องการแล้วเดี๋ยวอีกสายการบินก็จะทำเหมือนกันครับ

ดึงข้อมูลจากสายการบินสีแดง

ของเจ้านี้จะค่อนข้างยาวขึ้นมาหน่อยเพราะเราไม่ได้ใช้การยิงตรงๆแบบเมื่อกี้ หน้าตา function ก็จะประมาณนี้ครับ ประกอบด้วยส่วนที่ setup ตัว puppeteer รวมไปถึงคำสั่งในการเข้าถึงข้อมูล

const getRedAirlineFares = () => {
    return new Promise(async (resolve, reject) => {
        console.log('- Get flight from Red Airline');
        const browser = await puppeteer.launch({ headless: true });
        const page = await browser.newPage();
        const mainUri = 'https://www.airasia.com/th/th';
        const departSelector = 'input[aria-controls="home-origin-autocomplete-heatmapstation-combobox"]';

        try {
            console.log('go to page');
            await page.goto(mainUri);
            console.log('waiting for input field');
            await page.waitForSelector(departSelector);
            console.log('click on input field');
            await page.click(departSelector);

            console.log('input departure airport');
            await page.keyboard.type(departure);
            await page.waitForSelector('li[id="home-origin-autocomplete-heatmaplist-0"]');
            await page.keyboard.press('Enter');
            await page.waitFor(1000);

            console.log('input destination airport');
            await page.keyboard.type(destination);
            await page.waitForSelector('li[id="home-destination-autocomplete-heatmaplist-0"]');
            await page.waitFor(1000);
            await page.keyboard.press('Enter');
        } catch (error) {
            browser.close();
            console.error(error);
            resolve([]);
        }

        page.on('response', async (response) => {
            const pattern = /pricecalendar\/\d\/\d\/THB\/\w{3}\/\w{3}\/\d{4}-\d{2}-\d{2}\/1\/\d+/;

            if (pattern.test(response.url())) {
                console.log('detected', response.url());
                const data = await response.json();
                try {
                    const dataKey = `${departure}${destination}|THB`;
                    const result = Object.keys(data[dataKey]).map(key => {
                        const keyDate = dayjs(key, 'YYYY-MM-DD')
                        const date = keyDate.format('DD/MM/YYYY');
                        const day = keyDate.format('ddd');
                        const fare = data[dataKey][key];
                        return {
                            date, day, fare,
                            brand: 'RED'
                        };
                    });
                    browser.close();
                    resolve(result);
                } catch (error) {
                    console.error(error);
                    browser.close();
                    reject(error);
                }
            }
        });
    });
};

ส่วนที่ใช้เปิดเว็บแล้วก็เลือกสนามบินด้วยการพิมพ์ครับ เลียนแบบการใช้งานปกติของตัวผมเองนี่แหละ

console.log('- Get flight from Red Airline');
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
const mainUri = 'https://www.airasia.com/th/th';
const departSelector = 'input[aria-controls="home-origin-autocomplete-heatmapstation-combobox"]';
try {
    console.log('go to page');
    await page.goto(mainUri);
    console.log('waiting for input field');
    await page.waitForSelector(departSelector);
    console.log('click on input field');
    await page.click(departSelector);
    console.log('input departure airport');
    await page.keyboard.type(departure);
    await page.waitForSelector('li[id="home-origin-autocomplete-heatmaplist-0"]');
    await page.keyboard.press('Enter');
    await page.waitFor(1000);
    console.log('input destination airport');
    await page.keyboard.type(destination);
    await page.waitForSelector('li[id="home-destination-autocomplete-heatmaplist-0"]');
    await page.waitFor(1000);
    await page.keyboard.press('Enter');
} catch (error) {
    browser.close();
    console.error(error);
    resolve([]);
}

ส่วนที่ใช้เปิดเว็บแล้วก็เลือกสนามบินด้วยการพิมพ์ครับ เลียนแบบการใช้งานปกติของตัวผมเองนี่แหละ ต่อมาก็คือส่วนที่รอ response โดยผมเลือกรับเฉพาะ response จาก url เป้าหมายที่เราไปเรียกเองแล้วมันไม่ผ่านนั่นแหละครับ ซึ่งหลังจากได้ข้อมูลมาแล้วก็เอามาจัด format ให้เหมือนกัน

page.on('response', async (response) => {
    const pattern = /pricecalendar\/\d\/\d\/THB\/\w{3}\/\w{3}\/\d{4}-\d{2}-\d{2}\/1\/\d+/;
    if (pattern.test(response.url())) {
        console.log('detected', response.url());
        const data = await response.json();
        try {
            const dataKey = `${departure}${destination}|THB`;
            const result = Object.keys(data[dataKey]).map(key => {
                const keyDate = dayjs(key, 'YYYY-MM-DD')
                const date = keyDate.format('DD/MM/YYYY');
                const day = keyDate.format('ddd');
                const fare = data[dataKey][key];
                return {
                    date, day, fare,
                    brand: 'RED'
                };
            });
            browser.close();
            resolve(result);
        } catch (error) {
            console.error(error);
            browser.close();
            reject(error);
        }
    }
});

ฟังก์ชันสุดท้ายสำหรับรวมข้อมูลจาก 2 แหล่งแล้วเอามา filter ครับ โดยผมกำหนดไว้ว่าจะ "เอาแค่เที่ยวบินที่บินในเดือนนี้และเดือนหน้า ที่ราคาไม่เกิน 1,000 บาท" ครับ เสร็จแล้วก็ save ไปที่ไฟล์ชื่อ results.json เลย

const getFares = async () => {
    const red = await getRedAirlineFares();
    const yellow = await getYellowAirlineFares();
    const results = red.concat(yellow);
    const nextTwoMonth = dayjs().add(2, 'month').startOf('month');
    const filtered = results.filter(item => {
        return dayjs(item.date, 'DD/MM/YYYY').isBefore(nextTwoMonth, 'date') && item.fare < 1000
    })
    const writeFile = util.promisify(fs.writeFile);
    try {
        await writeFile('results.json', JSON.stringify(filtered));
        console.log(`founded ${filtered.length} in results.json`);
    } catch (error) {
        console.error(error);
    }
};

เมื่อเรา run ก็จะได้ผลลัพธ์ประมาณนี้ครับ

> node index.js

- Get flight from Red Airline
go to page
waiting for input field
click on input field
input departure airport
input destination airport
detected https://k.airasia.com/availabledates-pwa/api/v1/pricecalendar/0/1/THB/SNO/DMK/2019-08-21/1/16
- Get flight from Yellow Airline
founded 63 in results.json

และเมื่อเราเปิดดูไฟล์ results.json ก็จะได้ข้อมูลที่สวยงามรอให้เอาไปใช้งานต่อได้แล้วครับ

ตัวอย่างไฟล์ results.json

ตัวอย่าง code

https://github.com/clonezer/get-flight-fares

จบไปแล้วครับสำหรับการทดลองหาตั๋วที่ราคาถูกล่วงหน้า จาก 2 สายการบินชื่อดังในประเทศของเราครับ ซึ่งก็ถือว่าประหยัดเวลาวางแผนไปได้พอสมควร แต่อยากจะฝากใครที่อ่านจบแล้วกำลังมีไอเดียอยากจะไปขุดเอาข้อมูลพวกนี้ไปใช้ทำมาหากิน อย่าลืมไปอ่าน policy ของเว็บด้วยนะครับว่าเขาอนุญาตให้เราเอาไปใช้ประโยชน์แบบไหนได้หรือเปล่า สำหรับกรณีนี้ผมใช้เพื่อศึกษาการเขียน code เท่านั้นนะครับ

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