让我们一起创建一个 DEV.to CLI…… 设置 创建 src/api.mjs JavaScript 互操作 读取缓存 解析 HTML 格式化数据 查看文章 从缓存中查看帮助文章 解析文章 阅读文章 显示文章 HTML 转文本 收尾工作 所以你决定直接跳到最后?关于 CLI 的警告 Hacktoberfest 快乐!

2025-06-07

让我们一起创建一个 DEV.to CLI...

设置

创建 src/api.mjs

JavaScript 互操作

读取缓存

解析 HTML

格式化数据

查看文章

查看帮助

文章来自Cache

解析文章

阅读文章

显示文章

HTML 到文本

最后的润色

所以你决定直接跳到最后?

关于 CLI 的警告

Hacktoberfest 快乐!

为了 hacktoberfest,我将为 DEV.to 制作一个 CLI...让我们一起做吧!

devto CLI 演示

这是一个跟随式的教程……所以请跟着学习。但如果你觉得自己已经很优秀了,学不到什么新东西,可以直接跳到最后。

如果我跳过某些内容太快而您想要更多解释,请在评论中询问我!

设置

因为是我开车,所以语言由我来选择。我当然会用MojiScript 。


git clone https://github.com/joelnet/mojiscript-starter-app.git devto-cli
cd devto-cli
npm ci

DEV.to 没有 API。所有没有 API 的网站会怎么样?它们会被抓取!

# install axios
npm install --save-prod axios

添加 axios 依赖项index.mjs

import log from 'mojiscript/console/log'
import run from 'mojiscript/core/run'
import axios from 'mojiscript/net/axios'
import main from './main'

const dependencies = {
  axios,
  log
}

run ({ dependencies, main })

创建 src/api.mjs

创建一个新文件src/api.mjs来包含我们的抓取 API。我们使用mojiscript/net/axios,它是 的柯里化版本axios

import pipe from 'mojiscript/core/pipe'

const getData = response => response.data

export const getUrl = axios => pipe ([
  url => axios.get (url) ({}),
  getData
])

export const getDevToHtml = axios => pipe ([
  () => getUrl (axios) ('https://dev.to')
])

导入getDevToHtmlmain.mjs

import pipe from 'mojiscript/core/pipe'
import { getDevToHtml } from './api'

const main = ({ axios, log }) => pipe ([
  getDevToHtml (axios),
  log
])

export default main

现在运行代码:

npm start

如果一切顺利,您应该会看到一堆 HTML 充斥着控制台。

JavaScript 互操作

现在我不想在每次调试代码时都用 HTTP 调用来攻击 DEV.to,所以让我们将该输出缓存到文件中。

# this will get you the same version in this tutorial
curl -Lo devto.html https://raw.githubusercontent.com/joelnet/devto-cli/master/devto.html

接下来我将创建一个文件interop/fs.mjs,它将fs.readFile被放置在这里。我将它放在一个interop文件夹中,因为 MojiScript 要求 JavaScript 互操作文件也必须放在这个文件夹中。JavaScript 的编写方式与 MojiScript 不同,有时两者不兼容(除非在互操作目录中)。

为了fs.readFile与 MojiScript 兼容,我需要先promisify这样做。

promisify (fs.readFile)

既然已经承诺了,我也需要对其进行柯里化。

export const readFile = curry (2) (promisify (fs.readFile))

我也在处理 UTF8,所以让我们添加一个助手来让生活变得更轻松。

export const readUtf8File = file => readFile (file) ('utf8')

完整内容如下interop/fs.mjs

import fs from 'fs'
import curry from 'mojiscript/function/curry'
import { promisify } from 'util'

export const readFile = curry (2) (promisify (fs.readFile))

export const readUtf8File = file => readFile (file) ('utf8')

读取缓存

在 里面src/mocks/axios.mock.mjs,我将创建。当被调用mockAxios时,它将返回我们文件的内容。get

import pipe from 'mojiscript/core/pipe'
import { readUtf8File } from '../interop/fs'

const mockAxios = {
  get: () => pipe ([
    () => readUtf8File ('devto.html'),
    data => ({ data })
  ])
}

export default mockAxios

