פוסט אורח - בניית זחלן רשת Crawling Engine

07/02/2024

הכותב הוא Oragbakosi Valentine, בשלוש השנים האחרונות הוא עובד כמפתח תוכנה בגיטסטארט ולפני כן עבד בGoSquare.

גיטסטארט היא פלטפורמת Code as a Service שהופכת את מה יש לכם בבקלוג (backlog) לקוד באיכות גבוהה ובו זמנית מטפחת קהילה הולכת וגדלה של מפתחים ברחבי העולם. בתחילת השנה השיקה גיטסטארט את פעילותה בישראל והחלה לתמוך בצוותי פיתוח מקומיים.

1. תיאור הפרויקט

אחד הלקוחות שלנו, חברה קטנה שנותנת שירותי brand awareness ללקוחות ברחבי העולם ביקשה מאיתנו לפתח זחלן רשת (engine crawler) ו-פלטפורמת העתקת דפי רשת (web cloning platform). הפרויקט הזה מצא חן בעיני במיוחד בזכות זה שהוא נתן לנו הזדמנות ללמוד לעומק כיצד זחלני רשת פועלים ואיך דפדפנים מתמודדים עם רינדור ופירסור של HTML.

הפרויקט כלל:

  1. פיתוח של שירות תמיכה בהעתקת דפי אינטרנט, ואחסון של כל הנכסים (assets) בבקט של S3.
  2. פיתוח של שירות היכול לזחול באתרי מסחר אלקטרוניים ולבחור פריטי מידע מסויימים מדפי מוצרים, לדוגמא: שם מוצר, מחיר, תמונות, תיאור וכו'. (השירות צריך לתמוך בכל סוגי אתרי המסחר האלקטרוני).

הקוד כולו נכתב ב Go. בואו נמשיך ונראה את החלקים המרכזיים והמעניינים בפרויקט.

2. שירות העתקה

כדי להעתיק אתר באופן מיידי, בחרנו בדרך הפעולה הבאה:

  1. להוריד את קובץ ה- HTML של העמוד
  2. לשמור את ה-HTML בזיכרון מטמון ברדיס
  3. לפרסר את ה-HTML לעץ ה-DOM שניתן להפנות אליו בקלות
  4. לעבור על עץ ה-DOM ולשלוף ממנו assets כמו קישורים, תמונות, קבצי JavaScript, קבצי CSS וכו'.
  5. להעביר את הנכסים האלה לזיכרון מטמון ולהעלות לבקט S3 (במידה והאתר ירד, באופן זה יהיה לנו עותק של כל הנכסים).
  6. לשמור את תכונת ה-Etag של האובייקט כך שאם נתקל בנכס דומה, לא יהיה עלינו להעלות מחדש קבצים מיותרים.
  7. להחליף קישורי נכסים עם ה-URL ב-S3 של עץ ה-DOM.
  8. לשמור את עץ ה-DOM בחזרה כמחרוזת HTML

נוכל להשתמש בגישה המסורתית ולהשתמש בקליינט הדיפולטיבי של golang כדי להוריד את עמוד האינטרנט כפי שמוצג למטה:

res, err := http.Get(link)
    if err != nil {
        log.Fatal(err)
    }
    content, err := ioutil.ReadAll(res.Body)
    res.Body.Close()
    if err != nil {
        log.Fatal(err)
    }
    html := string(content)

אבל אם נעשה את זה נתקל במגבלה, הקליינט לא יחכה לטעינת כל הפריטים והנכסים בעמוד, וזה יכול להוות בעיה בממשק עם single-page applications (SPA) לכן החלטנו להשתמש ב- Headless Browser.

בהורדת עמוד עם "http.Get", קוד ה- JavaScript המוגדר בדף אינו מופעל, ולכן לפעמים ניתן לקבל HTML ריק (כי ב-SPA, יש צורך ב- JavaScript כדי לרנדר את התוכן של הדף). חבילת Rod היא חבילה של golang שדומה ל- selenium ומפעילה דפדפן במצב headless. בשימוש ב ROD פונקציית ההורדה נראית כך:

