وب اسکرپینگ (Web Scraping) با JavaScript و Node.js

جاوا اسکریپت با پیشرفت‌هایی که داشته و همچنین معرفی Node.js، حالا به یکی از محبوب‌ترین و پر استفاده‌ترین زبان‌های برنامه‌نویسی دنیا تبدیل شده. فرقی نمی‌کنه قصد توسعه نرم‌افزار وب داشته باشید و یا نرم افزار موبایل و دسکتاپ، جاوااسکریپت برای هر کاری کلی ابزار قدرتمند در اختیارتان قرار میده. حالا قصد دارم تو این مقاله بهتون نشون بدم که چطوری با استفاده از Node.js می‌تونید وب اسکرپینگ کنید.



وب اسکرپینگ (Web Scraping) چیست؟

قبل از اینکه بریم سراغ اصل مطلب، بهتره که خیلی خلاصه وب اسکرپینگ رو بهتون توضیح بدم:

به فرآیند جمع‌آوری خودکار دیتاهای ساختار یافته وب، وب اسکرپینگ و یا استخراج داده‌های وب میگیم.

و کاربردهای زیادی از جمله نظارت برروی قیمت‌ها و اخبار، جذب مخاطب، تحقیقات بازاریابی و... داره.


روش‌های مختلفی برای اسکرپ کردن وجود داره که هر کدوم مزایا و معایت خاص خودشون رو دارن و من قصد دارم تو این مقاله اسکرپ کردن به روش‌های زیر رو توضیح بدم:

  1. JSON API
  2. Server-Side Rendered HTML
  3. JavaScript rendered HTML

و برای هر یک از این ۳ مورد،‌ من از وب‌سایت‌های

(به ترتیب) استفاده می‌کنم. برای اینکه متوجه بشیم چه چیزی رو باید اسکرپ کنیم، چطوری با استفاده از network request ها API های مربوطه رو پیدا کنیم و چگونه با اسکریپت‌های Node.js، این فرآیند رو خودکار کنیم، لازمه که اول موارد رندر شده در صفحه رو بررسی کنیم.



روش شماره ۱: JSON API

یکی از رایج‌ترین روش‌های استفاده شده در وب برای لود کردن دیتا،‌ استاده از درخواست‌‌های asynchronous از طرف جاوااسکریپت به API Server و دریافت داده‌ها با فرمت JSON است که بعد از رندر شدن، به کاربر نمایش داده می‌شن. در این روش، ما با استفاده از اسکریپت نوشته شده در Node.js، درخواست‌های API رو رهگیری می‌کنیم و سپس شروع به کار روی داده‌های JSON می‌کنیم. برای یادگیری این روش از وب‌سایت nba.com/stats استفاده می‌کنیم.


پیدا کردن درخواست‌های API

قراره اسکریپتی بنویسیم که اطلاعات مربوط به هر سال LeBron James (بازیکن بسکتبال) رو ذخیره کنه.


مرحله ۱: مطلع شدن از داینامیک بودن داده‌ها

از اونجایی که فقط میخوایم اطلاعات مروبط به LeBron James رو بگیریم، پس به آدرس nba.com/stats/player/2544 که صفحه این بازیکن میشه میریم.

اسکرین شات از صفحه LeBron James
اسکرین شات از صفحه LeBron James


همونطور که می‌بینید، تمام داده‌های مورد نظرمون تو این جدول وجود داره. اما قبل از هرکاری باید برسی کنیم و ببینیم که آیا دیتاها به‌صورت استاتیک در جدول HTML قرار داده شدن و یا به‌صورت داینامیک و از طریق JSON API لود شدن. برای انجام این کار، روی صفحه راست کلیک کنید و گزینه View Page Source رو بزنید و یا از میانبرهای Ctrl+U استفاده کنید.

حالا تو صفحه سورس Ctrl+F بزنید و برای مثال عبارت "2020-21" رو سرچ کنید. اگه مثل تصویر پایین عبارتی که سرچ کردید تو سورس نبود، مطلع میشیم که دیتا به‌صورت داینامیک و از طریق JSON API لود شده.

اسکرین شات از صفحه سورس LeBron James
اسکرین شات از صفحه سورس LeBron James


مرحله ۲: پیدا کردن API

حالا که از داینامیک بودن دیتا‌ها مطمئن شدیم، وقشته بفهمیم که دیتا‌ها از کجا میان. برای این کار، روی جدول راست کلیک کنید و گزینه inspect رو بزنید و یا از میانبر Ctrl+Shift+I استفاده کنید.

سورس مربوط به جدول HTML
سورس مربوط به جدول HTML


خوشبختانه در تگ <nba-stat-table> یکسری سرنخ وجود داره که کمی تو این کار کمکمون می‌کنه.

<nba-stat-table
ng-if=&quot!datasets.Base.isLoading && !datasets.Base.noData&quot
rows=&quotdatasets.Base.sets[1].rows&quot
params=&quotparams&quot
filters=&quotfilters&quot
ai=&quotai&quot
template=&quotplayer/player-traditional&quot >


همونطوری که مشاهده می‌کنید، جدول به datasets.Base و datasets.Base.sets[1].rows اشاره می‌کنه. حالا باید به دنبال network call هایی بگردیم که با درخواست‌های API تطابق دارن. برای انجام این کار، به تب Network می‌ریم و صفحه رو یکبار رفرش می‌کنیم تا تمام درخواست‌ها ثبت بشن.

بعد از اینکه به تب Network رفتید و صفحه رو رفرش کردید، چیزایی که در تصویر زیر مشاهده می‌کنید رو شماهم باید در تب Network مرورگرتون مشاهده کنید. (بسته به مرورگتون ممکنه developer tools شما با مال من کمی متفاوت باشه که چیز مهمی نیست)

تب Network مرورگر فایرفاکس
تب Network مرورگر فایرفاکس


اینا تمام درخواست‌هایی هستند که مرورگر برای رندر کردن صفحه درست می‌کنه. حالا باید دنبال درخواست API مدنظرمون بگردیم. برای اینکار، روی آیکون XHR کلیک کنید و در کادر مربوط به سرچ، عبارت "Base" رو تایپ کنید.

