ساخت یک CLI با Node.js

Creating A Node.js CLI

ساخت یک CLI برای Node.js

زمانی که قصد ساخت برنامه ای را داریم معمولا از GUI یا «رابط گرافیکی» استفاده می کنیم اما برخی از اوقات نیاز به ساخت برنامه هایی داریم که از command-line interface یا «رابط خط فرمان» استفاده می کنند. شما می توانید با CLI کارهای روزمره و تکراری خود را آسان و خودکار کنید، همچنین می توانید برای دیگر توسعه دهندگان ابزارهایی را بسازید که با استفاده از آن ها کارشان را سرعت ببخشند.

پیش نیاز: برای مطالعه این مقاله آشنایی متوسط به بالا با node.js الزامی است چرا که من دستورات استفاده شده را توضیح نمی دهم. این مقاله برای یادگیری ساخت CLI است و ربطی به Node.js ندارد. همچنین اگر از ویندوز استفاده می کنید باید Git for Windows را نصب کنید تا به دستور های bash نیز دسترسی داشته باشید.

در این مقاله می خواهیم یک CLI مختلف را با استفاده از node.js بسازیم تا به طور کامل بر روند اجرای کار مسلط شده و آن را درک کنید.

پروژه دریافت خبر از ESPN

چرا از Node.js برای ساخت CLI خودمان استفاده کنیم؟ یکی از نقاط قوت اصلی node.js این است که تعداد پکیج های بسیار زیادی برای آن ساخته شده است (نزدیک به ۱ میلیون پکیج در وب سایت npm) بنابراین با نوشتن CLI خود با node.js می توانید از قدرت این زبان و پکیج های فراوان آن استفاده کنید.

ESPN.com یک وب سایت ورزشی است که در زمینه گزارش اخبار ورزشی فعالیت دارد. قدم اول برای ما راه اندازی محیط اولیه پروژه و نصب پکیج های مورد نظر است بنابراین یک آدرس مناسب در سیستم خود را انتخاب کرده و ترمینال را (gitbash برای کاربران ویندوز) در آن بخش باز کنید. پس از انجام این کار دستورات زیر را به ترتیب در آن اجرا کنید:

mkdir my-cli

cd my-cli

npm init -y

touch index.js

با انجام این کار پروژه npm خود را درون پوشه جدیدی به نام my-cli آغاز کرده ایم و فایلی به نام index.js را نیز درون آن ساخته ایم. در مرحله بعدی باید پکیج های مورد نیازمان را نصب کنیم:

npm install --save enquirer boxen ora chalk node-localstorage @rwxdev/espn

بگذارید وظیفه این پکیج ها را برایتان توضیح بدهم:

enquirer: این پکیج به ما اجازه می دهد ورودی های پیشرفته ای را از کاربر در ترمینال داشته باشیم. همانطور که می دانید محیط ترمینال بسیار ساده است اما enquirer به ما اجازه می دهد انواع و اقسام input ها را تعریف کنیم تا کاربر به راحتی از آن ها استفاده کند. تصویر زیر چند نوع از این input ها را نشان می دهد:

مثالی ساده از استفاده از enquirer
مثالی ساده از استفاده از enquirer

boxen: این پکیج به شما اجازه می دهد یک باکس (کادر مستطیل یا مربع) را در خروجی هایتان بکشید.

ora: این پکیج به شما اسپینر (spinner) می دهد. اسپینر نمادی است که دارای انیمیشن بوده و به کاربر می گوید که در حال بارگذاری چیزی هستیم.

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

node-localstorage: این پکیج به شما اجازه می دهد localStorage که مخصوص مرورگرها می باشد را در محیط Node.js نیز استفاده کنید.

@rwxdev/espn: یک ابزار CLI برای دریافت آمار لیگ NBA از ESPN

ما می خواهیم از دستور import های ES6 (شناخته شده با نام ES Modules) در اسکریپت خودمان استفاده کنیم بنابراین باید فایل package.json را باز کرده و این مسئله را در آن مشخص کنیم:

"type": "module",

در حال حاضر باید package.json شما بدین شکل باشد:

{

  "name": "my-cli",

  "version": "1.0.0",

  "description": "",

  "main": "index.js",

  "type": "module",

  "scripts": {

    "test": "echo \"Error: no test specified\" && exit 1"

  },

  "keywords": [],

  "author": "Amir ZM",

  "license": "ISC",

  "dependencies": {

    "@rwxdev/espn": "^0.0.2",

    "boxen": "^5.0.1",

    "chalk": "^4.1.1",

    "enquirer": "^2.3.6",

    "node-localstorage": "^2.1.6",

    "ora": "^5.4.0"

  }

}

در قدم اول فایل index.js را باز کرده و یک تابع ساده و async را در آن می نویسیم:

// index.js




const runCli = async () => {

    // دو پیام ساده را به عنوان خوش آمد گویی چاپ می کنیم

    console.log("Thanks for consuming sports headlines responsibly!");




    console.log("Thanks for using the ESPN cli!");

    return;

}




