Skip to main content

2 posts tagged with "mongodb"

View All Tags

· 8 min read
Luc Duong

Crawl dữ liệu trên trang chuyencuadev

Vô tình đọc được bài viết trên viblo nên biết được trang này. Mình liền nảy sinh ý định xấu xa, lấy toàn bộ dữ liệu mang về nhà cất.

Dữ liệu thì cũng không có gì ngoài danh sách các công ty IT ở VN (Khoảng ~ 3400). :)

Mình cũng chẳng biết lấy về làm gì nữa. Chắc là để build một cái tương tự. =))

Chuẩn bị

Để lấy được thì cũng phải chuẩn bị một vài cái trước khi lấy chứ nhỉ.

1. Yêu cầu

Yêu cầu thì đơn giản rồi. Lấy danh sách 3491 công ty từ trang chuyencuadev

2. Kiểm tra hàng họ

Check một hồi chả biết là họ đang dùng cái quỉ gì để làm được cái web này nữa. =))

Cơ mà thấy một nút Xem thêm to bự chảng gần cuối trang. Mừng thấy mẹ. Nhấn thử xem nó có cái gì.

Buồn thối ruột vì họ dùng server render, không phải XHR request. Vậy là không vui rồi. Vì lúc này mình chẳng chôm chỉa được dữ liệu có cấu trúc dạng JSON hoặc XML gì đó.

Lúc này mình phải nghĩ đến việc parse html rồi. Nhìn quanh quẩn thì thấy trên domain có cái /page/2.

Ồ. Có thể dùng được rồi. Bắt tay vào làm thôi.

3. Lựa chọn công cụ, ngôn ngữ.

python hoặc nodejs

Mình khoái nodejs hơn nên quyết định chọn nodejs cho game này.

Phân tích

Cấu trúc

Chẳng biết cấu trúc của cái nồi này ra sao, nên phải mò một lúc. Mình thấy họ phân trang, mỗi trang có n công ty. Mà cụ thể là:

À, có cái list-companies và trong đó có nhiều items tile. Hehe Bao nhiêu item đây.

-> Có 3491 / 20 ~ 175 pages Chốt hạ có 175 pages cần khai thác.

Tức là mình sẽ có một vòng for chạy từ 0 đến 174 và parse dữ liệu.

Tạm dừng ở đây đã.

Phân tích sâu hơn.

Mình có 175 lần lặp, mỗi lần sẽ parse data để lấy dữ liệu. Vậy thì hàm parse đó sẽ làm gì?

  • Get html từ https://chuyencuadev.com/page/${i}
  • Dùng DOM Parser hoặc cái gì đó giống jQuery để có thể động đến các thẻ html một cách nhanh nhất. Để đó, search sau.
  • Xác định đối tượng list-companies
#list-companies
.tile
.tile-icon img src (logo link)
.tile-content
.tile-title [0] => Company Name & Review Link
a href (review link)
text (company name)
.tile-title [1] => Info (Location, type, size, country, working time)
icon
text (Info - Repeat 5 times)
.tile-title [2] => Reviews (count, star)
a>span text => count
>span
i*5 (i | i.none)
  • Define Company Object (Sau khi đã xác định được các thành phần của một đối tượng)
{
id: ObjectId,
name: String,
reviewLink: String,
logo: String,
location: String,
type: String,
country: String,
workingTime: String,
reviewCount: Number,
star: Number,
}

Chết cha. Toàn bộ chỉ có thế. Code thôi.

Code

Create Project

mkdir -p ~/Project/crawler/crawl-companies

Packages

yarn add mongoose connect-mongo moment lodash request request-promise cheerio

Một vài thư viện mà mình nghĩ là mình sẽ dùng. Cơ mà chắc chỉ dùng mongoose, connect-mongo, request-promise, cheerio thôi. Còn momentlodash thì mình chưa biết sẽ làm gì với nó. Cơ mà trong đầu nghĩ là sẽ dùng. =))

