DevGang
Авторизоваться

Создание интерфейса командной строки Node с помощью Enquirer

Хотя конечные пользователи чаще видят графический интерфейс пользователя (GUI), интерфейс командной строки (CLI) может быть невероятно ценным дополнением к инструментам разработчика и быстрым проектам в целом.

Иногда интерфейс командной строки обеспечивает даже лучший опыт, чем графический интерфейс.

В этом посте мы рассмотрим, как создать Node CLI для проверки спортивных новостей с использованием данных с ESPN.com. Мы рассмотрим несколько инструментов и библиотек с открытым исходным кодом, которые полезны для создания интерфейса командной строки, который выполняет такие функции, как раскрашивание, загрузка счетчиков и рисование рамок вокруг вывода. Мы также будем использовать конструкцию async / await, чтобы ждать, пока пользователи ответят на запросы, которые мы создаем в нашем интерфейсе командной строки. 

Настройка проекта

Примечание. Если вы предпочитаете пропустить большую часть настройки, вы можете клонировать это репо, запустить npm install и начать добавлять код в index.js

Чтобы начать наш проект, давайте создадим новый каталог, инициализируем пакет npm и создадим файл index.js, в который мы напишем весь наш код:

mkdir my-cli
cd my-cli
npm init -y
touch index.js

Отсюда давайте установим пакеты, которые мы будем использовать в нашем интерфейсе командной строки:

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

Поскольку мы будем использовать операторы import в нашем скрипте, нам нужно обязательно пометить наш пакет как module. Добавьте в свой файл package.json следующую строку:

"type": "module",

Сначала мы собираемся настроить файл index.js. Мы настроим основную функцию, в которой мы сможем в конечном итоге запустить некоторый асинхронный код и остальную часть нашего интерфейса командной строки. Мы можем импортировать каждую из наших зависимостей по мере их использования:

// index.js

const runCli = async () => {
  // Welcome user
  console.log("Thanks for consuming sports headlines responsibly!");

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

runCli();

Вы можете запустить CLI с узлом ./index.js чтобы увидеть журналы, которые мы только что добавили.

Получение отображаемых данных

Теперь, когда мы выложили наш каркас, давайте добавим некоторые функции.

Мы импортируем библиотеку для получения заголовков ESPN, а затем сделаем первый вызов, чтобы их получить. Поскольку это асинхронный вызов для получения данных, пользователю придется дождаться завершения вызова, прежде чем увидеть какую-либо информацию. Было бы полезно указать пользователю, что CLI загружается.

Для этого мы будем использовать библиотеку под названием ora, чтобы показать анимированный индикатор загрузки, подобный этому:

Пример счетчика загрузки ora
Пример счетчика загрузки ora

Прежде чем сделать наш асинхронный вызов, мы запустим счетчик. Как только вызов будет выполнен, мы можем остановить счетчик:

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");
...

При запуске интерфейса командной строки node ./index.js  отображается краткий счетчик / загрузчик во время выборки данных. Когда у нас появятся данные, мы увидим галочку. Обратите внимание, что текст меняется на то, что мы передали функции .succeed. Довольно круто!!

Используя только что полученные данные $homepage, мы возьмем все заголовки, которые нам понадобятся для отображения пользователям позже. Опять же, здесь мы будем использовать наши вспомогательные функции:

...
  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);
    });
  }
...

Теперь мы собрали и сохранили всю информацию, которую хотим показывать пользователям: спорт и большой список заголовков на выбор. Мы будем использовать их позже.

Объявление параметров интерфейса командной строки

В нашем интерфейсе командной строки мы дадим пользователям на выбор несколько различных типов опций: статья для чтения, конкретный вид спорта, заголовки для которого нужно увидеть, и тип MORE, чтобы увидеть больше заголовков для определенного вида спорта.

Это может быть хорошим кандидатом для использования TypeScript, поскольку выборки будут вести себя по-разному в зависимости от их типа (например, заголовок, спорт, выход). В этом руководстве мы просто используем vanilla JavaScript, поэтому мы сами займемся незначительной обработкой типов.

Давайте объявим переменные, чтобы мы могли обрабатывать вводимые пользователем данные по-разному в зависимости от типа выбора. Мы также сделаем несколько общих вариантов на выбор пользователя, независимо от того, какие заголовки доступны в любой день.

Мы вставим следующий код сразу после нашего предыдущего цикла for, который мы добавили:

...
  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" },
  };
...