runCli();

ما با این کار ساختار اولیه برنامه را تنظیم کرده ایم.

استفاده از Ora برای نمایش انیمیشن

در این مرحله می خواهیم درخواستی را به سایت ESPN ارسال کرده و تیترهای خبر آن را دریافت کنیم. از آنجایی که این فرآیند async (ناهمگام) است کاربران باید منتظر دریافت داده ها از سمت ESPN باشند. از آنجایی که این مدت می تواند کمی طول بکشد از کتابخانه Ora برای ایجاد انیمیشن و نمایش حالت loading (همان spinner ها) استفاده می کنیم.

در قدم اول بیایید import های مربوط به پکیج هایمان را انجام بدهیم:

// index.js

import ora from "ora";

import {

    getArticleText,

    getHeadlines,

    getPageContents,

    getSports,

} from "@rwxdev/espn";




const homepageUrl = "https://espn.com/";




const runCli = async () => {

    // دو پیام ساده را به عنوان خوش آمد گویی چاپ می کنیم

    console.log("Thanks for consuming sports headlines responsibly!");




    console.log("Thanks for using the ESPN cli!");

    return;

}




runCli();

قبلا هم توضیح دادم که پکیج rwxdev/espn برای دریافت اخبار و پکیج ora برای نمایش اسپینر است. من از این بخش به بعد برای راحت تر شدن درک کدها آن ها را می شکنم بنابراین نگران ناقص بودن آن ها نباشید. در مرحله بعدی اسپینر خودمان را شروع می کنیم:

// index.js

import ora from "ora";

import {

    getArticleText,

    getHeadlines,

    getPageContents,

    getSports,

} from "@rwxdev/espn";




const homepageUrl = "https://espn.com/";




const runCli = async () => {

    // دو پیام ساده را به عنوان خوش آمد گویی چاپ می کنیم

    console.log("Thanks for consuming sports headlines responsibly!");

    const spinner = ora("Getting headlines...").start();

    const $homepage = await getPageContents(homepageUrl);

    spinner.succeed("ESPN headlines received");




    console.log("Thanks for using the ESPN cli!");

    return;

}




runCli();

صدا زدن متد ora باید با یک متن اولیه همراه باشد که من Getting headlines را انتخاب کرده ام و سپس متد start را صدا می زنیم. این متد باعث نمایش یک اسپینر می شود. در خط بعدی متد  getPageContents را صدا زده و آدرس سایت ESPN را به آن داده ایم. این متد تیتر خبر ها را دریافت می کند. در نهایت متد succeed را روی اسپینر خودمان صدا زده ایم تا یک تیک سبز رنگ به نشانه تمام شدن عملیات نمایش داده شود:

Thanks for consuming sports headlines responsibly!

✔ ESPN headlines received

Thanks for using the ESPN cli!

با اینکه انیمیشن جالب خودمان را داریم اما هنوز داده های دریافت شده را به کاربر نشان نمی دهیم. برای انجام این کار ابتدا باید در نظر بگیریم که کاربران چه نوع خبر ورزشی را می خواهند. ورزش های مختلفی وجود دارد و هر کدام خبر های خاص خودشان را دارند بنابراین بهتر است ابتدا دسته بندی خبر ها را دریافت کنیم و سپس تیترهای اصلی هر کدام را بگیریم:

const homepageUrl = "https://espn.com/";




const runCli = async () => {

    // دو پیام ساده را به عنوان خوش آمد گویی چاپ می کنیم

    console.log("Thanks for consuming sports headlines responsibly!");

    const spinner = ora("Getting headlines...").start();

    const $homepage = await getPageContents(homepageUrl);

    spinner.succeed("ESPN headlines received");




    const homepageHeadlines = getHeadlines($homepage);

    const sports = getSports($homepage);

    const headlinesBySport = {};

    for (let sport of sports) {

        getPageContents(sport.href).then(($sportPage) => {

            const headlines = getHeadlines($sportPage);

            headlinesBySport[sport.title] = headlines;

        }).catch((e) => {

            console.log("there was an issue getting headlines for a certain sport", e);

        });

    }




    console.log("Thanks for using the ESPN cli!");

    return;

}

تابع getSports ورزش های خاص را از هم جدا کرده و به ما می دهد. با این حساب می توانیم با یک حلقه for بین تیترها گردش کرده و آن ها را به همراه نوع ورزش در شیء headlinesBySport قرار بدهیم.

تعریف گزینه های CLI

ما در CLI خودمان می خواهیم چند گزینه ساده را به کاربران بدهیم. گزینه اول خواندن مقاله مربوط به یک سر تیتر است، گزینه دوم مشاهده تیتر خبر ها برای یک ورزش خاص است و گزینه سوم More (به معنی «بیشتر») خواهد بود تا به کاربر اجازه بدهیم سرتیترهای بیشتری را ببیند. ما باید در این بخش متغیرهایی را تعریف کنیم که بر اساس ورودی کاربر (انتخاب های او) رفتار متفاوتی را نمایش بدهیم:

