編寫web2.0爬蟲——頁面抓取部分

炒雞辣雞復讀機發表於2020-10-09

web2.0頁面抓取

什麼是web2.0頁面?
我個人理解的就是不是寫死的頁面

今天要給大夥介紹的頁面抓取框架是谷歌針對操作無頭瀏覽器推出的一個基於nodejs的框架——Puppeteer。這個框架的API位於中文API文件。遺憾的是,中文API翻譯得並不完整,所以還是得提高自身的英文文件閱讀能力。

安裝nodejs環境

參考部落格

利用koa構建nodejs後端服務

這一步的作用主要是讓我們寫的頁面抓取部分能對外直接提供方便呼叫的介面。至於到底使用koa還是別的Nodejs框架,甚至於不用web服務,都是可以的

安裝Puppeteer 和 Chromium

搭建起nodejs的開發環境之後,就可以安裝puppeteer和chromium了。

npm i puppeteer # 會同時安裝chromium,需要全域性代理
npm i puppeteer-core # 不會安裝chromium,不需要全域性代理

如果出現安裝不上的情況,那麼可能是你的npm和node不相容引起的,最好的辦法是重灌npm和nodejs。

簡單抓取一個Web2.0頁面

var glovakBrower;

async function crawl(url){
    if(typeof glovakBrower == 'undefined' || glovakBrower == 'undefine'){
        globalBrower = await puppeteer.launch({
            headless:true,
            args: [
                '--no-sandbox', 
                '--disable-setuid-sandbox',
                // '--proxy-server=http://127.0.0.1:18888'
            ],
            executablePath:'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
        });
    }

    var page = await glovakBrower.newPage();
    try {
        page.setDefaultNavigationTimeout(110 * 1000);
        page.setUserAgent('Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.130 Safari/537.36');
        page.setJavaScriptEnabled(true);
        page.setCacheEnabled(false);
        page.setRequestInterception(true);
        page.on('request', interceptedRequest => {
            let url = interceptedRequest.url();
            if(url.indexOf('.png') > -1 || url.indexOf('.jpg') > -1 
                                        || url.indexOf('.gif') > -1 
                                        || url.indexOf('.pdf') > -1
                                        || url.indexOf('.txt') > -1
                                        || url.indexOf('.mp4') > -1
                                        || interceptedRequest.resourceType() === 'image')
              interceptedRequest.abort();
            else
              interceptedRequest.continue();
        });
        await page.goto(url);
        await page.waitForSelector('html'); 
        let html = await page.content();
        return html;
    } catch (error) {
        
    }
    finally {
        page.close();
    }
}

這裡僅僅使用了goto方法,然後通過waitForSelector方法等待html的載入,後面我會介紹更為合理的方法。這樣一來,一個基本的抓取web2.0頁面的爬蟲就完成了。這樣的爬蟲交給單純做頁面文字抓取的服務就已經完全夠了。

一個真正的web2.0頁面抓取模組

1、DOM樹遍歷
2、事件遞迴觸發
3、表單自動填寫
等等

如何實現上面的功能?

先來看看會遇到的問題

1、事件觸發的時候,會產生新的事件,同時會導致頁面發生變化

此時我們有兩種方案解決這樣的問題,其一是重新載入整個頁面,其二是監聽頁面的變化。
先來說說前面那個方法存在的問題:頁面重新載入會導致所有事件會被重新觸發,進而造成混亂。
所以可以考慮監聽頁面的變化,在DOM中提供了DOM4型別事件,這種事件會儲存一段時間內頁面的節點變化情況,以非同步的方式去告訴呼叫者哪些節點發生了變化。我們拿到變化之後,只需要再使用dom樹遍歷的方式,例如treeWalker等遍歷新增的DOM節點,然後進行事件識別和觸發即可。
存在的問題:由於是非同步的,我們在上層觸發的時候無法做到判斷下層事件究竟需要執行多久,只能通過等待的方式去非同步觸發,而不是以同步的方式,等待下層的觸發完了再回到上層的事件觸發。
優化思路:nodejs本身也是單執行緒的,所以執行肯定是有先後關係的,需要弄清楚nodejs底層原理才行。

2、觸發一部分事件的時候,可能會導致呼叫window.open 和 window.close 操作

