自分のライブラリのダウンロードが気になる…けど。

·
#npm#CLI#開発

npmパッケージのダウンロード数を可視化するCLIツールを作った

はじめに

自分が公開しているnpmパッケージのダウンロード数を手軽に確認したいと思い、CLIツール「npm-dl-stats」を作成しました。

作ったもの

npmパッケージのダウンロード統計を美しく表示するCLIツールです。

主な機能

  • 📊 ダウンロード数でソート・色分け表示
  • 🎯 対話的なパッケージ選択機能
  • 💾 パッケージリストの保存・管理
  • 🎨 chalkによる見やすい色付き出力
  • ⚡ 軽量(8.8kB、依存2つのみ)

デモ

# インストール
npm install -g npm-dl-stats

# 自分のパッケージを登録
npm-dl-stats init me

# ダウンロード数を確認
npm-dl-stats me

出力例:

📦 npm downloads (last-month)

react     237,228,276  # 1位は太字緑
vue        28,122,368  # 2位は緑
svelte      8,583,371  # 3位は黄色
------  ------------
Total    273,934,015

なぜ作ったか

きっかけ

自分のnpmパッケージが何回ダウンロードされているか気になったのですが、毎回npmのサイトを開いて確認するのが面倒でした。

また、複数パッケージをまとめて確認したり、比較したりする良いツールがなかったため、自分で作ることにしました。

要件定義

以下の要件を満たすツールを目指しました:

  1. シンプル - 難しい設定不要、すぐ使える
  2. 軽量 - 依存を最小限に
  3. 見やすい - 色付け、ソート機能
  4. 実用的 - 自分が毎日使いたくなるもの

技術選定

基本構成

  • 言語: TypeScript
  • ランタイム: Node.js 18+
  • モジュール: ESM
  • 依存: minimist(CLI引数)、chalk(色付け)

なぜTypeScript?

型安全性とエディタ補完の恩恵を受けるためです。特にnpm APIのレスポンス型を定義することで、実行時エラーを防げました。

export type SearchApiResponse = {
  objects: Array<{
    package: {
      name: string
      version: string
      description?: string
    }
  }>
  total: number
}

なぜESM?

  • Node.js 18+で標準サポート
  • モダンなJavaScript
  • top-level awaitが使える

依存を最小限にした理由

  1. セキュリティ - 依存が少ないほど脆弱性リスクが低い
  2. メンテナンス - 依存更新の手間が少ない
  3. 軽量性 - インストールが速い

設計

ディレクトリ構造

src/
├── types.ts              # 型定義
├── config.ts             # 純粋関数(設定操作)
├── io/
│   ├── fs.ts             # ファイルIO(副作用)
│   └── fetch.ts          # HTTP通信(副作用)
├── commands/
│   ├── init.ts           # initコマンド
│   ├── config.ts         # configコマンド
│   └── show.ts           # showコマンド
└── index.ts              # エントリーポイント

設計原則

1. 副作用の分離

純粋関数と副作用を明確に分離しました。

純粋関数(config.ts):

// 設定にパッケージを追加(純粋関数)
export const addPackagesToList = (
  config: Config,
  listName: string,
  packages: string[]
): Config => {
  const existingPackages = config.lists[listName] || []
  const uniquePackages = [...new Set([...existingPackages, ...packages])]
  
  return {
    ...config,
    lists: {
      ...config.lists,
      [listName]: uniquePackages
    }
  }
}

副作用層(io/fs.ts):

// ファイルの読み書きは副作用層に隔離
export const loadConfig = async (): Promise<Config> => {
  try {
    if (!configExists()) {
      return defaultConfig()
    }
    
    const content = await readFile(CONFIG_PATH, 'utf-8')
    return parseConfig(content)  // 純粋関数を呼ぶ
  } catch {
    return defaultConfig()
  }
}

メリット:

  • テストが容易
  • ロジックの再利用性が高い
  • 予測可能な動作

2. 型安全性

npm APIのレスポンスは unknown として扱い、型アサーションで安全に変換します。

// response.json() は unknown を返す
const data = (await response.json()) as SearchApiResponse

型定義があることで、以下のようなミスを防げます:

// ✅ 型エラーで気づける
data.objects.map(obj => obj.package.nam)  // 'nam' → 'name'

3. エラーハンドリング

APIエラーやファイルIOエラーに対して適切に対応します。

try {
  const response = await fetch(url)
  if (!response.ok) {
    throw new Error(`Search API error: ${response.status}`)
  }
  // ...
} catch (error) {
  throw new Error(`Failed to search packages: ${error}`)
}

実装で工夫した点

1. 単一パッケージと複数パッケージのAPI形式の違い

npm Downloads APIは、単一パッケージと複数パッケージでレスポンス形式が異なります。

単一パッケージ:

{
  "downloads": 237228276,
  "package": "react",
  "start": "2025-12-27",
  "end": "2026-01-25"
}

複数パッケージ:

{
  "react": {
    "downloads": 237228276,
    "package": "react"
  },
  "vue": {
    "downloads": 28122368,
    "package": "vue"
  }
}

