server-queryselector aka парсим html в nodejs -1


Итак, мы хотим получить информацию с веб сайта — это можно сделать в 3 шага

1) Получить html сайта (пропустим этот шаг)

2) Распарсить html строку и создать dom. — builderdom.js

3) Найти нужные dom_node из dom по кссселекторам.

3.1) Распарсить строку кссселекторов и создать дерево для поиска. — cssselectorparser.js
3.2) Отфильтровать дом_ноды по дереву кссселекторов и найти нужные. — treeworker.js

2) Парсим html:

2.1) Нарезаем строки(выделил в отдельный проект superxmlparser74)
Создаем строки, накапливаем в них токены и обрезаем по маркерам

Таким образом у нас есть тег/innerTEXT — t, аттрибуты в виде массива — attr

клик
class superxmlparser74 {
    static parse(str, cbOpenTag, cbInnerText, cbClosedTag, cbSelfOpenTag = () => {
    }) {
        let isOpen = false;
        let startAttr = false;
        let t = ''
        let tAttrKey = '';
        let tAttrValue = '';
        let tAttrStart = false;
        let tAttr = '';
        let attr = [];
        let prevCh = '';
        for (let i = 0; i <= str.length - 1; i++) {
            //(1)<li (2)class="breadcrumb-item-selected text-gray-light breadcrumb-item text-mono h5-mktg" aria-current="GitHub Student Developer Pack"(3)>GitHub Student Developer Pack(4)</li(5)>
            //<selfclosing />
            //comments // <!-- -->
            if (str[i] === '/' && str[i + 1] === "/") {
                for (let j = i + 2; j <= str.length - 1; j++) {
                    if (str[j] === '\n') {
                        i = j;
                        break;
                    }
                }
                continue
            } else if (str[i] === "<") { //1
                //comments <!-- -->
                if (str[i + 1] === '!' && str[i + 2] === "-" && str[i + 3] === "-") {
                    for (let j = i + 4; j <= str.length - 1; j++) {
                        if (str[j] === '-' && str[j + 1] === '-' && str[j + 2] === '>') {
                            i = j + 2;
                            break;
                        }
                    }
                    continue
                }
                ///

                if (t.trim() !== '' && t.trim() !== "\n" && t.trim() !== "\t") {
                    //cut innerTEXT 4
                    cbInnerText({
                        value: t
                    });
                    t = '';
                } else if (str[i + 1] !== "/") {
                    cbInnerText({
                        value: ""
                    });
                }
                //open tag
                isOpen = true;
                if (str[i + 1] === "/") {
                    isOpen = false;
                    i = i + 1;
                    continue;
                }
            } else if (str[i] === '>') {
                ///closed tag - build 3/5
                if (isOpen) {
                    if (prevCh === "/") {
                        cbSelfOpenTag({
                            tag: t,
                            attr: attr
                        })
                    } else {
                        cbOpenTag({
                            tag: t,
                            attr: attr,
                        })
                    }
                } else {
                    cbClosedTag({})
                }
                attr = [];
                t = '';
                startAttr = false;
                isOpen = false;
            } else {
                //accum str
                if ((!startAttr && str[i] !== ' ') || !isOpen) {
                    t += str[i];
                } else if (startAttr) { //get attr 2
                    if (str[i] === '=') {
                        tAttrKey = tAttr
                        tAttr = '';
                    } else if (str[i] === '"') {
                        tAttrStart = !tAttrStart;
                        if (tAttrStart === false) {
                            if (tAttrKey === 'class') {
                                tAttrValue = tAttr.split(" ");
                            } else {
                                tAttrValue = [tAttr];
                            }
                            tAttr = '';
                            attr.push({key: tAttrKey, value: tAttrValue});
                            if (str[i + 1] === ' ') {
                                i = i + 1;
                                continue;
                            }
                        }
                    } else {
                        tAttr += str[i];
                    }

                } else if (str[i] === ' ' && isOpen) {
                    startAttr = true;
                }

            }
            prevCh = str[i];
        }
    }
}


2.2) Создаем дерево

const superxmlparser74 = require("superxmlparser74");

class dom_node {
    childrens = [];
    innerTEXT = '';
    tag;
    treeWorker;

    constructor() {
        this.treeWorker = global.treeworker;
    }

    innerHTML = (cliFormat = false) => {
        return this.treeWorker.getInnerHTML(this, cliFormat);
    };
    querySelector = (selector) => {
        this.treeWorker.setCurrentTreeByNode(this);
        return this.treeWorker.filtredBySelector(selector);
    }
}

