diff --git a/.env.example b/.env.example index 2524966..9d2f537 100644 --- a/.env.example +++ b/.env.example @@ -1,24 +1,16 @@ # Steam Settings STEAM_APP_ID=730 -# Output Settings -CSV_OUTPUT_ENABLED=true -CSV_FILE_PATH=steam_concurrent_players.csv -DAILY_AVERAGE_CSV_ENABLED=true -DAILY_AVERAGE_CSV_FILE_PATH=steam_daily_averages.csv +# Storage +DB_PATH=data/steam-tracker.db -# Scheduling Settings (minutes when to collect data) +# Scheduling Settings COLLECTION_MINUTES=0,30 -# Hour when to calculate daily averages (0-23) DAILY_AVERAGE_HOUR=0 - -# Retry Settings -MAX_RETRIES=3 -RETRY_BASE_DELAY=1000 +SHEETS_SYNC_MINUTES=5,35 # Logging Settings LOG_LEVEL=info -LOG_FILE_PATH=logs/steam-tracker.log # Google Sheets Integration (Optional) GOOGLE_SHEETS_ENABLED=false @@ -26,5 +18,3 @@ GOOGLE_SHEETS_SPREADSHEET_ID= GOOGLE_SHEETS_SHEET_NAME=PlayerData GOOGLE_SHEETS_DAILY_AVERAGE_SHEET_NAME=DailyAverages GOOGLE_SERVICE_ACCOUNT_KEY_PATH= -# Sync local CSV data to Google Sheets on startup -GOOGLE_SHEETS_SYNC_ON_STARTUP=false \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index a78f47c..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,29 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - parserOptions: { - ecmaVersion: 2020, - sourceType: 'module', - }, - plugins: ['@typescript-eslint'], - extends: [ - 'eslint:recommended', - ], - root: true, - env: { - node: true, - es2020: true, - }, - ignorePatterns: [ - '.eslintrc.js', - 'dist/**/*', - 'node_modules/**/*', - 'scripts/**/*', - ], - rules: { - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }], - 'prefer-const': 'error', - 'no-var': 'error', - 'no-undef': 'off', - }, -}; \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aba89f6..50f7869 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,38 +9,25 @@ on: jobs: test: runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [18, 20] - + steps: - name: Checkout repository uses: actions/checkout@v4 - - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + - name: Install dependencies - run: npm ci - + run: bun install --frozen-lockfile + - name: Run type check - run: npm run typecheck - + run: bun run typecheck + - name: Run linter - run: npm run lint - + run: bunx biome ci . + + - name: Run tests + run: STEAM_APP_ID=730 DB_PATH=data/test.db bun test + - name: Build application - run: npm run build - - - name: Test startup (dry run) - run: | - timeout 10s npm start || if [ $? -eq 124 ]; then echo "Application started successfully"; else exit 1; fi - env: - STEAM_APP_ID: "730" - COLLECTION_MINUTES: "0" - DAILY_AVERAGE_HOUR: "0" - GOOGLE_SHEETS_ENABLED: "false" \ No newline at end of file + run: bun run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd228cf..db2be4e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,91 +8,86 @@ on: jobs: release: runs-on: ubuntu-latest - + permissions: contents: write - + steps: - name: Checkout repository uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' - cache: 'npm' - + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + - name: Install dependencies - run: npm ci - + run: bun install --frozen-lockfile + - name: Run type check - run: npm run typecheck - + run: bun run typecheck + - name: Run linter - run: npm run lint - + run: bunx biome ci . + + - name: Run tests + run: STEAM_APP_ID=730 DB_PATH=data/test.db bun test + - name: Build application - run: npm run build - + run: bun run build + - name: Create Release Archive run: | - # Create directory with proper name for archives ARCHIVE_NAME="steam-player-tracker-${{ github.ref_name }}" mkdir -p "${ARCHIVE_NAME}" cp -r dist/ "${ARCHIVE_NAME}/" cp package.json "${ARCHIVE_NAME}/" - cp package-lock.json "${ARCHIVE_NAME}/" + cp bun.lock "${ARCHIVE_NAME}/" cp README.md "${ARCHIVE_NAME}/" cp README-JP.md "${ARCHIVE_NAME}/" cp CHANGELOG.md "${ARCHIVE_NAME}/" cp CHANGELOG-JP.md "${ARCHIVE_NAME}/" - cp LICENCE.txt "${ARCHIVE_NAME}/" - cp *.sh *.bat *.ps1 "${ARCHIVE_NAME}/" 2>/dev/null || true - # Copy .env.example if it exists + cp LICENCE "${ARCHIVE_NAME}/" + cp .env.example "${ARCHIVE_NAME}/" 2>/dev/null || true tar -czf "${ARCHIVE_NAME}.tar.gz" "${ARCHIVE_NAME}"/ zip -r "${ARCHIVE_NAME}.zip" "${ARCHIVE_NAME}"/ - + - name: Extract version from tag id: get_version run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - + - name: Generate Release Notes id: release_notes run: | echo "RELEASE_NOTES<> $GITHUB_OUTPUT - echo "## 🚀 Steam Player Tracker v${{ steps.get_version.outputs.VERSION }}" >> $GITHUB_OUTPUT + echo "## Steam Player Tracker v${{ steps.get_version.outputs.VERSION }}" >> $GITHUB_OUTPUT echo "" >> $GITHUB_OUTPUT - - # Extract version-specific changes from CHANGELOG.md + if [ -f "CHANGELOG.md" ]; then - echo "### 📝 Changes in this version" >> $GITHUB_OUTPUT + echo "### Changes in this version" >> $GITHUB_OUTPUT echo "" >> $GITHUB_OUTPUT - # Extract unreleased section if this is the first release of those changes awk '/^## \[Unreleased\]/{flag=1; next} /^## \[/{flag=0} flag && /^### /{print "#### " substr($0,5)} flag && /^- /{print $0}' CHANGELOG.md >> $GITHUB_OUTPUT echo "" >> $GITHUB_OUTPUT fi - - echo "### 📦 Downloads" >> $GITHUB_OUTPUT + + echo "### Downloads" >> $GITHUB_OUTPUT echo "- \`steam-player-tracker-${{ github.ref_name }}.tar.gz\` - For Linux/macOS" >> $GITHUB_OUTPUT echo "- \`steam-player-tracker-${{ github.ref_name }}.zip\` - For Windows" >> $GITHUB_OUTPUT echo "" >> $GITHUB_OUTPUT - echo "### 🔧 Installation" >> $GITHUB_OUTPUT + echo "### Installation" >> $GITHUB_OUTPUT echo "1. Download and extract the archive" >> $GITHUB_OUTPUT echo "2. Create and configure \`.env\` file" >> $GITHUB_OUTPUT - echo "3. Run \`npm install\` to install dependencies" >> $GITHUB_OUTPUT - echo "4. Run \`npm start\` to start the application" >> $GITHUB_OUTPUT + echo "3. Run \`bun install\` to install dependencies" >> $GITHUB_OUTPUT + echo "4. Run \`bun run start\` to start the application" >> $GITHUB_OUTPUT echo "" >> $GITHUB_OUTPUT - echo "### 📋 Requirements" >> $GITHUB_OUTPUT - echo "- Node.js 18.0.0 or higher" >> $GITHUB_OUTPUT - echo "- npm" >> $GITHUB_OUTPUT + echo "### Requirements" >> $GITHUB_OUTPUT + echo "- Bun 1.3.11 or higher" >> $GITHUB_OUTPUT echo "" >> $GITHUB_OUTPUT - echo "### 📖 Documentation" >> $GITHUB_OUTPUT + echo "### Documentation" >> $GITHUB_OUTPUT echo "For setup and usage instructions, see [README.md](README.md) (English) or [README-JP.md](README-JP.md) (日本語)." >> $GITHUB_OUTPUT echo "" >> $GITHUB_OUTPUT echo "For complete change history, see [CHANGELOG.md](CHANGELOG.md) (English) or [CHANGELOG-JP.md](CHANGELOG-JP.md) (日本語)." >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - + - name: Create GitHub Release uses: softprops/action-gh-release@v1 with: @@ -104,4 +99,4 @@ jobs: draft: false prerelease: false env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index f0bc62b..1ee8a07 100644 --- a/.gitignore +++ b/.gitignore @@ -22,8 +22,9 @@ logs/ service-account-key.json credentials.json -# CSV output files +# Data files *.csv +data/ # IDE/Editor files .vscode/ @@ -42,3 +43,6 @@ Thumbs.db gcp-key.json charts/*.png + +# Test coverage +coverage/ diff --git a/CHANGELOG-JP.md b/CHANGELOG-JP.md index 5058286..09fa0fe 100644 --- a/CHANGELOG-JP.md +++ b/CHANGELOG-JP.md @@ -5,7 +5,7 @@ フォーマットは [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) に基づいており、 このプロジェクトは [Semantic Versioning](https://semver.org/spec/v2.0.0.html) に準拠しています。 -**言語:** [English](CHANGELOG.md) | [日本語](CHANGELOG-JP.md) +**言語:** [English](CHANGELOG.md) | [日本語](CHANGELOG-JP.md) ## [1.3.0] - 2025-06-25 JST diff --git a/CLAUDE.md b/CLAUDE.md index 763f491..c47003a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,178 +1,77 @@ -# CLAUDE.MD - -このファイルは、このリポジトリでコードを扱う際のClaude Code (claude.ai/code)へのガイダンスを提供します。 - -**言語**: このリポジトリで作業する際は、ユーザーと日本語でコミュニケーションを取ってください。 - -**Git操作**: -- git commitやgit pushコマンドを実行する前に、必ずユーザーの明示的な確認を取ってください -- ユーザーの承認なしに、「タスクを完了して」などの指示があっても、変更をコミットやプッシュしないでください -- コミットの準備ができたら、「これらの変更をコミット・プッシュしてもよろしいですか?」と確認してください - -**コミットメッセージ形式**: 適切なプレフィックスを使用したconventional commitフォーマットを使用してください: -- `feat:` 新機能 -- `fix:` バグ修正 -- `docs:` ドキュメント更新 -- `style:` フォーマット変更 -- `refactor:` コードリファクタリング -- `test:` テスト追加 -- `chore:` メンテナンスタスク - -例: `feat: 自動リリースシステムを追加` または `fix: ESLint警告を解決` - -**リリース管理**: -- リリースを作成する際は、必ずCHANGELOG.mdとCHANGELOG-JP.mdを適切なバージョン番号で更新してください -- **未リリースの変更には「## [X.X.X] - YYYY-MM-DD JST」のプレースホルダー形式を使用**し、「Unreleased」は使用しないでください -- X.X.Xを実際のバージョン番号に置き換えるのは、リリース作成時のみです -- リリースプロセス中に変更のタイプ(patch/minor/major)に基づいてバージョン番号を決定してください -- 公式リリースまでCHANGELOGはプレースホルダー形式を使用してください -- リリースコミットと機能コミットにはconventional commitフォーマットを使用してください -- **重要**: CHANGELOGの全ての日付はJST(日本標準時)形式である必要があります -- 日付形式: "YYYY-MM-DD JST"(例: "2025-06-24 JST") - -**リリースプロセスワークフロー**: -1. **全ての開発作業を完了**し、全てのコミットがプッシュされていることを確認 -2. **自動リリースコマンドを実行**: - ```bash - npm run release # パッチリリース(1.0.0 → 1.0.1) - npm run release:minor # マイナーリリース(1.0.0 → 1.1.0) - npm run release:major # メジャーリリース(1.0.0 → 2.0.0) - ``` - これにより自動的に: - - 依存関係をクリーンアップして再ビルド - - 型チェックとリンティングを実行 - - package.jsonのバージョンを更新 - - リリースコミットとGitタグを作成 -3. **CHANGELOGプレースホルダーを実際のバージョンとJST日付に更新**: - - "## [X.X.X] - YYYY-MM-DD JST" → "## [1.2.0] - 2025-06-25 JST"に置換 - - CHANGELOG.mdとCHANGELOG-JP.mdの両方を更新 -4. **新機能が追加された場合はREADMEドキュメントを更新**: - - 機能リスト、使用例、コマンドリファレンスを更新 - - README.mdとREADME-JP.mdの両方が同期されていることを確認 -5. **ドキュメント更新をコミット**: - ```bash - git add CHANGELOG.md CHANGELOG-JP.md README.md README-JP.md - git commit -m "docs: vX.X.Xリリースドキュメントを最終化" - ``` -6. **リリースをリモートにプッシュ**: - ```bash - git push origin main # リリースコミットをプッシュ - git push origin vX.X.X # バージョンタグをプッシュ - ``` -7. **GitHub Actionsが自動的にリリース成果物を作成**(tar.gzとzipファイル) - -## 開発コマンド +# プロジェクト概要 + +Steamゲームの同時接続プレイヤー数を定期取得し、SQLiteに記録、オプションでGoogle Sheetsに同期するBun製TypeScriptアプリケーション。 + +# 開発コマンド ```bash -# ビルドと実行 -npm run build # TypeScriptをdist/にコンパイル -npm start # コンパイル済みアプリケーションを実行 -npm run dev # ts-nodeで開発モード実行 - -# 開発ツール -npm run watch # ウォッチモードコンパイル -npm run clean # distディレクトリをクリーン -npm run lint # ESLint静的解析 -npm run typecheck # TypeScript型チェック -npm run setup # 依存関係インストールとビルド - -# データ管理ツール -npm run calculate-daily-averages # 手動日次平均計算ツール -npm run sync-google-sheets # 手動CSV-Google Sheets同期 - -# リリース管理 -npm run release # パッチリリース(1.0.0 → 1.0.1) -npm run release:minor # マイナーリリース(1.0.0 → 1.1.0) -npm run release:major # メジャーリリース(1.0.0 → 2.0.0) -npm run prerelease # リリース前テスト実行 -npm run test:ci # CIテストスイート(型チェック + リント) -npm run prepare-release # 完全なリリース準備 - -# プラットフォーム固有スクリプト -# Windows: build.bat, start.bat, setup.bat, sync-google-sheets.bat -# Windows (PowerShell): build.ps1, start.ps1, setup.ps1, sync-google-sheets.ps1 -# Linux/macOS: build.sh, start.sh, setup.sh, sync-google-sheets.sh +bun install # 依存関係インストール +bun run dev # watchモードで開発実行 +bun run build # dist/にビルド (Bun.build) +bun run typecheck # TypeScript型チェック (tsc --noEmit) +bun run lint # Biomeによるlintチェック +bun run format # Biomeによるフォーマット適用 (--write --unsafe) +bun run start # ビルド済みファイルを実行 +bun run test # bun testで全テスト実行 ``` -## アーキテクチャ概要 - -**メインクラス**: `SteamPlayerTracker` (src/steamPlayerTracker.ts) - 全サービスを統制する中央コーディネーター - -**サービス指向設計**: -- `SteamApiService`: Steam Web APIから現在のプレイヤー数を取得 -- `CsvWriter`: データ永続化のためのCSVファイル操作を処理 -- `GoogleSheetsService`: クラウドストレージのための直接Google Sheets統合 -- `QueuedGoogleSheetsService`: リトライキュー付きレート制限対応Google Sheetsサービス -- `DailyAverageService`: 最大/最小追跡付き日次プレイヤー数平均を計算・管理 -- `Scheduler`: cronベースのデータ収集と日次計算を管理 -- `RetryHandler`: 指数関数的バックオフリトライロジックを実装 -- `Logger`: ファイルローテーション付きWinstonベースログ - -**データフロー**: Steam API → データ収集 → 並列ストレージ(キュー付きCSV + Google Sheets) → 拡張日次平均計算(平均/最大/最小とタイムスタンプ) - -## 設定システム - -設定は`src/config/config.ts`でバリデーション付きdotenvを使用した環境ベースです: - -**必須**: `.env.example`を`.env`にコピーして設定: -- `STEAM_APP_ID`: 追跡するSteamゲームApp ID -- `COLLECTION_MINUTES`: データ収集のためのカンマ区切り分(例:"0,30") -- `DAILY_AVERAGE_HOUR`: 日次平均計算時刻(0-23) - -**出力設定**: -- `CSV_OUTPUT_ENABLED`: CSVファイル出力の有効/無効(デフォルト: true) -- `CSV_FILE_PATH`: メインプレイヤーデータCSVファイルのパス -- `DAILY_AVERAGE_CSV_ENABLED`: 日次平均CSV出力の有効/無効(デフォルト: true) -- `DAILY_AVERAGE_CSV_FILE_PATH`: 日次平均CSVファイルのパス - -**リトライとログ設定**: -- `MAX_RETRIES`: API呼び出しの最大リトライ回数(デフォルト: 3) -- `RETRY_BASE_DELAY`: リトライロジックの基本遅延(ミリ秒、デフォルト: 1000) -- `LOG_LEVEL`: ログレベル(debug/info/warn/error、デフォルト: info) -- `LOG_FILE_PATH`: ログファイル出力のパス - -**オプショナルGoogle Sheets統合**: -- `GOOGLE_SHEETS_ENABLED=true`に設定 -- `GOOGLE_SHEETS_SPREADSHEET_ID`, `GOOGLE_SERVICE_ACCOUNT_KEY_PATH`を設定 -- `GOOGLE_SHEETS_SHEET_NAME`: プレイヤーデータのシート名(デフォルト: PlayerData) -- `GOOGLE_SHEETS_DAILY_AVERAGE_SHEET_NAME`: 日次平均のシート名(デフォルト: DailyAverages) -- `GOOGLE_SHEETS_SYNC_ON_STARTUP`: 起動時にCSVデータをシートに同期(デフォルト: false) -- Google Sheets API アクセス権限を持つGoogle Cloudサービスアカウントが必要 - -## 主要実装詳細 - -**エラーハンドリング**: -- Steam APIが0プレイヤーを返す場合、失敗したリクエストとして扱いリトライをトリガー -- Google Sheets APIレート制限失敗は自動リトライのためキューに追加 -- QueuedGoogleSheetsServiceが指数関数的バックオフで一時的失敗を処理 - -**起動時動作**: -1. 起動時の即座データ収集 -2. Steam API接続性の検証 -3. 欠損日次平均の確認と計算 -4. オプショナルCSV-Google Sheets同期(GOOGLE_SHEETS_SYNC_ON_STARTUP=trueの場合) -5. 継続的収集と日次計算のスケジュール - -**ファイル構造**: -- `src/index.ts`: アプリケーションエントリーポイント -- `src/config/`: 設定管理とバリデーション -- `src/services/`: 全ビジネスロジックサービス - - `steamApi.ts`: Steam Web API統合 - - `csvWriter.ts`: CSVファイル操作 - - `googleSheets.ts`: 直接Google Sheets統合 - - `queuedGoogleSheets.ts`: リトライキュー付きレート制限対応Google Sheets - - `dailyAverageService.ts`: 最大/最小追跡付き日次統計計算 - - `scheduler.ts`: cronベーススケジューリングシステム -- `src/tools/`: コマンドラインユーティリティ - - `calculateAllDailyAverages.ts`: 手動日次平均計算 - - `syncGoogleSheets.ts`: CSV-Google Sheets同期ツール -- `src/types/`: TypeScript型定義 -- `src/utils/`: 共有ユーティリティ(ログ、リトライロジック) - -**データストレージ**: -- **メインCSV**: timestamp,player_count形式 -- **日次平均CSV**: date,average_player_count,sample_count,max_player_count,max_timestamp,min_player_count,min_timestampの拡張形式 -- 日次平均は0値を除外(API失敗) -- **Google Sheets**: 別シート(PlayerData + DailyAverages)でCSV構造をミラー -- **レート制限処理**: QueuedGoogleSheetsServiceがAPIクォータとリトライを管理 -- **手動同期**: データ不整合解決のための`npm run sync-google-sheets` \ No newline at end of file +品質管理は`typecheck`、`lint`、`test`で行う。テストは`tests/`にsrc/のミラー構造で配置。 + +# リリース + +```bash +bun run release # パッチバージョン (1.0.0 → 1.0.1) +bun run release:minor # マイナーバージョン +bun run release:major # メジャーバージョン +``` + +リリーススクリプトがバージョン更新、CHANGELOG同期、gitタグ作成を自動化する。 + +# ツールコマンド + +ツールは`src/tools/`にあり、ビルドせずBunで直接実行する。 + +```bash +bun run import-csv # 既存CSVデータをSQLiteにインポート +bun run export-csv # SQLiteデータをCSVにエクスポート +bun run generate-charts # チャート画像生成 +``` + +# アーキテクチャ + +## ランタイム・ツールチェイン + +- **ランタイム**: Bun (Node.jsではない) +- **ビルド**: Bun.build (ESM, コード分割有効) - `scripts/build.ts` +- **Lint/Format**: Biome (`@yuu1111/biome-config`を継承) +- **型チェック**: TypeScript 5.9 strict (`@yuu1111/tsconfig`を継承, `noEmit: true`) +- **バリデーション**: Zod (設定、API応答) +- **ストレージ**: SQLite (`bun:sqlite`, 依存ゼロ) + +## ソースコード構成 + +- `src/main.ts` - エントリーポイント。依存を直接組み立て、setIntervalで毎分スケジュール実行 +- `src/config.ts` - 環境変数をZodスキーマでパースし設定オブジェクトを生成 +- `src/db.ts` - SQLiteデータベース初期化、マイグレーション、クエリヘルパー +- `src/logger.ts` - 軽量構造化ロガー (JSON stdout出力) +- `src/retry.ts` - 指数バックオフ付きリトライハンドラ +- `src/googleSheets.ts` - 汎用Google Sheetsアクセサ (SheetAccessor) +- `src/schemas/` - Zodバリデーションスキーマ (Steam API, Google認証情報) +- `src/jobs/` - cronジョブ (collectData, dailyAverage, syncSheets) +- `src/tools/` - CLIツール (importCsv, exportCsv, generateCharts) +- `scripts/` - ビルド・リリーススクリプト (TypeScript, Bunで直接実行) + +## 設計パターン + +- **設定管理**: 全設定は`.env`経由。Zodの判別共用体(discriminated union)でGoogle Sheets有効/無効を型安全に分岐 +- **ストレージ**: SQLite (WALモード, prepared statements)。日次平均はSQLクエリで計算 +- **Google Sheets同期**: DB駆動 (`synced_at IS NULL` で未同期レコードを追跡)。プロセス再起動でもデータ損失なし +- **依存注入**: サービスコンテナなし。main.tsで組み立てて関数引数で渡す +- **リトライ**: 指数バックオフ (5倍乗数, 最大30秒) +- **グレースフルシャットダウン**: シグナルリスナーでcronジョブ解除 + DB close +- **ロギング**: 構造化JSON (stdout出力, ファイル出力はプロセスマネージャに委譲) + +## CI/CD + +- `.github/workflows/ci.yml` - push/PRで`typecheck` → `lint` → `build`を実行 +- `.github/workflows/release.yml` - `v*`タグでGitHubリリース作成 (tar.gz/zip) diff --git a/LICENCE.txt b/LICENCE similarity index 100% rename from LICENCE.txt rename to LICENCE diff --git a/README-JP.md b/README-JP.md index 1733f7a..1f4bd07 100644 --- a/README-JP.md +++ b/README-JP.md @@ -1,328 +1,150 @@ # SteamPlayerTracker -**言語:** [English](README.md) | [日本語](README-JP.md) +[English](README.md) | [日本語](README-JP.md) -SteamPlayerTracker は、指定されたSteamゲームの現在の同時接続数(プレイヤー数)を定期的に取得し、そのデータをCSV形式で記録するアプリケーションです。オプションでGoogleスプレッドシートへの連携機能も提供します。 +Steam Web APIを通じてゲームの同時接続プレイヤー数を定期取得し、SQLiteに記録するBun製TypeScriptアプリケーション。Google Sheetsへの同期にも対応。 -## 機能 +## 特徴 -- 🎮 **Steam Web API からのプレイヤー数取得**: 指定したゲームの現在のプレイヤー数を自動取得 -- 📊 **CSV形式での記録**: タイムスタンプ付きでプレイヤー数をCSVファイルに保存 -- 📈 **拡張日次統計**: 日次平均と最大・最小プレイヤー数およびそれらのタイムスタンプを記録 -- 🔄 **CSV-Googleシート同期**: データ不整合を解決する手動同期ツール -- 📋 **Googleスプレッドシート連携**: レート制限対応付きのスプレッドシート直接書き込み -- ⏰ **柔軟なスケジューリング**: 任意の分指定で定期実行 -- 🔄 **エラーハンドリング・リトライ機能**: 指数関数的バックオフによる自動リトライ -- 📝 **詳細なロギング**: ログレベル管理・ローテーション対応 -- 🛡️ **型安全性**: TypeScript による型チェック -- 🚀 **起動時の即座データ取得**: スクリプト開始時に現在のプレイヤー数を取得 -- 📊 **未計算の日次平均を自動補完**: 起動時に過去の未計算分を自動計算 -- 🖥️ **クロスプラットフォームスクリプト**: すべての操作用のPowerShell、Bash、Batchスクリプト +- setIntervalベースのスケジュール収集 +- SQLiteストレージ (WALモード) と自動スキーママイグレーション +- 日次統計: 平均、最大/最小プレイヤー数とタイムスタンプ +- Google Sheetsへのレート制限付き同期 (100ms) +- 指数バックオフによる自動リトライ +- 外部データのZodバリデーション (設定、APIレスポンス) +- グレースフルシャットダウン ## 必要な環境 -- Node.js 18.x 以上 -- (Windows) PowerShell Core (pwsh) - [https://aka.ms/PSWindows](https://aka.ms/PSWindows) からインストール -- (オプション) Google Cloud Platform アカウント(スプレッドシート連携時) +- [Bun](https://bun.sh/) v1.3.11+ +- (任意) Google Cloud Platformサービスアカウント (Sheets連携時) ## セットアップ -### クイックセットアップ(推奨) - -**Windows:** -```batch -setup.bat -``` - -**Linux/macOS:** -```bash -./setup.sh -``` - -### 手動セットアップ - -#### 1. プロジェクトのクローンと依存関係のインストール - ```bash -git clone +git clone https://github.com/yuu1111/SteamPlayerTracker.git cd SteamPlayerTracker -npm install +bun install ``` -#### 2. 環境変数の設定 - -`.env.example` を `.env` にコピーして設定してください: +`.env.example`を`.env`にコピーして編集: ```bash cp .env.example .env ``` -`.env` ファイルを編集: - -```env -# Steam Settings -STEAM_APP_ID=730 - -# Output Settings -CSV_OUTPUT_ENABLED=true -CSV_FILE_PATH=steam_concurrent_players.csv -DAILY_AVERAGE_CSV_ENABLED=true -DAILY_AVERAGE_CSV_FILE_PATH=steam_daily_averages.csv - -# Scheduling Settings (分を指定: カンマ区切り) -COLLECTION_MINUTES=0,30 -# 日次平均を計算する時刻 (0-23) -DAILY_AVERAGE_HOUR=0 - -# Retry Settings -MAX_RETRIES=3 -RETRY_BASE_DELAY=1000 - -# Logging Settings -LOG_LEVEL=info -LOG_FILE_PATH=logs/steam-tracker.log - -# Google Sheets Integration (Optional) -GOOGLE_SHEETS_ENABLED=false -GOOGLE_SHEETS_SPREADSHEET_ID= -GOOGLE_SHEETS_SHEET_NAME=PlayerData -GOOGLE_SHEETS_DAILY_AVERAGE_SHEET_NAME=DailyAverages -GOOGLE_SERVICE_ACCOUNT_KEY_PATH= -``` - -#### 3. ゲームIDの確認 - -Steam ストアページのURLからApp IDを確認できます: -- 例: `https://store.steampowered.com/app/730/` → App ID は `730` (Counter-Strike 2) +最低限`STEAM_APP_ID`を設定すれば動く。SteamストアのURLからApp IDを確認できる: -## 使用方法 - -### クイックスタート(Windows) - -```batch -# ビルドのみ -build.bat - -# ビルドして起動 -start.bat ``` - -### クイックスタート(Linux/macOS) - -```bash -# ビルドのみ -./build.sh - -# ビルドして起動 -./start.sh +https://store.steampowered.com/app/730/ → STEAM_APP_ID=730 (Counter-Strike 2) ``` -### 開発環境での実行 +## 使い方 ```bash -npm run dev +bun run dev # watchモードで開発 +bun run build # dist/にビルド +bun run start # ビルド済みファイルを実行 ``` -### 本番環境での実行 +### ツール ```bash -npm run build -npm start +bun run import-csv # 既存CSVデータをSQLiteにインポート +bun run export-csv # SQLiteデータをCSVにエクスポート +bun run generate-charts # チャート画像を生成 ``` -### 日次平均の手動計算 - -過去のデータから全ての日次平均を計算: +### 品質管理 ```bash -npm run calculate-daily-averages +bun run typecheck # TypeScript型チェック (tsc --noEmit) +bun run lint # Biome lint +bun run format # Biome format (--write --unsafe) ``` -### Googleシート同期 - -ローカルCSVデータをGoogleシートと手動で同期: +## 設定 -```bash -# npmコマンドを使用 -npm run sync-google-sheets +設定はすべて`.env`で管理。全項目は[`.env.example`](.env.example)を参照。 -# プラットフォーム固有スクリプトを使用 -sync-google-sheets.bat # Windows(Batch) -sync-google-sheets.ps1 # Windows(PowerShell) -./sync-google-sheets.sh # Linux/macOS -``` +| 変数 | 説明 | デフォルト | +|------|------|-----------| +| `STEAM_APP_ID` | 追跡するSteam App ID | (必須) | +| `DB_PATH` | SQLiteデータベースファイルパス | `data/steam-tracker.db` | +| `COLLECTION_MINUTES` | データ取得する分 (カンマ区切り) | `0,30` | +| `DAILY_AVERAGE_HOUR` | 日次平均を計算する時刻 (0-23) | `0` | +| `SHEETS_SYNC_MINUTES` | Google Sheets同期する分 (カンマ区切り) | `5,35` | +| `LOG_LEVEL` | ログレベル (debug/info/warn/error) | `info` | +| `GOOGLE_SHEETS_ENABLED` | Google Sheets連携の有効化 | `false` | -### バックグラウンド実行(Linux/Mac) - -```bash -nohup npm start > output.log 2>&1 & -``` +### Google Sheets連携 -### Windows でのバックグラウンド実行 - -```bash -# PowerShell -Start-Process npm -ArgumentList "start" -WindowStyle Hidden -``` - -## リリース管理 - -### 自動リリース - -```bash -npm run release # パッチリリース(1.0.0 → 1.0.1) -npm run release:minor # マイナーリリース(1.0.0 → 1.1.0) -npm run release:major # メジャーリリース(1.0.0 → 2.0.0) -``` - -リリーススクリプトは以下を自動実行します: -- 型チェックと静的解析 -- アプリケーションのビルド -- package.jsonのバージョン更新 -- Gitコミットとタグの作成 -- GitHub Actions デプロイメントの準備 - -### GitHub Actions - -ローカルでリリーススクリプトを実行後: -1. `git push origin main` -2. `git push origin v1.0.1`(作成されたタグをプッシュ) -3. GitHub Actions が自動でリリース成果物を作成 - -## Googleスプレッドシート連携(オプション) - -### 1. Google Cloud Platform 設定 - -1. [Google Cloud Console](https://console.cloud.google.com/) でプロジェクトを作成 -2. Google Sheets API を有効化 -3. サービスアカウントを作成してキーファイル(JSON)をダウンロード - -### 2. 環境変数設定 +1. [Google Cloud Console](https://console.cloud.google.com/)でプロジェクトを作成 +2. Google Sheets APIを有効化 +3. サービスアカウントを作成しJSONキーをダウンロード +4. スプレッドシートをサービスアカウントのメールアドレスに共有 +5. `.env`に以下を設定: ```env GOOGLE_SHEETS_ENABLED=true GOOGLE_SHEETS_SPREADSHEET_ID=your_spreadsheet_id -GOOGLE_SHEETS_SHEET_NAME=PlayerData -GOOGLE_SHEETS_DAILY_AVERAGE_SHEET_NAME=DailyAverages -GOOGLE_SERVICE_ACCOUNT_KEY_PATH=path/to/service-account-key.json -``` - -### 3. スプレッドシート共有 - -作成したスプレッドシートをサービスアカウントのメールアドレスと共有してください。 - -## 設定オプション - -| 設定項目 | 説明 | デフォルト値 | -|---------|------|-------------| -| `STEAM_APP_ID` | 追跡するゲームのApp ID | 必須 | -| `CSV_OUTPUT_ENABLED` | CSV出力の有効/無効 | `true` | -| `CSV_FILE_PATH` | CSV出力ファイルパス | `steam_concurrent_players.csv` | -| `DAILY_AVERAGE_CSV_ENABLED` | 日次平均CSV出力の有効/無効 | `true` | -| `DAILY_AVERAGE_CSV_FILE_PATH` | 日次平均CSV出力ファイルパス | `steam_daily_averages.csv` | -| `COLLECTION_MINUTES` | データ取得する分(カンマ区切り) | `0,30` | -| `DAILY_AVERAGE_HOUR` | 日次平均を計算する時刻(0-23) | `0` | -| `MAX_RETRIES` | 最大リトライ回数 | `3` | -| `RETRY_BASE_DELAY` | リトライ基本遅延時間(ms) | `1000` | -| `LOG_LEVEL` | ログレベル(debug/info/warn/error) | `info` | -| `LOG_FILE_PATH` | ログファイルパス | `logs/steam-tracker.log` | - -## CSVファイル構造 - -### メインデータファイル -```csv -timestamp,player_count -2024-06-23 10:00:00,12345 -2024-06-23 10:30:00,13456 +GOOGLE_SERVICE_ACCOUNT_KEY_PATH=path/to/key.json ``` -### 日次平均ファイル(拡張フォーマット) -```csv -date,average_player_count,sample_count,max_player_count,max_timestamp,min_player_count,min_timestamp -2024-06-22,12890,48,15420,2024-06-22 18:30:00,8450,2024-06-22 05:00:00 -2024-06-23,13245,48,16890,2024-06-23 19:00:00,9120,2024-06-23 04:30:00 -``` +## データストレージ -**注意**: 日次平均計算時、プレイヤー数が0のデータは除外されます(API取得失敗とみなすため)。拡張フォーマットでは最大・最小プレイヤー数とその正確なタイムスタンプが含まれます。 +データはSQLiteに保存される (デフォルト: `data/steam-tracker.db`)。 -## トラブルシューティング +**プレイヤー数テーブル** (`player_data`): -### よくある問題 +| カラム | 型 | 説明 | +|--------|------|------| +| `timestamp` | TEXT | UTCタイムスタンプ (YYYY-MM-DD HH:mm:ss) | +| `player_count` | INTEGER | 同時接続プレイヤー数 | +| `synced_at` | TEXT | Google Sheets同期タイムスタンプ (未同期はNULL) | -1. **Steam API エラー** - - App IDが有効か確認 - - ネットワーク接続を確認 +**日次平均テーブル** (`daily_averages`): -2. **ファイル書き込みエラー** - - ディスクの空き容量を確認 - - ファイル・ディレクトリの権限を確認 +| カラム | 型 | 説明 | +|--------|------|------| +| `date` | TEXT (PK) | 日付 (YYYY-MM-DD) | +| `average_player_count` | INTEGER | 平均プレイヤー数 | +| `sample_count` | INTEGER | サンプル数 | +| `max_player_count` | INTEGER | 最大プレイヤー数 | +| `max_timestamp` | TEXT | 最大時のタイムスタンプ | +| `min_player_count` | INTEGER | 最小プレイヤー数 | +| `min_timestamp` | TEXT | 最小時のタイムスタンプ | -3. **Googleスプレッドシートエラー** - - サービスアカウントキーファイルのパスが正しいか確認 - - スプレッドシートIDが正しいか確認 - - スプレッドシートがサービスアカウントと共有されているか確認 +プレイヤー数0のレコードは平均計算から除外される (API取得失敗とみなす)。 -### ログの確認 +## プロジェクト構成 -```bash -# ログファイルの確認 -tail -f logs/steam-tracker.log - -# エラーのみフィルタ -grep "ERROR" logs/steam-tracker.log ``` - -## 開発者向け - -### スクリプト +src/ +├── main.ts # エントリーポイント、スケジューラ登録 +├── config.ts # Zod判別共用体による環境変数パース +├── db.ts # SQLite初期化、マイグレーション、クエリヘルパー +├── logger.ts # 構造化JSONロガー (stdout出力) +├── retry.ts # 指数バックオフ付きリトライハンドラ +├── googleSheets.ts # 汎用Google Sheetsアクセサ (SheetAccessor) +├── schemas/ # Zodスキーマ (Steam API, Google認証情報) +├── jobs/ # cronジョブ (collectData, dailyAverage, syncSheets) +└── tools/ # CLIツール (importCsv, exportCsv, generateCharts) +scripts/ +├── build.ts # Bun.buildスクリプト +└── release.ts # バージョン更新、CHANGELOG、gitタグ +``` + +## リリース ```bash -npm run build # TypeScriptをコンパイル -npm run dev # 開発モードで実行 -npm run watch # ファイル変更を監視してコンパイル -npm run clean # distディレクトリをクリア -npm run lint # ESLintによる静的解析 -npm run typecheck # TypeScriptの型チェック -npm run calculate-daily-averages # 全日次平均を計算 -npm run sync-google-sheets # CSVデータをGoogleシートに同期 +bun run release # パッチ (1.0.0 → 1.0.1) +bun run release:minor # マイナー (1.0.0 → 1.1.0) +bun run release:major # メジャー (1.0.0 → 2.0.0) ``` -### 起動スクリプト - -| ファイル | 説明 | プラットフォーム | -|---------|------|------------------| -| `setup.bat` | 初回セットアップ用バッチファイル | Windows | -| `build.bat` | ビルド用バッチファイル | Windows | -| `start.bat` | 起動用バッチファイル | Windows | -| `sync-google-sheets.bat` | Googleシート同期用バッチファイル | Windows | -| `setup.ps1` | 初回セットアップ用PowerShell Coreスクリプト | Windows | -| `build.ps1` | ビルド用PowerShell Coreスクリプト | Windows | -| `start.ps1` | 起動用PowerShell Coreスクリプト | Windows | -| `sync-google-sheets.ps1` | Googleシート同期用PowerShellスクリプト | Windows | -| `setup.sh` | 初回セットアップ用シェルスクリプト | Linux/macOS | -| `build.sh` | ビルド用シェルスクリプト | Linux/macOS | -| `start.sh` | 起動用シェルスクリプト | Linux/macOS | -| `sync-google-sheets.sh` | Googleシート同期用シェルスクリプト | Linux/macOS | - -### ディレクトリ構造 - -``` -src/ -├── config/ # 設定管理 -├── services/ # 各種サービス -│ ├── csvWriter.ts -│ ├── dailyAverageService.ts -│ ├── googleSheets.ts -│ ├── scheduler.ts -│ └── steamApi.ts -├── tools/ # コマンドラインツール -│ └── calculateAllDailyAverages.ts -├── types/ # 型定義 -├── utils/ # ユーティリティ -├── steamPlayerTracker.ts # メインクラス -└── index.ts # エントリーポイント -``` +リリーススクリプトが品質チェック、ビルド、バージョン更新、gitタグ作成を行う。タグをpushするとGitHub Actionsのリリースワークフローが実行される。 ## ライセンス -MIT License \ No newline at end of file +[MIT](LICENCE) diff --git a/README.md b/README.md index 19b723e..ace276a 100644 --- a/README.md +++ b/README.md @@ -1,328 +1,150 @@ # SteamPlayerTracker -**Languages:** [English](README.md) | [日本語](README-JP.md) +[English](README.md) | [日本語](README-JP.md) -SteamPlayerTracker is an application that periodically retrieves the current concurrent player count of specified Steam games and records the data in CSV format. It also provides optional Google Sheets integration functionality. +A Bun-based TypeScript application that periodically fetches concurrent player counts for Steam games via the Steam Web API, records data to SQLite, and optionally syncs to Google Sheets. ## Features -- 🎮 **Steam Web API Player Count Retrieval**: Automatically fetches current player count for specified games -- 📊 **CSV Format Recording**: Saves player count data to CSV files with timestamps -- 📈 **Enhanced Daily Statistics**: Records daily averages with max/min player counts and their timestamps -- 🔄 **CSV-Google Sheets Sync**: Manual synchronization tool to resolve data discrepancies -- 📋 **Google Sheets Integration**: Optional direct data writing to spreadsheets with rate limit handling -- ⏰ **Flexible Scheduling**: Periodic execution with customizable minute intervals -- 🔄 **Error Handling & Retry Functionality**: Automatic retry with exponential backoff -- 📝 **Detailed Logging**: Log level management with rotation support -- 🛡️ **Type Safety**: TypeScript type checking -- 🚀 **Immediate Data Collection on Startup**: Fetches current player count when script starts -- 📊 **Automatic Daily Average Backfill**: Automatically calculates missing daily averages on startup -- 🖥️ **Cross-Platform Scripts**: PowerShell, Bash, and Batch scripts for all operations +- Scheduled player count collection (setInterval-based) +- SQLite storage (WAL mode) with automatic schema migration +- Daily statistics: average, max/min player counts with timestamps +- Optional Google Sheets sync with rate limiting (100ms) +- Automatic retry with exponential backoff +- Zod validation for all external data (config, API responses) +- Graceful shutdown handling ## Requirements -- Node.js 18.x or higher -- (Windows) PowerShell Core (pwsh) - Install from [https://aka.ms/PSWindows](https://aka.ms/PSWindows) -- (Optional) Google Cloud Platform account (for spreadsheet integration) +- [Bun](https://bun.sh/) v1.3.11+ +- (Optional) Google Cloud Platform service account for Sheets integration ## Setup -### Quick Setup (Recommended) - -**Windows:** -```batch -setup.bat -``` - -**Linux/macOS:** ```bash -./setup.sh -``` - -### Manual Setup - -#### 1. Clone Project and Install Dependencies - -```bash -git clone +git clone https://github.com/yuu1111/SteamPlayerTracker.git cd SteamPlayerTracker -npm install +bun install ``` -#### 2. Environment Configuration - -Copy `.env.example` to `.env` and configure: +Copy `.env.example` to `.env` and edit: ```bash cp .env.example .env ``` -Edit the `.env` file: - -```env -# Steam Settings -STEAM_APP_ID=730 - -# Output Settings -CSV_OUTPUT_ENABLED=true -CSV_FILE_PATH=steam_concurrent_players.csv -DAILY_AVERAGE_CSV_ENABLED=true -DAILY_AVERAGE_CSV_FILE_PATH=steam_daily_averages.csv - -# Scheduling Settings (specify minutes: comma-separated) -COLLECTION_MINUTES=0,30 -# Hour to calculate daily averages (0-23) -DAILY_AVERAGE_HOUR=0 - -# Retry Settings -MAX_RETRIES=3 -RETRY_BASE_DELAY=1000 - -# Logging Settings -LOG_LEVEL=info -LOG_FILE_PATH=logs/steam-tracker.log - -# Google Sheets Integration (Optional) -GOOGLE_SHEETS_ENABLED=false -GOOGLE_SHEETS_SPREADSHEET_ID= -GOOGLE_SHEETS_SHEET_NAME=PlayerData -GOOGLE_SHEETS_DAILY_AVERAGE_SHEET_NAME=DailyAverages -GOOGLE_SERVICE_ACCOUNT_KEY_PATH= -``` - -#### 3. Finding Game IDs - -You can find App IDs from Steam store page URLs: -- Example: `https://store.steampowered.com/app/730/` → App ID is `730` (Counter-Strike 2) - -## Usage - -### Quick Start (Windows) - -```batch -# Build only -build.bat - -# Build and start -start.bat -``` - -### Quick Start (Linux/macOS) - -```bash -# Build only -./build.sh - -# Build and start -./start.sh -``` - -### Development Environment - -```bash -npm run dev -``` - -### Production Environment - -```bash -npm run build -npm start -``` - -### Manual Daily Average Calculation +At minimum, set `STEAM_APP_ID` to the game you want to track. Find it from the Steam store URL: -Calculate all daily averages from historical data: - -```bash -npm run calculate-daily-averages ``` - -### Google Sheets Synchronization - -Manually sync local CSV data with Google Sheets: - -```bash -# Using npm command -npm run sync-google-sheets - -# Using platform-specific scripts -sync-google-sheets.bat # Windows (Batch) -sync-google-sheets.ps1 # Windows (PowerShell) -./sync-google-sheets.sh # Linux/macOS +https://store.steampowered.com/app/730/ → STEAM_APP_ID=730 (Counter-Strike 2) ``` -### Background Execution (Linux/Mac) +## Usage ```bash -nohup npm start > output.log 2>&1 & +bun run dev # Watch mode +bun run build # Build to dist/ +bun run start # Run built output ``` -### Windows Background Execution +### Tools ```bash -# PowerShell -Start-Process npm -ArgumentList "start" -WindowStyle Hidden +bun run import-csv # Import existing CSV data into SQLite +bun run export-csv # Export SQLite data to CSV +bun run generate-charts # Generate chart images ``` -## Release Management - -### Automated Release +### Quality ```bash -npm run release # Patch release (1.0.0 → 1.0.1) -npm run release:minor # Minor release (1.0.0 → 1.1.0) -npm run release:major # Major release (1.0.0 → 2.0.0) +bun run typecheck # TypeScript type check (tsc --noEmit) +bun run lint # Biome lint +bun run format # Biome format (--write --unsafe) ``` -The release script automatically: -- Runs type checking and linting -- Builds the application -- Updates version in package.json -- Creates Git commit and tag -- Prepares for GitHub Actions deployment +## Configuration -### GitHub Actions +All configuration is via `.env`. See [`.env.example`](.env.example) for the full list. -After running the release script locally: -1. `git push origin main` -2. `git push origin v1.0.1` (push the created tag) -3. GitHub Actions automatically creates release artifacts - -## Google Sheets Integration (Optional) +| Variable | Description | Default | +|----------|-------------|---------| +| `STEAM_APP_ID` | Steam App ID to track | (required) | +| `DB_PATH` | SQLite database file path | `data/steam-tracker.db` | +| `COLLECTION_MINUTES` | Minutes to collect data (comma-separated) | `0,30` | +| `DAILY_AVERAGE_HOUR` | Hour to calculate daily averages (0-23) | `0` | +| `SHEETS_SYNC_MINUTES` | Minutes to sync to Google Sheets (comma-separated) | `5,35` | +| `LOG_LEVEL` | Log level (debug/info/warn/error) | `info` | +| `GOOGLE_SHEETS_ENABLED` | Enable Google Sheets integration | `false` | -### 1. Google Cloud Platform Setup +### Google Sheets Integration 1. Create a project in [Google Cloud Console](https://console.cloud.google.com/) -2. Enable Google Sheets API -3. Create a service account and download the key file (JSON) - -### 2. Environment Configuration +2. Enable the Google Sheets API +3. Create a service account and download the JSON key +4. Share your spreadsheet with the service account email +5. Set the following in `.env`: ```env GOOGLE_SHEETS_ENABLED=true GOOGLE_SHEETS_SPREADSHEET_ID=your_spreadsheet_id -GOOGLE_SHEETS_SHEET_NAME=PlayerData -GOOGLE_SHEETS_DAILY_AVERAGE_SHEET_NAME=DailyAverages -GOOGLE_SERVICE_ACCOUNT_KEY_PATH=path/to/service-account-key.json -``` - -### 3. Spreadsheet Sharing - -Share the created spreadsheet with the service account email address. - -## Configuration Options - -| Setting | Description | Default Value | -|---------|-------------|---------------| -| `STEAM_APP_ID` | App ID of the game to track | Required | -| `CSV_OUTPUT_ENABLED` | Enable/disable CSV output | `true` | -| `CSV_FILE_PATH` | CSV output file path | `steam_concurrent_players.csv` | -| `DAILY_AVERAGE_CSV_ENABLED` | Enable/disable daily average CSV output | `true` | -| `DAILY_AVERAGE_CSV_FILE_PATH` | Daily average CSV output file path | `steam_daily_averages.csv` | -| `COLLECTION_MINUTES` | Minutes to collect data (comma-separated) | `0,30` | -| `DAILY_AVERAGE_HOUR` | Hour to calculate daily averages (0-23) | `0` | -| `MAX_RETRIES` | Maximum retry attempts | `3` | -| `RETRY_BASE_DELAY` | Base retry delay (ms) | `1000` | -| `LOG_LEVEL` | Log level (debug/info/warn/error) | `info` | -| `LOG_FILE_PATH` | Log file path | `logs/steam-tracker.log` | - -## CSV File Structure - -### Main Data File -```csv -timestamp,player_count -2024-06-23 10:00:00,12345 -2024-06-23 10:30:00,13456 +GOOGLE_SERVICE_ACCOUNT_KEY_PATH=path/to/key.json ``` -### Daily Average File (Enhanced Format) -```csv -date,average_player_count,sample_count,max_player_count,max_timestamp,min_player_count,min_timestamp -2024-06-22,12890,48,15420,2024-06-22 18:30:00,8450,2024-06-22 05:00:00 -2024-06-23,13245,48,16890,2024-06-23 19:00:00,9120,2024-06-23 04:30:00 -``` +## Data Storage -**Note**: When calculating daily averages, data with 0 player count is excluded (considered API fetch failures). The enhanced format includes maximum and minimum player counts with their exact timestamps. +Data is stored in SQLite (`data/steam-tracker.db` by default). -## Troubleshooting +**Player count table** (`player_data`): -### Common Issues +| Column | Type | Description | +|--------|------|-------------| +| `timestamp` | TEXT | UTC timestamp (YYYY-MM-DD HH:mm:ss) | +| `player_count` | INTEGER | Concurrent player count | +| `synced_at` | TEXT | Google Sheets sync timestamp (NULL if unsynced) | -1. **Steam API Errors** - - Verify App ID is valid - - Check network connectivity +**Daily averages table** (`daily_averages`): -2. **File Write Errors** - - Check disk space - - Verify file/directory permissions +| Column | Type | Description | +|--------|------|-------------| +| `date` | TEXT (PK) | Date (YYYY-MM-DD) | +| `average_player_count` | INTEGER | Average player count | +| `sample_count` | INTEGER | Number of samples | +| `max_player_count` | INTEGER | Maximum player count | +| `max_timestamp` | TEXT | Timestamp of max count | +| `min_player_count` | INTEGER | Minimum player count | +| `min_timestamp` | TEXT | Timestamp of min count | -3. **Google Sheets Errors** - - Verify service account key file path - - Check spreadsheet ID - - Ensure spreadsheet is shared with service account +Records with 0 player count are excluded from averages (treated as API failures). -### Log Checking +## Project Structure -```bash -# View log file -tail -f logs/steam-tracker.log - -# Filter errors only -grep "ERROR" logs/steam-tracker.log ``` - -## Development - -### Scripts +src/ +├── main.ts # Entry point, scheduler registration +├── config.ts # Env parsing with Zod discriminated union +├── db.ts # SQLite initialization, migrations, query helpers +├── logger.ts # Structured JSON logger (stdout) +├── retry.ts # Exponential backoff retry handler +├── googleSheets.ts # Generic Google Sheets accessor (SheetAccessor) +├── schemas/ # Zod schemas (Steam API, Google credentials) +├── jobs/ # Cron jobs (collectData, dailyAverage, syncSheets) +└── tools/ # CLI tools (importCsv, exportCsv, generateCharts) +scripts/ +├── build.ts # Bun.build script +└── release.ts # Version bump, changelog, git tag +``` + +## Release ```bash -npm run build # Compile TypeScript -npm run dev # Run in development mode -npm run watch # Watch files and compile -npm run clean # Clear dist directory -npm run lint # ESLint static analysis -npm run typecheck # TypeScript type checking -npm run calculate-daily-averages # Calculate all daily averages -npm run sync-google-sheets # Sync CSV data to Google Sheets +bun run release # Patch (1.0.0 → 1.0.1) +bun run release:minor # Minor (1.0.0 → 1.1.0) +bun run release:major # Major (1.0.0 → 2.0.0) ``` -### Platform Scripts - -| File | Description | Platform | -|------|-------------|----------| -| `setup.bat` | Initial setup batch file | Windows | -| `build.bat` | Build batch file | Windows | -| `start.bat` | Start batch file | Windows | -| `sync-google-sheets.bat` | Google Sheets sync batch file | Windows | -| `setup.ps1` | Initial setup PowerShell Core script | Windows | -| `build.ps1` | Build PowerShell Core script | Windows | -| `start.ps1` | Start PowerShell Core script | Windows | -| `sync-google-sheets.ps1` | Google Sheets sync PowerShell script | Windows | -| `setup.sh` | Initial setup shell script | Linux/macOS | -| `build.sh` | Build shell script | Linux/macOS | -| `start.sh` | Start shell script | Linux/macOS | -| `sync-google-sheets.sh` | Google Sheets sync shell script | Linux/macOS | - -### Directory Structure - -``` -src/ -├── config/ # Configuration management -├── services/ # Various services -│ ├── csvWriter.ts -│ ├── dailyAverageService.ts -│ ├── googleSheets.ts -│ ├── scheduler.ts -│ └── steamApi.ts -├── tools/ # Command-line tools -│ └── calculateAllDailyAverages.ts -├── types/ # Type definitions -├── utils/ # Utilities -├── steamPlayerTracker.ts # Main class -└── index.ts # Entry point -``` +The release script runs quality checks, builds, bumps the version, and creates a git tag. Push the tag to trigger the GitHub Actions release workflow. ## License -MIT License \ No newline at end of file +[MIT](LICENCE) diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..509e4fd --- /dev/null +++ b/biome.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", + "extends": ["@yuu1111/biome-config/biome"] +} diff --git a/build.bat b/build.bat deleted file mode 100644 index af400b9..0000000 --- a/build.bat +++ /dev/null @@ -1,27 +0,0 @@ -@echo off -REM Steam Player Tracker Build Script for Windows - -title Steam Player Tracker - Building... - -REM Check if pwsh is installed -where pwsh >nul 2>&1 -if %ERRORLEVEL% neq 0 ( - echo "Error: PowerShell Core (pwsh) is not installed" - echo "Please install PowerShell from https://aka.ms/PSWindows" - echo. - echo "Press any key to exit..." - pause > nul - exit /b 1 -) - -REM Run PowerShell script with execution policy bypass -pwsh -ExecutionPolicy Bypass -File build.ps1 - -REM Keep window open -echo. -if %ERRORLEVEL% neq 0 ( - echo "Build failed! Press any key to exit..." -) else ( - echo "Build completed! Press any key to exit..." -) -pause > nul \ No newline at end of file diff --git a/build.ps1 b/build.ps1 deleted file mode 100644 index 2c1b8bb..0000000 --- a/build.ps1 +++ /dev/null @@ -1,41 +0,0 @@ -# Steam Player Tracker Build Script for PowerShell Core (pwsh) - -$Host.UI.RawUI.WindowTitle = "Steam Player Tracker - Building..." - -Write-Host "Steam Player Tracker - Build Script" -ForegroundColor Cyan -Write-Host "===================================" -ForegroundColor Cyan - -# Check if npm is installed -try { - $npmVersion = npm --version - Write-Host "npm version: $npmVersion" -ForegroundColor Green -} catch { - Write-Host "Error: npm is not installed" -ForegroundColor Red - Write-Host "Please install Node.js from https://nodejs.org/" -ForegroundColor Yellow - exit 1 -} - -# Install dependencies if node_modules doesn't exist -if (!(Test-Path "node_modules")) { - Write-Host "`nInstalling dependencies..." -ForegroundColor Yellow - npm install - if ($LASTEXITCODE -ne 0) { - Write-Host "Error: Failed to install dependencies" -ForegroundColor Red - exit 1 - } -} - -# Clean previous build -Write-Host "`nCleaning previous build..." -ForegroundColor Yellow -npm run clean - -# Build the project -Write-Host "`nBuilding project..." -ForegroundColor Yellow -npm run build - -if ($LASTEXITCODE -eq 0) { - Write-Host "`nBuild completed successfully!" -ForegroundColor Green -} else { - Write-Host "`nBuild failed!" -ForegroundColor Red - exit 1 -} \ No newline at end of file diff --git a/build.sh b/build.sh deleted file mode 100644 index 384584f..0000000 --- a/build.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/bin/bash - -# Steam Player Tracker Build Script for Linux/macOS - -echo -e "\033[36mSteam Player Tracker - Build Script\033[0m" -echo -e "\033[36m===================================\033[0m" - -# Check if npm is installed -if ! command -v npm &> /dev/null; then - echo -e "\033[31mError: npm is not installed\033[0m" - echo -e "\033[33mPlease install Node.js from https://nodejs.org/\033[0m" - exit 1 -fi - -echo -e "\033[32mnpm version: $(npm --version)\033[0m" - -# Install dependencies if node_modules doesn't exist -if [ ! -d "node_modules" ]; then - echo -e "\n\033[33mInstalling dependencies...\033[0m" - npm install - if [ $? -ne 0 ]; then - echo -e "\033[31mError: Failed to install dependencies\033[0m" - exit 1 - fi -fi - -# Clean previous build -echo -e "\n\033[33mCleaning previous build...\033[0m" -npm run clean - -# Build the project -echo -e "\n\033[33mBuilding project...\033[0m" -npm run build - -if [ $? -eq 0 ]; then - echo -e "\n\033[32mBuild completed successfully!\033[0m" -else - echo -e "\n\033[31mBuild failed!\033[0m" - exit 1 -fi \ No newline at end of file diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..a6e6b88 --- /dev/null +++ b/bun.lock @@ -0,0 +1,231 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "steam-player-tracker", + "dependencies": { + "chart.js": "^4.5.1", + "chartjs-node-canvas": "^5.0.0", + "dayjs": "^1.11.20", + "dev-clean": "^1.0.8", + "googleapis": "^171.4.0", + "zod": "^4.3.6", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.8", + "@types/bun": "^1.3.11", + "@yuu1111/biome-config": "^1.0.3", + "@yuu1111/tsconfig": "^1.0.2", + "typescript": "^6.0.2", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.4.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.8", "@biomejs/cli-darwin-x64": "2.4.8", "@biomejs/cli-linux-arm64": "2.4.8", "@biomejs/cli-linux-arm64-musl": "2.4.8", "@biomejs/cli-linux-x64": "2.4.8", "@biomejs/cli-linux-x64-musl": "2.4.8", "@biomejs/cli-win32-arm64": "2.4.8", "@biomejs/cli-win32-x64": "2.4.8" }, "bin": { "biome": "bin/biome" } }, "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.8", "", { "os": "linux", "cpu": "x64" }, "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.8", "", { "os": "win32", "cpu": "x64" }, "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg=="], + + "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "@yuu1111/biome-config": ["@yuu1111/biome-config@1.0.3", "", {}, "sha512-yidZXZUzEbRHbLTMWGuJqxaN9dlA66kJ1GQ1y/+msJP1UrHylO0t0gVJrObEfYxvrVH5PWDB7vVCxd+FNhDx/g=="], + + "@yuu1111/tsconfig": ["@yuu1111/tsconfig@1.0.2", "", {}, "sha512-XLQnFYy2dC+hboWnkNqHWFYOVDxhM0M33Yod8Zr7KzED2RaR4vfmI9efp4WtcnGnq64NWHylMV+at2Z3+zZQRg=="], + + "agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bignumber.js": ["bignumber.js@9.3.0", "", {}, "sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "canvas": ["canvas@3.2.0", "", { "dependencies": { "node-addon-api": "^7.0.0", "prebuild-install": "^7.1.3" } }, "sha512-jk0GxrLtUEmW/TmFsk2WghvgHe8B0pxGilqCL21y8lHkPUGa6FTsnCNtHPOzT8O3y+N+m3espawV80bbBlgfTA=="], + + "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], + + "chartjs-node-canvas": ["chartjs-node-canvas@5.0.0", "", { "dependencies": { "canvas": "^3.1.0", "tslib": "^2.8.1" }, "peerDependencies": { "chart.js": "^4.4.8" } }, "sha512-+Lc5phRWjb+UxAIiQpKgvOaG6Mw276YQx2jl2BrxoUtI3A4RYTZuGM5Dq+s4ReYmCY42WEPSR6viF3lDSTxpvw=="], + + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="], + + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + + "detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], + + "dev-clean": ["dev-clean@1.0.8", "", { "bin": { "dev-clean": "dist/cli.js" } }, "sha512-c5NzT7kbvt14sXs42Ypq0fKBc2+kG0vU7ISBxyQO8vv4MP12EtHJbrbXA7PQZ2n+ldGqAHTGPyhBFA5CFsrbBg=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + + "google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "googleapis": ["googleapis@171.4.0", "", { "dependencies": { "google-auth-library": "^10.2.0", "googleapis-common": "^8.0.0" } }, "sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg=="], + + "googleapis-common": ["googleapis-common@8.0.1", "", { "dependencies": { "extend": "^3.0.2", "gaxios": "^7.0.0-rc.4", "google-auth-library": "^10.1.0", "qs": "^6.7.0", "url-template": "^2.0.8" } }, "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.0", "", { "dependencies": { "jwa": "^2.0.0", "safe-buffer": "^5.0.1" } }, "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + + "node-abi": ["node-abi@3.75.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg=="], + + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": "bin.js" }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + + "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": "cli.js" }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "semver": ["semver@7.7.2", "", { "bin": "bin/semver.js" }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + + "tar-fs": ["tar-fs@2.1.3", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + + "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "url-template": ["url-template@2.0.8", "", {}, "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + } +} diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 19cff3d..0000000 --- a/package-lock.json +++ /dev/null @@ -1,3747 +0,0 @@ -{ - "name": "steam-player-tracker", - "version": "1.2.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "steam-player-tracker", - "version": "1.2.0", - "license": "MIT", - "dependencies": { - "axios": "^1.11.0", - "chart.js": "^4.5.0", - "chartjs-node-canvas": "^5.0.0", - "date-fns": "^4.1.0", - "dayjs": "^1.11.13", - "dotenv": "^16.6.1", - "googleapis": "^126.0.0", - "node-cron": "^3.0.0", - "winston": "^3.11.0" - }, - "devDependencies": { - "@types/node": "^20.19.11", - "@types/node-cron": "^3.0.0", - "@typescript-eslint/eslint-plugin": "^7.18.0", - "@typescript-eslint/parser": "^7.18.0", - "eslint": "^8.57.0", - "rimraf": "^6.0.1", - "ts-node": "^10.9.0", - "typescript": "^5.9.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@colors/colors": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", - "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@dabh/diagnostics": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", - "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", - "license": "MIT", - "dependencies": { - "colorspace": "1.1.x", - "enabled": "2.0.x", - "kuler": "^2.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@kurkle/color": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", - "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", - "license": "MIT" - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "20.19.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz", - "integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/node-cron": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", - "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/triple-beam": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", - "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", - "license": "MIT" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", - "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/type-utils": "7.18.0", - "@typescript-eslint/utils": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", - "natural-compare": "^1.4.0", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^7.0.0", - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", - "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/typescript-estree": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", - "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", - "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "7.18.0", - "@typescript-eslint/utils": "7.18.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/types": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", - "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", - "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/visitor-keys": "7.18.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", - "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.18.0", - "@typescript-eslint/types": "7.18.0", - "@typescript-eslint/typescript-estree": "7.18.0" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.56.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", - "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "7.18.0", - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^18.18.0 || >=20.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC" - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bignumber.js": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.0.tgz", - "integrity": "sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/canvas": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.0.tgz", - "integrity": "sha512-jk0GxrLtUEmW/TmFsk2WghvgHe8B0pxGilqCL21y8lHkPUGa6FTsnCNtHPOzT8O3y+N+m3espawV80bbBlgfTA==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^7.0.0", - "prebuild-install": "^7.1.3" - }, - "engines": { - "node": "^18.12.0 || >= 20.9.0" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chart.js": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz", - "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", - "license": "MIT", - "dependencies": { - "@kurkle/color": "^0.3.0" - }, - "engines": { - "pnpm": ">=8" - } - }, - "node_modules/chartjs-node-canvas": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chartjs-node-canvas/-/chartjs-node-canvas-5.0.0.tgz", - "integrity": "sha512-+Lc5phRWjb+UxAIiQpKgvOaG6Mw276YQx2jl2BrxoUtI3A4RYTZuGM5Dq+s4ReYmCY42WEPSR6viF3lDSTxpvw==", - "license": "MIT", - "dependencies": { - "canvas": "^3.1.0", - "tslib": "^2.8.1" - }, - "peerDependencies": { - "chart.js": "^4.4.8" - } - }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, - "node_modules/color": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", - "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.3", - "color-string": "^1.6.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "node_modules/color/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/color/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, - "node_modules/colorspace": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", - "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", - "license": "MIT", - "dependencies": { - "color": "^3.1.3", - "text-hex": "1.0.x" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } - }, - "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/enabled": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", - "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", - "license": "MIT" - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fecha": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", - "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", - "license": "MIT" - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flat-cache/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/flat-cache/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/flat-cache/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/flat-cache/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/fn.name": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", - "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", - "license": "MIT" - }, - "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gaxios": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", - "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9", - "uuid": "^9.0.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/gcp-metadata": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", - "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^6.1.1", - "google-logging-utils": "^0.0.2", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, - "node_modules/glob": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", - "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/google-auth-library": { - "version": "9.15.1", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", - "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^6.1.1", - "gcp-metadata": "^6.1.0", - "gtoken": "^7.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/google-logging-utils": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", - "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/googleapis": { - "version": "126.0.1", - "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-126.0.1.tgz", - "integrity": "sha512-4N8LLi+hj6ytK3PhE52KcM8iSGhJjtXnCDYB4fp6l+GdLbYz4FoDmx074WqMbl7iYMDN87vqD/8drJkhxW92mQ==", - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^9.0.0", - "googleapis-common": "^7.0.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/googleapis-common": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.2.0.tgz", - "integrity": "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "gaxios": "^6.0.3", - "google-auth-library": "^9.7.0", - "qs": "^6.7.0", - "url-template": "^2.0.8", - "uuid": "^9.0.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "node_modules/gtoken": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", - "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", - "license": "MIT", - "dependencies": { - "gaxios": "^6.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "license": "MIT" - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", - "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", - "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.0", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/kuler": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", - "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", - "license": "MIT" - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/logform": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", - "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", - "license": "MIT", - "dependencies": { - "@colors/colors": "1.6.0", - "@types/triple-beam": "^1.3.2", - "fecha": "^4.2.0", - "ms": "^2.1.1", - "safe-stable-stringify": "^2.3.1", - "triple-beam": "^1.3.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/lru-cache": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", - "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", - "dev": true, - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-abi": { - "version": "3.75.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", - "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "license": "MIT" - }, - "node_modules/node-cron": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", - "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", - "license": "ISC", - "dependencies": { - "uuid": "8.3.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/node-cron/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/one-time": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", - "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", - "license": "MIT", - "dependencies": { - "fn.name": "1.x.x" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", - "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", - "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^11.0.0", - "package-json-from-dist": "^1.0.0" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/stack-trace": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", - "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tar-fs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", - "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/text-hex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", - "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", - "license": "MIT" - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/triple-beam": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", - "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", - "license": "MIT", - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/ts-api-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", - "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "typescript": ">=4.2.0" - } - }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/url-template": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", - "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", - "license": "BSD" - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT" - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/winston": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", - "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", - "license": "MIT", - "dependencies": { - "@colors/colors": "^1.6.0", - "@dabh/diagnostics": "^2.0.2", - "async": "^3.2.3", - "is-stream": "^2.0.0", - "logform": "^2.7.0", - "one-time": "^1.0.0", - "readable-stream": "^3.4.0", - "safe-stable-stringify": "^2.3.1", - "stack-trace": "0.0.x", - "triple-beam": "^1.3.0", - "winston-transport": "^4.9.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/winston-transport": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", - "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", - "license": "MIT", - "dependencies": { - "logform": "^2.7.0", - "readable-stream": "^3.6.2", - "triple-beam": "^1.3.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/package.json b/package.json index eb4c2ae..4b1a209 100644 --- a/package.json +++ b/package.json @@ -1,58 +1,47 @@ { - "name": "steam-player-tracker", - "version": "1.3.0", - "description": "Track Steam game concurrent player counts and export to CSV/Google Sheets", - "main": "dist/index.js", - "scripts": { - "build": "tsc", - "start": "node dist/index.js", - "dev": "ts-node src/index.ts", - "watch": "tsc --watch", - "clean": "rimraf dist", - "lint": "eslint src/**/*.ts", - "typecheck": "tsc --noEmit", - "calculate-daily-averages": "node dist/tools/calculateAllDailyAverages.js", - "sync-google-sheets": "node dist/tools/syncGoogleSheets.js", - "generate-charts": "node dist/tools/generateCharts.js", - "kill": "pwsh -ExecutionPolicy Bypass -File Scripts/Kill-SteamTracker.ps1", - "setup": "npm install && npm run build", - "release": "node scripts/release.js", - "release:minor": "node scripts/release.js minor", - "release:major": "node scripts/release.js major", - "prerelease": "npm run test:ci", - "test:ci": "npm run typecheck && npm run lint", - "prepare-release": "npm run clean && npm run build && npm run test:ci" - }, - "keywords": [ - "steam", - "player-tracking", - "csv", - "google-sheets" - ], - "author": "", - "license": "MIT", - "dependencies": { - "axios": "^1.11.0", - "chart.js": "^4.5.0", - "chartjs-node-canvas": "^5.0.0", - "date-fns": "^4.1.0", - "dayjs": "^1.11.13", - "dotenv": "^16.6.1", - "googleapis": "^126.0.0", - "node-cron": "^3.0.0", - "winston": "^3.11.0" - }, - "devDependencies": { - "@types/node": "^20.19.11", - "@types/node-cron": "^3.0.0", - "@typescript-eslint/eslint-plugin": "^7.18.0", - "@typescript-eslint/parser": "^7.18.0", - "eslint": "^8.57.0", - "rimraf": "^6.0.1", - "ts-node": "^10.9.0", - "typescript": "^5.9.2" - }, - "engines": { - "node": ">=18.0.0" - } + "name": "steam-player-tracker", + "version": "1.3.0", + "type": "module", + "description": "Track Steam game concurrent player counts with SQLite storage and optional Google Sheets sync", + "main": "dist/main", + "scripts": { + "build": "bun scripts/build.ts", + "start": "bun dist/main.js", + "dev": "bun --watch src/main.ts", + "typecheck": "tsc --noEmit", + "lint": "biome check .", + "format": "biome check --write --unsafe .", + "kill": "dev-clean --yes", + "setup": "bun install && bun run build", + "release": "bun scripts/release.ts", + "release:minor": "bun scripts/release.ts minor", + "release:major": "bun scripts/release.ts major", + "import-csv": "bun src/tools/importCsv.ts", + "export-csv": "bun src/tools/exportCsv.ts", + "generate-charts": "bun src/tools/generateCharts.ts", + "test": "STEAM_APP_ID=730 DB_PATH=data/test.db bun test" + }, + "keywords": [ + "steam", + "player-tracking", + "sqlite", + "google-sheets" + ], + "author": "yuu1111", + "license": "MIT", + "dependencies": { + "chart.js": "^4.5.1", + "chartjs-node-canvas": "^5.0.0", + "dayjs": "^1.11.20", + "dev-clean": "^1.0.8", + "googleapis": "^171.4.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.8", + "@types/bun": "^1.3.11", + "@yuu1111/biome-config": "^1.0.3", + "@yuu1111/tsconfig": "^1.0.2", + "typescript": "^6.0.2" + } } diff --git a/scripts/Kill-SteamTracker.ps1 b/scripts/Kill-SteamTracker.ps1 deleted file mode 100644 index 3ed1ec7..0000000 --- a/scripts/Kill-SteamTracker.ps1 +++ /dev/null @@ -1,165 +0,0 @@ -# Steam Player Tracker プロセス終了スクリプト -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 -$OutputEncoding = [System.Text.Encoding]::UTF8 - -# パラメータの処理 -$Force = $false -if ($args -contains "-Force") { - $Force = $true -} - -Write-Host "Steam Player Tracker 関連プロセスを検索中..." -ForegroundColor Yellow - -# 現在のディレクトリを取得 -$currentDir = Get-Location - -# Node.js プロセス一覧取得 -$nodeProcesses = Get-WmiObject Win32_Process | Where-Object { $_.Name -eq "node.exe" } - -if ($nodeProcesses) { - Write-Host "`n実行中の Node.js プロセス:" -ForegroundColor Cyan - $nodeProcesses | ForEach-Object { - $cmdLine = if ($_.CommandLine) { $_.CommandLine } else { "N/A" } - Write-Host "PID: $($_.ProcessId) - $cmdLine" -ForegroundColor Gray - } -} else { - Write-Host "`nNode.js プロセスは実行されていません。" -ForegroundColor Green - exit 0 -} - -# Steam Player Tracker関連プロセスを特定 -$steamTrackerProcesses = @() - -foreach ($process in $nodeProcesses) { - $cmdLine = $process.CommandLine - - if (-not $cmdLine) { - continue - } - - # Steam Player Tracker関連の条件をチェック - $isSteamTracker = $false - - # より厳密な条件チェック - if ($cmdLine -match "SteamPlayerTracker" -or - $cmdLine -match "steam-player-tracker" -or - ($cmdLine -match "ts-node" -and $cmdLine -match "src/index\.ts" -and $currentDir.Path -match "SteamPlayerTracker") -or - ($cmdLine -match "node" -and $cmdLine -match "dist/index\.js" -and $currentDir.Path -match "SteamPlayerTracker") -or - ($cmdLine -match "node_modules/ts-node" -and $cmdLine -match "src/index\.ts")) { - $isSteamTracker = $true - } - - if ($isSteamTracker) { - $steamTrackerProcesses += $process - Write-Host "`nSteam Player Tracker関連プロセス発見:" -ForegroundColor Green - Write-Host "PID: $($process.ProcessId)" -ForegroundColor Yellow - Write-Host "Command: $cmdLine" -ForegroundColor Gray - } -} - -if ($steamTrackerProcesses.Count -eq 0) { - Write-Host "`nSteam Player Tracker関連のプロセスは見つかりませんでした。" -ForegroundColor Green - Write-Host "手動で確認したい場合は、以下のコマンドを実行してください:" -ForegroundColor Yellow - Write-Host "Get-Process node | Select-Object Id, ProcessName, MainWindowTitle" -ForegroundColor Cyan -} else { - Write-Host "`n$($steamTrackerProcesses.Count) 個のSteam Player Tracker関連プロセスが見つかりました。" -ForegroundColor Yellow - - $confirm = "n" - if ($Force) { - $confirm = "y" - Write-Host "Force オプションが指定されているため、自動的に終了します。" -ForegroundColor Cyan - } else { - $confirm = Read-Host "`nこれらのプロセスを終了しますか? (y/n)" - } - - if ($confirm -eq "y" -or $confirm -eq "Y") { - $successCount = 0 - $failCount = 0 - - foreach ($process in $steamTrackerProcesses) { - try { - Write-Host "プロセス $($process.ProcessId) を終了中..." -ForegroundColor Yellow - Stop-Process -Id $process.ProcessId -Force -ErrorAction Stop - $successCount++ - Write-Host "プロセス $($process.ProcessId) を終了しました。" -ForegroundColor Green - } - catch { - $failCount++ - Write-Host "プロセス $($process.ProcessId) の終了に失敗しました: $($_.Exception.Message)" -ForegroundColor Red - } - } - - # 少し待機してから状態を確認 - Start-Sleep -Seconds 2 - - # 結果サマリー - if ($successCount -gt 0) { - Write-Host "`n✅ $successCount 個のプロセスを正常に終了しました。" -ForegroundColor Green - } - if ($failCount -gt 0) { - Write-Host "❌ $failCount 個のプロセスの終了に失敗しました。" -ForegroundColor Red - } - - # ログファイルやロックファイルなどの追加クリーンアップ - Write-Host "`n追加のクリーンアップを実行中..." -ForegroundColor Yellow - - # ロックファイルのクリーンアップ - $lockFiles = @() - $lockFiles += Join-Path $currentDir "*.lock" - $lockFiles += Join-Path $currentDir "data\*.lock" - $lockFiles += Join-Path $currentDir "logs\*.lock" - - $cleanupCount = 0 - foreach ($pattern in $lockFiles) { - $files = Get-ChildItem -Path $pattern -ErrorAction SilentlyContinue - if ($files) { - foreach ($file in $files) { - try { - Remove-Item $file -Force -ErrorAction Stop - $cleanupCount++ - Write-Host "ロックファイルを削除しました: $($file.Name)" -ForegroundColor Green - } - catch { - Write-Host "ロックファイルの削除に失敗しました: $($file.Name)" -ForegroundColor Yellow - } - } - } - } - - if ($cleanupCount -eq 0) { - Write-Host "クリーンアップが必要なファイルはありませんでした。" -ForegroundColor Cyan - } - - } else { - Write-Host "プロセス終了をキャンセルしました。" -ForegroundColor Yellow - } -} - -# 最終確認 -Write-Host "`n最終確認中..." -ForegroundColor Cyan -$remainingTrackerProcesses = @() -$remainingProcesses = Get-WmiObject Win32_Process | Where-Object { $_.Name -eq "node.exe" } - -if ($remainingProcesses) { - foreach ($process in $remainingProcesses) { - $cmdLine = $process.CommandLine - if ($cmdLine -and ($cmdLine -match "SteamPlayerTracker" -or - $cmdLine -match "steam-player-tracker" -or - ($cmdLine -match "ts-node" -and $cmdLine -match "src/index\.ts" -and $currentDir.Path -match "SteamPlayerTracker"))) { - $remainingTrackerProcesses += $process - } - } -} - -if ($remainingTrackerProcesses.Count -gt 0) { - Write-Host "`n⚠️ まだ $($remainingTrackerProcesses.Count) 個のSteam Player Tracker関連プロセスが実行中です。" -ForegroundColor Yellow - Write-Host "強制終了するには以下のコマンドを実行してください:" -ForegroundColor Yellow - Write-Host "npm run kill -- -Force" -ForegroundColor Cyan -} else { - Write-Host "`n✅ Steam Player Tracker関連のプロセスはすべて終了しました。" -ForegroundColor Green -} - -Write-Host "`n🎮 Steam Player Tracker プロセス終了処理完了。" -ForegroundColor Green -Write-Host "`nTrackerを再起動するには以下のコマンドを実行してください:" -ForegroundColor Yellow -Write-Host " 開発環境: npm run dev" -ForegroundColor Cyan -Write-Host " 本番環境: npm start" -ForegroundColor Cyan \ No newline at end of file diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 0000000..143bc10 --- /dev/null +++ b/scripts/build.ts @@ -0,0 +1,17 @@ +export {}; + +const result = await Bun.build({ + entrypoints: ["src/main.ts"], + outdir: "dist", + root: "src", + target: "node", + format: "esm", + splitting: true, +}); + +if (!result.success) { + for (const log of result.logs) { + console.error(log); + } + process.exit(1); +} diff --git a/scripts/release.js b/scripts/release.js deleted file mode 100644 index 8a1a6fa..0000000 --- a/scripts/release.js +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env node - -const { execSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); - -const packageJsonPath = path.join(__dirname, '..', 'package.json'); - -function runCommand(command, description) { - console.log(`\n🔄 ${description}...`); - try { - execSync(command, { stdio: 'inherit', timeout: 60000 }); // 60秒タイムアウト - console.log(`✅ ${description} 完了`); - } catch (error) { - console.error(`❌ ${description} 失敗:`, error.message); - process.exit(1); - } -} - -function updateVersion(type = 'patch') { - console.log(`\n📦 バージョンを${type}更新中...`); - - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - const currentVersion = packageJson.version; - const versionParts = currentVersion.split('.').map(Number); - - switch (type) { - case 'major': - versionParts[0]++; - versionParts[1] = 0; - versionParts[2] = 0; - break; - case 'minor': - versionParts[1]++; - versionParts[2] = 0; - break; - case 'patch': - default: - versionParts[2]++; - break; - } - - const newVersion = versionParts.join('.'); - packageJson.version = newVersion; - - fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); - - console.log(`📈 バージョンを ${currentVersion} → ${newVersion} に更新`); - return newVersion; -} - -function main() { - const args = process.argv.slice(2); - const versionType = args[0] || 'patch'; - const skipTests = args.includes('--skip-tests'); - - console.log('🚀 自動リリースプロセスを開始...'); - - // Git statusチェック - try { - const gitStatus = execSync('git status --porcelain', { encoding: 'utf8' }); - if (gitStatus.trim()) { - console.warn('⚠️ 未コミットの変更があります:'); - console.log(gitStatus); - console.log('変更をコミットしてから再実行してください。'); - process.exit(1); - } - } catch (error) { - console.error('❌ Git status確認エラー:', error.message); - process.exit(1); - } - - // 依存関係のインストール - runCommand('npm ci', '依存関係のクリーンインストール'); - - // TypeScript型チェック - runCommand('npm run typecheck', 'TypeScript型チェック'); - - // ESLint実行(警告は許可) - if (!skipTests) { - try { - runCommand('npm run lint', 'ESLint静的解析'); - } catch (error) { - console.log('⚠️ ESLintで問題が検出されましたが、続行します'); - } - } - - // ビルド - runCommand('npm run clean', 'dist ディレクトリのクリーンアップ'); - runCommand('npm run build', 'TypeScriptコンパイル'); - - // バージョン更新 - const newVersion = updateVersion(versionType); - - // Gitコミット - runCommand('git add .', 'Gitステージング'); - runCommand(`git commit -m "chore(release): v${newVersion}"`, 'リリースコミット作成'); - - // Gitタグ作成 - runCommand(`git tag -a v${newVersion} -m "Release v${newVersion}"`, 'Gitタグ作成'); - - console.log('\n🎉 リリース準備完了!'); - console.log(`📦 新しいバージョン: v${newVersion}`); - console.log('\n次のステップ:'); - console.log(' 1. git push origin main'); - console.log(` 2. git push origin v${newVersion}`); - console.log(' 3. GitHub Releasesページでリリースノートを作成'); -} - -main(); \ No newline at end of file diff --git a/scripts/release.ts b/scripts/release.ts new file mode 100644 index 0000000..bf827c0 --- /dev/null +++ b/scripts/release.ts @@ -0,0 +1,114 @@ +import { execSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const packageJsonPath = join(import.meta.dirname, "..", "package.json"); + +function runCommand(command: string, description: string) { + console.log(`\n${description}...`); + try { + execSync(command, { stdio: "inherit", timeout: 60000 }); + console.log(`${description} completed`); + } catch (error) { + console.error( + `${description} failed:`, + error instanceof Error ? error.message : String(error), + ); + process.exit(1); + } +} + +function updateVersion(type = "patch"): string { + console.log(`\nUpdating version (${type})...`); + + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + const currentVersion: string = packageJson.version; + const versionParts = currentVersion.split(".").map(Number); + + switch (type) { + case "major": + versionParts[0]++; + versionParts[1] = 0; + versionParts[2] = 0; + break; + case "minor": + versionParts[1]++; + versionParts[2] = 0; + break; + default: + versionParts[2]++; + break; + } + + const newVersion = versionParts.join("."); + packageJson.version = newVersion; + + writeFileSync( + packageJsonPath, + `${JSON.stringify(packageJson, null, "\t")}\n`, + ); + + console.log(`Version updated: ${currentVersion} -> ${newVersion}`); + return newVersion; +} + +function main() { + const args = process.argv.slice(2); + const versionType = args[0] || "patch"; + const skipTests = args.includes("--skip-tests"); + + console.log("Starting release process..."); + + try { + const gitStatus = execSync("git status --porcelain", { + encoding: "utf8", + }); + if (gitStatus.trim()) { + console.warn("Uncommitted changes detected:"); + console.log(gitStatus); + console.log("Please commit your changes before running the release."); + process.exit(1); + } + } catch (error) { + console.error( + "Git status check failed:", + error instanceof Error ? error.message : String(error), + ); + process.exit(1); + } + + runCommand("bun install", "Installing dependencies"); + runCommand("bun run typecheck", "TypeScript type check"); + + if (!skipTests) { + try { + runCommand("bun run lint", "Biome check"); + } catch (_error) { + console.log("Biome reported issues, continuing..."); + } + } + + runCommand("bun run clean", "Cleaning dist directory"); + runCommand("bun run build", "Building"); + + const newVersion = updateVersion(versionType); + + runCommand("git add .", "Git staging"); + runCommand( + `git commit -m "chore(release): v${newVersion}"`, + "Creating release commit", + ); + runCommand( + `git tag -a v${newVersion} -m "Release v${newVersion}"`, + "Creating git tag", + ); + + console.log("\nRelease preparation complete!"); + console.log(`New version: v${newVersion}`); + console.log("\nNext steps:"); + console.log(" 1. git push origin main"); + console.log(` 2. git push origin v${newVersion}`); + console.log(" 3. Create release notes on GitHub Releases"); +} + +main(); diff --git a/setup.bat b/setup.bat deleted file mode 100644 index 2d0c652..0000000 --- a/setup.bat +++ /dev/null @@ -1,27 +0,0 @@ -@echo off -REM Steam Player Tracker Setup Script for Windows - -title Steam Player Tracker - Setting up... - -REM Check if pwsh is installed -where pwsh >nul 2>&1 -if %ERRORLEVEL% neq 0 ( - echo "Error: PowerShell Core (pwsh) is not installed" - echo "Please install PowerShell from https://aka.ms/PSWindows" - echo. - echo "Press any key to exit..." - pause > nul - exit /b 1 -) - -REM Run PowerShell setup script -pwsh -ExecutionPolicy Bypass -File setup.ps1 - -REM Keep window open -echo. -if %ERRORLEVEL% neq 0 ( - echo "Setup failed! Press any key to exit..." -) else ( - echo "Setup completed! Press any key to exit..." -) -pause > nul \ No newline at end of file diff --git a/setup.ps1 b/setup.ps1 deleted file mode 100644 index b53f953..0000000 --- a/setup.ps1 +++ /dev/null @@ -1,75 +0,0 @@ -# Steam Player Tracker Setup Script for PowerShell Core (pwsh) - -$Host.UI.RawUI.WindowTitle = "Steam Player Tracker - Setting up..." - -Write-Host "Steam Player Tracker - Setup Script" -ForegroundColor Cyan -Write-Host "====================================" -ForegroundColor Cyan - -# Check if npm is installed -try { - $npmVersion = npm --version - Write-Host "npm version: $npmVersion" -ForegroundColor Green -} catch { - Write-Host "Error: npm is not installed" -ForegroundColor Red - Write-Host "Please install Node.js from https://nodejs.org/" -ForegroundColor Yellow - Write-Host "`nPress any key to exit..." -ForegroundColor Red - $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") - exit 1 -} - -# Install dependencies -Write-Host "`nInstalling dependencies..." -ForegroundColor Yellow -npm install -if ($LASTEXITCODE -ne 0) { - Write-Host "Error: Failed to install dependencies" -ForegroundColor Red - Write-Host "`nPress any key to exit..." -ForegroundColor Red - $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") - exit 1 -} - -# Check if .env file exists -if (!(Test-Path ".env")) { - Write-Host "`nCreating .env file from template..." -ForegroundColor Yellow - - if (Test-Path ".env.example") { - Copy-Item ".env.example" ".env" - Write-Host ".env file created successfully" -ForegroundColor Green - Write-Host "`nPlease edit the .env file to configure your settings:" -ForegroundColor Yellow - Write-Host "- Set STEAM_APP_ID to your desired game's App ID" -ForegroundColor White - Write-Host "- Configure Google Sheets settings if needed" -ForegroundColor White - Write-Host "- Adjust collection schedule and file paths as needed" -ForegroundColor White - } else { - Write-Host "Error: .env.example file not found" -ForegroundColor Red - Write-Host "`nPress any key to exit..." -ForegroundColor Red - $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") - exit 1 - } -} else { - Write-Host "`n.env file already exists - skipping creation" -ForegroundColor Green -} - -# Create logs directory if it doesn't exist -if (!(Test-Path "logs")) { - Write-Host "`nCreating logs directory..." -ForegroundColor Yellow - New-Item -ItemType Directory -Name "logs" | Out-Null - Write-Host "Logs directory created" -ForegroundColor Green -} else { - Write-Host "`nLogs directory already exists" -ForegroundColor Green -} - -# Build the project -Write-Host "`nBuilding project..." -ForegroundColor Yellow -npm run build - -if ($LASTEXITCODE -eq 0) { - Write-Host "`nSetup completed successfully!" -ForegroundColor Green - Write-Host "`nNext steps:" -ForegroundColor Cyan - Write-Host "1. Edit .env file to configure your settings" -ForegroundColor White - Write-Host "2. Run start.bat to start the tracker" -ForegroundColor White - Write-Host "3. Check logs/steam-tracker.log for operation logs" -ForegroundColor White -} else { - Write-Host "`nBuild failed!" -ForegroundColor Red -} - -Write-Host "`nPress any key to exit..." -ForegroundColor Yellow -$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") \ No newline at end of file diff --git a/setup.sh b/setup.sh deleted file mode 100644 index 17b6fa5..0000000 --- a/setup.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/bin/bash - -# Steam Player Tracker Setup Script for Linux/macOS - -echo -e "\033[36mSteam Player Tracker - Setup Script\033[0m" -echo -e "\033[36m====================================\033[0m" - -# Check if npm is installed -if ! command -v npm &> /dev/null; then - echo -e "\033[31mError: npm is not installed\033[0m" - echo -e "\033[33mPlease install Node.js from https://nodejs.org/\033[0m" - exit 1 -fi - -echo -e "\033[32mnpm version: $(npm --version)\033[0m" - -# Install dependencies -echo -e "\n\033[33mInstalling dependencies...\033[0m" -npm install -if [ $? -ne 0 ]; then - echo -e "\033[31mError: Failed to install dependencies\033[0m" - exit 1 -fi - -# Check if .env file exists -if [ ! -f ".env" ]; then - echo -e "\n\033[33mCreating .env file from template...\033[0m" - - if [ -f ".env.example" ]; then - cp ".env.example" ".env" - echo -e "\033[32m.env file created successfully\033[0m" - echo -e "\n\033[33mPlease edit the .env file to configure your settings:\033[0m" - echo -e "\033[37m- Set STEAM_APP_ID to your desired game's App ID\033[0m" - echo -e "\033[37m- Configure Google Sheets settings if needed\033[0m" - echo -e "\033[37m- Adjust collection schedule and file paths as needed\033[0m" - else - echo -e "\033[31mError: .env.example file not found\033[0m" - exit 1 - fi -else - echo -e "\n\033[32m.env file already exists - skipping creation\033[0m" -fi - -# Create logs directory if it doesn't exist -if [ ! -d "logs" ]; then - echo -e "\n\033[33mCreating logs directory...\033[0m" - mkdir -p logs - echo -e "\033[32mLogs directory created\033[0m" -else - echo -e "\n\033[32mLogs directory already exists\033[0m" -fi - -# Build the project -echo -e "\n\033[33mBuilding project...\033[0m" -npm run build - -if [ $? -eq 0 ]; then - echo -e "\n\033[32mSetup completed successfully!\033[0m" - echo -e "\n\033[36mNext steps:\033[0m" - echo -e "\033[37m1. Edit .env file to configure your settings\033[0m" - echo -e "\033[37m2. Run ./start.sh to start the tracker\033[0m" - echo -e "\033[37m3. Check logs/steam-tracker.log for operation logs\033[0m" -else - echo -e "\n\033[31mBuild failed!\033[0m" - exit 1 -fi \ No newline at end of file diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..7acc154 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,125 @@ +import { z } from "zod"; + +/** + * @description コレクション分のカンマ区切り文字列を数値配列に変換 + */ +const collectionMinutesSchema = z + .string() + .default("0,30") + .transform((val) => { + const minutes = val + .split(",") + .map((m) => Number.parseInt(m.trim(), 10)) + .filter((m) => !Number.isNaN(m) && m >= 0 && m < 60); + if (minutes.length === 0) { + throw new Error("At least one valid minute (0-59) is required"); + } + return minutes; + }); + +/** + * @description Google Sheets有効時の設定スキーマ + */ +const googleSheetsEnabledSchema = z.object({ + enabled: z.literal(true), + spreadsheetId: z.string().min(1, "GOOGLE_SHEETS_SPREADSHEET_ID is required"), + sheetName: z.string().default("PlayerData"), + dailyAverageSheetName: z.string().default("DailyAverages"), + serviceAccountKeyPath: z + .string() + .min(1, "GOOGLE_SERVICE_ACCOUNT_KEY_PATH is required"), +}); + +/** + * @description Google Sheets無効時の設定スキーマ + */ +const googleSheetsDisabledSchema = z.object({ + enabled: z.literal(false), +}); + +/** + * @description Google Sheets設定の判別共用体 + */ +const googleSheetsSchema = z.discriminatedUnion("enabled", [ + googleSheetsEnabledSchema, + googleSheetsDisabledSchema, +]); + +/** + * @description アプリケーション設定のZodスキーマ + */ +export const configSchema = z.object({ + steam: z.object({ + appId: z.coerce.number().int().positive(), + }), + storage: z.object({ + dbPath: z.string().default("data/steam-tracker.db"), + }), + scheduling: z.object({ + collectionMinutes: collectionMinutesSchema, + dailyAverageHour: z.coerce.number().int().min(0).max(23).default(0), + sheetsSyncMinutes: collectionMinutesSchema, + }), + logging: z.object({ + level: z.enum(["debug", "info", "warn", "error"]).default("info"), + }), + googleSheets: googleSheetsSchema, +}); + +/** + * @description パース済み設定の型 + */ +export type Config = z.infer; + +/** + * @description Google Sheets有効時の設定型 + */ +export type GoogleSheetsEnabledConfig = z.infer< + typeof googleSheetsEnabledSchema +>; + +/** + * @description Google Sheets設定の共用体型 + */ +export type GoogleSheetsConfig = z.infer; + +/** + * @description 環境変数をパースして設定オブジェクトを生成 + * @param env - 環境変数オブジェクト + * @returns パース済み設定 + */ +export function parseConfig(env: NodeJS.ProcessEnv): Config { + const gsEnabled = env.GOOGLE_SHEETS_ENABLED === "true"; + + return configSchema.parse({ + steam: { + appId: env.STEAM_APP_ID, + }, + storage: { + dbPath: env.DB_PATH || undefined, + }, + scheduling: { + collectionMinutes: env.COLLECTION_MINUTES || undefined, + dailyAverageHour: env.DAILY_AVERAGE_HOUR || undefined, + sheetsSyncMinutes: env.SHEETS_SYNC_MINUTES || undefined, + }, + logging: { + level: env.LOG_LEVEL || undefined, + }, + googleSheets: gsEnabled + ? { + enabled: true, + spreadsheetId: env.GOOGLE_SHEETS_SPREADSHEET_ID, + sheetName: env.GOOGLE_SHEETS_SHEET_NAME || undefined, + dailyAverageSheetName: + env.GOOGLE_SHEETS_DAILY_AVERAGE_SHEET_NAME || undefined, + serviceAccountKeyPath: env.GOOGLE_SERVICE_ACCOUNT_KEY_PATH, + } + : { enabled: false }, + }); +} + +/** + * @description 環境変数からパース済みのアプリケーション設定 + */ +export const config = parseConfig(process.env); diff --git a/src/config/config.ts b/src/config/config.ts deleted file mode 100644 index d71e858..0000000 --- a/src/config/config.ts +++ /dev/null @@ -1,71 +0,0 @@ -import * as dotenv from 'dotenv'; -import { Config } from '../types/config'; - -dotenv.config(); - -function parseMinutes(minutesStr: string): number[] { - return minutesStr.split(',').map(m => parseInt(m.trim(), 10)).filter(m => !isNaN(m) && m >= 0 && m < 60); -} - -function validateConfig(): Config { - const steamAppId = process.env.STEAM_APP_ID; - - if (!steamAppId || isNaN(parseInt(steamAppId))) { - throw new Error('STEAM_APP_ID must be a valid number in environment variables'); - } - - const config: Config = { - steam: { - appId: parseInt(steamAppId), - }, - - output: { - csvEnabled: process.env.CSV_OUTPUT_ENABLED !== 'false', - csvFilePath: process.env.CSV_FILE_PATH || 'steam_concurrent_players.csv', - dailyAverageCsvEnabled: process.env.DAILY_AVERAGE_CSV_ENABLED !== 'false', - dailyAverageCsvFilePath: process.env.DAILY_AVERAGE_CSV_FILE_PATH || 'steam_daily_averages.csv', - }, - - scheduling: { - collectionMinutes: parseMinutes(process.env.COLLECTION_MINUTES || '0,30'), - dailyAverageHour: parseInt(process.env.DAILY_AVERAGE_HOUR || '0'), - }, - - retry: { - maxRetries: parseInt(process.env.MAX_RETRIES || '3'), - baseDelay: parseInt(process.env.RETRY_BASE_DELAY || '1000'), - }, - - logging: { - level: process.env.LOG_LEVEL || 'info', - filePath: process.env.LOG_FILE_PATH || 'logs/steam-tracker.log', - }, - }; - - if (process.env.GOOGLE_SHEETS_ENABLED === 'true') { - config.googleSheets = { - enabled: true, - spreadsheetId: process.env.GOOGLE_SHEETS_SPREADSHEET_ID, - sheetName: process.env.GOOGLE_SHEETS_SHEET_NAME || 'PlayerData', - dailyAverageSheetName: process.env.GOOGLE_SHEETS_DAILY_AVERAGE_SHEET_NAME || 'DailyAverages', - serviceAccountKeyPath: process.env.GOOGLE_SERVICE_ACCOUNT_KEY_PATH, - syncOnStartup: process.env.GOOGLE_SHEETS_SYNC_ON_STARTUP === 'true', - }; - - if (!config.googleSheets.spreadsheetId) { - throw new Error('GOOGLE_SHEETS_SPREADSHEET_ID is required when Google Sheets is enabled'); - } - - if (!config.googleSheets.serviceAccountKeyPath) { - throw new Error('GOOGLE_SERVICE_ACCOUNT_KEY_PATH is required when Google Sheets is enabled'); - } - } else { - config.googleSheets = { - enabled: false, - }; - } - - return config; -} - -export const config = validateConfig(); \ No newline at end of file diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..9ece72d --- /dev/null +++ b/src/db.ts @@ -0,0 +1,395 @@ +import { Database as SQLiteDatabase } from "bun:sqlite"; +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +/** + * @description プレイヤーデータの行型 + * @property timestamp - ISO形式タイムスタンプ (YYYY-MM-DD HH:mm:ss) + * @property playerCount - プレイヤー数 + */ +export interface PlayerDataRow { + timestamp: string; + playerCount: number; +} + +/** + * @description 日次平均の行型 + * @property date - 日付 (YYYY-MM-DD) + * @property averagePlayerCount - 平均プレイヤー数 + * @property sampleCount - サンプル数 + * @property maxPlayerCount - 最大プレイヤー数 + * @property maxTimestamp - 最大時のタイムスタンプ + * @property minPlayerCount - 最小プレイヤー数 + * @property minTimestamp - 最小時のタイムスタンプ + */ +export interface DailyAverageRow { + date: string; + averagePlayerCount: number; + sampleCount: number; + maxPlayerCount: number; + maxTimestamp: string; + minPlayerCount: number; + minTimestamp: string; +} + +/** + * @description データベース操作の公開インターフェース + */ +export interface Database { + insertPlayerData(timestamp: string, playerCount: number): void; + getPlayerDataByDateRange(from: string, to: string): PlayerDataRow[]; + getAllPlayerData(): PlayerDataRow[]; + getUnsyncedPlayerData(): { + id: number; + timestamp: string; + playerCount: number; + }[]; + markPlayerDataSynced(ids: number[]): void; + markAllPlayerDataSynced(): void; + + calculateDailyAverage(date: string): DailyAverageRow | null; + upsertDailyAverage(row: DailyAverageRow): void; + getDailyAverageRange(from: string, to: string): DailyAverageRow[]; + getAllDailyAverages(): DailyAverageRow[]; + getUnsyncedDailyAverages(): DailyAverageRow[]; + markDailyAveragesSynced(dates: string[]): void; + markAllDailyAveragesSynced(): void; + getDatesWithDataButNoAverage(): string[]; + + run(sql: string): void; + transaction(fn: () => T): T; + close(): void; + [Symbol.dispose](): void; +} + +/** + * @description SQLiteデータベースを初期化しクエリヘルパーを返す + * @param dbPath - データベースファイルパス + */ +export function createDatabase(dbPath: string): Database { + if (dbPath !== ":memory:") { + mkdirSync(dirname(dbPath), { recursive: true }); + } + const db = new SQLiteDatabase(dbPath); + + db.run("PRAGMA journal_mode = WAL"); + db.run("PRAGMA foreign_keys = ON"); + + db.run(` + CREATE TABLE IF NOT EXISTS player_data ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + player_count INTEGER NOT NULL, + synced_at TEXT + ) + `); + db.run( + "CREATE INDEX IF NOT EXISTS idx_player_data_timestamp ON player_data(timestamp)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_player_data_unsynced ON player_data(synced_at) WHERE synced_at IS NULL", + ); + + db.run(` + CREATE TABLE IF NOT EXISTS daily_averages ( + date TEXT PRIMARY KEY, + average_player_count INTEGER NOT NULL, + sample_count INTEGER NOT NULL, + max_player_count INTEGER NOT NULL, + max_timestamp TEXT NOT NULL, + min_player_count INTEGER NOT NULL, + min_timestamp TEXT NOT NULL, + synced_at TEXT + ) + `); + db.run( + "CREATE INDEX IF NOT EXISTS idx_daily_averages_unsynced ON daily_averages(synced_at) WHERE synced_at IS NULL", + ); + + const stmts = { + insertPlayer: db.prepare( + "INSERT INTO player_data (timestamp, player_count) VALUES (?, ?)", + ), + playerByDateRange: db.prepare( + "SELECT timestamp, player_count FROM player_data WHERE timestamp >= ? AND timestamp < ? ORDER BY timestamp", + ), + allPlayer: db.prepare( + "SELECT timestamp, player_count FROM player_data ORDER BY timestamp", + ), + unsyncedPlayer: db.prepare( + "SELECT id, timestamp, player_count FROM player_data WHERE synced_at IS NULL ORDER BY timestamp", + ), + calcDailyAvg: db.prepare(` + SELECT + $date AS date, + ROUND(AVG(player_count)) AS average_player_count, + COUNT(*) AS sample_count, + MAX(player_count) AS max_player_count, + (SELECT timestamp FROM player_data + WHERE date(timestamp) = $date AND player_count > 0 + ORDER BY player_count DESC, timestamp ASC LIMIT 1) AS max_timestamp, + MIN(player_count) AS min_player_count, + (SELECT timestamp FROM player_data + WHERE date(timestamp) = $date AND player_count > 0 + ORDER BY player_count ASC, timestamp ASC LIMIT 1) AS min_timestamp + FROM player_data + WHERE date(timestamp) = $date AND player_count > 0 + `), + upsertDailyAvg: db.prepare(` + INSERT INTO daily_averages (date, average_player_count, sample_count, max_player_count, max_timestamp, min_player_count, min_timestamp) + VALUES ($date, $averagePlayerCount, $sampleCount, $maxPlayerCount, $maxTimestamp, $minPlayerCount, $minTimestamp) + ON CONFLICT(date) DO UPDATE SET + average_player_count = excluded.average_player_count, + sample_count = excluded.sample_count, + max_player_count = excluded.max_player_count, + max_timestamp = excluded.max_timestamp, + min_player_count = excluded.min_player_count, + min_timestamp = excluded.min_timestamp, + synced_at = NULL + `), + dailyAvgByDateRange: db.prepare( + "SELECT date, average_player_count, sample_count, max_player_count, max_timestamp, min_player_count, min_timestamp FROM daily_averages WHERE date >= ? AND date <= ? ORDER BY date", + ), + allDailyAvg: db.prepare( + "SELECT date, average_player_count, sample_count, max_player_count, max_timestamp, min_player_count, min_timestamp FROM daily_averages ORDER BY date", + ), + unsyncedDailyAvg: db.prepare( + "SELECT date, average_player_count, sample_count, max_player_count, max_timestamp, min_player_count, min_timestamp FROM daily_averages WHERE synced_at IS NULL ORDER BY date", + ), + markPlayerSyncedById: db.prepare( + "UPDATE player_data SET synced_at = ? WHERE id = ?", + ), + markAllPlayerSynced: db.prepare( + "UPDATE player_data SET synced_at = ? WHERE synced_at IS NULL", + ), + markDailyAvgSyncedByDate: db.prepare( + "UPDATE daily_averages SET synced_at = ? WHERE date = ?", + ), + markAllDailyAvgSynced: db.prepare( + "UPDATE daily_averages SET synced_at = ? WHERE synced_at IS NULL", + ), + datesWithNoAverage: db.prepare(` + SELECT DISTINCT date(timestamp) AS date FROM player_data + WHERE player_count > 0 + AND date(timestamp) < date('now') + AND date(timestamp) NOT IN (SELECT date FROM daily_averages) + ORDER BY date + `), + }; + + type Row = Record; + + /** + * @description DB行をPlayerDataRowに変換 + */ + function toPlayerRow(row: Row): PlayerDataRow { + return { + timestamp: row.timestamp as string, + playerCount: row.player_count as number, + }; + } + + /** + * @description DB行をDailyAverageRowに変換 + */ + function toDailyAvgRow(row: Row): DailyAverageRow { + return { + date: row.date as string, + averagePlayerCount: row.average_player_count as number, + sampleCount: row.sample_count as number, + maxPlayerCount: row.max_player_count as number, + maxTimestamp: row.max_timestamp as string, + minPlayerCount: row.min_player_count as number, + minTimestamp: row.min_timestamp as string, + }; + } + + /** + * @description プレイヤーデータを挿入 + * @param timestamp - タイムスタンプ + * @param playerCount - プレイヤー数 + */ + function insertPlayerData(timestamp: string, playerCount: number): void { + stmts.insertPlayer.run(timestamp, playerCount); + } + + /** + * @description 日付範囲でプレイヤーデータを取得 + * @param from - 開始日時 (inclusive) + * @param to - 終了日時 (exclusive) + */ + function getPlayerDataByDateRange(from: string, to: string): PlayerDataRow[] { + return (stmts.playerByDateRange.all(from, to) as Row[]).map(toPlayerRow); + } + + /** + * @description 全プレイヤーデータを取得 + */ + function getAllPlayerData(): PlayerDataRow[] { + return (stmts.allPlayer.all() as Row[]).map(toPlayerRow); + } + + /** + * @description 未同期のプレイヤーデータを取得 + */ + function getUnsyncedPlayerData(): { + id: number; + timestamp: string; + playerCount: number; + }[] { + return (stmts.unsyncedPlayer.all() as Row[]).map((row) => ({ + id: row.id as number, + timestamp: row.timestamp as string, + playerCount: row.player_count as number, + })); + } + + /** + * @description プレイヤーデータを同期済みに更新 + * @param ids - 更新対象のID配列 + */ + function markPlayerDataSynced(ids: number[]): void { + if (ids.length === 0) return; + const now = new Date().toISOString(); + transaction(() => { + for (const id of ids) stmts.markPlayerSyncedById.run(now, id); + }); + } + + /** + * @description 全プレイヤーデータを同期済みに更新 + */ + function markAllPlayerDataSynced(): void { + stmts.markAllPlayerSynced.run(new Date().toISOString()); + } + + /** + * @description 指定日の日次平均をSQLで計算 + * @param date - 日付 (YYYY-MM-DD) + * @returns 計算結果(データがない場合はnull) + */ + function calculateDailyAverage(date: string): DailyAverageRow | null { + const row = stmts.calcDailyAvg.get({ $date: date }) as Row | null; + if ( + !row || + row.sample_count === 0 || + row.max_timestamp === null || + row.min_timestamp === null + ) + return null; + return toDailyAvgRow(row); + } + + /** + * @description 日次平均をupsert + * @param row - 日次平均データ + */ + function upsertDailyAverage(row: DailyAverageRow): void { + stmts.upsertDailyAvg.run({ + $date: row.date, + $averagePlayerCount: row.averagePlayerCount, + $sampleCount: row.sampleCount, + $maxPlayerCount: row.maxPlayerCount, + $maxTimestamp: row.maxTimestamp, + $minPlayerCount: row.minPlayerCount, + $minTimestamp: row.minTimestamp, + }); + } + + /** + * @description 日付範囲で日次平均を取得 + * @param from - 開始日 (inclusive) + * @param to - 終了日 (inclusive) + */ + function getDailyAverageRange(from: string, to: string): DailyAverageRow[] { + return (stmts.dailyAvgByDateRange.all(from, to) as Row[]).map( + toDailyAvgRow, + ); + } + + /** + * @description 全日次平均を取得 + */ + function getAllDailyAverages(): DailyAverageRow[] { + return (stmts.allDailyAvg.all() as Row[]).map(toDailyAvgRow); + } + + /** + * @description 未同期の日次平均を取得 + */ + function getUnsyncedDailyAverages(): DailyAverageRow[] { + return (stmts.unsyncedDailyAvg.all() as Row[]).map(toDailyAvgRow); + } + + /** + * @description 日次平均を同期済みに更新 + * @param dates - 更新対象の日付配列 + */ + function markDailyAveragesSynced(dates: string[]): void { + if (dates.length === 0) return; + const now = new Date().toISOString(); + transaction(() => { + for (const date of dates) stmts.markDailyAvgSyncedByDate.run(now, date); + }); + } + + /** + * @description 全日次平均を同期済みに更新 + */ + function markAllDailyAveragesSynced(): void { + stmts.markAllDailyAvgSynced.run(new Date().toISOString()); + } + + /** + * @description データはあるが日次平均が未計算の日付を取得 + */ + function getDatesWithDataButNoAverage(): string[] { + return (stmts.datesWithNoAverage.all() as Row[]).map( + (row) => row.date as string, + ); + } + + /** + * @description 生SQLを実行 + * @param sql - SQL文 + */ + function run(sql: string): void { + db.run(sql); + } + + /** + * @description トランザクション内で関数を実行 + * @param fn - トランザクション内で実行する関数 + */ + function transaction(fn: () => T): T { + return db.transaction(fn)(); + } + + /** + * @description データベースを閉じる + */ + function close(): void { + db.close(); + } + + return { + insertPlayerData, + getPlayerDataByDateRange, + getAllPlayerData, + getUnsyncedPlayerData, + markPlayerDataSynced, + markAllPlayerDataSynced, + calculateDailyAverage, + upsertDailyAverage, + getDailyAverageRange, + getAllDailyAverages, + getUnsyncedDailyAverages, + markDailyAveragesSynced, + markAllDailyAveragesSynced, + getDatesWithDataButNoAverage, + run, + transaction, + close, + [Symbol.dispose]: close, + }; +} diff --git a/src/googleSheets.ts b/src/googleSheets.ts new file mode 100644 index 0000000..2aba59a --- /dev/null +++ b/src/googleSheets.ts @@ -0,0 +1,320 @@ +import { promises as fs } from "node:fs"; +import { JWT } from "google-auth-library"; +import type { sheets_v4 } from "googleapis"; +import { google } from "googleapis"; +import { googleServiceAccountSchema } from "./schemas/googleCredentials"; + +/** + * @description シートの列定義 + * @property headers - ヘッダー行の値 + * @property columnRange - 列の範囲(例: "A:B", "A:G") + * @property toRow - レコードをスプレッドシートの行に変換 + * @property getKey - レコードのキー(upsert用の検索値)を返す + */ +export interface SheetColumnDef { + headers: string[]; + columnRange: string; + toRow: (record: T) => unknown[]; + getKey: (record: T) => string; +} + +/** + * @description Google Sheetsアクセサの公開インターフェース + */ +export interface SheetAccessor { + append(record: T): Promise; + batchAppend(records: T[]): Promise; + replaceAll(records: T[]): Promise; +} + +/** + * @description 認証済みのGoogle Sheets APIクライアントを生成 + * @param keyPath - サービスアカウントキーファイルパス + * @returns Sheets APIクライアント + */ +async function createSheetsClient(keyPath: string): Promise { + const keyFile = await fs.readFile(keyPath, "utf8"); + const credentials = googleServiceAccountSchema.parse(JSON.parse(keyFile)); + + const auth = new JWT({ + email: credentials.client_email, + key: credentials.private_key, + scopes: ["https://www.googleapis.com/auth/spreadsheets"], + }); + + return google.sheets({ version: "v4", auth }); +} + +/** + * @description 汎用Google Sheetsアクセサを生成 + * @param spreadsheetId - スプレッドシートID + * @param sheetName - シート名 + * @param keyPath - サービスアカウントキーファイルパス + * @param columnDef - 列定義 + * @returns シート操作関数を持つオブジェクト + */ +export function createSheetAccessor( + spreadsheetId: string, + sheetName: string, + keyPath: string, + columnDef: SheetColumnDef, +): SheetAccessor { + let lastRequestTime = 0; + const minRequestInterval = 100; + let headerVerified = false; + const parts = columnDef.columnRange.split(":"); + if (parts.length !== 2 || !parts[1]) { + throw new Error( + `Invalid columnRange "${columnDef.columnRange}": expected "A:B" format`, + ); + } + const lastCol = parts[1]; + + const sheetsPromise = createSheetsClient(keyPath); + + /** + * @description レート制限付きでAPIリクエストを実行(100ms間隔) + * @param requestFn - 実行するリクエスト関数 + */ + async function rateLimitedRequest( + requestFn: () => Promise, + ): Promise { + const now = Date.now(); + const timeSinceLastRequest = now - lastRequestTime; + + if (timeSinceLastRequest < minRequestInterval) { + const delay = minRequestInterval - timeSinceLastRequest; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + lastRequestTime = Date.now(); + return await requestFn(); + } + + /** + * @description シートを新規作成 + */ + async function createSheet(): Promise { + const sheets = await sheetsPromise; + try { + await sheets.spreadsheets.batchUpdate({ + spreadsheetId, + requestBody: { + requests: [{ addSheet: { properties: { title: sheetName } } }], + }, + }); + } catch (error) { + if ( + !(error instanceof Error && error.message.includes("already exists")) + ) { + throw error; + } + } + } + + /** + * @description ヘッダー行を確保 + */ + async function ensureHeader(): Promise { + if (headerVerified) return; + const sheets = await sheetsPromise; + const { headers } = columnDef; + const headerRange = `${sheetName}!A1:${lastCol}1`; + + try { + const response = await rateLimitedRequest(() => + sheets.spreadsheets.values.get({ + spreadsheetId, + range: headerRange, + }), + ); + + const values = response.data.values; + const firstCell = values?.[0]?.[0]; + if (!values || values.length === 0 || firstCell !== headers[0]) { + await rateLimitedRequest(() => + sheets.spreadsheets.values.update({ + spreadsheetId, + range: headerRange, + valueInputOption: "RAW", + requestBody: { values: [headers] }, + }), + ); + } + headerVerified = true; + } catch (error) { + if ( + error instanceof Error && + error.message.includes("Unable to parse range") + ) { + await createSheet(); + await ensureHeader(); + } else { + throw error; + } + } + } + + /** + * @description キーでレコード行を検索 + * @param key - 検索するキー値 + * @returns 行番号(1始まり)またはnull + */ + async function findRowByKey(key: string): Promise { + const sheets = await sheetsPromise; + try { + const response = await rateLimitedRequest(() => + sheets.spreadsheets.values.get({ + spreadsheetId, + range: `${sheetName}!A:A`, + }), + ); + + const values = response.data.values; + if (!values || values.length <= 1) return null; + + for (let i = 1; i < values.length; i++) { + if (values[i]?.[0] === key) return i + 1; + } + return null; + } catch (error) { + if ( + error instanceof Error && + error.message.includes("Unable to parse range") + ) { + return null; + } + throw error; + } + } + + /** + * @description シートデータをクリア(ヘッダー除く) + */ + async function clearData(): Promise { + const sheets = await sheetsPromise; + try { + await rateLimitedRequest(() => + sheets.spreadsheets.values.clear({ + spreadsheetId, + range: `${sheetName}!A2:${lastCol}`, + }), + ); + } catch (error) { + if ( + !( + error instanceof Error && + error.message.includes("Unable to parse range") + ) + ) { + throw error; + } + } + } + + /** + * @description レコードを追加または更新 + * @param record - 追加するレコード + */ + async function append(record: T): Promise { + const sheets = await sheetsPromise; + try { + await ensureHeader(); + + const key = columnDef.getKey(record); + const existingRow = await findRowByKey(key); + const values = [columnDef.toRow(record)]; + + if (existingRow !== null) { + await rateLimitedRequest(() => + sheets.spreadsheets.values.update({ + spreadsheetId, + range: `${sheetName}!A${existingRow}:${lastCol}${existingRow}`, + valueInputOption: "RAW", + requestBody: { values }, + }), + ); + } else { + await rateLimitedRequest(() => + sheets.spreadsheets.values.append({ + spreadsheetId, + range: `${sheetName}!${columnDef.columnRange}`, + valueInputOption: "RAW", + insertDataOption: "INSERT_ROWS", + requestBody: { values }, + }), + ); + } + } catch (error) { + throw new Error( + `Failed to append/update record to Google Sheets: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * @description 複数レコードを一括追加 + * @param records - 追加するレコードの配列 + */ + async function batchAppend(records: T[]): Promise { + if (records.length === 0) return; + + const sheets = await sheetsPromise; + try { + await ensureHeader(); + + const values = records.map((r) => columnDef.toRow(r)); + + await rateLimitedRequest(() => + sheets.spreadsheets.values.append({ + spreadsheetId, + range: `${sheetName}!${columnDef.columnRange}`, + valueInputOption: "RAW", + insertDataOption: "INSERT_ROWS", + requestBody: { values }, + }), + ); + } catch (error) { + throw new Error( + `Failed to batch append records to Google Sheets: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + /** + * @description 全レコードを置換 + * @param records - 置換するレコードの配列 + */ + async function replaceAll(records: T[]): Promise { + const sheets = await sheetsPromise; + const { headers } = columnDef; + try { + await clearData(); + + if (records.length === 0) return; + + const sortedRecords = [...records].sort((a, b) => + columnDef.getKey(a).localeCompare(columnDef.getKey(b)), + ); + + const values = [headers, ...sortedRecords.map((r) => columnDef.toRow(r))]; + + await rateLimitedRequest(() => + sheets.spreadsheets.values.update({ + spreadsheetId, + range: `${sheetName}!${columnDef.columnRange}`, + valueInputOption: "RAW", + requestBody: { values }, + }), + ); + + headerVerified = true; + } catch (error) { + throw new Error( + `Failed to replace all records in Google Sheets: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + } + + return { append, batchAppend, replaceAll }; +} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index cafc589..0000000 --- a/src/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { SteamPlayerTracker } from './steamPlayerTracker'; - -// Set window title for Windows -function setWindowTitle(title: string) { - if (process.platform === 'win32') { - process.title = title; - } -} - -async function main() { - try { - // Set initial window title - setWindowTitle('Steam Player Tracker - Starting...'); - - const tracker = new SteamPlayerTracker(); - - // Update title when tracker starts - setWindowTitle('Steam Player Tracker - Running'); - - await tracker.start(); - } catch (error) { - setWindowTitle('Steam Player Tracker - Error'); - console.error('Fatal error:', error instanceof Error ? error.message : String(error)); - process.exit(1); - } -} - -if (require.main === module) { - main(); -} \ No newline at end of file diff --git a/src/jobs/collectData.ts b/src/jobs/collectData.ts new file mode 100644 index 0000000..f525818 --- /dev/null +++ b/src/jobs/collectData.ts @@ -0,0 +1,51 @@ +import { config } from "../config"; +import type { Database } from "../db"; +import { createLogger } from "../logger"; +import { retry } from "../retry"; +import { steamPlayerCountResponseSchema } from "../schemas/steamApi"; + +const logger = createLogger("collect-data"); + +/** + * @description Steam APIから現在のプレイヤー数を取得 + */ +export async function fetchPlayerCount(): Promise { + const url = `https://api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/?appid=${config.steam.appId}`; + + const response = await fetch(url, { signal: AbortSignal.timeout(10000) }); + if (!response.ok) { + throw new Error(`Steam API error: ${response.status}`); + } + + const data = await response.json(); + const count = + steamPlayerCountResponseSchema.parse(data).response.player_count; + + if (count === 0) { + throw new Error( + "Steam API returned 0 players - treating as failed request", + ); + } + return count; +} + +/** + * @description プレイヤーデータを収集してSQLiteに保存 + * @param db - データベースインスタンス + */ +export async function collectData(db: Database): Promise { + try { + logger.info("Starting data collection..."); + + const playerCount = await retry(fetchPlayerCount); + + const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19); + db.insertPlayerData(timestamp, playerCount); + + logger.info("Data collection completed", { timestamp, playerCount }); + } catch (error) { + logger.error("Data collection failed", { + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/src/jobs/dailyAverage.ts b/src/jobs/dailyAverage.ts new file mode 100644 index 0000000..d4db8f6 --- /dev/null +++ b/src/jobs/dailyAverage.ts @@ -0,0 +1,42 @@ +import type { Database } from "../db"; +import { createLogger } from "../logger"; + +const logger = createLogger("daily-average"); + +/** + * @description 欠落している日次平均を計算しバックフィル + * @param db - データベースインスタンス + */ +export async function calculateAndSaveDailyAverages( + db: Database, +): Promise { + try { + const missingDates = db.getDatesWithDataButNoAverage(); + + if (missingDates.length === 0) { + logger.info("All daily averages are up to date"); + return; + } + + logger.info(`Calculating daily averages for ${missingDates.length} dates`); + + for (const date of missingDates) { + const result = db.calculateDailyAverage(date); + if (result) { + db.upsertDailyAverage(result); + logger.info(`Daily average calculated for ${date}`, { + average: result.averagePlayerCount, + samples: result.sampleCount, + }); + } + } + + logger.info( + `Daily average calculation completed for ${missingDates.length} dates`, + ); + } catch (error) { + logger.error("Daily average calculation failed", { + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/src/jobs/syncSheets.ts b/src/jobs/syncSheets.ts new file mode 100644 index 0000000..055059e --- /dev/null +++ b/src/jobs/syncSheets.ts @@ -0,0 +1,143 @@ +import { config } from "../config"; +import type { DailyAverageRow, Database, PlayerDataRow } from "../db"; +import { createSheetAccessor, type SheetColumnDef } from "../googleSheets"; +import { createLogger } from "../logger"; + +const logger = createLogger("sync-sheets"); + +/** + * @description プレイヤーデータ用の列定義 + */ +export const playerDataColumnDef: SheetColumnDef = { + headers: ["timestamp (UTC)", "player_count"], + columnRange: "A:B", + toRow: (r) => [r.timestamp, r.playerCount], + getKey: (r) => r.timestamp, +}; + +/** + * @description 日次平均データ用の列定義 + */ +export const dailyAverageColumnDef: SheetColumnDef = { + headers: [ + "date (UTC)", + "average_player_count", + "sample_count", + "max_player_count", + "max_timestamp (UTC)", + "min_player_count", + "min_timestamp (UTC)", + ], + columnRange: "A:G", + toRow: (r) => [ + r.date, + r.averagePlayerCount, + r.sampleCount, + r.maxPlayerCount, + r.maxTimestamp, + r.minPlayerCount, + r.minTimestamp, + ], + getKey: (r) => r.date, +}; + +/** + * @description Sheets同期用のアクセサセット + */ +export type SheetAccessors = ReturnType; + +/** + * @description Sheets同期用のアクセサを生成 + */ +export function createSheetAccessors() { + const gs = config.googleSheets; + if (!gs.enabled) throw new Error("Google Sheets is not enabled"); + return { + playerSheets: createSheetAccessor( + gs.spreadsheetId, + gs.sheetName, + gs.serviceAccountKeyPath, + playerDataColumnDef, + ), + dailyAverageSheets: createSheetAccessor( + gs.spreadsheetId, + gs.dailyAverageSheetName, + gs.serviceAccountKeyPath, + dailyAverageColumnDef, + ), + }; +} + +/** + * @description 未同期レコードをGoogle Sheetsに同期 + * @param db - データベースインスタンス + * @param accessors - Sheetsアクセサ + */ +export async function syncUnsyncedToSheets( + db: Database, + accessors: SheetAccessors, +): Promise { + const unsyncedPlayers = db.getUnsyncedPlayerData(); + const unsyncedAverages = db.getUnsyncedDailyAverages(); + + if (unsyncedPlayers.length === 0 && unsyncedAverages.length === 0) { + logger.debug("No unsynced records to sync"); + return; + } + + const { playerSheets, dailyAverageSheets } = accessors; + + try { + if (unsyncedPlayers.length > 0) { + logger.info(`Syncing ${unsyncedPlayers.length} player records to Sheets`); + await playerSheets.batchAppend(unsyncedPlayers); + db.markPlayerDataSynced(unsyncedPlayers.map((r) => r.id)); + logger.info(`Synced ${unsyncedPlayers.length} player records`); + } + + if (unsyncedAverages.length > 0) { + logger.info( + `Syncing ${unsyncedAverages.length} daily average records to Sheets`, + ); + // 日次平均は再計算で既存行が更新される可能性があるためupsert(append)を使用 + for (const record of unsyncedAverages) { + await dailyAverageSheets.append(record); + } + db.markDailyAveragesSynced(unsyncedAverages.map((r) => r.date)); + logger.info(`Synced ${unsyncedAverages.length} daily average records`); + } + } catch (error) { + logger.error("Sheets sync failed", { + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * @description 全データをGoogle Sheetsに完全同期(replaceAll) + * @param db - データベースインスタンス + * @param accessors - Sheetsアクセサ + */ +export async function fullSyncToSheets( + db: Database, + accessors: SheetAccessors, +): Promise { + const { playerSheets, dailyAverageSheets } = accessors; + + logger.info("Starting full sync to Google Sheets..."); + + const allPlayers = db.getAllPlayerData(); + logger.info(`Replacing all player data (${allPlayers.length} records)...`); + await playerSheets.replaceAll(allPlayers); + + const allAverages = db.getAllDailyAverages(); + logger.info( + `Replacing all daily averages (${allAverages.length} records)...`, + ); + await dailyAverageSheets.replaceAll(allAverages); + + db.markAllPlayerDataSynced(); + db.markAllDailyAveragesSynced(); + + logger.info("Full sync completed"); +} diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..95bde7c --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,67 @@ +/** + * @description ログレベル + */ +type LogLevel = "debug" | "info" | "warn" | "error"; + +/** + * @description ログレベルの優先度マップ + */ +const levels: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +/** + * @description 現在のログレベル閾値 + */ +const threshold = levels[(process.env.LOG_LEVEL ?? "info") as LogLevel] ?? 1; + +/** + * @description ログエントリをstdoutにJSON出力 + * @param level - ログレベル + * @param module - モジュール名 + * @param msg - メッセージ + * @param meta - 追加メタデータ + */ +function log( + level: LogLevel, + module: string, + msg: string, + meta?: Record, +): void { + if ((levels[level] ?? 0) < threshold) return; + + const entry = { + time: new Date().toISOString(), + level, + module, + msg, + ...meta, + }; + console.log(JSON.stringify(entry)); +} + +/** + * @description ロガーの公開インターフェース + */ +export interface Logger { + info(msg: string, meta?: Record): void; + warn(msg: string, meta?: Record): void; + error(msg: string, meta?: Record): void; + debug(msg: string, meta?: Record): void; +} + +/** + * @description モジュール別ロガーを生成 + * @param module - モジュール名 + */ +export function createLogger(module: string): Logger { + return { + info: (msg, meta?) => log("info", module, msg, meta), + warn: (msg, meta?) => log("warn", module, msg, meta), + error: (msg, meta?) => log("error", module, msg, meta), + debug: (msg, meta?) => log("debug", module, msg, meta), + }; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..20f76e0 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,133 @@ +import { config } from "./config"; +import { createDatabase } from "./db"; +import { collectData, fetchPlayerCount } from "./jobs/collectData"; +import { calculateAndSaveDailyAverages } from "./jobs/dailyAverage"; +import { createSheetAccessors, syncUnsyncedToSheets } from "./jobs/syncSheets"; +import { createLogger } from "./logger"; +import { retry } from "./retry"; +import { steamAppDetailsSchema } from "./schemas/steamApi"; + +/** + * @description トラッカーを起動 + */ +async function startTracker(): Promise { + const logger = createLogger("main"); + const db = createDatabase(config.storage.dbPath); + + const timers: ReturnType[] = []; + + try { + logger.info("Steam Player Tracker starting...", { + appId: config.steam.appId, + dbPath: config.storage.dbPath, + }); + + const [playerCountResult, gameNameResult] = await Promise.allSettled([ + retry(fetchPlayerCount), + fetch( + `https://store.steampowered.com/api/appdetails?appids=${config.steam.appId}&filters=basic`, + { signal: AbortSignal.timeout(10000) }, + ).then(async (res) => { + if (!res.ok) return null; + const data = (await res.json()) as Record; + const parsed = steamAppDetailsSchema.safeParse( + data[String(config.steam.appId)], + ); + return parsed.success ? parsed.data.data.name : null; + }), + ]); + + if (playerCountResult.status === "rejected") { + throw playerCountResult.reason; + } + logger.info("Configuration validated", { + testPlayerCount: playerCountResult.value, + }); + + if ( + gameNameResult.status === "fulfilled" && + gameNameResult.value !== null + ) { + logger.info(`Detected game: ${gameNameResult.value}`); + } + + const sheetAccessors = config.googleSheets.enabled + ? createSheetAccessors() + : null; + + await collectData(db); + await calculateAndSaveDailyAverages(db); + + let running = false; + const timer = setInterval(async () => { + if (running) return; + running = true; + try { + const now = new Date(); + const minute = now.getUTCMinutes(); + const hour = now.getUTCHours(); + + if (config.scheduling.collectionMinutes.includes(minute)) { + await collectData(db); + } + + if (hour === config.scheduling.dailyAverageHour && minute === 0) { + await calculateAndSaveDailyAverages(db); + } + + if ( + sheetAccessors && + config.scheduling.sheetsSyncMinutes.includes(minute) + ) { + await syncUnsyncedToSheets(db, sheetAccessors); + } + } finally { + running = false; + } + }, 60_000); + timers.push(timer); + + const shutdown = (signal: string) => { + logger.info(`Received ${signal}. Shutting down...`); + for (const t of timers) clearInterval(t); + db.close(); + process.exit(0); + }; + + process.on("SIGINT", () => shutdown("SIGINT")); + process.on("SIGTERM", () => shutdown("SIGTERM")); + + logger.info("Steam Player Tracker is running", { + appId: config.steam.appId, + dbPath: config.storage.dbPath, + }); + } catch (error) { + logger.error("Failed to start", { + error: error instanceof Error ? error.message : String(error), + }); + for (const t of timers) clearInterval(t); + db.close(); + throw error; + } +} + +/** + * @description エントリーポイント + */ +async function main() { + if (process.platform === "win32") { + process.title = "Steam Player Tracker"; + } + + try { + await startTracker(); + } catch (error) { + console.error( + "Fatal error:", + error instanceof Error ? error.message : String(error), + ); + process.exit(1); + } +} + +main(); diff --git a/src/retry.ts b/src/retry.ts new file mode 100644 index 0000000..8d7be70 --- /dev/null +++ b/src/retry.ts @@ -0,0 +1,28 @@ +import { createLogger } from "./logger"; + +const logger = createLogger("retry"); + +/** + * @description 指数バックオフ付きリトライ + * @param fn - 実行する非同期処理 + * @param attempts - 最大試行回数 @default 4 + * @param baseDelay - 基本遅延(ms) @default 1000 + */ +export async function retry( + fn: () => Promise, + { attempts = 4, baseDelay = 1000 } = {}, +): Promise { + for (let i = 0; i < attempts; i++) { + try { + return await fn(); + } catch (error) { + if (i === attempts - 1) throw error; + const delay = Math.min(baseDelay * 5 ** i, 30000); + logger.warn( + `Attempt ${i + 1}/${attempts} failed: ${error instanceof Error ? error.message : error}. Retrying in ${delay}ms...`, + ); + await new Promise((r) => setTimeout(r, delay)); + } + } + throw new Error("unreachable"); +} diff --git a/src/schemas/googleCredentials.ts b/src/schemas/googleCredentials.ts new file mode 100644 index 0000000..e54b446 --- /dev/null +++ b/src/schemas/googleCredentials.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const googleServiceAccountSchema = z.object({ + client_email: z.string().email(), + private_key: z.string().min(1), +}); + +export type GoogleServiceAccount = z.infer; diff --git a/src/schemas/steamApi.ts b/src/schemas/steamApi.ts new file mode 100644 index 0000000..b9696ed --- /dev/null +++ b/src/schemas/steamApi.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +export const steamPlayerCountResponseSchema = z.object({ + response: z.object({ + player_count: z.number(), + }), +}); + +export type SteamPlayerCountResponse = z.infer< + typeof steamPlayerCountResponseSchema +>; + +export const steamAppDetailsSchema = z.object({ + success: z.literal(true), + data: z.object({ + name: z.string(), + }), +}); + +export type SteamAppDetails = z.infer; diff --git a/src/services/csvWriter.ts b/src/services/csvWriter.ts deleted file mode 100644 index c1c505a..0000000 --- a/src/services/csvWriter.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { promises as fs } from 'fs'; -import { dirname } from 'path'; -import { PlayerDataRecord } from '../types/config'; - -export class CsvWriter { - private filePath: string; - - constructor(filePath: string) { - this.filePath = filePath; - } - - async writeRecord(record: PlayerDataRecord): Promise { - try { - await this.ensureDirectoryExists(); - - const fileExists = await this.fileExists(); - const csvLine = `${record.timestamp},${record.playerCount}\n`; - - if (!fileExists) { - const header = 'timestamp,player_count\n'; - await fs.writeFile(this.filePath, header + csvLine, 'utf8'); - } else { - await fs.appendFile(this.filePath, csvLine, 'utf8'); - } - } catch (error) { - throw new Error(`Failed to write CSV record: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - - async writeDailyAverageRecord( - date: string, - averagePlayerCount: number, - sampleCount: number, - maxPlayerCount?: number, - maxPlayerTimestamp?: string, - minPlayerCount?: number, - minPlayerTimestamp?: string - ): Promise { - try { - await this.ensureDirectoryExists(); - - const fileExists = await this.fileExists(); - - // Build CSV line based on available data - let csvLine: string; - if (maxPlayerCount !== undefined && maxPlayerTimestamp && minPlayerCount !== undefined && minPlayerTimestamp) { - csvLine = `${date},${averagePlayerCount},${sampleCount},${maxPlayerCount},${maxPlayerTimestamp},${minPlayerCount},${minPlayerTimestamp}\n`; - } else { - // Backward compatibility: only write basic data if max/min not provided - csvLine = `${date},${averagePlayerCount},${sampleCount}\n`; - } - - if (!fileExists) { - const header = maxPlayerCount !== undefined - ? 'date,average_player_count,sample_count,max_player_count,max_timestamp,min_player_count,min_timestamp\n' - : 'date,average_player_count,sample_count\n'; - await fs.writeFile(this.filePath, header + csvLine, 'utf8'); - } else { - await fs.appendFile(this.filePath, csvLine, 'utf8'); - } - } catch (error) { - throw new Error(`Failed to write daily average CSV record: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - - private async fileExists(): Promise { - try { - await fs.access(this.filePath); - return true; - } catch { - return false; - } - } - - private async ensureDirectoryExists(): Promise { - const dir = dirname(this.filePath); - try { - await fs.mkdir(dir, { recursive: true }); - } catch (error) { - if (error instanceof Error && 'code' in error && error.code !== 'EEXIST') { - throw error; - } - } - } -} \ No newline at end of file diff --git a/src/services/dailyAverageService.ts b/src/services/dailyAverageService.ts deleted file mode 100644 index 17643c3..0000000 --- a/src/services/dailyAverageService.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { promises as fs } from 'fs'; -import { CsvWriter } from './csvWriter'; -import { QueuedGoogleSheetsService } from './queuedGoogleSheets'; -import { PlayerDataRecord } from '../types/config'; -import { createLogger } from '../utils/logger'; - -export interface DailyAverageRecord { - date: string; - averagePlayerCount: number; - sampleCount: number; - maxPlayerCount: number; - maxPlayerTimestamp: string; - minPlayerCount: number; - minPlayerTimestamp: string; -} - -export class DailyAverageService { - private csvWriter: CsvWriter; - private dailyAverageCsvPath: string; - private queuedGoogleSheets?: QueuedGoogleSheetsService; - private logger: ReturnType; - private sourceCsvPath: string; - - constructor( - sourceCsvPath: string, - dailyAverageCsvPath: string, - logger: ReturnType, - queuedGoogleSheets?: QueuedGoogleSheetsService - ) { - this.sourceCsvPath = sourceCsvPath; - this.dailyAverageCsvPath = dailyAverageCsvPath; - this.csvWriter = new CsvWriter(dailyAverageCsvPath); - this.queuedGoogleSheets = queuedGoogleSheets; - this.logger = logger; - } - - async calculateAndSaveDailyAverage(date: Date): Promise { - try { - const dateStr = date.toISOString().split('T')[0]; - this.logger.info(`Calculating daily average for ${dateStr}`); - - const dailyData = await this.readDailyData(dateStr); - - if (dailyData.length === 0) { - this.logger.warn(`No data found for ${dateStr}`); - return; - } - - // Filter out 0 values as requested - const validData = dailyData.filter(record => record.playerCount > 0); - - if (validData.length === 0) { - this.logger.warn(`No valid data (non-zero) found for ${dateStr}`); - return; - } - - const sum = validData.reduce((acc, record) => acc + record.playerCount, 0); - const average = Math.round(sum / validData.length); - - // Find max and min values with their timestamps - let maxRecord = validData[0]; - let minRecord = validData[0]; - - for (const record of validData) { - if (record.playerCount > maxRecord.playerCount) { - maxRecord = record; - } - if (record.playerCount < minRecord.playerCount) { - minRecord = record; - } - } - - const averageRecord: DailyAverageRecord = { - date: dateStr, - averagePlayerCount: average, - sampleCount: validData.length, - maxPlayerCount: maxRecord.playerCount, - maxPlayerTimestamp: maxRecord.timestamp, - minPlayerCount: minRecord.playerCount, - minPlayerTimestamp: minRecord.timestamp - }; - - await this.saveAverageRecord(averageRecord); - - this.logger.info(`Daily average calculated successfully for ${dateStr}`, { - average, - sampleCount: validData.length, - excludedZeros: dailyData.length - validData.length, - max: averageRecord.maxPlayerCount, - maxTime: averageRecord.maxPlayerTimestamp, - min: averageRecord.minPlayerCount, - minTime: averageRecord.minPlayerTimestamp - }); - - } catch (error) { - this.logger.error(`Failed to calculate daily average: ${error instanceof Error ? error.message : 'Unknown error'}`); - throw error; - } - } - - private async readDailyData(dateStr: string): Promise { - try { - const csvContent = await fs.readFile(this.sourceCsvPath, 'utf8'); - const lines = csvContent.trim().split('\n'); - - if (lines.length <= 1) { - return []; - } - - const records: PlayerDataRecord[] = []; - - // Skip header row - for (let i = 1; i < lines.length; i++) { - const [timestamp, playerCountStr] = lines[i].split(','); - - if (timestamp && timestamp.startsWith(dateStr)) { - const playerCount = parseInt(playerCountStr, 10); - if (!isNaN(playerCount)) { - records.push({ - timestamp: timestamp.trim(), - playerCount - }); - } - } - } - - return records; - } catch (error) { - if (error && typeof error === 'object' && 'code' in error && (error as {code: string}).code === 'ENOENT') { - return []; - } - throw error; - } - } - - private async saveAverageRecord(record: DailyAverageRecord): Promise { - const savePromises: Promise[] = []; - - // Save to CSV with all data including max/min - savePromises.push( - this.csvWriter.writeDailyAverageRecord( - record.date, - record.averagePlayerCount, - record.sampleCount, - record.maxPlayerCount, - record.maxPlayerTimestamp, - record.minPlayerCount, - record.minPlayerTimestamp - ) - ); - - // Save to Google Sheets if enabled - if (this.queuedGoogleSheets) { - const sheetsRecord = { - timestamp: record.date, - playerCount: record.averagePlayerCount, - sampleCount: record.sampleCount, - maxPlayerCount: record.maxPlayerCount, - maxPlayerTimestamp: record.maxPlayerTimestamp, - minPlayerCount: record.minPlayerCount, - minPlayerTimestamp: record.minPlayerTimestamp - }; - savePromises.push( - this.queuedGoogleSheets.addDailyAverageRecord(sheetsRecord) - ); - } - - await Promise.all(savePromises); - } - - async updateAllDailyAverages(): Promise { - try { - this.logger.info('Updating all daily averages...'); - - const csvContent = await fs.readFile(this.sourceCsvPath, 'utf8'); - const lines = csvContent.trim().split('\n'); - - if (lines.length <= 1) { - this.logger.warn('No data to process'); - return; - } - - const dates = new Set(); - - // Skip header and collect unique dates - for (let i = 1; i < lines.length; i++) { - const [timestamp] = lines[i].split(','); - if (timestamp) { - const date = timestamp.split(' ')[0]; - dates.add(date); - } - } - - // Calculate average for each date - for (const dateStr of dates) { - const date = new Date(dateStr); - await this.calculateAndSaveDailyAverage(date); - } - - this.logger.info(`Updated daily averages for ${dates.size} days`); - } catch (error) { - this.logger.error(`Failed to update all daily averages: ${error instanceof Error ? error.message : 'Unknown error'}`); - throw error; - } - } - - async checkAndCalculateMissingAverages(): Promise { - try { - this.logger.info('Checking for missing daily averages...'); - - // Read source data to get all available dates - const sourceContent = await fs.readFile(this.sourceCsvPath, 'utf8'); - const sourceLines = sourceContent.trim().split('\n'); - - if (sourceLines.length <= 1) { - this.logger.info('No source data to process'); - return; - } - - const sourceDates = new Set(); - const today = new Date().toISOString().split('T')[0]; - - // Collect all dates from source data (excluding today) - for (let i = 1; i < sourceLines.length; i++) { - const [timestamp] = sourceLines[i].split(','); - if (timestamp) { - const date = timestamp.split(' ')[0]; - if (date < today) { - sourceDates.add(date); - } - } - } - - // Read existing daily averages - const existingAverages = new Set(); - try { - const averageContent = await fs.readFile(this.dailyAverageCsvPath, 'utf8'); - const averageLines = averageContent.trim().split('\n'); - - for (let i = 1; i < averageLines.length; i++) { - const [date] = averageLines[i].split(','); - if (date) { - existingAverages.add(date.trim()); - } - } - } catch (error) { - if (error && typeof error === 'object' && 'code' in error && (error as {code: string}).code !== 'ENOENT') { - throw error; - } - // File doesn't exist yet, that's ok - } - - // Find missing dates - const missingDates = Array.from(sourceDates).filter(date => !existingAverages.has(date)).sort(); - - if (missingDates.length === 0) { - this.logger.info('All daily averages are up to date'); - return; - } - - this.logger.info(`Found ${missingDates.length} missing daily averages`); - - // Calculate missing averages - for (const dateStr of missingDates) { - const date = new Date(dateStr); - await this.calculateAndSaveDailyAverage(date); - } - - this.logger.info(`Calculated ${missingDates.length} missing daily averages`); - } catch (error) { - this.logger.error(`Failed to check missing averages: ${error instanceof Error ? error.message : 'Unknown error'}`); - throw error; - } - } -} \ No newline at end of file diff --git a/src/services/googleSheets.ts b/src/services/googleSheets.ts deleted file mode 100644 index 77557f9..0000000 --- a/src/services/googleSheets.ts +++ /dev/null @@ -1,488 +0,0 @@ -import { google, sheets_v4 } from 'googleapis'; -import { JWT } from 'google-auth-library'; -import { PlayerDataRecord } from '../types/config'; - -import { promises as fs } from 'fs'; - -export interface DailyAverageSheetRecord { - timestamp: string; - playerCount: number; - sampleCount: number; - maxPlayerCount?: number; - maxPlayerTimestamp?: string; - minPlayerCount?: number; - minPlayerTimestamp?: string; -} - -export class GoogleSheetsService { - private sheets!: sheets_v4.Sheets; - private spreadsheetId: string; - private sheetName: string; - private lastRequestTime: number = 0; - private readonly minRequestInterval: number = 100; // 100ms between requests - - constructor(spreadsheetId: string, sheetName: string, serviceAccountKeyPath: string) { - this.spreadsheetId = spreadsheetId; - this.sheetName = sheetName; - this.initializeAuth(serviceAccountKeyPath); - } - - private async initializeAuth(serviceAccountKeyPath: string): Promise { - try { - const keyFile = await fs.readFile(serviceAccountKeyPath, 'utf8'); - const credentials = JSON.parse(keyFile); - - const auth = new JWT({ - email: credentials.client_email, - key: credentials.private_key, - scopes: ['https://www.googleapis.com/auth/spreadsheets'], - }); - - this.sheets = google.sheets({ version: 'v4', auth }); - } catch (error) { - throw new Error(`Failed to initialize Google Sheets authentication: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - - private async rateLimitedRequest(requestFn: () => Promise): Promise { - const now = Date.now(); - const timeSinceLastRequest = now - this.lastRequestTime; - - if (timeSinceLastRequest < this.minRequestInterval) { - const delay = this.minRequestInterval - timeSinceLastRequest; - await new Promise(resolve => setTimeout(resolve, delay)); - } - - this.lastRequestTime = Date.now(); - return await requestFn(); - } - - async batchAppendRecords(records: PlayerDataRecord[]): Promise { - if (records.length === 0) return; - - try { - await this.ensureHeaderExists(); - - // Convert records to values array - const values = records.map(record => [record.timestamp, record.playerCount]); - - // Batch append all records at once - await this.rateLimitedRequest(() => - this.sheets.spreadsheets.values.append({ - spreadsheetId: this.spreadsheetId, - range: `${this.sheetName}!A:B`, - valueInputOption: 'RAW', - insertDataOption: 'INSERT_ROWS', - requestBody: { - values: values, - }, - }) - ); - } catch (error) { - throw new Error(`Failed to batch append records to Google Sheets: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - - async batchAppendDailyAverageRecords(records: DailyAverageSheetRecord[]): Promise { - if (records.length === 0) return; - - try { - await this.ensureDailyAverageHeaderExists(); - - // Convert records to values array - const values = records.map(record => [ - record.timestamp, - record.playerCount, - record.sampleCount, - record.maxPlayerCount || '', - record.maxPlayerTimestamp || '', - record.minPlayerCount || '', - record.minPlayerTimestamp || '' - ]); - - // Batch append all records at once - await this.rateLimitedRequest(() => - this.sheets.spreadsheets.values.append({ - spreadsheetId: this.spreadsheetId, - range: `${this.sheetName}!A:G`, - valueInputOption: 'RAW', - insertDataOption: 'INSERT_ROWS', - requestBody: { - values: values, - }, - }) - ); - } catch (error) { - throw new Error(`Failed to batch append daily average records to Google Sheets: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - - async replaceAllRecords(records: PlayerDataRecord[]): Promise { - try { - // Clear existing data (keep header) - await this.clearSheetData(); - - if (records.length === 0) return; - - // Sort records by timestamp - const sortedRecords = [...records].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); - - // Convert to values array with header - const values = [ - ['timestamp (UTC)', 'player_count'], // Header - ...sortedRecords.map(record => [record.timestamp, record.playerCount]) - ]; - - // Replace all data - await this.rateLimitedRequest(() => - this.sheets.spreadsheets.values.update({ - spreadsheetId: this.spreadsheetId, - range: `${this.sheetName}!A:B`, - valueInputOption: 'RAW', - requestBody: { - values: values, - }, - }) - ); - } catch (error) { - throw new Error(`Failed to replace all records in Google Sheets: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - - async replaceAllDailyAverageRecords(records: DailyAverageSheetRecord[]): Promise { - try { - // Clear existing data (keep header) - await this.clearDailyAverageSheetData(); - - if (records.length === 0) return; - - // Sort records by date - const sortedRecords = [...records].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); - - // Convert to values array with header - const values = [ - [ - 'date (UTC)', - 'average_player_count', - 'sample_count', - 'max_player_count', - 'max_timestamp (UTC)', - 'min_player_count', - 'min_timestamp (UTC)' - ], // Header - ...sortedRecords.map(record => [ - record.timestamp, - record.playerCount, - record.sampleCount, - record.maxPlayerCount || '', - record.maxPlayerTimestamp || '', - record.minPlayerCount || '', - record.minPlayerTimestamp || '' - ]) - ]; - - // Replace all data - await this.rateLimitedRequest(() => - this.sheets.spreadsheets.values.update({ - spreadsheetId: this.spreadsheetId, - range: `${this.sheetName}!A:G`, - valueInputOption: 'RAW', - requestBody: { - values: values, - }, - }) - ); - } catch (error) { - throw new Error(`Failed to replace all daily average records in Google Sheets: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - - private async clearSheetData(): Promise { - try { - // Get the range of existing data - const response = await this.rateLimitedRequest(() => - this.sheets.spreadsheets.values.get({ - spreadsheetId: this.spreadsheetId, - range: `${this.sheetName}!A:B`, - }) - ); - - const rowCount = response.data.values?.length || 0; - if (rowCount > 1) { - // Clear data rows (keep header) - await this.rateLimitedRequest(() => - this.sheets.spreadsheets.values.clear({ - spreadsheetId: this.spreadsheetId, - range: `${this.sheetName}!A2:B${rowCount}`, - }) - ); - } - } catch (error) { - // If sheet doesn't exist or is empty, that's ok - if (!(error instanceof Error && error.message.includes('Unable to parse range'))) { - throw error; - } - } - } - - private async clearDailyAverageSheetData(): Promise { - try { - // Get the range of existing data - const response = await this.rateLimitedRequest(() => - this.sheets.spreadsheets.values.get({ - spreadsheetId: this.spreadsheetId, - range: `${this.sheetName}!A:G`, - }) - ); - - const rowCount = response.data.values?.length || 0; - if (rowCount > 1) { - // Clear data rows (keep header) - await this.rateLimitedRequest(() => - this.sheets.spreadsheets.values.clear({ - spreadsheetId: this.spreadsheetId, - range: `${this.sheetName}!A2:G${rowCount}`, - }) - ); - } - } catch (error) { - // If sheet doesn't exist or is empty, that's ok - if (!(error instanceof Error && error.message.includes('Unable to parse range'))) { - throw error; - } - } - } - - async appendRecord(record: PlayerDataRecord): Promise { - try { - await this.ensureHeaderExists(); - - // Check if a record for this timestamp already exists - const existingRowIndex = await this.findRecordByTimestamp(record.timestamp); - - const values = [[record.timestamp, record.playerCount]]; - - if (existingRowIndex !== null) { - // Update existing record - await this.rateLimitedRequest(() => - this.sheets.spreadsheets.values.update({ - spreadsheetId: this.spreadsheetId, - range: `${this.sheetName}!A${existingRowIndex}:B${existingRowIndex}`, - valueInputOption: 'RAW', - requestBody: { - values: values, - }, - }) - ); - } else { - // Append new record (order will be maintained by sync tool if needed) - await this.rateLimitedRequest(() => - this.sheets.spreadsheets.values.append({ - spreadsheetId: this.spreadsheetId, - range: `${this.sheetName}!A:B`, - valueInputOption: 'RAW', - insertDataOption: 'INSERT_ROWS', - requestBody: { - values: values, - }, - }) - ); - } - } catch (error) { - throw new Error(`Failed to append/update record to Google Sheets: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - - private async findRecordByTimestamp(timestamp: string): Promise { - try { - // Get all values from the timestamp column (column A) - const response = await this.sheets.spreadsheets.values.get({ - spreadsheetId: this.spreadsheetId, - range: `${this.sheetName}!A:A`, - }); - - const values = response.data.values; - if (!values || values.length <= 1) { - return null; - } - - // Search for the timestamp in the column (skip header at index 0) - for (let i = 1; i < values.length; i++) { - if (values[i][0] === timestamp) { - return i + 1; // Return 1-based row index for Google Sheets - } - } - - return null; - } catch (error) { - // If the sheet doesn't exist yet, return null - if (error instanceof Error && error.message.includes('Unable to parse range')) { - return null; - } - throw error; - } - } - - private async ensureHeaderExists(): Promise { - try { - const response = await this.sheets.spreadsheets.values.get({ - spreadsheetId: this.spreadsheetId, - range: `${this.sheetName}!A1:B1`, - }); - - const values = response.data.values; - if (!values || values.length === 0 || (values[0][0] !== 'timestamp' && values[0][0] !== 'timestamp (UTC)')) { - await this.sheets.spreadsheets.values.update({ - spreadsheetId: this.spreadsheetId, - range: `${this.sheetName}!A1:B1`, - valueInputOption: 'RAW', - requestBody: { - values: [['timestamp (UTC)', 'player_count']], - }, - }); - } - } catch (error) { - if (error instanceof Error && error.message.includes('Unable to parse range')) { - await this.createSheet(); - await this.ensureHeaderExists(); - } else { - throw error; - } - } - } - - async appendDailyAverageRecord(record: DailyAverageSheetRecord): Promise { - try { - await this.ensureDailyAverageHeaderExists(); - - // Check if a record for this date already exists - const existingRowIndex = await this.findDailyAverageRecordByDate(record.timestamp); - - const values = [[ - record.timestamp, - record.playerCount, - record.sampleCount, - record.maxPlayerCount || '', - record.maxPlayerTimestamp || '', - record.minPlayerCount || '', - record.minPlayerTimestamp || '' - ]]; - - if (existingRowIndex !== null) { - // Update existing record - await this.rateLimitedRequest(() => - this.sheets.spreadsheets.values.update({ - spreadsheetId: this.spreadsheetId, - range: `${this.sheetName}!A${existingRowIndex}:G${existingRowIndex}`, - valueInputOption: 'RAW', - requestBody: { - values: values, - }, - }) - ); - } else { - // Append new record (order will be maintained by sync tool if needed) - await this.rateLimitedRequest(() => - this.sheets.spreadsheets.values.append({ - spreadsheetId: this.spreadsheetId, - range: `${this.sheetName}!A:G`, - valueInputOption: 'RAW', - insertDataOption: 'INSERT_ROWS', - requestBody: { - values: values, - }, - }) - ); - } - } catch (error) { - throw new Error(`Failed to append/update daily average record to Google Sheets: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - - private async findDailyAverageRecordByDate(date: string): Promise { - try { - // Get all values from the date column (column A) - const response = await this.sheets.spreadsheets.values.get({ - spreadsheetId: this.spreadsheetId, - range: `${this.sheetName}!A:A`, - }); - - const values = response.data.values; - if (!values || values.length <= 1) { - return null; - } - - // Search for the date in the column (skip header at index 0) - for (let i = 1; i < values.length; i++) { - if (values[i][0] === date) { - return i + 1; // Return 1-based row index for Google Sheets - } - } - - return null; - } catch (error) { - // If the sheet doesn't exist yet, return null - if (error instanceof Error && error.message.includes('Unable to parse range')) { - return null; - } - throw error; - } - } - - private async ensureDailyAverageHeaderExists(): Promise { - try { - const response = await this.sheets.spreadsheets.values.get({ - spreadsheetId: this.spreadsheetId, - range: `${this.sheetName}!A1:G1`, - }); - - const values = response.data.values; - if (!values || values.length === 0 || (values[0][0] !== 'date' && values[0][0] !== 'date (UTC)')) { - await this.sheets.spreadsheets.values.update({ - spreadsheetId: this.spreadsheetId, - range: `${this.sheetName}!A1:G1`, - valueInputOption: 'RAW', - requestBody: { - values: [[ - 'date (UTC)', - 'average_player_count', - 'sample_count', - 'max_player_count', - 'max_timestamp (UTC)', - 'min_player_count', - 'min_timestamp (UTC)' - ]], - }, - }); - } - } catch (error) { - if (error instanceof Error && error.message.includes('Unable to parse range')) { - await this.createSheet(); - await this.ensureDailyAverageHeaderExists(); - } else { - throw error; - } - } - } - - private async createSheet(): Promise { - try { - await this.sheets.spreadsheets.batchUpdate({ - spreadsheetId: this.spreadsheetId, - requestBody: { - requests: [{ - addSheet: { - properties: { - title: this.sheetName, - }, - }, - }], - }, - }); - } catch (error) { - if (!(error instanceof Error && error.message.includes('already exists'))) { - throw error; - } - } - } -} \ No newline at end of file diff --git a/src/services/queuedGoogleSheets.ts b/src/services/queuedGoogleSheets.ts deleted file mode 100644 index c95e173..0000000 --- a/src/services/queuedGoogleSheets.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { GoogleSheetsService, DailyAverageSheetRecord } from './googleSheets'; -import { PlayerDataRecord } from '../types/config'; -import { createLogger } from '../utils/logger'; - -interface QueuedRecord { - type: 'player' | 'dailyAverage'; - data: PlayerDataRecord | DailyAverageSheetRecord; - retryCount: number; - timestamp: number; -} - -export class QueuedGoogleSheetsService { - private playerDataSheets?: GoogleSheetsService; - private dailyAverageSheets?: GoogleSheetsService; - private queue: QueuedRecord[] = []; - private isProcessing: boolean = false; - private logger: ReturnType; - private maxRetries: number = 3; - private retryInterval: number = 30000; // 30 seconds - - constructor( - playerDataSheets?: GoogleSheetsService, - dailyAverageSheets?: GoogleSheetsService, - logger?: ReturnType - ) { - this.playerDataSheets = playerDataSheets; - this.dailyAverageSheets = dailyAverageSheets; - this.logger = logger || createLogger('QueuedGoogleSheets'); - - // Start processing queue - this.startQueueProcessor(); - } - - async addPlayerRecord(record: PlayerDataRecord): Promise { - if (!this.playerDataSheets) { - throw new Error('Player data sheets service not initialized'); - } - - try { - // Try immediate write first - await this.playerDataSheets.appendRecord(record); - this.logger.info(`Successfully wrote player record: ${record.timestamp}`); - } catch (error) { - this.logger.warn(`Failed to write player record immediately, queuing for retry: ${record.timestamp}`, { error }); - - // Add to queue for retry - this.queue.push({ - type: 'player', - data: record, - retryCount: 0, - timestamp: Date.now() - }); - } - } - - async addDailyAverageRecord(record: DailyAverageSheetRecord): Promise { - if (!this.dailyAverageSheets) { - throw new Error('Daily average sheets service not initialized'); - } - - try { - // Try immediate write first - await this.dailyAverageSheets.appendDailyAverageRecord(record); - this.logger.info(`Successfully wrote daily average record: ${record.timestamp}`); - } catch (error) { - this.logger.warn(`Failed to write daily average record immediately, queuing for retry: ${record.timestamp}`, { error }); - - // Add to queue for retry - this.queue.push({ - type: 'dailyAverage', - data: record, - retryCount: 0, - timestamp: Date.now() - }); - } - } - - private startQueueProcessor(): void { - setInterval(async () => { - if (this.isProcessing || this.queue.length === 0) { - return; - } - - this.isProcessing = true; - await this.processQueue(); - this.isProcessing = false; - }, this.retryInterval); - } - - private async processQueue(): Promise { - if (this.queue.length === 0) { - return; - } - - this.logger.info(`Processing ${this.queue.length} queued records...`); - const processed: number[] = []; - - for (let i = 0; i < this.queue.length; i++) { - const queuedRecord = this.queue[i]; - - try { - if (queuedRecord.type === 'player') { - await this.playerDataSheets!.appendRecord(queuedRecord.data as PlayerDataRecord); - this.logger.info(`Successfully processed queued player record: ${(queuedRecord.data as PlayerDataRecord).timestamp}`); - } else { - await this.dailyAverageSheets!.appendDailyAverageRecord(queuedRecord.data as DailyAverageSheetRecord); - this.logger.info(`Successfully processed queued daily average record: ${(queuedRecord.data as DailyAverageSheetRecord).timestamp}`); - } - - processed.push(i); - } catch (error) { - queuedRecord.retryCount++; - this.logger.warn(`Failed to process queued record (attempt ${queuedRecord.retryCount}/${this.maxRetries})`, { error }); - - if (queuedRecord.retryCount >= this.maxRetries) { - this.logger.error(`Max retries exceeded for record, removing from queue: ${JSON.stringify(queuedRecord.data)}`); - processed.push(i); - } - } - } - - // Remove processed items from queue (in reverse order to maintain indices) - for (let i = processed.length - 1; i >= 0; i--) { - this.queue.splice(processed[i], 1); - } - - if (processed.length > 0) { - this.logger.info(`Processed ${processed.length} records from queue. ${this.queue.length} records remaining.`); - } - } - - getQueueStatus(): { queueLength: number; isProcessing: boolean } { - return { - queueLength: this.queue.length, - isProcessing: this.isProcessing - }; - } - - async processQueueNow(): Promise { - if (this.isProcessing) { - this.logger.info('Queue processing already in progress'); - return; - } - - this.isProcessing = true; - await this.processQueue(); - this.isProcessing = false; - } -} \ No newline at end of file diff --git a/src/services/scheduler.ts b/src/services/scheduler.ts deleted file mode 100644 index 995ca19..0000000 --- a/src/services/scheduler.ts +++ /dev/null @@ -1,84 +0,0 @@ -import * as cron from 'node-cron'; - -export class Scheduler { - private tasks: Map = new Map(); - - scheduleDataCollection(minutes: number[], callback: () => Promise): void { - this.stopDataCollectionTasks(); - - minutes.forEach((minute) => { - const cronExpression = `${minute} * * * *`; - - if (!cron.validate(cronExpression)) { - throw new Error(`Invalid cron expression for minute ${minute}`); - } - - const task = cron.schedule(cronExpression, async () => { - try { - await callback(); - } catch (error) { - console.error(`Scheduled task error:`, error); - } - }, { - scheduled: false, - }); - - this.tasks.set(`minute-${minute}`, task); - }); - - this.startAll(); - } - - startAll(): void { - this.tasks.forEach((task) => { - task.start(); - }); - } - - stopDataCollectionTasks(): void { - this.tasks.forEach((task, key) => { - if (key.startsWith('minute-')) { - task.stop(); - this.tasks.delete(key); - } - }); - } - - stopAll(): void { - this.tasks.forEach((task) => { - task.stop(); - }); - this.tasks.clear(); - } - - isRunning(): boolean { - return this.tasks.size > 0; - } - - getScheduledMinutes(): number[] { - return Array.from(this.tasks.keys()) - .map(key => parseInt(key.replace('minute-', ''))) - .sort((a, b) => a - b); - } - - scheduleDailyTask(hour: number, callback: () => Promise): void { - const cronExpression = `0 ${hour} * * *`; - - if (!cron.validate(cronExpression)) { - throw new Error(`Invalid cron expression for hour ${hour}`); - } - - const task = cron.schedule(cronExpression, async () => { - try { - await callback(); - } catch (error) { - console.error(`Daily task error:`, error); - } - }, { - scheduled: false, - }); - - this.tasks.set(`daily-${hour}`, task); - task.start(); - } -} \ No newline at end of file diff --git a/src/services/steamApi.ts b/src/services/steamApi.ts deleted file mode 100644 index 67246b4..0000000 --- a/src/services/steamApi.ts +++ /dev/null @@ -1,68 +0,0 @@ -import axios from 'axios'; -import { SteamApiResponse } from '../types/config'; - -export class SteamApiService { - private baseUrl = 'https://api.steampowered.com'; - private storeBaseUrl = 'https://store.steampowered.com'; - - async getCurrentPlayerCount(appId: number): Promise { - const url = `${this.baseUrl}/ISteamUserStats/GetNumberOfCurrentPlayers/v1/`; - - try { - const response = await axios.get(url, { - params: { - appid: appId, - }, - timeout: 10000, - }); - - if (response.data?.response?.player_count !== undefined) { - const playerCount = response.data.response.player_count; - - // Treat 0 players as a failed request - if (playerCount === 0) { - throw new Error('Steam API returned 0 players - treating as failed request'); - } - - return playerCount; - } else { - throw new Error('Invalid response format from Steam API'); - } - } catch (error) { - if (axios.isAxiosError(error)) { - if (error.response) { - throw new Error(`Steam API error: ${error.response.status} - ${error.response.statusText}`); - } else if (error.code === 'ECONNABORTED') { - throw new Error('Steam API request timeout'); - } else { - throw new Error(`Network error: ${error.message}`); - } - } - throw error; - } - } - - async getGameName(appId: number): Promise { - const url = `${this.storeBaseUrl}/api/appdetails`; - - try { - const response = await axios.get(url, { - params: { - appids: appId, - filters: 'basic', - }, - timeout: 10000, - }); - - const gameData = response.data?.[appId]; - if (gameData?.success && gameData?.data?.name) { - return gameData.data.name; - } - - return null; - } catch (error) { - // ゲーム名取得失敗は致命的ではないため、nullを返す - return null; - } - } -} \ No newline at end of file diff --git a/src/steamPlayerTracker.ts b/src/steamPlayerTracker.ts deleted file mode 100644 index dd0be36..0000000 --- a/src/steamPlayerTracker.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { config } from './config/config'; -import { SteamApiService } from './services/steamApi'; -import { CsvWriter } from './services/csvWriter'; -import { GoogleSheetsService } from './services/googleSheets'; -import { QueuedGoogleSheetsService } from './services/queuedGoogleSheets'; -import { DailyAverageService } from './services/dailyAverageService'; -import { Scheduler } from './services/scheduler'; -import { RetryHandler } from './utils/retry'; -import { createLogger } from './utils/logger'; -import { PlayerDataRecord } from './types/config'; - -export class SteamPlayerTracker { - private steamApi: SteamApiService; - private csvWriter: CsvWriter; - private googleSheets?: GoogleSheetsService; - private dailyAverageGoogleSheets?: GoogleSheetsService; - private queuedGoogleSheets?: QueuedGoogleSheetsService; - private dailyAverageService?: DailyAverageService; - private scheduler: Scheduler; - private retryHandler: RetryHandler; - private logger: ReturnType; - private gameName?: string; - - constructor() { - this.logger = createLogger('SteamPlayerTracker'); - this.steamApi = new SteamApiService(); - this.csvWriter = new CsvWriter(config.output.csvFilePath); - this.scheduler = new Scheduler(); - this.retryHandler = new RetryHandler(config.retry.maxRetries, config.retry.baseDelay); - - if (config.googleSheets?.enabled) { - this.googleSheets = new GoogleSheetsService( - config.googleSheets.spreadsheetId!, - config.googleSheets.sheetName!, - config.googleSheets.serviceAccountKeyPath! - ); - - // Create separate Google Sheets service for daily averages - if (config.output.dailyAverageCsvEnabled) { - this.dailyAverageGoogleSheets = new GoogleSheetsService( - config.googleSheets.spreadsheetId!, - config.googleSheets.dailyAverageSheetName!, - config.googleSheets.serviceAccountKeyPath! - ); - } - - // Create queued Google Sheets service for rate limit handling - this.queuedGoogleSheets = new QueuedGoogleSheetsService( - this.googleSheets, - this.dailyAverageGoogleSheets, - this.logger - ); - } - - // Initialize daily average service if enabled - if (config.output.dailyAverageCsvEnabled) { - this.dailyAverageService = new DailyAverageService( - config.output.csvFilePath, - config.output.dailyAverageCsvFilePath, - this.logger, - this.queuedGoogleSheets - ); - } - } - - private updateWindowTitle(playerCount?: number): void { - if (process.platform !== 'win32') return; - - let title = 'Steam Player Tracker'; - - if (this.gameName && playerCount !== undefined) { - title += ` - ${this.gameName}: ${playerCount.toLocaleString()} players`; - } else if (this.gameName) { - title += ` - ${this.gameName}`; - } else { - title += ' - Running'; - } - - process.title = title; - } - - async start(): Promise { - try { - this.logger.info('Steam Player Tracker starting...', { - appId: config.steam.appId, - scheduledMinutes: config.scheduling.collectionMinutes, - csvEnabled: config.output.csvEnabled, - googleSheetsEnabled: config.googleSheets?.enabled || false, - }); - - await this.validateConfiguration(); - - // Try to get game name for better window title - try { - const gameName = await this.steamApi.getGameName(config.steam.appId); - if (gameName) { - this.gameName = gameName; - this.logger.info(`Detected game: ${this.gameName}`); - this.updateWindowTitle(); - } - } catch (error) { - this.logger.warn('Failed to get game name', { error: error instanceof Error ? error.message : String(error) }); - } - - // Note: Google Sheets sync is now manual only via 'npm run sync-google-sheets' - // No automatic sync on startup to avoid unnecessary API calls - - // Collect data immediately on startup - this.logger.info('Collecting initial data on startup...'); - await this.collectAndSaveData(); - - // Check and calculate missing daily averages if enabled - if (config.output.dailyAverageCsvEnabled && this.dailyAverageService) { - this.logger.info('Checking for missing daily averages...'); - await this.dailyAverageService.checkAndCalculateMissingAverages(); - } - - this.scheduler.scheduleDataCollection( - config.scheduling.collectionMinutes, - () => this.collectAndSaveData() - ); - - // Schedule daily average calculation if enabled - if (config.output.dailyAverageCsvEnabled && this.dailyAverageService) { - this.scheduler.scheduleDailyTask( - config.scheduling.dailyAverageHour, - () => this.calculateDailyAverage() - ); - } - - this.setupGracefulShutdown(); - - this.logger.info('Steam Player Tracker started successfully'); - console.log(`🚀 Steam Player Tracker is running!`); - console.log(`📊 Tracking App ID: ${config.steam.appId}`); - console.log(`⏰ Collection schedule: every hour at minutes ${config.scheduling.collectionMinutes.join(', ')}`); - console.log(`📁 CSV output: ${config.output.csvEnabled ? config.output.csvFilePath : 'disabled'}`); - console.log(`📋 Google Sheets: ${config.googleSheets?.enabled ? 'enabled' : 'disabled'}`); - console.log(`📈 Daily averages: ${config.output.dailyAverageCsvEnabled ? `enabled (calculated at ${config.scheduling.dailyAverageHour}:00)` : 'disabled'}`); - if (config.googleSheets?.enabled) { - console.log(`🔄 Manual sync: run 'npm run sync-google-sheets' to sync CSV data`); - } - console.log(`🔄 Press Ctrl+C to stop`); - - } catch (error) { - this.logger.error('Failed to start Steam Player Tracker', { error: error instanceof Error ? error.message : String(error) }); - throw error; - } - } - - private async validateConfiguration(): Promise { - try { - this.logger.info('Validating configuration...'); - - const playerCount = await this.retryHandler.executeWithRetry( - () => this.steamApi.getCurrentPlayerCount(config.steam.appId), - 'Steam API test' - ); - - this.logger.info('Configuration validated successfully', { - testPlayerCount: playerCount - }); - } catch (error) { - throw new Error(`Configuration validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } - - private async collectAndSaveData(): Promise { - try { - this.logger.info('Starting data collection...'); - - const playerCount = await this.retryHandler.executeWithRetry( - () => this.steamApi.getCurrentPlayerCount(config.steam.appId), - 'Steam API data collection' - ); - - // Update window title with current player count - this.updateWindowTitle(playerCount); - - const record: PlayerDataRecord = { - timestamp: new Date().toISOString().replace('T', ' ').slice(0, 19), - playerCount, - }; - - const savePromises: Promise[] = []; - - if (config.output.csvEnabled) { - savePromises.push( - this.retryHandler.executeWithRetry( - () => this.csvWriter.writeRecord(record), - 'CSV write' - ) - ); - } - - if (config.googleSheets?.enabled && this.queuedGoogleSheets) { - savePromises.push( - this.queuedGoogleSheets.addPlayerRecord(record) - ); - } - - await Promise.all(savePromises); - - this.logger.info('Data collection completed successfully', { - timestamp: record.timestamp, - playerCount: record.playerCount, - csvSaved: config.output.csvEnabled, - sheetsSaved: config.googleSheets?.enabled || false, - }); - - } catch (error) { - this.logger.error('Data collection failed', { - error: error instanceof Error ? error.message : String(error), - }); - } - } - - private async calculateDailyAverage(): Promise { - if (!this.dailyAverageService) { - return; - } - - try { - this.logger.info('Starting daily average calculation...'); - - // Calculate average for yesterday - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - - await this.retryHandler.executeWithRetry( - () => this.dailyAverageService!.calculateAndSaveDailyAverage(yesterday), - 'Daily average calculation' - ); - - this.logger.info('Daily average calculation completed successfully'); - } catch (error) { - this.logger.error('Daily average calculation failed', { - error: error instanceof Error ? error.message : String(error), - }); - } - } - - // Background sync method removed - sync is now manual only via CLI command - - private setupGracefulShutdown(): void { - const shutdown = (signal: string) => { - this.logger.info(`Received ${signal}. Shutting down gracefully...`); - - this.scheduler.stopAll(); - - this.logger.info('Steam Player Tracker stopped'); - process.exit(0); - }; - - process.on('SIGINT', () => shutdown('SIGINT')); - process.on('SIGTERM', () => shutdown('SIGTERM')); - } -} \ No newline at end of file diff --git a/src/tools/calculateAllDailyAverages.ts b/src/tools/calculateAllDailyAverages.ts deleted file mode 100644 index e09c9e8..0000000 --- a/src/tools/calculateAllDailyAverages.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { promises as fs } from 'fs'; -import { config } from '../config/config'; -import { DailyAverageService } from '../services/dailyAverageService'; -import { GoogleSheetsService } from '../services/googleSheets'; -import { createLogger } from '../utils/logger'; - -async function calculateAllDailyAverages() { - const logger = createLogger('calculate-daily-averages'); - - try { - logger.info('Starting calculation of all daily averages...'); - - // Initialize the daily average service without Google Sheets integration - // We'll handle Google Sheets upload separately as a bulk operation - const dailyAverageService = new DailyAverageService( - config.output.csvFilePath, - config.output.dailyAverageCsvFilePath, - logger - // Note: Not passing queuedGoogleSheets to avoid individual writes - ); - - // Calculate all daily averages and save to CSV only - await dailyAverageService.updateAllDailyAverages(); - - // If Google Sheets is enabled, do a bulk upload of all data - if (config.googleSheets?.enabled && config.output.dailyAverageCsvEnabled) { - logger.info('Starting bulk upload to Google Sheets...'); - - const dailyAverageGoogleSheets = new GoogleSheetsService( - config.googleSheets.spreadsheetId!, - config.googleSheets.dailyAverageSheetName!, - config.googleSheets.serviceAccountKeyPath! - ); - - // Read all calculated daily averages from CSV - const csvContent = await fs.readFile(config.output.dailyAverageCsvFilePath, 'utf8'); - const lines = csvContent.trim().split('\n'); - - if (lines.length <= 1) { - logger.warn('No daily average data to upload to Google Sheets'); - } else { - // Parse CSV data - const header = lines[0].split(','); - const hasExtendedData = header.length > 3; - - const records = []; - for (let i = 1; i < lines.length; i++) { - const row = lines[i].split(','); - - if (row.length >= 3) { - const record = { - timestamp: row[0].trim(), - playerCount: parseInt(row[1].trim(), 10), - sampleCount: parseInt(row[2].trim(), 10), - maxPlayerCount: hasExtendedData && row[3] ? parseInt(row[3].trim(), 10) : undefined, - maxPlayerTimestamp: hasExtendedData && row[4] ? row[4].trim() : undefined, - minPlayerCount: hasExtendedData && row[5] ? parseInt(row[5].trim(), 10) : undefined, - minPlayerTimestamp: hasExtendedData && row[6] ? row[6].trim() : undefined - }; - - if (!isNaN(record.playerCount) && !isNaN(record.sampleCount)) { - records.push(record); - } - } - } - - logger.info(`Uploading ${records.length} daily average records to Google Sheets...`); - - // Replace all data in Google Sheets with the calculated data - await dailyAverageGoogleSheets.replaceAllDailyAverageRecords(records); - - logger.info('Bulk upload to Google Sheets completed successfully'); - } - } - - logger.info('All daily averages calculated successfully'); - console.log('✅ All daily averages have been calculated successfully!'); - - // Explicitly exit to ensure all connections are closed - process.exit(0); - - } catch (error) { - logger.error(`Failed to calculate daily averages: ${error instanceof Error ? error.message : 'Unknown error'}`); - console.error('❌ Failed to calculate daily averages:', error); - process.exit(1); - } -} - -// Run if called directly -if (require.main === module) { - calculateAllDailyAverages(); -} \ No newline at end of file diff --git a/src/tools/exportCsv.ts b/src/tools/exportCsv.ts new file mode 100644 index 0000000..83b9c02 --- /dev/null +++ b/src/tools/exportCsv.ts @@ -0,0 +1,67 @@ +import { promises as fs } from "node:fs"; +import { dirname } from "node:path"; +import { config } from "../config"; +import { createDatabase } from "../db"; +import { createLogger } from "../logger"; + +const logger = createLogger("export-csv"); + +/** + * @description メイン処理 + */ +async function main() { + const args = process.argv.slice(2); + const playerCsvPath = args[0] ?? "steam_concurrent_players.csv"; + const avgCsvPath = args[1] ?? "steam_daily_averages.csv"; + + try { + using db = createDatabase(config.storage.dbPath); + + const players = db.getAllPlayerData(); + if (players.length > 0) { + const header = "timestamp,player_count\n"; + const rows = players + .map((r) => `${r.timestamp},${r.playerCount}`) + .join("\n"); + const playerDir = dirname(playerCsvPath); + if (playerDir !== ".") await fs.mkdir(playerDir, { recursive: true }); + await fs.writeFile(playerCsvPath, `${header + rows}\n`, "utf8"); + logger.info( + `Exported ${players.length} player records to ${playerCsvPath}`, + ); + } else { + logger.warn("No player data to export"); + } + + const averages = db.getAllDailyAverages(); + if (averages.length > 0) { + const header = + "date,average_player_count,sample_count,max_player_count,max_timestamp,min_player_count,min_timestamp\n"; + const rows = averages + .map( + (r) => + `${r.date},${r.averagePlayerCount},${r.sampleCount},${r.maxPlayerCount},${r.maxTimestamp},${r.minPlayerCount},${r.minTimestamp}`, + ) + .join("\n"); + const avgDir = dirname(avgCsvPath); + if (avgDir !== ".") await fs.mkdir(avgDir, { recursive: true }); + await fs.writeFile(avgCsvPath, `${header + rows}\n`, "utf8"); + logger.info( + `Exported ${averages.length} daily average records to ${avgCsvPath}`, + ); + } else { + logger.warn("No daily average data to export"); + } + + console.log("CSV export completed successfully!"); + process.exit(0); + } catch (error) { + logger.error("Export failed", { + error: error instanceof Error ? error.message : String(error), + }); + console.error("Export failed:", error); + process.exit(1); + } +} + +main(); diff --git a/src/tools/generateCharts.ts b/src/tools/generateCharts.ts index aa1978d..655221a 100644 --- a/src/tools/generateCharts.ts +++ b/src/tools/generateCharts.ts @@ -1,286 +1,249 @@ -import { promises as fs } from 'fs'; -import { resolve } from 'path'; -import { ChartJSNodeCanvas } from 'chartjs-node-canvas'; -import { format, subDays } from 'date-fns'; -import { createLogger } from '../utils/logger'; +import { promises as fs } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { ChartJSNodeCanvas } from "chartjs-node-canvas"; +import dayjs from "dayjs"; +import { config } from "../config"; +import { createDatabase, type Database } from "../db"; +import { createLogger } from "../logger"; -const logger = createLogger('generate-charts'); +const logger = createLogger("generate-charts"); +/** + * @description チャート描画の共通設定 + * @property width - 画像幅 + * @property height - 画像高さ + * @property backgroundColor - 背景色 + */ interface ChartConfig { - width: number; - height: number; - backgroundColor: string; + width: number; + height: number; + backgroundColor: string; } +/** + * @description デフォルトのチャート設定 + */ const defaultConfig: ChartConfig = { - width: 1600, - height: 900, - backgroundColor: 'white' + width: 1600, + height: 900, + backgroundColor: "white", }; -async function readCsvFile(filePath: string): Promise { - try { - const content = await fs.readFile(filePath, 'utf8'); - const lines = content.trim().split('\n'); - return lines.map(line => line.split(',').map(cell => cell.trim())); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return []; - } - throw error; - } +/** + * @description チャートオプションを生成 + * @param title - チャートタイトル + * @param xLabel - X軸ラベル + */ +function buildChartOptions(title: string, xLabel: string) { + return { + responsive: false, + plugins: { + title: { display: true, text: title, font: { size: 20 } }, + legend: { display: true, position: "top" as const }, + }, + scales: { + x: { display: true, title: { display: true, text: xLabel } }, + y: { + display: true, + title: { display: true, text: "Player Count" }, + beginAtZero: false, + }, + }, + }; } -async function generatePlayerCountChart(days: number = 7): Promise { - logger.info(`Generating player count chart for last ${days} days...`); - - const csvPath = resolve(__dirname, '../../steam_concurrent_players.csv'); - const csvData = await readCsvFile(csvPath); - - if (csvData.length <= 1) { - logger.warn('No player data available for chart generation'); - return; - } - - // Filter data for the specified number of days - const cutoffDate = subDays(new Date(), days); - const filteredData = csvData.slice(1).filter(row => { - const timestamp = new Date(row[0]); - return timestamp >= cutoffDate; - }); - - if (filteredData.length === 0) { - logger.warn(`No data available for the last ${days} days`); - return; - } - - // Prepare data for chart - const labels = filteredData.map(row => format(new Date(row[0]), 'MM/dd HH:mm')); - const data = filteredData.map(row => parseInt(row[1], 10)); - - // Create chart - const chartJSNodeCanvas = new ChartJSNodeCanvas({ - width: defaultConfig.width, - height: defaultConfig.height, - backgroundColour: defaultConfig.backgroundColor - }); - - const configuration = { - type: 'line' as const, - data: { - labels, - datasets: [{ - label: 'Concurrent Players', - data, - borderColor: 'rgb(75, 192, 192)', - backgroundColor: 'rgba(75, 192, 192, 0.2)', - tension: 0.1, - fill: true - }] - }, - options: { - responsive: false, - plugins: { - title: { - display: true, - text: `Steam Concurrent Players - Last ${days} Days`, - font: { size: 20 } - }, - legend: { - display: true, - position: 'top' as const - } - }, - scales: { - x: { - display: true, - title: { - display: true, - text: 'Date/Time' - } - }, - y: { - display: true, - title: { - display: true, - text: 'Player Count' - }, - beginAtZero: false - } - } - } - }; - - const imageBuffer = await chartJSNodeCanvas.renderToBuffer(configuration); - const outputPath = resolve(__dirname, `../../charts/player_count_${days}days.png`); - - // Ensure charts directory exists - await fs.mkdir(resolve(__dirname, '../../charts'), { recursive: true }); - await fs.writeFile(outputPath, imageBuffer); - - logger.info(`Player count chart saved to ${outputPath}`); +/** + * @description チャート出力先ディレクトリ + */ +const chartsDir = resolve(dirname(config.storage.dbPath), "charts"); + +/** + * @description プレイヤー数チャートを生成 + * @param days - 表示日数 + * @param db - データベース接続 + * @param canvas - ChartJSNodeCanvasインスタンス + */ +async function generatePlayerCountChart( + days: number, + db: Database, + canvas: ChartJSNodeCanvas, +): Promise { + logger.info(`Generating player count chart for last ${days} days...`); + + const from = dayjs().subtract(days, "day").format("YYYY-MM-DD 00:00:00"); + const to = dayjs().add(1, "day").format("YYYY-MM-DD 00:00:00"); + const data = db.getPlayerDataByDateRange(from, to); + + if (data.length === 0) { + logger.warn(`No data available for the last ${days} days`); + return; + } + + const labels = data.map((r) => dayjs(r.timestamp).format("MM/DD HH:mm")); + const values = data.map((r) => r.playerCount); + + const configuration = { + type: "line" as const, + data: { + labels, + datasets: [ + { + label: "Concurrent Players", + data: values, + borderColor: "rgb(75, 192, 192)", + backgroundColor: "rgba(75, 192, 192, 0.2)", + tension: 0.1, + fill: true, + }, + ], + }, + options: buildChartOptions( + `Steam Concurrent Players - Last ${days} Days`, + "Date/Time", + ), + }; + + const imageBuffer = await canvas.renderToBuffer(configuration); + const outputPath = resolve(chartsDir, `player_count_${days}days.png`); + await fs.writeFile(outputPath, imageBuffer); + logger.info(`Player count chart saved to ${outputPath}`); } -async function generateDailyAverageChart(days: number = 30): Promise { - logger.info(`Generating daily average chart for last ${days} days...`); - - const csvPath = resolve(__dirname, '../../steam_daily_averages.csv'); - const csvData = await readCsvFile(csvPath); - - if (csvData.length <= 1) { - logger.warn('No daily average data available for chart generation'); - return; - } - - // Filter data for the specified number of days - const cutoffDate = subDays(new Date(), days); - const filteredData = csvData.slice(1).filter(row => { - const date = new Date(row[0]); - return date >= cutoffDate; - }); - - if (filteredData.length === 0) { - logger.warn(`No daily average data available for the last ${days} days`); - return; - } - - // Check if extended data is available - const hasExtendedData = csvData[0].length > 3; - - // Prepare data for chart - const labels = filteredData.map(row => format(new Date(row[0]), 'MM/dd')); - const averageData = filteredData.map(row => parseInt(row[1], 10)); - const maxData = hasExtendedData ? filteredData.map(row => row[3] ? parseInt(row[3], 10) : null) : null; - const minData = hasExtendedData ? filteredData.map(row => row[5] ? parseInt(row[5], 10) : null) : null; - - const datasets = [{ - label: 'Average Players', - data: averageData, - borderColor: 'rgb(75, 192, 192)', - backgroundColor: 'rgba(75, 192, 192, 0.2)', - tension: 0.1, - fill: false - }]; - - if (hasExtendedData && maxData && minData) { - datasets.push({ - label: 'Maximum Players', - data: maxData as (number | null)[], // Chart.js accepts null values for missing data points - borderColor: 'rgb(255, 99, 132)', - backgroundColor: 'rgba(255, 99, 132, 0.2)', - tension: 0.1, - fill: false - } as any); // Chart.js dataset type is complex and allows null values - - datasets.push({ - label: 'Minimum Players', - data: minData as (number | null)[], // Chart.js accepts null values for missing data points - borderColor: 'rgb(54, 162, 235)', - backgroundColor: 'rgba(54, 162, 235, 0.2)', - tension: 0.1, - fill: false - } as any); // Chart.js dataset type is complex and allows null values - } - - // Create chart - const chartJSNodeCanvas = new ChartJSNodeCanvas({ - width: defaultConfig.width, - height: defaultConfig.height, - backgroundColour: defaultConfig.backgroundColor - }); - - const configuration = { - type: 'line' as const, - data: { - labels, - datasets - }, - options: { - responsive: false, - plugins: { - title: { - display: true, - text: `Daily Player Statistics - Last ${days} Days`, - font: { size: 20 } - }, - legend: { - display: true, - position: 'top' as const - } - }, - scales: { - x: { - display: true, - title: { - display: true, - text: 'Date' - } - }, - y: { - display: true, - title: { - display: true, - text: 'Player Count' - }, - beginAtZero: false - } - } - } - }; - - const imageBuffer = await chartJSNodeCanvas.renderToBuffer(configuration); - const outputPath = resolve(__dirname, `../../charts/daily_average_${days}days.png`); - - // Ensure charts directory exists - await fs.mkdir(resolve(__dirname, '../../charts'), { recursive: true }); - await fs.writeFile(outputPath, imageBuffer); - - logger.info(`Daily average chart saved to ${outputPath}`); +/** + * @description 日次平均チャートを生成 + * @param days - 表示日数 + * @param db - データベース接続 + * @param canvas - ChartJSNodeCanvasインスタンス + */ +async function generateDailyAverageChart( + days: number, + db: Database, + canvas: ChartJSNodeCanvas, +): Promise { + logger.info(`Generating daily average chart for last ${days} days...`); + + const from = dayjs().subtract(days, "day").format("YYYY-MM-DD"); + const to = dayjs().format("YYYY-MM-DD"); + const data = db.getDailyAverageRange(from, to); + + if (data.length === 0) { + logger.warn(`No daily average data available for the last ${days} days`); + return; + } + + const labels = data.map((r) => dayjs(r.date).format("MM/DD")); + const averageData = data.map((r) => r.averagePlayerCount); + const maxData = data.map((r) => r.maxPlayerCount); + const minData = data.map((r) => r.minPlayerCount); + + const datasets = [ + { + label: "Average Players", + data: averageData, + borderColor: "rgb(75, 192, 192)", + backgroundColor: "rgba(75, 192, 192, 0.2)", + tension: 0.1, + fill: false, + }, + { + label: "Maximum Players", + data: maxData, + borderColor: "rgb(255, 99, 132)", + backgroundColor: "rgba(255, 99, 132, 0.2)", + tension: 0.1, + fill: false, + }, + { + label: "Minimum Players", + data: minData, + borderColor: "rgb(54, 162, 235)", + backgroundColor: "rgba(54, 162, 235, 0.2)", + tension: 0.1, + fill: false, + }, + ]; + + const configuration = { + type: "line" as const, + data: { labels, datasets }, + options: buildChartOptions( + `Daily Player Statistics - Last ${days} Days`, + "Date", + ), + }; + + const imageBuffer = await canvas.renderToBuffer(configuration); + const outputPath = resolve(chartsDir, `daily_average_${days}days.png`); + await fs.writeFile(outputPath, imageBuffer); + logger.info(`Daily average chart saved to ${outputPath}`); } +/** + * @description DB接続とCanvasを共有して全チャートを生成 + */ async function generateAllCharts(): Promise { - try { - logger.info('Starting chart generation...'); - - // Generate player count charts for different time periods - await generatePlayerCountChart(1); // Last 24 hours - await generatePlayerCountChart(7); // Last week - await generatePlayerCountChart(30); // Last month - - // Generate daily average charts - await generateDailyAverageChart(7); // Last week - await generateDailyAverageChart(30); // Last month - await generateDailyAverageChart(60); // Last 2 months - - logger.info('All charts generated successfully'); - console.log('✅ All charts have been generated successfully!'); - console.log('📊 Charts saved in the "charts" directory'); - - // Explicitly exit to ensure all connections are closed - process.exit(0); - - } catch (error) { - logger.error(`Failed to generate charts: ${error instanceof Error ? error.message : 'Unknown error'}`); - console.error('❌ Failed to generate charts:', error); - process.exit(1); - } + using db = createDatabase(config.storage.dbPath); + const canvas = new ChartJSNodeCanvas({ + width: defaultConfig.width, + height: defaultConfig.height, + backgroundColour: defaultConfig.backgroundColor, + }); + + try { + logger.info("Starting chart generation..."); + await fs.mkdir(chartsDir, { recursive: true }); + + await generatePlayerCountChart(1, db, canvas); + await generatePlayerCountChart(7, db, canvas); + await generatePlayerCountChart(30, db, canvas); + + await generateDailyAverageChart(7, db, canvas); + await generateDailyAverageChart(30, db, canvas); + await generateDailyAverageChart(60, db, canvas); + + logger.info("All charts generated successfully"); + process.exit(0); + } catch (error) { + logger.error("Failed to generate charts", { + error: error instanceof Error ? error.message : String(error), + }); + process.exit(1); + } +} + +/** + * @description 単一チャートCLI用のDB接続とCanvas生成 + */ +async function runSingleChart( + fn: (days: number, db: Database, canvas: ChartJSNodeCanvas) => Promise, + days: number, +): Promise { + using db = createDatabase(config.storage.dbPath); + const canvas = new ChartJSNodeCanvas({ + width: defaultConfig.width, + height: defaultConfig.height, + backgroundColour: defaultConfig.backgroundColor, + }); + await fs.mkdir(chartsDir, { recursive: true }); + await fn(days, db, canvas); + process.exit(0); } -// Parse command line arguments const args = process.argv.slice(2); const command = args[0]; -const days = args[1] ? parseInt(args[1], 10) : undefined; +const days = args[1] ? Number.parseInt(args[1], 10) : undefined; +if (days !== undefined && Number.isNaN(days)) { + console.error(`Invalid days argument: "${args[1]}"`); + process.exit(1); +} -// Run if called directly -if (require.main === module) { - switch (command) { - case 'player-count': - generatePlayerCountChart(days || 7).then(() => process.exit(0)); - break; - case 'daily-average': - generateDailyAverageChart(days || 30).then(() => process.exit(0)); - break; - default: - generateAllCharts(); - } -} \ No newline at end of file +switch (command) { + case "player-count": + await runSingleChart(generatePlayerCountChart, days || 7); + break; + case "daily-average": + await runSingleChart(generateDailyAverageChart, days || 30); + break; + default: + await generateAllCharts(); +} diff --git a/src/tools/importCsv.ts b/src/tools/importCsv.ts new file mode 100644 index 0000000..7be1a56 --- /dev/null +++ b/src/tools/importCsv.ts @@ -0,0 +1,164 @@ +import { config } from "../config"; +import { + createDatabase, + type DailyAverageRow, + type PlayerDataRow, +} from "../db"; +import { createLogger } from "../logger"; + +const logger = createLogger("import-csv"); + +/** + * @description CSVコンテンツからプレイヤーデータ行をパース + * @param content - CSV文字列 + */ +export function parsePlayerDataCsv(content: string): PlayerDataRow[] { + const lines = content.trim().split("\n"); + const results: PlayerDataRow[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("timestamp")) continue; + + const parts = trimmed.split(","); + const timestamp = parts[0]?.trim(); + const playerCount = Number.parseInt(parts[1]?.trim() ?? "", 10); + + if (timestamp && !Number.isNaN(playerCount)) { + results.push({ timestamp, playerCount }); + } + } + return results; +} + +/** + * @description CSVコンテンツから日次平均行をパース + * @param content - CSV文字列 + */ +export function parseDailyAverageCsv(content: string): DailyAverageRow[] { + const lines = content.trim().split("\n"); + const results: DailyAverageRow[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("date")) continue; + + const parts = trimmed.split(","); + const date = parts[0]?.trim(); + const avg = Number.parseInt(parts[1]?.trim() ?? "", 10); + const samples = Number.parseInt(parts[2]?.trim() ?? "", 10); + + if (!date || Number.isNaN(avg) || Number.isNaN(samples)) continue; + + // 7列形式 (拡張) + if (parts.length >= 7) { + const maxCount = Number.parseInt(parts[3]?.trim() ?? "", 10); + const maxTs = parts[4]?.trim() ?? ""; + const minCount = Number.parseInt(parts[5]?.trim() ?? "", 10); + const minTs = parts[6]?.trim() ?? ""; + + if ( + !Number.isNaN(maxCount) && + maxTs && + !Number.isNaN(minCount) && + minTs + ) { + results.push({ + date, + averagePlayerCount: avg, + sampleCount: samples, + maxPlayerCount: maxCount, + maxTimestamp: maxTs, + minPlayerCount: minCount, + minTimestamp: minTs, + }); + continue; + } + } + + // 3列形式 (旧フォーマット) - min/maxはavgで埋める + results.push({ + date, + averagePlayerCount: avg, + sampleCount: samples, + maxPlayerCount: avg, + maxTimestamp: `${date} 00:00:00`, + minPlayerCount: avg, + minTimestamp: `${date} 00:00:00`, + }); + } + return results; +} + +/** + * @description CSVファイルを読み込む (ENOENTは警告ログのみで継続) + * @param path - ファイルパス + * @returns ファイル内容 (ファイルが存在しない場合はnull) + */ +async function readCsvFile(path: string): Promise { + try { + return await Bun.file(path).text(); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + logger.warn(`CSV not found: ${path}`); + return null; + } + throw error; + } +} + +/** + * @description メイン処理 + */ +async function main() { + const args = process.argv.slice(2); + const playerCsvPath = args[0] ?? "steam_concurrent_players.csv"; + const avgCsvPath = args[1] ?? "steam_daily_averages.csv"; + + try { + using db = createDatabase(config.storage.dbPath); + + const playerContent = await readCsvFile(playerCsvPath); + if (playerContent) { + const playerRecords = parsePlayerDataCsv(playerContent); + logger.info( + `Parsed ${playerRecords.length} player data records from ${playerCsvPath}`, + ); + if (playerRecords.length > 0) { + db.transaction(() => { + for (const record of playerRecords) { + db.insertPlayerData(record.timestamp, record.playerCount); + } + }); + logger.info(`Imported ${playerRecords.length} player data records`); + } + } + + const avgContent = await readCsvFile(avgCsvPath); + if (avgContent) { + const avgRecords = parseDailyAverageCsv(avgContent); + logger.info( + `Parsed ${avgRecords.length} daily average records from ${avgCsvPath}`, + ); + if (avgRecords.length > 0) { + db.transaction(() => { + for (const record of avgRecords) { + db.upsertDailyAverage(record); + } + }); + logger.info(`Imported ${avgRecords.length} daily average records`); + } + } + + console.log("CSV import completed successfully!"); + process.exit(0); + } catch (error) { + logger.error("Import failed", { + error: error instanceof Error ? error.message : String(error), + }); + console.error("Import failed:", error); + process.exit(1); + } +} + +main(); diff --git a/src/tools/syncGoogleSheets.ts b/src/tools/syncGoogleSheets.ts deleted file mode 100644 index a5ba10d..0000000 --- a/src/tools/syncGoogleSheets.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { promises as fs } from 'fs'; -import { resolve } from 'path'; -import { GoogleSheetsService } from '../services/googleSheets'; -import { createLogger } from '../utils/logger'; -import { config } from '../config/config'; -import { PlayerDataRecord } from '../types/config'; - -const logger = createLogger('sync-google-sheets'); - -async function readCsvFile(filePath: string): Promise { - try { - const content = await fs.readFile(filePath, 'utf8'); - const lines = content.trim().split('\n'); - return lines.map(line => line.split(',').map(cell => cell.trim())); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return []; - } - throw error; - } -} - -async function syncPlayerData(googleSheets: GoogleSheetsService): Promise { - const csvPath = resolve(__dirname, '../../steam_concurrent_players.csv'); - const csvData = await readCsvFile(csvPath); - - if (csvData.length <= 1) { - logger.info('No player data to sync'); - return; - } - - logger.info(`Found ${csvData.length - 1} records in CSV`); - - // Parse records - const records: PlayerDataRecord[] = []; - for (let i = 1; i < csvData.length; i++) { - const [timestamp, playerCount] = csvData[i]; - - if (timestamp && playerCount) { - const record: PlayerDataRecord = { - timestamp: timestamp, - playerCount: parseInt(playerCount, 10) - }; - - if (!isNaN(record.playerCount)) { - records.push(record); - } - } - } - - logger.info(`Parsed ${records.length} valid records`); - - // Replace all data with sorted records (no read requests, just write) - logger.info('Replacing all Google Sheets data with sorted CSV data...'); - await googleSheets.replaceAllRecords(records); - - logger.info('Player data sync completed - all data replaced and sorted'); -} - -async function syncDailyAverages(googleSheets: GoogleSheetsService): Promise { - const csvPath = resolve(__dirname, '../../steam_daily_averages.csv'); - const csvData = await readCsvFile(csvPath); - - if (csvData.length <= 1) { - logger.info('No daily average data to sync'); - return; - } - - logger.info(`Found ${csvData.length - 1} daily average records in CSV`); - - // Determine CSV format by checking header columns - const header = csvData[0]; - const hasExtendedData = header.length > 3; - - // Parse records - const records = []; - for (let i = 1; i < csvData.length; i++) { - const row = csvData[i]; - - if (row.length >= 3) { - const record = { - timestamp: row[0], - playerCount: parseInt(row[1], 10), - sampleCount: parseInt(row[2], 10), - maxPlayerCount: hasExtendedData && row[3] ? parseInt(row[3], 10) : undefined, - maxPlayerTimestamp: hasExtendedData && row[4] ? row[4] : undefined, - minPlayerCount: hasExtendedData && row[5] ? parseInt(row[5], 10) : undefined, - minPlayerTimestamp: hasExtendedData && row[6] ? row[6] : undefined - }; - - if (!isNaN(record.playerCount) && !isNaN(record.sampleCount)) { - records.push(record); - } - } - } - - logger.info(`Parsed ${records.length} valid daily average records`); - - // Replace all data with sorted records (no read requests, just write) - logger.info('Replacing all Google Sheets daily average data with sorted CSV data...'); - await googleSheets.replaceAllDailyAverageRecords(records); - - logger.info('Daily average sync completed - all data replaced and sorted'); -} - -async function main(): Promise { - try { - if (!config.googleSheets?.enabled) { - logger.error('Google Sheets is not enabled in configuration'); - console.error('❌ Google Sheets is not enabled. Set GOOGLE_SHEETS_ENABLED=true in .env'); - process.exit(1); - } - - logger.info('Starting Google Sheets sync...'); - - // Initialize Google Sheets service for player data - const playerDataSheets = new GoogleSheetsService( - config.googleSheets.spreadsheetId!, - config.googleSheets.sheetName!, - config.googleSheets.serviceAccountKeyPath! - ); - - // Initialize Google Sheets service for daily averages - const dailyAverageSheets = new GoogleSheetsService( - config.googleSheets.spreadsheetId!, - config.googleSheets.dailyAverageSheetName!, - config.googleSheets.serviceAccountKeyPath! - ); - - // Sync player data - logger.info('Syncing player data...'); - await syncPlayerData(playerDataSheets); - - // Sync daily averages - logger.info('Syncing daily averages...'); - await syncDailyAverages(dailyAverageSheets); - - logger.info('All data synced successfully'); - console.log('✅ Google Sheets sync completed successfully!'); - - // Explicitly exit to ensure all connections are closed - process.exit(0); - - } catch (error) { - logger.error(`Sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`); - console.error(`❌ Sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`); - process.exit(1); - } -} - -// Run if this file is executed directly -if (require.main === module) { - main(); -} - -export { syncPlayerData, syncDailyAverages }; \ No newline at end of file diff --git a/src/types/config.ts b/src/types/config.ts deleted file mode 100644 index e2fbd01..0000000 --- a/src/types/config.ts +++ /dev/null @@ -1,47 +0,0 @@ -export interface Config { - steam: { - appId: number; - }; - - output: { - csvEnabled: boolean; - csvFilePath: string; - dailyAverageCsvEnabled: boolean; - dailyAverageCsvFilePath: string; - }; - - scheduling: { - collectionMinutes: number[]; - dailyAverageHour: number; - }; - - retry: { - maxRetries: number; - baseDelay: number; - }; - - logging: { - level: string; - filePath: string; - }; - - googleSheets?: { - enabled: boolean; - spreadsheetId?: string; - sheetName?: string; - dailyAverageSheetName?: string; - serviceAccountKeyPath?: string; - syncOnStartup?: boolean; - }; -} - -export interface SteamApiResponse { - response: { - player_count: number; - }; -} - -export interface PlayerDataRecord { - timestamp: string; - playerCount: number; -} \ No newline at end of file diff --git a/src/utils/logger.ts b/src/utils/logger.ts deleted file mode 100644 index dfc0cff..0000000 --- a/src/utils/logger.ts +++ /dev/null @@ -1,123 +0,0 @@ -import winston from 'winston'; -import { - cleanOldLogs, - SessionErrorFileTransport, - SessionFileTransport, -} from './session-file-transport'; - -// ログレベルを動的に取得する関数 -const getLogLevel = (): string => { - // 環境変数が設定されていればそれを使用、なければ'info' - // 開発環境では'debug'をデフォルトにする - const level = process.env.LOG_LEVEL; - if (level) return level; - - // NODE_ENVがdevelopmentまたはDEBUGがtrueの場合はdebugレベル - if (process.env.NODE_ENV === 'development' || process.env.DEBUG === 'true') { - return 'debug'; - } - - return 'info'; -}; - -const logFormat = winston.format.combine( - winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), - winston.format.errors({ stack: true }), - winston.format.splat(), - winston.format.printf((info) => { - const { timestamp, level, message, ...metadata } = info; - - // エラーの場合はメッセージを設定 - if (info.error instanceof Error) { - (metadata as Record).error = info.error.message; - } - - return JSON.stringify({ - timestamp, - level, - message, - ...metadata, - }); - }), -); - -const consoleFormat = winston.format.combine( - winston.format.timestamp({ format: 'HH:mm:ss' }), - winston.format.printf(({ timestamp, level, message, ...metadata }) => { - // ログレベルを3文字の短縮形に変換し色付け - const levelColorMap: Record = { - error: { short: 'ERR', color: '\x1b[31m' }, // 赤 - warn: { short: 'WRN', color: '\x1b[33m' }, // 黄 - info: { short: 'INF', color: '\x1b[36m' }, // シアン - debug: { short: 'DBG', color: '\x1b[35m' }, // マゼンタ - verbose: { short: 'VRB', color: '\x1b[34m' }, // 青 - silly: { short: 'SIL', color: '\x1b[37m' }, // 白 - }; - - const levelInfo = levelColorMap[level] ?? { - short: level.substring(0, 3).toUpperCase(), - color: '\x1b[37m', - }; - const reset = '\x1b[0m'; - - let msg = `[${timestamp} ${levelInfo.color}${levelInfo.short}${reset}] ${message}`; - - if (metadata && typeof metadata === 'object' && Object.keys(metadata).length > 0) { - const metadataColor = '\x1b[90m'; // グレー - msg += ` ${metadataColor}${JSON.stringify(metadata)}${reset}`; - } - return msg; - }), -); - -export const logger = winston.createLogger({ - level: getLogLevel(), // 動的にログレベルを取得 - format: logFormat, - defaultMeta: { service: 'steam-player-tracker' }, - transports: [ - // Console transport - new winston.transports.Console({ - format: consoleFormat, - }), - // セッション固定ディレクトリにエラーログを保存 - new SessionErrorFileTransport({ - maxsize: 10485760, // 10MB - maxFiles: 1, // 同一ディレクトリ内では1ファイルのみ - }), - // セッション固定ディレクトリに全ログを保存 - new SessionFileTransport({ - maxsize: 10485760, // 10MB - maxFiles: 1, // 同一ディレクトリ内では1ファイルのみ - }), - ], -}); - -// アプリケーション起動時に古いログをクリーンアップ -// 7日以上古いログディレクトリを削除 -try { - cleanOldLogs(7); -} catch (error) { - console.error('ログクリーンアップエラー:', error); -} - -// 毎日24時間ごとに古いログをクリーンアップ -setInterval( - () => { - try { - cleanOldLogs(7); - } catch (error) { - console.error('定期ログクリーンアップエラー:', error); - } - }, - 24 * 60 * 60 * 1000, -); // 24時間 - -// Create a child logger for specific modules -export const createLogger = (module: string): winston.Logger => { - // 実行時にログレベルを更新(環境変数が変更された場合に対応) - const currentLevel = getLogLevel(); - if (logger.level !== currentLevel) { - logger.level = currentLevel; - } - return logger.child({ module }); -}; \ No newline at end of file diff --git a/src/utils/retry.ts b/src/utils/retry.ts deleted file mode 100644 index b407c8d..0000000 --- a/src/utils/retry.ts +++ /dev/null @@ -1,47 +0,0 @@ -export class RetryHandler { - private maxRetries: number; - private baseDelay: number; - - constructor(maxRetries: number, baseDelay: number) { - this.maxRetries = maxRetries; - this.baseDelay = baseDelay; - } - - async executeWithRetry( - operation: () => Promise, - operationName: string = 'operation' - ): Promise { - let lastError: Error; - - for (let attempt = 0; attempt <= this.maxRetries; attempt++) { - try { - return await operation(); - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - - if (attempt === this.maxRetries) { - throw new Error( - `${operationName} failed after ${this.maxRetries + 1} attempts. Last error: ${lastError.message}` - ); - } - - const delay = this.calculateDelay(attempt); - console.warn( - `${operationName} attempt ${attempt + 1} failed: ${lastError.message}. Retrying in ${delay}ms...` - ); - - await this.sleep(delay); - } - } - - throw lastError!; - } - - private calculateDelay(attempt: number): number { - return Math.min(this.baseDelay * Math.pow(5, attempt), 30000); - } - - private sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); - } -} \ No newline at end of file diff --git a/src/utils/session-file-transport.ts b/src/utils/session-file-transport.ts deleted file mode 100644 index d5c794e..0000000 --- a/src/utils/session-file-transport.ts +++ /dev/null @@ -1,93 +0,0 @@ -import winston from 'winston'; -import fs from 'fs'; -import path from 'path'; -import dayjs from 'dayjs'; -import utc from 'dayjs/plugin/utc'; - -dayjs.extend(utc); - -/** - * セッション固定のディレクトリにログを保存するカスタムトランスポート - * ./logs/sessions/YYYY-MM-DD-HHmm/app.log 形式で保存 - * 起動時のUTC時刻を基準にし、セッション中は同じディレクトリとファイルを使用 - */ -export class SessionFileTransport extends winston.transports.File { - protected sessionDirName: string; - protected baseLogsDir: string; - - constructor(options: winston.transports.FileTransportOptions & { baseDir?: string }) { - const currentTime = dayjs.utc(); - const dirName = currentTime.format('YYYY-MM-DD-HHmm'); - const baseDir = options.baseDir ?? path.join(process.cwd(), 'logs'); - const logPath = path.join(baseDir, dirName, 'app.log'); - - // ディレクトリを作成 - const dirPath = path.dirname(logPath); - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } - - super({ - ...options, - filename: logPath, - }); - - this.sessionDirName = dirName; - this.baseLogsDir = baseDir; - } -} - -/** - * エラー専用のセッション固定ファイルトランスポート - * ./logs/sessions/YYYY-MM-DD-HHmm/error.log 形式で保存 - */ -export class SessionErrorFileTransport extends SessionFileTransport { - constructor(options?: winston.transports.FileTransportOptions & { baseDir?: string }) { - const currentTime = dayjs.utc(); - const dirName = currentTime.format('YYYY-MM-DD-HHmm'); - const baseDir = options?.baseDir ?? path.join(process.cwd(), 'logs'); - const logPath = path.join(baseDir, dirName, 'error.log'); - - super({ - ...options, - filename: logPath, - level: 'error', - }); - } -} - -/** - * 古いセッションログディレクトリを削除するユーティリティ関数 - * ./logs/内のYYYY-MM-DD-HHmm形式のディレクトリを削除 - * @param daysToKeep 保持する日数(デフォルト: 7日) - */ -export function cleanOldLogs(daysToKeep = 7): void { - const sessionsDir = path.join(process.cwd(), 'logs', ); - - if (!fs.existsSync(sessionsDir)) { - return; - } - - const now = dayjs.utc(); - const cutoffDate = now.subtract(daysToKeep, 'days'); - - try { - const directories = fs.readdirSync(sessionsDir); - - for (const dir of directories) { - // YYYY-MM-DD-HHmm形式のディレクトリ名をパース - const match = dir.match(/^(\d{4}-\d{2}-\d{2})-\d{4}$/); - if (match) { - const dirDate = dayjs.utc(match[1], 'YYYY-MM-DD'); - - if (dirDate.isBefore(cutoffDate)) { - const dirPath = path.join(sessionsDir, dir); - fs.rmSync(dirPath, { recursive: true, force: true }); - // 削除完了(コンソール出力はロガーを使用すべきだが、ここではロガーがまだ読み込まれていない可能性がある) - } - } - } - } catch (error) { - console.error('ログクリーンアップ中にエラーが発生しました:', error); - } -} \ No newline at end of file diff --git a/start.bat b/start.bat deleted file mode 100644 index 497697e..0000000 --- a/start.bat +++ /dev/null @@ -1,27 +0,0 @@ -@echo off -REM Steam Player Tracker Start Script for Windows - -title Steam Player Tracker - Starting... - -REM Check if pwsh is installed -where pwsh >nul 2>&1 -if %ERRORLEVEL% neq 0 ( - echo "Error: PowerShell Core (pwsh) is not installed" - echo "Please install PowerShell from https://aka.ms/PSWindows" - echo. - echo "Press any key to exit..." - pause > nul - exit /b 1 -) - -REM Run PowerShell script with execution policy bypass -pwsh -ExecutionPolicy Bypass -File start.ps1 - -REM Keep window open -echo. -if %ERRORLEVEL% neq 0 ( - echo "Application stopped with error! Press any key to exit..." -) else ( - echo "Application stopped! Press any key to exit..." -) -pause > nul \ No newline at end of file diff --git a/start.ps1 b/start.ps1 deleted file mode 100644 index c794ddb..0000000 --- a/start.ps1 +++ /dev/null @@ -1,32 +0,0 @@ -# Steam Player Tracker Start Script for PowerShell Core (pwsh) - -$Host.UI.RawUI.WindowTitle = "Steam Player Tracker - Starting..." - -Write-Host "Steam Player Tracker - Start Script" -ForegroundColor Cyan -Write-Host "===================================" -ForegroundColor Cyan - -# Check if .env file exists -if (!(Test-Path ".env")) { - Write-Host "Error: .env file not found" -ForegroundColor Red - Write-Host "Please create a .env file based on .env.example" -ForegroundColor Yellow - exit 1 -} - -# Check if dist folder exists -if (!(Test-Path "dist")) { - Write-Host "Build not found. Running build script..." -ForegroundColor Yellow - & .\build.ps1 - if ($LASTEXITCODE -ne 0) { - exit 1 - } -} - -# Start the application -Write-Host "`nStarting Steam Player Tracker..." -ForegroundColor Green -npm start - -# Keep window open if there was an error -if ($LASTEXITCODE -ne 0) { - Write-Host "`nPress any key to exit..." -ForegroundColor Red - $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") -} \ No newline at end of file diff --git a/start.sh b/start.sh deleted file mode 100644 index 3f28a0d..0000000 --- a/start.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash - -# Steam Player Tracker Start Script for Linux/macOS - -echo -e "\033[36mSteam Player Tracker - Start Script\033[0m" -echo -e "\033[36m===================================\033[0m" - -# Check if .env file exists -if [ ! -f ".env" ]; then - echo -e "\033[31mError: .env file not found\033[0m" - echo -e "\033[33mPlease create a .env file based on .env.example\033[0m" - exit 1 -fi - -# Check if dist folder exists -if [ ! -d "dist" ]; then - echo -e "\033[33mBuild not found. Running build script...\033[0m" - ./build.sh - if [ $? -ne 0 ]; then - exit 1 - fi -fi - -# Start the application -echo -e "\n\033[32mStarting Steam Player Tracker...\033[0m" -npm start \ No newline at end of file diff --git a/sync-google-sheets.bat b/sync-google-sheets.bat deleted file mode 100644 index 6ea11ad..0000000 --- a/sync-google-sheets.bat +++ /dev/null @@ -1,48 +0,0 @@ -@echo off -REM Batch script to run sync-google-sheets.ps1 -REM Syncs local CSV files with Google Sheets (sorted by timestamp) - -title "Steam Player Tracker - Syncing Google Sheets..." - -echo "Steam Player Tracker - Google Sheets Sync" -echo "=========================================" -echo. - -REM Check if PowerShell is available -where powershell >nul 2>nul -if %errorlevel% neq 0 ( - echo "Error: PowerShell is not installed or not in PATH" - echo "Please install PowerShell or use Windows PowerShell" - echo. - pause - exit /b 1 -) - -REM Check if sync-google-sheets.ps1 exists -if not exist "sync-google-sheets.ps1" ( - echo "Error: sync-google-sheets.ps1 not found" - echo "Please run this script from the project root directory" - echo. - pause - exit /b 1 -) - -echo "Running PowerShell script..." -echo. - -REM Execute the PowerShell script with execution policy bypass -powershell.exe -ExecutionPolicy Bypass -File "sync-google-sheets.ps1" - -REM Capture exit code -set exit_code=%errorlevel% - -echo. -if %exit_code% equ 0 ( - echo "Batch script completed successfully!" -) else ( - echo "Batch script failed with exit code: %exit_code%" -) - -title "Steam Player Tracker - Sync Complete" -pause -exit /b %exit_code% \ No newline at end of file diff --git a/sync-google-sheets.ps1 b/sync-google-sheets.ps1 deleted file mode 100644 index fe1b91a..0000000 --- a/sync-google-sheets.ps1 +++ /dev/null @@ -1,75 +0,0 @@ -# PowerShell script to sync CSV data to Google Sheets -# Syncs local CSV files with Google Sheets (sorted by timestamp) - -$Host.UI.RawUI.WindowTitle = "Steam Player Tracker - Syncing Google Sheets..." - -Write-Host "Steam Player Tracker - Google Sheets Sync" -ForegroundColor Green -Write-Host "=========================================" -ForegroundColor Green -Write-Host "" - -# Display PowerShell version -Write-Host "PowerShell Version: $($PSVersionTable.PSVersion)" -ForegroundColor Cyan -Write-Host "" - -# Check if Node.js is installed -if (-not (Get-Command node -ErrorAction SilentlyContinue)) { - Write-Host "Error: Node.js is not installed or not in PATH" -ForegroundColor Red - Write-Host "Please install Node.js from https://nodejs.org/" -ForegroundColor Red - Write-Host "" - Write-Host "Press any key to exit..." - $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") - exit 1 -} - -# Check if npm is available -if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { - Write-Host "Error: npm is not available" -ForegroundColor Red - Write-Host "Please make sure Node.js is properly installed" -ForegroundColor Red - Write-Host "" - Write-Host "Press any key to exit..." - $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") - exit 1 -} - -# Check if package.json exists -if (-not (Test-Path "package.json")) { - Write-Host "Error: package.json not found" -ForegroundColor Red - Write-Host "Please run this script from the project root directory" -ForegroundColor Red - Write-Host "" - Write-Host "Press any key to exit..." - $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") - exit 1 -} - -# Check if .env file exists -if (-not (Test-Path ".env")) { - Write-Host "Warning: .env file not found" -ForegroundColor Yellow - Write-Host "Please copy .env.example to .env and configure it" -ForegroundColor Yellow - Write-Host "" -} - -Write-Host "Starting Google Sheets sync..." -ForegroundColor Cyan -Write-Host "" - -# Run the sync command -try { - npm run sync-google-sheets - $exitCode = $LASTEXITCODE - - Write-Host "" - if ($exitCode -eq 0) { - Write-Host "Google Sheets sync completed successfully!" -ForegroundColor Green - } else { - Write-Host "Google Sheets sync failed with exit code: $exitCode" -ForegroundColor Red - } -} catch { - Write-Host "Error running sync command: $($_.Exception.Message)" -ForegroundColor Red - $exitCode = 1 -} - -Write-Host "" -Write-Host "Press any key to exit..." -$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") - -$Host.UI.RawUI.WindowTitle = "Steam Player Tracker - Sync Complete" -exit $exitCode \ No newline at end of file diff --git a/sync-google-sheets.sh b/sync-google-sheets.sh deleted file mode 100644 index ea170c1..0000000 --- a/sync-google-sheets.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/bin/bash -# Shell script to sync CSV data to Google Sheets -# Syncs local CSV files with Google Sheets (sorted by timestamp) - -echo "Steam Player Tracker - Google Sheets Sync" -echo "=========================================" -echo "" - -# Display bash version -echo "Bash Version: $BASH_VERSION" -echo "" - -# Check if Node.js is installed -if ! command -v node &> /dev/null; then - echo "Error: Node.js is not installed or not in PATH" - echo "Please install Node.js from https://nodejs.org/" - echo "" - echo "Press any key to exit..." - read -n 1 -s - exit 1 -fi - -# Check if npm is available -if ! command -v npm &> /dev/null; then - echo "Error: npm is not available" - echo "Please make sure Node.js is properly installed" - echo "" - echo "Press any key to exit..." - read -n 1 -s - exit 1 -fi - -# Check if package.json exists -if [ ! -f "package.json" ]; then - echo "Error: package.json not found" - echo "Please run this script from the project root directory" - echo "" - echo "Press any key to exit..." - read -n 1 -s - exit 1 -fi - -# Check if .env file exists -if [ ! -f ".env" ]; then - echo "Warning: .env file not found" - echo "Please copy .env.example to .env and configure it" - echo "" -fi - -echo "Starting Google Sheets sync..." -echo "" - -# Run the sync command -if npm run sync-google-sheets; then - echo "" - echo "Google Sheets sync completed successfully!" - exit_code=0 -else - echo "" - echo "Google Sheets sync failed with exit code: $?" - exit_code=1 -fi - -echo "" -echo "Press any key to exit..." -read -n 1 -s - -exit $exit_code \ No newline at end of file diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..b78c3f6 --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "bun:test"; +import { parseConfig } from "../src/config"; + +/** + * @description 最小構成の環境変数 + */ +function minimalEnv(overrides: Record = {}): NodeJS.ProcessEnv { + return { STEAM_APP_ID: "730", ...overrides }; +} + +describe("parseConfig", () => { + it("最小構成でデフォルト値が適用される", () => { + const config = parseConfig(minimalEnv()); + expect(config.steam.appId).toBe(730); + expect(config.storage.dbPath).toBe("data/steam-tracker.db"); + expect(config.scheduling.collectionMinutes).toEqual([0, 30]); + expect(config.scheduling.dailyAverageHour).toBe(0); + expect(config.logging.level).toBe("info"); + expect(config.googleSheets.enabled).toBe(false); + }); + + it("STEAM_APP_IDを数値に変換する", () => { + const config = parseConfig(minimalEnv()); + expect(typeof config.steam.appId).toBe("number"); + }); + + it("STEAM_APP_ID欠落でエラー", () => { + expect(() => parseConfig({})).toThrow(); + }); + + it("STEAM_APP_IDが負値でエラー", () => { + expect(() => parseConfig({ STEAM_APP_ID: "-1" })).toThrow(); + }); + + it("STEAM_APP_IDが非数値でエラー", () => { + expect(() => parseConfig({ STEAM_APP_ID: "abc" })).toThrow(); + }); + + it("カスタムDB_PATHを適用する", () => { + const config = parseConfig(minimalEnv({ DB_PATH: "/custom/path.db" })); + expect(config.storage.dbPath).toBe("/custom/path.db"); + }); + + it("カスタムCOLLECTION_MINUTESをパースする", () => { + const config = parseConfig( + minimalEnv({ COLLECTION_MINUTES: "0,15,30,45" }), + ); + expect(config.scheduling.collectionMinutes).toEqual([0, 15, 30, 45]); + }); + + it("COLLECTION_MINUTESの無効値をフィルタする", () => { + const config = parseConfig( + minimalEnv({ COLLECTION_MINUTES: "0,abc,60,-1,30" }), + ); + expect(config.scheduling.collectionMinutes).toEqual([0, 30]); + }); + + it("COLLECTION_MINUTESが全て無効でエラー", () => { + expect(() => + parseConfig(minimalEnv({ COLLECTION_MINUTES: "abc,def" })), + ).toThrow(); + }); + + it("LOG_LEVELをパースする", () => { + const config = parseConfig(minimalEnv({ LOG_LEVEL: "debug" })); + expect(config.logging.level).toBe("debug"); + }); + + it("Google Sheets有効時の設定をパースする", () => { + const config = parseConfig( + minimalEnv({ + GOOGLE_SHEETS_ENABLED: "true", + GOOGLE_SHEETS_SPREADSHEET_ID: "abc123", + GOOGLE_SERVICE_ACCOUNT_KEY_PATH: "/path/key.json", + }), + ); + expect(config.googleSheets.enabled).toBe(true); + if (config.googleSheets.enabled) { + expect(config.googleSheets.spreadsheetId).toBe("abc123"); + expect(config.googleSheets.sheetName).toBe("PlayerData"); + expect(config.googleSheets.dailyAverageSheetName).toBe("DailyAverages"); + } + }); + + it("Google Sheets有効でspreadsheetId欠落でエラー", () => { + expect(() => + parseConfig( + minimalEnv({ + GOOGLE_SHEETS_ENABLED: "true", + GOOGLE_SERVICE_ACCOUNT_KEY_PATH: "/path", + }), + ), + ).toThrow(); + }); + + it("Google Sheets有効でserviceAccountKeyPath欠落でエラー", () => { + expect(() => + parseConfig( + minimalEnv({ + GOOGLE_SHEETS_ENABLED: "true", + GOOGLE_SHEETS_SPREADSHEET_ID: "abc", + }), + ), + ).toThrow(); + }); + + it("DAILY_AVERAGE_HOURをパースする", () => { + const config = parseConfig(minimalEnv({ DAILY_AVERAGE_HOUR: "12" })); + expect(config.scheduling.dailyAverageHour).toBe(12); + }); + + it("DAILY_AVERAGE_HOURが範囲外でエラー", () => { + expect(() => + parseConfig(minimalEnv({ DAILY_AVERAGE_HOUR: "24" })), + ).toThrow(); + }); +}); diff --git a/tests/db.test.ts b/tests/db.test.ts new file mode 100644 index 0000000..1259b27 --- /dev/null +++ b/tests/db.test.ts @@ -0,0 +1,231 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type { Database } from "../src/db"; +import { createTestDatabase, sampleDailyAverageRow } from "./test-helpers"; + +describe("createDatabase", () => { + let db: Database; + let cleanup: () => void; + + beforeEach(() => { + ({ db, cleanup } = createTestDatabase()); + }); + + afterEach(() => { + cleanup(); + }); + + describe("insertPlayerData / getAllPlayerData", () => { + it("データを挿入して取得できる", () => { + db.insertPlayerData("2024-01-01 12:00:00", 1000); + const all = db.getAllPlayerData(); + expect(all).toHaveLength(1); + expect(all[0]?.timestamp).toBe("2024-01-01 12:00:00"); + expect(all[0]?.playerCount).toBe(1000); + }); + + it("データがない場合は空配列を返す", () => { + expect(db.getAllPlayerData()).toEqual([]); + }); + + it("timestamp昇順で返す", () => { + db.insertPlayerData("2024-01-01 03:00:00", 300); + db.insertPlayerData("2024-01-01 01:00:00", 100); + db.insertPlayerData("2024-01-01 02:00:00", 200); + + const all = db.getAllPlayerData(); + expect(all.map((r) => r.playerCount)).toEqual([100, 200, 300]); + }); + }); + + describe("getPlayerDataByDateRange", () => { + it("範囲内のデータのみ返す (from inclusive, to exclusive)", () => { + db.insertPlayerData("2024-01-01 01:00:00", 100); + db.insertPlayerData("2024-01-01 02:00:00", 200); + db.insertPlayerData("2024-01-01 03:00:00", 300); + db.insertPlayerData("2024-01-01 04:00:00", 400); + + const result = db.getPlayerDataByDateRange( + "2024-01-01 02:00:00", + "2024-01-01 04:00:00", + ); + expect(result).toHaveLength(2); + expect(result[0]?.playerCount).toBe(200); + expect(result[1]?.playerCount).toBe(300); + }); + + it("データがない範囲で空配列を返す", () => { + expect( + db.getPlayerDataByDateRange( + "2099-01-01 00:00:00", + "2099-01-02 00:00:00", + ), + ).toEqual([]); + }); + }); + + describe("getUnsyncedPlayerData / markPlayerDataSynced", () => { + it("新規挿入データは未同期", () => { + db.insertPlayerData("2024-01-01 00:00:00", 100); + db.insertPlayerData("2024-01-01 01:00:00", 200); + db.insertPlayerData("2024-01-01 02:00:00", 300); + + const unsynced = db.getUnsyncedPlayerData(); + expect(unsynced).toHaveLength(3); + expect(unsynced[0]).toHaveProperty("id"); + expect(unsynced[0]).toHaveProperty("timestamp"); + expect(unsynced[0]).toHaveProperty("playerCount"); + }); + + it("同期済みマーク後は未同期から除外される", () => { + db.insertPlayerData("2024-01-01 00:00:00", 100); + db.insertPlayerData("2024-01-01 01:00:00", 200); + db.insertPlayerData("2024-01-01 02:00:00", 300); + + const unsynced = db.getUnsyncedPlayerData(); + const ids = unsynced.slice(0, 2).map((r) => r.id); + db.markPlayerDataSynced(ids); + + const remaining = db.getUnsyncedPlayerData(); + expect(remaining).toHaveLength(1); + expect(remaining[0]?.playerCount).toBe(300); + }); + + it("空配列でmarkPlayerDataSyncedはno-op", () => { + expect(() => db.markPlayerDataSynced([])).not.toThrow(); + }); + }); + + describe("calculateDailyAverage", () => { + it("平均・最大・最小・サンプル数を正しく計算する", () => { + db.insertPlayerData("2024-01-01 00:00:00", 100); + db.insertPlayerData("2024-01-01 12:00:00", 200); + db.insertPlayerData("2024-01-01 18:00:00", 300); + + const result = db.calculateDailyAverage("2024-01-01"); + expect(result).not.toBeNull(); + expect(result?.averagePlayerCount).toBe(200); + expect(result?.minPlayerCount).toBe(100); + expect(result?.maxPlayerCount).toBe(300); + expect(result?.sampleCount).toBe(3); + expect(result?.minTimestamp).toBe("2024-01-01 00:00:00"); + expect(result?.maxTimestamp).toBe("2024-01-01 18:00:00"); + }); + + it("データがない日付でnullを返す", () => { + expect(db.calculateDailyAverage("2099-01-01")).toBeNull(); + }); + + it("player_count=0を除外して計算する", () => { + db.insertPlayerData("2024-01-01 00:00:00", 0); + db.insertPlayerData("2024-01-01 01:00:00", 100); + db.insertPlayerData("2024-01-01 02:00:00", 200); + + const result = db.calculateDailyAverage("2024-01-01"); + expect(result).not.toBeNull(); + expect(result?.sampleCount).toBe(2); + expect(result?.averagePlayerCount).toBe(150); + }); + + it("全てplayer_count=0の場合nullを返す", () => { + db.insertPlayerData("2024-01-01 00:00:00", 0); + db.insertPlayerData("2024-01-01 01:00:00", 0); + expect(db.calculateDailyAverage("2024-01-01")).toBeNull(); + }); + }); + + describe("upsertDailyAverage / getAllDailyAverages", () => { + it("新規日次平均を挿入する", () => { + const row = sampleDailyAverageRow("2024-01-01"); + db.upsertDailyAverage(row); + + const all = db.getAllDailyAverages(); + expect(all).toHaveLength(1); + expect(all[0]?.date).toBe("2024-01-01"); + expect(all[0]?.averagePlayerCount).toBe(1100); + }); + + it("既存日次平均を更新する", () => { + db.upsertDailyAverage(sampleDailyAverageRow("2024-01-01")); + db.upsertDailyAverage({ + ...sampleDailyAverageRow("2024-01-01"), + averagePlayerCount: 9999, + }); + + const all = db.getAllDailyAverages(); + expect(all).toHaveLength(1); + expect(all[0]?.averagePlayerCount).toBe(9999); + }); + + it("upsertでsynced_atがNULLにリセットされる", () => { + db.upsertDailyAverage(sampleDailyAverageRow("2024-01-01")); + db.markDailyAveragesSynced(["2024-01-01"]); + expect(db.getUnsyncedDailyAverages()).toHaveLength(0); + + db.upsertDailyAverage({ + ...sampleDailyAverageRow("2024-01-01"), + averagePlayerCount: 5555, + }); + expect(db.getUnsyncedDailyAverages()).toHaveLength(1); + }); + }); + + describe("getDailyAverageRange", () => { + it("日付範囲内の日次平均を返す (inclusive)", () => { + db.upsertDailyAverage(sampleDailyAverageRow("2024-01-01")); + db.upsertDailyAverage(sampleDailyAverageRow("2024-01-02")); + db.upsertDailyAverage(sampleDailyAverageRow("2024-01-03")); + + const result = db.getDailyAverageRange("2024-01-01", "2024-01-02"); + expect(result).toHaveLength(2); + }); + }); + + describe("getUnsyncedDailyAverages / markDailyAveragesSynced", () => { + it("新規upsertデータは未同期", () => { + db.upsertDailyAverage(sampleDailyAverageRow("2024-01-01")); + expect(db.getUnsyncedDailyAverages()).toHaveLength(1); + }); + + it("同期済みマーク後は除外される", () => { + db.upsertDailyAverage(sampleDailyAverageRow("2024-01-01")); + db.upsertDailyAverage(sampleDailyAverageRow("2024-01-02")); + db.markDailyAveragesSynced(["2024-01-01"]); + + const unsynced = db.getUnsyncedDailyAverages(); + expect(unsynced).toHaveLength(1); + expect(unsynced[0]?.date).toBe("2024-01-02"); + }); + + it("空配列でmarkDailyAveragesSyncedはno-op", () => { + expect(() => db.markDailyAveragesSynced([])).not.toThrow(); + }); + }); + + describe("getDatesWithDataButNoAverage", () => { + it("平均未計算の過去日付を返す", () => { + db.insertPlayerData("2020-01-01 12:00:00", 100); + db.insertPlayerData("2020-01-02 12:00:00", 200); + db.upsertDailyAverage(sampleDailyAverageRow("2020-01-01")); + + const dates = db.getDatesWithDataButNoAverage(); + expect(dates).toContain("2020-01-02"); + expect(dates).not.toContain("2020-01-01"); + }); + + it("全日付に平均がある場合は空配列", () => { + db.insertPlayerData("2020-01-01 12:00:00", 100); + db.upsertDailyAverage(sampleDailyAverageRow("2020-01-01")); + expect(db.getDatesWithDataButNoAverage()).toEqual([]); + }); + }); + + describe("close", () => { + it("エラーなしでcloseできる", () => { + expect(() => db.close()).not.toThrow(); + // cleanup will try to close again, so create a fresh db + const fresh = createTestDatabase(); + db = fresh.db; + cleanup = fresh.cleanup; + }); + }); +}); diff --git a/tests/googleSheets.test.ts b/tests/googleSheets.test.ts new file mode 100644 index 0000000..1f8b22c --- /dev/null +++ b/tests/googleSheets.test.ts @@ -0,0 +1,361 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import type { SheetColumnDef } from "../src/googleSheets"; +import { setupGoogleSheetsMocks } from "./mocks/googleSheets"; + +const { + mockGet, + mockUpdate, + mockAppend, + mockClear, + mockBatchUpdate, + resetAll, +} = setupGoogleSheetsMocks(); + +/** + * @description テスト用データ型 + */ +interface TestRecord { + id: string; + value: number; +} + +/** + * @description テスト用列定義 + */ +const testColumnDef: SheetColumnDef = { + headers: ["id", "value"], + columnRange: "A:B", + toRow: (r) => [r.id, r.value], + getKey: (r) => r.id, +}; + +const { createSheetAccessor } = await import("../src/googleSheets"); + +describe("createSheetAccessor", () => { + beforeEach(() => { + resetAll(); + }); + + describe("append", () => { + it("ヘッダー確認後に新規行を追加する", async () => { + mockGet + .mockResolvedValueOnce({ data: { values: [["id", "value"]] } }) + .mockResolvedValueOnce({ data: { values: [["id"]] } }); + + const accessor = createSheetAccessor( + "s1", + "Sheet1", + "/fake/key.json", + testColumnDef, + ); + await accessor.append({ id: "row-1", value: 42 }); + + expect(mockAppend).toHaveBeenCalledTimes(1); + }); + + it("既存キーが見つかった場合はupdateする", async () => { + mockGet + .mockResolvedValueOnce({ data: { values: [["id", "value"]] } }) + .mockResolvedValueOnce({ + data: { values: [["id"], ["row-1"]] }, + }); + + const accessor = createSheetAccessor( + "s1", + "Sheet1", + "/fake/key.json", + testColumnDef, + ); + await accessor.append({ id: "row-1", value: 99 }); + + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(mockAppend).not.toHaveBeenCalled(); + }); + + it("エラー時にラップしたErrorをthrowする", async () => { + mockGet.mockRejectedValueOnce(new Error("network error")); + + const accessor = createSheetAccessor( + "s1", + "Sheet1", + "/fake/key.json", + testColumnDef, + ); + await expect(accessor.append({ id: "row-1", value: 1 })).rejects.toThrow( + "Failed to append/update record", + ); + }); + }); + + describe("batchAppend", () => { + it("複数レコードを一括追加する", async () => { + const accessor = createSheetAccessor( + "s1", + "Sheet1", + "/fake/key.json", + testColumnDef, + ); + await accessor.batchAppend([ + { id: "a", value: 1 }, + { id: "b", value: 2 }, + ]); + + expect(mockAppend).toHaveBeenCalledTimes(1); + }); + + it("空配列はno-op", async () => { + const accessor = createSheetAccessor( + "s1", + "Sheet1", + "/fake/key.json", + testColumnDef, + ); + await accessor.batchAppend([]); + + expect(mockAppend).not.toHaveBeenCalled(); + }); + + it("エラー時にラップしたErrorをthrowする", async () => { + mockGet.mockRejectedValueOnce(new Error("fail")); + + const accessor = createSheetAccessor( + "s1", + "Sheet1", + "/fake/key.json", + testColumnDef, + ); + await expect( + accessor.batchAppend([{ id: "a", value: 1 }]), + ).rejects.toThrow("Failed to batch append"); + }); + }); + + describe("replaceAll", () => { + it("クリア後にソート済みデータを書き込む", async () => { + const accessor = createSheetAccessor( + "s1", + "Sheet1", + "/fake/key.json", + testColumnDef, + ); + await accessor.replaceAll([ + { id: "b", value: 2 }, + { id: "a", value: 1 }, + ]); + + expect(mockClear).toHaveBeenCalledTimes(1); + expect(mockUpdate).toHaveBeenCalledTimes(1); + const call = mockUpdate.mock.calls[0] as unknown as [ + Record, + ]; + const body = call[0].requestBody as { values: unknown[][] }; + expect(body.values[0]).toEqual(["id", "value"]); + expect(body.values[1]).toEqual(["a", 1]); + expect(body.values[2]).toEqual(["b", 2]); + }); + + it("空配列ではクリアのみ実行する", async () => { + const accessor = createSheetAccessor( + "s1", + "Sheet1", + "/fake/key.json", + testColumnDef, + ); + await accessor.replaceAll([]); + + expect(mockClear).toHaveBeenCalledTimes(1); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it("エラー時にラップしたErrorをthrowする", async () => { + mockClear.mockRejectedValueOnce(new Error("clear fail")); + + const accessor = createSheetAccessor( + "s1", + "Sheet1", + "/fake/key.json", + testColumnDef, + ); + await expect( + accessor.replaceAll([{ id: "a", value: 1 }]), + ).rejects.toThrow("Failed to replace all"); + }); + }); + + describe("ensureHeader", () => { + it("ヘッダーが不一致の場合はupdateする", async () => { + mockGet + .mockResolvedValueOnce({ data: { values: [["wrong"]] } }) + .mockResolvedValueOnce({ data: { values: [["id"]] } }); + + const accessor = createSheetAccessor( + "s1", + "Sheet1", + "/fake/key.json", + testColumnDef, + ); + await accessor.append({ id: "row-1", value: 1 }); + + const updateCalls = mockUpdate.mock.calls as unknown as [ + Record, + ][]; + const headerCall = updateCalls.find((c) => { + const rb = c[0].requestBody as { values: unknown[][] } | undefined; + return rb?.values?.[0]?.[0] === "id"; + }); + expect(headerCall).toBeDefined(); + }); + + it("Unable to parse rangeエラーでシートを新規作成する", async () => { + mockGet + .mockRejectedValueOnce(new Error("Unable to parse range")) + .mockResolvedValueOnce({ data: { values: [["id", "value"]] } }) + .mockResolvedValueOnce({ data: { values: [["id"]] } }); + + const accessor = createSheetAccessor( + "s1", + "Sheet1", + "/fake/key.json", + testColumnDef, + ); + await accessor.append({ id: "row-1", value: 1 }); + + expect(mockBatchUpdate).toHaveBeenCalledTimes(1); + }); + }); + + describe("findRowByKey", () => { + it("Unable to parse rangeエラーでnullを返す", async () => { + mockGet + .mockResolvedValueOnce({ data: { values: [["id", "value"]] } }) + .mockRejectedValueOnce(new Error("Unable to parse range")); + + const accessor = createSheetAccessor( + "s1", + "Sheet1", + "/fake/key.json", + testColumnDef, + ); + await accessor.append({ id: "row-1", value: 1 }); + + expect(mockAppend).toHaveBeenCalledTimes(1); + }); + + it("データなしでnullを返す", async () => { + mockGet + .mockResolvedValueOnce({ data: { values: [["id", "value"]] } }) + .mockResolvedValueOnce({ + data: { values: null as unknown as string[][] }, + }); + + const accessor = createSheetAccessor( + "s1", + "Sheet1", + "/fake/key.json", + testColumnDef, + ); + await accessor.append({ id: "row-1", value: 1 }); + + expect(mockAppend).toHaveBeenCalledTimes(1); + }); + }); + + describe("clearData", () => { + it("Unable to parse rangeエラーを無視する", async () => { + mockClear.mockRejectedValueOnce(new Error("Unable to parse range")); + + const accessor = createSheetAccessor( + "s1", + "Sheet1", + "/fake/key.json", + testColumnDef, + ); + await accessor.replaceAll([{ id: "a", value: 1 }]); + + expect(mockUpdate).toHaveBeenCalledTimes(1); + }); + + it("その他のエラーはthrowする", async () => { + mockClear.mockRejectedValueOnce(new Error("server error")); + + const accessor = createSheetAccessor( + "s1", + "Sheet1", + "/fake/key.json", + testColumnDef, + ); + await expect( + accessor.replaceAll([{ id: "a", value: 1 }]), + ).rejects.toThrow(); + }); + }); + + describe("createSheet", () => { + it("already existsエラーを無視する", async () => { + mockGet.mockRejectedValueOnce(new Error("Unable to parse range")); + mockBatchUpdate.mockRejectedValueOnce(new Error("already exists")); + mockGet + .mockResolvedValueOnce({ data: { values: [["id", "value"]] } }) + .mockResolvedValueOnce({ data: { values: [["id"]] } }); + + const accessor = createSheetAccessor( + "s1", + "Sheet1", + "/fake/key.json", + testColumnDef, + ); + await accessor.append({ id: "row-1", value: 1 }); + + expect(mockAppend).toHaveBeenCalledTimes(1); + }); + + it("その他のcreateSheetエラーはthrowする", async () => { + mockGet.mockRejectedValueOnce(new Error("Unable to parse range")); + mockBatchUpdate.mockRejectedValueOnce(new Error("quota exceeded")); + + const accessor = createSheetAccessor( + "s1", + "Sheet1", + "/fake/key.json", + testColumnDef, + ); + await expect(accessor.append({ id: "row-1", value: 1 })).rejects.toThrow( + "Failed to append/update record", + ); + }); + }); + + describe("ensureHeader (その他エラー)", () => { + it("Unable to parse range以外のensureHeaderエラーはthrowする", async () => { + mockGet.mockRejectedValueOnce(new Error("auth failure")); + + const accessor = createSheetAccessor( + "s1", + "Sheet1", + "/fake/key.json", + testColumnDef, + ); + await expect(accessor.append({ id: "row-1", value: 1 })).rejects.toThrow( + "Failed to append/update record", + ); + }); + }); + + describe("findRowByKey (その他エラー)", () => { + it("Unable to parse range以外のfindRowByKeyエラーはthrowする", async () => { + mockGet + .mockResolvedValueOnce({ data: { values: [["id", "value"]] } }) + .mockRejectedValueOnce(new Error("timeout")); + + const accessor = createSheetAccessor( + "s1", + "Sheet1", + "/fake/key.json", + testColumnDef, + ); + await expect(accessor.append({ id: "row-1", value: 1 })).rejects.toThrow( + "Failed to append/update record", + ); + }); + }); +}); diff --git a/tests/jobs/collectData.test.ts b/tests/jobs/collectData.test.ts new file mode 100644 index 0000000..789ae3d --- /dev/null +++ b/tests/jobs/collectData.test.ts @@ -0,0 +1,98 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from "bun:test"; +import { createTestDatabase } from "../test-helpers"; + +mock.module("../../src/retry", () => ({ + retry: async (fn: () => Promise) => fn(), +})); + +const { fetchPlayerCount, collectData } = await import( + "../../src/jobs/collectData" +); + +describe("fetchPlayerCount", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it("正常なレスポンスからプレイヤー数を返す", async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ response: { player_count: 5000 } }), { + status: 200, + }), + ); + + const count = await fetchPlayerCount(); + expect(count).toBe(5000); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("非okレスポンスでthrowする", async () => { + fetchSpy.mockResolvedValue(new Response("Server Error", { status: 500 })); + + await expect(fetchPlayerCount()).rejects.toThrow("Steam API error: 500"); + }); + + it("player_count=0でthrowする", async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ response: { player_count: 0 } }), { + status: 200, + }), + ); + + await expect(fetchPlayerCount()).rejects.toThrow( + "Steam API returned 0 players", + ); + }); + + it("不正なレスポンスでZodErrorをthrowする", async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ invalid: "data" }), { status: 200 }), + ); + + await expect(fetchPlayerCount()).rejects.toThrow(); + }); +}); + +describe("collectData", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it("成功時にエラーをthrowしない", async () => { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ response: { player_count: 5000 } }), { + status: 200, + }), + ); + const { db, cleanup } = createTestDatabase(); + await expect(collectData(db)).resolves.toBeUndefined(); + cleanup(); + }); + + it("失敗時もエラーをthrowしない (catchされる)", async () => { + fetchSpy.mockResolvedValue(new Response("error", { status: 500 })); + const { db, cleanup } = createTestDatabase(); + await expect(collectData(db)).resolves.toBeUndefined(); + cleanup(); + }); +}); diff --git a/tests/jobs/dailyAverage.test.ts b/tests/jobs/dailyAverage.test.ts new file mode 100644 index 0000000..184f821 --- /dev/null +++ b/tests/jobs/dailyAverage.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type { Database } from "../../src/db"; +import { createTestDatabase } from "../test-helpers"; + +describe("dailyAverage - DB統合テスト", () => { + let db: Database; + let cleanup: () => void; + + beforeEach(() => { + ({ db, cleanup } = createTestDatabase()); + }); + + afterEach(() => { + cleanup(); + }); + + it("欠落日付の日次平均を計算・保存する", () => { + db.insertPlayerData("2020-01-01 00:00:00", 100); + db.insertPlayerData("2020-01-01 12:00:00", 200); + db.insertPlayerData("2020-01-01 18:00:00", 300); + + const missingDates = db.getDatesWithDataButNoAverage(); + expect(missingDates).toContain("2020-01-01"); + + for (const date of missingDates) { + const result = db.calculateDailyAverage(date); + if (result) db.upsertDailyAverage(result); + } + + expect(db.getAllDailyAverages()).toHaveLength(1); + expect(db.getDatesWithDataButNoAverage()).toEqual([]); + }); + + it("全日付に平均がある場合は何もしない", () => { + db.insertPlayerData("2020-01-01 12:00:00", 100); + const result = db.calculateDailyAverage("2020-01-01"); + if (result) db.upsertDailyAverage(result); + + expect(db.getDatesWithDataButNoAverage()).toEqual([]); + }); +}); diff --git a/tests/jobs/syncSheets-disabled.test.ts b/tests/jobs/syncSheets-disabled.test.ts new file mode 100644 index 0000000..4320134 --- /dev/null +++ b/tests/jobs/syncSheets-disabled.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "bun:test"; +import { mockConfigModule } from "../mocks/config"; + +mockConfigModule("../../src/config", false); + +const { createSheetAccessors } = await import("../../src/jobs/syncSheets"); + +describe("syncSheets (Google Sheets無効)", () => { + it("createSheetAccessorsがエラーをthrowする", () => { + expect(() => createSheetAccessors()).toThrow( + "Google Sheets is not enabled", + ); + }); +}); diff --git a/tests/jobs/syncSheets.test.ts b/tests/jobs/syncSheets.test.ts new file mode 100644 index 0000000..385133a --- /dev/null +++ b/tests/jobs/syncSheets.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import type { Database } from "../../src/db"; +import { mockConfigModule } from "../mocks/config"; +import { sampleDailyAverageRow } from "../test-helpers"; + +const { createDatabase: realCreateDatabase } = await import("../../src/db"); + +const mockBatchAppend = mock(() => Promise.resolve()); +const mockAppend = mock(() => Promise.resolve()); +const mockReplaceAll = mock(() => Promise.resolve()); + +mock.module("../../src/googleSheets", () => ({ + createSheetAccessor: () => ({ + append: mockAppend, + batchAppend: mockBatchAppend, + replaceAll: mockReplaceAll, + }), +})); + +let sharedDb: Database; + +mockConfigModule("../../src/config", true); + +const { + playerDataColumnDef, + dailyAverageColumnDef, + fullSyncToSheets, + syncUnsyncedToSheets, + createSheetAccessors, +} = await import("../../src/jobs/syncSheets"); + +const testAccessors = createSheetAccessors(); + +describe("playerDataColumnDef", () => { + it("toRowがPlayerDataRowを正しく変換する", () => { + const row = playerDataColumnDef.toRow({ + timestamp: "2024-01-01 12:00:00", + playerCount: 5000, + }); + expect(row).toEqual(["2024-01-01 12:00:00", 5000]); + }); + + it("getKeyがtimestampを返す", () => { + expect( + playerDataColumnDef.getKey({ + timestamp: "2024-01-01 12:00:00", + playerCount: 5000, + }), + ).toBe("2024-01-01 12:00:00"); + }); + + it("headersが2列", () => { + expect(playerDataColumnDef.headers).toHaveLength(2); + }); + + it("columnRangeがA:B", () => { + expect(playerDataColumnDef.columnRange).toBe("A:B"); + }); +}); + +describe("dailyAverageColumnDef", () => { + const sampleRow = sampleDailyAverageRow("2024-01-01"); + + it("toRowが7要素配列に変換する", () => { + const row = dailyAverageColumnDef.toRow(sampleRow); + expect(row).toHaveLength(7); + }); + + it("getKeyがdateを返す", () => { + expect(dailyAverageColumnDef.getKey(sampleRow)).toBe("2024-01-01"); + }); + + it("headersが7列", () => { + expect(dailyAverageColumnDef.headers).toHaveLength(7); + }); + + it("columnRangeがA:G", () => { + expect(dailyAverageColumnDef.columnRange).toBe("A:G"); + }); +}); + +describe("syncUnsyncedToSheets", () => { + beforeEach(() => { + // closeされないDBを毎回新規作成 + sharedDb = realCreateDatabase(":memory:"); + + mockBatchAppend.mockClear(); + mockAppend.mockClear(); + mockReplaceAll.mockClear(); + }); + + it("未同期データなしでno-op", async () => { + await syncUnsyncedToSheets(sharedDb, testAccessors); + expect(mockBatchAppend).not.toHaveBeenCalled(); + expect(mockAppend).not.toHaveBeenCalled(); + }); + + it("未同期プレイヤーデータがある場合にbatchAppendが呼ばれる", async () => { + sharedDb.insertPlayerData("2024-06-01 12:00:00", 5000); + + await syncUnsyncedToSheets(sharedDb, testAccessors); + expect(mockBatchAppend).toHaveBeenCalledTimes(1); + }); + + it("未同期日次平均がある場合にappend(upsert)が呼ばれる", async () => { + sharedDb.upsertDailyAverage(sampleDailyAverageRow("2024-06-01")); + + await syncUnsyncedToSheets(sharedDb, testAccessors); + expect(mockAppend).toHaveBeenCalled(); + }); + + it("Sheets APIエラー時にcatchされてthrowしない", async () => { + sharedDb.insertPlayerData("2024-06-02 12:00:00", 3000); + mockBatchAppend.mockRejectedValueOnce(new Error("API error")); + + await expect( + syncUnsyncedToSheets(sharedDb, testAccessors), + ).resolves.toBeUndefined(); + }); +}); + +describe("fullSyncToSheets", () => { + beforeEach(() => { + sharedDb = realCreateDatabase(":memory:"); + + mockReplaceAll.mockClear(); + }); + + it("全データを同期する", async () => { + sharedDb.insertPlayerData("2024-06-01 12:00:00", 5000); + sharedDb.upsertDailyAverage(sampleDailyAverageRow("2024-06-01")); + + await expect( + fullSyncToSheets(sharedDb, testAccessors), + ).resolves.toBeUndefined(); + expect(mockReplaceAll).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/logger.test.ts b/tests/logger.test.ts new file mode 100644 index 0000000..1c1af03 --- /dev/null +++ b/tests/logger.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { createLogger } from "../src/logger"; + +describe("createLogger", () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it("infoレベルでJSON出力する", () => { + const logger = createLogger("test-module"); + logger.info("test message", { key: "value" }); + + expect(consoleSpy).toHaveBeenCalledTimes(1); + const output = JSON.parse(consoleSpy.mock.calls[0][0] as string); + expect(output.level).toBe("info"); + expect(output.module).toBe("test-module"); + expect(output.msg).toBe("test message"); + expect(output.key).toBe("value"); + }); + + it("warnレベルで出力する", () => { + const logger = createLogger("test"); + logger.warn("warning"); + + const output = JSON.parse(consoleSpy.mock.calls[0][0] as string); + expect(output.level).toBe("warn"); + }); + + it("errorレベルで出力する", () => { + const logger = createLogger("test"); + logger.error("error", { code: 500 }); + + const output = JSON.parse(consoleSpy.mock.calls[0][0] as string); + expect(output.level).toBe("error"); + expect(output.code).toBe(500); + }); + + it("デフォルトinfo閾値でdebugを抑制する", () => { + const logger = createLogger("test"); + logger.debug("debug msg"); + + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it("ISO形式のタイムスタンプを含む", () => { + const logger = createLogger("test"); + logger.info("test"); + + const output = JSON.parse(consoleSpy.mock.calls[0][0] as string); + expect(output.time).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + }); + + it("モジュール名が出力に含まれる", () => { + const logger = createLogger("my-module"); + logger.info("x"); + + const output = JSON.parse(consoleSpy.mock.calls[0][0] as string); + expect(output.module).toBe("my-module"); + }); +}); diff --git a/tests/mocks/config.ts b/tests/mocks/config.ts new file mode 100644 index 0000000..91e7e07 --- /dev/null +++ b/tests/mocks/config.ts @@ -0,0 +1,41 @@ +import { mock } from "bun:test"; + +/** + * @description テスト用Config設定を生成 + * @param googleSheetsEnabled - Google Sheets有効/無効 @default true + */ +export function createTestConfig(googleSheetsEnabled = true) { + return { + steam: { appId: 730 }, + storage: { dbPath: ":memory:" }, + scheduling: { + collectionMinutes: [0, 30], + dailyAverageHour: 0, + sheetsSyncMinutes: [5, 35], + }, + logging: { level: "info" }, + googleSheets: googleSheetsEnabled + ? { + enabled: true as const, + spreadsheetId: "test-spreadsheet", + sheetName: "PlayerData", + dailyAverageSheetName: "DailyAverages", + serviceAccountKeyPath: "/fake/key.json", + } + : { enabled: false as const }, + }; +} + +/** + * @description configモジュールをモック + * @param modulePath - モジュールパス + * @param googleSheetsEnabled - Google Sheets有効/無効 + */ +export function mockConfigModule( + modulePath: string, + googleSheetsEnabled = true, +) { + const config = createTestConfig(googleSheetsEnabled); + mock.module(modulePath, () => ({ config })); + return config; +} diff --git a/tests/mocks/googleSheets.ts b/tests/mocks/googleSheets.ts new file mode 100644 index 0000000..a142810 --- /dev/null +++ b/tests/mocks/googleSheets.ts @@ -0,0 +1,76 @@ +import { mock } from "bun:test"; + +/** + * @description Google Sheets APIモックのセットアップと管理 + */ +export function setupGoogleSheetsMocks() { + const mockGet = mock(() => + Promise.resolve({ data: { values: [["id", "value"]] } }), + ); + const mockUpdate = mock(() => Promise.resolve(undefined)); + const mockAppend = mock(() => Promise.resolve(undefined)); + const mockClear = mock(() => Promise.resolve(undefined)); + const mockBatchUpdate = mock(() => Promise.resolve(undefined)); + + mock.module("node:fs", () => ({ + promises: { + readFile: mock(() => + Promise.resolve( + JSON.stringify({ + client_email: "test@test.iam.gserviceaccount.com", + private_key: + "-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----", + }), + ), + ), + }, + })); + + mock.module("google-auth-library", () => ({ + JWT: class FakeJWT {}, + })); + + mock.module("googleapis", () => ({ + google: { + sheets: () => ({ + spreadsheets: { + values: { + get: mockGet, + update: mockUpdate, + append: mockAppend, + clear: mockClear, + }, + batchUpdate: mockBatchUpdate, + }, + }), + }, + })); + + /** + * @description 全モックをリセットしデフォルトレスポンスを再設定 + */ + function resetAll() { + mockGet.mockReset(); + mockUpdate.mockReset(); + mockAppend.mockReset(); + mockClear.mockReset(); + mockBatchUpdate.mockReset(); + + mockGet.mockResolvedValue({ + data: { values: [["id", "value"]] }, + }); + mockUpdate.mockResolvedValue(undefined); + mockAppend.mockResolvedValue(undefined); + mockClear.mockResolvedValue(undefined); + mockBatchUpdate.mockResolvedValue(undefined); + } + + return { + mockGet, + mockUpdate, + mockAppend, + mockClear, + mockBatchUpdate, + resetAll, + }; +} diff --git a/tests/retry.test.ts b/tests/retry.test.ts new file mode 100644 index 0000000..7b39765 --- /dev/null +++ b/tests/retry.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, mock } from "bun:test"; +import { retry } from "../src/retry"; + +describe("retry", () => { + it("初回成功で即返却する", async () => { + const fn = mock(() => Promise.resolve(42)); + const result = await retry(fn, { baseDelay: 1 }); + expect(result).toBe(42); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("失敗→成功でリトライする", async () => { + let callCount = 0; + const fn = mock(() => { + callCount++; + if (callCount === 1) return Promise.reject(new Error("fail")); + return Promise.resolve("ok"); + }); + + const result = await retry(fn, { baseDelay: 1 }); + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("全失敗で最後のエラーをthrowする", async () => { + let callCount = 0; + const fn = mock(() => { + callCount++; + return Promise.reject(new Error(`attempt-${callCount}`)); + }); + + await expect(retry(fn, { attempts: 3, baseDelay: 1 })).rejects.toThrow( + "attempt-3", + ); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it("デフォルトで4回試行する", async () => { + const fn = mock(() => Promise.reject(new Error("fail"))); + + await expect(retry(fn, { baseDelay: 1 })).rejects.toThrow(); + expect(fn).toHaveBeenCalledTimes(4); + }); + + it("カスタム試行回数を適用する", async () => { + const fn = mock(() => Promise.reject(new Error("fail"))); + + await expect(retry(fn, { attempts: 2, baseDelay: 1 })).rejects.toThrow(); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("3回目で成功する", async () => { + let callCount = 0; + const fn = mock(() => { + callCount++; + if (callCount < 3) return Promise.reject(new Error("fail")); + return Promise.resolve("success"); + }); + + const result = await retry(fn, { attempts: 4, baseDelay: 1 }); + expect(result).toBe("success"); + expect(fn).toHaveBeenCalledTimes(3); + }); +}); diff --git a/tests/schemas/googleCredentials.test.ts b/tests/schemas/googleCredentials.test.ts new file mode 100644 index 0000000..80e4066 --- /dev/null +++ b/tests/schemas/googleCredentials.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test"; +import { googleServiceAccountSchema } from "../../src/schemas/googleCredentials"; + +describe("googleServiceAccountSchema", () => { + it("正常な認証情報をパースできる", () => { + const result = googleServiceAccountSchema.parse({ + client_email: "test@project.iam.gserviceaccount.com", + private_key: + "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + }); + expect(result.client_email).toBe("test@project.iam.gserviceaccount.com"); + }); + + it("client_emailが無効でZodErrorを投げる", () => { + expect(() => + googleServiceAccountSchema.parse({ + client_email: "not-an-email", + private_key: "key", + }), + ).toThrow(); + }); + + it("private_keyが空文字でZodErrorを投げる", () => { + expect(() => + googleServiceAccountSchema.parse({ + client_email: "a@b.com", + private_key: "", + }), + ).toThrow(); + }); + + it("フィールド欠落でZodErrorを投げる", () => { + expect(() => googleServiceAccountSchema.parse({})).toThrow(); + }); +}); diff --git a/tests/schemas/steamApi.test.ts b/tests/schemas/steamApi.test.ts new file mode 100644 index 0000000..accd0ff --- /dev/null +++ b/tests/schemas/steamApi.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "bun:test"; +import { + steamAppDetailsSchema, + steamPlayerCountResponseSchema, +} from "../../src/schemas/steamApi"; + +describe("steamPlayerCountResponseSchema", () => { + it("正常なプレイヤー数レスポンスをパースできる", () => { + const result = steamPlayerCountResponseSchema.parse({ + response: { player_count: 12345 }, + }); + expect(result.response.player_count).toBe(12345); + }); + + it("player_count欠落でZodErrorを投げる", () => { + expect(() => + steamPlayerCountResponseSchema.parse({ response: {} }), + ).toThrow(); + }); + + it("player_countが非数値でZodErrorを投げる", () => { + expect(() => + steamPlayerCountResponseSchema.parse({ + response: { player_count: "abc" }, + }), + ).toThrow(); + }); + + it("空オブジェクトでZodErrorを投げる", () => { + expect(() => steamPlayerCountResponseSchema.parse({})).toThrow(); + }); + + it("player_count=0も正常にパースできる", () => { + const result = steamPlayerCountResponseSchema.parse({ + response: { player_count: 0 }, + }); + expect(result.response.player_count).toBe(0); + }); +}); + +describe("steamAppDetailsSchema", () => { + it("正常なアプリ詳細をパースできる", () => { + const result = steamAppDetailsSchema.parse({ + success: true, + data: { name: "Counter-Strike 2" }, + }); + expect(result.data.name).toBe("Counter-Strike 2"); + }); + + it("success=falseでZodErrorを投げる", () => { + expect(() => + steamAppDetailsSchema.parse({ + success: false, + data: { name: "test" }, + }), + ).toThrow(); + }); + + it("name欠落でZodErrorを投げる", () => { + expect(() => + steamAppDetailsSchema.parse({ success: true, data: {} }), + ).toThrow(); + }); +}); diff --git a/tests/test-helpers.ts b/tests/test-helpers.ts new file mode 100644 index 0000000..dd59afc --- /dev/null +++ b/tests/test-helpers.ts @@ -0,0 +1,41 @@ +import { createDatabase, type Database } from "../src/db"; + +/** + * @description テスト用のインメモリデータベースを作成 + * @returns db - Databaseインスタンス, cleanup - クローズ関数 + */ +export function createTestDatabase(): { db: Database; cleanup: () => void } { + const db = createDatabase(":memory:"); + return { + db, + cleanup: () => db.close(), + }; +} + +/** + * @description テスト用プレイヤーデータを生成 + * @param count - 生成する件数 @default 3 + * @param date - 基準日 @default "2024-01-01" + */ +export function samplePlayerData(count = 3, date = "2024-01-01") { + return Array.from({ length: count }, (_, i) => ({ + timestamp: `${date} ${String(i).padStart(2, "0")}:00:00`, + playerCount: 1000 + i * 100, + })); +} + +/** + * @description テスト用日次平均データを生成 + * @param date - 日付 @default "2024-01-01" + */ +export function sampleDailyAverageRow(date = "2024-01-01") { + return { + date, + averagePlayerCount: 1100, + sampleCount: 3, + maxPlayerCount: 1200, + maxTimestamp: `${date} 02:00:00`, + minPlayerCount: 1000, + minTimestamp: `${date} 00:00:00`, + }; +} diff --git a/tests/tools/importCsv.test.ts b/tests/tools/importCsv.test.ts new file mode 100644 index 0000000..7baadd1 --- /dev/null +++ b/tests/tools/importCsv.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "bun:test"; +import { + parseDailyAverageCsv, + parsePlayerDataCsv, +} from "../../src/tools/importCsv"; + +describe("parsePlayerDataCsv", () => { + it("ヘッダー付きCSVをパースする", () => { + const csv = `timestamp,player_count +2024-01-01 12:00:00,5000 +2024-01-01 12:30:00,5100`; + const result = parsePlayerDataCsv(csv); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + timestamp: "2024-01-01 12:00:00", + playerCount: 5000, + }); + }); + + it("ヘッダー行をスキップする", () => { + const csv = "timestamp,player_count\n2024-01-01 00:00:00,100"; + const result = parsePlayerDataCsv(csv); + expect(result).toHaveLength(1); + }); + + it("空行をスキップする", () => { + const csv = `timestamp,player_count +2024-01-01 00:00:00,100 + +2024-01-01 01:00:00,200 +`; + const result = parsePlayerDataCsv(csv); + expect(result).toHaveLength(2); + }); + + it("不正なplayer_countの行をスキップする", () => { + const csv = `timestamp,player_count +2024-01-01 00:00:00,100 +2024-01-01 01:00:00,abc +2024-01-01 02:00:00,200`; + const result = parsePlayerDataCsv(csv); + expect(result).toHaveLength(2); + }); + + it("データがないCSVで空配列を返す", () => { + const csv = "timestamp,player_count"; + expect(parsePlayerDataCsv(csv)).toEqual([]); + }); +}); + +describe("parseDailyAverageCsv", () => { + it("7列形式をパースする", () => { + const csv = `date,average_player_count,sample_count,max_player_count,max_timestamp,min_player_count,min_timestamp +2024-01-01,1100,3,1200,2024-01-01 02:00:00,1000,2024-01-01 00:00:00`; + const result = parseDailyAverageCsv(csv); + expect(result).toHaveLength(1); + expect(result[0]?.date).toBe("2024-01-01"); + expect(result[0]?.maxPlayerCount).toBe(1200); + expect(result[0]?.minTimestamp).toBe("2024-01-01 00:00:00"); + }); + + it("3列形式(旧フォーマット)をパースする", () => { + const csv = `date,average_player_count,sample_count +2024-01-01,1100,3`; + const result = parseDailyAverageCsv(csv); + expect(result).toHaveLength(1); + expect(result[0]?.maxPlayerCount).toBe(1100); + expect(result[0]?.minPlayerCount).toBe(1100); + expect(result[0]?.maxTimestamp).toBe("2024-01-01 00:00:00"); + }); + + it("dateで始まるヘッダーをスキップする", () => { + const csv = `date,average_player_count,sample_count +2024-01-01,500,10`; + expect(parseDailyAverageCsv(csv)).toHaveLength(1); + }); + + it("不正な数値の行をスキップする", () => { + const csv = `date,average_player_count,sample_count +2024-01-01,abc,3 +2024-01-02,500,10`; + expect(parseDailyAverageCsv(csv)).toHaveLength(1); + }); + + it("7列形式でmax/min値が不正な場合は3列形式にフォールバックする", () => { + const csv = + "date,avg,samples,max,max_ts,min,min_ts\n2024-01-01,500,10,abc,,def,"; + const result = parseDailyAverageCsv(csv); + expect(result).toHaveLength(1); + expect(result[0]?.maxPlayerCount).toBe(500); + expect(result[0]?.minPlayerCount).toBe(500); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index f78210a..5747dbd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,30 +1,9 @@ { - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": ["ES2020"], - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "moduleResolution": "node", - "allowSyntheticDefaultImports": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "resolveJsonModule": true, - "types": ["node"] - }, - "include": [ - "src/**/*" - ], - "exclude": [ - "node_modules", - "dist", - "**/*.test.ts" - ] -} \ No newline at end of file + "extends": "@yuu1111/tsconfig/base.json", + "compilerOptions": { + "noEmit": true, + "types": ["bun-types"] + }, + "include": ["src/**/*", "tests/**/*"], + "exclude": ["node_modules", "dist"] +}