From fe6f723f76563953ce492e7e8f6a95fff51b0a95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=89=A2=E5=BA=9F=E7=89=A9?= Date: Mon, 5 Oct 2026 09:41:13 +0800 Subject: [PATCH] Enable MCP and restore live analytics with D1 fallback --- .github/workflows/i18n.yml | 2 +- README.md | 4 ++ migrations/schema.sql | 6 +++ package.json | 3 +- scripts/test-services.mjs | 11 +++++ src/client/common.ts | 21 ++++++++-- src/index.ts | 22 ++++------ src/routes/analytics.ts | 23 ++++++----- src/routes/wiki.ts | 9 ++++- src/utils/analytics.ts | 4 ++ src/utils/localAnalytics.ts | 81 +++++++++++++++++++++++++++++++++++++ tests/services.test.ts | 40 ++++++++++++++++++ wrangler.production.toml | 2 +- 13 files changed, 194 insertions(+), 34 deletions(-) create mode 100644 scripts/test-services.mjs create mode 100644 src/utils/localAnalytics.ts create mode 100644 tests/services.test.ts diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml index d8c77c3..02cc638 100644 --- a/.github/workflows/i18n.yml +++ b/.github/workflows/i18n.yml @@ -14,6 +14,6 @@ jobs: cache: npm - run: npm ci - run: node scripts/prepare-production.mjs - - run: npm run typecheck && npm run typecheck:client && npm run test:i18n && npm run test:permissions && npm run test:git + - run: npm run typecheck && npm run typecheck:client && npm run test:i18n && npm run test:permissions && npm run test:git && npm run test:services - run: npm run build - run: I18N_CHECK_BUILD=1 npm run test:i18n diff --git a/README.md b/README.md index 3cb5e7d..fb58918 100644 --- a/README.md +++ b/README.md @@ -31,3 +31,7 @@ VireoWiki 当前版本采用 [Mozilla Public License 2.0](LICENSE)(MPL-2.0) 后台“服务条款与隐私政策”提供两份可编辑的 Markdown 初始模板,发布后作为普通 Wiki 文档保留修订历史。页脚入口为 `/terms`、`/privacy`,使用站点配置中的协议文档地址;请按本站实际服务修改模板。 文档的“更多 → Git 提交”支持使用标准 Git 客户端提交 `page.md`,详见 [Git 编辑说明](GIT_EDITING.md)。网页与 Git 共用 Wiki 权限和编辑校验,服务器拒绝所有非快进覆盖及分支删除。 + +统计功能在未配置 Analytics Engine 或其查询凭据时使用 D1 后备实现,成功的文档 API 读取(包括单页导航)记录访问量,编辑器读取和私有/删除页面不计入公开热门。后台统计同样可直接使用;小时汇总保留约 90 天,文档累计访问量单独保留。后备性能百分位使用响应时间直方图上界估算。历史上没有记录的访问不能补算。 + +生产配置默认启用 MCP(`MCP_MODE = "open"`):公共阅读工具可连接 `/api/mcp`,编辑操作仍需有效身份及相应权限。可通过配置设为 `disabled` 关闭。 diff --git a/migrations/schema.sql b/migrations/schema.sql index e200056..313f6a3 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -680,3 +680,9 @@ CREATE TABLE IF NOT EXISTS git_commit_links ( page_id INTEGER NOT NULL, oid TEXT NOT NULL, revision_id INTEGER, user_id INTEGER, PRIMARY KEY(page_id, oid) ); + +-- Local analytics fallback: hourly aggregates and lifetime page totals. +CREATE TABLE IF NOT EXISTS wiki_analytics_hourly (hour INTEGER NOT NULL, key TEXT NOT NULL, type TEXT NOT NULL, page_id INTEGER NOT NULL DEFAULT 0, referrer TEXT NOT NULL, country TEXT NOT NULL, device TEXT NOT NULL, query TEXT NOT NULL, error_message TEXT NOT NULL, path TEXT NOT NULL, status_code INTEGER NOT NULL, duration_bucket INTEGER NOT NULL, events INTEGER NOT NULL DEFAULT 1, duration_sum REAL NOT NULL DEFAULT 0, PRIMARY KEY(hour,key)); +CREATE INDEX IF NOT EXISTS wiki_analytics_time ON wiki_analytics_hourly(type,hour); +CREATE INDEX IF NOT EXISTS wiki_analytics_expiration ON wiki_analytics_hourly(hour); +CREATE TABLE IF NOT EXISTS wiki_page_view_totals (page_id INTEGER PRIMARY KEY, views INTEGER NOT NULL DEFAULT 0); diff --git a/package.json b/package.json index 2efde76..4e52a7d 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "deploy:do": "wrangler deploy -c do-worker/wrangler.toml", "test:i18n": "node scripts/test-i18n.mjs", "test:permissions": "node scripts/test-permissions.mjs", - "test:git": "node scripts/test-git.mjs" + "test:git": "node scripts/test-git.mjs", + "test:services": "node scripts/test-services.mjs" }, "dependencies": { "@block65/webcrypto-web-push": "^1.0.2", diff --git a/scripts/test-services.mjs b/scripts/test-services.mjs new file mode 100644 index 0000000..c8f73c6 --- /dev/null +++ b/scripts/test-services.mjs @@ -0,0 +1,11 @@ +import { build } from 'esbuild'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +const dir = await mkdtemp(join(tmpdir(), 'vireo-services-')); +try { + const outfile = join(dir, 'services.test.mjs'); + await build({ entryPoints: ['tests/services.test.ts'], outfile, bundle: true, platform: 'node', format: 'esm', banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" } }); + process.exitCode = spawnSync(process.execPath, ['--test', outfile], { stdio: 'inherit' }).status ?? 1; +} finally { await rm(dir, { recursive: true, force: true }); } diff --git a/src/client/common.ts b/src/client/common.ts index 477fd23..5e37edf 100644 --- a/src/client/common.ts +++ b/src/client/common.ts @@ -1692,7 +1692,13 @@ async function loadRecentChanges() { // ── 실시간 트렌딩 로드 ── // ── 실시간 트렌딩 로드 ── +const trendingCleanup = new WeakMap(); +let trendingRefresh = null; +let trendingLoading = false; async function loadTrending() { + if (!trendingRefresh) trendingRefresh = setInterval(() => { if (!document.hidden && document.querySelector('.trending-container')) void loadTrending(); }, 30000); + if (trendingLoading) return; + trendingLoading = true; try { const res = await fetch('/api/analytics/trending?limit=10'); if (!res.ok) return; @@ -1713,14 +1719,17 @@ async function loadTrending() { const content = data.trending && data.trending.length > 0 ? html : emptyMsg; document.querySelectorAll('.trending-container').forEach(el => { + if (el._trendingContent === content) return; + trendingCleanup.get(el)?.(); + el._trendingContent = content; el.innerHTML = content; if (data.trending && data.trending.length > 0) { initTrendingTicker(el, Math.min(data.trending.length, 10)); } }); } catch (e) { - // 무시 - } + // Retry during the next refresh. + } finally { trendingLoading = false; } } function initTrendingTicker(container, count) { @@ -1748,6 +1757,7 @@ function initTrendingTicker(container, count) { container.style.left = '0'; container.style.width = '100%'; container.style.transition = 'transform 0.4s ease'; + container.style.transform = 'translateY(0)'; let currentIndex = 0; let tickerInterval = setInterval(slideNext, 3000); @@ -1768,7 +1778,7 @@ function initTrendingTicker(container, count) { container.style.transform = `translateY(-${currentIndex * itemHeight}px)`; } - window.addEventListener('resize', () => { + const resizeHandler = () => { const nextHeight = getItemHeight(); if (nextHeight === itemHeight) return; itemHeight = nextHeight; @@ -1777,7 +1787,9 @@ function initTrendingTicker(container, count) { } else { applyFoldedState(); } - }, { passive: true }); + }; + window.addEventListener('resize', resizeHandler, { passive: true }); + trendingCleanup.set(container, () => { clearInterval(tickerInterval); window.removeEventListener('resize', resizeHandler); }); const section = container.closest('.sidebar-section'); const expandBtn = section ? section.querySelector('.trending-expand-btn') : null; @@ -1786,6 +1798,7 @@ function initTrendingTicker(container, count) { // 클릭 시 이벤트 전파 방지 등을 고려해 다시 세팅 const clone = expandBtn.cloneNode(true); expandBtn.replaceWith(clone); + clone.innerHTML = ui("m_06864158538ff797"); clone.addEventListener('click', (e) => { e.preventDefault(); diff --git a/src/index.ts b/src/index.ts index e7f2407..a118bcb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { localTrending, localPageViews } from './utils/localAnalytics'; import gitRoutes from './routes/git'; import { HTTPException } from 'hono/http-exception'; import permissionGroupsRoutes from './routes/permissionGroups'; @@ -33,7 +34,7 @@ import analyticsRoutes from './routes/analytics'; import blogRoutes from './routes/blog'; import exploreRoutes from './routes/explore'; import qrLoginRoutes from './routes/qr-login'; -import { trackPageView, trackError, queryAnalytics } from './utils/analytics'; +import { trackError, queryAnalytics } from './utils/analytics'; import { isR2OnlyNamespace, isMapNamespace, normalizeSlug } from './utils/slug'; import { getEnabledExtensions } from './utils/extensions'; import { getRevisionContent } from './utils/r2'; @@ -194,6 +195,7 @@ app.get('/sw.js', async (c) => { // ── 공개 Analytics API (인기 문서, 문서별 조회수) ── app.get('/api/analytics/trending', async (c) => { + if (c.env.WIKI_VISIBILITY === 'closed' && !c.get('user')) return c.json({ error: 'Authentication required' }, 401); const cache = caches.default; const cacheKey = c.req.url; @@ -205,7 +207,7 @@ app.get('/api/analytics/trending', async (c) => { const accountId = c.env.CF_ACCOUNT_ID; const apiToken = c.env.CF_API_TOKEN; - if (!accountId || !apiToken) return c.json({ trending: [] }); + if (!accountId || !apiToken || !c.env.ANALYTICS) return c.json({ trending: await localTrending(c.env.DB, Math.min(72, Math.max(1, Number(c.req.query('hours')) || 24)), Math.min(20, Math.max(1, Number(c.req.query('limit')) || 10))) }, 200, { 'Cache-Control': 'no-store' }); const hours = Math.min(72, Math.max(1, Number(c.req.query('hours')) || 24)); const limit = Math.min(20, Math.max(1, Number(c.req.query('limit')) || 10)); @@ -234,7 +236,7 @@ app.get('/api/analytics/trending', async (c) => { app.get('/api/analytics/page-views/:slug', requireAdmin, async (c) => { const accountId = c.env.CF_ACCOUNT_ID; const apiToken = c.env.CF_API_TOKEN; - if (!accountId || !apiToken) return c.json({ total: 0, recent: 0 }); + if (!accountId || !apiToken || !c.env.ANALYTICS) return c.json(await localPageViews(c.env.DB,c.req.param('slug')),200,{'Cache-Control':'no-store'}); const slug = c.req.param('slug'); // Analytics Engine 쿼리는 파라미터 바인딩이 없어 문자열 보간을 쓴다. 닫는 따옴표 @@ -503,7 +505,7 @@ app.get('/w/*', async (c) => { if (canUseCache && !isMapNamespace(slug)) { const cached = await cache.match(ssrCacheKey); if (cached) { - trackPageView(c, slug, Date.now() - startTime); + return new Response(cached.body, cached); } } @@ -519,7 +521,7 @@ app.get('/w/*', async (c) => { if (!user && canUseCache && permsQueryRaw == null) { const cached = await cache.match(ssrCacheKey); if (cached) { - trackPageView(c, slug, Date.now() - startTime); + return new Response(cached.body, cached); } } @@ -787,11 +789,6 @@ ${contentBlock} // 크롤러: 본문(마크다운)이 보이는 미니멀 HTML로 응답 // renderForAI 결과는 그대로 마크다운이므로 escape 후
에 넣어 전달한다.
         if (isCrawler) {
-            // 관리자 열람 전용 비공개 문서는 Analytics Engine 통계에서 완전히 제외
-            // (sourceWasPrivate: 비공개 슬러그가 public 으로 redirect 된 진입 경로도 함께 차단)
-            if (page.is_private !== 1 && !sourceWasPrivate) {
-                trackPageView(c, page.slug, Date.now() - startTime);
-            }
             // 표시 이름은 title 우선, 호출/공식 식별자는 slug. 둘 다 크롤러에 노출해 검색 색인성 유지.
             const displayName = page.title || page.slug;
             const title = `${displayName} - ${wikiName}`;
@@ -856,11 +853,6 @@ ${contentBlock}
     const response = await renderHtml(c, '/', ssrData);
 
     // Analytics: 문서 조회 추적 (존재하는 문서만)
-    // 관리자 열람 전용 비공개 문서는 Analytics Engine 통계에서 완전히 제외
-    // (sourceWasPrivate: 비공개 슬러그가 public 으로 redirect 된 진입 경로도 함께 차단)
-    if (!ssrData._ssrNotFound && ssrData.is_private !== 1 && !sourceWasPrivate) {
-        trackPageView(c, ssrData.slug || slug, Date.now() - startTime);
-    }
 
     // 4) 공개 문서이면 Edge 캐시에 24시간 저장
     if (shouldCache) {
diff --git a/src/routes/analytics.ts b/src/routes/analytics.ts
index f18aa07..bedddaf 100644
--- a/src/routes/analytics.ts
+++ b/src/routes/analytics.ts
@@ -1,3 +1,4 @@
+import { localDashboard } from '../utils/localAnalytics';
 import { ui } from '../i18n/server';
 import { Hono } from 'hono';
 import { requireAdmin } from '../middleware/session';
@@ -14,7 +15,7 @@ const DATASET = 'cloudwiki';
 function getAnalyticsCredentials(c: any): { accountId: string; apiToken: string } | null {
     const accountId = (c.env.CF_ACCOUNT_ID || '').trim();
     const apiToken = (c.env.CF_API_TOKEN || '').trim();
-    if (!accountId || !apiToken) return null;
+    if (!accountId || !apiToken || !c.env.ANALYTICS) return null;
     return { accountId, apiToken };
 }
 
@@ -34,7 +35,7 @@ function credentialError(c: any) {
  */
 analyticsRoutes.get('/overview', async (c) => {
     const creds = getAnalyticsCredentials(c);
-    if (!creds) return credentialError(c);
+    if (!creds) return c.json(await localDashboard(c));
 
     const period = c.req.query('period') || '7d';
     const days = period === '90d' ? 90 : period === '30d' ? 30 : 7;
@@ -74,7 +75,7 @@ analyticsRoutes.get('/overview', async (c) => {
  */
 analyticsRoutes.get('/pages', async (c) => {
     const creds = getAnalyticsCredentials(c);
-    if (!creds) return credentialError(c);
+    if (!creds) return c.json(await localDashboard(c));
 
     const period = c.req.query('period') || '7d';
     const limit = Math.min(100, Math.max(1, Number(c.req.query('limit')) || 20));
@@ -102,7 +103,7 @@ analyticsRoutes.get('/pages', async (c) => {
  */
 analyticsRoutes.get('/trending', async (c) => {
     const creds = getAnalyticsCredentials(c);
-    if (!creds) return credentialError(c);
+    if (!creds) return c.json(await localDashboard(c));
 
     const hours = Math.min(72, Math.max(1, Number(c.req.query('hours')) || 24));
 
@@ -128,7 +129,7 @@ analyticsRoutes.get('/trending', async (c) => {
  */
 analyticsRoutes.get('/referrers', async (c) => {
     const creds = getAnalyticsCredentials(c);
-    if (!creds) return credentialError(c);
+    if (!creds) return c.json(await localDashboard(c));
 
     const period = c.req.query('period') || '7d';
     const limit = Math.min(100, Math.max(1, Number(c.req.query('limit')) || 20));
@@ -156,7 +157,7 @@ analyticsRoutes.get('/referrers', async (c) => {
  */
 analyticsRoutes.get('/countries', async (c) => {
     const creds = getAnalyticsCredentials(c);
-    if (!creds) return credentialError(c);
+    if (!creds) return c.json(await localDashboard(c));
 
     const period = c.req.query('period') || '7d';
     const days = period === '90d' ? 90 : period === '30d' ? 30 : 7;
@@ -183,7 +184,7 @@ analyticsRoutes.get('/countries', async (c) => {
  */
 analyticsRoutes.get('/devices', async (c) => {
     const creds = getAnalyticsCredentials(c);
-    if (!creds) return credentialError(c);
+    if (!creds) return c.json(await localDashboard(c));
 
     const period = c.req.query('period') || '7d';
     const days = period === '90d' ? 90 : period === '30d' ? 30 : 7;
@@ -208,7 +209,7 @@ analyticsRoutes.get('/devices', async (c) => {
  */
 analyticsRoutes.get('/searches', async (c) => {
     const creds = getAnalyticsCredentials(c);
-    if (!creds) return credentialError(c);
+    if (!creds) return c.json(await localDashboard(c));
 
     const period = c.req.query('period') || '7d';
     const limit = Math.min(100, Math.max(1, Number(c.req.query('limit')) || 30));
@@ -236,7 +237,7 @@ analyticsRoutes.get('/searches', async (c) => {
  */
 analyticsRoutes.get('/errors', async (c) => {
     const creds = getAnalyticsCredentials(c);
-    if (!creds) return credentialError(c);
+    if (!creds) return c.json(await localDashboard(c));
 
     const period = c.req.query('period') || '7d';
     const limit = Math.min(100, Math.max(1, Number(c.req.query('limit')) || 30));
@@ -265,7 +266,7 @@ analyticsRoutes.get('/errors', async (c) => {
  */
 analyticsRoutes.get('/performance', async (c) => {
     const creds = getAnalyticsCredentials(c);
-    if (!creds) return credentialError(c);
+    if (!creds) return c.json(await localDashboard(c));
 
     const period = c.req.query('period') || '7d';
     const days = period === '90d' ? 90 : period === '30d' ? 30 : 7;
@@ -305,7 +306,7 @@ analyticsRoutes.get('/performance', async (c) => {
  */
 analyticsRoutes.get('/page/:slug', async (c) => {
     const creds = getAnalyticsCredentials(c);
-    if (!creds) return credentialError(c);
+    if (!creds) return c.json(await localDashboard(c));
 
     const slug = c.req.param('slug');
     const period = c.req.query('period') || '7d';
diff --git a/src/routes/wiki.ts b/src/routes/wiki.ts
index 44453dc..b626fee 100644
--- a/src/routes/wiki.ts
+++ b/src/routes/wiki.ts
@@ -1,3 +1,4 @@
+import { trackPageView } from '../utils/analytics';
 import { ui } from '../i18n/server';
 import { Hono, type Context } from 'hono';
 import type { Env, Page, Revision, User } from '../types';
@@ -1537,7 +1538,13 @@ wiki.get('/w/wiki-stats', async (c) => {
  * - 리다이렉트 처리: 문서가 없고 리다이렉트가 존재하면 대상 문서 반환 (redirected_from 포함)
  * - 비공개 문서: 관리자만 접근 가능
  */
-wiki.get('/w/:slug', async (c) => {
+wiki.get('/w/:slug', async (c, next) => {
+    const start = Date.now(); await next();
+    if (c.res.status === 200 && c.req.query('for_edit') !== 'true') {
+        const result = await c.res.clone().json() as {slug?:string; id?:number; is_private?:boolean; deleted_at?:number};
+        if (result.id && result.slug && !result.is_private && !result.deleted_at) trackPageView(c, result.slug, Date.now()-start);
+    }
+}, async (c) => {
     if (c.env.WIKI_VISIBILITY === 'closed' && !c.get('user')) {
         return c.json({ error: ui("m_640f50ae9c6b8e41") }, 401);
     }
diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts
index 49bf05a..4de77ca 100644
--- a/src/utils/analytics.ts
+++ b/src/utils/analytics.ts
@@ -1,3 +1,4 @@
+import { recordLocalAnalytics } from './localAnalytics';
 import { ui } from '../i18n/server';
 import type { Context } from 'hono';
 import type { Env } from '../types';
@@ -29,6 +30,7 @@ function detectDevice(ua: string): string {
 
 export function trackPageView(c: Context, slug: string, responseTimeMs: number = 0) {
     if (slug.startsWith('이미지:')) return;
+    c.executionCtx.waitUntil(recordLocalAnalytics(c, 'pageview', slug, responseTimeMs).catch(error => console.error('Local analytics write failed:', error)));
     try {
         const analytics = c.env.ANALYTICS;
         if (!analytics) return;
@@ -62,6 +64,7 @@ export function trackPageView(c: Context, slug: string, responseTimeMs: num
 }
 
 export function trackSearch(c: Context, query: string, resultCount: number, responseTimeMs: number = 0) {
+    c.executionCtx.waitUntil(recordLocalAnalytics(c, 'search', query, responseTimeMs).catch(error => console.error('Local analytics write failed:', error)));
     try {
         const analytics = c.env.ANALYTICS;
         if (!analytics) return;
@@ -95,6 +98,7 @@ export function trackSearch(c: Context, query: string, resultCount: number,
 }
 
 export function trackError(c: Context, path: string, statusCode: number, errorMessage: string, responseTimeMs: number = 0) {
+    c.executionCtx.waitUntil(recordLocalAnalytics(c, 'error', path, responseTimeMs, statusCode, errorMessage).catch(error => console.error('Local analytics write failed:', error)));
     try {
         const analytics = c.env.ANALYTICS;
         if (!analytics) return;
diff --git a/src/utils/localAnalytics.ts b/src/utils/localAnalytics.ts
new file mode 100644
index 0000000..9c03c4b
--- /dev/null
+++ b/src/utils/localAnalytics.ts
@@ -0,0 +1,81 @@
+import type { Context } from 'hono';
+import type { Env } from '../types';
+const schema = [
+`CREATE TABLE IF NOT EXISTS wiki_analytics_hourly (hour INTEGER NOT NULL, key TEXT NOT NULL, type TEXT NOT NULL, page_id INTEGER NOT NULL DEFAULT 0, referrer TEXT NOT NULL, country TEXT NOT NULL, device TEXT NOT NULL, query TEXT NOT NULL, error_message TEXT NOT NULL, path TEXT NOT NULL, status_code INTEGER NOT NULL, duration_bucket INTEGER NOT NULL, events INTEGER NOT NULL DEFAULT 1, duration_sum REAL NOT NULL DEFAULT 0, PRIMARY KEY(hour,key))`,
+`CREATE INDEX IF NOT EXISTS wiki_analytics_time ON wiki_analytics_hourly(type,hour)`,
+`CREATE INDEX IF NOT EXISTS wiki_analytics_expiration ON wiki_analytics_hourly(hour)`,
+`CREATE TABLE IF NOT EXISTS wiki_page_view_totals (page_id INTEGER PRIMARY KEY, views INTEGER NOT NULL DEFAULT 0)`,
+];
+const pruned = new WeakMap();
+const ready = new WeakMap>();
+export function ensureLocalAnalytics(db: D1Database) {
+    let promise = ready.get(db);
+    if (!promise) { promise = db.batch(schema.map(sql => db.prepare(sql))).then(() => {}); ready.set(db, promise); promise.catch(() => ready.delete(db)); }
+    return promise;
+}
+function referrerOrigin(value: string) { try { return new URL(value).origin.slice(0,160); } catch { return ''; } }
+export async function recordLocalAnalytics(c: Context, type: 'pageview'|'search'|'error', value: string, duration = 0, status = 200, message = '') {
+    const db = c.env.DB; await ensureLocalAnalytics(db);
+    let pageId = 0;
+    if (type === 'pageview') {
+        const page = await db.prepare('SELECT id FROM pages WHERE slug=? AND is_private=0 AND deleted_at IS NULL').bind(value).first<{id:number}>();
+        if (!page) return; pageId = page.id;
+    }
+    const ua = c.req.header('User-Agent') || '';
+    if (type === 'pageview' && /bot|crawler|spider|preview/i.test(ua)) return;
+    const country = String((c.req.raw.cf as any)?.country || '').slice(0,2);
+    const device = /mobile|android|iphone|ipad/i.test(ua) ? 'mobile' : 'desktop';
+    const referrer = referrerOrigin(c.req.header('Referer') || '');
+    const query = type === 'search' ? value.slice(0,200) : '';
+    const path = type === 'error' ? value.split('?')[0].slice(0,300) : '';
+    const error = type === 'error' ? message.slice(0,300) : '';
+    const ms = Number.isFinite(duration) ? Math.min(60000,Math.max(0,duration)) : 0;
+    const bucket = [25,50,100,250,500,1000,2500,5000,10000,30000,60000].find(b => ms <= b)!;
+    const hour = Math.floor(Date.now()/3600000)*3600;
+    if (pruned.get(db) !== hour) { pruned.set(db,hour); await db.prepare('DELETE FROM wiki_analytics_hourly WHERE hourb.toString(16).padStart(2,'0')).join('');
+    const write = db.prepare(`INSERT INTO wiki_analytics_hourly(hour,key,type,page_id,referrer,country,device,query,error_message,path,status_code,duration_bucket,duration_sum) SELECT ?,?,?,?,?,?,?,?,?,?,?,?,? WHERE ?=0 OR EXISTS(SELECT 1 FROM pages WHERE id=? AND is_private=0 AND deleted_at IS NULL) ON CONFLICT(hour,key) DO UPDATE SET events=events+1,duration_sum=duration_sum+excluded.duration_sum`)
+        .bind(hour,key,type,pageId,referrer,country,device,query,error,path,status,bucket,ms,pageId,pageId);
+    const statements = [write];
+    if (pageId) statements.push(db.prepare(`INSERT INTO wiki_page_view_totals(page_id,views) SELECT ?,1 WHERE EXISTS(SELECT 1 FROM pages WHERE id=? AND is_private=0 AND deleted_at IS NULL) ON CONFLICT(page_id) DO UPDATE SET views=views+1`).bind(pageId,pageId));
+    await db.batch(statements);
+}
+const PUBLIC = `FROM wiki_analytics_hourly a JOIN pages p ON p.id=a.page_id WHERE a.type='pageview' AND p.is_private=0 AND p.deleted_at IS NULL`;
+export async function localTrending(db: D1Database, hours=24, limit=10) {
+    await ensureLocalAnalytics(db);
+    return (await db.prepare(`SELECT p.slug,SUM(a.events) AS views ${PUBLIC} AND a.hour>=? GROUP BY p.id ORDER BY views DESC,p.slug LIMIT ?`).bind(Math.floor(Date.now()/3600000)*3600-hours*3600,limit).all()).results;
+}
+export async function localPageViews(db:D1Database,slug:string) {
+    await ensureLocalAnalytics(db);
+    const total = await db.prepare(`SELECT COALESCE(t.views,0) AS total FROM pages p LEFT JOIN wiki_page_view_totals t ON t.page_id=p.id WHERE p.slug=? AND p.is_private=0 AND p.deleted_at IS NULL`).bind(slug).first<{total:number}>();
+    const recent = await db.prepare(`SELECT COALESCE(SUM(a.events),0) AS recent ${PUBLIC} AND p.slug=? AND a.hour>=?`).bind(slug,Math.floor(Date.now()/1000)-7*86400).first<{recent:number}>();
+    return {total:total?.total||0,recent:recent?.recent||0};
+}
+export async function localDashboard(c: Context) {
+    const db = c.env.DB; await ensureLocalAnalytics(db);
+    const endpoint=c.req.path.split('/analytics/')[1], days=c.req.query('period')==='90d'?90:c.req.query('period')==='30d'?30:7;
+    const since=Math.floor(Date.now()/1000)-days*86400,limit=Math.min(100,Math.max(1,Number(c.req.query('limit'))||30));
+    const all=async(sql:string,params:any[]=[]) => (await db.prepare(sql).bind(...params).all()).results;
+    const daily=()=>all(`SELECT date(a.hour,'unixepoch') AS date,SUM(a.events) AS views ${PUBLIC} AND a.hour>=? GROUP BY date ORDER BY date`,[since]);
+    if(endpoint==='trending')return {trending:await localTrending(db,Math.min(72,Math.max(1,Number(c.req.query('hours'))||24)),20)};
+    if(endpoint==='overview')return {summary:await db.prepare(`SELECT COALESCE(SUM(a.events),0) AS total_views,COALESCE(SUM(a.events),0) AS sampled_views,COUNT(DISTINCT NULLIF(a.country,'')) AS unique_countries ${PUBLIC} AND a.hour>=?`).bind(since).first(),daily:await daily()};
+    if(endpoint==='pages')return {pages:await localTrending(db,days*24,limit)};
+    if(['referrers','countries','devices'].includes(endpoint)) {
+        const column=endpoint==='referrers'?'referrer':endpoint==='countries'?'country':'device';
+        return {[endpoint]:await all(`SELECT a.${column} AS ${column==='referrer'?'referer':column},SUM(a.events) AS views ${PUBLIC} AND a.hour>=? AND a.${column}!='' GROUP BY a.${column} ORDER BY views DESC LIMIT ?`,[since,limit])};
+    }
+    if(endpoint==='searches')return {searches:await all(`SELECT query,SUM(events) AS count FROM wiki_analytics_hourly WHERE type='search' AND hour>=? GROUP BY query ORDER BY count DESC LIMIT ?`,[since,limit])};
+    if(endpoint==='errors')return {errors:await all(`SELECT path,error_message,status_code,SUM(events) AS count FROM wiki_analytics_hourly WHERE type='error' AND hour>=? GROUP BY path,error_message,status_code ORDER BY count DESC LIMIT ?`,[since,limit])};
+    if(endpoint==='performance') {
+        const histogram=await all(`SELECT a.duration_bucket,SUM(a.events) AS count ${PUBLIC} AND a.hour>=? GROUP BY a.duration_bucket ORDER BY a.duration_bucket`,[since]) as any[];
+        const total=histogram.reduce((s,r)=>s+Number(r.count),0);
+        const quantile=(fraction:number)=>{let sum=0;for(const row of histogram){sum+=Number(row.count);if(sum>=total*fraction)return row.duration_bucket;}return 0;};
+        const avg=await db.prepare(`SELECT COALESCE(SUM(a.duration_sum)/NULLIF(SUM(a.events),0),0) AS avg_response_ms ${PUBLIC} AND a.hour>=?`).bind(since).first();
+        return {summary:{...avg,p95_response_ms:quantile(.95),p99_response_ms:quantile(.99)},percentile_method:'histogram_upper_bound',daily:await all(`SELECT date(a.hour,'unixepoch') AS date,SUM(a.duration_sum)/SUM(a.events) AS avg_response_ms ${PUBLIC} AND a.hour>=? GROUP BY date ORDER BY date`,[since])};
+    }
+    if(endpoint?.startsWith('page/')) {
+        const slug=c.req.param('slug') || '';return {slug,total:(await localPageViews(db,slug)).total,daily:await all(`SELECT date(a.hour,'unixepoch') AS date,SUM(a.events) AS views ${PUBLIC} AND p.slug=? AND a.hour>=? GROUP BY date ORDER BY date`,[slug,since])};
+    }
+    return null;
+}
+export const LOCAL_ANALYTICS_SCHEMA = schema;
diff --git a/tests/services.test.ts b/tests/services.test.ts
new file mode 100644
index 0000000..b414086
--- /dev/null
+++ b/tests/services.test.ts
@@ -0,0 +1,40 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import {DatabaseSync} from 'node:sqlite';
+import {Hono} from 'hono';
+import {recordLocalAnalytics,localTrending,localPageViews,ensureLocalAnalytics,localDashboard} from '../src/utils/localAnalytics';
+import analyticsRoutes from '../src/routes/analytics';
+import mcpRoutes from '../src/routes/mcp';
+import {RBAC} from '../src/utils/role';
+function fixture(){
+ const sql=new DatabaseSync(':memory:');sql.exec('CREATE TABLE pages(id INTEGER PRIMARY KEY,slug TEXT,is_private INTEGER DEFAULT 0,deleted_at INTEGER);INSERT INTO pages(id,slug,is_private) VALUES(1,\'Public\',0),(2,\'Secret\',1);');
+ const db:any={prepare(query:string){let params:any[]=[];const s={bind(...args:any[]){params=args;return s},async first(){return sql.prepare(query).get(...params)||null},async all(){return{results:sql.prepare(query).all(...params)}},async run(){return{meta:{changes:Number(sql.prepare(query).run(...params).changes)}}}};return s},async batch(items:any[]){sql.exec('BEGIN');try{const result=[];for(const item of items)result.push(await item.run());sql.exec('COMMIT');return result}catch(error){sql.exec('ROLLBACK');throw error}}};
+ const c:any={env:{DB:db},req:{header:(name:string)=>name==='Referer'?'https://example.com/path?token=private':name==='User-Agent'?'Browser':'',raw:{cf:{country:'CN'}}}};
+ return{sql,db,c};
+}
+test('D1 counts reads, updates trending immediately, and excludes private/deleted pages',async()=>{
+ const {sql,db,c}=fixture();await recordLocalAnalytics(c,'pageview','Public',42);await recordLocalAnalytics(c,'pageview','Public',100);await recordLocalAnalytics(c,'pageview','Secret');await recordLocalAnalytics(c,'pageview','missing');
+ assert.deepEqual(JSON.parse(JSON.stringify(await localPageViews(db,'Public'))),{total:2,recent:2});
+ assert.equal((await localTrending(db))[0].views,2);assert.equal((await localTrending(db)).length,1);
+ assert.equal((sql.prepare('SELECT referrer FROM wiki_analytics_hourly LIMIT 1').get() as any).referrer,'https://example.com');
+ sql.exec('UPDATE pages SET slug=\'Moved\' WHERE id=1');assert.equal((await localPageViews(db,'Moved')).total,2);
+ sql.exec('UPDATE pages SET is_private=1 WHERE id=1');assert.deepEqual(await localTrending(db),[]);assert.equal((await localPageViews(db,'Moved')).total,0);
+ sql.exec('UPDATE pages SET is_private=0,deleted_at=1 WHERE id=1');assert.deepEqual(await localTrending(db),[]);
+});
+test('hourly retention preserves lifetime counts',async()=>{
+ const {sql,db,c}=fixture();await ensureLocalAnalytics(db);await recordLocalAnalytics(c,'pageview','Public');sql.exec('UPDATE wiki_analytics_hourly SET hour=1');
+ assert.equal((await localPageViews(db,'Public')).total,1);assert.equal((await localPageViews(db,'Public')).recent,0);assert.deepEqual(await localTrending(db),[]);
+});
+test('admin statistics work without external analytics credentials and remain protected',async()=>{
+ const {db,c}=fixture();await recordLocalAnalytics(c,'pageview','Public',40);await recordLocalAnalytics(c,'search','query');await recordLocalAnalytics(c,'error','/missing?secret=x',20,404,'Not found');
+ const app=new Hono();app.use('*',async(c,next)=>{c.set('user',{role:c.req.header('X-Test-Role')||'admin'});c.set('rbac',new RBAC());await next()});app.route('/api/admin/analytics',analyticsRoutes);
+ for(const endpoint of ['overview','pages','trending','referrers','countries','devices','searches','errors','performance','page/Public']){const response=await app.request('/api/admin/analytics/'+endpoint,{}, {DB:db});assert.equal(response.status,200,endpoint);assert.ok(await response.json());}
+ assert.equal((await app.request('/api/admin/analytics/overview',{headers:{'X-Test-Role':'user'}},{DB:db})).status,403);
+});
+test('MCP open mode initializes and lists public tools; disabled mode stays denied',async()=>{
+ const app=new Hono();app.use('*',async(c,next)=>{c.set('user',null);c.set('rbac',new RBAC());await next()});app.route('/api/mcp',mcpRoutes);
+ const request=(method:string,mode='open')=>app.request('/api/mcp',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({jsonrpc:'2.0',id:1,method,params:{protocolVersion:'2024-11-05',capabilities:{},clientInfo:{name:'test',version:'1'}}})},{MCP_MODE:mode,WIKI_VISIBILITY:'open'});
+ const initialized=await request('initialize');assert.equal(initialized.status,200);assert.ok((await initialized.json() as any).result.capabilities.tools);
+ const tools=await request('tools/list');assert.equal(tools.status,200);assert.ok((await tools.json() as any).result.tools.length>0);
+ assert.equal((await request('initialize','disabled')).status,403);
+});
diff --git a/wrangler.production.toml b/wrangler.production.toml
index 28454f1..56c82f5 100644
--- a/wrangler.production.toml
+++ b/wrangler.production.toml
@@ -38,7 +38,7 @@ WIKI_LOGO_URL = "/logo.png"
 WIKI_FAVICON_URL = "/icon.png"
 SUPER_ADMIN_EMAILS = "hekuo5310@gmail.com"
 SELECTED_ICONS_ONLY = "false"
-MCP_MODE = "disabled"
+MCP_MODE = "open"
 ALLOW_CRAWL = "false"
 ENABLE_CONCURRENT_EDIT_DETECTION = "true"
 EDIT_REQUEST_ENABLED = "false"