بعد از اعمال فیلتر،‌ از بین درخواست‌ها دنبال درخواستی بگردید که مانند تصویر زیر، در Response آن یکسری اطلاعات JSON برامون برگردونده باشه:

playerdashboardyearoveryearریسپانس مربوط به
playerdashboardyearoveryearریسپانس مربوط به


حالا با استفاده از سرنخ‌هایی که قبلا از جدولمون پیدا کردیم، می‌دونیم که برای پیدا کردن داده‌‌های جدول باید دنبال دیتاستی به اسم Base بگردیم. با کمی دقت به داده‌های Response متوجه می‌شیم که دومین آیتم موجود در resultSets با داده‌های جدول مطابقت داره و همین بهمون نشون میده که این همون درخواست API ای هست که دنبالشیم.


دانلود داده‌های Response با استفاده از cURL

حالا که می‌دوینم چطوری می‌تونیم داده‌هایی که برامون از اهمیت برخورد دارن رو به‌صورت دستی پیدا کنیم، وقشته که با استفاده از اسکریپت این کار رو خودکار انجام بدیم. در این مرحله قراره از ابزارهای curl و jq در ترمینال استفاده کنیم.

برای این کار نیاز که jq روی سیستمون نصب باشه و اگه نصب نیست برید نصبش کنید.


خب، حالا باز به تب Network برید و روی playerdashboardyearoveryear راست کلیک کنید و از منوی Copy گزینه Copy as cURL رو بزنید. الان تو کلیپ‌بورد سیستممون یه چیزی شبیه به دستور زیر رو داریم:

curl 'https://stats.nba.com/stats/playerdashboardbyyearoveryear?DateFrom=&DateTo=&GameSegment=&LastNGames=0&LeagueID=00&Location=&MeasureType=Base&Month=0&OpponentTeamID=0&Outcome=&PORound=0&PaceAdjust=N&PerMode=PerGame&Period=0&PlayerID=2544&PlusMinus=N&Rank=N&Season=2021-22&SeasonSegment=&SeasonType=Regular+Season&ShotClockRange=&Split=yoy&VsConference=&VsDivision=' \
-H 'Connection: keep-alive' \
-H 'sec-ch-ua: &quot Not A;Brand&quotv=&quot99&quot, &quotChromium&quotv=&quot98&quot, &quotGoogle Chrome&quotv=&quot98&quot' \
-H 'Accept: application/json, text/plain, */*' \
-H 'x-nba-stats-token: true' \
-H 'sec-ch-ua-mobile: ?0' \
-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36' \
-H 'x-nba-stats-origin: stats' \
-H 'sec-ch-ua-platform: &quotLinux&quot' \
-H 'Origin: https://www.nba.com' \
-H 'Sec-Fetch-Site: same-site' \
-H 'Sec-Fetch-Mode: cors' \
-H 'Sec-Fetch-Dest: empty' \
-H 'Referer: https://www.nba.com/' \
-H 'Accept-Language: en-US,en;q=0.9,fa;q=0.8' \
-H 'If-Modified-Since: Sun, 13 Feb 2022 15:37:37 GMT' \
--compressed


دستوری که کپی کردید رو با فلگ "--compressed" توی ترمینال paste کنید و بعد از زدن دکمه enter و کمی صبر،‌ می‌بینیم که پاسخ‌های JSON برامون بر‌میگرده.

البته اگه از مرورگر کروم استفاده می‌کنید دیگه لازم نیست خودتون فلگ --compressed رو اضافه کنید. چون دستور کپی شده از اونجا، خودش این فلگ رو داره.


اگه به فلگ‌های "-H" توی دستور کپی شده دقت کنید، می‌بینید که کلی HTTP Header داریم که به خیلی از اون‌ها اصلا نیازی نداریم.

می‌خوام که درخواستمون تا حد ممکن ساده باشه، پس تا جای ممکنه گزینه‌‌های اضافه رو حذف می‌کنم و چیزی که در آخر برامون می‌مونه، دستور زیره:

curl 'https://stats.nba.com/stats/playerdashboardbyyearoveryear?DateFrom=&DateTo=&GameSegment=&LastNGames=0&LeagueID=00&Location=&MeasureType=Base&Month=0&OpponentTeamID=0&Outcome=&PORound=0&PaceAdjust=N&PerMode=PerGame&Period=0&PlayerID=2544&PlusMinus=N&Rank=N&Season=2021-22&SeasonSegment=&SeasonType=Regular+Season&ShotClockRange=&Split=yoy&VsConference=&VsDivision=' \
-H 'Connection: keep-alive' \
-H 'x-nba-stats-token: true' \
-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36' \
-H 'Referer: https://www.nba.com/' \
--compressed


از اونجایی که ریزالتی که تو ترمینال داریم مشاهده می‌کنیم، اصلا خوانا نیست، پس وقشته که از jq استفاده کنیم. برای این کار، آخر دستورمون "| jq" رو اضافه می‌کنیم. التبه jq بجز قشنگ و مرتب‌ کردن ریزالت، قابلیت‌های دیگه‌ای هم داره. براث مثال، با قرار دادن "| jq .resultSets[1].rowSet[0]" آخر دستور، از jq می‌خوایم که برامون فقط سطر اول جدول رو برگردونه:

curl 'https://stats.nba.com/stats/playerdashboardbyyearoveryear?DateFrom=&DateTo=&GameSegment=&LastNGames=0&LeagueID=00&Location=&MeasureType=Base&Month=0&OpponentTeamID=0&Outcome=&PORound=0&PaceAdjust=N&PerMode=PerGame&Period=0&PlayerID=2544&PlusMinus=N&Rank=N&Season=2021-22&SeasonSegment=&SeasonType=Regular+Season&ShotClockRange=&Split=yoy&VsConference=&VsDivision=' \
-H 'Connection: keep-alive' \
-H 'x-nba-stats-token: true' \
-H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36' \
-H 'Referer: https://www.nba.com/' \
--compressed | jq '.resultSets[1].rowSet[0]'


خروجی دستور بالا:

[ &quotBy Year&quot, &quot2021-22&quot, 1610612747, &quotLAL&quot, &quot2022-02-12T00:00:00&quot,
40, 20, 20, 0.5, 36.7, 11, 21.1, 0.52, 2.8, 7.9, 0.352, 4.3, 5.7, 0.746,
1.1, 6.8, 7.9, 6.5, 3.2, 1.6, 1, 0.8, 2.3, 5.3, 29, -0.9, 52.7, 15, 4, 19,
19, 11, 18, 14, 2, 3, 7, 1, 1, 8, 17, 17, 9, 10, 6, 6, 15, 17, 10, 4, 7, 2,
15, 4, 18, 6, 17, 9, 264, &quot2021-22&quot ]


نوشتن اسکریپت Node.js برای اسکرپ کردن چند صفحه

تا اینجای کار URL و HTTP Header هایی که نیاز داریم رو پیدا کردیم و حالا می‌تونیم اسکریپتی بنویسم که API رو بطور اتوماتیک اسکرپ کنه. این اسکریپت رو می‌تونید تقریبا با هر زبانی که بخواید بنویسد، اما تو این مقاله قصد داریم تا با استفاده از Node.js و لایبری request این کارو کنیم.

برای ساخت پروژه جاوااسکریپت جدید، به دایرکتوری مد نظرتون برید و تو ترمینال این ۲ دستور زیر رو وارد کنید:

npm init -y
npm install --save request request-promise-native


حالا باید درخواستمون رو از فرمت cURL به فرمت موردنیاز برای کتابخونه request، ترجمه کنیم. برای اینکه از مزایای async/await بهره‌مند هم بشیم و بتونیم کدهای asynchronous و خوانا بنویسیم، از Promise استفاده می‌کنیم. فایلی به اسم "index.js" ایجاد کنید و کدهای زیر رو توش قرار بدید:

const rp = require(&quotrequest-promise-native&quot);
const fs = require(&quotfs&quot);

async function main() {
     console.log(&quotMaking API Request...&quot);

     // request the data from the JSON API
     const results = await rp({
     uri: &quothttps://stats.nba.com/stats/playerdashboardbyyearoveryear?DateFrom=&DateTo=&GameSegment=&LastNGames=0&LeagueID=00&Location=&MeasureType=Base&Month=0&OpponentTeamID=0&Outcome=&PORound=0&PaceAdjust=N&PerMode=PerGame&Period=0&PlayerID=2544&PlusMinus=N&Rank=N&Season=2021-22&SeasonSegment=&SeasonType=Regular+Season&ShotClockRange=&Split=yoy&VsConference=&VsDivision=&quot,
     headers: {
          &quotConnection&quot: &quotkeep-alive&quot,
          &quotUser-Agent&quot: &quotMozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36&quot,
          &quotx-nba-stats-origin&quot: &quotstats&quot,
          &quotReferer&quot: &quothttps://stats.nba.com/&quot
     },
     json: true
});

     console.log(&quotGot results =&quot, results);

     // save the JSON to disk
     await fs.promises.writeFile(&quotoutput.json&quot, JSON.stringify(results, null, 2));

     console.log(&quotDone!&quot);
}

// start the main script
main();


و حالا در ترمینال با زدن دستور زیر، اسکریپت رو اجرا کنید:

node index.js


و تمام! حالا اگه به فایل "output.json" که بعد از اجرای دستور بالا، توی دایرکتوری‌مون ایجاد شده برید،‌ داده‌های JSON رو مشاهده می‌کنید.


گرفتن دیتای سایر بازیکن‌ها

تا اینجا، کارمون تموم شده و اسکریپتی که نوشتیم دیتاهای مربوط به LeBron رو برامون اسکرپ می‌کنه. اما الان قصد دارم تا این یکم این اسکریپت رو ارتقاع بدم تا علاوه بر دیتای LeBron، دیتای چند بازیکن دیگه رو هم اسکرپ کنه.

اگه به API URL دقت کنید، عبارت "PlayerID" رو مشاهده می‌کنید:


الان فقط به ID سایر بازیکنان نیاز داریم. به صفحه چندتا دیگه از بازیکنا میرم و PlayerID اونارو برمیدارم. در مجموع ما الان ۴ تا بازیکن زیر رو داریم:

Player ID | Player | URL
2544 | LeBron James | https://stats.nba.com/player/2544/
1629029 | Luka Doncic | https://stats.nba.com/player/1629029/
201935 | James Harden | https://stats.nba.com/player/201935/
202695 | Kawhi Leonard | https://stats.nba.com/player/202695/


حالا فقط اسکریپتمون رو به شکل زیر تغییر میدیم تا بتونیم دیتای سایر بازیکن‌ها رو هم اسکرپ کنیم:

const rp = require(&quotrequest-promise-native&quot);
const fs = require(&quotfs&quot);

// helper to delay execution by 300ms to 1100ms
async function delay() {
     const durationMs = Math.random() * 800 + 300;
     return new Promise(resolve => {
          setTimeout(() => resolve(), durationMs);
     });
}

async function fetchPlayerYearOverYear(playerId) {
    console.log(`Making API Request for ${playerId}...`);

    // add the playerId to the URI and the Referer header
    // NOTE: we could also have used the `qs` option for the
    // query parameters.
    const results = await rp({
         uri: &quothttps://stats.nba.com/stats/playerdashboardbyyearoveryear?DateFrom=&DateTo=&GameSegment=&LastNGames=0&LeagueID=00&Location=&MeasureType=Base&Month=0&OpponentTeamID=0&Outcome=&PORound=0&PaceAdjust=N&PerMode=PerGame&Period=0&&quot +
`PlayerID=${playerId}` +
&quot&PlusMinus=N&Rank=N&Season=2019-20&SeasonSegment=&SeasonType=Regular+Season&ShotClockRange=&Split=yoy&VsConference=&VsDivision=&quot,
          headers: {
              &quotConnection&quot: &quotkeep-alive&quot,
              &quotUser-Agent&quot: &quotMozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36&quot,
              &quotx-nba-stats-origin&quot: &quotstats&quot,
              &quotReferer&quot: `https://stats.nba.com/player/${playerId}/`
          },
          json: true
     });

     // save to disk with playerID as the file name
     await fs.promises.writeFile(
         `${playerId}.json`,
         JSON.stringify(results, null, 2)
     );
}