Поскольку это интерфейс командной строки, мы будем запрашивать у пользователя информацию снова и снова, пока он не решит выйти. Это хорошо подходит для цикла while, в котором мы можем указать сделанный пользователем выбор. Сохраним выделение и заголовок выделения:

...
  let selection;
  let selectionTitle;
  let articleText;
  let currentPrompt;
  let exit = false;
  while(!exit) {
    // Where we'll handle the user's selection
  }
  console.log("Thanks for using the ESPN cli!");
...

Каждый раз, когда пользователь делает выбор, мы сохраняем выбор, возвращаемся к нашему циклу, а затем обработаем сделанный ранее выбор. Есть несколько разных случаев, поэтому давайте рассмотрим их один за другим.

Если бы вы сейчас запустили CLI, цикл while был бы бесконечным, потому что мы не говорим ему о выходе и не позволяем пользователю вводить данные. Если это произойдет с вами, нажмите ctrl + c  или закройте окно терминала. Теперь, когда это позади, давайте сделаем нашу первую подсказку.

Создание запросов с помощью Enquirer

Мы будем использовать Enquirer, библиотеку, которая позволяет нам показывать что-то в консоли, а затем ждать ввода пользователя. Как мы скоро увидим, у него очень простой API.

Начнем с импорта в начало нашего файла:

import ora from "ora";
import enquirer from "enquirer";
...

Мы можем использовать его в нашем цикле while, создав первое приглашение для отображения пользователю. Он спросит пользователя, какую историю с домашней страницы он хотел бы прочитать, а затем предложит несколько вариантов:

  1. Прочтите один из заголовков главной страницы в списке
  2. Перечислите различные виды спорта, о которых можно будет увидеть заголовки.
  3. Выйти из приложения
...
      while(!exit) {
        if (!selection || selection.title === genericOptions.HOMEPAGEHEADLINES.title) {
          currentPrompt = new enquirer.Select({
            name: "homepage",
            message: "What story shall we read?",
            choices: [...homepageHeadlines.map(item => item.title), genericOptions.LISTSPORTS.title, genericOptions.EXIT.title]
          });
        }
        selectionTitle = await currentPrompt.run();
      }
    ...

В качестве значения choices мы предоставляем массив строк, из которых пользователь может выбирать. Ниже этого блока if мы фактически запускаем приглашение и сохраняем выбор пользователя как файл selectionTitle. Нам понадобится больше информации, чем просто заголовок, поэтому давайте объявим несколько переменных, которые будут искать выбор на основе заголовка выбора:

...
    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!");
...

Эти переменные в основном объединяют все наши различные типы опций в один большой массив выборов. Из этого массива allOptions мы можем найти тот, заголовок который совпадает с выбором нашего пользователя.

Теперь, когда мы обрабатываем только один тип выбора, наш интерфейс командной строки покажет исходные заголовки домашней страницы. Это шаг в правильном направлении! Однако, если вы пробежитесь по нему сейчас, вы застрянете в бесконечном цикле, как только сделаете свой первый выбор, что неинтересно.

Выход из интерфейса командной строки

Теперь займемся другим типом выбора: выходом. Мы могли бы проверить, что пользователь выбрал для выхода selection?.title === genericOptions.EXIT.title, но также было бы полезно иметь это в качестве последнего бита наших блоков if...else, чтобы мы могли избавиться от наших бесконечных циклов, прежде чем обрабатывать все другие типы выбора. Мы добавим наш блок выхода в конец нашего цикла 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 {
      exit = true;
      break;
    }
...

Теперь, если пользователь решит выйти или если он выберет выбор, который еще не обработан, интерфейс командной строки немедленно закроется. Больше никаких бесконечных циклов!

Обработка выбора других пользователей

Мы уже показываем заголовки на главной странице, поэтому теперь мы также можем показывать заголовки для других видов спорта, если это то, что выбирает пользователь.

Давайте добавим еще один блок, чтобы справиться с этим:

...
    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;
    }
...

Как только пользователь выбирает конкретный вид спорта, мы хотим, чтобы он мог видеть заголовки об этом виде спорта. Поэтому мы сделаем что-то подобное, когда показывали заголовки на главной странице, но на этот раз они нам нужны только для выбранного вида спорта. Мы можем вставить еще один блок if else непосредственно перед нашим блоком else, где мы выходим из CLI:

...
    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;
    }
...

Стилизация журналов с помощью boxen