func RodDownload(url_path string) string{
page := rod.New().MustConnect().MustPage(url_path)
        // set user agent header
        userAgentHeaderOverride := proto.NetworkSetUserAgentOverride{
            UserAgent: GetEnv("userAgent"),
        }
        page.SetUserAgent(&userAgentHeaderOverride)
        webPage := page.MustWaitLoad().MustHTML()

        // expire after 5 hours
        _, err = redis_conn.Do("SETEX", url_path, 18000, webPage)
        if err != nil {
            log.Fatal("error getting web page", err)
        }
        return webPage
]

חייבים לשמור את הדף לרדיס אם הדף אינו קיים שם כבר. אנו נשתמש ב-URL כמפתח ברדיס:

func downloadWebPage(url_path string) string {
    var redis_url string
        redis_url = GetEnv("REDIS_URL")
    redis_conn, err := redis.Dial("tcp", redis_url)
    if err != nil {
        // don't proceed if conn to redis fails
        log.Fatal("error connecting to redis server", err)
    }

    defer redis_conn.Close()

    webPage, err := redis.String(redis_conn.Do("HGET", url_path, url_path))
    if err != nil {
        // if redis key is abscent, create one with new content
        webPage= RodDownload(url_path)

    }
    return webPage
}

כעת יש לנו דף אינטרנט וניתן לפרסר אותו ולהתחיל לשחק איתו. כדי לפרסר את ה-HTML לעץ DOM, בחרנו ב- goquery. העבודה עם מחרוזות Raw HTML כאן היא די מאתגרת ואנחנו צריכים להמיר אותן לאובייקט שניתן לעבוד איתו. במבט לאחור, ייתכן שהיה עלינו להשתמש ב-Rod כדי להעביר את רכיבי ה-DOM אך בזמן האמת, היינו זקוקים לפרסר HTML קל משקל וללא תלות ו – goquery היה נראה כמו פתרון טוב. זו פונקציית הפיענוח:

func ParseHTML(web_data string)*goquery.Document {
// load html string to go query html parser
    doc, err := goquery.NewDocumentFromReader(strings.NewReader(web_data))
    if err != nil {
        log.Fatal(err)
    }
    return doc
}

לפני שנמשיך עם מניפולציות ה-DOM, בואו ונסתכל על פונקציית השמירה. חשוב לזכור שעלינו לשמור את הנכסים הקיימים באמצעות ה- ETAG על מנת להמנע מלמלא את הבקט שלנו בקבצים מאותו הסוג. תחילה נבנה קליינט HTTP שיסייע בהורדת הנכסים:

//go http client constructor
func goclient(url_path string) (*http.Response, error) {
    // set client with proxy and time out of 10 secs for perf increase
    var client *http.Client
    if GetEnv("HTTP_PROXY") != "" {
        proxyUrl, _ := url.Parse(GetEnv("HTTP_PROXY"))
        client = &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{Proxy: http.ProxyURL(proxyUrl)}}

    } else {
        client = &http.Client{Timeout: 10 * time.Second}
    }

    req, err := http.NewRequest("GET", url_path, nil)
    if err != nil {
        return nil, errRequest
    }
    req.Header.Add("Accept", GetEnv("accept"))
    req.Header.Add("User-Agent", GetEnv("userAgent"))
    // get response from client req
    resp, err := client.Do(req)
    if err != nil {
        log.Printf("error getting resource %s \\n", err)
    }
    return resp, nil
}

כעת אנו יכולים להוריד את הנכס.

// get file buffer asset from url
func getAssetContent(url_path string) (string, []byte, error) {
    reader, err := goclient(url_path)
    if err != nil {
        return "", []byte{}, errAssetContent
    }

    if reader != nil {
        buffer, err := ioutil.ReadAll(reader.Body)
        if err != nil {
            fmt.Println("error reading reader as buffer ", err)
        }
        // get hash of content
        hash := fmt.Sprintf("%x", md5.Sum(buffer))
        return hash, buffer, nil
    }

    return "", nil, nil
}

ונכתוב פונקציית שמירה פשוטה המבוססת על פונקציית בדיקת S3