const runCli = async () => {

    // دو پیام ساده را به عنوان خوش آمد گویی چاپ می کنیم

    console.log("Thanks for consuming sports headlines responsibly!");

    const spinner = ora("Getting headlines...").start();

    const $homepage = await getPageContents(homepageUrl);

    spinner.succeed("ESPN headlines received");




    const homepageHeadlines = getHeadlines($homepage);

    const sports = getSports($homepage);

    const headlinesBySport = {};

    for (let sport of sports) {

        getPageContents(sport.href).then(($sportPage) => {

            const headlines = getHeadlines($sportPage);

            headlinesBySport[sport.title] = headlines;

        }).catch((e) => {

            console.log("there was an issue getting headlines for a certain sport", e);

        });

    }




    const selectionTypes = {

        HEADLINE: "headline",

        SPORT: "sport",

        MORE: "more"

    };

    const genericOptions = {

        HOMEPAGE_HEADLINES: { title: "see homepage headlines" },

        LIST_SPORTS: { title: "see headlines for specific sports", type: selectionTypes.MORE },

        OTHER_SPORTS: { title: "see headlines for other sports", type: selectionTypes.MORE },

        EXIT: { title: "exit" },

    };




    console.log("Thanks for using the ESPN cli!");

    return;

}

متغیر selectionTypes انواع انتخاب های ممکن برای کاربر را مشخص می کند:

  • HEADLINE یا تیتر خبر
  • SPORT یا ورزشی خاص
  • MORE یا مشاهده بیشتر

در مرحله بعدی یک شیء به نام genericOptions را داریم. این شیء دارای خصوصیت است که در واقع همان گزینه هایی هستند که به کاربر نمایش خواهیم داد. هر کدام از این خصوصیات مانند HOMEPAGE_HEADLINES دارای یک تگ title هستند که نشان می دهد عنوان آن گزینه چه خواهد بود.

CLI ما آنقدر اجرا خواهد شد تا اینکه کاربر exit را انتخاب کرده و از CLI خارج شود. این تکرار بدین معنی است که می توانیم از یک حلقه while استفاده کنیم:

const runCli = async () => {

    // دو پیام ساده را به عنوان خوش آمد گویی چاپ می کنیم

    console.log("Thanks for consuming sports headlines responsibly!");

    const spinner = ora("Getting headlines...").start();

    const $homepage = await getPageContents(homepageUrl);

    spinner.succeed("ESPN headlines received");




    const homepageHeadlines = getHeadlines($homepage);

    const sports = getSports($homepage);

    const headlinesBySport = {};

    for (let sport of sports) {

        getPageContents(sport.href).then(($sportPage) => {

            const headlines = getHeadlines($sportPage);

            headlinesBySport[sport.title] = headlines;

        }).catch((e) => {

            console.log("there was an issue getting headlines for a certain sport", e);

        });

    }




    const selectionTypes = {

        HEADLINE: "headline",

        SPORT: "sport",

        MORE: "more"

    };

    const genericOptions = {

        HOMEPAGE_HEADLINES: { title: "see homepage headlines" },

        LIST_SPORTS: { title: "see headlines for specific sports", type: selectionTypes.MORE },

        OTHER_SPORTS: { title: "see headlines for other sports", type: selectionTypes.MORE },

        EXIT: { title: "exit" },

    };




    let selection;

    let selectionTitle;

    let articleText;

    let currentPrompt;

    let exit = false;

    while (!exit) {

        // در این بخش باید انتخاب کاربر را مدیریت کنیم

    }




    console.log("Thanks for using the ESPN cli!");

    return;

}

به نظر شما در حلقه بالا چه چیزی بنویسیم؟ من در ابتدا چند متغیر خالی را تعریف کرده ام که درون این حلقه while استفاده خواهند شد و انتخاب های کاربر و نتایج آن را در بر خواهند داشت. در واقع هر زمانی که کاربر گزینه ای را انتخاب می کند ما آن را ذخیره می کنیم، سپس دوباره از ابتدای حلقه شروع کرده و آن انتخاب را پردازش می کنیم. تا زمانی که متغیر exit روی false باشد ما به گردش در حلقه ادامه می دهیم.

هشدار: در حال حاضر که درون حلقه while چیزی نداریم،‌ CLI را اجرا نکنید چرا که در یک حلقه بی نهایت گیر می کنید. در صورتی که چنین اتفاقی افتاد،‌ کلید های Ctrl + C را بزنید تا پروسه فعال در ترمینال متوقف شود.

انتخاب های کاربر در این زمینه زیاد است بنابراین بهتر است قدم به قدم جلو برویم.

قدم اول: ساخت prompt و نمایش تیترها

احتمالا می پرسید prompt چیست؟ در دنیای ترمینال ها و command line ها کلمه prompt به دو چیز اشاره می کند:

  • بخش وضعیت در ترمینال که معمولا نام کاربری و نام کامپیوتر شما را دارد (مثال $~:amir@AmirPC).
  • سوالی که در ترمینال از یک کاربر پرسیده می شود prompt نام دارد.