使用模拟很简单。我只需要更改dependencies。 中的所有内容都不main.mjs需要更改!

// don't forget to add the import!
import mockAxios from './mocks/axios.mock'

const dependencies = {
  axios: mockAxios,
  log
}

现在运行时npm start不会再有 HTTP 请求了。这很好,因为我可能还要运行npm start一堆程序才能完成这个任务!

解析 HTML

我喜欢cheerio用它来解析。我敢肯定那些酷酷的小子们都在用这个。

npm install --save-prod cheerio

创建另一个互操作interop/cheerio.mjs

import cheerio from 'cheerio';
import pipe from 'mojiscript/core/pipe';
import map from 'mojiscript/list/map';

export const getElements = selector => pipe ([
  cheerio.load,
  $ => $ (selector),
  $articles => $articles.toArray (),
  map (cheerio)
])

注意:调用 cheerio 时toArray,元素会丢失所有有用的 cheerio 方法。所以我们必须map cheerio将其返回到所有元素。

接下来添加getElementsmain

import { getElements } from './interop/cheerio'

const main = ({ axios, log }) => pipe ([
  getDevToHtml (axios),
  getElements ('.single-article:not(.feed-cta)'),
  log
])

再次运行npm start即可查看元素数组。

npm install --save-prod reselect nothis

创建interop/parser.mjs。我将使用它reselect从 HTML 中选择我需要的属性。我不会详细讲解它。它基本上就是对元素进行一系列的获取操作。代码很容易阅读,你也可以跳过它,因为它并不重要。

import reselect from 'reselect'
import nothis from 'nothis'

const { createSelector } = reselect
const isTextNode = nothis(({ nodeType }) => nodeType === 3)

const parseUrl = element => `http://dev.to${element.find('a.index-article-link').attr('href')}`
const parseTitle = element => element.find('h3').contents().filter(isTextNode).text().trim()
const parseUserName = element => element.find('.featured-user-name,h4').text().trim().split('')[0]
const parseTags = element => element.find('.featured-tags a,.tags a').text().substr(1).split('#')
const parseComments = element => element.find('.comments-count .engagement-count-number').text().trim() || '0'
const parseReactions = element => element.find('.reactions-count .engagement-count-number').text().trim() || '0'

export const parseElement = createSelector(
  parseUrl,
  parseTitle,
  parseUserName,
  parseTags,
  parseComments,
  parseReactions,
  (url, title, username, tags, comments, reactions) => ({
    url,
    title,
    username,
    tags,
    comments,
    reactions
  })
)

添加parseElementmain

import map from 'mojiscript/list/map'
import { parseElement } from './interop/parser'

const main = ({ axios, log }) => pipe ([
  getDevToHtml (axios),
  getElements ('.single-article:not(.feed-cta)'),
  map (parseElement),
  log,
])

现在当你运行时npm start你应该会看到类似这样的内容:

[
  { url:
     'http://dev.to/ccleary00/how-to-find-the-best-open-source-nodejs-projects-to-study-for-leveling-up-your-skills-1c28',
    title:
     'How to find the best open source Node.js projects to study for leveling up your skills',
    username: 'Corey Cleary',
    tags: [ 'node', 'javascript', 'hacktoberfest' ],
    comments: '0',
    reactions: '33' } ]

格式化数据

添加importformatPost并添加formatPostmain并更改logmap (log)

import $ from 'mojiscript/string/template'

const formatPost = $`${'title'}
${'url'}\n#${'tags'}
${'username'} ・ 💖  ${'comments'} 💬  ${'reactions'}
`

const main = ({ axios, log }) => pipe ([
  getDevToHtml (axios),
  getElements ('.single-article:not(.feed-cta)'),
  map (parseElement),
  map (formatPost),
  map (log)
])

再次运行npm start,您应该会看到一些如下所示的记录:

The Introvert's Guide to Professional Development
http://dev.to/geekgalgroks/the-introverts-guide-to-professional-development-3408
#introvert,tips,development,professional
Jenn ・ 💖  1 💬  50

最后,这开始看起来像某种东西了!

我还将添加一个条件,以便在设置时main.mjs使用axiosproductionNODE_ENV

import ifElse from 'mojiscript/logic/ifElse'