class BuilderDOM {
    html_to_dom(str) {
        var utils = {
            noEndTag(tag) {
                let noEndTags = [
                    'noscript',
                    'link',
                    'base',
                    'meta',
                    'input',
                    'svg',
                    'path',
                    'img',
                    'br',
                    'area',
                    'base',
                    'br',
                    'col',
                    'embed',
                    'hr',
                    'img',
                    'input',
                    'keygen',
                    'link',
                    'meta',
                    'param',
                    'source',
                    'track',
                    'wbr'
                ];
                return noEndTags.includes(tag);
            }
        };

        let res = [];
        let parentStack = [];
        superxmlparser74.parse(str,
            (item) => {
                //opentag
                if (item.tag === 'p' && parentStack[parentStack.length - 1]?.tag === 'p') {
                    parentStack.pop();
                }
                //
                let el = new dom_node();
                el.attr = item.attr;
                el.tag = item.tag;
                res.push(el);
                el.attr.push({
                    key: 'tag',
                    value: [item.tag]
                })
                if (parentStack[parentStack.length - 1] && el.tag !== 'script') {
                    parentStack[parentStack.length - 1].childrens.push(el)
                }
                if (!utils.noEndTag(el.tag)) {
                    parentStack.push(el);
                }
            },
            (item) => {
                //innertext
                if (parentStack[parentStack.length - 1]) {
                    parentStack[parentStack.length - 1].innerTEXT += item.value;
                }
            },
            (item) => {
                //closedtag
                parentStack.pop();
            });

        return res;
    }

}

3) Поиск

3.1) Парсинг кссселекторов

Разбиваем строку кссселекторов по разделителям, определяем какой это кссселектор, обрезаем и создаем дерево.

class cssSelectorParser {
    parse(str) {
        let res = [];
        str = this.utils.lex(str);
        for (var i = 0; i <= str.length - 1; i++) {
            if (str[i].includes(".")) {
                res.push({key: 'class', value: str[i].substring(1)});
            } else if (str[i].includes("#")) {
                res.push({key: 'id', value: str[i].substring(1)});
            } else if (str[i].includes("[")) {
                let current = str[i];
                current = current.substring(1);
                current = current.slice(0, -1);
                current = current.split("=");
                res.push({key: current[0], value: current[1]});
            } else if (str[i] === '>') {
                res.push({key: '', value: str[i]});
            } else if (str[i] === ' ') {
                res.push({key: '', value: str[i]});
            } else if(str[i] !== '') {
                res.push({key: 'tag', value: str[i]});
            }
        }
        //merge
        let mergeRes = [];
        let t = [];
        for (var i = 0; i <= res.length - 1; i++) {
            if (res[i].value === ' ') {
                mergeRes.push(t);
                t = [];
            } else {
                t.push(res[i]);
            }
        }
        mergeRes.push(t);
        //
        return mergeRes;
    }
 
    utils = {
        lex(str) {
            let res = '';
            for (var i = 0; i <= str.length - 1; i++) {
                res += str[i];
                if (str[i + 1] === "." || str[i + 1] === '#' || str[i + 1] === '>' || str[i + 1] === '[' || (str[i] === ' ')) {
                    res += "\n";
                } else if (str[i + 1] === " ") {
                    res += "\n"
                }
            }
            return res.split("\n");
        }
    }
}

3.2) Теперь отфильтруем дом_ноды по кссселекторам

class treeWorker {
    //Текущий массив дом_ноде
    _tree;
 
    //Построить массив элементов всех детей ноды
    setCurrentTreeByNode(node) {
        let tree = this._getChildrens([node]);
        this._tree = tree;
    }
    //Основной цикл, где мы и фильтруем dom по дереву кссселекторов
    filtredBySelector(selector) {
        let cssselectorParser = new cssSelectorParser();
        selector = cssselectorParser.parse(selector);
        let res;
        for (let i = 0; i <= selector.length - 1; i++) {
            let currentSelector = selector[i];
            let key;
            let item;
            let isArrowSelector = (currentSelector[0].value === '>');
            if (isArrowSelector) {
                continue;
            }
            for (var j = 0; j < currentSelector.length; j++) {
                key = currentSelector[j].key
                item = currentSelector[j].value;
                this._filtredByAttribute(key, item)
            }
            res = this._tree;
            let nextSelectorArrow = selector[i + 1] && selector[i + 1][0] && selector[i + 1][0].value === '>';
            this._sliceChildrens(nextSelectorArrow)
        }
        return res;
    }
 