منظور ما از prompt در اینجا مورد دوم است، یعنی می خواهیم با پکیج Enquirer اولین سوال را از کاربر بپرسیم. با این حساب اولین کاری که می کنیم وارد کردن این پکیج به فایل index.js است:

// index.js

import ora from "ora";

import enquirer from "enquirer";

import {

    getArticleText,

    getHeadlines,

    getPageContents,

    getSports,

} from "@rwxdev/espn";

// بقیه کدها

حالا باید از این پکیج در حلقه while استفاده کنیم تا از کاربر بپرسیم چه نوع مقالاتی را می خواهد بخواند و سپس گزینه های زیر را به او نمایش بدهیم:

  • خواندن یکی از تیترهای اصلی در لیست
  • نمایش ورزش های مختلف و مطالعه خبر های مربوط به آن ورزش خاص
  • خروج از برنامه

برای انجام این کار به شکل زیر عمل می کنیم:

// بقیه کدها

let selection;

let selectionTitle;

let articleText;

let currentPrompt;

let exit = false;




while (!exit) {

    if (!selection || selection.title === genericOptions.HOMEPAGE_HEADLINES.title) {

        currentPrompt = new enquirer.Select({

            name: "homepage",

            message: "What story shall we read?",

            choices: [...homepageHeadlines.map(item => item.title), genericOptions.LIST_SPORTS.title, genericOptions.EXIT.title]

        });

    }

    selectionTitle = await currentPrompt.run();

}

// بقیه کدها

در این حلقه گفته ایم اگر متغیر selection خالی بود یا false بود یا خصوصیت title آن برابر با title در HOMEPAGE_HEADLINES (انتخاب عناوین خبری) بود باید کار خاصی را انجام بدهیم. ابتدا متد select از کتابخانه enquirer را صدا می زنیم و خصوصیات مورد نیاز را به آن می دهیم. خصوصیت اول name یا نام گیزنه است، خصوصیت دوم message یا پیامی است که برای آن گزینه نمایش داده می شود و گزینه سوم choices یا انتخاب های موجود است. ما در اینجا چندین گزینه را برای کاربر در نظر گرفته ایم. گزینه اول homepageHeadlines یا تیترهای خبری دریافت شده از سایت ESPN است که با تابع map پخش کرده ایم. گزینه بعدی نمایش انواع ورزش ها و گزینه آخر خروج از برنامه است.

طبیعتا این برای کار با انتخاب های کاربر کافی نیست بنابراین ادامه می دهیم:

// بقیه کدها

while (!exit) {

    if (!selection || selection.title === genericOptions.HOMEPAGE_HEADLINES.title) {

        currentPrompt = new enquirer.Select({

            name: "homepage",

            message: "What story shall we read?",

            choices: [...homepageHeadlines.map(item => item.title), genericOptions.LIST_SPORTS.title, genericOptions.EXIT.title]

        });

    }




    selectionTitle = await currentPrompt.run();

    const combinedSportHeadlines = Object.values(headlinesBySport).reduce((accumulator, item) => {

        return [...accumulator, ...item];

    }, [])

    const allOptions = [...Object.values(genericOptions), ...homepageHeadlines, ...sports, ...combinedSportHeadlines];

    selection = allOptions.find(item => item.title === selectionTitle);

}

// بقیه کدها

ما در دو خصوصیت selectionTitle و allOptions تمام گزینه های مختلف را دریافت کرده و آن ها را در یک آرایه بزرگ قرار داده ایم. چرا؟ به دلیل اینکه در ادامه کاربر باید یکی از این گزینه ها (مثلا یکی از ده ها تیتر خبر دریافت شده از ESPN) را انتخاب کند و ما باید تمام این تیترها را داشته باشیم تا به عنوان گزینه به کاربر نشان بدهیم یا انتخاب او را در بین تیترهای خبر پیدا کنیم.

در حال حاضر اگر با دستور node index.js سعی در اجرا کردن CLI داشته باشید ۷ تیتر خبری برایتان نمایش داده می شود، گزینه هشتم نمایش انواع ورزش ها و گزینه نهم خروج از CLI است:

عناوین اخبار نمایش داده شده است.
عناوین اخبار نمایش داده شده است.

هیچ کدام از این گزینه ها در حال حاضر کار نمی کنند و اگر هر کدام را انتخاب کنید وارد یک حلقه نامتناهی می شوید بنابراین Ctrl + C را بزنید تا ترمینال متوقف شود.

قدم دوم: خروج از CLI

از آنجایی که خارج شدن از CLI از دیگر گزینه ها ساده تر است بیایید ابتدا آن را پیاده سازی کنیم. سوال من از شما این است که چطور می توانیم انتخاب گزینه exit (خروج) را توسط کاربر تشخیص بدهیم؟ ساده است! باید یک شرط ساده و به شکل زیر داشته باشیم:

selection?.title === genericOptions.EXIT.title