//cache already uploaded asset
func cachelink(link string) (bool, string, string, error) {
    etag, err := goclient(link)
    if err != nil {
        return false, "", "", err
    }

    path, _ := url.Parse(link)
    // get file extension from file url
    filetype := filepath.Ext(path.Path)
    if etag != nil {
        // cache files with their etag values
        etag_val := etag.Header.Get("Etag")
        var fileExist bool
        if etag_val != "" {
            // check if file exist in cache
            fileExist = S3KeyCache.Check(etag_val)
        } else {
            // use sha value if file etag value does not exist
            hash, _, err := getAssetContent(link)
            if err != nil {
                return false, "", "", errCacheLink
            }

            if hash != "" {
                etag_val = hash
                fileExist = S3KeyCache.Check(hash)
            }
        }
        // construct new key with file extension
        return fileExist, etag_val, filetype, nil
    }

    return false, "", "", nil
}

וסוף סוף, יש לנו את פונקציית ההעלאה. אנו נשתמש ב-sync.mutex של golang כדי לנצל את התמיכה של השפה במקביליות:

// fetchAndUploadContent fetches assets and uploads to S3
func fetchAndUploadContent(waitGroup *sync.WaitGroup, mutex *sync.Mutex, info fetchUploadJobInfo) {
    defer waitGroup.Done()

    _, buffer, err := getAssetContent(*info.url)
    if err != nil {
        return
    }

    if buffer != nil {
        newKey := UploadToS3(*info.filename, buffer, *info.filetype)

        mutex.Lock()
        defer mutex.Unlock()

        // add etag to cache
        S3KeyCache.Add(*info.filename)
        info.node.SetAttr(*info.attr, newKey)
    }
}

//add upload to a go routine
func schedule_upload(path string, node *goquery.Selection, attr string, waitGroup *sync.WaitGroup, mutex *sync.Mutex) {
    fileExist, etag, fileType, err := cachelink(path)
    if err != nil {
        return
    }

    // if file exist in cache, replace with link else upload and replace with link
    if etag != "" {
        if fileExist {
            signed_key := Sign_url(etag)
            node.SetAttr(attr, signed_key)
        } else {

            waitGroup.Add(1)

            go fetchAndUploadContent(waitGroup, mutex, fetchUploadJobInfo{
                url:      &path,
                filename: &etag,
                filetype: &fileType,
                attr:     &attr,
                node:     node,
            })
        }
    }
}

עכשיו כשסיימנו את כל זה, ניתן לעבור על עץ ה-DOM ולהחליף את הקישורים הנדרשים עם מה שאנו צריכים.


func replace_document(document *goquery.Document, attr string, tag string, domain string) *goquery.Document {
    var mutex sync.Mutex
    var waitGroup sync.WaitGroup

    // find all instances of tags and retrieve attributes
    document.Find(tag).Each(func(_ int, node *goquery.Selection) {
        href, ok := node.Attr(attr)
        if ok {
            // clear all empty space from file url
            href = strings.ReplaceAll(href, " ", "")
            // resolve files that start with "//" or http
            if strings.HasPrefix(href, "http") || strings.HasPrefix(href, "//") {
                absolute_href := href
                if strings.HasPrefix(href, "//") {
                    absolute_href = fmt.Sprintf("https:%s", href)
                }
                // schedule the upload ia a go routine
                schedule_upload(absolute_href, node, attr, &waitGroup, &mutex)
            } else {
                new_path := fmt.Sprintf("%s%s", domain, href)
                // schedule the upload ia a go routine
                schedule_upload(new_path, node, attr, &waitGroup, &mutex)

            }
        }
    })

    waitGroup.Wait()

    return document
}

אחרי שחיברנו את כל זה יחד, יש לנו כעת CacheWebpageService שלם:

func CacheWebpageService(web_data string, domain string) *goquery.Document {
    // load html string to go query html parser
    doc, err := ParseHTML(web_data)
    if err != nil {
        log.Fatal(err)
    }
    // compute and replace relevant assets
    css_doc := replace_document(doc, "href", "link", domain)
    js_doc := replace_document(css_doc, "src", "script", domain)
    img_doc := replace_document(js_doc, "src", "img", domain)

    return img_doc
}