async function main() {
     // PlayerIDs for LeBron, Harden, Kawhi, Luka
     const playerIds = [2544, 201935, 202695, 1629029];
     console.log(&quotStarting script for players&quot, playerIds);

    // make an API request for each player
     for (const playerId of playerIds) {
          await fetchPlayerYearOverYear(playerId);

          // be polite to our friendly data hosts and
        // don't crash their servers
        await delay();
     }

     console.log(&quotDone!&quot);
}

// start the main script
main();


در کد بالا، من فانکشنی async به اسم fetchPlayerYearOverYear نوشتم و با استفاده از حلقه for، تمام ID های موجود در آرایه رو fetch کردم و از اونجایی هم که نمی‌خوام سرور رو با تعداد درخواست‌های بالام بمب بارون کنم، بعد از هر مرحله fetch، از delay استفاده کردم.

اگه تعداد درخواست‌هامون در یک مدت زمانی کوتاه زیاد باشه، ممکنه به عنوان اسپمر شناخته بشیم و بلاک شویم.


اولین بخش از مقاله یعنی آموزش اسکرپ کردن به روش JSON API با استفاده از Node.js تموم شد و حالا روش ۲ و ۳ مونده.



روش شماره ۲: Server-side Rendered HTML

علاوه بر روش قبلی که گرفتن دیتا به‌صورت async از طریق API بود، روش دیگه‌ای وجود داره که در اون دیتا به‌صورت مستقیم و قبل از اینکه صفحه وب لود بشه به HTML رندر میشه. روشی که وب سرورها استفاده می‌کنن. در این قسمت توضیح میدم که چطوری می‌تونیم با دانلود و parse کردن (تبدیل) HTML به کمک Cheerio، دیتا استخراج کنیم.


پیدا کردن HTML همراه با دیتا

در ایجا قراره داده‌هارو از box scores وب‌سایت espn.com استخراج کنیم.


مرحله ۱: مطلع شدن از داینامیک بودن داده‌ها

مثل روش اول، در این روش هم باید مطمئن بشیم که داده‌ها به‌صورت داینامیک در صفحه لود میشن.
برای مثال، می‌خوایم دیتاهای box scores مربوط به یکی از بازی‌های بسکتبال 76ers رو اسکرپ کنیم.

لینک گزارش بازی: https://www.espn.com/nba/boxscore?gameId=401160888.

اسکرین شات از Box Score مربوط به بازی
اسکرین شات از Box Score مربوط به بازی


دقیقا همون کاری که در روش ۱ برای دیدن Source صفحه کردیم، اینبار هم انجام میدیم. روی صفحه راست کلیک کنید و گزینه View Page Source رو بزنید و یا از میانبرهای Ctrl+U استفاده کنید.

حالا تو صفحه سورس Ctrl+F بزنید و یکی از نتایجی که تو جدول مشاهده می‌کنید رو سرچ کنید. برای مثال من "0-11" رو سرچ می‌کنم.

اسکرین شات از source صفحه
اسکرین شات از source صفحه


اینم از چیزی که دنبالش بودیم!


مرحله ۲: پیدا کردن نحوه دسترسی به دیتا

بعد از اینکه از وجود داده‌ها در HTML مطلع شدیم، حالا باید ببینیم که چطوری می‌تونیم به اون‌ها از طریق مرورگر دسترسی پیدا کنیم. تو اینجا ما به یه چیزی نیاز داریم تا بخش‌هایی از HTML که برامون اهمیت دارن رو شناسایی کنه، چیزی که CSS Selector ها هم به‌خوبی بتونن باهاش کار کنن.

دوباره به صفحه بر می‌گردیم و روی جدول راست کلیک می‌کنیم و گزینه Inspect رو میزنیم. در نتجیه باید یه چی شبیه به چیزی که در تصویر زیر می‌بینید رو مشاهده کنید:

اسکرین شات از HTML مربوط به جدول
اسکرین شات از HTML مربوط به جدول


الان به یک Selector نیاز داریم که تمام سطرهای جدول یا همون <tr> هارو برامون برگردونه که این کار رو با استفاده از فانکشن "document.querySelectorAll()" انجام میدیم. برای این کار، توی Console مرورگرتون کد زیر رو بزنید و enter کنید:

document.querySelectorAll('tr')


خروجی کد بالا:

> NodeList(41) [tr, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr.highlight, tr.highlight, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr.highlight, tr.highlight, tr, tr, tr.highlight, tr.highlight, tr, tr]


همونطور که می‌بینید استفاده از "tr" خیلی کلی هست و کدی که نوشتیم برامون ۴۱ سطر برگردوند و این در حالی است که تیم 76ers فقط ۱۳ بازیکن در جدول داره. دوباره به کدای HTML در Inspector نگاه کنید و متوجه میشید که المنت‌های والد تگ‌های <tr>، یکسری ID و Class دارن:

کدهای HTMl جدول Box Score
کدهای HTMl جدول Box Score


با استفاده از این ID و Class ها، کد Selector مون رو بازنویسی می‌کنیم:

document.querySelectorAll('.gamepackage-away-wrap tbody tr')


خروجی کد بالا:

> NodeList(15) [tr, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr.highlight, tr.highlight]


به هدفمون خیلی نزدیک شدیم. ولی اگه به خروجی نگاه کنید می‌بینید که دو تگ <tr> با کلاس highlight برامون برگردونده که خلاصه کل سطرهاست و بهشون نیازی نداریم. می‌تونیم خیلی راحت دستور Selector رو آپدیت کنیم تا اونارو فیلتر کنه:

document.querySelectorAll('.gamepackage-away-wrap tbody tr:not(.highlight)')


خروجی کد بالا:

> NodeList(13) [tr, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr, tr]


عالی شد! حالا یک Selector داریم که تمام سطرهای بازیکن‌ها رو از جدول صفحه Box Score برامون برمیگردونه. کار این قسمت تموم و میشه و حالا نوبت نوشتن اسکریپته.


نوشتن اسکریپت Node.js برای اسکرپ کردن صفحه

الان علاوه بر URL صفحه‌ای که میخوایم اسکرپ کنیم، یک Selector هم داریم که برامون تمام سطرهایی که از جدول صفحه Box Score می‌خوایم رو برمیگردونه.


دانلود صفحه HTML