const isProd = env => env === 'production'
const getAxios = () => axios
const getMockAxios = () => mockAxios

const dependencies = {
  axios: ifElse (isProd) (getAxios) (getMockAxios) (process.env.NODE_ENV),
  log
}

运行它和不运行它production以确保两者都正常工作。

# dev mode
npm start

# production mode
NODE_ENV=production npm start

查看文章

这份清单很不错,我本来打算就此打住,但如果我也能读到这篇文章,那就太酷了。

我希望能够输入类似以下内容:

devto read 3408

我注意到 URL 末尾有一个我可以使用的 ID:http://dev.to/geekgalgroks/the-introverts-guide-to-professional-development-3408<-- 就在那里。

因此我将进行修改parser.mjs以包含一个新的解析器来获取该 ID。

const parseId = createSelector(
  parseUrl,
  url => url.match(/-(\w+)$/, 'i')[1]
)

然后只需按照模式parseId进入parseElement即可。

现在 CLI 将有两个分支,一个用于显示 feed,另一个用于显示文章。因此,让我们将 feed 逻辑从main.mjs和 拆分到 中src/showFeed.mjs

import pipe from 'mojiscript/core/pipe'
import map from 'mojiscript/list/map'
import $ from 'mojiscript/string/template'
import { getDevToHtml } from './api'
import { getElements } from './interop/cheerio'
import { parseElement } from './interop/parser'

const formatPost = $`${'title'}
${'url'}\n#${'tags'}
${'username'} ・ 💖  ${'comments'} 💬  ${'reactions'}
`

export const shouldShowFeed = args => args.length < 1

export const showFeed = ({ axios, log }) => pipe ([
  getDevToHtml (axios),
  getElements ('.single-article:not(.feed-cta)'),
  map (parseElement),
  map (formatPost),
  map (log)
])

接下来,我要绕一圈cond。CLIshowFeed中可能会有更多分支(也许有帮助?),但目前我们只有 1 条路径。

main.mjs现在看起来应该是这样的。

import pipe from 'mojiscript/core/pipe'
import cond from 'mojiscript/logic/cond'
import { showFeed } from './showFeed'

const main = dependencies => pipe ([
  cond ([
    [ () => true, showFeed (dependencies) ]
  ])
])

export default main

我们需要访问节点的参数。因此,请进行以下更改main.mjs。我之所以slice对其进行更改,是因为前两个参数是垃圾参数,我不需要它们。

// add this line
const state = process.argv.slice (2)

// add state to run
run ({ dependencies, state, main })

好的,在我们真正查看文章之前,还有很多工作要做。所以我们先添加帮助。这很简单。

查看帮助

创造src/showHelp.mjs

import pipe from 'mojiscript/core/pipe'

const helpText = `usage: devto [<command>] [<args>]

  <default>
    Show article feed
  read <id>    Read an article
`

export const showHelp = ({ log }) => pipe ([
  () => log (helpText)
])

现在我们可以简化main.mjs并将新案例添加到cond

import pipe from 'mojiscript/core/pipe'
import cond from 'mojiscript/logic/cond'
import { shouldShowFeed, showFeed } from './showFeed'
import { showHelp } from './showHelp'

const main = dependencies => pipe ([
  cond ([
    [ shouldShowFeed, showFeed (dependencies) ],
    [ () => true, showHelp (dependencies) ]
  ])
])

export default main

现在如果我们运行npm start -- help,我们应该会看到帮助:

usage: devto [<command>] [<args>]

  <default>    Show article feed
  read <id>    Read an article

如果我们跑步的话npm start我们仍然可以看到我们的反馈!

文章来自Cache

就像我从缓存中读取主信息一样,我也想从缓存中读取文章。

curl -Lo article.html https://raw.githubusercontent.com/joelnet/devto-cli/master/article.html

修改一下axios.mock.mjs也读一下文章。

import pipe from 'mojiscript/core/pipe'
import ifElse from 'mojiscript/logic/ifElse'
import { readUtf8File } from '../interop/fs'

const feedOrArticle = ifElse (url => url === 'https://dev.to') (() => 'devto.html') (() => 'article.html')