cheerio => jQuery basic for nodejs.

index.js

Thử code cái đã. :D

const mongoose = require('mongoose')
const request = require('request-promise')
const cheerio = require('cheerio')

const URL = 'https://chuyencuadev.com/'
const companyCount = 3491 // Cái này là mình thấy trên trang này nó ghi vậy. =))
const pageSize = 20 // Đã test. :)
const pageCount = parseInt(companyCount / pageSize)

/**
* Get content for each page
*
* @param {*} uri (Ex: ${URL}page/2)
*/
const getPageContent = (uri) => {
const options = {
uri,
headers: {
'User-Agent': 'Request-Promise'
},
transform: (body) => {
return cheerio.load(body)
}
}

return request(options)
}

getPageContent(`${URL}page/2`).then($ => {
console.log(`Tile > `, $('title').text())
})

Well done! Ta có output ưng ý.

console.log(`Tile > `, $('title').text())

hmmm... Cùng thử lấy ra list-companies như mình mô tả ở trên nào.

/**
* Parse html to companies
*
* @param {*} $
*/
const html2Companies = ($) => {
const companies = []
$('#list-companies .tile').each((_, c) => {
console.log($(c).html())
})
return companies
}

-> Tuyệt cú mèo. Mọi thứ đúng như những gì mình làm với jQuery trên client. :) Vậy thì cứ thế mà chiến tiếp thôi.

Mình sẽ tiến hành parse list of companies

/**
* Parse html to companies
*
* @param {*} $
*/
const html2Companies = ($) => {
const companies = []
$('#list-companies .tile').each((_, c) => {
companies.push(html2Company($(c)))
})
return companies
}

Còn đây là parse từng company Object

/**
* Parse html to company Object
*
* @param {*} $
*/
const html2Company = ($) => {
// logo
const logo = $.find('.tile-icon img').attr('data-original') // Lúc đầu mình tưởng là src, nhưng không, họ dùng data-original sau đó dùng jquery để chuyển sang src. Thôi thì cái nào cuũng được =))
const cName = $.find('.tile-content .tile-title:nth-child(1) a')
const name = cName.find('span').text()
const reviewLink = cName.attr('href')
$.find('.tile-content .tile-title:nth-child(2) i').replaceWith('|') // Hơi độc. Cấu trúc của họ là <i> text <i> text ... Mình phải replace thẻ `i` thành separator `|`
const details = $.find('.tile-content .tile-title:nth-child(2)')
.html().split('|').map(d => d.replace(/^\s+/, '')) // Sau đó split về mảng và replace first `\s` character
const reviews = $.find('.tile-content .tile-title:nth-child(3)')
const reviewCount = reviews.find('a>span').text() // reviewCount
const star = reviews.find('>span i:not(.none)').length // Những cái nào không `none` thì nó là số lượng star được voted.

return {
name,
reviewLink,
logo,
location: details[1],
type: details[2],
size: details[3],
country: details[4],
reviewCount,
star,
}
}

Và response theo ý muốn...

[
{
name: 'CodeLink',
reviewLink: '/codelink/reviews',
logo: 'https://cdn.itviec.com/system/production/employers/logos/3074/codelink-logo-170-151.png?1476957849',
location: 'District 3, Ho Chi Minh',
type: 'Product',
size: '11-50',
country: 'Viet Nam',
reviewCount: '10',
star: 3
},
...
]

Cũng gọi là chim ưng với kết quả này. Mình bắt tay làm tới. Quất thẳng 172 cái pages và lưu vào trong mongo-db

Define DB & Schema

Mình hơi màu mè tẹo. Tạo hẳn cái Schema cho nó vui. models/Company.js

const mongoose = require('mongoose')
const Schema = mongoose.Schema