需要對瀏覽器的window.open和window.close方法的預設行為進行修改,避免出現頁面重定向的行為,為啥要避免?前面提到了,不論是open還是location都會導致頁面主體發生變化,導致頁面重新載入,進而造成所有事件被重新觸發,造成混亂。
puppeteer提供了相關的方法在頁面載入前去修改BOM中物件的預設行為,但是並不是所有物件的所有方法都能被修改,這取決於瀏覽器是如何定義這些方法的。幸運的是,window.open 和 window.close方法是完全可以進行修改的。

3、觸發一部分事件的時候,可能會導致呼叫window.location 操作

window.location方法在瀏覽器中預設是無法修改的,所以我們如果在程式碼中直接修改其預設的行為是不生效的。
解決方案:修改chromium原始碼,重新編譯chrome。

4、form表單填寫時,點選提交會導致頁面重新載入

首先修改submit方法的預設行為,然後給form表單新增dom2型submit事件,再利用dispatchEvent觸發該事件。

程式碼部分

建立瀏覽器物件,由於ndoejs預設是單執行緒的,所以我們只需要在全域性開啟一個瀏覽器物件即可,減少記憶體的浪費。

if (typeof globalBrower === 'undefined' || globalBrower === 'undefine') {
        globalBrower = await puppeteer.launch({
            headless: false,
            ignoreHTTPSErrors: true,        // 忽略證照錯誤
            waitUntil: 'networkidle2',
            defaultViewport: {
                width: 1920,
                height: 1080
            },
            args: [
                '--disable-gpu', // 關閉GPU
                '--disable-dev-shm-usage',
                '--disable-web-security',
                '--disable-xss-auditor',    // 關閉 XSS Auditor
                '--no-zygote',
                '--no-sandbox', // 關閉沙箱模式
                '--disable-setuid-sandbox',
                '--allow-running-insecure-content',     // 允許不安全內容
                '--disable-webgl',
                '--disable-popup-blocking',
            ],
            executablePath: 'path/to/chrome',
        });
    }

剛才我們提到了,需要在頁面載入之前修改一些瀏覽器預設的行為。可以使用page.evaluateOnNewDocument的方法。

// 修改location的預設行為
// 注意:在修改之前,確認你已經修改過瀏覽器中關於Location的預設行為了
await page.evaluateOnNewDocument(() => {

            var oldLocation = window.location;
            var fakeLocation = Object();
            fakeLocation.replace = fakeLocation.assign = function (value) {
            };
            fakeLocation.reload = function () {
            };
            fakeLocation.toString = function () {
            };
            Object.defineProperties(fakeLocation, {
                'href': {
                    'get': function () {
                    },
                    'set': function (value) {
                    }
                },
                // hash, host, hostname ...
            });
            var replaceLocation = function (obj) {
                Object.defineProperty(obj, 'location', {
                    'get': function () {
                    },
                    'set': function (value) {
                    }
                });
            };

            replaceLocation(window);
            addEventListener('DOMContentLoaded', function () {
            })
        });
// 修改bom中一些其他函式的預設行為
async function initHook() {

    window.onbeforeunload = function (e) {
    };


    //todo hook History API,許多前端框架都採用此API進行頁面路由,記錄url並取消操作
    window.history.pushState = function (a, b, url) {
    };
    window.history.replaceState = function (a, b, url) {
       
    };
    Object.defineProperty(window.history, "pushState", {"writable": false, "configurable": false});
    Object.defineProperty(window.history, "replaceState", {"writable": false, "configurable": false});

    // todo 監聽hash變化,Vue等框架預設使用hash部分進行前端頁面路由
    window.addEventListener("hashchange", function () {
       
    });

    // todo 監聽視窗的開啟和關閉,記錄新視窗開啟的url,並取消實際操作
    window.alert = () => {
    };
    Object.defineProperty(window, "alert", {"writable": false, "configurable": false});

    window.prompt = (msg, input) => {
    };
    Object.defineProperty(window, "prompt", {"writable": false, "configurable": false});

    window.confirm = () => {
    };
    Object.defineProperty(window, "confirm", {"writable": false, "configurable": false});

    window.open = function (url) {
    };
    Object.defineProperty(window, "open", {"writable": false, "configurable": false});

    window.close = function () {
    };
    Object.defineProperty(window, "close", {"writable": false, "configurable": false});

    window.__originalSetTimeout = window.setTimeout;
    window.setTimeout = function () {
    };
    window.__originalSetInterval = window.setInterval;
    window.setInterval = function () {
    };

    XMLHttpRequest.prototype.__originalOpen = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
    }
    XMLHttpRequest.prototype.__originalSend = XMLHttpRequest.prototype.send;
    XMLHttpRequest.prototype.send = function (data) {
    }

    HTMLFormElement.prototype.__originalSubmit = HTMLFormElement.prototype.submit;
    HTMLFormElement.prototype.submit = function () {
    	// hook code
    }

}