کاری که اینجا می‌خوایم بکنیم، دقیقا مثل کاریه که در روش قبلی انجام دادیم. به دایرکتوری که میخواید پروژتون رو اونجا ایجاد کنید برید و دستورات زیر رو تو ترمینال وارد کنید:

npm init -y
npm install --save request request-promise-native


من معمولا ترجیح میدم که فایل HTML رو تو دایرکتوری ذخیره کنم تا وقتی که دارم فرآیند Parse کردن رو تکرار می‌کنم، مدام به سرور درخواست نفرستم. پس اول بریم یک اسکریپت به اسم "index.js" بسازیم تا صفحه HTML رو برامون دانلود کنه:

const rp = require('request-promise-native');
const fs = require('fs');

async function downloadBoxScoreHtml() {
     // where to download the HTML from
     const uri = 'https://www.espn.com/nba/boxscore?gameId=401160888';
     // the output filename
     const filename = 'boxscore.html';

     // download the HTML from the web server
     console.log(`Downloading HTML from ${uri}...`);
     const results = await rp({ uri: uri });

     // save the HTML to disk
     await fs.promises.writeFile(filename, results);
}

async function main() {
     console.log('Starting...');

     await downloadBoxScoreHtml();

     console.log('Done!');
}

main();


حالا با وارد کردن دستور زیر در ترمینال، اسکریپت رو ران می‌کنیم:

node index.js


حالا در دایرکتوری پروژه، فایلی به اسم boxscore.html داریم که حاوی HTML صفحه‌ی مد نظرمونه.


قبل از Parse کردن HTML، بیاید اسکریپت رو آپدیت کنیم تا فقط وقتی فایل رو دانلود که اون فایل در دایرکتوری وجود نداشته باشه. برای انجام این کار، تکه کد زیر (مثل تصویر پایین‌تر) رو قبل از قسمت ایجاد درخواست اضافه می‌کنیم:

// check if we already have the file
const fileExists = fs.existsSync(filename);
if (fileExists) {
     console.log(`Skipping download for ${uri} since ${filename} already exists.`);
     return;
}


تبدیل (Parse) کردن HTML با استفاده از Cheerio

بهترین راه برای بیرون کشیدن دیتا از HTML، استفاده از یک HTML Parser مثل Cheerio می‌باشد. Cheerio شباهت زیادی با jQuery داره، با این تفاوت که سمت سروره.


ابتدا با زدن دستور زیر در ترمینال، Cheerio رو نصب می‌کنیم:

npm install --save cheerio


برای شروع و استفاده از Cheerio، باید HTML رو به عنوان رشته (String) بهش پاس بدیم تا اونو برامون Parse و به قول معروف queryable یا قابل پرس و جو کنه و این کارو با استفاده از دستور load انجام میدیم:

const $ = cheerio.load('<html>...</html>')


کار خوندن و load کردن فایل HTML توسط Cheerio رو با دستورات زیر انجام میدیم:

// the input filename
const htmlFilename = 'boxscore.html';
// read the HTML from disk
const html = await fs.promises.readFile(htmlFilename);
// parse the HTML with Cheerio
const $ = cheerio.load(html);


بعد از اینه HTML پارس شد، می‌تونیم با استفاده از فانکشن $، Selector مون رو بنویسم:

const $trs = $('.gamepackage-away-wrap tbody tr:not(.highlight)')


کدی که نوشتیم، تمام ندهای tr جدول رو برامون برمیگردونه و برای اینکه از این بابت هم مطمئن شیم، می‌تونیم از "$.html" استفاده کنیم:

console.log($.html($trs));


خروجی کد بالا:

<tr>
     <td class=&quotname&quot>
          <a name=&quot&lpos=nba:game:boxscore:playercard&quot href=&quothttps://www.espn.com/nba/player/_/id/6440/tobias-harris&quot data-player-uid=&quots:40~l:46~a:6440&quot>
               <span>T. Harris</span>
               <span class=&quotabbr&quot>T. Harris</span>
          </a>
     <span class=&quotposition&quot>SF</span>
     </td>
     <td class=&quotmin&quot>38</td>
     <td class=&quotfg&quot>7-17</td>
      <td class=&quot3pt&quot>3-7</td>
     <td class=&quotft&quot>1-1</td>
     <td class=&quotoreb&quot>0</td>
     <td class=&quotdreb&quot>5</td>
     <td class=&quotreb&quot>5</td>
     <td class=&quotast&quot>2</td>
     <td class=&quotast&quot>1</td>
     <td class=&quotast&quot>0</td>
     <td class=&quotast&quot>1</td>
     <td class=&quotast&quot>2</td>
     <td class=&quotast&quot>3</td>
     <td class=&quotplusminus&quot>-10</td>
     <td class=&quotpts&quot>18</td>
</tr>
...


حالا می‌تونیم یک object mapping برای اتریبیوت class مربوط به <td> بسازیم تا مقادیر اونو ذخیره کنیم. این کارو روی هر سطر تکرار می‌کنیم تا تمام داده‌ها رو بیرون بکشیم:

const values = $trs.toArray().map(tr => {
     // find all children <td>
     const tds = $(tr).find('td').toArray();

     // create a player object based on the <td> values
     const player = {};
     for (td of tds) {
          // parse the <td>
          const $td = $(td);

          // map the td class attr to its value
          const key = $td.attr('class');
          const value = $td.text();
          player[key] = value;
     }

     return player:
});


حالا اگه یه نگاهی به خروجی کارمون بندازیم، یه چیزی شبیه به مقادیر زیر رو خواهیم داشت:

[
     { 
          &quotname&quot: &quotT. HarrisT. HarrisSF&quot,
          &quotmin&quot: &quot38&quot,
          &quotfg&quot: &quot7-17&quot,
          &quot3pt&quot: &quot3-7&quot, 
          &quotft&quot: &quot1-1&quot,
          &quotoreb&quot: &quot0&quot, 
          &quotdreb&quot: &quot5&quot,
          &quotreb&quot: &quot5&quot, 
          &quotast&quot: &quot2&quot, 
          &quotstl&quot: &quot1&quot,
          &quotblk&quot: &quot0&quot,
          &quotto&quot: &quot2&quot,
          &quotpf&quot: &quot3&quot,
          &quotplusminus&quot: &quot-10&quot, 
          &quotpts&quot: &quot18&quot
     }
     ...
]