const companySchema = new Schema({
name: String,
reviewLink: String,
logo: String,
location: String,
type: String,
country: String,
workingTime: String,
reviewCount: Number,
star: Number,
}, {
timestamps: true
})

const Company = mongoose.model('Company', companySchema)

module.exports = Company

Và đúng thủ tụ.

const crawl = async() => {
const companies = await getPageContent(`${URL}page/2`).then($ => html2Companies($))
return Company.create(companies)
}

mongoose.Promise = global.Promise
mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/crawl', {
useMongoClient: true
}, (error) => {
if (error) {
console.log('%s MongoDB connection error. Please make sure MongoDB is running.', chalk.red('✗'))
process.exit()
}

crawl().then((companies) => {
if (!companies)
return
console.log(`Created ${companies.length} companies`)
return
}).then(() => {
process.exit()
})
})

Kết quả có vẻ không đẹp cho lắm khi một số request bị timeout. :(. Mỗi lần như vậy mình lại phải chạy lại từ đầu thì không ổn. Mình quyết định viết một recursive function. Hi vọng sẽ chạy những cái page nào bị timeout trước đó.

Cái hàm đó nhìn như thế này.

const crawl = async(pages, results) => {
const chunks = await Promise.all(pages.map(uri => crawlPage(uri)))
const availableChunks = _.filter(chunks, c => typeof c === 'object')
const remainPages = _.filter(chunks, c => typeof c === 'string')
if (availableChunks.length > 0) {
results = await Promise.all(availableChunks.map(companies => Company.create(companies)))
.then((data) => data.reduce((page1, page2) => page1.concat(page2)))
}
if (remainPages && remainPages.length > 0) {
console.log(`Remain ${remainPages.length}.`)
results = results.concat(await crawl(remainPages, results))
}
return results
}

Kết quả

Chả được cái tích sự gì khi mà server bên đó hơi cùi tí tẹo. Mới chạy có mấy chục cái request liên tục mà tải không nổi. Bị request timeout, nhảy vô web của họ thì thấy bị hi sinh luôn.

Kiến thức đã dùng

  • nodejs ES6 (Node v8)
  • async/await
  • Promise

Source Code

git@github.com:lucduong/crawl-companies.git

https://github.com/lucduong/crawl-companies

Kết luận.

Thôi dẹp, lấy được đến đâu lấy. =)).

Đùa vậy thôi, mình có sửa lại chỗ này tí Company.create(companies) thành createCompanies(companies) và trong hàm createCompanies mình findOneAndUpdate để sau này stop, start lại nó ko update trùng.

Đúng ra còn nhiều cách tối ưu khác nữa nhưng mình xin phép nhường lại cho bạn đọc. Và hi vọng các bạn sẽ contribute thêm.

Có thể là mình sẽ đánh dấu lại những trang nào đã lấy dữ liệu rồi và loại trừ, lần sau chạy lại sẽ ko lấy nữa. Các bạn làm giúp mình với nhé. :D

· 6 min read
Luc Duong

Từ hàng tỉ phép so sánh đến 10 giây.

Mình mới làm một dự án nho nhỏ về xử lý dữ liệu cho khách hàng X. Dữ liệu không lớn lắm, chỉ vài trăm MB nhưng cũng có khá nhiều điều để nói.

Mình viết bài này để chia sẻ lại với anh em cách mà mình đã làm nhé.

Bài toán

  • Trong DB (MY_DOMAIN) mình có khoảng 500K domains có dạng /^[\w]+(\.com)?\.vn$/
  • Hàng ngày mình phải tải dữ liệu từ trên một số trang web nước ngoài về, Khoảng (~4 triệu domains) được lưu trong các files (~20 files khác nhau). Các domains này có dạng bất kỳ, đúng chuẩn tên miền. :)
  • Nhiệm vụ
    • Tải 20 files về (~350MB mỗi ngày)
    • Duyệt ~4 triệu domains trong 20 files đấy
    • Kiểm tra xem domain thứ i có nằm trong cái MY_DOMAIN không, nếu có thì đưa vào một cái bảng mới DB_RESULT
    • Báo kết quả ? domains, ? inserted, ? updated, ? matched