שימו לב לאיך שהשתמשנו בפונקציית- replace_document כדי להחליף את כל נכסי ה- CSS ולאחר מכן את נכסי ה- JS ולבסוף כמובן את נכסי ה- Img ובאופן זה, אנחנו יכולים עכשיו לשכפל כל דף אינטרנט באופן מיידי.

3. שירות זחלן

בניית שירות הזחלן הייתה אתגר לא קטן מכיוון שלא מדובר בזחלן רגיל. הזחלן שלנו צריך להצליח לבחור מידע ספציפי בכל עמוד אינטרנט ומכיוון שדפי אינטרנט בנויים אחרת אחד מהשני היה צורך בפתרון ייחודי. דוגמה טובה המסבירה את האתגר היא ההבדל בין תיאור מוצר של אמזון שיכול להיות מקונן במבנה עץ ה-DOM הבא .item -> .product -> .descr בעוד זה שבאיביי יכול להיות בתוך div -> .value -> section -> div.

ניסינו מספר רב של פתרונות כדי שהשירות יעבוד בכל פלטפורמת מסחר אלקטרוני אך לבסוף החלטנו ללכת עם גישה הכוללת קובץ קונפיגורציה המכיל את נתיב עץ ה-DOM המוגדר מראש לפריט היעד שלנו.