به اون چیزی که می‌خوایم خیلی نزدیک شدیم، ولی چندتا مشکل کوچیک داریم. به‌نظر میرسه مقدار name اشتباهه و تمام اعداد، به‌صورت String ذخیره شده. این مشکل رو از روش‌های مختلفی میشه حل کرد و ما از سریع‌ترین روش‌های ممکن برای حلش استفاده می‌کنیم.


اول میریم سراغ درست کردن name. اگه به کد HTML نگاه کنید، متوجه میشید که ستون اول نسبت به سایر ستون‌ها، کمی متفاوته:

<td class=&quotname&quot>
     <a name=&quot&lpos=nba:game:boxscore:playercard&quot href=&quothttps://www.espn.com/nba/player/_/id/6440/tobias-harris&quot data-player-uid=&quots:40~l:46~a:6440&quot>
          <span>T. Harris</span>
          <span class=&quotabbr&quot>T. Harris</span>
     </a>
     <span class=&quotposition&quot>SF</span>
</td>


به شکل زیر، اولین تگ <span> توی تگ <a> مربوط به ستون name رو انتخاب می‌کنیم:

for (td of tds) {
     const $td = $(td);

     // map the td class attr to its value
    const key = $td.attr('class');

     let value;
     if (key === 'name') {
          value = $td.find('a span:first-child').text();
     } else {
          value = $td.text();
     }

     player[key] = value;
}


و حالا نوبت درست کردن اعداده. از اونجایی که می‌دونیم تمام مقادیرمون String هستن، خیلی ساده می‌تونیم تمام اعداد رو به شکل زیر، تبدیل به عدد کنیم:

player[key] = isNaN(+value) ? value : +value;


با تغییراتی که انجام دادیم، خروجی‌مون بدین شکل میشه:

[ 
     {
          &quotname&quot: &quotT. Harris&quot,
          &quotmin&quot: 38,
          &quotfg&quot: &quot7-17&quot,
          &quot3pt&quot: &quot3-7&quot,
          &quotft&quot: &quot1-1&quot,
          &quotoreb&quot: 0,
          &quotdreb&quot: 5,
          &quotreb&quot: 5,
          &quotast&quot: 2,
          &quotstl&quot: 1,
          &quotblk&quot: 0,
          &quotto&quot: 2,
          &quotpf&quot: 3,
          &quotplusminus&quot: -10,
          &quotpts&quot: 18
     },
     ...
]


حالا تمام کاری که مونده انجام بدیم، ذخیره کردن مقادیر توی دایرکتوریمونه:

// save the scraped results to disk
await fs.promises.writeFile(
     'boxscore.json',
     JSON.stringify(values, null, 2)
);


و تمام! ما HTML رو که داده‌ها از قبل توش رندر شده بود رو دانلود کردیم و بعد با استفاده از Cheerio اونارو لود و Parse کردیم و در نهایت دیتا رو با فرمت JSON در فایل مربوط، ذخیره کردیم.


کد کامل اسکریپتی که نوشتیم:

const rp = require('request-promise-native');
const fs = require('fs');
const cheerio = require('cheerio');

async function downloadBoxScoreHtml() {
     // where to download the HTML from
     const uri = 'https://www.espn.com/nba/boxscore?gameId=401160888';
     // the output filename
     const filename = 'boxscore.html';

     // check if we already have the file
     const fileExists = fs.existsSync(filename);
     if (fileExists) {
          console.log(`Skipping download for ${uri} since ${filename} already exists.`);
          return;
     }

     // download the HTML from the web server
     console.log(`Downloading HTML from ${uri}...`);
     const results = await rp({ uri: uri });

     // save the HTML to disk
     await fs.promises.writeFile(filename, results);
}

async function parseBoxScore() {
     console.log('Parsing box score HTML...');

     // the input filename
     const htmlFilename = 'boxscore.html';
     // read the HTML from disk
     const html = await fs.promises.readFile(htmlFilename);
     // parse the HTML with Cheerio
     const $ = cheerio.load(html);

     // Get our rows
     const $trs = $('.gamepackage-away-wrap tbody tr:not(.highlight)');

     const values = $trs.toArray().map(tr => {
          // find all children <td>
          const tds = $(tr).find('td').toArray();

          // create a player object based on the <td> values
          const player = {};
          for (td of tds) {
               const $td = $(td);

               // map the td class attr to its value
               const key = $td.attr('class');
               let value;
               if (key === 'name') {
                    value = $td.find('a span:first-child').text();
               } else {
                    value = $td.text();
               }
              player[key] = isNaN(+value) ? value : +value;
          }

          return player;
         });

         return values;
     }

async function main() {
     console.log('Starting...');

     await downloadBoxScoreHtml();
     const boxScore = await parseBoxScore();

     // save the scraped results to disk
     await fs.promises.writeFile(
         'boxscore.json',
         JSON.stringify(boxScore, null, 2)
     );

     console.log('Done!');
}

main():


اینم از آموزش اسکرپ کردن به روش Server-side Rendered HTML. روش‌‌های ۱ و ۲ تموم شد و حالا فقط روش شماره ۳ مونده.



روش شماره ۳: JavaScript rendered HTML

در این قسمت قراره روش اسکرپ کردن HTML بعد از اجرا شدن JavaScript در صفحه رو توضیح بدم. در بسیاری از موارد می‌تونیم این کارو به شکلی که در روش ۱ انجام دادیم، انجام بدیم، اما معمولا گرفتن HTML و کار روش، ممکنه راحت‌تر باشه. اینجا دیگه کتابخونه Request که قبلا ازش استفاده کردیم، به کارمون نمیاد. چون، این کتابخونه فقط HTML رو دانلود می‌کنه و هیچ جاوااسکریپتی رو در صفحه اجرا نمی‌کنه. پس نیازه که از یه کتابخونه دیگه‌ای استفاده کنیم و Puppeteer همون کتابخونه قدرتمندیه که قراره در این قسمت ازش استفاده کنیم.

برای این قسمت، قراره دیتای مربوط به پرتاب‌های Steph Curry از وب‌سایت Basketball Reference رو اسکرپ کنیم.


نوشتن اسکریپت Node.js برای اسکرپ کردن صفحه بعد از اجرا شدن جاوااسکریپت