با اینکه این کار مشکلی ندارد و کاملا پاسخگوی ما است اما من ترجیح می دهم که عملیات exit را به شکل if ... else بنویسم تا هم کدهایمان کوتاه تر و خوانا تر باشد و هم اینکه از شر حلقه نامتناهی خود خلاص شویم:

while (!exit) {

    if (!selection || selection.title === genericOptions.HOMEPAGE_HEADLINES.title) {

        currentPrompt = new enquirer.Select({

            name: "homepage",

            message: "What story shall we read?",

            choices: [...homepageHeadlines.map(item => item.title), genericOptions.LIST_SPORTS.title, genericOptions.EXIT.title]

        });

    } else {

        exit = true;

        break;

    }




    selectionTitle = await currentPrompt.run();

    const combinedSportHeadlines = Object.values(headlinesBySport).reduce((accumulator, item) => {

        return [...accumulator, ...item];

    }, [])

    const allOptions = [...Object.values(genericOptions), ...homepageHeadlines, ...sports, ...combinedSportHeadlines];

    selection = allOptions.find(item => item.title === selectionTitle);

}

حالا می توانید CLI را در ترمینال اجرا کنید. با انتخاب گزینه exit به راحتی از CLI خارج خواهید شد.

قدم سوم: مدیریت انتخاب های دیگر کاربر

ما در حال حاضر تیتر خبر های ورزشی را نشان می دهیم بنابراین نشان دادن اخبار برای یک ورزش خاص نباید کار سختی باشد. اگر کاربر گزینه ورزش ها را انتخاب کرد باید لیستی از ورزش های موجود را نشان بدهیم. ما قبلا این لیست را با متد ()getSports گرفته بودیم بنابراین فقط کافی است که یک بلوک else if دیگر به شرط خودمان اضافه کنیم:

while (!exit) {

    if (!selection || selection.title === genericOptions.HOMEPAGE_HEADLINES.title) {

        currentPrompt = new enquirer.Select({

            name: "homepage",

            message: "What story shall we read?",

            choices: [...homepageHeadlines.map(item => item.title), genericOptions.LIST_SPORTS.title, genericOptions.EXIT.title]

        });

    }

    else if (selection.type === selectionTypes.MORE) {

        currentPrompt = new enquirer.Select({

            name: "sports",

            message: "Which sport would you like headlines for?",

            choices: sports.map(choice => choice.title)

        });

    }

    else {

        exit = true;

        break;

    }




    selectionTitle = await currentPrompt.run();

    const combinedSportHeadlines = Object.values(headlinesBySport).reduce((accumulator, item) => {

        return [...accumulator, ...item];

    }, [])

    const allOptions = [...Object.values(genericOptions), ...homepageHeadlines, ...sports, ...combinedSportHeadlines];

    selection = allOptions.find(item => item.title === selectionTitle);

}

در اینجا اگر انتخاب کاربر برابر MORE باشد، متد map را روی آرایه sports اجرا می کنیم تا تمام ورزش ها در قالب یک آرایه برایمان برگردانده شوند. با این کار کاربر می تواند آن ها را انتخاب کند. در مرحله بعدی کاربر یکی از این ورزش ها را انتخاب خواهد کرد و ما هم باید تیترهای خبری را فقط از آن ورزش نمایش بدهیم. برای این کار نیاز به اضافه کردن یک بلوک else if دیگر داریم:

while (!exit) {

    if (!selection || selection.title === genericOptions.HOMEPAGE_HEADLINES.title) {

        currentPrompt = new enquirer.Select({

            name: "homepage",

            message: "What story shall we read?",

            choices: [...homepageHeadlines.map(item => item.title), genericOptions.LIST_SPORTS.title, genericOptions.EXIT.title]

        });

    }

    else if (selection.type === selectionTypes.MORE) {

        currentPrompt = new enquirer.Select({

            name: "sports",

            message: "Which sport would you like headlines for?",

            choices: sports.map(choice => choice.title)

        });

    }

    else if (selection.type === selectionTypes.SPORT) {

        const sportHeadlines = headlinesBySport[selection.title];

        const sportChoices = sportHeadlines.map(option => option.title);

        currentPrompt = new enquirer.Select({

            name: "sportHeadlines",

            message: `Select a ${selection.title} headline to get article text`,

            choices: [...sportChoices, genericOptions.HOMEPAGE_HEADLINES.title, genericOptions.OTHER_SPORTS.title, genericOptions.EXIT.title]

        });

    }

    else {

        exit = true;

        break;

    }




    selectionTitle = await currentPrompt.run();

    const combinedSportHeadlines = Object.values(headlinesBySport).reduce((accumulator, item) => {

        return [...accumulator, ...item];

    }, [])

    const allOptions = [...Object.values(genericOptions), ...homepageHeadlines, ...sports, ...combinedSportHeadlines];

    selection = allOptions.find(item => item.title === selectionTitle);

}

همانطور که می بینید من ابتدا بررسی کرده ام که انتخاب کاربر selectionTypes.SPORT باشد. اگر اینطور بود آنگاه ما عنوان آن ورزش خاص را از آرایه headlinesBySport جدا می کنیم. حالا می دانیم کاربر چه ورزشی را می خواهد. در ادامه مثل همیشه از تابع map برای پخش کردن تمام عناوین خبری استفاده کرده ایم و آن ها را به کاربر نمایش داده ایم.

