ساخت وب اسکرپر (Web Scraper) با جاوا اسکریپت – راهنمای کاربردی


اگر میخواهید دادهها را از وب گردآوری کنید، با منابع زیادی مواجه خواهید شد که مراحل ساخت یک وب اسکرپر را با استفاده از ابزارهای تثبیت شده بکاند مانند پایتون یا PHP توضیح میدهند. اما در مورد ابزار بکاند مهم نوظهور دیگری به نام Node.js راهنماهای چندانی وجود ندارند.
به لطف حضور Node.js، جاوا اسکریپت به یک زبان عالی برای اجرای وب اسکرپینگ تبدیل شده است. نه تنها Node سریع است، بلکه میتوان از بسیاری از متدهایی که در جاوا اسکریپت فرانتاند، برای کوئری زدن به DOM استفاده میشود استفاده کرد. Node.js ابزارهایی برای کوئری زدن به صفحههای وب استاتیک و دینامیک برخوردار است و به خوبی با بسیاری از API-های مفید و ماژولهای node سازگار شده است.
در این مقاله، به بررسی یک روش قدرتمند برای استفاده از جاوا اسکریپت جهت ساخت یک وب اسکرپر میپردازیم. همچنین یکی از مفاهیم کلیدی نوشتن کدهای پایدار برای واکشی دادهها یعنی «کدنویسی ناهمگام» (asynchronous code) را بررسی خواهیم کرد.
کد ناهمگام
واکشی کردن دادهها غالباً یکی از نخستین مواردی است که افراد تازهکار برای آن به کدنویسی ناهمگام نیاز پیدا میکنند. جاوا اسکریپت به صورت پیشفرض همگام است یعنی رویدادها به صورت خط به خط اجرا میشوند. هر زمان که تابعی فراخوانی شود، برنامه پیش از رفتن به خط بعدی کد، منتظر میماند تا تابع بازگشت یابد.
اما واکشی کردن دادهها به طور کلی به کدنویسی ناهمگام وابسته است. چنین کدهایی از جریان معمول رویدادهای همگام حذف میشوند و بدین ترتیب کد همگام میتواند همچنان که کد ناهمگام منتظر پایان یافتن یک عملیات مانند واکشی دادهها از یک وبسایت است، به اجرای وظایف خود ادامه دهد.
ترکیب کردن این دو نوع اجرا یعنی همگام و ناهمگام ساختار پیچیدهای دارد که ممکن است موجب سردرگمی افراد تازهکار شود. ما از کلیدواژههای async و await استفاده میکنیم که در ES7 معرفی شدهاند. این کلیدواژهها در واقع اسامی مستعاری برای ساختار Promise هستند که در ES6 معرفی شد و البته promise خود یک اسم مستعار برای سیستم قبلی callback در جاوا اسکریپت محسوب میشود.
Callback-های ارسالی
در روزگار قدیم جاوا اسکریپت از callback استفاده میکرد که در آن درون هر تابع یک تابع ناهمگام نیز قرار میگرفت و بدین ترتیب چیزی ایجاد میشد که به نام «هرم مرگ» (pyramid of doom) یا «جهنم کالبک» (callback hell) مشهور بود. مثال زیر یک نمونه ساده از آن است:
1/* Passed-in Callbacks */
2doSomething(function(result) {
3 doSomethingElse(result, function(newResult) {
4 doThirdThing(newResult, function(finalResult) {
5 console.log(finalResult);
6 }, failureCallback);
7 }, failureCallback);
8}, failureCallback);
Then ،Promise و Catch
در ES6 ساختار جدیدی معرفی شد که کار دیباگ کردن کدهای ناهمگام را بسیار سادهتر و آسانتر میساخت. این ساختار بر اساس شیئی به نام Promise و متدهای then و catch بنا شده بود:
1/* "Vanilla" Promise Syntax */
2doSomething()
3.then(result => doSomethingElse(result))
4.then(newResult => doThirdThing(newResult))
5.then(finalResult => {
6 console.log(finalResult);
7})
8.catch(failureCallback);
Async و Await
در نهایت ES7 دو کلیدواژه به نامهای Async و Await معرفی کرد که امکان کدنویسی ناهمگام را به صورتی که تا حد امکان به کد همگام جاوا اسکریپت شبیه باشد فراهم میساخت. نمونهای از آن را در مثال زیر میبینید. این توسعه اخیر به تدریج خواناترین روش برای اجرای وظایف ناهمگام در جاوا اسکریپت تلقی شد و در مقایسه با ساختار معمولی Promise حتی از نظر کارایی حافظه نیز موجب ارتقا میشود.
1/* Async/Await Syntax */
2(async () => {
3 try {
4 const result = await doSomething();
5 const newResult = await doSomethingElse(result);
6 const finalResult = await doThirdThing(newResult);
7 console.log(finalResult);
8 } catch(err) {
9 console.log(err);
10 }
11})();
متغیرهای استاتیک
در گذشته بازیابی دادهها از دامنه دیگر مستلزم استفاده از XMLHttpRequest یا شیء XHR بود. امروزه میتوانیم از API واکشی جاوا اسکریپت به این منظور استفاده کنیم. متد ()fetch یک آرگومان الزامی دارد که نشاندهنده مسیر منبعی است که میخواهیم واکشی کنیم و یک Promise بازگشت میدهد.
برای استفاده از ()fetch در Node.js باید یک پیادهسازی از fetch را ایمپورت کنید. Isomorphic Fetch یک گزینه رایج محسوب میشود. آن را با وارد کردن دستور زیر در ترمینال نصب کنید:
npm install isomorphic-fetch es6-promise
سپس آن را در ابتدای سند به صورت زیر الزام (require) کنید:
1const fetch = require('isomorphic-fetch')
JSON
اگر میخواهید دادههای JSON را واکشی کنید، باید پیش از پردازش کردن پاسخ، متد ()json را روی آن اجرا کنید:
1(async () => {
2 const response = await fetch('https://wordpress.org/wp-json');
3 const json = await response.json();
4 console.log(JSON.stringify(json));
5})()
JSON دریافت دادههای مورد نیاز و پردازش کردن آنها را به فرایندی نسبتاً سرراست تبدیل کرده است. اما اگر دادهها در قالب JSON نباشند چطور؟
HTML
در مورد اغلب وبسایتها باید دادههایی را که میخواهیم، از قالب HTML استخراج کنیم. در وبسایتهای استاتیک، دو روش به این منظور وجود دارد:
روش اول: عبارتهای منظم
اگر نیازهای شما ساده هستند و با نوشتن regex نیز مشکلی ندارید، میتوانید به سادگی از متد ()text استفاده کنید و سپس دادههایی که لازم دارید را با استفاده از متد match استخراج کنید. برای نمونه، در ادامه کدی را میبینید که برای استخراج محتوای تگ نخست h1 در یک صفحه مورد استفاده قرار میگیرد:
1(async () => {
2 const response = await fetch('https://example.com');
3 const text = await response.text();
4 console.log(text.match(/(?<=\<h1>).*(?=\<\/h1>)/));
5})()
روش دوم: تحلیلگر DOM
اگر با سندهای پیچیدهتری سروکار دارید، بهتر است از آرایهای از متدهای داخلی جاوا اسکریپت برای کوئری زدن به DOM استفاده کنید. به این منظور از متدهایی مانند getElementById و querySelector استفاده کنید.
اگر بخواهیم کد فرانتاند را بنویسیم، میتوانیم از اینترفیس DOMParser استفاده کنیم. از آنجا که ما از Node.js استفاده میکنیم، میتوانیم از ماژول node نیز به جای آن بهره بگیریم. یک گزینه رایج jsdom است که میتوان با دستور زیر آن را نصب کرد:
npm i jsdom
همچنین با دستورهای زیر آن را require میکنیم:
1const jsdom = require("jsdom");
2const { JSDOM } = jsdom;
با استفاده از jsdom میتوانیم HTML ایمپورت شده را مانند شیء DOM خود و با بهرهگیری از querySelector و متدهای مرتبط ایمپورت کنیم:
1(async () => {
2 const response = await fetch('https://example.com');
3 const text = await response.text();
4 const dom = await new JSDOM(text);
5 console.log(dom.window.document.querySelector("h1").textContent);
6})()
وبسایتهای دینامیک
اگر بخواهیم دادهها را از یک وبسایت دینامیک مانند یک شبکه اجتماعی دریافت کنیم چطور؟ محتوای چنین وبسایتهایی به صورت در لحظه تولید میشود و لذا فرایند کار کاملاً متفاوت خواهد بود. در این حالت اجرای یک درخواست fetch عملی نخواهد بود، زیرا کدِ استاتیکِ سایت را بازگشت میدهد و نه محتوای دینامیک آن را که احتمالاً مورد نظر ما است.
اگر چنین نیتی در سر دارید بهترین ماژول node برای اجرای این کار puppeteer است، چون جایگزین اصلی آن PhantomJS دیگر توسعه نمییابد.
Puppeteer امکان اجرای کروم یا کرومیوم را روی پروتکل DevTools فراهم ساخته است و قابلیتهایی مانند ناوبری خودکار صفحه و تهیه تصویری از صفحه را دارد. DevTools به صورت پیشفرض یک مرورگر headless را اجرا میکند، اما تغییر دادن این تنظیمات برای دیباگ کردن مفید خواهد بود.
شروع
برای نصب Puppeteer به دایرکتوری پروژه در ترمینال بروید و عبارت زیر را وارد کنید:
npm i puppeteer
در ادامه مقداری کد اولیه برای شروع کار را مشاهده میکنید:
1const puppeteer = require('puppeteer');
2const browser = await puppeteer.launch({
3 headless: false,
4});
5const page = await browser.newPage();
6await page.setRequestInterception(true);
7await page.goto('http://www.example.com/');
ابتدا puppeteer را راهاندازی میکنیم. حالت adless را نیز غیرفعال میکنیم به طوری که میتوانیم ببینیم چه کار میکنیم. سپس یک برگه جدید باز میکنیم. متد زیر اختیاری است و امکان استفاده از متدهای abort ،continue و respond را در ادامه میدهد. در نهایت به صفحه منتخب میرویم.
page.setRequestInterception(true)
در مثال DOM Parser فوق میتوانیم عناصر را با استفاده از document.querySelector و متدهای مرتبط کوئری کنیم.
لاگین شدن
اگر لازم باشد در یک وبسایت لاگین کنیم این کار با استفاده از متدهای type و click به سادگی انجامشدنی است. بدین ترتیب عناصر DOM با استفاده از همان ساختار querySelector شناسایی میشوند:
1await page.type('#username', 'UsernameGoesHere');
2await page.type('#password', 'PasswordGoesHere');
3await page.click('button');
4await page.waitForNavigation();
مدیریت اسکرول بینهایت
وبسایتهای دینامیک عموماً از طریق مکانیسم اسکرول بینهایت محتوا را به نمایش میگذارند. برای فائق آمدن بر این مشکل، باید کاری کنیم که puppeteer بر مبنای نوعی معیار اقدام به اسکرول کردن بکند.
در ادامه مثال سادهای را میبینید که 5 بار اسکرول میکند و به مدت 1 ثانیه بین هر اسکرول صبر میکند تا محتوا بارگذاری شود.
1for (let j = 0; j < 5; j++) {
2 await page.evaluate('window.scrollTo(0, document.body.scrollHeight)');
3 await page.waitFor(1000);
4}
از آنجا که زمانهای بارگذاری متفاوت هستند، کد فوق لزوماً هر بار تعداد نتیجه یکسانی را بارگذاری نمیکند. اگر با چنین مشکلی مواجه شدید، میتوانید تا زمانی که تعداد مشخصی از عناصر پیدا نشدهاند، اسکرول کنید و یا معیار دیگری را انتخاب کنید.
بهینهسازی
در نهایت چند روش وجود دارند که میتوانند موجب بهینهسازی کدهای ما شوند و از این رو کد تا حد امکان سریع و روان اجرا شود. به عنوان مثال در ادامه روشی را ملاحظه میکنید که موجب میشود puppeteer از بارگذاری فونتها و تصاویر منع شود.
1await page.setRequestInterception(true);
2page.on('request', (req) => {
3 if (req.resourceType() == 'font' || req.resourceType() == 'image'){
4 req.abort();
5 }
6 else {
7 req.continue();
8 }
9});
همچنین میتوان CSS را به روش مشابهی غیرفعال کرد، اما در پارهای موارد CSS در دادههای دینامیکی که میخواهید بارگذاری کنید، یکپارچهسازی شده است و از این رو باید در این زمینه هوشیار باشید.
سخن پایانی
مطالبی که در این نوشته مطرح کردیم تقریباً همه آن چیزی بود که برای ساخت یک وب اسکرپر کارآمد لازم بود. زمانی که دادهها را در حافظه گردآوری کردید، میتوانید آنها را با استفاده از ماژول fs در یک سند محلی ذخیره کنید، روی پایگاه داده آپلود کنید یا با استفاده از یک API مانند گوگل شیتز مستقیماً به یک سند ارسال کنید. اگر در زمینه وب اسکرپینگ تازهکار هستید یا در این زمینه معلوماتی دارید اما در زمینه Node.js مبتدی هستید، این مقاله احتمالاً برای شما مفید بوده است و شما را با برخی از ابزارهای قدرتمند Node.js که امکان وب اسکرپینگ را مهیا میسازند آشنا ساخته است.
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای JavaScript (جاوا اسکریپت)
- مجموعه آموزشهای برنامهنویسی
- مجموعه آموزشهای دادهکاوی و یادگیری ماشین
- وب اسکرپینگ (Web Scraping) چیست؟ — به زبان ساده
- وب اسکرپینگ (Web Scraping) با پایتون و کتابخانه Beautiful Soup — راهنمای جامع
- ساخت خزنده وب (Web Crawler) با فریمورک Scrapy — از صفر تا صد
==