const mockAxios = {
  get: url => pipe ([
    () => feedOrArticle (url),
    readUtf8File,
    data => ({ data })
  ])
}

export default mockAxios

解析文章

解析文章 HTML 要容易得多,因为我打算将整个article-body区块格式化为文本。所以我只需要标题和正文。

创造interop/articleParser.mjs

import reselect from 'reselect'

const { createSelector } = reselect

const parseTitle = $ => $('h1').first().text().trim()
const parseBody = $ => $('#article-body').html()

export const parseArticle = createSelector(
  parseTitle,
  parseBody,
  (title, body) => ({
    title,
    body
  })
)

阅读文章

因为没有状态,当我发出命令时,CLI 不知道要拉取哪个 URL read。因为我比较懒,所以我会再次查询 feed,然后从 feed 中拉取 URL。

因此我将跳回去showFeed.mjs并展示该功能。

我只是从中提取函数showFeed并将它们放入其中getArticles。我没有在这里添加任何新代码。

export const getArticles = axios => pipe ([
  getDevToHtml (axios),
  getElements ('.single-article:not(.feed-cta)'),
  map (parseElement)
])

export const showFeed = ({ axios, log }) => pipe ([
  getArticles (axios),
  map (formatPost),
  map (log)
])

显示文章

现在我想编写一个类似下面的函数,但会报错id:未定义。id是 的参数pipe,但在这里无法访问。 的输入filter是文章数组,而不是id

const getArticle = ({ axios }) => pipe ([
  getArticles (axios),
  filter (article => article.id === id), // 'id' is not defined
  articles => articles[0]
])

但有一个技巧。使用W 组合器,我可以创建一个闭包,这样它就id暴露出来了。

const getArticle = ({ axios }) => W (id => pipe ([
  getArticles (axios),
  filter (article => article.id === id),
  articles => articles[0]
]))

把这个代码块和上面的代码块比较一下,区别不大,只是添加了W (id =>一个结束符)。W 组合器真是个好工具。更多关于函数组合器的内容,以后再写一篇文章吧 :) 现在,我们继续。

总体src/showArticle.mjs看起来应该是这样的:

import W from 'mojiscript/combinators/W'
import pipe from 'mojiscript/core/pipe'
import filter from 'mojiscript/list/filter'
import { getArticles } from './showFeed'

export const shouldShowArticle = args => args.length === 2 && args[0] === 'read'

const getArticle = ({ axios }) => W (id => pipe ([
  getArticles (axios),
  filter (article => article.id === id),
  articles => articles[0]
]))

export const showArticle = ({ axios, log }) => pipe ([
  getArticle ({ axios }),
  log
])

修改main.mjscond包含新功能:

import { shouldShowArticle, showArticle } from './showArticle'

const main = dependencies => pipe ([
  cond ([
    [ shouldShowArticle, args => showArticle (dependencies) (args[1]) ],
    [ shouldShowFeed, showFeed (dependencies) ],
    [ () => true, showHelp (dependencies) ]
  ])
])

运行npm run start -- 1i0a(替换 id)你应该会看到类似这样的内容:

{ id: '1i0a',
  url:
   'http://dev.to/ppshobi/-email-sending-in-django-2-part--1--1i0a',
  title: 'Email Sending in Django 2, Part -1',
  username: 'Shobi',
  tags: [ 'django', 'emails', 'consoleemailbackend' ],
  comments: '0',
  reactions: '13' }

HTML 到文本

我发现了一个很棒的 npm 包,看起来它可以帮我处理这个问题。

npm install --save-prod html-to-text

我们已经完成了大部分基础工作,因此,要发出 HTTP 请求、解析 HTML 并将其格式化为文本,就这么简单。打开showArticle.mjs

const getArticleTextFromUrl = axios => pipe ([
  ({ url }) => getUrl (axios) (url),
  cheerio.load,
  parseArticle,
  article => `${article.title}\n\n${htmlToText.fromString (article.body)}`
])

我还想为id未找到的情况创建一个视图。

const showArticleNotFound = $`Article ${0} not found.\n`

我还将创建一个isArticleFound条件以使代码更具可读性。

const isArticleFound = article => article != null

