diff --git a/.gitea/workflows/backend-docker.yml b/.gitea/workflows/backend-docker.yml index 2d215cf..a16bacf 100644 --- a/.gitea/workflows/backend-docker.yml +++ b/.gitea/workflows/backend-docker.yml @@ -18,25 +18,130 @@ permissions: packages: write jobs: + resolve-build-targets: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.targets.outputs.matrix }} + count: ${{ steps.targets.outputs.count }} + frontend_changed: ${{ steps.targets.outputs.frontend_changed }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve build targets + id: targets + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + BEFORE_SHA: ${{ github.event.before }} + CURRENT_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + declare -A SELECTED=() + BUILD_ALL=0 + + if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then + BUILD_ALL=1 + fi + + BEFORE="${BEFORE_SHA:-}" + if [ "${BUILD_ALL}" -ne 1 ] && { [ -z "${BEFORE}" ] || printf '%s' "${BEFORE}" | grep -Eq '^0+$'; }; then + if git rev-parse --verify HEAD^ >/dev/null 2>&1; then + BEFORE="$(git rev-parse HEAD^)" + else + BUILD_ALL=1 + fi + fi + + CHANGED_FILES="" + if [ "${BUILD_ALL}" -ne 1 ]; then + CHANGED_FILES="$(git diff --name-only "${BEFORE}" "${CURRENT_SHA}" || true)" + fi + + while IFS= read -r path; do + [ -n "${path}" ] || continue + + case "${path}" in + backend/*) + SELECTED[backend]=1 + ;; + frontend/*) + SELECTED[frontend]=1 + ;; + admin/*) + SELECTED[admin]=1 + ;; + deploy/docker/*|.gitea/workflows/backend-docker.yml) + BUILD_ALL=1 + ;; + esac + done <<< "${CHANGED_FILES}" + + if [ "${BUILD_ALL}" -eq 1 ] || [ "${#SELECTED[@]}" -eq 0 ]; then + SELECTED[backend]=1 + SELECTED[frontend]=1 + SELECTED[admin]=1 + fi + + COMPONENTS=() + [ -n "${SELECTED[backend]:-}" ] && COMPONENTS+=(backend) + [ -n "${SELECTED[frontend]:-}" ] && COMPONENTS+=(frontend) + [ -n "${SELECTED[admin]:-}" ] && COMPONENTS+=(admin) + + COMPONENTS_CSV="$(IFS=,; echo "${COMPONENTS[*]}")" + export COMPONENTS_CSV + + python <<'PY' >> "$GITHUB_OUTPUT" + import json + import os + + mapping = { + "backend": { + "component": "backend", + "dockerfile": "backend/Dockerfile", + "context": "backend", + "default_image_name": "termi-astro-backend", + }, + "frontend": { + "component": "frontend", + "dockerfile": "frontend/Dockerfile", + "context": "frontend", + "default_image_name": "termi-astro-frontend", + }, + "admin": { + "component": "admin", + "dockerfile": "admin/Dockerfile", + "context": "admin", + "default_image_name": "termi-astro-admin", + }, + } + + components = [item for item in os.environ.get("COMPONENTS_CSV", "").split(",") if item] + matrix = {"include": [mapping[item] for item in components]} + + print(f"matrix={json.dumps(matrix, separators=(',', ':'))}") + print(f"count={len(components)}") + print(f"frontend_changed={'true' if 'frontend' in components else 'false'}") + PY + + echo "Selected components: ${COMPONENTS_CSV}" + if [ -n "${CHANGED_FILES}" ]; then + echo "Changed files:" + printf '%s\n' "${CHANGED_FILES}" + else + echo "Changed files: " + fi + build-and-push: + needs: resolve-build-targets + if: needs.resolve-build-targets.outputs.count != '0' runs-on: ubuntu-latest strategy: fail-fast: false max-parallel: 1 - matrix: - include: - - component: backend - dockerfile: backend/Dockerfile - context: backend - default_image_name: termi-astro-backend - - component: frontend - dockerfile: frontend/Dockerfile - context: frontend - default_image_name: termi-astro-frontend - - component: admin - dockerfile: admin/Dockerfile - context: admin - default_image_name: termi-astro-admin + matrix: ${{ fromJson(needs.resolve-build-targets.outputs.matrix) }} steps: - name: Checkout @@ -103,6 +208,8 @@ jobs: echo "tag_latest=latest" echo "tag_branch=${SAFE_REF}" echo "tag_sha=${SHORT_SHA}" + echo "cache_ref_branch=${IMAGE_BASE}:buildcache-${SAFE_REF}" + echo "cache_ref_shared=${IMAGE_BASE}:buildcache" echo "frontend_public_api_base_url=${FRONTEND_PUBLIC_API_BASE_URL}" echo "admin_vite_api_base=${ADMIN_VITE_API_BASE}" echo "admin_vite_frontend_base_url=${ADMIN_VITE_FRONTEND_BASE_URL}" @@ -200,6 +307,8 @@ jobs: TAG_LATEST: ${{ steps.meta.outputs.tag_latest }} TAG_BRANCH: ${{ steps.meta.outputs.tag_branch }} TAG_SHA: ${{ steps.meta.outputs.tag_sha }} + CACHE_REF_BRANCH: ${{ steps.meta.outputs.cache_ref_branch }} + CACHE_REF_SHARED: ${{ steps.meta.outputs.cache_ref_shared }} FRONTEND_PUBLIC_API_BASE_URL: ${{ steps.meta.outputs.frontend_public_api_base_url }} ADMIN_VITE_API_BASE: ${{ steps.meta.outputs.admin_vite_api_base }} ADMIN_VITE_FRONTEND_BASE_URL: ${{ steps.meta.outputs.admin_vite_frontend_base_url }} @@ -222,8 +331,12 @@ jobs: --file "${DOCKERFILE}" \ "${BUILD_ARGS[@]}" \ --build-arg BUILDKIT_INLINE_CACHE=1 \ + --cache-from "type=registry,ref=${CACHE_REF_BRANCH}" \ + --cache-from "type=registry,ref=${CACHE_REF_SHARED}" \ --cache-from "type=registry,ref=${IMAGE_BASE}:${TAG_BRANCH}" \ --cache-from "type=registry,ref=${IMAGE_BASE}:${TAG_LATEST}" \ + --cache-to "type=registry,ref=${CACHE_REF_BRANCH},mode=max" \ + --cache-to "type=registry,ref=${CACHE_REF_SHARED},mode=max" \ --cache-to "type=inline" \ --tag "${IMAGE_BASE}:${TAG_LATEST}" \ --tag "${IMAGE_BASE}:${TAG_BRANCH}" \ @@ -244,3 +357,57 @@ jobs: echo "- ${IMAGE_BASE}:${TAG_LATEST}" echo "- ${IMAGE_BASE}:${TAG_BRANCH}" echo "- ${IMAGE_BASE}:${TAG_SHA}" + + submit-indexnow: + needs: + - resolve-build-targets + - build-and-push + if: needs.resolve-build-targets.outputs.frontend_changed == 'true' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Submit IndexNow (optional) + shell: bash + env: + INDEXNOW_KEY: ${{ secrets.INDEXNOW_KEY }} + SITE_URL: ${{ vars.INDEXNOW_SITE_URL }} + PUBLIC_API_BASE_URL: ${{ vars.INDEXNOW_PUBLIC_API_BASE_URL }} + GITHUB_REF_NAME_VALUE: ${{ github.ref_name }} + run: | + set -euo pipefail + + REF_NAME="${GITHUB_REF_NAME_VALUE:-${GITHUB_REF_NAME:-${GITEA_REF_NAME:-}}}" + if [ "${GITHUB_EVENT_NAME:-${GITEA_EVENT_NAME:-}}" != "push" ]; then + echo "Current event is not push, skip IndexNow submission." + exit 0 + fi + + if [ "${REF_NAME}" != "main" ] && [ "${REF_NAME}" != "master" ]; then + echo "Current ref '${REF_NAME}' is not main/master, skip IndexNow submission." + exit 0 + fi + + if [ -z "${INDEXNOW_KEY:-}" ]; then + echo "Missing INDEXNOW_KEY secret, skip IndexNow submission." + exit 0 + fi + + if [ -z "${SITE_URL:-}" ]; then + echo "Missing INDEXNOW_SITE_URL variable, skip IndexNow submission." + exit 0 + fi + + pnpm --dir frontend run indexnow:submit diff --git a/README.md b/README.md index 43473ab..11eda4d 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ docker compose -f deploy/docker/compose.package.yml --env-file deploy/docker/.en - Secrets - `REGISTRY_USERNAME` - `REGISTRY_TOKEN` + - `INDEXNOW_KEY`(可选;如果要在主分支镜像发布后自动提交 IndexNow) - Variables(可选) - `REGISTRY_HOST`(默认 `git.init.cool`) - `IMAGE_NAMESPACE`(默认仓库 owner) @@ -153,6 +154,16 @@ docker compose -f deploy/docker/compose.package.yml --env-file deploy/docker/.en - `ADMIN_VITE_API_BASE`(admin 镜像构建注入的 API 默认地址,默认 `http://localhost:5150`;运行时可被 `ADMIN_API_BASE_URL` 覆盖) - `ADMIN_VITE_FRONTEND_BASE_URL`(admin 镜像构建注入的前台跳转默认基址,默认 `http://localhost:4321`;运行时可被 `ADMIN_FRONTEND_BASE_URL` 覆盖) - `ADMIN_VITE_BASENAME`(可选;如果 admin 要挂在 `/admin` 这类路径前缀下,构建时设置为 `/admin`) + - `INDEXNOW_SITE_URL`(可选;自动提交 IndexNow 时使用的前台 canonical 域名,例如 `https://blog.init.cool`) + - `INDEXNOW_PUBLIC_API_BASE_URL`(可选;如果站点公开 API 不是 `${INDEXNOW_SITE_URL}/api`,可在这里显式指定) + +如果同时配置了 `INDEXNOW_KEY` + `INDEXNOW_SITE_URL`,主分支镜像发布成功后会自动执行一次: + +```powershell +pnpm --dir frontend run indexnow:submit +``` + +用来把首页、文章、分类、标签、评测等 canonical URL 提交到 IndexNow。 ### MCP Server diff --git a/admin/src/lib/types.ts b/admin/src/lib/types.ts index 878effe..6d0abf8 100644 --- a/admin/src/lib/types.ts +++ b/admin/src/lib/types.ts @@ -349,6 +349,8 @@ export interface AdminAnalyticsResponse { recent_events: AnalyticsRecentEvent[] providers_last_7d: AnalyticsProviderBucket[] top_referrers: AnalyticsReferrerBucket[] + ai_referrers_last_7d: AnalyticsReferrerBucket[] + ai_discovery_page_views_last_7d: number popular_posts: AnalyticsPopularPost[] daily_activity: AnalyticsDailyBucket[] } @@ -409,6 +411,7 @@ export interface AdminSiteSettingsResponse { media_r2_secret_access_key: string | null seo_default_og_image: string | null seo_default_twitter_handle: string | null + seo_wechat_share_qr_enabled: boolean notification_webhook_url: string | null notification_channel_type: 'webhook' | 'ntfy' | string notification_comment_enabled: boolean @@ -482,6 +485,7 @@ export interface SiteSettingsPayload { mediaR2SecretAccessKey?: string | null seoDefaultOgImage?: string | null seoDefaultTwitterHandle?: string | null + seoWechatShareQrEnabled?: boolean notificationWebhookUrl?: string | null notificationChannelType?: 'webhook' | 'ntfy' | string | null notificationCommentEnabled?: boolean diff --git a/admin/src/pages/analytics-page.tsx b/admin/src/pages/analytics-page.tsx index 4ac3f49..f4c9f6a 100644 --- a/admin/src/pages/analytics-page.tsx +++ b/admin/src/pages/analytics-page.tsx @@ -1,4 +1,4 @@ -import { BarChart3, BrainCircuit, Clock3, Eye, RefreshCcw, Search } from 'lucide-react' +import { BarChart3, BrainCircuit, Clock3, Eye, RefreshCcw, Search, Sparkles } from 'lucide-react' import { startTransition, useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' @@ -80,6 +80,31 @@ function formatDuration(value: number | null) { return `${minutes} 分 ${restSeconds} 秒` } +function formatReferrerLabel(value: string) { + switch (value) { + case 'chatgpt-search': + return 'ChatGPT Search' + case 'perplexity': + return 'Perplexity' + case 'copilot-bing': + return 'Copilot / Bing' + case 'gemini': + return 'Gemini' + case 'claude': + return 'Claude' + case 'google': + return 'Google' + case 'duckduckgo': + return 'DuckDuckGo' + case 'kagi': + return 'Kagi' + case 'direct': + return 'Direct' + default: + return value + } +} + export function AnalyticsPage() { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) @@ -197,6 +222,11 @@ export function AnalyticsPage() { icon: Clock3, }, ] + const aiDiscoveryShare = + data.content_overview.page_views_last_7d > 0 + ? (data.ai_discovery_page_views_last_7d / data.content_overview.page_views_last_7d) * 100 + : 0 + const aiDiscoveryTopSource = data.ai_referrers_last_7d[0] return (
@@ -241,6 +271,94 @@ export function AnalyticsPage() { ))}
+
+ + +
+
+ AI 来源流量 + + 最近 7 天 + +
+
+ {data.ai_discovery_page_views_last_7d} +
+

+ 来自 ChatGPT Search、Perplexity、Copilot/Bing、Gemini、Claude + 等 AI / 答案引擎的页面访问。 +

+
+ +
+
+

+ 占全部 page_view +

+

{formatPercent(aiDiscoveryShare)}

+

+ 总 page_view:{data.content_overview.page_views_last_7d} +

+
+
+

+ 最高来源 +

+

+ {aiDiscoveryTopSource ? formatReferrerLabel(aiDiscoveryTopSource.referrer) : '暂无'} +

+

+ {aiDiscoveryTopSource ? `${aiDiscoveryTopSource.count} 次访问` : '等待来源数据'} +

+
+
+
+
+ + + + + + AI 来源明细 + + + 便于判断 GEO 改造后,哪些 AI 搜索或答案引擎真正把流量带回来了。 + + + + {data.ai_referrers_last_7d.length ? ( + data.ai_referrers_last_7d.map((item) => { + const width = `${ + Math.max( + (item.count / Math.max(data.ai_discovery_page_views_last_7d, 1)) * 100, + 8, + ) + }%` + + return ( +
+
+ {formatReferrerLabel(item.referrer)} + {item.count} +
+
+
+
+
+ ) + }) + ) : ( +

+ 最近 7 天还没有识别到 AI 搜索来源流量。 +

+ )} + + +
+
@@ -491,7 +609,7 @@ export function AnalyticsPage() { key={item.referrer} className="flex items-center justify-between rounded-2xl border border-border/70 bg-background/70 px-4 py-3" > - {item.referrer} + {formatReferrerLabel(item.referrer)} {item.count}
)) diff --git a/admin/src/pages/dashboard-page.tsx b/admin/src/pages/dashboard-page.tsx index 36672bd..fd13070 100644 --- a/admin/src/pages/dashboard-page.tsx +++ b/admin/src/pages/dashboard-page.tsx @@ -6,6 +6,7 @@ import { MessageSquareWarning, RefreshCcw, Rss, + Sparkles, Star, Tags, Workflow, @@ -37,7 +38,7 @@ import { formatReviewStatus, formatReviewType, } from '@/lib/admin-format' -import type { AdminDashboardResponse, WorkerOverview } from '@/lib/types' +import type { AdminAnalyticsResponse, AdminDashboardResponse, WorkerOverview } from '@/lib/types' function StatCard({ label, @@ -66,9 +67,35 @@ function StatCard({ ) } +function formatAiSourceLabel(value: string) { + switch (value) { + case 'chatgpt-search': + return 'ChatGPT Search' + case 'perplexity': + return 'Perplexity' + case 'copilot-bing': + return 'Copilot / Bing' + case 'gemini': + return 'Gemini' + case 'claude': + return 'Claude' + case 'google': + return 'Google' + case 'duckduckgo': + return 'DuckDuckGo' + case 'kagi': + return 'Kagi' + case 'direct': + return 'Direct' + default: + return value + } +} + export function DashboardPage() { const [data, setData] = useState(null) const [workerOverview, setWorkerOverview] = useState(null) + const [analytics, setAnalytics] = useState(null) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) @@ -78,13 +105,15 @@ export function DashboardPage() { setRefreshing(true) } - const [next, nextWorkerOverview] = await Promise.all([ + const [next, nextWorkerOverview, nextAnalytics] = await Promise.all([ adminApi.dashboard(), adminApi.getWorkersOverview(), + adminApi.analytics(), ]) startTransition(() => { setData(next) setWorkerOverview(nextWorkerOverview) + setAnalytics(nextAnalytics) }) if (showToast) { @@ -105,7 +134,7 @@ export function DashboardPage() { void loadDashboard(false) }, [loadDashboard]) - if (loading || !data || !workerOverview) { + if (loading || !data || !workerOverview || !analytics) { return (
@@ -113,7 +142,10 @@ export function DashboardPage() { ))}
- +
+ + +
) } @@ -160,6 +192,12 @@ export function DashboardPage() { icon: Workflow, }, ] + const aiTrafficShare = + analytics.content_overview.page_views_last_7d > 0 + ? (analytics.ai_discovery_page_views_last_7d / analytics.content_overview.page_views_last_7d) * 100 + : 0 + const topAiSource = analytics.ai_referrers_last_7d[0] + const totalAiSourceBuckets = analytics.ai_referrers_last_7d.length return (
@@ -328,6 +366,73 @@ export function DashboardPage() {

+
+
+
+

+ GEO / AI 来源概览 +

+

{analytics.ai_discovery_page_views_last_7d}

+

+ 最近 7 天来自 ChatGPT Search、Perplexity、Copilot/Bing、Gemini、Claude 的页面访问。 +

+
+
+ +
+
+ +
+
+
访问占比
+
{Math.round(aiTrafficShare)}%
+
基于近 7 天全部 page_view
+
+
+
最高来源
+
+ {topAiSource ? formatAiSourceLabel(topAiSource.referrer) : '暂无'} +
+
+ {topAiSource ? `${topAiSource.count} 次访问` : '等待来源数据'} +
+
+
+
已识别来源
+
{totalAiSourceBuckets}
+
当前已聚合的 AI 搜索渠道
+
+
+ + {analytics.ai_referrers_last_7d.length ? ( +
+ {analytics.ai_referrers_last_7d.slice(0, 4).map((item) => { + const width = `${Math.max( + (item.count / Math.max(analytics.ai_discovery_page_views_last_7d, 1)) * 100, + 8, + )}%` + + return ( +
+
+ {formatAiSourceLabel(item.referrer)} + {item.count} +
+
+
+
+
+ ) + })} +
+ ) : ( +

最近 7 天还没有识别到 AI 搜索来源流量。

+ )} +
+
diff --git a/admin/src/pages/site-settings-page.tsx b/admin/src/pages/site-settings-page.tsx index a760dac..eecd6f7 100644 --- a/admin/src/pages/site-settings-page.tsx +++ b/admin/src/pages/site-settings-page.tsx @@ -211,6 +211,7 @@ function toPayload(form: AdminSiteSettingsResponse): SiteSettingsPayload { mediaR2SecretAccessKey: form.media_r2_secret_access_key, seoDefaultOgImage: form.seo_default_og_image, seoDefaultTwitterHandle: form.seo_default_twitter_handle, + seoWechatShareQrEnabled: form.seo_wechat_share_qr_enabled, notificationWebhookUrl: form.notification_webhook_url, notificationChannelType: form.notification_channel_type, notificationCommentEnabled: form.notification_comment_enabled, @@ -857,6 +858,24 @@ export function SiteSettingsPage() { } /> +
+ +