Build extension to check timesheet on WSM (P1)

Bài toán

Hôm nọ mình quên k check timesheet trên wsm nên bị dính mấy phát IL & LE nên nhân dịp đang nghịch thằng puppeteer này mình build luôn 1 cái extension để check wsm luôn OK. Chủ đề đã có. GIờ vạch ra những issue nào Những thứ hay ho mình sẽ áp dụng trong phần này (toàn những thứ mình đã viết trước đây - giờ chỉ là kết hợp lại vs nhau thôi)

  1. Server Do timesheet thay đổi liên tục nên mình sẽ không dùng DB để lưu Ta sẽ dùng Nodejs & Puppeteer để build 2 API check timesheet & submit request. Mọi người có thể tham khảo bài viết về puppeteer

  2. Client Mình thích xài Angular 2 nên sẽ dùng thằng này cho phần client. Để cho tiện mình sẽ dùng ng-cli

  3. Còn về cách build extension thì mọi người có thể tham khảo tại đây

Triển chiêu (server)

API check timesheet

@Input sẽ có dạng như sau

{
  "email": "[email protected]",
  "password": "wsm-puppeteer"
}

@Output

{
    "day_IL": ['2018-02-26 07:47', '2018-02-21 08:50', '2018-02-23 08:50'],
    "day_LE": ['2018-03-06 15:40', '2018-02-08 14:40'],
}

Login

Đầu tiên ta cần phải login vào hệ thống WSM. Thử xem form login của wsm có những element gì nào Thật may là họ dùng id cho những input, như vậy ta sẽ không sợ bị trùng element. Vậy là ta có

    const puppeteer = require('puppeteer');
    const DOMAIN = 'https://wsm.framgia.vn';

    const URL_TIMESHEET = DOMAIN + '/vi/dashboard/user_timesheets';
    async functon loginWSM(email, password) {
        browser = await puppeteer.launch({headless: true});
        const page = await browser.newPage();
        page.setViewport({width: 1280, height: 720});
        await page.goto(URL_TIMESHEET);

        // START LOGIN ===============
        await page.click('.btn-login');

        await page.type('#user_email', email);
        await page.waitFor(500);
        await page.type('#user_password', password);
        await page.waitFor(500);

        await page.click('#devise-login-form .login-success');
    }

Sau khi login thì ta sẽ được chuyển đến màn hình timesheet. Nếu ở ngay chỗ này ta dùng hàm evaluate để lấy dữ liệu thì sẽ bị lỗi. Vì content ở bảng này được load bằng AJAX. Khi code trong evaluate thực hiện thì chưa chắc AJAX đã chạy xong => ta sẽ không lấy được thông tin gì hết

Vậy ta sẽ phải xem trước & sau khi AJAX done thì element nào sẽ được thay đổi

Thử F12 & chỉnh tốc độ mạng về yếu nhất (Tab Network -> Ô select "No Throttling" -> chọn GPRS) để ta có thể quan sát được rõ nhất việc AJAX hoạt động. Các bạn có thể tham khảo ảnh bên dưới Bạn để í thấy <tbody> đang bị trống => ta sẽ phải chờ cho đến khi content trong thẻ <tbody> được load xong. Rất may là puppeteer có support việc này bằng waitForSelector

await page.click('#devise-login-form .login-success');
// check authentication
// Để cho chắc ăn thì mình lấy thêm element thằng cha của nó nữa
await page.waitForSelector('.curr tbody tr', {timeout: 10000}).catch(async function (e) {
    throw new Error("Login fail");
});

Phân tích & lấy timesheet IL LE

OK. Vậy là ta đã login được thành công rồi. Nhưng mục tiêu chính của API này là lấy time IL LE. Vậy phải làm sao... Mình đâu có mò được vào DB để xem Như ta đã biết thì khi IL LE hay timesheet ngày nào đó của bạn có "vấn đề" thì nó sẽ được bôi 1 màu nào đó. Nhưng IL/LE thì được bôi màu nào. Mình đã phải chuột bạch vài buổi để có thể check được việc này. Nhưng sau đó mình phát hiện ra việc đó quá thừa thãi vì...

Hệ thống đã chú thích sẵn cho ta rồi 😐. OK. Ta chỉ tập trung vào màu đỏ "IL/LO/LE/WO/QQ/QT ngoài quota" thôi nhé. Inspect thì ta thấy nó có 1 class là brg-red

1 ngày có 2 mốc time in-out. Nên ta cần phải inspect xem chúng sẽ tương ứng vs class || id nào