هدف‌مون اسکرپ کردن دیتا از چارت مربوط پرتاب‌های بازی Steph Curry موجود در صفحه زیره:

https://www.basketball-reference.com/players/c/curryst01/shooting/2020

 Steph Curry نمودار پرتاب‌های بازی
Steph Curry نمودار پرتاب‌های بازی


با راست کلیک روی یکی از نمادهای ● یا × روی عکس و زدن گزینه Inspect (مثل کاری که در روش‌های ۱ و ۲ انجام دادیم)، می‌تونیم تگ‌های تو در تو <div> رو مشاهده کنیم:

کدهای HTML مربوط به چارت
کدهای HTML مربوط به چارت


حالا با استفاده از Console مرورگر، یک Selector می‌نویسیم تا تگ‌های <div> که میخوایم رو برامون بگیره:

document.querySelectorAll('.shot-area > div')


خروجی کد بالا:

> NodeList(66) [div.tooltip.miss, div.tooltip.miss, div.tooltip.miss, div.tooltip.make, div.tooltip.make, ...]


حالا با استفاده از این Selector میریم تا اسکریپت Node.js رو بنویسیم.


راه‌اندازی Puppeteer

برای ساخت پروژه جاوااسکریپت جدید، به دایرکتوری مد نظرتون برید، یک فایل به اسم "index.js" بسازید و تو ترمینال این ۲ دستور زیر رو وارد کنید:

npm init -y
npm install --save cheerio puppeteer


کار با لایبری Puppeteer، کمی از request پیچیده‌تره، ولی سعی می‌کنم تا به ساده‌ترین شکل ممکن کار باهاشو بهتون توضیح بدم. Puppeteer با اجرای یک مرورگر headless کروم کار می‌کنه، بنابراین به‌صورت تئوری هر کاری که مرورگر شما بتونه انجام بده، اینم میتونه انجامش بده. برای شروع، نیاز داریم تا ابتدا puppeteer رو import کنیم و سپس مرورگر رو اجرا کنیم:

const fs = require('fs');
const puppeteer = require('puppeteer');

async function main() {
     console.log('Starting...');

     const browser = await puppeteer.launch();

     // TODO download the HTML after running js on the page

     await browser.close();

     console.log('Done!');
}

main();


در قدم بعدی، نیاز که یک صفحه جدید بسازیم تا HTML رو باش fetch کنیم. برای ساخت صفحه، دوست دارم که یه helper function (تابع کمکی) داشته باشم تا بتونم تمام گزینه‌هایی که برای درخواستم می‌خوام رو، توش قرار بدم. تو اینجا من timeout رو ۲۰ ثانیه قرار دادم و از یک User Agent string جعلی هم استفاده کردم:

async function newPage(browser) {
     // get a new page
     page = await browser.newPage();
     page.setDefaultTimeout(20000); // 20s

     // spoof user agent
     await page.setUserAgent('Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.111 Safari/537.36');

     // pretend to be desktop
     await page.setViewport({
          width: 1980,
          height: 1080,
     });

     return page;
}


پس از ست کردن صفحه، حالا باید کار fetch کردن و اجرای JS رو انجام بدیم که دوباره این کار رو با یک helper function دیگه انجام دادم:

async function fetchUrl(browser, url) {
     const page = await newPage(browser);

     await page.goto(url, {
          timeout: 20000,
          waitUntil: 'domcontentloaded'
     });

     const html = await page.content();
     await page.close();

     return html;
}


دانلود صفحه HTML

با دو فانکشنی که نوشتیم، می‌تونیم کدی بنویسیم تا دیتای مربوط به پرتاب‌ها رو برامون دانلود کنه:

async function downloadShootingData(browser) {
     const url = 'https://www.basketball-reference.com/players/c/curryst01/shooting/2020';
     const htmlFilename = 'shots.html';

     // download the HTML from the web server
     console.log(`Downloading HTML from ${url}...`);
     const html = await fetchUrl(browser, url);

     // save the HTML to disk
     await fs.promises.writeFile(htmlFilename, html);
}


دوباره، از اونجای puppeteer ممکنه کمی کند باشه، توصیه می‌کنم که قبل از اجرای فانکشن fetchUrl، چک کنید که آیا فایل رو از قبل داریم یا نه:

async function downloadShootingData(browser) {
     const url = 'https://www.basketball-reference.com/players/c/curryst01/shooting/2020';
     const htmlFilename = 'shots.html';

     // check if we already have the file
     const fileExists = fs.existsSync(htmlFilename);
     if (fileExists) {
          console.log(`Skipping download for ${url} since ${htmlFilename} already exists.`);
          return;
     }

     // download the HTML from the web server
     console.log(`Downloading HTML from ${url}...`);
     const html = await fetchUrl(browser, url);

     // save the HTML to disk
     await fs.promises.writeFile(htmlFilename, html);
}


حالا فقط نیازه تا قبل از اینکه مرورگر رو اجرا کنیم، فانکشن رو صدا بزنیم.

async function main() {
     console.log('Starting...');

     // download the HTML after javascript has run
     const browser = await puppeteer.launch();
     await downloadShootingData(browser);
     await browser.close();

     console.log('Done!');
}


سپس با دستور زیر در ترمینال، اسکریپت رو اجرا می‌کنیم:

node index.js


حالا اگه فایل shots.html رو چک کنید، تمام تگ‌های <div> که می‌خوایم رو توش می‌بینید که منتظرن تا اونارو Parse و سپس ذخیرشون کنیم.


تبدیل (Parse) کردن HTML با استفاده از Cheerio

فرآيند Parse کردن در این روش، به همون شکلی که در روش ۲ انجام دادیم، انجام میشه. یک فایل HTML داریم و می‌خوایم داده رو با استفاده از selector ازش استخراج کنیم. دوباره به تگ <div> مربوط به shot نگاه کنید. به‌نظرتون چه داده‌هایی می‌تونیم ازش بدست بیاریم؟


نظرتون چیه که موقیت x و y رو از اتریبیوت style، مقدار امتیاز پرتاب رو از اتریبیوت tip و اینکه پرتاب موفقیت آمیز بود یا خیر رو از اتریبیوت class بگیریم؟