在hook完物件後,我們需要使用page.on方法在頁面事件觸發的時候進行處理

await page.on('request', async interceptedRequest => {
});

await page.on('dialog', async dialog => {
});

await page.on('response', interceptedResponse => {
});

await page.on('requestfailed', async failed => {
});

await page.on('console', async msg => {
});

page.on('framenavigated', frameTo => {
});

值得注意的是,觸發request事件需要開啟頁面攔截。page基礎設定相關的程式碼為

await page.setDefaultNavigationTimeout(110 * 1000);
await page.setUserAgent('Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko)Chrome/44.0.2403.130 Safari/537.36');
await page.setJavaScriptEnabled(true);
await page.setCacheEnabled(false);
await page.setRequestInterception(true); // 頁面的request事件觸發取決於此
await page.setViewport({"width": 1920, "height": 1080});

以上準備工作完成之後,就可以呼叫goto方法對頁面進行載入。注意呼叫的先後順序,否則會出現意想不到的問題。

await page.goto(url, {
   waitUntil: 'networkidle2'
});

其中,waitUntil的存在是因為goto方法是非同步的,在等到還有networkidle2(兩個網路通訊)的時候結束等待。
然後我們需要監聽整個body的節點變化,只需要利用dom4級別的事件監聽即可

await page.$eval('body', () => {
            // todo 這裡監聽的是dom4事件,即dom樹被修改,觸發節點變化的事件
            // mutations 是一個node_list
            let observer = new MutationObserver((mutations) => {
                console.log('eventLoop-nodesMutated:', mutations.length, typeof mutations);
                mutations.forEach((mutation) => {
                    if (mutation.type === 'childList') {
                        for (let i = 0; i < mutation.addedNodes.length; i++) {
                            // 構造treeWalker進行節點遍歷
                        }
                    } else if (mutation.type === 'attributes') {
                    }
                });
            });
            observer.observe(document.getElementsByTagName('body')[0], {
                childList: true,
                attributes: true,
                characterData: false,
                subtree: true,
                characterDataOldValue: false,
                attributeFilter: ['src', 'href'],
            });
        });

最後,在遍歷所有form表單,建立事件並觸發即可

let formsParams = await page.evaluate(() => {
    // 表單處理
    let regexps = {
    };
    let name_type = {
    };
    let name_values = {
    };
    let textLengthReg = '([\\s\\S]*)?((len$)|(length$)|(size$))';
    let formsParams = [];
    for (let i = 0; i < document.forms.length; i++) {
        let form = document.forms[i];
        // console.log(form.method, form.action);
        let ans = {};
        // todo form can't intercept, so I
        for (let j = 0; j < form.length; j++) {
            let length = -1;
            let input = form[j];
            let name = input.name.replace('_', '').toLowerCase().trim();
            if (input.type === 'submit') {
                // 如果當前輸入框為 submit,則點選提交
                form.addEventListener('submit', (e) => {
                    e.preventDefault();
                    new FormData(form);
                });
                form.addEventListener('formdata', (e) => {
                    let data = e.formData;
                    let request = new XMLHttpRequest();
                    request.open(form.method, form.action);
                    request.send(data);
                });

                let evt = document.createEvent('HTMLEvents');
                evt.initEvent('submit', true, true);
                try {
                    form.dispatchEvent(evt);
                } catch {
                }
                continue;
            }
            for (let key in regexps) {
                if (name.match(regexps[key])) {
                    // 如果name匹配到了當前正規表示式,填入值即可
                    
                    break;
                }
            }
            formsParams.push({
                'url': form.action,
                'method': form.method,
                'params': ans
            });
        }
    }
    return formsParams;
});

剩下還有一些對標籤的觸發比較簡單,大家可以自行解決。

相關文章