我将使用相同的 W Combinator 技术来创建闭包并公开id和修改showArticle

export const showArticle = ({ axios, log }) => W (id => pipe ([
  getArticle ({ axios }),
  ifElse (isArticleFound) (getArticleTextFromUrl (axios)) (() => showArticleNotFound (id)),
  log
]))

总体showArticle.mjs看起来是这样的:

import cheerio from 'cheerio'
import htmlToText from 'html-to-text'
import W from 'mojiscript/combinators/W'
import pipe from 'mojiscript/core/pipe'
import filter from 'mojiscript/list/filter'
import ifElse from 'mojiscript/logic/ifElse'
import $ from 'mojiscript/string/template'
import { getUrl } from './api'
import { parseArticle } from './interop/articleParser'
import { getArticles } from './showFeed'

const isArticleFound = article => article != null
const showArticleNotFound = $`Article ${0} not found.\n`
const getArticleTextFromUrl = axios => pipe ([
  ({ url }) => getUrl (axios) (url),
  cheerio.load,
  parseArticle,
  article => `${article.title}\n\n${htmlToText.fromString (article.body)}`
])

export const shouldShowArticle = args => args.length === 2 && args[0] === 'read'

const getArticle = ({ axios }) => W (id => pipe ([
  getArticles (axios),
  filter (article => article.id === id),
  articles => articles[0]
]))

export const showArticle = ({ axios, log }) => W (id => pipe ([
  getArticle ({ axios }),
  ifElse (isArticleFound) (getArticleTextFromUrl (axios)) (() => showArticleNotFound (id)),
  log
]))

npm start -- read 1i0a再次运行,您就应该看到该文章!

小黄人在欢呼

最后的润色

我想id在 feed 中将其说得更清楚一些。

const formatPost = $`${'id'}${'title'}
${'url'}\n#${'tags'}
${'username'} ・ 💖  ${'comments'} 💬  ${'reactions'}
`

将其添加到package.json,我将命名该命令devto

  "bin": {
    "devto": "./src/index.mjs"
  }

src/index.mjs顶部添加这个神秘的魔法:

#!/bin/sh 
':' //# comment; exec /usr/bin/env NODE_ENV=production node --experimental-modules --no-warnings "$0" "$@"

运行此命令来创建该命令的全局链接。

npm link

如果一切顺利,您现在应该能够运行以下命令:

# get the feed
devto

# read the article
devto read <id>

所以你决定直接跳到最后?

你可以把马牵到水边……或者做其他事情。

要赶上我们其他人的步伐,请按照以下步骤操作:

# clone the repo
git clone https://github.com/joelnet/devto-cli
cd devto-cli

# install
npm ci
npm run build
npm link

# run
devto

关于 CLI 的警告

抓取网站数据是个坏主意。网站一旦发生变化(这种情况肯定会发生),你的代码就会崩溃。

这只是#hacktoberfest的一个趣味演示,并非可维护的项目。如果您发现任何 Bug,请提交 PR 并附上 Bug 报告进行修复。我本人不负责维护此项目。

如果这是一个真实的项目,那么有些事情会很酷:

  • 登录,这样您就可以阅读您的订阅源。
  • 更多互动、评论、点赞、标签。或许可以发篇文章?

Hacktoberfest 快乐!

对于那些读完整篇文章的朋友们,感谢你们抽出时间。我知道这篇文章很长。我希望它很有趣,希望你们学到了一些东西,最重要的是,希望你们玩得开心。

对于那些实际上一步一步跟随并自己创建 CLI 的人来说:你们使我变得完整💖。

请在评论或推特中告诉我您学到了什么、发现了什么有趣的东西或任何其他评论或批评。

我的文章非常注重功能性 JavaScript,如果您需要更多,请在这里关注我,或在 Twitter 上关注我@joelnet

更多文章

问我一些关于函数式编程的愚蠢问题
让我们来讨论一下 JavaScript 的自动生成文档工具

干杯!

文章来源:https://dev.to/joelnet/lets-make-a-devto-cli-together-4eh1
PREV
使用 Restapify 快速轻松地模拟 REST API
NEXT
我是 browserless.io 的创始人——我们来聊聊如何创办自己的公司