پس بریم فایل HTML رو لود و سپس با استفاده از cheerio اونو Parse کنیم:

async function parseShots() {
     console.log('Parsing shots HTML...');

     // the input filename
     const htmlFilename = 'shots.html';
     // read the HTML from disk
     const html = await fs.promises.readFile(htmlFilename);
     // parse the HTML with Cheerio
     const $ = cheerio.load(html);

     // for each of the shot divs, convert to JSON
     const divs = $('.shot-area > div').toArray();

     // TODO: convert divs to shot JSON objects

     return shots;
}


الان تگ‌های <div> رو به عنوان آرایه در اختیار داریم. حالا با map کردن اونا به آبجکت‌های JSON، مقادیر اتربیوبت‌هایی که در بالا بهشون اشاره کردیم رو برمی‌گردونیم:

const shots = divs.map(div => {
      const $div = $(div);

     // style=&quotleft:50px;top:120px&quot -> x = 50, y = 120
     // slice -2 to drop &quotpx&quot, prefix with `+` to make a number
     const x = +$div.css('left').slice(0, -2);
     const y = +$div.css('top').slice(0, -2);

     // class=&quottooltip make&quot or &quottooltip miss&quot
     const madeShot = $div.hasClass('make');

     // tip=&quot...Made 3-pointer...&quot
     const shotPts = $div.attr('tip').includes('3-pointer') ? 3 : 2;

     return { x, y, madeShot, shotPts };
});


کد بالا باید خروجی شبیه به زیر رو بهمون بده:

[
     {
          &quotx&quot: 259,
          &quoty&quot: 331,
          &quotmadeShot&quot: false,
          &quotshotPts&quot: 3
          },
          ...
]


حالا فقط باید فانکشن parseShots() رو به فانکشن main() وصل کنیم و نتیحه رو ذخیره کنیم:

async function main() {
     console.log('Starting...');

     // download the HTML after javascript has run
     const browser = await puppeteer.launch();
     await downloadShootingData(browser);
     await browser.close();

     // parse the HTML
     const shots = await parseShots();

     // save the scraped results to disk
     await fs.promises.writeFile('shots.json', JSON.stringify(shots, null, 2));

     console.log('Done!');
}


و الان اگه فایل shots.json رو چک کنید، باید نتیجه کارمون رو مشاهده کنید.


کد کامل اسکریپتی که نوشتیم:

const fs = require('fs');
const cheerio = require('cheerio');
const puppeteer = require('puppeteer');

const TIMEOUT = 20000; // 20s timeout with puppeteer operations
const USER_AGENT =
  'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.111 Safari/537.36';

async function newPage(browser) {
  // get a new page
  page = await browser.newPage();
  page.setDefaultTimeout(TIMEOUT);

  // spoof user agent
  await page.setUserAgent(USER_AGENT);

  // pretend to be desktop
  await page.setViewport({
    width: 1980,
    height: 1080,
  });

  return page;
}

async function fetchUrl(browser, url) {
  const page = await newPage(browser);

  await page.goto(url, { timeout: TIMEOUT, waitUntil: 'domcontentloaded' });
  const html = await page.content(); // sometimes this seems to hang, so now we create a new page each time
  await page.close();

  return html;
}

async function downloadShootingData(browser) {
  const url =
    'https://www.basketball-reference.com/players/c/curryst01/shooting/2020';
  const htmlFilename = 'shots.html';

  // check if we already have the file
  const fileExists = fs.existsSync(htmlFilename);
  if (fileExists) {
    console.log(
      `Skipping download for ${url} since ${htmlFilename} already exists.`
    );
    return;
  }

  // download the HTML from the web server
  console.log(`Downloading HTML from ${url}...`);
  const html = await fetchUrl(browser, url);

  // save the HTML to disk
  await fs.promises.writeFile(htmlFilename, html);
}

async function parseShots() {
  console.log('Parsing box score HTML...');

  // the input filename
  const htmlFilename = 'shots.html';
  // read the HTML from disk
  const html = await fs.promises.readFile(htmlFilename);
  // parse the HTML with Cheerio
  const $ = cheerio.load(html);

  // for each of the shot divs, convert to JSON
  const divs = $('.shot-area > div').toArray();
  const shots = divs.map(div => {
    const $div = $(div);

    // style=&quotleft:50px;top:120px&quot -> x = 50, y = 120
    const x = +$div.css('left').slice(0, -2);
    const y = +$div.css('top').slice(0, -2);

    // class=&quottooltip make&quot or &quottooltip miss&quot
    const madeShot = $div.hasClass('make');

    // tip=&quot...Made 3-pointer...&quot
    const shotPts = $div.attr('tip').includes('3-pointer') ? 3 : 2;

    return {
      x,
      y,
      madeShot,
      shotPts,
    };
  });

  return shots;
}

async function main() {
  console.log('Starting...');

  // download the HTML after javascript has run
  const browser = await puppeteer.launch();
  await downloadShootingData(browser);
  await browser.close();

  // parse the HTML
  const shots = await parseShots();

  // save the scraped results to disk
  await fs.promises.writeFile('shots.json', JSON.stringify(shots, null, 2));

  console.log('Done!');
}

main();


اینم از روش شماره ۳ و آخرین روش! موفق شدیم تا یک صفحه رو پس اجرا شدن JavaScript روش دانلود، کدهای HTML اش رو Parse و داده‌ها رو ازش استخراج کنیم و در نهایت در یک فایل JSON ذخیره کنیم.



امیدوارم که این مقاله براتون مفید بوده باشه و تونسته باشم که به خوبی وب اسکرپینگ با جاوا اسکریپت رو توضیح داده باشم.

فقط حواستون باشه که با اسکرپ کردن میشه کارهای زیادی انجام داد و اگه قراره ازش استفاده کنید، در جای درست و به روش درست ازش استفاده کنید (از اسکرپ کردن خیلی راحت میشه سو استفاده ها کرد :) ) .

خلاصه اگه سوالی، پیشنهادی چیزی داشتید تو کامنت برام بنویسید. همچنین، من تمام کدهایی که نوشتیم + خروجی اون‌هارو تو ریپازیتوری گیت‌هابم هم قرار دادم که می‌تونید ازشون استفاده کنید.


- منبع