    //Построить весь хтмл ноды
    getInnerHTML(dom_node, cliFormat = false) {
        let res = '';
        let lvl = -1;
        function deep(node) {
            let leftMargin = '';
            for (let i = 0; i <= lvl; i++) {
                leftMargin += (cliFormat) ? '   ' : '';
            }
            res += leftMargin + '<' + node.tag + ">"
            res += (cliFormat) ? "\n" : "";
            res += (cliFormat && node.innerTEXT !== '') ? leftMargin + '   ' : '';
            res += node.innerTEXT;
            res += (cliFormat && node.innerTEXT !== '') ? "\n" : "";
            node.childrens.forEach((childNode) => {
                lvl++;
                deep(childNode);
                lvl--;
            });
            res += leftMargin + '';
            res += (cliFormat && lvl !== -1) ? "\n" : "";
        }
 
        deep(dom_node);
        return res;
    }
 
    //Фильтрация текущего массива дом_ноде по аттрибутам
    _filtredByAttribute(_key, _value) {
        this._tree = this._tree.filter((item) => {
            let currentAttr = item.attr.find((attr) => attr.key === _key);
            if (currentAttr) {
                return currentAttr.value.includes(_value.trim())
            }
        });
    }
    //Получить детей(первый срез или весь) текущего массива дом_ноде
    _sliceChildrens(firstChild = false) {
        let res = [];
        if (firstChild) {
            for (let i = 0; i <= this._tree.length - 1; i++) {
                res.push(...this._tree[i].childrens);
            }
        } else {
            res = this._getChildrens(this._tree)
        }
        this._tree = res;
    }
 
    //Получить всех детей дом нод
    _getChildrens(currentNodes) {
        //get all childs
        let allChilds = [...currentNodes];
        let queue = [...currentNodes];
        while(queue.length){
            let item = queue.shift();
            for(let i = 0; i <= item.childrens.length - 1; i++){
                queue.push(item.childrens[i]);
                allChilds.push(item.childrens[i]);
            }
        }
        return allChilds;
    }
 
}

Рассмотрим подробнее — Основной цикл, где мы и фильтруем «текущие элементы dom» по дереву кссселекторов.

//
Храним текущие дом_ноды в this._tree, фильтруем их, нарезаем детей, репит

filtredBySelector(selector) {
        let cssselectorParser = new cssSelectorParser();
        //Получаем дерево кссселекторов
        selector = cssselectorParser.parse(selector);
        let res;
        //проходим по дереву
        for (let i = 0; i <= selector.length - 1; i++) {
            let currentSelector = selector[i];
            let key;
            let item;
            //если текущ элем дерева - эрров - пропускаем фильтр
            let isArrowSelector = (currentSelector[0].value === '>');
            if (isArrowSelector) {
                continue;
            }
            //проходим по всем элементам текущего кссселектора
            for (var j = 0; j < currentSelector.length; j++) {
                key = currentSelector[j].key
                item = currentSelector[j].value;
                //фильтруем текущее this._tree по аттрибутам
                this._filtredByAttribute(key, item)
                }
            }
            res = this._tree;
            //если следующий элемент - эрров - срезаем только первый слой, если нет - всех детей
            let nextSelectorArrow = selector[i + 1] && selector[i + 1][0] && selector[i + 1][0].value === '>';
            this._sliceChildrens(nextSelectorArrow)
        }
        return res;
    }

//

Эти сущности и выполняют основную работу, теперь создадим входную сущность documentServer.

class documentServer {
 
    builderDOM = new BuilderDOM();
    domTreeWorker;
    startNode;
    querySelector(selector) {
        this.domTreeWorker.setCurrentTreeByNode(this.startNode);
        return this.domTreeWorker.filtredBySelector(selector);
    }
 
    build(str) {
        this.domTreeWorker = new treeWorker();
        global.treeworker = this.domTreeWorker;
        let dom = this.builderDOM.html_to_dom(str);
        global.treeworker = null;
        this.startNode = dom[0];
    }
}

Осталось реализовать фичу — квериселектор из ноды, поэтому прокинем domTreeWorker в дом_ноду через глобал

class dom_node {
    childrens = [];
    innerTEXT = '';
    tag;
    treeWorker;
 
    constructor() {
        this.treeWorker = global.treeworker;
    }
 
    innerHTML = (cliFormat = false) => {
        return this.treeWorker.getInnerHTML(this, cliFormat);
    };
    querySelector = (selector) => {
        this.treeWorker.setCurrentTreeByNode(this);
        return this.treeWorker.filtredBySelector(selector);
    }
}

Ссылка на гитхаб




Комментарии (2):

  1. maeris
    /#24970208 / +3

    А как вы тестировали парсеры?

    А если кто-то не хочет, чтобы его сайт парсили, и специально невалидный HTML опубликовал, они продолжат работать?

    На пропущенном шаге "Получить html сайта" как передавать заголовки так, чтобы не отличили от настоящего пользователя?

    Почему не подошла ни одна из десятка библиотек для парсинга через headless браузер?

    • ru51a4
      /#24970886

      >Почему не подошла ни одна из десятка библиотек для парсинга через headless браузер?
      Спортивный интерес