Tiếp cận bài toán

  • Đại ca khách hàng nói rằng 4 triệu đấy nhân với lại 500 ngàn tức là khoảng 2.000.000.000.000 phép so sánh.
  • OK. Em cứ tiếp nhận vậy đã. 1 giây xử lý được khoảng 1 triệu phép tính. Tức là con số trên bỏ đi 6 số 0, mình còn lại 2.000.000 giây. Chết mợ rồi. Nếu 2 triệu giây có nghĩa là: 2 triệu / 60 = 33 ngàn phút.
  • Có nghĩa là rơi vào khoảng 555 giờ hay chia cho 24 là ra khoảng hơn 23 ngày
  • LOL, 23 ngày nếu chạy trâu bò => Không ổn tí nào.

Giải quyết vấn đề

  • Suy nghĩ thêm thì sẽ thấy bảng chữ cái và số nó bắt đầu từ 0-9 & a-z, cho nên. Mình quyết định làm thế này.
    • Chia cái DB mà có 500 ngàn records của mình ra làm 36 bảng. Mỗi bảng có n dòng. Đẹp đẹp là 500.000/36=13,889 records
    • Mỗi 1 domains trong ~ 4 triệu domains, mình sẽ lấy chữ cái đầu tiên và chỉ search trong cái bảng prefix_${first_char_of_domain}
    • Ồ, tốc độ có vẻ cải thiện. 4,000,000 * 13,889 = ~55,000,000,000 => 2 ngàn tỉ xuống còn 55 tỉ => Còn khoảng 0,5 ngày ~ 12 hours.
    • 23 ngày xuống còn 12 giờ cũng đẹp phết nhưng mà ai chấp nhận cái nồi đấy.
  • Tiếp tục suy nghĩ. Xong cmnr, mỗi lần lại phải search xem trong db có cái domain ấy không thì không ổn. Vậy làm thế nào bây giờ.
  • OK. Mang 36 cái bảng ấy lên 36 cái mảng để search local cho nó nhanh. Lúc này chịu khó tốn ram 1 tí. Nhưng mà chấp nhận được.
  • Suy nghĩ, Duyệt 4,000,000 lần qua 13,889 bực lắm. Có cách nào so sánh nhanh hơn không.
  • Mình nghĩ đến một cách là. Lúc insert domains xuống database MY_DOMAIN ấy. mình quyết định lưu thêm 1 record là domainWord. VD: ltv.vn thì sẽ có 2 fields: domain, domainWord, records tương ứng là: ltv.vn, ltv
  • Lúc lấy lên DB mình sẽ có một cái HashMap như sau:
const domainMap = {
'ltv': ['.vn'],
'lucduong', ['.com.vn', '.vn'],
//... -> Max của cái hash này là 500 ngàn keys
}
  • Giờ duyệt mỗi 4,000,000 domains
    • VD: domains[i]ltv.net
    • Mình chỉ lấy ltv và search trong cái domainMap của mình.
    • insertToResultDB({domain: 'ltv', suffixes: domainMap['ltv']}) Lúc này suffixes là cái [.vn]
  • Độ phức tạp giải thuật của HashMap khi lookup key là ~O(1) cho nên tốc độ lookup key sẽ rất nhanh.
  • Tuy nhiên mỗi lần thấy trong HashMap thì lại insert thì performance sẽ giảm lắm. Vì cứ phải connect, insert, next. Mình không hài lòng nên đã chơi cái trò là
  • addDomainToBulk({domain: 'ltv', suffixes: domainMap['ltv']})
  • Kết thúc vòng lặp 4,000,000, mình quất bulkInsert cái mảng.
  • Final result: ~30 seconds thỉnh thoảng dữ liệu nhiều hơn thì rơi vào khoảng 1 phút
  • Vẫn chưa hài lòng lắm mình quyết định chia cái domainMap thành "36 cái maps"