Наконец, мы действительно можем показать текст статьи, если пользователь выбирает заголовок для чтения. Поскольку это статья, мы можем добавить к ней немного стиля, мы можем добавить к нему немного стиля, чтобы это был не просто старый текст.

Сначала импортируйте библиотеку с именем boxen в верхней части файла:

import ora from "ora";
import enquirer from "enquirer";
import boxen from "boxen";
...

Это позволит нам обвести текст статьи, который мы показываем, рамкой. Вставьте еще один if else перед нашим последним блоком else. Мы выйдем из некоторого текста, используя стиль из boxen, прежде чем показывать другое приглашение:

 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 {
...

Теперь интерфейс командной строки позволяет пользователям видеть заголовки на первой странице, список видов спорта, заголовки для конкретных видов спорта и читать статьи для заголовков. Выглядит неплохо! Нам нужно добавить два последних штриха, прежде чем мы закончим.

Очистка подсказок Enquirer

Если вы пробежитесь по CLI несколько раз, вы заметите, что подсказки как бы накапливаются друг на друге. Они остаются в консоли, но не представляют там особой ценности. Поскольку мы сохраняем приглашение каждый раз, мы можем использовать Enquirer, чтобы очистить его. В начале нашего цикла while мы можем вызвать .clear() на currentPrompt. Мы будем использовать необязательную цепочку, чтобы не возникало ошибок при первом выполнении:

...
  while(!exit) {
    currentPrompt?.clear();
...

Последнее, что мы добавим в наш CLI, - это журнал в самом начале, сообщающий пользователям, сколько раз они использовали CLI за день. Это будет полезно, потому что это, как правило, не продуктивный CLI, поэтому это поможет вам понять, насколько вы отвлекаетесь. Может быть, вы поймете, что вам следует больше сосредоточиться на своей работе.

Для более полезных интерфейсов командной строки, таких как инструменты разработчика, эта функция также может показать вам, насколько ценным или незаменимым является этот инструмент. Затем, конечно, если вы понимаете, что запускаете один и тот же сценарий 100 раз в день, это может означать, что вам следует включить его функциональность во что-то, что работает само по себе, без вмешательства человека.

Добавление цвета в журналы консоли

Для этого мы сохраним количество ежедневных выполнений, используя модуль node-localstorage, который мы импортируем здесь. Это будет вести себя так же, как Window.localstorage, поэтому мы можем использовать аналогичный API. Мы могли бы также использовать здесь базу данных, если бы нам это было нужно, но это должно сработать.

В зависимости от того, сколько раз мы запускали CLI, мы хотим отображать наш журнал другим цветом, поэтому мы импортируем нашу последнюю библиотеку Chalk, чтобы изменить цвет текста:

import ora from "ora";
import enquirer from "enquirer";
import boxen from "boxen";
import { LocalStorage } from "node-localstorage";
const localStorage = new LocalStorage("./scratch"); // scratch is the name of the directory where local storage is saved, this can be change to whatever you'd like
import chalk from "chalk";
...

Теперь мы можем создать новую функцию, в которой мы будем обрабатывать всю логику ежедневного использования, используя Chalk для изменения цвета журнала и локального хранилища для подсчета ежедневного использования. Мы вызовем его в начале нашей основной функции:

...
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!");
...

Обзор конечного продукта

Теперь мы обрабатываем все случаи, используя все установленные библиотеки. Ваш окончательный сценарий должен выглядеть примерно так:

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();

Вы также можете найти, как должен выглядеть код здесь.

Заключение

Все готово! Наш интерфейс командной строки покажет нам заголовки домашней страницы ESPN, перечислит доступные виды спорта и позволит нам читать статьи. Мы предоставили пользователю подсказки, отобразили анимированный счетчик во время загрузки, цветной вывод на консоль и нарисовали поля вокруг некоторого текста консоли.

Мы познакомились с несколькими различными утилитами Node CLI, но есть еще несколько, которые вы можете изучить и поиграть. Интерфейсы командной строки могут быть чрезвычайно полезны для продуктивности, и они могут быть просто забавными! Поэкспериментируйте с тем, что мы сделали в этом уроке, и посмотрите, как вы можете превратить это во что-то полезное для вас!

Источник:

#NodeJS
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

Поделитесь своим опытом, расскажите о новом инструменте, библиотеке или фреймворке. Для этого не обязательно становится постоянным автором.

Попробовать

В подарок 100$ на счет при регистрации

Получить