כל אתר צריך להיות מוגדר מראש לפי שני דברים: התכונות שנרצה לחלץ ממנו (לדוגמה, תמונות, מחיר וכו') ומה נתיב עץ ה-DOM שיוביל אל התכונות האלה. על מנת לעשות את זה, עלינו להכנס באופן ידני בדפים לדוגמה באתרים אלה. יש גם מקרים בהם אותו אתר יכול להכיל 2 דפי מוצרים עם נתיבי DOM שונים, ולכן attrdetails מקבלת מערך של נתיבים כדי להתמודד עם מקרה כזה, לדוגמה:

{
  "rooturl":"The url the config is mapped to eg amazon.com",
  //this allows to add multiple config at once
  "attrdetails":[
    {
      "attrselector":"The class selector value (eg img-class -> div -> .img)",
      "attrname":"The attr name (eg image_selector)"
    },
     {
      "attrselector":"The class selector value (eg title-class -> .item-title)",
      "attrname":"The attr name (eg title_selector)"
    }
  ]
}

באופן הזה, המנוע צריך רק לקרוא את הקונפיג ולהשתמש בסלקטור שנבדק מראש כדי לקבל פריטים מעץ ה-DOM. עכשיו בואו ונסתכל על הקוד עצמו:

  1. הגדר את Rod כך שיגיע לנתוני דף וכתובת URL

  2. בקר שוב ושוב בכל קישור בדפי האינטרנט שמצאתם

  3. עבור דרך עץ ה-DOM בדף.

  4. בדוק אם יש לדף את התכונות של הקונפיג.

  5. שמור ערכים מהדף למאגר האחסון (או לכל אחסון לבחירתכם).

  6. שמור את קישור הדף במפה שנבדקה כדי למנוע ביקורים כפולים באותו דף.

תחילה בואו ונגדיר את ה-ROD . שימו לב שאנו לא לוקחים בחשבון דברים כמו VPN Config או שינויים ב-UserAgent, וכו ', כדי לשמור על ההנחיות פה פשוטות ככל הניתן:


//open browser instance with config
func setupPage(page string) *rod.Page {
    userAgentConfig := proto.NetworkSetUserAgentOverride{
        UserAgent: config.GetEnv("userAgent"),
    }
    browser = rod.New().MustConnect()
    return browser.MustPage(strings.TrimRight(page, "%")).MustSetUserAgent(&userAgentConfig).Timeout(1 * time.Minute)
}

עכשיו, בואו נכין את הפונקציה שתתשאל את ה- Config Selector:


func queryWithSelector(page *rod.Page, base *url.URL, crawlConfig crawlConfiguration, selectorConfig, otherSelector []db.ProductSelector) {
    allLinks := page.MustElements(linksSelector)
    fmt.Println("Getiing with product selector.....")
    doc, err := goquery.NewDocumentFromReader(strings.NewReader(page.MustHTML()))
    if err != nil {
        log.Print(err)
    }

    for _, productSelector := range selectorConfig {
        // get all nodes that contain the parsed selector
        classSelector := page.MustElements(*productSelector.AttrSelector)
        // transverse the slice that has all the nodes
        for _, link := range classSelector {
            // extract href attr from parent node
            href := link.MustAttribute(`href`)

            // if no href is found in parent node
            // transverse child node for href attr
            if href == nil {
                children, err := link.Elements(childrenSelector)
                if err != nil {
                    fmt.Println("error parsing children ", err)
                    continue
                }

                for _, child := range children {
                    childHref, _ := child.Attribute(`href`)
                    // if href found, add to DB
                    if childHref != nil {
                        saveOtherDetails(doc, base.String(), base, otherSelector)

                    }
                }

            } else {
                // if href found in parent node
                // add to DB
                saveOtherDetails(doc, base.String(), base, otherSelector)

            }
        }
    }
    resolvedURLString := ""
    var resolvedURL *url.URL
    ....
}

func saveOtherDetails(){
//save attributes to disk
}

//extract image from DOM attribute
func get_image_value(otherSelector []db.ProductSelector, page *goquery.Document, base *url.URL) string {
    selector := filterSlice("image", otherSelector)
    srcValue := "no_image"
    for _, img_item := range selector {
        page.Find(*img_item.AttrSelector).Each(func(_ int, node *goquery.Selection) {
            src, ok := node.Attr("src")
            if ok {
                srcValueUrl, _ := url.Parse(src)
                if srcValueUrl.IsAbs() {
                    srcValue = src
                } else {
                    resolvedsrcURL := base.ResolveReference(srcValueUrl)
                    srcValue = resolvedsrcURL.String()
                }
            } else {
                node.Find("img").Each(func(_ int, node *goquery.Selection) {
                    src, ok := node.Attr("src")
                    if ok {
                        srcValueUrl, _ := url.Parse(src)
                        if srcValueUrl.IsAbs() {
                            srcValue = src
                        } else {
                            resolvedsrcURL := base.ResolveReference(srcValueUrl)
                            srcValue = resolvedsrcURL.String()
                        }
                    }
                })

            }
        })
    }
    return srcValue

}

//get text from other DOM elements
func get_attr_values(otherSelector []db.ProductSelector, page *goquery.Document, prefix string) string {
    texttrim := ""
    selector := filterSlice(prefix, otherSelector)

    for _, item := range selector {

        page.Find(*item.AttrSelector).Each(func(_ int, node *goquery.Selection) {
            text := node.Text()
            textTrim := strings.TrimSpace(text)
            if textTrim != "" {
                texttrim = textTrim
            }
        })
    }
    return texttrim
}

לסיכום הפונקציה queryWithSelector עוברת ראשית על עץ ה- DOM של הדף הראשי ובודקת אם התכונות קונפיג קיימות בעמוד. אם כן, ניתן להוסיף אותן בקלות לאחסון שלנו. אם צריך היא גם עוברת גם על כל צמתי הצאצאים, שתשמור את כל הערכים האחרים שנמצאים בצמתי הצאצאים. עכשיו, בואו נחבר את זה לחלקים האחרים של היישום. ראשית, בואו ניצור פונקציה שתבקר בדף ותקבל את הנתונים מכל הדפים.

//get page details and pass it down where the products are extracted
func getPageData(base *url.URL, crawlConfig crawlConfiguration, selectorConfig, otherSelector []db.ProductSelector) {
    page := setupPage(base.String())
    defer page.Close()
    page.MustWaitLoad()
    queryWithSelector(page, base, crawlConfig, selectorConfig, otherSelector)
}

ועכשיו, אנו משיגים את נתוני הדף אך נשתמש במקביליות כדי להאיץ את התהליך.


func getData(crawlConfig crawlConfiguration, selectorConfig, otherSelector []db.ProductSelector) {

    if err := sem.Acquire(semCtx, 1); err != nil {
        logrus.Warnf("failed to acquire semaphore %v\\n", err)
    }

    crawlConfig.website = strings.TrimSpace(crawlConfig.website)
    // visited?
    _, ok := visited.Load(crawlConfig.website)

    if !ok && crawlConfig.depth >= 0 {
        // mark visited
        visited.Store(crawlConfig.website, none)

        base, err := url.Parse(crawlConfig.website)

        if err != nil {
            logrus.Warnf("unable to parse website: %s\\n%v", crawlConfig.website, err)
            return
        }
        //read txt file if supplied
        if len(os.Args) == 5 {
            file, err := os.Open(os.Args[1])

            if err != nil {
                log.Println(err)
            }
            defer file.Close()

            scanner := bufio.NewScanner(file)

            for scanner.Scan() {
                tryError := rod.Try(func() {
                    line := strings.TrimSpace(scanner.Text())
                    base, err = url.Parse(line)
                    if err == nil {
                        getPageData(base, crawlConfig, selectorConfig, otherSelector)
                    }
                })

                if errors.Is(tryError, context.DeadlineExceeded) {
                    // code for timeout error
                    logrus.Errorf("TIMEOUT ON %s", scanner.Text(), tryError)

                } else if tryError != nil {
                    // code for other types of error
                    logrus.Errorf("ERROR ON %v %v", scanner.Text(), tryError)
                }
            }
            return
        }

        //recursively visit sites if no txt supplied
        tryError := rod.Try(func() {
            getPageData(base, crawlConfig, selectorConfig, otherSelector)
        })

        if errors.Is(tryError, context.DeadlineExceeded) {
            // code for timeout error
            logrus.Errorf("TIMEOUT ON %s", crawlConfig.website, tryError)

        } else if tryError != nil {
            // code for other types of error
            logrus.Errorf("ERROR ON %v %v", crawlConfig.website, tryError)
        }

    }

    sem.Release(1)
    waitgroup.Done()
}

func GetURL(website string, depth, worker int, selectorConfig, otherSelector []db.ProductSelector) {
    // number of workers
    numWorkers := worker

    waitgroup = &sync.WaitGroup{}


    semCtx = context.TODO()
    sem = semaphore.NewWeighted(int64(numWorkers))

    config := crawlConfiguration{website: website, depth: depth}

    // get root urls
    waitgroup.Add(1)
    go getData(config, selectorConfig, otherSelector)
    waitgroup.Wait()

    type kvPair struct {
        key   string
        depth int
    }

    keys := []kvPair{}

    allSiteLink.Range(func(k, v interface{}) bool {
        if _, ok := visited.Load(k); !ok {
            keys = append(keys, kvPair{k.(string), v.(int)})
        }
        return true
    })

    for len(keys) > 0 {
        size := minInt(len(keys), numWorkers)

        for i := 0; i < size; i++ {
            depth := keys[i].depth
            fmt.Println("DEPTH::::::::", depth)

            allSiteLink.Delete(keys[i].key)

            newConfig := crawlConfiguration{website: keys[i].key, depth: keys[i].depth - 1}
            waitgroup.Add(1)
            go getData(newConfig, selectorConfig, otherSelector)
        }

        waitgroup.Wait()

        keys = keys[size:]
        fmt.Println("cleaning up")
        browser.Close()

        allSiteLink.Range(func(k, v interface{}) bool {
            if _, ok := visited.Load(k); !ok {
                keys = append(keys, kvPair{key: k.(string), depth: v.(int) - 1})
                allSiteLink.Delete(k)
            }
            return true
        })
    }

    fmt.Println("---", len(keys))
}

func minInt(a, b int) int {
    if a >= b {
        return b
    }
    return a
}

עם כל אלה, אנו יכולים לנסות כל אתר ולשלוף מאפיינים שאנו זקוקים אליהם ללא קשר לאופן בו הם מובנים.

אני מקווה שמצאתם את התוכן הזה שימושי. אם יש לכם שאלות או תגובות, תכתבו לי כאן, המאמר תורגם על ידי דני רווה בסיועו האדיב של עידו ברגר, אז אשמח אם תשאירו לי תגובות \ שאלות באנגלית.

אם אתם מעוניינים במה שאנחנו עושים בגיטסטארט או אפילו להעזר בצוותים שלנו, צרו איתנו קשר דרך האתר (https://gitstart.com) או או כתבו ישירות לדני במייל Danny.raveh@gitstart.com.