From 343b5fdee6d968666768583e3c42e11ffcc22f82 Mon Sep 17 00:00:00 2001 From: yCENzh <166907745+yCENzh@users.noreply.github.com> Date: Mon, 4 May 2026 06:41:01 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E4=BB=A3=E7=A0=81=E8=B4=A8?= =?UTF-8?q?=E9=87=8F=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D=20+=20cloud.umami?= =?UTF-8?q?.is=20API=20=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 35 ++++++ LICENSE | 21 ++++ README.md | 226 ++++++++++++++++++++++++++---------- package.json | 2 +- src/astro/index.ts | 1 - src/astro/integration.ts | 11 +- src/index.ts | 13 ++- src/modules/umami/api.ts | 184 +++++++++++++++++++++++------ src/modules/umami/client.ts | 78 +++++++++---- src/modules/umami/types.ts | 81 ++++++++++++- src/runtime/client.ts | 139 +++++++++++----------- src/utils/umami/cache.ts | 2 +- test/api.test.ts | 219 ++++++++++++++++++++++++++++++++++ test/cache-ttl.test.ts | 34 ++++++ test/client.test.ts | 125 ++++++++++++++++++++ tsconfig.json | 5 + 16 files changed, 978 insertions(+), 198 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE create mode 100644 test/api.test.ts create mode 100644 test/cache-ttl.test.ts create mode 100644 test/client.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c508e5a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: Lint / Typecheck / Test / Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm exec tsc --noEmit + + - name: Run tests + run: pnpm test + + - name: Build + run: pnpm build diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5119afd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) yCENzh + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 398c52e..3351c73 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,17 @@ # oddmisc -杂七杂八奇怪小工具 npm 包 +杂七杂八的小工具 npm 包。当前主要提供 Umami 分享链接的统计读取能力,包括 Node / 浏览器通用的 `UmamiClient`、纯浏览器运行时,以及一个开箱即用的 Astro 集成。 [![npm version](https://img.shields.io/npm/v/oddmisc.svg)](https://www.npmjs.com/package/oddmisc) -[![License](https://img.shields.io/npm/l/oddmisc.svg)](https://github.com/yCENzh/oddmisc/blob/main/LICENSE) +[![License](https://img.shields.io/npm/l/oddmisc.svg)](./LICENSE) + +## 特性 + +- **零运行时依赖**:仅依赖平台自带的 `fetch` / `URL` / `localStorage`。 +- **双端通用**:同一份 API 同时可在 Node 与浏览器中使用。 +- **内存 + localStorage 双级缓存**:默认 1 小时 TTL,浏览器刷新后仍然命中缓存。 +- **Astro 集成**:一行配置自动向页面注入运行时客户端,并挂载到 `window.oddmisc`。 +- **完整类型声明**:CJS / ESM 双格式输出,附带 `.d.ts`。 ## 安装 @@ -17,116 +25,206 @@ yarn add oddmisc ## 快速开始 -### 基本使用 +### 在 Node / 通用环境中使用 -```javascript +```ts import { createUmamiClient } from 'oddmisc'; -// 创建客户端 const client = createUmamiClient({ - shareUrl: 'https://your-umami-instance.com/share/your-share-id' + shareUrl: 'https://your-umami-instance.com/share/' +}); + +// 按路径查询页面统计 +const page = await client.getPageStats('/about'); +console.log(page.pageviews, page.visitors, page.visits); + +// 按完整 URL 查询 +const pageByUrl = await client.getPageStatsByUrl('https://site.example/about'); + +// 站点整体统计 +const site = await client.getSiteStats(); +``` + +所有查询都会被缓存,命中缓存时返回值上会带有 `_fromCache: true` 字段,可据此判断来源。调用 `client.clearCache()` 可清空缓存。 + +除了基础统计,还可以读取在线访客、时间序列、TopN 维度聚合等: + +```ts +// 当前在线访客(实时,不缓存) +const live = await client.getActiveVisitors(); + +// 按小时聚合的 pageviews / sessions 序列 +const series = await client.getPageviews({ + startAt: Date.now() - 24 * 3600_000, + endAt: Date.now(), + unit: 'hour', + timezone: 'Asia/Shanghai' }); -// 获取页面访问统计 -const stats = await client.getPageStats('/about'); -console.log(`页面浏览量: ${stats.pageviews}, 访客数: ${stats.visitors}, 访问次数: ${stats.visits}`); +// Top 10 路径 / 国家 / 浏览器 +const topPaths = await client.getMetrics('path', { limit: 10 }); +const topCountries = await client.getMetrics('country', { limit: 10 }); -// 获取网站整体统计 -const siteStats = await client.getSiteStats(); -console.log(`总浏览量: ${siteStats.pageviews}, 总访客数: ${siteStats.visitors}, 总访问次数: ${siteStats.visits}`); +// 网站元信息 / 可用数据区间 +const info = await client.getWebsite(); +const range = await client.getDateRange(); ``` ### Astro 集成 -在 `astro.config.mjs` 中配置: +在 `astro.config.mjs` 中加入: -```javascript +```js import { defineConfig } from 'astro/config'; -import { umami } from 'oddmisc'; +import { umami } from 'oddmisc/astro'; export default defineConfig({ integrations: [ umami({ - shareUrl: 'https://your-umami-instance.com/share/your-share-id' + shareUrl: 'https://your-umami-instance.com/share/' }) ] }); ``` -**禁用 umami:** +如需临时禁用注入,将 `shareUrl` 设为 `false` 即可: -```javascript -umami({ - shareUrl: false // 设为 false 则跳过 umami 集成 -}) +```js +umami({ shareUrl: false }); ``` -然后在前端代码中使用: +集成会向每个页面注入一段自包含的运行时脚本,并在 `window` 上挂载 `oddmisc` 命名空间: -```javascript -// 全局可用 -const stats = await window.oddmisc.getStats('/some-page'); -const siteStats = await window.oddmisc.getSiteStats(); -``` +```js +// 站点级统计 +const site = await window.oddmisc.getSiteStats(); -## API 参考 +// 指定页面 +const about = await window.oddmisc.getPageStats('/about'); -### UmamiClient +// 直接访问底层 client(同一接口) +const client = window.oddmisc.umami; +``` -#### 构造函数 -```javascript -const client = createUmamiClient(config); +脚本初始化完成后会派发 `oddmisc-ready` 事件,可用于避免在客户端上出现竞态: + +```js +window.addEventListener('oddmisc-ready', (e) => { + e.detail.client.getSiteStats().then(console.log); +}); ``` -#### 配置选项 -```javascript -const config = { - shareUrl: 'https://umami.example.com/share/abc123' // 必需 -}; +## API 参考 + +### `createUmamiClient(config)` / `new UmamiClient(config)` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `shareUrl` | `string` | Umami 分享链接,必填。支持自部署与 `cloud.umami.is` 形式 | + +返回的 `UmamiClient` 实例提供: + +| 方法 | 说明 | +| --- | --- | +| `getPageStats(path, options?)` | 按 `path` 查询(底层等价于 `path=eq.`) | +| `getPageStatsByUrl(url, options?)` | 按完整 URL 查询 | +| `getSiteStats(options?)` | 站点整体统计 | +| `getActiveVisitors()` | 当前在线访客数(实时,不缓存) | +| `getPageviews({ startAt, endAt, unit, timezone }?)` | 按时间聚合的 `pageviews` / `sessions` 序列 | +| `getMetrics(type, { startAt, endAt, limit }?)` | TopN 维度聚合 | +| `getWebsite()` | 网站元信息(`name` / `domain` 等) | +| `getDateRange()` | 此分享可用的数据范围 | +| `clearCache()` | 清空内存 + localStorage 缓存,以及已缓存的 share token | + +`options` 支持 `startAt` / `endAt`(毫秒时间戳),默认 `startAt=0` 到 `endAt=Date.now()`,即「建站起至今」。 + +`getMetrics(type)` 支持的维度(与 Umami v2 对齐):`path`、`referrer`、`browser`、`os`、`device`、`country`、`region`、`city`、`event`、`title`、`language`、`screen`、`tag`。**注意** `cloud.umami.is` 对 `url` / `host` 会返回 400,因此这两种没有放在类型中。 + +统一的统计返回结构: + +```ts +interface StatsResult { + pageviews: number; + visitors: number; + visits: number; + /** Umami v2+ 返回,表示跳出数 */ + bounces?: number; + /** Umami v2+ 返回,总访问时长(秒) */ + totaltime?: number; + /** Umami v2+ 返回,与上一周期的对比 */ + comparison?: { + pageviews?: number; + visitors?: number; + visits?: number; + bounces?: number; + totaltime?: number; + }; + _fromCache?: boolean; +} ``` -#### 方法 +> 针对 `cloud.umami.is` 与新版自托管 Umami,内部会自动附带 `x-umami-share-context: 1` 头;缺失该头时服务端会直接返回 401。 -- `getPageStats(path)` - 获取指定页面统计 -- `getPageStatsByUrl(url)` - 通过 URL 获取页面统计 -- `getSiteStats()` - 获取网站整体统计 -- `clearCache()` - 清除缓存 -- `getConfig()` - 获取当前配置 -- `updateConfig(newConfig)` - 更新配置 +### 运行时 `initUmamiRuntime(config)` -### Astro 集成 +若不使用 Astro,可以自行调用 `initUmamiRuntime({ shareUrl })` 在浏览器中挂载客户端: -```javascript -umami(options: UmamiIntegrationOptions) +```ts +import { initUmamiRuntime } from 'oddmisc'; +initUmamiRuntime({ shareUrl: 'https://.../share/' }); ``` -选项: -```javascript -interface UmamiIntegrationOptions { - shareUrl: string | false; // Umami 分享链接,设为 false 则跳过 -} -``` +挂载完成后会触发 `oddmisc-ready` 事件,`detail.client` 即为挂在 `window.oddmisc` 上的对象。 + +### 错误类型 + +所有错误都继承自 `UmamiError`(带 `code` 与可选 `status`): + +- `UmamiUrlError` — `code: 'INVALID_URL'`,无效分享链接。 +- `UmamiAuthError` — `code: 'AUTH_FAILED'`,常见于 401,通常意味着 shareId 失效。 +- `UmamiNetworkError` — `code: 'NETWORK_ERROR'`,上游返回非预期状态码。 + +### 工具类 + +- `parseShareUrl(shareUrl)` — 解析分享链接,返回 `{ apiBase, shareId }`。 +- `CacheManager` — 通用的内存 + localStorage 双级缓存,可在其它场景复用。 + +## 关于 Umami 分享 API + +经 `cloud.umami.is` 线上验证,本库使用如下端点(均以 `x-umami-share-token` + `x-umami-share-context: 1` 双头进行认证): + +- `GET /api/share/` — 解析 shareId,得到 `websiteId` + JWT `token` +- `GET /api/websites/` — 网站元信息 +- `GET /api/websites//stats?startAt&endAt[&path|url]` — 聚合统计 +- `GET /api/websites//active` — 当前在线访客 +- `GET /api/websites//pageviews?startAt&endAt&unit&timezone` — 时间序列 +- `GET /api/websites//metrics?startAt&endAt&type[&limit]` — TopN 聚合 +- `GET /api/websites//daterange` — 可用数据区间 ## 支持的 Umami URL 格式 -- `https://umami.example.com/share/abc123` -- `https://cloud.umami.is/analytics/us/share/abc123` -- `https://umami.example.com/analytics/share/abc123` +- `https://umami.example.com/share/` +- `https://cloud.umami.is/analytics/us/share/` +- `https://umami.example.com/analytics/share/` ## 浏览器兼容性 -- 现代浏览器(Chrome 60+, Firefox 60+, Safari 12+) -- 支持 localStorage 的环境 +现代浏览器(Chrome 60+、Firefox 60+、Safari 12+);需要 `fetch`、`URL`、`AbortController`、`localStorage`。 -## License +## 开发 -MIT © yCENzh +```bash +pnpm install +pnpm build # 使用 tsup 构建 dist/ +pnpm test # 运行 vitest 单元测试 +pnpm test:coverage # 生成覆盖率报告 +``` -## 贡献 +## License -欢迎提交 Issue 和 Pull Request! +MIT © yCENzh ## 相关链接 -- [Umami 官网](https://umami.is/) -- [GitHub 仓库](https://github.com/yCENzh/oddmisc) \ No newline at end of file +- [Umami 官方站点](https://umami.is/) +- [GitHub 仓库](https://github.com/yCENzh/oddmisc) diff --git a/package.json b/package.json index c366fa3..445073a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "oddmisc", "version": "1.1.6", - "description": "杂七杂八奇怪小工具 npm 包", + "description": "杂七杂八奇怪小工具 npm 包 — Umami 分享链接统计读取与 Astro 集成", "homepage": "https://github.com/yCENzh/oddmisc#readme", "bugs": { "url": "https://github.com/yCENzh/oddmisc/issues" diff --git a/src/astro/index.ts b/src/astro/index.ts index fac3a13..14cc9c0 100644 --- a/src/astro/index.ts +++ b/src/astro/index.ts @@ -1,3 +1,2 @@ -// Astro 集成入口 export { umami, oddmisc } from './integration'; export type { UmamiIntegrationOptions, OddmiscIntegrationOptions } from './integration'; \ No newline at end of file diff --git a/src/astro/integration.ts b/src/astro/integration.ts index e469965..95d7501 100644 --- a/src/astro/integration.ts +++ b/src/astro/integration.ts @@ -14,7 +14,7 @@ export interface OddmiscIntegrationOptions { type AstroConfigSetupParams = HookParameters<'astro:config:setup'>; -function injectUmamiRuntime(shareUrl: string | false) { +function injectUmamiRuntime(shareUrl: string | false): string | undefined { if (!shareUrl) return; let runtimeCode = ''; @@ -23,19 +23,18 @@ function injectUmamiRuntime(shareUrl: string | false) { const runtimePath = join(__dirname, './runtime/client.global.js'); runtimeCode = readFileSync(runtimePath, 'utf-8'); } catch { - console.warn('[oddmisc] 无法读取运行时文件,使用备用方案'); + console.warn('[oddmisc] 无法读取运行时文件,已跳过客户端注入'); + return; } - const initCode = ` + return ` // oddmisc Umami Runtime ${runtimeCode} -if (typeof window !== 'undefined') { +if (typeof window !== 'undefined' && typeof __oddmiscRuntime !== 'undefined') { __oddmiscRuntime.initUmamiRuntime(${JSON.stringify({ shareUrl })}); } `; - - return initCode; } export function umami(options: UmamiIntegrationOptions): AstroIntegration { diff --git a/src/index.ts b/src/index.ts index caa4684..f699163 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,18 @@ export { UmamiError, UmamiUrlError, UmamiAuthError, UmamiNetworkError } from './ // Umami 模块 export { UmamiClient, createUmamiClient } from './modules/umami/client'; -export type { UmamiConfig, StatsResult, StatsQueryParams } from './modules/umami/types'; +export type { + UmamiConfig, + StatsResult, + StatsQueryParams, + StatsComparison, + MetricType, + MetricEntry, + PageviewPoint, + PageviewsSeries, + WebsiteInfo, + DateRange +} from './modules/umami/types'; // 工具函数 export { CacheManager } from './utils/umami/cache'; diff --git a/src/modules/umami/api.ts b/src/modules/umami/api.ts index 50dd20b..8e5d161 100644 --- a/src/modules/umami/api.ts +++ b/src/modules/umami/api.ts @@ -1,28 +1,63 @@ -import type { ShareData, StatsQueryParams } from './types'; +import type { + ShareData, + StatsQueryParams, + PageviewsSeries, + MetricEntry, + MetricType, + WebsiteInfo, + DateRange +} from './types'; import { CacheManager } from '../../utils/umami/cache'; import { fetchWithTimeout } from '../../utils/fetch'; import { UmamiNetworkError, UmamiAuthError } from '../../errors'; +// cloud.umami.is / 新版自托管 Umami 的 share token 鉴权必须带上此 context 头, +// 否则 /websites/* 一律 401。 +const SHARE_CONTEXT_HEADER = 'x-umami-share-context'; +const SHARE_CONTEXT_VALUE = '1'; + interface StatsAPIParams extends Partial { path?: string; url?: string; } -interface StatsAPIResponse { +export interface StatsAPIResponse { pageviews?: number | { value: number }; visitors?: number | { value: number }; visits?: number | { value: number }; + bounces?: number | { value: number }; + totaltime?: number | { value: number }; + comparison?: { + pageviews?: number; + visitors?: number; + visits?: number; + bounces?: number; + totaltime?: number; + }; _fromCache?: boolean; [key: string]: unknown; } +export interface TimeRange { + startAt?: number; + endAt?: number; +} + +export interface PageviewsParams extends TimeRange { + unit?: 'year' | 'month' | 'day' | 'hour' | 'minute'; + timezone?: string; +} + +export interface MetricsParams extends TimeRange { + limit?: number; +} + +type Cached = T & { _fromCache?: boolean }; + export class UmamiAPI { - private cacheManager: CacheManager; private sharePromise: Promise | null = null; - constructor(cacheManager: CacheManager) { - this.cacheManager = cacheManager; - } + constructor(private readonly cacheManager: CacheManager) {} async getShareData(baseUrl: string, shareId: string): Promise { if (!this.sharePromise) { @@ -34,6 +69,10 @@ export class UmamiAPI { return this.sharePromise; } + clearShareCache(): void { + this.sharePromise = null; + } + private async fetchShareData(baseUrl: string, shareId: string): Promise { const res = await fetchWithTimeout(`${baseUrl}/share/${shareId}`); if (!res.ok) { @@ -42,45 +81,124 @@ export class UmamiAPI { return res.json(); } - async getStats(baseUrl: string, shareId: string, params: StatsAPIParams): Promise { - const cacheKey = `${baseUrl}|${shareId}|${JSON.stringify(params)}`; - - const cached = this.cacheManager.get(cacheKey); - if (cached) { - return { ...cached, _fromCache: true }; - } - - const { websiteId, token } = await this.getShareData(baseUrl, shareId); - - const queryParams = new URLSearchParams({ - startAt: '0', - endAt: Date.now().toString() - }); - - if (params.path) { - queryParams.set('path', params.path); - } - - const statsUrl = `${baseUrl}/websites/${websiteId}/stats?${queryParams.toString()}`; - - const res = await fetchWithTimeout(statsUrl, { - headers: { 'x-umami-share-token': token } + private async authedFetch(baseUrl: string, shareId: string, path: string): Promise { + const { token } = await this.getShareData(baseUrl, shareId); + const res = await fetchWithTimeout(`${baseUrl}${path}`, { + headers: { + 'x-umami-share-token': token, + [SHARE_CONTEXT_HEADER]: SHARE_CONTEXT_VALUE + } }); if (!res.ok) { if (res.status === 401) { this.cacheManager.clear(); + this.sharePromise = null; throw new UmamiAuthError('认证失败,请检查 shareId', res.status); } - throw new UmamiNetworkError(`获取统计失败: ${res.status}`, res.status); + throw new UmamiNetworkError(`请求 ${path} 失败: ${res.status}`, res.status); } + return (await res.json()) as T; + } - const data = await res.json(); + private async cachedGet( + baseUrl: string, + shareId: string, + path: string, + cacheKey: string + ): Promise> { + const cached = this.cacheManager.get(cacheKey) as T | null; + if (cached) return { ...cached, _fromCache: true }; + const data = await this.authedFetch(baseUrl, shareId, path); this.cacheManager.set(cacheKey, data); return data; } - clearShareCache(): void { - this.sharePromise = null; + private buildRangeQuery(range: TimeRange = {}): URLSearchParams { + return new URLSearchParams({ + startAt: (range.startAt ?? 0).toString(), + endAt: (range.endAt ?? Date.now()).toString() + }); + } + + async getStats(baseUrl: string, shareId: string, params: StatsAPIParams): Promise { + const { websiteId } = await this.getShareData(baseUrl, shareId); + const qp = this.buildRangeQuery(params); + if (params.path) qp.set('path', params.path); + if (params.url) qp.set('url', params.url); + return this.cachedGet( + baseUrl, + shareId, + `/websites/${websiteId}/stats?${qp.toString()}`, + `${baseUrl}|${shareId}|stats|${qp.toString()}` + ); + } + + /** 实时数据,不缓存。 */ + async getActiveVisitors(baseUrl: string, shareId: string): Promise<{ visitors: number }> { + const { websiteId } = await this.getShareData(baseUrl, shareId); + return this.authedFetch(baseUrl, shareId, `/websites/${websiteId}/active`); + } + + async getWebsite(baseUrl: string, shareId: string): Promise> { + const { websiteId } = await this.getShareData(baseUrl, shareId); + return this.cachedGet( + baseUrl, + shareId, + `/websites/${websiteId}`, + `${baseUrl}|${shareId}|website` + ); + } + + async getDateRange(baseUrl: string, shareId: string): Promise> { + const { websiteId } = await this.getShareData(baseUrl, shareId); + return this.cachedGet( + baseUrl, + shareId, + `/websites/${websiteId}/daterange`, + `${baseUrl}|${shareId}|daterange` + ); + } + + async getPageviews( + baseUrl: string, + shareId: string, + params: PageviewsParams = {} + ): Promise> { + const { websiteId } = await this.getShareData(baseUrl, shareId); + const qp = this.buildRangeQuery(params); + qp.set('unit', params.unit ?? 'day'); + qp.set('timezone', params.timezone ?? 'UTC'); + return this.cachedGet( + baseUrl, + shareId, + `/websites/${websiteId}/pageviews?${qp.toString()}`, + `${baseUrl}|${shareId}|pageviews|${qp.toString()}` + ); + } + + async getMetrics( + baseUrl: string, + shareId: string, + type: MetricType, + params: MetricsParams = {} + ): Promise { + const { websiteId } = await this.getShareData(baseUrl, shareId); + const qp = this.buildRangeQuery(params); + qp.set('type', type); + if (typeof params.limit === 'number') qp.set('limit', params.limit.toString()); + const cacheKey = `${baseUrl}|${shareId}|metrics|${qp.toString()}`; + + // CacheManager 只能存对象,数组包一层再缓存 + const cached = this.cacheManager.get(cacheKey) as { data: MetricEntry[] } | null; + if (cached) return cached.data; + + const data = await this.authedFetch( + baseUrl, + shareId, + `/websites/${websiteId}/metrics?${qp.toString()}` + ); + this.cacheManager.set(cacheKey, { data }); + return data; } } diff --git a/src/modules/umami/client.ts b/src/modules/umami/client.ts index c8c929e..bcdd199 100644 --- a/src/modules/umami/client.ts +++ b/src/modules/umami/client.ts @@ -1,13 +1,36 @@ import { CacheManager } from '../../utils/umami/cache'; -import { UmamiAPI } from './api'; +import { UmamiAPI, type PageviewsParams, type MetricsParams, type StatsAPIResponse } from './api'; import { parseShareUrl } from '../../utils/umami/url-parser'; -import type { UmamiConfig, StatsResult, StatsQueryParams } from './types'; +import { UmamiUrlError } from '../../errors'; +import type { + UmamiConfig, + StatsResult, + StatsQueryParams, + MetricType, + MetricEntry, + PageviewsSeries, + WebsiteInfo, + DateRange +} from './types'; function extractValue(field: number | { value: number } | undefined): number { if (typeof field === 'number') return field; return field?.value ?? 0; } +function toStatsResult(data: StatsAPIResponse): StatsResult { + const result: StatsResult = { + pageviews: extractValue(data.pageviews), + visitors: extractValue(data.visitors), + visits: extractValue(data.visits), + _fromCache: data._fromCache + }; + if (data.bounces !== undefined) result.bounces = extractValue(data.bounces); + if (data.totaltime !== undefined) result.totaltime = extractValue(data.totaltime); + if (data.comparison) result.comparison = data.comparison; + return result; +} + export class UmamiClient { private baseUrl: string; private shareId: string; @@ -16,7 +39,7 @@ export class UmamiClient { constructor(config: UmamiConfig) { if (!config.shareUrl) { - throw new Error('shareUrl 是必需参数'); + throw new UmamiUrlError('shareUrl 是必需参数'); } const { apiBase, shareId } = parseShareUrl(config.shareUrl); @@ -35,13 +58,7 @@ export class UmamiClient { path: `eq.${path}`, ...options }); - - return { - pageviews: extractValue(data.pageviews), - visitors: extractValue(data.visitors), - visits: extractValue(data.visits), - _fromCache: data._fromCache - }; + return toStatsResult(data); } async getPageStatsByUrl( @@ -52,24 +69,39 @@ export class UmamiClient { url, ...options }); - - return { - pageviews: extractValue(data.pageviews), - visitors: extractValue(data.visitors), - visits: extractValue(data.visits), - _fromCache: data._fromCache - }; + return toStatsResult(data); } async getSiteStats(options: Partial = {}): Promise { const data = await this.api.getStats(this.baseUrl, this.shareId, options); + return toStatsResult(data); + } + + /** 当前在线访客数(实时,不走缓存) */ + async getActiveVisitors(): Promise { + const data = await this.api.getActiveVisitors(this.baseUrl, this.shareId); + return data.visitors ?? 0; + } + + /** 按时间聚合的 pageviews / sessions 序列 */ + async getPageviews(options: PageviewsParams = {}): Promise { + const data = await this.api.getPageviews(this.baseUrl, this.shareId, options); + return { pageviews: data.pageviews ?? [], sessions: data.sessions ?? [] }; + } + + /** Top N 维度聚合(top 路径 / 国家 / 浏览器等) */ + getMetrics(type: MetricType, options: MetricsParams = {}): Promise { + return this.api.getMetrics(this.baseUrl, this.shareId, type, options); + } + + /** 网站元信息(name / domain 等) */ + getWebsite(): Promise { + return this.api.getWebsite(this.baseUrl, this.shareId); + } - return { - pageviews: extractValue(data.pageviews), - visitors: extractValue(data.visitors), - visits: extractValue(data.visits), - _fromCache: data._fromCache - }; + /** 此分享可用的数据范围 */ + getDateRange(): Promise { + return this.api.getDateRange(this.baseUrl, this.shareId); } clearCache(): void { diff --git a/src/modules/umami/types.ts b/src/modules/umami/types.ts index 1ab7c73..71a5034 100644 --- a/src/modules/umami/types.ts +++ b/src/modules/umami/types.ts @@ -1,17 +1,36 @@ interface UmamiConfig { - /** 如: https://umami.example.com/share/abc123 */ + /** 如: `https://umami.example.com/share/abc123` */ shareUrl: string; } interface StatsQueryParams { path?: string; url?: string; + /** 毫秒时间戳,默认 `0`(建站起) */ + startAt?: number; + /** 毫秒时间戳,默认 `Date.now()` */ + endAt?: number; +} + +interface StatsComparison { + pageviews?: number; + visitors?: number; + visits?: number; + bounces?: number; + totaltime?: number; } interface StatsResult { pageviews: number; visitors: number; - visits?: number; + visits: number; + /** 跳出数(Umami v2+) */ + bounces?: number; + /** 总访问时长,单位秒(Umami v2+) */ + totaltime?: number; + /** 与上一周期的对比(Umami v2+) */ + comparison?: StatsComparison; + /** 是否命中本地缓存 */ _fromCache?: boolean; } @@ -20,4 +39,60 @@ interface ShareData { token: string; } -export type { UmamiConfig, StatsQueryParams, StatsResult, ShareData }; +/** Umami v2 支持的聚合维度;`url` / `host` 在 cloud 上会返回 400,所以不包含。 */ +type MetricType = + | 'path' + | 'referrer' + | 'browser' + | 'os' + | 'device' + | 'country' + | 'region' + | 'city' + | 'event' + | 'title' + | 'language' + | 'screen' + | 'tag'; + +interface MetricEntry { + x: string; + y: number; +} + +/** `/pageviews` 时间序列中的一个点,和 `MetricEntry` 同形。 */ +type PageviewPoint = MetricEntry; + +interface PageviewsSeries { + pageviews: PageviewPoint[]; + sessions: PageviewPoint[]; +} + +interface WebsiteInfo { + id: string; + name: string; + domain: string; + shareId: string | null; + createdAt: string; + updatedAt: string; + resetAt: string | null; +} + +interface DateRange { + startDate: string; + endDate: string; +} + +export type { + UmamiConfig, + StatsQueryParams, + StatsResult, + StatsComparison, + ShareData, + MetricType, + MetricEntry, + PageviewPoint, + PageviewsSeries, + WebsiteInfo, + DateRange +}; diff --git a/src/runtime/client.ts b/src/runtime/client.ts index dd40932..67da123 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -1,11 +1,19 @@ /** - * 浏览器运行时客户端 - * 注意:此文件会被内联注入到页面,不能有外部依赖 + * 浏览器运行时客户端。 + * 此文件会被内联注入到页面,因此不能有任何外部依赖。 */ const DEFAULT_TIMEOUT = 10000; -async function fetchWithTimeout(url: string, options?: RequestInit, timeout = DEFAULT_TIMEOUT): Promise { +// cloud.umami.is / 新版自托管 Umami 的 share token 鉴权必须同时带此 context 头。 +const SHARE_CONTEXT_HEADER = 'x-umami-share-context'; +const SHARE_CONTEXT_VALUE = '1'; + +async function fetchWithTimeout( + url: string, + options?: RequestInit, + timeout = DEFAULT_TIMEOUT +): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { @@ -22,7 +30,9 @@ interface UmamiRuntimeConfig { interface StatsResult { pageviews: number; visitors: number; - visits?: number; + visits: number; + bounces?: number; + totaltime?: number; _fromCache?: boolean; } @@ -31,6 +41,14 @@ interface ShareData { token: string; } +function extract(field: unknown): number { + if (typeof field === 'number') return field; + if (field && typeof (field as { value?: unknown }).value === 'number') { + return (field as { value: number }).value; + } + return 0; +} + function parseShareUrl(shareUrl: string): { apiBase: string; shareId: string } { const url = new URL(shareUrl); const pathParts = url.pathname.split('/'); @@ -41,37 +59,33 @@ function parseShareUrl(shareUrl: string): { apiBase: string; shareId: string } { } const shareId = pathParts[shareIndex + 1]; - if (!shareId) { throw new Error('无效的分享 URL:缺少分享 ID'); } const pathBeforeShare = pathParts.slice(0, shareIndex).join('/'); const apiBase = `${url.protocol}//${url.host}${pathBeforeShare}/api`; - return { apiBase, shareId }; } class SimpleCache { private cache = new Map(); - private storageKey: string; - private ttl: number; - private storageCache: Record | null = null; - constructor(storageKey: string, ttl: number) { - this.storageKey = storageKey; - this.ttl = ttl; + constructor(private readonly storageKey: string, private readonly ttl: number) { this.loadFromStorage(); } private loadFromStorage(): void { - if (this.storageCache !== null) return; try { const stored = localStorage.getItem(this.storageKey); - this.storageCache = stored ? JSON.parse(stored) : {}; - } catch { - this.storageCache = {}; - } + if (!stored) return; + const parsed = JSON.parse(stored) as Record; + for (const [key, entry] of Object.entries(parsed)) { + if (entry && typeof entry.timestamp === 'number' && !this.isExpired(entry.timestamp)) { + this.cache.set(key, entry); + } + } + } catch {} } private saveToStorage(): void { @@ -81,7 +95,6 @@ class SimpleCache { obj[key] = value; }); localStorage.setItem(this.storageKey, JSON.stringify(obj)); - this.storageCache = obj; } catch {} } @@ -96,19 +109,18 @@ class SimpleCache { } if (cached) { this.cache.delete(key); + this.saveToStorage(); } return null; } set(key: string, value: unknown): void { - const entry = { value, timestamp: Date.now() }; - this.cache.set(key, entry); + this.cache.set(key, { value, timestamp: Date.now() }); this.saveToStorage(); } clear(): void { this.cache.clear(); - this.storageCache = null; try { localStorage.removeItem(this.storageKey); } catch {} @@ -133,13 +145,8 @@ class UmamiRuntimeClient { } private async getShareData(): Promise { - if (this.shareData) { - return this.shareData; - } - - if (this.sharePromise) { - return this.sharePromise; - } + if (this.shareData) return this.shareData; + if (this.sharePromise) return this.sharePromise; this.sharePromise = (async (): Promise => { const res = await fetchWithTimeout(`${this.apiBase}/share/${this.shareId}`); @@ -155,57 +162,57 @@ class UmamiRuntimeClient { return this.sharePromise; } - async getStats(path?: string): Promise { - const cacheKey = path ? `stats-${path}` : 'stats-site'; - - const cached = this.cache.get(cacheKey); - if (cached) { - return { ...cached as StatsResult, _fromCache: true }; + private async authedFetch(path: string): Promise { + const { websiteId, token } = await this.getShareData(); + const res = await fetchWithTimeout(`${this.apiBase}/websites/${websiteId}${path}`, { + headers: { + 'x-umami-share-token': token, + [SHARE_CONTEXT_HEADER]: SHARE_CONTEXT_VALUE + } + }); + if (!res.ok) { + throw new Error(`请求 ${path} 失败: ${res.status}`); } + return (await res.json()) as T; + } - const { websiteId, token } = await this.getShareData(); + async getStats(path?: string): Promise { + const cacheKey = path ? `stats-${path}` : 'stats-site'; + const cached = this.cache.get(cacheKey) as StatsResult | null; + if (cached) return { ...cached, _fromCache: true }; const params = new URLSearchParams({ startAt: '0', endAt: Date.now().toString() }); + if (path) params.set('path', `eq.${path}`); - if (path) { - params.set('path', `eq.${path}`); - } - - const res = await fetchWithTimeout( - `${this.apiBase}/websites/${websiteId}/stats?${params.toString()}`, - { - headers: { 'x-umami-share-token': token } - } - ); - - if (!res.ok) { - throw new Error(`获取统计失败: ${res.status}`); - } - - const data = await res.json(); - + const data = await this.authedFetch>(`/stats?${params.toString()}`); const result: StatsResult = { - pageviews: data.pageviews?.value ?? data.pageviews ?? 0, - visitors: data.visitors?.value ?? data.visitors ?? 0, - visits: data.visits?.value ?? data.visits ?? 0 + pageviews: extract(data.pageviews), + visitors: extract(data.visitors), + visits: extract(data.visits) }; + if (data.bounces !== undefined) result.bounces = extract(data.bounces); + if (data.totaltime !== undefined) result.totaltime = extract(data.totaltime); this.cache.set(cacheKey, result); - return result; } - async getSiteStats(): Promise { + getSiteStats(): Promise { return this.getStats(); } - async getPageStats(path: string): Promise { + getPageStats(path: string): Promise { return this.getStats(path); } + async getActiveVisitors(): Promise { + const data = await this.authedFetch<{ visitors?: number }>('/active'); + return typeof data?.visitors === 'number' ? data.visitors : 0; + } + clearCache(): void { this.cache.clear(); this.shareData = null; @@ -214,11 +221,13 @@ class UmamiRuntimeClient { } function mountEmptyClient(): void { + const zeroStats = () => Promise.resolve({ pageviews: 0, visitors: 0, visits: 0 }); (window as typeof window & { oddmisc?: Record }).oddmisc = { - getStats: () => Promise.resolve({ pageviews: 0, visitors: 0, visits: 0 }), - getSiteStats: () => Promise.resolve({ pageviews: 0, visitors: 0, visits: 0 }), - getPageStats: () => Promise.resolve({ pageviews: 0, visitors: 0, visits: 0 }), - clearCache: () => {}, + getStats: zeroStats, + getSiteStats: zeroStats, + getPageStats: zeroStats, + getActiveVisitors: () => Promise.resolve(0), + clearCache: () => {} }; } @@ -229,15 +238,14 @@ export function initUmamiRuntime(config: UmamiRuntimeConfig): void { } else { try { const client = new UmamiRuntimeClient(config); - (window as typeof window & { oddmisc?: Record }).oddmisc = { umami: client, getStats: (path?: string) => client.getStats(path), getSiteStats: () => client.getSiteStats(), getPageStats: (path: string) => client.getPageStats(path), - clearCache: () => client.clearCache(), + getActiveVisitors: () => client.getActiveVisitors(), + clearCache: () => client.clearCache() }; - console.log('[oddmisc] Umami runtime client initialized'); } catch (error) { console.warn('[oddmisc] 初始化失败:', error instanceof Error ? error.message : error); @@ -260,6 +268,7 @@ interface OddmiscReadyEvent extends CustomEvent { getStats: (path?: string) => Promise; getSiteStats: () => Promise; getPageStats: (path: string) => Promise; + getActiveVisitors: () => Promise; clearCache: () => void; }; }; diff --git a/src/utils/umami/cache.ts b/src/utils/umami/cache.ts index 4636dba..d902547 100644 --- a/src/utils/umami/cache.ts +++ b/src/utils/umami/cache.ts @@ -1,6 +1,6 @@ const isBrowser = typeof window !== 'undefined' && typeof localStorage !== 'undefined'; -export class CacheManager { +export class CacheManager { private memoryCache = new Map(); private readonly cacheKey: string; private readonly ttl: number; diff --git a/test/api.test.ts b/test/api.test.ts new file mode 100644 index 0000000..1c1eb9d --- /dev/null +++ b/test/api.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { UmamiAPI } from '../src/modules/umami/api'; +import { CacheManager } from '../src/utils/umami/cache'; +import { UmamiAuthError, UmamiNetworkError } from '../src/errors'; + +const BASE = 'https://umami.example.com/api'; +const SHARE_ID = 'abc123'; + +function mockFetchOk(body: unknown) { + return vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => body + } as unknown as Response)); +} + +describe('UmamiAPI.getStats', () => { + let api: UmamiAPI; + let cache: CacheManager; + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + cache = new CacheManager('api-test', 3600000); + cache.clear(); + api = new UmamiAPI(cache); + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('forwards the path query parameter to the stats endpoint', async () => { + const fetchMock = vi.fn() + // share data + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ websiteId: 'w1', token: 't1' }) } as unknown as Response) + // stats + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ pageviews: 1, visitors: 2, visits: 3 }) } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await api.getStats(BASE, SHARE_ID, { path: 'eq./about' }); + + const statsCall = fetchMock.mock.calls[1][0] as string; + expect(statsCall).toContain('/websites/w1/stats?'); + expect(statsCall).toContain('path=eq.%2Fabout'); + }); + + it('forwards the url query parameter to the stats endpoint', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ websiteId: 'w1', token: 't1' }) } as unknown as Response) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ pageviews: 0, visitors: 0, visits: 0 }) } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await api.getStats(BASE, SHARE_ID, { url: 'https://site.example/page' }); + + const statsCall = fetchMock.mock.calls[1][0] as string; + expect(statsCall).toContain('url=https%3A%2F%2Fsite.example%2Fpage'); + }); + + it('returns cached response on the second call without calling fetch again', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ websiteId: 'w1', token: 't1' }) } as unknown as Response) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ pageviews: 5, visitors: 6, visits: 7 }) } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const first = await api.getStats(BASE, SHARE_ID, { path: 'eq./a' }); + expect(first._fromCache).toBeUndefined(); + + const second = await api.getStats(BASE, SHARE_ID, { path: 'eq./a' }); + expect(second._fromCache).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('throws UmamiAuthError on 401 responses', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ websiteId: 'w1', token: 't1' }) } as unknown as Response) + .mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({}) } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(api.getStats(BASE, SHARE_ID, {})).rejects.toBeInstanceOf(UmamiAuthError); + }); + + it('throws UmamiNetworkError on other non-ok stats responses', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ websiteId: 'w1', token: 't1' }) } as unknown as Response) + .mockResolvedValueOnce({ ok: false, status: 503, json: async () => ({}) } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(api.getStats(BASE, SHARE_ID, {})).rejects.toBeInstanceOf(UmamiNetworkError); + }); + + it('retries share data fetch after a prior failure', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({}) } as unknown as Response) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ websiteId: 'w1', token: 't1' }) } as unknown as Response) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ pageviews: 1, visitors: 1, visits: 1 }) } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(api.getStats(BASE, SHARE_ID, {})).rejects.toBeInstanceOf(UmamiNetworkError); + const result = await api.getStats(BASE, SHARE_ID, {}); + expect(result.pageviews).toBe(1); + }); + + it('sends x-umami-share-token and x-umami-share-context headers on authed requests', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ websiteId: 'w1', token: 'tok-xyz' }) } as unknown as Response) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ pageviews: 1, visitors: 1, visits: 1 }) } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await api.getStats(BASE, SHARE_ID, {}); + + const [, init] = fetchMock.mock.calls[1]; + const headers = (init as RequestInit).headers as Record; + expect(headers['x-umami-share-token']).toBe('tok-xyz'); + expect(headers['x-umami-share-context']).toBe('1'); + }); + + it('clears sharePromise on 401 so next call refetches share data', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ websiteId: 'w1', token: 't1' }) } as unknown as Response) + .mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({}) } as unknown as Response) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ websiteId: 'w1', token: 't2' }) } as unknown as Response) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ pageviews: 1, visitors: 1, visits: 1 }) } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(api.getStats(BASE, SHARE_ID, {})).rejects.toBeInstanceOf(UmamiAuthError); + const result = await api.getStats(BASE, SHARE_ID, {}); + expect(result.pageviews).toBe(1); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); +}); + +describe('UmamiAPI additional endpoints', () => { + let api: UmamiAPI; + let cache: CacheManager; + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + cache = new CacheManager('api-extra', 3600000); + cache.clear(); + api = new UmamiAPI(cache); + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('getActiveVisitors hits /active with required headers', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ websiteId: 'w1', token: 't1' }) } as unknown as Response) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ visitors: 7 }) } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const r = await api.getActiveVisitors(BASE, SHARE_ID); + expect(r.visitors).toBe(7); + expect((fetchMock.mock.calls[1][0] as string)).toContain('/websites/w1/active'); + const headers = (fetchMock.mock.calls[1][1] as RequestInit).headers as Record; + expect(headers['x-umami-share-context']).toBe('1'); + }); + + it('getPageviews forwards startAt/endAt/unit/timezone', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ websiteId: 'w1', token: 't1' }) } as unknown as Response) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ pageviews: [], sessions: [] }) } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await api.getPageviews(BASE, SHARE_ID, { startAt: 1000, endAt: 2000, unit: 'hour', timezone: 'Asia/Shanghai' }); + + const url = fetchMock.mock.calls[1][0] as string; + expect(url).toContain('/websites/w1/pageviews?'); + expect(url).toContain('startAt=1000'); + expect(url).toContain('endAt=2000'); + expect(url).toContain('unit=hour'); + expect(url).toContain('timezone=Asia%2FShanghai'); + }); + + it('getMetrics forwards type and limit and parses array response', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ websiteId: 'w1', token: 't1' }) } as unknown as Response) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ([{ x: 'CN', y: 12 }, { x: 'US', y: 4 }]) } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const res = await api.getMetrics(BASE, SHARE_ID, 'country', { limit: 5 }); + expect(res).toEqual([{ x: 'CN', y: 12 }, { x: 'US', y: 4 }]); + const url = fetchMock.mock.calls[1][0] as string; + expect(url).toContain('type=country'); + expect(url).toContain('limit=5'); + }); + + it('getMetrics caches the array response', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ websiteId: 'w1', token: 't1' }) } as unknown as Response) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ([{ x: '/', y: 1 }]) } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await api.getMetrics(BASE, SHARE_ID, 'path', { startAt: 0, endAt: 1, limit: 3 }); + const again = await api.getMetrics(BASE, SHARE_ID, 'path', { startAt: 0, endAt: 1, limit: 3 }); + expect(again).toEqual([{ x: '/', y: 1 }]); + expect(fetchMock).toHaveBeenCalledTimes(2); // share + 1 stats; second call hits cache + }); + + it('getWebsite & getDateRange hit the expected paths', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ websiteId: 'w1', token: 't1' }) } as unknown as Response) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ id: 'w1', name: 'site', domain: 'x.com', shareId: null, createdAt: '', updatedAt: '', resetAt: null }) } as unknown as Response) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ startDate: '2025-01-01T00:00:00Z', endDate: '2026-01-01T00:00:00Z' }) } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const w = await api.getWebsite(BASE, SHARE_ID); + const d = await api.getDateRange(BASE, SHARE_ID); + expect(w.name).toBe('site'); + expect(d.startDate).toContain('2025'); + expect((fetchMock.mock.calls[1][0] as string)).toMatch(/\/websites\/w1$/); + expect((fetchMock.mock.calls[2][0] as string)).toContain('/websites/w1/daterange'); + }); +}); diff --git a/test/cache-ttl.test.ts b/test/cache-ttl.test.ts new file mode 100644 index 0000000..51ca924 --- /dev/null +++ b/test/cache-ttl.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { CacheManager } from '../src/utils/umami/cache'; + +describe('CacheManager TTL', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('expires values after the configured TTL', () => { + vi.setSystemTime(new Date('2024-01-01T00:00:00Z')); + const cache = new CacheManager('ttl-test', 1000); + cache.clear(); + + cache.set('k', 'v'); + expect(cache.get('k')).toBe('v'); + + vi.setSystemTime(new Date('2024-01-01T00:00:02Z')); + expect(cache.get('k')).toBeNull(); + }); + + it('keeps values while within the TTL window', () => { + vi.setSystemTime(new Date('2024-01-01T00:00:00Z')); + const cache = new CacheManager('ttl-test-2', 5000); + cache.clear(); + + cache.set('k', { n: 1 }); + vi.setSystemTime(new Date('2024-01-01T00:00:03Z')); + expect(cache.get('k')).toEqual({ n: 1 }); + }); +}); diff --git a/test/client.test.ts b/test/client.test.ts new file mode 100644 index 0000000..9aca4c3 --- /dev/null +++ b/test/client.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { UmamiClient, createUmamiClient } from '../src/modules/umami/client'; +import { UmamiUrlError } from '../src/errors'; + +const SHARE_URL = 'https://umami.example.com/share/abc123'; + +describe('UmamiClient', () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('throws UmamiUrlError when shareUrl is missing', () => { + expect(() => new UmamiClient({ shareUrl: '' as unknown as string })).toThrow(UmamiUrlError); + }); + + it('createUmamiClient returns a UmamiClient instance', () => { + const client = createUmamiClient({ shareUrl: SHARE_URL }); + expect(client).toBeInstanceOf(UmamiClient); + }); + + it('normalises stats responses that wrap values in { value }', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ websiteId: 'w1', token: 't1' }) } as unknown as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ pageviews: { value: 11 }, visitors: { value: 22 }, visits: { value: 33 } }) + } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const client = createUmamiClient({ shareUrl: SHARE_URL }); + client.clearCache(); + const result = await client.getSiteStats(); + expect(result).toMatchObject({ pageviews: 11, visitors: 22, visits: 33 }); + }); + + it('sends a url filter when calling getPageStatsByUrl', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ websiteId: 'w1', token: 't1' }) } as unknown as Response) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ pageviews: 1, visitors: 1, visits: 1 }) } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const client = createUmamiClient({ shareUrl: SHARE_URL }); + client.clearCache(); + await client.getPageStatsByUrl('https://site.example/page'); + + const statsCall = fetchMock.mock.calls[1][0] as string; + expect(statsCall).toContain('url=https%3A%2F%2Fsite.example%2Fpage'); + }); + + it('exposes bounces / totaltime / comparison from stats response', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ websiteId: 'w1', token: 't1' }) } as unknown as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + pageviews: 57, + visitors: 19, + visits: 22, + bounces: 13, + totaltime: 1714, + comparison: { pageviews: 100, visitors: 10, visits: 20, bounces: 5, totaltime: 500 } + }) + } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const client = createUmamiClient({ shareUrl: SHARE_URL }); + client.clearCache(); + const result = await client.getSiteStats(); + expect(result.bounces).toBe(13); + expect(result.totaltime).toBe(1714); + expect(result.comparison).toEqual({ pageviews: 100, visitors: 10, visits: 20, bounces: 5, totaltime: 500 }); + }); + + it('getActiveVisitors returns the visitor count as a number', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ websiteId: 'w1', token: 't1' }) } as unknown as Response) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ visitors: 42 }) } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const client = createUmamiClient({ shareUrl: SHARE_URL }); + client.clearCache(); + expect(await client.getActiveVisitors()).toBe(42); + }); + + it('getMetrics returns the Umami [{x,y}] array unchanged', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ websiteId: 'w1', token: 't1' }) } as unknown as Response) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ([{ x: '/', y: 12 }, { x: '/posts', y: 5 }]) } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const client = createUmamiClient({ shareUrl: SHARE_URL }); + client.clearCache(); + const top = await client.getMetrics('path', { limit: 10 }); + expect(top).toEqual([{ x: '/', y: 12 }, { x: '/posts', y: 5 }]); + const url = fetchMock.mock.calls[1][0] as string; + expect(url).toContain('type=path'); + expect(url).toContain('limit=10'); + }); + + it('getWebsite returns metadata', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ websiteId: 'w1', token: 't1' }) } as unknown as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ id: 'w1', name: 'Fuwari', domain: 'fuwari.oh1.top', shareId: 'abc123', createdAt: '', updatedAt: '', resetAt: null }) + } as unknown as Response); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const client = createUmamiClient({ shareUrl: SHARE_URL }); + client.clearCache(); + const info = await client.getWebsite(); + expect(info.name).toBe('Fuwari'); + expect(info.domain).toBe('fuwari.oh1.top'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 8121a57..5ebaad5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,11 @@ "moduleResolution": "node", "esModuleInterop": true, "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "declaration": true,