この違いを吸収する実装:

if (packages.length === 1) {
  // 単一パッケージ
  const singleData = data as { downloads: number; package: string }
  result[singleData.package] = singleData.downloads ?? 0
} else {
  // 複数パッケージ
  const bulkData = data as BulkDownloadsApiResponse
  for (const [pkg, info] of Object.entries(bulkData)) {
    result[pkg] = info?.downloads ?? 0
  }
}

2. 色付けとランキング

トップ3を強調表示することで、視覚的にわかりやすくしました。

entries.forEach(([pkg, count], index) => {
  if (index === 0) {
    console.log(chalk.bold.green(paddedPkg), chalk.bold.green(formattedCount))
  } else if (index === 1) {
    console.log(chalk.green(paddedPkg), chalk.green(formattedCount))
  } else if (index === 2) {
    console.log(chalk.yellow(paddedPkg), chalk.yellow(formattedCount))
  } else {
    console.log(paddedPkg, chalk.gray(formattedCount))
  }
})

3. 対話的なパッケージ選択

--pick オプションで、リストからパッケージを選択できるようにしました。

const pickPackages = async (packages: string[]): Promise<string[]> => {
  console.log(chalk.cyan("\n📋 Select packages:\n"))

  packages.forEach((pkg, i) => {
    console.log(`  ${chalk.gray(`${i + 1}.`)} ${pkg}`)
  })
  console.log(`  ${chalk.gray(`${packages.length + 1}.`)} ${chalk.bold("ALL")}`)

  const rl = createInterface({ input, output })
  const answer = await rl.question(
    chalk.yellow("\nSelect (1-" + (packages.length + 1) + " or comma-separated): ")
  )
  rl.close()

  // カンマ区切りの番号を処理
  const indices = answer.trim()
    .split(",")
    .map(s => parseInt(s.trim()))
    .filter(n => !isNaN(n) && n >= 1 && n <= packages.length)

  return indices.map(i => packages[i - 1])
}

4. 設定ファイルの保存場所

XDG Base Directory仕様に従い、~/.config/ 配下に保存します。

const CONFIG_DIR = join(homedir(), '.config', 'npm-dl-stats')
const CONFIG_PATH = join(CONFIG_DIR, 'config.json')

config コマンドで場所を確認できるようにしました:

$ npm-dl-stats config

⚙️  Configuration

Location: /Users/username/.config/npm-dl-stats/config.json

Lists:
  me (4 packages)
    - md-blog-core
    - create-neomutt-gmail
    - mutt-config-core
    - config-fs-utils

開発の流れ

1. プロトタイプ

まず最小構成で動くものを作りました:

  • npm APIを叩く
  • 結果を表示

2. 機能追加

段階的に機能を追加:

  • 設定ファイル保存
  • 色付け
  • ソート
  • 選択機能

3. 公開準備

  • README作成
  • LICENSE追加
  • package.json調整
  • GitHubリポジトリ作成
  • npm publish

ハマったポイント

1. 型アサーションの必要性

最初は型注釈 : Type で書いていましたが、TypeScriptのstrict modeでエラーになりました。

// ❌ エラー
const data: SearchApiResponse = await response.json()

// ✅ OK
const data = (await response.json()) as SearchApiResponse

response.json()Promise<any> ではなく Promise<unknown> を返すため、型アサーションが必要でした。

2. ESMでのファイル拡張子

ESMでは、importに .js 拡張子が必要です(.ts ではない)。

// ✅ 正しい
import { runInit } from './commands/init.js'

// ❌ 間違い
import { runInit } from './commands/init.ts'

TypeScriptがトランスパイル時に拡張子を変換しないため、.js と書く必要があります。

3. グローバルコマンドの実行権限

npm link でグローバルインストールする際、実行権限が必要です。

chmod +x dist/index.js

また、shebang(#!/usr/bin/env node)も必須です。

パフォーマンス

バンドルサイズ

  • パッケージサイズ: 8.8 kB
  • 展開後: 25.1 kB
  • 依存: 2つのみ(minimist, chalk)

実行速度

ローカルキャッシュがあれば、コマンド実行は即座に完了します。 API通信も数百ミリ秒で完了するため、ストレスなく使えます。

今後の展望

追加したい機能

  • グラフ表示(Sparkline)
  • JSONエクスポート
  • 過去データとの比較
  • GitHub READMEバッジ生成
  • 週次レポート(cron対応)

改善点

  • テストコードの追加
  • CI/CDの構築
  • より詳細なエラーメッセージ

まとめ

「自分が欲しいツール」を作るというシンプルな動機から始まり、実際に使えるものができました。

学んだこと:

  • 副作用の分離による設計の重要性
  • 型安全性がもたらす恩恵
  • 依存を減らすことの価値
  • ユーザー体験(色付け、ソート)の重要性

結果:

  • 約3時間で実装からnpm公開まで完了
  • 軽量で高速なCLIツール
  • 毎日使いたくなるツール

同じようなツールを作りたい方の参考になれば幸いです。


リンク

インストール

npm install -g npm-dl-stats

ぜひ使ってみてください!