Vậy là in-out sẽ tương ứng với class event-in & event-out. Các bạn để í class ở thẻ <td> sẽ tương ứng với ngày đó luôn nhé (Quá tiện rồi) Áp dụng evaluate của puppeteer ta sẽ lấy được dữ liệu IL LE của mình rồi

async function detechDate(page) {
    return await page.evaluate(() => {
        let dayIL = [];
        let dayLE = [];
        document.querySelectorAll(".calendar-content .calendar-content .brg-red.event-in").forEach(function (day) {
            dayIL.push(day.closest('td').getAttribute('class') + ' ' + day.innerText);
        });

        document.querySelectorAll(".calendar-content .calendar-content .brg-red.event-out").forEach(function (day) {
            dayLE.push(day.closest('td').getAttribute('class') + ' ' + day.innerText);
        });

        return {
            "day_IL": dayIL,
            "day_LE": dayLE,
        };
    });
}

API Submit request

@Input:

{
    "reason": "Sorry I'm late. Traffic was unusually bad today.",
    "date": "2018-03-05 07:50:00",
    "type_request": 1,
    "email": "[email protected]",
    "password": "wsm-puppeteer"
    "compensation_date": "2018-03-06 16:45:00",
}

Tương tự như phần login. Ta cũng sẽ phải inspect element của form xem. Mình sẽ cần những field sau cho mỗi request

  1. type_request: Hình thức xin phép (IL/LE)
  2. date: Ngày xin phép
  3. compensation_date: Ngày làm bù
  4. reason: Lý do

Phần này chỉ là điền vào form mà thôi

async function submitFormRequest(req) {
    if (params.type_request == TYPE_REQUEST_LE) {
        await page.waitFor(500);
        await page.select('select[name="request_leave[leave_type_id]"]', params.type_request.toString());
        elementTime = "request_leave_early";
    }

    await page.waitFor(500);
    await page.type('#' + elementTime, params.date);
    await page.waitFor(500);
    await page.keyboard.press('Tab');
    await page.waitFor(500);
    await page.type('#request_leave_compensation_attributes_compensation_from', params.compensation_date);
    await page.waitFor(500);
    
    await page.type('#new_request_leave textarea', params.reason);
    await page.waitFor(500);

    await page.click('#new_request_leave input[name=commit]');
}

Ơ. thế này thì đơn giản quá nhể !!! Đừng mơ. Ta còn phải xử lí lỗi nếu input không hợp lệ nữa cơ

Xử lí lỗi của form

1. Sweet alert Nếu bạn nhập thời gian IL là ngày quá khứ thì sẽ hiển thị popup báo lỗi ngay lập tức. Ta sẽ phải check case này & response về cho client lỗi.

Mình đã check element & các bạn có thể tham khảo cách của mình

// check valid time
let timeInvalid = await page.waitForSelector('.showSweetAlert', {timeout: 500})
    .then(async function (e) {
        return await page.evaluate(() => {
            return document.querySelector('.showSweetAlert p').innerText;
        });
    }).catch(async function (e) {
        return true;
    });

if (typeof timeInvalid == "string") {
    throw new Error(timeInvalid);
}

2. Check form valid

Khi submit form sẽ có 2 case. Nếu request được tạo thành công sẽ redirect đến màn hình list. Còn nếu lỗi nó sẽ hiển thị ở trang tạo form.

Ta có thể dùng hàm waitForSelector cho 1 element ở màn hình list để check xem request được tạo thành công hay k. Nhưng nếu mạng chậm, request được tạo xong mà chưa kịp redirect đến màn hình list thì sao nhỉ. Lỗi phải không ?

Cách tối ưu nhất là sau khi submit form ta sẽ phải chờ page được load xong thì mới check element. Và rất may là ta có hàm waitForNavigation giải quyết được vấn đề này

await page.waitForNavigation();
let url_edit = await page.waitForSelector('#request_leave_search', {timeout: 2000})
    .then(async function (e) {
        return Promise.resolve(await page.evaluate(() => {
            return document.querySelector(".list-request-leaves tr:nth-child(1) td:last-child a.btn-warning").getAttribute('href');
        }));
    })
    .catch(async function (e) {
        const errorSubmitForm = await page.evaluate(() => {
            let ulError = document.querySelector('#error_explanation ul');
            let messError = "Error when create request. Please try again after few minutes !";
            if (ulError !== null) {
                messError = ulError.innerText;
            }

            return messError;
        });
        throw new Error(errorSubmitForm);
    });

Vậy là ta đã build xong 2 API cho server rồi. Mình chỉ quote những đoạn code quan trọng lên thôi. Bạn nào quan tâm có thể tham khảo code trên git trong p2 của mình

All Rights Reserved