const domainMaps = {
//...,
'l': {
'ltv': ['.vn'],
'lucduong': ['.com.vn','.vn']
},
'v': {
'viblo': ['.com.vn', '.vn']
},
//...,
}
  • Mục đích là để giảm số lần lookup key của cái map.
  • Thực sự là giảm rất nhiều. (cwl)
  • Sau đó final result của mình chỉ giao động trong khoảng 10 giây ~ 20 giây

Code mẫu

Hàm thêm dữ liệu vào bảng MY_DOMAIN

const importDomainsFromExcel = async (domains) => {
const promises = []
let tablePrefix = 'MY_DOMAIN_'
const tbMap = {}
_.forEach(domains, d => {
// EX: d = ltv.vn
let firstChar = d[0]
let tableNm = `${tablePrefix}${firstChar}`
let domainWord = d.replace(/(\.com)?\.vn/g, '') // => domainWord = 'ltv'
if (!!tbMap[tableNm]) {
tbMap[tableNm].push({
domain: d,
domainWord,
})
} else {
tbMap[tableNm] = [{
domain: d,
domainWord,
}]
}
})

Object.keys(tbMap).forEach(tableNm => {
promises.push(insertDomainsToMyDomain(tableNm, tbMap[tableNm])) // bulkInsert
})

return Promise.all(promises)
}

Hàm thêm nhiều domains vào bảng MY_DOMAIN_X

const insertDomainsToMyDomain = async (tableNm, domains) => {
// get connection assign to db
const collection = db.collection(tableNm)
const bulk = initBulkInsert()
_.forEach(domains, d => {
bulk.insert(d)
})
return bulk.execute()
}

Hàm đọc dữ liệu từ nhiều bảng MY_DOMAIN_ và assign vào HashMap

// get CHARS from constants ['0', '...', '9', 'a', ..., 'z']
const fetchDomainsToHashMap = async () => {
const domainMap = {}
let tablePrefix = 'MY_DOMAIN_'
const promises = []
_.forEach(CHARS, c => {
let tableNm = `${tablePrefix}${c}`
promises.push(fetchDomainsFromCollection(db.collection(tableNm)))
})

const domains = await Promise.all(promises).then(async (results) => {
const resDomains = []
_.forEach(results, _domains => {
resDomains.concat(_domains)
})
return resDomains
})

_.forEach(domains, d => {
let firstChar = d[0]
if (!!domainMap[firstChar]) {
if (!!domainMap[firstChar][d.domainWord]) {
domainMap[firstChar][d.domainWord].push(d.suffixes)
} else {
domainMap[firstChar][d.domainWord] = [d.suffixes]
}
} else {
domainMap[firstChar] = {
[d.domainWord]: [d.suffixes]
}
}
})

return domainMap
}

Hàm so sánh và insert vào DB

const compareAndImport = async (domains4MillionsFromFiles, domainMap) => {
const bulkPrepare = initBulkInsert()
_.forEach(domains4MillionsFromFiles, d => {
let domainWord = d.replace(/(\.com)?\.vn/g, '')
let suffixes = domainMap[d[0][domainWord]]
if (!!suffixes) {
bulkPrepare.insert({
domain: d,
suffixes: suffixes
})
}
})

return bulkPrepare.execute()
}

Ngôn ngữ sử dụng

  • NodeJs v8 -> Code web chính
  • Go Lang v1.8x -> Một vài hàm import excel, download file, ...

Kết quả

10s ~ 20s

Mẫu DB

MY_DOMAIN_L

domaindomainWord
lucduong.com.vnlucduong
ltv.vnltv

DB_RESULT

domainsuffixes
ltv[.ltv]

Kết luận

Một bài viết mang tính chất giải trí. :)