قدم چهارم: نمایش متن خبر

حالا که سه گزینه اصلی (دریافت تیتر اخبار، انتخاب یک ورزش خاص، خروج از برنامه) را تکمیل کرده ایم باید مکانیسمی داشته باشیم که پس از انتخاب یک تیتر خبری، متن آن را به کاربر نمایش بدهیم. ما می توانیم به راحتی متن مقاله را console.log کنیم اما خواندن یک متن خالی آنچنان جذاب نیست بنابراین من می خواهم با استفاده از پکیج boxen متن مقاله را کمی استایل دهی کنم. طبیعتا اولین قدم این است که boxen را وارد اسکریپت خود کنیم:

// index.js

import ora from "ora";

import enquirer from "enquirer";

import boxen from "boxen";

import {

    getArticleText,

    getHeadlines,

    getPageContents,

    getSports,

} from "@rwxdev/espn";

این پکیج به ما اجازه می دهد دور متن یک باکس ساده را بکشیم تا ظاهر متن بهتر باشد. برای انجام این کار یک بلوک else if را به حلقه while اضافه می کنیم:

while (!exit) {

    if (!selection || selection.title === genericOptions.HOMEPAGE_HEADLINES.title) {

        currentPrompt = new enquirer.Select({

            name: "homepage",

            message: "What story shall we read?",

            choices: [...homepageHeadlines.map(item => item.title), genericOptions.LIST_SPORTS.title, genericOptions.EXIT.title]

        });

    }

    else if (selection.type === selectionTypes.MORE) {

        currentPrompt = new enquirer.Select({

            name: "sports",

            message: "Which sport would you like headlines for?",

            choices: sports.map(choice => choice.title)

        });

    }

    else if (selection.type === selectionTypes.SPORT) {

        const sportHeadlines = headlinesBySport[selection.title];

        const sportChoices = sportHeadlines.map(option => option.title);

        currentPrompt = new enquirer.Select({

            name: "sportHeadlines",

            message: `Select a ${selection.title} headline to get article text`,

            choices: [...sportChoices, genericOptions.HOMEPAGE_HEADLINES.title, genericOptions.OTHER_SPORTS.title, genericOptions.EXIT.title]

        });

    }

    else if (selection.type === selectionTypes.HEADLINE) {

        articleText = await getArticleText(selection.href);

        console.log(boxen(selection.href, { borderStyle: 'bold' }));

        console.log(boxen(articleText, { borderStyle: 'singleDouble' }));

        currentPrompt = new enquirer.Select({

            name: "article",

            message: "Done reading? What next?",

            choices: [genericOptions.HOMEPAGE_HEADLINES.title, genericOptions.LIST_SPORTS.title, genericOptions.EXIT.title]

        });

        articleText = "";

    }

    else {

        exit = true;

        break;

    }




    selectionTitle = await currentPrompt.run();

    const combinedSportHeadlines = Object.values(headlinesBySport).reduce((accumulator, item) => {

        return [...accumulator, ...item];

    }, [])

    const allOptions = [...Object.values(genericOptions), ...homepageHeadlines, ...sports, ...combinedSportHeadlines];

    selection = allOptions.find(item => item.title === selectionTitle);

}

همانطور که می بینید من با متد getArticleText مقالات را دریافت کرده ام و همچنین با از خصوصیت های boxen مانند borderStyle (استایل باکس) و بین آدرس مقاله (URL) و متن آن تفاوت قائل شده ام. در حال حاضر اگر CLI را اجرا کنید (دستور node index.js) خواهید دید که ابتدا URL مقاله را ببینید و در باکس بعدی متن مقاله نمایش داده می شود. با این حساب همه چیز کار می کند و چند عملیات پایانی باقی مانده است.

قدم پنجم: پاک کردن متون اضافی

در حال حاضر اگر بین گزینه های مختلف در CLI جا به جا شوید، متوجه موضوع خاصی خواهید شد: پس از انتخاب یک گزینه متن آن در ترمینال باقی می ماند و اگر بین چند گزینه جا به جا شویم تمام این متون روی هم انباشته می شوند تا جایی که نیاز به اسکرول کردن خواهیم داشت. این مسئله ظاهر پروژه را خراب می کند بنابراین باید پس از انتخاب هر گزینه یک بار ترمینال را پاک کنیم. چطور می توان این کار را انجام داد؟ متدی به نام clear در ترمینال وجود دارد که صفحه را پاک می کند بنابراین ما می توانیم آن را در ابتدای حلقه while صدا بزنیم تا هر بار که کاربر گزینه ای را انتخاب کرد ترمینال پاک شود:

while (!exit) {

    currentPrompt?.clear();




    if (!selection || selection.title === genericOptions.HOMEPAGE_HEADLINES.title) {

        currentPrompt = new enquirer.Select({

            name: "homepage",

            message: "What story shall we read?",

            choices: [...homepageHeadlines.map(item => item.title), genericOptions.LIST_SPORTS.title, genericOptions.EXIT.title]

        });

    }

// بقیه کدها

قدم ششم: تعداد دفعات استفاده از CLI

هدف بعدی ما این است که به کاربر بگوییم در روز چند بار از این CLI استفاده کرده است. طبیعتا نمی توانیم مقادیر را بدین شکل در یک اسکریپت ساده ذخیره کنیم چرا که با هر بار خارج شدن از CLI تمام مموری اختصاص داده شده به اسکریپت ما پاک می شود. به همین دلیل است که می خواهیم از پکیج node-localstorage استفاده کنیم. از طرف دیگر می خواهیم بر اساس اینکه کاربر چند بار در روز از CLI استفاده کرده است، عدد را با رنگ خاصی نمایش بدهیم. اینجاست که پکیج Chalk وارد صحنه می شود و به ما اجازه می دهد رنگ های مختلفی را روی متون مختلف ترمینال تنظیم کنیم. طبیعتا قدم اول برای این کار وارد کردن هر دو پکیج در اسکریپت است:

// index.js

import ora from "ora";

import enquirer from "enquirer";

import boxen from "boxen";

import { LocalStorage } from "node-localstorage";

const localStorage = new LocalStorage("./scratch");

import chalk from "chalk";

import {

    getArticleText,

    getHeadlines,

    getPageContents,

    getSports,

} from "@rwxdev/espn";

آدرس scratch/. ای که به LocalStorage پاس داده ام یک پوشه ساده در پروژه شما است که توسط پکیج  node-localstorage ایجاد می شود. همانطور که گفتم این مقادیر نمی توانند در مموری ذخیره شوند بنابراین پکیج node-localstorage آن ها را در هارد دیسک ذخیره می کند.

در مرحله بعدی باید تابعی را تعریف کنیم که مسئول رهگیری و اضافه کردن به شمارنده تعداد دفعات استفاده از CLI باشد. من این تابع را در پایین import ها انجام می دهم:

// index.js

import ora from "ora";

import enquirer from "enquirer";

import boxen from "boxen";

import { LocalStorage } from "node-localstorage";

const localStorage = new LocalStorage("./scratch");

import chalk from "chalk";

import {

    getArticleText,

    getHeadlines,

    getPageContents,

    getSports,

} from "@rwxdev/espn";







const homepageUrl = "https://espn.com/";




const showTodaysUsage = () => {

    const dateOptions = { year: 'numeric', month: 'numeric', day: 'numeric' };

    const now = new Date();

    const dateString = now.toLocaleString('en-US', dateOptions);

    const todaysRuns = parseInt(localStorage.getItem(dateString)) || 0;

    const chalkColor = todaysRuns < 5 ? "green" : todaysRuns > 10 ? "red" : "yellow";

    console.log(chalk[chalkColor](`Times you've checked ESPN today: ${todaysRuns}`));

    localStorage.setItem(dateString, todaysRuns + 1);

}

همانطور که در کد بالا مشاهده می کنید ما ابتدا تاریخ روز را با استفاده از new Date می گیریم و سپس ورودی های آن روز را وارد localstorage می کنیم. در مرحله بعدی گفته ام اگر ورودی های روز (todaysRuns) کمتر از ۵ عدد بود رنگ سبز را نشان می دهیم، اگر بیشتر از ۱۰ عدد بود قرمز را نشان می دهیم و در غیر این صورت (بین ۵ تا ۱۰) زرد را نشان می دهیم. همانطور که می بینید chalk مجموعه ای از رنگ ها را دارد بنابراین می توانیم از کلید مورد نظر، رنگ آن را بگیریم و سپس نوشته دلخواهمان را log کنیم. setItem در انتها نیز یک مورد به دفعات استفاده شده اضافه می کند.

حالا آن را در ابتدای متد runCLI صدا می زنیم:

const runCli = async () => {

    // دو پیام ساده را به عنوان خوش آمد گویی چاپ می کنیم

    showTodaysUsage();

    console.log("Thanks for consuming sports headlines responsibly!");




    const spinner = ora("Getting headlines...").start();

    const $homepage = await getPageContents(homepageUrl);

    spinner.succeed("ESPN headlines received");




    const homepageHeadlines = getHeadlines($homepage);

    const sports = getSports($homepage);

// بقیه کدها

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

// index.js

import ora from "ora";

import enquirer from "enquirer";

import boxen from "boxen";

import { LocalStorage } from "node-localstorage";

const localStorage = new LocalStorage("./scratch");

import chalk from "chalk";

import {

    getArticleText,

    getHeadlines,

    getPageContents,

    getSports,

} from "@rwxdev/espn";







const homepageUrl = "https://espn.com/";




const showTodaysUsage = () => {

    const dateOptions = { year: 'numeric', month: 'numeric', day: 'numeric' };

    const now = new Date();

    const dateString = now.toLocaleString('en-US', dateOptions);

    const todaysRuns = parseInt(localStorage.getItem(dateString)) || 0;

    const chalkColor = todaysRuns < 5 ? "green" : todaysRuns > 10 ? "red" : "yellow";

    console.log(chalk[chalkColor](`Times you've checked ESPN today: ${todaysRuns}`));

    localStorage.setItem(dateString, todaysRuns + 1);

}




const runCli = async () => {

    // دو پیام ساده را به عنوان خوش آمد گویی چاپ می کنیم

    showTodaysUsage();

    console.log("Thanks for consuming sports headlines responsibly!");




    const spinner = ora("Getting headlines...").start();

    const $homepage = await getPageContents(homepageUrl);

    spinner.succeed("ESPN headlines received");




    const homepageHeadlines = getHeadlines($homepage);

    const sports = getSports($homepage);

    const headlinesBySport = {};

    for (let sport of sports) {

        getPageContents(sport.href).then(($sportPage) => {

            const headlines = getHeadlines($sportPage);

            headlinesBySport[sport.title] = headlines;

        }).catch((e) => {

            console.log("there was an issue getting headlines for a certain sport", e);

        });

    }




    const selectionTypes = {

        HEADLINE: "headline",

        SPORT: "sport",

        MORE: "more"

    };

    const genericOptions = {

        HOMEPAGE_HEADLINES: { title: "see homepage headlines" },

        LIST_SPORTS: { title: "see headlines for specific sports", type: selectionTypes.MORE },

        OTHER_SPORTS: { title: "see headlines for other sports", type: selectionTypes.MORE },

        EXIT: { title: "exit" },

    };




    let selection;

    let selectionTitle;

    let articleText;

    let currentPrompt;

    let exit = false;




    while (!exit) {

        currentPrompt?.clear();




        if (!selection || selection.title === genericOptions.HOMEPAGE_HEADLINES.title) {

            currentPrompt = new enquirer.Select({

                name: "homepage",

                message: "What story shall we read?",

                choices: [...homepageHeadlines.map(item => item.title), genericOptions.LIST_SPORTS.title, genericOptions.EXIT.title]

            });

        }

        else if (selection.type === selectionTypes.MORE) {

            currentPrompt = new enquirer.Select({

                name: "sports",

                message: "Which sport would you like headlines for?",

                choices: sports.map(choice => choice.title)

            });

        }

        else if (selection.type === selectionTypes.SPORT) {

            const sportHeadlines = headlinesBySport[selection.title];

            const sportChoices = sportHeadlines.map(option => option.title);

            currentPrompt = new enquirer.Select({

                name: "sportHeadlines",

                message: `Select a ${selection.title} headline to get article text`,

                choices: [...sportChoices, genericOptions.HOMEPAGE_HEADLINES.title, genericOptions.OTHER_SPORTS.title, genericOptions.EXIT.title]

            });

        }

        else if (selection.type === selectionTypes.HEADLINE) {

            articleText = await getArticleText(selection.href);

            console.log(boxen(selection.href, { borderStyle: 'bold' }));

            console.log(boxen(articleText, { borderStyle: 'singleDouble' }));

            currentPrompt = new enquirer.Select({

                name: "article",

                message: "Done reading? What next?",

                choices: [genericOptions.HOMEPAGE_HEADLINES.title, genericOptions.LIST_SPORTS.title, genericOptions.EXIT.title]

            });

            articleText = "";

        }

        else {

            exit = true;

            break;

        }




        selectionTitle = await currentPrompt.run();

        const combinedSportHeadlines = Object.values(headlinesBySport).reduce((accumulator, item) => {

            return [...accumulator, ...item];

        }, [])

        const allOptions = [...Object.values(genericOptions), ...homepageHeadlines, ...sports, ...combinedSportHeadlines];

        selection = allOptions.find(item => item.title === selectionTitle);

    }




    console.log("Thanks for using the ESPN cli!");

    return;

}




runCli();

به همین راحتی یک CLI جذاب را برای کاربران خودمان ایجاد کرده ایم!


منبع: وب سایت logrocket

نویسنده شوید
دیدگاه‌های شما (1 دیدگاه)

در این قسمت، به پرسش‌های تخصصی شما درباره‌ی محتوای مقاله پاسخ داده نمی‌شود. سوالات خود را اینجا بپرسید.

alri
02 مرداد 1400
سلام وقت بخیر. من از دنبال کنندگان همیشگی سایت شما هستم . ضمن تشکر بابت زحماتی که برای نوشتن و تهیه مقالات دارید (کار بسیار با ارزشی میکنید) ازتون یه درخواست دارم . یه syntax highlighter o خوب نصب کنید . به محض اینکه آدم به قسمت کد ها میرسه به یکباره ناخودآگاه بیخیال خوندن کد ها و ادامه دادن برای خواندن بقیه نوشته میشه . به نظر من حیفه !

در این قسمت، به پرسش‌های تخصصی شما درباره‌ی محتوای مقاله پاسخ داده نمی‌شود. سوالات خود را اینجا بپرسید.