Initial commit: 91 API Server

This commit is contained in:
yuqianhe
2026-06-10 02:33:35 +00:00
commit b1867e13c0
28 changed files with 4056 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# Binary
api-server
# Runtime data
data/
# IDE
.idea/
.vscode/
*.swp

211
README.md Normal file
View File

@@ -0,0 +1,211 @@
# api-server
基于 Go + Gin 的 API 服务平台,将 [sol148-extractor](../sol148-extractor) 的功能封装为 RESTful API内置统计分析监控和嵌入式视频浏览前端。
---
## 三层架构
```
┌─────────────────────────────────────────────────────────────┐
│ 前端(嵌入式 SPA
│ / 运维仪表盘Chart.js 暗色主题) │
│ /video 视频网站(浏览/搜索/提取/作者/分类) │
├─────────────────────────────────────────────────────────────┤
│ 核心业务 APIsol148-extractor 功能) │
│ POST /api/extract 视频源提取 │
│ POST /api/extract/batch 批量提取(并发可控) │
│ GET /api/categories 分类目录 │
│ GET /api/category/:code 分类视频列表(分页/全量) │
│ GET /api/author/:uid 作者视频列表(分页/全量) │
├─────────────────────────────────────────────────────────────┤
│ 统计监控 API运维用监控业务 API
│ GET /api/stats/overview 总览 │
│ GET /api/stats/requests 时间序列 │
│ GET /api/stats/geo 地理分布ip2region
│ GET /api/stats/concurrency 并发统计 │
│ GET /api/stats/latency 延迟百分位 │
│ GET /api/stats/status 状态码分布 │
│ GET /api/stats/endpoints 热门端点 │
│ GET /api/stats/top-ips 高频 IP │
│ GET /api/stats/realtime 实时统计 │
├─────────────────────────────────────────────────────────────┤
│ 内部引擎Go 原生实现) │
│ scraper/ Session 预热 + 正则提取Python → Go 移植) │
│ analytics/ 线程安全统计引擎 + ip2region │
│ middleware/ Gin 中间件自动采集请求 │
└─────────────────────────────────────────────────────────────┘
```
---
## 项目结构
```
api-server/
├── main.go # CLI 入口
├── cmd/
│ ├── root.go # cobra 根命令 + viper
│ └── serve.go # serve 子命令
├── internal/
│ ├── scraper/ # 核心提取引擎sol148-extractor 的 Go 移植)
│ │ ├── session.go # HTTP Session 预热(反爬 Cookie
│ │ ├── extractor.go # 视频源提取strencode2 → unescape → mp4
│ │ ├── author.go # 作者视频列表提取
│ │ └── category.go # 分类视频列表提取
│ ├── handler/
│ │ ├── extract.go # POST /api/extract, /api/extract/batch
│ │ ├── content.go # GET /api/categories, /category/:code, /author/:uid
│ │ ├── stats.go # GET /api/stats/*
│ │ └── handler.go # /api/health, /api/ping
│ ├── analytics/
│ │ ├── engine.go # 线程安全统计引擎
│ │ └── geoip.go # ip2region 地理位置
│ ├── middleware/analytics.go # Gin 中间件(请求采集)
│ ├── config/config.go # 配置管理
│ ├── model/stats.go # 数据模型
│ └── server/server.go # 路由注册 + 嵌入式前端
├── web/
│ ├── embed.go # //go:embed 嵌入
│ └── static/
│ ├── index.html + app.js + style.css # 运维仪表盘
│ └── videoapp/
│ ├── index.html # 视频网站 SPA
│ ├── app.js # 路由/API 调用/渲染
│ └── style.css # 视频站主题
├── data/ # ip2region.xdb 数据库
└── go.mod
```
---
## 核心业务 API
### 视频源提取
```bash
# 单个提取
curl -X POST http://localhost:8080/api/extract \
-H 'Content-Type: application/json' \
-d '{"url":"https://h1014.sol148.com/view_video.php?viewkey=xxx"}'
# 批量提取(并发度可配)
curl -X POST http://localhost:8080/api/extract/batch \
-H 'Content-Type: application/json' \
-d '{"urls":["url1","url2","url3"], "concurrency":3}'
```
响应示例:
```json
{
"code": 0,
"data": {
"url": "原始 URL",
"video_url": "https://cdn.xxx/.../12345.mp4?st=...",
"video_id": "12345",
"title": "视频标题",
"duration": "00:09:29",
"views": 65979,
"favorites": 1213,
"author_name": "作者名",
"author_url": "https://h1014.sol148.com/uprofile.php?UID=xxx"
}
}
```
### 分类浏览
```bash
# 分类列表
curl http://localhost:8080/api/categories
# 分类视频(分页)
curl "http://localhost:8080/api/category/rf?page=1&viewtype=basic"
# 全量获取
curl "http://localhost:8080/api/category/top?all=true&max_pages=5"
```
### 作者浏览
```bash
# 作者视频(分页)
curl "http://localhost:8080/api/author/kBBKslfHMfbdEHb5SeiLocPIbBow7t6?page=1"
# 全量获取
curl "http://localhost:8080/api/author/kBBKslfHMfbdEHb5SeiLocPIbBow7t6?all=true"
```
---
## 视频网站前端 (`/video`)
一个完整功能的单页应用,直观展示和测试所有 API
| 页面 | 路由 | 调用的 API |
|------|------|-----------|
| 首页 | `#/home` | `/categories`, `/stats/overview` |
| 分类浏览 | `#/browse` | `/categories`, `/category/:code` |
| 分类列表 | `#/categories` | `/categories` |
| 视频详情 | `#/watch/:viewkey` | `/extract`, `/category/:code` |
| 作者页 | `#/author/:uid` | `/author/:uid` |
| 搜索 | `#/search` | `/category/:code` (全量 + 前端过滤) |
特色功能:
- 导航栏实时显示 QPS / 活跃连接 / 延迟(来自 `/stats/realtime`
- 视频详情页自动调用提取 API 获取视频源
- 一键复制视频源地址
- 分类分页浏览
- 客户端搜索过滤
- 暗色主题,响应式布局
---
## 设计对应关系sol148-extractor → api-server
| sol148-extractor (Python) | api-server (Go) |
|---------------------------|-----------------|
| `extract.py` — 视频源提取 | `scraper/extractor.go` + `POST /api/extract` |
| `extract_author.py` — 作者列表 | `scraper/author.go` + `GET /api/author/:uid` |
| `crawl_category.py` — 分类列表 | `scraper/category.go` + `GET /api/category/:code` |
| `argparse` CLI | `cobra` + `viper` |
| `requests.Session` 预热 | `scraper/session.go` — HTTP Cookie Jar + 自动预热 |
| `strencode2 → unescape → regex` | Go `url.QueryUnescape` + `regexp` |
| JSON / URL-only 输出 | RESTful JSON API |
| `-i urls.txt -o results.json` | `POST /extract/batch` 并发提取 |
| `--delay` 页间延迟 | HTTP 无状态,按需调用 |
---
## 快速开始
```bash
# 编译
go build -o api-server .
# 启动(无需 ip2region 也可运行)
./api-server serve
# 带 IP 地理位置
./api-server serve --geoip --geoip-db ./data/ip2region.xdb
# 开发模式
./api-server serve --mode debug --port 9090
```
```bash
# 访问
http://localhost:8080/ # 运维仪表盘
http://localhost:8080/video # 视频网站
```
---
## 技术栈
- **Go 1.22+** — 高性能编译型语言
- **Gin** — HTTP 框架
- **Cobra + Viper** — CLI + 配置
- **ip2region** — IP 地理位置
- **Chart.js** — 前端图表
- **//go:embed** — 资源嵌入

75
cmd/root.go Normal file
View File

@@ -0,0 +1,75 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var cfgFile string
// rootCmd 根命令
var rootCmd = &cobra.Command{
Use: "api-server",
Short: "高性能 API 服务,内置统计分析与运维监控",
Long: `api-server 是一个基于 Gin 框架的高性能 API 服务程序。
它内置了请求统计、IP 地理位置分析、并发监控等功能,
并通过嵌入式前端提供可视化仪表盘。
参考设计sol148-extractor模块化、CLI 驱动、Session 管理)`,
Run: func(cmd *cobra.Command, args []string) {
// 默认显示帮助
if len(args) == 0 {
cmd.Help()
return
}
},
}
// Execute 执行 CLI
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "配置文件路径 (YAML/JSON)")
rootCmd.PersistentFlags().String("host", "0.0.0.0", "监听地址")
rootCmd.PersistentFlags().IntP("port", "p", 8080, "监听端口")
rootCmd.PersistentFlags().String("mode", "release", "运行模式 (debug/release/test)")
rootCmd.PersistentFlags().Bool("geoip", false, "启用 IP 地理位置解析")
rootCmd.PersistentFlags().String("geoip-db", "./data/ip2region.xdb", "ip2region 数据库路径")
viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("host"))
viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port"))
viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode"))
viper.BindPFlag("geoip", rootCmd.PersistentFlags().Lookup("geoip"))
viper.BindPFlag("geoip_db", rootCmd.PersistentFlags().Lookup("geoip-db"))
// 添加子命令
rootCmd.AddCommand(serveCmd)
rootCmd.AddCommand(versionCmd)
}
func initConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
viper.AddConfigPath("./config")
}
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err == nil {
fmt.Fprintf(os.Stderr, "使用配置文件: %s\n", viper.ConfigFileUsed())
}
}

57
cmd/serve.go Normal file
View File

@@ -0,0 +1,57 @@
package cmd
import (
"fmt"
"log"
"api-server/internal/config"
"api-server/internal/server"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// serveCmd 启动 HTTP 服务
var serveCmd = &cobra.Command{
Use: "serve",
Short: "启动 HTTP API 服务",
Long: `启动 HTTP API 服务,包括:
- RESTful API 接口
- 统计分析 API/api/stats/*
- 嵌入式监控仪表盘(/
- 请求日志与并发监控`,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println(`
╔══════════════════════════════════════╗
║ api-server - 量化级 API 服务 ║
║ Go + Gin + Cobra + ip2region ║
╚══════════════════════════════════════╝`)
// 加载配置
cfg := config.Load(viper.GetViper())
if cfg.Server.Mode == "debug" {
log.Println("[config] 运行模式: debug")
}
// 创建并启动服务
srv, err := server.New(cfg)
if err != nil {
return fmt.Errorf("创建服务失败: %w", err)
}
return srv.Run()
},
}
// versionCmd 版本信息
var versionCmd = &cobra.Command{
Use: "version",
Short: "显示版本信息",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("api-server v1.0.0")
fmt.Println(" Go: go1.22+")
fmt.Println(" Web: Gin + embedded single-page app")
fmt.Println(" Stats: ip2region + in-memory analytics engine")
},
}

50
go.mod Normal file
View File

@@ -0,0 +1,50 @@
module api-server
go 1.25.0
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.12.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260609051939-e360f3d0315b // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

110
go.sum Normal file
View File

@@ -0,0 +1,110 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260609051939-e360f3d0315b h1:NLXReseDanLoFX7P+Qhghvyoi7t2wuT8HQI0NUK61zM=
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260609051939-e360f3d0315b/go.mod h1:sj5LMpsqB4IWdwIrcmmBJM6m+rW/uOQLSGUPhKkqdh8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,628 @@
package analytics
import (
"log"
"math"
"sort"
"sync"
"sync/atomic"
"time"
"api-server/internal/model"
)
// Engine 统计分析引擎(线程安全)
type Engine struct {
mu sync.RWMutex
geoip *GeoIP
startTime time.Time
// 请求记录(环形缓冲区思想,简单用切片 + 定期清理)
records []model.RequestRecord
maxRecs int
// 并发跟踪
activeConns int64 // 当前活跃连接(原子操作)
peakConns int64 // 历史峰值
connSamples []model.ConcurrencySample // 并发采样
maxSamples int
// 聚合计数器
totalRequests int64 // 总请求数(原子)
totalErrors int64 // 总错误数(原子)
totalTraffic int64 // 总流量字节(原子)
// IP 计数器
ipCount map[string]int64
// 端点计数器: "METHOD /path" -> count+latency
endpointStats map[string]*endpointAgg
// 停止信号
stopCh chan struct{}
}
type endpointAgg struct {
Count int64
TotalLatency time.Duration
Errors int64
}
// NewEngine 创建统计引擎
func NewEngine(geo *GeoIP) *Engine {
e := &Engine{
geoip: geo,
startTime: time.Now(),
records: make([]model.RequestRecord, 0, 100000),
maxRecs: 200000,
maxSamples: 720, // 默认保留720个采样点每小时一个保留30天
connSamples: make([]model.ConcurrencySample, 0, 720),
ipCount: make(map[string]int64),
endpointStats: make(map[string]*endpointAgg),
stopCh: make(chan struct{}),
}
// 启动定期清理
go e.cleanupLoop()
// 启动并发采样
go e.concurrencySampler()
return e
}
// ── 数据写入 ──────────────────────────────────────────────────
// Record 记录一条请求
func (e *Engine) Record(r model.RequestRecord) {
// 并发计数
atomic.AddInt64(&e.totalRequests, 1)
if r.StatusCode >= 400 {
atomic.AddInt64(&e.totalErrors, 1)
}
atomic.AddInt64(&e.totalTraffic, r.BodyBytes)
// IP 地理信息
if e.geoip != nil {
info := e.geoip.Lookup(r.ClientIP)
r.Region = info.Region
r.ISP = info.ISP
}
e.mu.Lock()
defer e.mu.Unlock()
// 存入记录
if len(e.records) < e.maxRecs {
e.records = append(e.records, r)
}
// IP 计数
e.ipCount[r.ClientIP]++
// 端点统计
key := r.Method + " " + r.Path
ep, ok := e.endpointStats[key]
if !ok {
ep = &endpointAgg{}
e.endpointStats[key] = ep
}
ep.Count++
ep.TotalLatency += r.Latency
if r.StatusCode >= 400 {
ep.Errors++
}
}
// ── 并发管理 ──────────────────────────────────────────────────
// ConnEnter 连接进入
func (e *Engine) ConnEnter() {
cur := atomic.AddInt64(&e.activeConns, 1)
// 更新峰值(非精确,竞态可接受)
for {
peak := atomic.LoadInt64(&e.peakConns)
if cur <= peak || atomic.CompareAndSwapInt64(&e.peakConns, peak, cur) {
break
}
}
}
// ConnLeave 连接离开
func (e *Engine) ConnLeave() {
atomic.AddInt64(&e.activeConns, -1)
}
// concurrencySampler 定期采样并发数
func (e *Engine) concurrencySampler() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-e.stopCh:
return
case t := <-ticker.C:
e.mu.Lock()
cur := atomic.LoadInt64(&e.activeConns)
s := model.ConcurrencySample{
Time: t.Format("15:04:05"),
Value: cur,
}
if len(e.connSamples) >= e.maxSamples {
e.connSamples = e.connSamples[1:]
}
e.connSamples = append(e.connSamples, s)
e.mu.Unlock()
}
}
}
// ── 清理 ──────────────────────────────────────────────────────
func (e *Engine) cleanupLoop() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for {
select {
case <-e.stopCh:
return
case <-ticker.C:
e.cleanup(time.Now().Add(-7 * 24 * time.Hour))
}
}
}
// cleanup 清理过期记录
func (e *Engine) cleanup(before time.Time) {
e.mu.Lock()
defer e.mu.Unlock()
cutoff := 0
for i, r := range e.records {
if r.Timestamp.After(before) {
cutoff = i
break
}
}
if cutoff > 0 {
e.records = e.records[cutoff:]
}
}
// Stop 停止引擎
func (e *Engine) Stop() {
close(e.stopCh)
}
// ── 查询 API ──────────────────────────────────────────────────
// Overview 总览统计
func (e *Engine) Overview() model.OverviewStats {
e.mu.RLock()
defer e.mu.RUnlock()
total := atomic.LoadInt64(&e.totalRequests)
errors := atomic.LoadInt64(&e.totalErrors)
active := atomic.LoadInt64(&e.activeConns)
traffic := atomic.LoadInt64(&e.totalTraffic)
// 平均延迟 & QPS
avgLat := 0.0
qps := 0.0
uptime := time.Since(e.startTime)
if uptime.Seconds() > 0 {
qps = float64(total) / uptime.Seconds()
}
recs := e.recentRecords(time.Minute)
if len(recs) > 0 {
var sum time.Duration
for _, r := range recs {
sum += r.Latency
}
avgLat = float64(sum.Microseconds()) / float64(len(recs)) / 1000.0
}
lastReqTime := time.Time{}
if len(e.records) > 0 {
lastReqTime = e.records[len(e.records)-1].Timestamp
}
errRate := 0.0
if total > 0 {
errRate = float64(errors) / float64(total) * 100
}
uniqueIPs := int64(len(e.ipCount))
return model.OverviewStats{
TotalRequests: total,
ActiveConn: active,
AvgLatency: math.Round(avgLat*100) / 100,
ErrorRate: math.Round(errRate*100) / 100,
RequestsPerSec: math.Round(qps*100) / 100,
Uptime: uptime,
UniqueIPs: uniqueIPs,
TotalTraffic: traffic / (1024 * 1024),
LastRequestTime: lastReqTime,
}
}
// TimeSeries 时间序列数据
func (e *Engine) TimeSeries(period time.Duration, buckets int) []model.TimeSeriesPoint {
e.mu.RLock()
defer e.mu.RUnlock()
if buckets <= 0 {
buckets = 60
}
now := time.Now()
start := now.Add(-period)
interval := period / time.Duration(buckets)
// 过滤时间段内的记录
recs := e.recordsInRange(start, now)
if len(recs) == 0 {
return nil
}
bucketsData := make([]struct {
count int
errors int
latSum time.Duration
}, buckets)
for _, r := range recs {
idx := int(r.Timestamp.Sub(start) / interval)
if idx < 0 {
idx = 0
}
if idx >= buckets {
idx = buckets - 1
}
bucketsData[idx].count++
if r.StatusCode >= 400 {
bucketsData[idx].errors++
}
bucketsData[idx].latSum += r.Latency
}
result := make([]model.TimeSeriesPoint, buckets)
for i := 0; i < buckets; i++ {
t := start.Add(interval * time.Duration(i))
b := bucketsData[i]
avgMs := 0.0
if b.count > 0 {
avgMs = float64(b.latSum.Microseconds()) / float64(b.count) / 1000.0
}
result[i] = model.TimeSeriesPoint{
Time: t.Format("15:04"),
Count: int64(b.count),
Errors: int64(b.errors),
AvgMs: math.Round(avgMs*100) / 100,
}
}
return result
}
// GeoDistribution 地理分布 Top N
func (e *Engine) GeoDistribution(topN int) []model.GeoDistribution {
e.mu.RLock()
defer e.mu.RUnlock()
regionCount := make(map[string]int64)
// 只统计最近1小时
cutoff := time.Now().Add(-1 * time.Hour)
for _, r := range e.records {
if r.Timestamp.After(cutoff) {
regionCount[r.Region]++
}
}
total := int64(0)
for _, c := range regionCount {
total += c
}
type kv struct {
k string
v int64
}
var sorted []kv
for k, v := range regionCount {
sorted = append(sorted, kv{k, v})
}
sort.Slice(sorted, func(i, j int) bool { return sorted[i].v > sorted[j].v })
if topN > 0 && len(sorted) > topN {
sorted = sorted[:topN]
}
result := make([]model.GeoDistribution, len(sorted))
for i, kv := range sorted {
pct := 0.0
if total > 0 {
pct = float64(kv.v) / float64(total) * 100
}
result[i] = model.GeoDistribution{
Region: kv.k,
Count: kv.v,
Pct: math.Round(pct*100) / 100,
}
}
return result
}
// Concurrency 并发统计
func (e *Engine) Concurrency() model.ConcurrencyStats {
e.mu.RLock()
defer e.mu.RUnlock()
cur := atomic.LoadInt64(&e.activeConns)
peak := atomic.LoadInt64(&e.peakConns)
avg := 0.0
if len(e.connSamples) > 0 {
var sum int64
for _, s := range e.connSamples {
sum += s.Value
}
avg = float64(sum) / float64(len(e.connSamples))
}
return model.ConcurrencyStats{
Current: cur,
Peak: peak,
Avg: math.Round(avg*100) / 100,
Samples: e.connSamples,
}
}
// Latency 延迟统计
func (e *Engine) Latency() model.LatencyStats {
e.mu.RLock()
defer e.mu.RUnlock()
recs := e.recentRecords(5 * time.Minute)
if len(recs) == 0 {
return model.LatencyStats{}
}
latencies := make([]float64, len(recs))
var sum, maxVal, minVal float64
minVal = math.MaxFloat64
for i, r := range recs {
ms := float64(r.Latency.Microseconds()) / 1000.0
latencies[i] = ms
sum += ms
if ms > maxVal {
maxVal = ms
}
if ms < minVal {
minVal = ms
}
}
sort.Float64s(latencies)
n := len(latencies)
percentile := func(p float64) float64 {
idx := int(math.Ceil(p*float64(n))) - 1
if idx < 0 {
idx = 0
}
if idx >= n {
idx = n - 1
}
return latencies[idx]
}
return model.LatencyStats{
Avg: math.Round(sum/float64(n)*100) / 100,
P50: math.Round(percentile(0.50)*100) / 100,
P90: math.Round(percentile(0.90)*100) / 100,
P95: math.Round(percentile(0.95)*100) / 100,
P99: math.Round(percentile(0.99)*100) / 100,
Max: math.Round(maxVal*100) / 100,
Min: math.Round(minVal*100) / 100,
Count: int64(n),
}
}
// StatusDist 状态码分布
func (e *Engine) StatusDist() []model.StatusDistribution {
e.mu.RLock()
defer e.mu.RUnlock()
counts := map[string]int64{"2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0}
var total int64
recs := e.recentRecords(time.Hour)
for _, r := range recs {
switch {
case r.StatusCode >= 500:
counts["5xx"]++
case r.StatusCode >= 400:
counts["4xx"]++
case r.StatusCode >= 300:
counts["3xx"]++
default:
counts["2xx"]++
}
total++
}
order := []string{"2xx", "3xx", "4xx", "5xx"}
result := make([]model.StatusDistribution, 0, 4)
for _, code := range order {
ct := counts[code]
if ct > 0 || total > 0 {
pct := 0.0
if total > 0 {
pct = float64(ct) / float64(total) * 100
}
result = append(result, model.StatusDistribution{
Code: code,
Count: ct,
Pct: math.Round(pct*100) / 100,
})
}
}
return result
}
// TopEndpoints Top N 端点
func (e *Engine) TopEndpoints(topN int) []model.EndpointStats {
e.mu.RLock()
defer e.mu.RUnlock()
type kv struct {
k string
v *endpointAgg
}
var pairs []kv
for k, v := range e.endpointStats {
pairs = append(pairs, kv{k, v})
}
sort.Slice(pairs, func(i, j int) bool { return pairs[i].v.Count > pairs[j].v.Count })
if topN > 0 && len(pairs) > topN {
pairs = pairs[:topN]
}
result := make([]model.EndpointStats, len(pairs))
for i, p := range pairs {
avgLat := 0.0
if p.v.Count > 0 {
avgLat = float64(p.v.TotalLatency.Microseconds()) / float64(p.v.Count) / 1000.0
}
errRate := 0.0
if p.v.Count > 0 {
errRate = float64(p.v.Errors) / float64(p.v.Count) * 100
}
result[i] = model.EndpointStats{
Path: p.k,
Count: p.v.Count,
AvgLatency: math.Round(avgLat*100) / 100,
ErrorRate: math.Round(errRate*100) / 100,
}
}
return result
}
// TopIPs Top N IP
func (e *Engine) TopIPs(topN int) []model.TopIP {
e.mu.RLock()
defer e.mu.RUnlock()
type kv struct {
ip string
ct int64
}
var pairs []kv
for ip, ct := range e.ipCount {
pairs = append(pairs, kv{ip, ct})
}
sort.Slice(pairs, func(i, j int) bool { return pairs[i].ct > pairs[j].ct })
if topN > 0 && len(pairs) > topN {
pairs = pairs[:topN]
}
result := make([]model.TopIP, len(pairs))
for i, p := range pairs {
region := "未知"
if e.geoip != nil {
region = e.geoip.Lookup(p.ip).Region
}
result[i] = model.TopIP{
IP: p.ip,
Region: region,
Count: p.ct,
}
}
return result
}
// Realtime 实时统计
func (e *Engine) Realtime() model.RealtimeStats {
e.mu.RLock()
defer e.mu.RUnlock()
active := atomic.LoadInt64(&e.activeConns)
recs := e.recentRecords(time.Minute)
var totalLatMs float64
errors := int64(0)
for _, r := range recs {
totalLatMs += float64(r.Latency.Microseconds()) / 1000.0
if r.StatusCode >= 400 {
errors++
}
}
avgLat := 0.0
if len(recs) > 0 {
avgLat = totalLatMs / float64(len(recs))
}
qps := float64(len(recs)) / 60.0
errRate := 0.0
if len(recs) > 0 {
errRate = float64(errors) / float64(len(recs)) * 100
}
return model.RealtimeStats{
QPS: math.Round(qps*100) / 100,
ActiveConn: active,
AvgLatencyMs: math.Round(avgLat*100) / 100,
LastMinuteCount: int64(len(recs)),
ErrorRate: math.Round(errRate*100) / 100,
}
}
// ── 内部辅助 ──────────────────────────────────────────────────
// recentRecords 获取最近 duration 内的记录(调用方需持有锁)
func (e *Engine) recentRecords(d time.Duration) []model.RequestRecord {
cutoff := time.Now().Add(-d)
for i := len(e.records) - 1; i >= 0; i-- {
if e.records[i].Timestamp.Before(cutoff) {
return e.records[i+1:]
}
}
return e.records
}
// recordsInRange 获取时间范围内的记录(调用方需持有锁)
func (e *Engine) recordsInRange(start, end time.Time) []model.RequestRecord {
if len(e.records) == 0 {
return nil
}
// 二分查找起始位置
startIdx := sort.Search(len(e.records), func(i int) bool {
return !e.records[i].Timestamp.Before(start)
})
if startIdx >= len(e.records) {
return nil
}
// 收集范围内的记录
var result []model.RequestRecord
for i := startIdx; i < len(e.records); i++ {
if e.records[i].Timestamp.After(end) {
break
}
result = append(result, e.records[i])
}
return result
}
// LogStats 定期输出统计摘要到日志
func (e *Engine) LogStats() {
o := e.Overview()
log.Printf("[stats] 总请求=%d | 活跃=%d | QPS=%.1f | 错误率=%.1f%% | 延迟=%.1fms | 独立IP=%d",
o.TotalRequests, o.ActiveConn, o.RequestsPerSec, o.ErrorRate, o.AvgLatency, o.UniqueIPs)
}

140
internal/analytics/geoip.go Normal file
View File

@@ -0,0 +1,140 @@
package analytics
import (
"fmt"
"log"
"strings"
"sync"
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
)
// GeoIP 基于 ip2region 的 IP 地理位置解析器
type GeoIP struct {
mu sync.RWMutex
searcher *xdb.Searcher
enabled bool
cache map[string]*GeoInfo
}
// GeoInfo IP 地理位置信息
type GeoInfo struct {
Country string
Province string
City string
ISP string
Region string
}
// NewGeoIP 创建解析器dbPath 是 ip2region.xdb 路径
func NewGeoIP(dbPath string, enableCache bool) (*GeoIP, error) {
g := &GeoIP{
enabled: true,
cache: make(map[string]*GeoInfo),
}
searcher, err := xdb.NewWithFileOnly(xdb.IPv4, dbPath)
if err != nil {
log.Printf("[geoip] 无法加载 %s: %v禁用 IP 解析", dbPath, err)
g.enabled = false
return g, nil
}
g.searcher = searcher
log.Printf("[geoip] 已加载 IP 地理位置库: %s", dbPath)
return g, nil
}
// Lookup 查询 IP 对应的地理位置
func (g *GeoIP) Lookup(ip string) *GeoInfo {
if !g.enabled || g.searcher == nil {
return &GeoInfo{Region: "未知"}
}
if isPrivateIP(ip) {
return &GeoInfo{Region: "内网", Country: "内网", Province: "内网", City: "内网"}
}
g.mu.RLock()
if info, ok := g.cache[ip]; ok {
g.mu.RUnlock()
return info
}
g.mu.RUnlock()
region, err := g.searcher.Search(ip)
if err != nil {
return &GeoInfo{Region: "未知"}
}
info := parseRegion(region)
g.mu.Lock()
g.cache[ip] = info
if len(g.cache) > 50000 {
g.cache = make(map[string]*GeoInfo)
}
g.mu.Unlock()
return info
}
// parseRegion 解析 ip2region 返回的 "国家|区域|省份|城市|ISP" 格式
func parseRegion(raw string) *GeoInfo {
parts := strings.Split(raw, "|")
if len(parts) < 5 {
return &GeoInfo{Region: raw}
}
info := &GeoInfo{
Country: parts[0],
Province: parts[2],
City: parts[3],
ISP: parts[4],
}
regionParts := []string{}
if parts[0] != "0" && parts[0] != "" {
regionParts = append(regionParts, parts[0])
}
if parts[2] != "0" && parts[2] != "" {
regionParts = append(regionParts, parts[2])
}
if parts[3] != "0" && parts[3] != "" {
regionParts = append(regionParts, parts[3])
}
if len(regionParts) == 0 {
info.Region = "未知"
} else {
info.Region = strings.Join(regionParts, "-")
}
return info
}
// isPrivateIP 判断是否为内网 IP
func isPrivateIP(ip string) bool {
if strings.HasPrefix(ip, "10.") ||
strings.HasPrefix(ip, "172.16.") ||
strings.HasPrefix(ip, "192.168.") ||
strings.HasPrefix(ip, "127.") ||
ip == "::1" ||
strings.HasPrefix(ip, "fe80:") {
return true
}
return false
}
// Close 释放资源
func (g *GeoIP) Close() {
if g.searcher != nil {
g.searcher.Close()
}
}
// FormatRegion 用于日志/展示的短格式
func (gi *GeoInfo) FormatRegion() string {
if gi.Region == "内网" || gi.Region == "未知" {
return gi.Region
}
return fmt.Sprintf("%s [%s]", gi.Region, gi.ISP)
}

87
internal/config/config.go Normal file
View File

@@ -0,0 +1,87 @@
package config
import (
"time"
"github.com/spf13/viper"
)
// Config 全局配置
type Config struct {
Server ServerConfig
Analytics AnalyticsConfig
GeoIP GeoIPConfig
}
// ServerConfig HTTP 服务配置
type ServerConfig struct {
Host string
Port int
ReadTimeout time.Duration
WriteTimeout time.Duration
Mode string // debug / release / test
}
// AnalyticsConfig 统计配置
type AnalyticsConfig struct {
Retention time.Duration
CleanupInterval time.Duration
Buckets int // 时间序列分桶数
}
// GeoIPConfig IP 地理位置配置
type GeoIPConfig struct {
Enabled bool
DBPath string
}
// Default 默认配置
func Default() *Config {
return &Config{
Server: ServerConfig{
Host: "0.0.0.0",
Port: 8080,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
Mode: "release",
},
Analytics: AnalyticsConfig{
Retention: 7 * 24 * time.Hour,
CleanupInterval: 10 * time.Minute,
Buckets: 60,
},
GeoIP: GeoIPConfig{
Enabled: true,
DBPath: "./data/ip2region.xdb",
},
}
}
// Load 从 viper 加载,合并默认值
func Load(v *viper.Viper) *Config {
cfg := Default()
if v.IsSet("host") {
cfg.Server.Host = v.GetString("host")
}
if v.IsSet("port") {
cfg.Server.Port = v.GetInt("port")
}
if v.IsSet("mode") {
cfg.Server.Mode = v.GetString("mode")
}
if v.IsSet("retention") {
cfg.Analytics.Retention = v.GetDuration("retention")
}
if v.IsSet("buckets") {
cfg.Analytics.Buckets = v.GetInt("buckets")
}
if v.IsSet("geoip") {
cfg.GeoIP.Enabled = v.GetBool("geoip")
}
if v.IsSet("geoip_db") {
cfg.GeoIP.DBPath = v.GetString("geoip_db")
}
return cfg
}

114
internal/handler/content.go Normal file
View File

@@ -0,0 +1,114 @@
package handler
import (
"net/http"
"strconv"
"api-server/internal/scraper"
"github.com/gin-gonic/gin"
)
// ContentHandler 内容列表 API 处理器
type ContentHandler struct {
authorSession *scraper.Session
categorySession *scraper.Session
}
// NewContentHandler 创建内容处理器
func NewContentHandler() *ContentHandler {
return &ContentHandler{
authorSession: scraper.NewSession(),
categorySession: scraper.NewSession(),
}
}
// RegisterRoutes 注册路由
func (h *ContentHandler) RegisterRoutes(r *gin.RouterGroup) {
r.GET("/categories", h.ListCategories)
r.GET("/category/:code", h.CategoryVideos)
r.GET("/author/:uid", h.AuthorVideos)
}
// ListCategories GET /api/categories
func (h *ContentHandler) ListCategories(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"data": scraper.Categories,
})
}
// CategoryVideos GET /api/category/:code?page=1&viewtype=basic&max_pages=0
func (h *ContentHandler) CategoryVideos(c *gin.Context) {
code := c.Param("code")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
viewtype := c.DefaultQuery("viewtype", "basic")
maxPages := scraper.ParseMaxPages(c.DefaultQuery("max_pages", "0"))
allMode := c.Query("all") == "true"
// 验证分类代码
valid := false
for _, cat := range scraper.Categories {
if cat.Code == code {
valid = true
break
}
}
if !valid {
c.JSON(http.StatusBadRequest, gin.H{
"code": 1,
"msg": "无效的分类代码,可用: rf, top, ori, hot, long, longer, tf, hd, md, mf",
})
return
}
var result *scraper.CategoryResult
var err error
if allMode || maxPages > 1 {
result, err = scraper.ExtractCategoryAll(h.categorySession, code, viewtype, maxPages)
} else {
result, err = scraper.ExtractCategory(h.categorySession, code, page, viewtype)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 1,
"msg": "提取失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "data": result})
}
// AuthorVideos GET /api/author/:uid?page=1&all=false
func (h *ContentHandler) AuthorVideos(c *gin.Context) {
uid := c.Param("uid")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
allMode := c.Query("all") == "true"
if uid == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请提供作者 UID"})
return
}
var result *scraper.AuthorResult
var err error
if allMode {
result, err = scraper.ExtractAuthorAll(h.authorSession, uid)
} else {
result, err = scraper.ExtractAuthor(h.authorSession, uid, page)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 1,
"msg": "提取失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "data": result})
}

116
internal/handler/extract.go Normal file
View File

@@ -0,0 +1,116 @@
package handler
import (
"net/http"
"strings"
"sync"
"api-server/internal/scraper"
"github.com/gin-gonic/gin"
)
// ExtractHandler 视频提取 API 处理器
type ExtractHandler struct {
session *scraper.Session
}
// NewExtractHandler 创建提取处理器
func NewExtractHandler() *ExtractHandler {
return &ExtractHandler{
session: scraper.NewSession(),
}
}
// RegisterRoutes 注册路由
func (h *ExtractHandler) RegisterRoutes(r *gin.RouterGroup) {
r.POST("/extract", h.ExtractSingle)
r.POST("/extract/batch", h.ExtractBatch)
}
// ExtractSingle POST /api/extract
// Body: {"url": "https://h1014.sol148.com/view_video.php?viewkey=xxx"}
func (h *ExtractHandler) ExtractSingle(c *gin.Context) {
var req struct {
URL string `json:"url" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请提供视频 URL"})
return
}
info, err := scraper.ExtractVideo(h.session, req.URL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 1,
"msg": "提取失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "data": info})
}
// ExtractBatch POST /api/extract/batch
// Body: {"urls": ["url1", "url2", ...], "concurrency": 3}
func (h *ExtractHandler) ExtractBatch(c *gin.Context) {
var req struct {
URLs []string `json:"urls" binding:"required"`
Concurrency int `json:"concurrency"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "请提供 URL 列表"})
return
}
if req.Concurrency <= 0 {
req.Concurrency = 3
}
if req.Concurrency > 10 {
req.Concurrency = 10
}
type result struct {
URL string `json:"url"`
Data *scraper.VideoInfo `json:"data,omitempty"`
Err string `json:"error,omitempty"`
}
results := make([]result, len(req.URLs))
sem := make(chan struct{}, req.Concurrency)
var wg sync.WaitGroup
for i, u := range req.URLs {
wg.Add(1)
go func(idx int, urlStr string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
// 每个 goroutine 使用独立 session
s := scraper.NewSession()
info, err := scraper.ExtractVideo(s, strings.TrimSpace(urlStr))
if err != nil {
results[idx] = result{URL: urlStr, Err: err.Error()}
} else {
results[idx] = result{URL: urlStr, Data: info}
}
}(i, u)
}
wg.Wait()
success := 0
for _, r := range results {
if r.Err == "" {
success++
}
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"total": len(req.URLs),
"success": success,
"data": results,
})
}

View File

@@ -0,0 +1,33 @@
package handler
import (
"net/http"
"time"
"api-server/internal/config"
"github.com/gin-gonic/gin"
)
// Handler 基础处理器
type Handler struct {
Cfg *config.Config
}
// New 创建处理器
func New(cfg *config.Config) *Handler {
return &Handler{Cfg: cfg}
}
// Health 健康检查
func (h *Handler) Health(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"time": time.Now().UTC().Format(time.RFC3339),
})
}
// Ping 简单 ping
func (h *Handler) Ping(c *gin.Context) {
c.String(http.StatusOK, "pong")
}

144
internal/handler/stats.go Normal file
View File

@@ -0,0 +1,144 @@
package handler
import (
"net/http"
"strconv"
"time"
"api-server/internal/analytics"
"github.com/gin-gonic/gin"
)
// StatsHandler 统计分析 API 处理器
type StatsHandler struct {
Engine *analytics.Engine
}
// NewStats 创建统计处理器
func NewStats(engine *analytics.Engine) *StatsHandler {
return &StatsHandler{Engine: engine}
}
// RegisterRoutes 注册统计相关路由
func (h *StatsHandler) RegisterRoutes(r *gin.RouterGroup) {
stats := r.Group("/stats")
{
stats.GET("/overview", h.Overview)
stats.GET("/requests", h.TimeSeries)
stats.GET("/geo", h.GeoDistribution)
stats.GET("/concurrency", h.Concurrency)
stats.GET("/latency", h.Latency)
stats.GET("/status", h.StatusDist)
stats.GET("/endpoints", h.TopEndpoints)
stats.GET("/top-ips", h.TopIPs)
stats.GET("/realtime", h.Realtime)
}
}
// Overview 总览统计 GET /api/stats/overview
func (h *StatsHandler) Overview(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"data": h.Engine.Overview(),
})
}
// TimeSeries 时间序列 GET /api/stats/requests?period=1h&buckets=60
func (h *StatsHandler) TimeSeries(c *gin.Context) {
period := parseDuration(c.DefaultQuery("period", "1h"))
buckets, _ := strconv.Atoi(c.DefaultQuery("buckets", "60"))
if buckets <= 0 || buckets > 500 {
buckets = 60
}
c.JSON(http.StatusOK, gin.H{
"code": 0,
"data": h.Engine.TimeSeries(period, buckets),
})
}
// GeoDistribution 地理分布 GET /api/stats/geo?top=10
func (h *StatsHandler) GeoDistribution(c *gin.Context) {
top, _ := strconv.Atoi(c.DefaultQuery("top", "10"))
c.JSON(http.StatusOK, gin.H{
"code": 0,
"data": h.Engine.GeoDistribution(top),
})
}
// Concurrency 并发统计 GET /api/stats/concurrency
func (h *StatsHandler) Concurrency(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"data": h.Engine.Concurrency(),
})
}
// Latency 延迟统计 GET /api/stats/latency
func (h *StatsHandler) Latency(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"data": h.Engine.Latency(),
})
}
// StatusDist 状态码分布 GET /api/stats/status
func (h *StatsHandler) StatusDist(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"data": h.Engine.StatusDist(),
})
}
// TopEndpoints Top 端点 GET /api/stats/endpoints?top=10
func (h *StatsHandler) TopEndpoints(c *gin.Context) {
top, _ := strconv.Atoi(c.DefaultQuery("top", "10"))
c.JSON(http.StatusOK, gin.H{
"code": 0,
"data": h.Engine.TopEndpoints(top),
})
}
// TopIPs Top IP GET /api/stats/top-ips?top=10
func (h *StatsHandler) TopIPs(c *gin.Context) {
top, _ := strconv.Atoi(c.DefaultQuery("top", "10"))
c.JSON(http.StatusOK, gin.H{
"code": 0,
"data": h.Engine.TopIPs(top),
})
}
// Realtime 实时统计 GET /api/stats/realtime
func (h *StatsHandler) Realtime(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"data": h.Engine.Realtime(),
})
}
// ── 辅助 ──────────────────────────────────────────────────────
func parseDuration(s string) time.Duration {
// 支持: 1h, 24h, 7d, 30m, 1d
if d, err := time.ParseDuration(s); err == nil {
return d
}
// 自定义解析
if len(s) >= 2 {
val, err := strconv.Atoi(s[:len(s)-1])
if err != nil {
return time.Hour
}
unit := s[len(s)-1:]
switch unit {
case "d":
return time.Duration(val) * 24 * time.Hour
case "h":
return time.Duration(val) * time.Hour
case "m":
return time.Duration(val) * time.Minute
}
}
return time.Hour
}

View File

@@ -0,0 +1,44 @@
package middleware
import (
"time"
"api-server/internal/analytics"
"api-server/internal/model"
"github.com/gin-gonic/gin"
)
// Analytics 返回 Gin 中间件,用于采集请求统计
func Analytics(engine *analytics.Engine) gin.HandlerFunc {
return func(c *gin.Context) {
// 连接进入
engine.ConnEnter()
defer engine.ConnLeave()
start := time.Now()
// 处理请求
c.Next()
// 收集指标
latency := time.Since(start)
bodySize := c.Writer.Size()
if bodySize < 0 {
bodySize = 0
}
record := model.RequestRecord{
Timestamp: start,
Method: c.Request.Method,
Path: c.Request.URL.Path,
StatusCode: c.Writer.Status(),
Latency: latency,
ClientIP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
BodyBytes: int64(bodySize),
}
engine.Record(record)
}
}

107
internal/model/stats.go Normal file
View File

@@ -0,0 +1,107 @@
package model
import "time"
// ── 请求记录 ──────────────────────────────────────────────────
// RequestRecord 单次请求的完整记录
type RequestRecord struct {
Timestamp time.Time `json:"timestamp"`
Method string `json:"method"`
Path string `json:"path"`
StatusCode int `json:"status_code"`
Latency time.Duration `json:"latency_ms"`
ClientIP string `json:"client_ip"`
UserAgent string `json:"user_agent"`
Region string `json:"region"` // 国家|省份|城市
ISP string `json:"isp"` // 运营商
BodyBytes int64 `json:"body_bytes"` // 响应体大小
}
// ── 统计 API 响应模型 ──────────────────────────────────────────
// OverviewStats 总览统计
type OverviewStats struct {
TotalRequests int64 `json:"total_requests"` // 总请求数
ActiveConn int64 `json:"active_conn"` // 当前活跃连接数
AvgLatency float64 `json:"avg_latency_ms"` // 平均延迟 (ms)
ErrorRate float64 `json:"error_rate"` // 错误率 (4xx+5xx)
RequestsPerSec float64 `json:"requests_per_sec"` // 每秒请求数 (QPS)
Uptime time.Duration `json:"uptime_seconds"` // 运行时长 (秒)
UniqueIPs int64 `json:"unique_ips"` // 独立 IP 数
TotalTraffic int64 `json:"total_traffic_mb"` // 总流量 (MB)
LastRequestTime time.Time `json:"last_request_time"` // 最近一次请求时间
}
// TimeSeriesPoint 时间序列数据点
type TimeSeriesPoint struct {
Time string `json:"time"` // YYYY-MM-DD HH:MM
Count int64 `json:"count"` // 请求数
Errors int64 `json:"errors"` // 错误数
AvgMs float64 `json:"avg_ms"` // 平均延迟
}
// GeoDistribution 地理分布
type GeoDistribution struct {
Region string `json:"region"` // 区域名
Count int64 `json:"count"` // 请求数
Pct float64 `json:"pct"` // 占比
}
// ConcurrencyStats 并发统计
type ConcurrencyStats struct {
Current int64 `json:"current"` // 当前并发
Peak int64 `json:"peak"` // 历史峰值
Avg float64 `json:"avg"` // 平均并发
Samples []ConcurrencySample `json:"samples"` // 采样数据
}
// ConcurrencySample 并发采样点
type ConcurrencySample struct {
Time string `json:"time"`
Value int64 `json:"value"`
}
// LatencyStats 延迟统计
type LatencyStats struct {
Avg float64 `json:"avg_ms"`
P50 float64 `json:"p50_ms"`
P90 float64 `json:"p90_ms"`
P95 float64 `json:"p95_ms"`
P99 float64 `json:"p99_ms"`
Max float64 `json:"max_ms"`
Min float64 `json:"min_ms"`
Count int64 `json:"count"`
}
// StatusDistribution 状态码分布
type StatusDistribution struct {
Code string `json:"code"` // "2xx", "3xx", "4xx", "5xx"
Count int64 `json:"count"`
Pct float64 `json:"pct"`
}
// EndpointStats 端点统计
type EndpointStats struct {
Path string `json:"path"`
Method string `json:"method"`
Count int64 `json:"count"`
AvgLatency float64 `json:"avg_latency_ms"`
ErrorRate float64 `json:"error_rate"`
}
// TopIP 高频 IP
type TopIP struct {
IP string `json:"ip"`
Region string `json:"region"`
Count int64 `json:"count"`
}
// RealtimeStats 实时统计
type RealtimeStats struct {
QPS float64 `json:"qps"`
ActiveConn int64 `json:"active_conn"`
AvgLatencyMs float64 `json:"avg_latency_ms"`
LastMinuteCount int64 `json:"last_minute_count"`
ErrorRate float64 `json:"error_rate"`
}

182
internal/scraper/author.go Normal file
View File

@@ -0,0 +1,182 @@
package scraper
import (
"fmt"
"io"
"math"
"regexp"
"strings"
)
const PerPage = 8
// ── 正则 ────────────────────────────────────────────────────
var (
reAuthorVideoLink = regexp.MustCompile(`<a\s+href="([^"]*view_video\.php\?own=1&viewkey=([^"&\s]+)[^"]*)"`)
reAuthorThumb = regexp.MustCompile(`(?i)<img[^>]*src="([^"]+)"`)
reAuthorDuration = regexp.MustCompile(`<span\s+class="duration"[^>]*>([\d:]+)</span>`)
reAuthorTitle = regexp.MustCompile(`(?s)<span\s+class="video-title[^"]*"[^>]*>(.*?)</span>`)
reAuthorWellBlock = regexp.MustCompile(`(?s)<div\s+class="well\s+well-sm">(.*?)</div>\s*</div>`)
reAuthorViews = regexp.MustCompile(`(?i)<span\s+class="info">(?:热度|Views):</span>\s*(\d[\d,]*)`)
reAuthorFavorites = regexp.MustCompile(`(?i)<span\s+class="info">(?:收藏|Favorites):</span>\s*(\d[\d,]*)`)
reAuthorComments = regexp.MustCompile(`(?i)<span\s+class="info">(?:留言|Comments):</span>\s*(\d[\d,]*)`)
reAuthorPoint = regexp.MustCompile(`(?i)<span\s+class="info">(?:积分|Point):</span>\s*(\d[\d,]*)`)
reAuthorAdded = regexp.MustCompile(`(?i)<span\s+class="info">(?:添加时间|Added):</span>\s*([^<]+)`)
reAuthorPageInfo = regexp.MustCompile(`视频\s*\d+-\d+\s*的\s*(\d+)`)
reAuthorName = regexp.MustCompile(`(?i)<span\s+class="title[^"]*"[^>]*>\s*([^<\n]+)`)
reAuthorTotalVids = regexp.MustCompile(`(?i)<small>\s*\((\d+)[^)]*\)\s*</small>`)
)
// ── 数据模型 ────────────────────────────────────────────────
// AuthorVideoItem 作者视频条目
type AuthorVideoItem struct {
Viewkey string `json:"viewkey"`
URL string `json:"url"`
Title string `json:"title"`
Duration string `json:"duration"`
Thumbnail string `json:"thumbnail"`
Views int64 `json:"views"`
Favorites int64 `json:"favorites"`
Comments int64 `json:"comments"`
Point int64 `json:"point"`
Added string `json:"added"`
}
// AuthorResult 作者视频列表结果
type AuthorResult struct {
UID string `json:"uid"`
Page int `json:"page"`
AuthorName string `json:"author_name"`
TotalVideos int `json:"total_videos"`
TotalPages int `json:"total_pages"`
Items []AuthorVideoItem `json:"items"`
}
// ── 提取器 ──────────────────────────────────────────────────
// ExtractAuthor 提取作者视频列表
func ExtractAuthor(s *Session, uid string, page int) (*AuthorResult, error) {
url := fmt.Sprintf("%s/uvideos.php?UID=%s&type=public&page=%d", BaseURL, uid, page)
resp, err := s.Get(url)
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
html := string(body)
result := &AuthorResult{
UID: uid,
Page: page,
}
// 解析作者名
if m := reAuthorName.FindStringSubmatch(html); m != nil {
result.AuthorName = strings.TrimSpace(m[1])
}
// 解析总视频数
if m := reAuthorTotalVids.FindStringSubmatch(html); m != nil {
result.TotalVideos = int(parseInt(m[1]))
}
// 解析分页信息
if m := reAuthorPageInfo.FindStringSubmatch(html); m != nil {
total := int(parseInt(m[1]))
result.TotalPages = int(math.Ceil(float64(total) / PerPage))
if result.TotalVideos == 0 {
result.TotalVideos = total
}
} else if result.TotalVideos > 0 {
result.TotalPages = int(math.Ceil(float64(result.TotalVideos) / PerPage))
}
// 解析视频条目
blocks := reAuthorWellBlock.FindAllStringSubmatch(html, -1)
seen := make(map[string]bool)
for _, block := range blocks {
if len(block) < 2 {
continue
}
item := parseAuthorItem(block[1])
if item.Viewkey == "" || seen[item.Viewkey] {
continue
}
seen[item.Viewkey] = true
result.Items = append(result.Items, item)
}
return result, nil
}
// ExtractAuthorAll 提取作者全部页面
func ExtractAuthorAll(s *Session, uid string) (*AuthorResult, error) {
// 先获取第1页来探测总页数
first, err := ExtractAuthor(s, uid, 1)
if err != nil {
return nil, err
}
totalPages := first.TotalPages
if totalPages <= 1 {
return first, nil
}
// 收集剩余页面
for p := 2; p <= totalPages; p++ {
pageResult, err := ExtractAuthor(s, uid, p)
if err != nil {
// 容忍部分页面失败
continue
}
first.Items = append(first.Items, pageResult.Items...)
}
first.Page = 0 // 表示全量
return first, nil
}
func parseAuthorItem(html string) AuthorVideoItem {
item := AuthorVideoItem{}
if m := reAuthorVideoLink.FindStringSubmatch(html); m != nil {
item.URL = m[1]
if !strings.HasPrefix(item.URL, "http") {
item.URL = BaseURL + "/" + strings.TrimLeft(item.URL, "/")
}
item.Viewkey = m[2]
}
if m := reAuthorThumb.FindStringSubmatch(html); m != nil {
item.Thumbnail = m[1]
}
if m := reAuthorDuration.FindStringSubmatch(html); m != nil {
item.Duration = m[1]
}
if m := reAuthorTitle.FindStringSubmatch(html); m != nil {
item.Title = strings.TrimSpace(m[1])
}
if m := reAuthorViews.FindStringSubmatch(html); m != nil {
item.Views = parseInt(m[1])
}
if m := reAuthorFavorites.FindStringSubmatch(html); m != nil {
item.Favorites = parseInt(m[1])
}
if m := reAuthorComments.FindStringSubmatch(html); m != nil {
item.Comments = parseInt(m[1])
}
if m := reAuthorPoint.FindStringSubmatch(html); m != nil {
item.Point = parseInt(m[1])
}
if m := reAuthorAdded.FindStringSubmatch(html); m != nil {
item.Added = strings.TrimSpace(m[1])
}
return item
}

View File

@@ -0,0 +1,244 @@
package scraper
import (
"fmt"
"io"
"regexp"
"strings"
)
// ── 分类定义 ────────────────────────────────────────────────
// CategoryInfo 分类信息
type CategoryInfo struct {
Code string `json:"code"`
Name string `json:"name"`
Desc string `json:"desc"`
}
// Categories 所有分类
var Categories = []CategoryInfo{
{Code: "rf", Name: "最近更新", Desc: "最新发布的视频"},
{Code: "top", Name: "本月最热", Desc: "当月热度最高的视频"},
{Code: "ori", Name: "原创", Desc: "原创内容"},
{Code: "hot", Name: "收藏最多", Desc: "收藏数最高的视频"},
{Code: "long", Name: "10分钟以上", Desc: "时长超过10分钟的长视频"},
{Code: "longer", Name: "20分钟以上", Desc: "时长超过20分钟的超长视频"},
{Code: "tf", Name: "最近讨论", Desc: "讨论度最高的视频"},
{Code: "hd", Name: "高清", Desc: "高清晰度视频"},
{Code: "md", Name: "每月讨论", Desc: "本月月度精选"},
{Code: "mf", Name: "收藏最多(月)", Desc: "月度收藏排行榜"},
}
// CategoryName 根据代码返回中文名
func CategoryName(code string) string {
for _, c := range Categories {
if c.Code == code {
return c.Name
}
}
return code
}
// ── 正则 ────────────────────────────────────────────────────
var (
reCatVideoLink = regexp.MustCompile(`(?s)<a\s+href="(https?://[^"]*view_video\.php\?viewkey=([^"&\s]+)[^"]*)"\s*>(.*?)</a>`)
reCatThumb = regexp.MustCompile(`(?i)<img\s[^>]*src="([^"]+)"`)
reCatDuration = regexp.MustCompile(`<span\s+class="duration"[^>]*>([\d:]+)</span>`)
reCatTitle = regexp.MustCompile(`(?s)<span\s+class="video-title[^"]*"[^>]*>(.*?)</span>`)
reCatWellStart = regexp.MustCompile(`<div\s+class="well\s+well-sm\s+videos-text-align">`)
reCatAuthor = regexp.MustCompile(`(?i)<span\s+class="info">作者:</span>\s*([^<]+)`)
reCatViews = regexp.MustCompile(`(?i)<span\s+class="info">热度:</span>\s*(\d[\d,]*)`)
reCatFavorites = regexp.MustCompile(`(?i)<span\s+class="info">收藏:</span>\s*(\d[\d,]*)`)
reCatComments = regexp.MustCompile(`(?i)<span\s+class="info">留言:</span>\s*(\d[\d,]*)`)
reCatAdded = regexp.MustCompile(`(?i)<span\s+class="info">添加时间:</span>\s*([^<]+)`)
reCatPageLinks = regexp.MustCompile(`page=(\d+)`)
)
// ── 数据模型 ────────────────────────────────────────────────
// CategoryVideoItem 分类视频条目
type CategoryVideoItem struct {
Viewkey string `json:"viewkey"`
URL string `json:"url"`
Title string `json:"title"`
Duration string `json:"duration"`
Thumbnail string `json:"thumbnail"`
Author string `json:"author"`
Views int64 `json:"views"`
Favorites int64 `json:"favorites"`
Comments int64 `json:"comments"`
Added string `json:"added"`
}
// CategoryResult 分类视频列表结果
type CategoryResult struct {
Category string `json:"category"`
Page int `json:"page"`
TotalPages int `json:"total_pages"`
Viewtype string `json:"viewtype"`
Items []CategoryVideoItem `json:"items"`
}
// ── 提取器 ──────────────────────────────────────────────────
// ExtractCategory 提取分类视频列表
func ExtractCategory(s *Session, code string, page int, viewtype string) (*CategoryResult, error) {
if viewtype == "" {
viewtype = "basic"
}
url := fmt.Sprintf("%s/v.php?category=%s&viewtype=%s&page=%d", BaseURL, code, viewtype, page)
resp, err := s.Get(url)
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
html := string(body)
result := &CategoryResult{
Category: code,
Page: page,
Viewtype: viewtype,
}
// 探测总页数
maxPage := 1
for _, m := range reCatPageLinks.FindAllStringSubmatch(html, -1) {
p := int(parseInt(m[1]))
if p > maxPage {
maxPage = p
}
}
result.TotalPages = maxPage
// 解析视频条目 — 用 well 块起始标记切分 HTML
blocks := splitWellBlocks(html)
seen := make(map[string]bool)
for _, block := range blocks {
if len(block) < 50 {
continue
}
item := parseCategoryItem(block)
if item.Viewkey == "" || seen[item.Viewkey] {
continue
}
seen[item.Viewkey] = true
result.Items = append(result.Items, item)
}
return result, nil
}
// ExtractCategoryAll 提取分类全部页面
func ExtractCategoryAll(s *Session, code string, viewtype string, maxPages int) (*CategoryResult, error) {
if viewtype == "" {
viewtype = "basic"
}
first, err := ExtractCategory(s, code, 1, viewtype)
if err != nil {
return nil, err
}
totalPages := first.TotalPages
if maxPages > 0 && maxPages < totalPages {
totalPages = maxPages
}
if totalPages <= 1 {
return first, nil
}
for p := 2; p <= totalPages; p++ {
pageResult, err := ExtractCategory(s, code, p, viewtype)
if err != nil {
continue
}
first.Items = append(first.Items, pageResult.Items...)
}
first.Page = 0
return first, nil
}
func parseCategoryItem(html string) CategoryVideoItem {
item := CategoryVideoItem{}
if m := reCatVideoLink.FindStringSubmatch(html); m != nil {
item.URL = m[1]
if !strings.HasPrefix(item.URL, "http") {
item.URL = BaseURL + "/" + strings.TrimLeft(item.URL, "/")
}
item.Viewkey = m[2]
}
if m := reCatThumb.FindStringSubmatch(html); m != nil {
item.Thumbnail = m[1]
}
if m := reCatDuration.FindStringSubmatch(html); m != nil {
item.Duration = m[1]
}
if m := reCatTitle.FindStringSubmatch(html); m != nil {
item.Title = strings.TrimSpace(m[1])
}
if m := reCatAuthor.FindStringSubmatch(html); m != nil {
item.Author = strings.TrimSpace(m[1])
}
if m := reCatViews.FindStringSubmatch(html); m != nil {
item.Views = parseInt(m[1])
}
if m := reCatFavorites.FindStringSubmatch(html); m != nil {
item.Favorites = parseInt(m[1])
}
if m := reCatComments.FindStringSubmatch(html); m != nil {
item.Comments = parseInt(m[1])
}
if m := reCatAdded.FindStringSubmatch(html); m != nil {
item.Added = strings.TrimSpace(m[1])
}
return item
}
// ParseMaxPages 从查询参数解析最大页数限制
func ParseMaxPages(s string) int {
if s == "" || s == "all" || s == "0" {
return 0
}
return int(parseInt(s))
}
// splitWellBlocks 按 well 块起始标签切分 HTML返回各块的内容
func splitWellBlocks(html string) []string {
var blocks []string
// 找到所有 well 块的起始位置
locs := reCatWellStart.FindAllStringIndex(html, -1)
if len(locs) == 0 {
return blocks
}
// 找到分页区域的位置作为终止边界
paginationStart := strings.Index(html, `<ul class="pagination`)
if paginationStart < 0 {
paginationStart = len(html)
}
for i, loc := range locs {
start := loc[1] // well 块起始标签之后
end := paginationStart
if i+1 < len(locs) {
end = locs[i+1][0] // 下一个 well 块起始之前
}
if end > start {
blocks = append(blocks, html[start:end])
}
}
return blocks
}

View File

@@ -0,0 +1,107 @@
package scraper
import (
"fmt"
"io"
"net/url"
"regexp"
"strconv"
"strings"
)
// ── 正则 ────────────────────────────────────────────────────
var (
reStrencode2 = regexp.MustCompile(`strencode2\s*\(\s*["']([^"']+)["']`)
reSourceSrc = regexp.MustCompile(`src\s*=\s*['"]([^'"]+\.mp4[^'"]*)['"]`)
reVideoID = regexp.MustCompile(`/(\d+)\.mp4`)
reTitle = regexp.MustCompile(`(?is)<title>\s*(.+?)\s*-\s*91porn\s*</title>`)
reDuration = regexp.MustCompile(`时长:\s*<span[^>]*>([\d:]+)</span>`)
reViews = regexp.MustCompile(`热度:\s*<span[^>]*>(\d[\d,]*)`)
reFavorites = regexp.MustCompile(`收藏:\s*<span[^>]*>(\d[\d,]*)`)
reAuthorLink = regexp.MustCompile(`(?i)<a\s+href\s*=\s*["']([^"']*uprofile\.php\?UID=[^"']+)["']\s*>\s*<span[^>]*>\s*([^<\n]+)`)
)
// ── 数据模型 ────────────────────────────────────────────────
// VideoInfo 视频源提取结果
type VideoInfo struct {
URL string `json:"url"`
VideoURL string `json:"video_url"`
VideoID string `json:"video_id"`
Title string `json:"title"`
Duration string `json:"duration"`
Views int64 `json:"views"`
Favorites int64 `json:"favorites"`
AuthorName string `json:"author_name"`
AuthorURL string `json:"author_url"`
}
// ── 提取器 ──────────────────────────────────────────────────
// ExtractVideo 从视频播放页 URL 提取正片源 + 元信息
func ExtractVideo(s *Session, videoURL string) (*VideoInfo, error) {
resp, err := s.Get(videoURL)
if err != nil {
return nil, fmt.Errorf("请求失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
html := string(body)
info := &VideoInfo{URL: videoURL}
// 提取视频源
m := reStrencode2.FindStringSubmatch(html)
if m == nil {
return nil, fmt.Errorf("未找到 strencode2 调用")
}
decoded, err := url.QueryUnescape(m[1])
if err != nil {
decoded = m[1]
}
m2 := reSourceSrc.FindStringSubmatch(decoded)
if m2 == nil {
return nil, fmt.Errorf("解码后未找到视频源 <source>")
}
info.VideoURL = m2[1]
// 提取 video_id
m3 := reVideoID.FindStringSubmatch(info.VideoURL)
if m3 != nil {
info.VideoID = m3[1]
}
// 提取元信息
if m := reTitle.FindStringSubmatch(html); m != nil {
info.Title = strings.TrimSpace(m[1])
}
if m := reDuration.FindStringSubmatch(html); m != nil {
info.Duration = m[1]
}
if m := reViews.FindStringSubmatch(html); m != nil {
info.Views = parseInt(m[1])
}
if m := reFavorites.FindStringSubmatch(html); m != nil {
info.Favorites = parseInt(m[1])
}
if m := reAuthorLink.FindStringSubmatch(html); m != nil {
info.AuthorURL = m[1]
if !strings.HasPrefix(info.AuthorURL, "http") {
info.AuthorURL = BaseURL + "/" + strings.TrimLeft(info.AuthorURL, "/")
}
info.AuthorName = strings.TrimSpace(m[2])
}
return info, nil
}
func parseInt(s string) int64 {
s = strings.ReplaceAll(s, ",", "")
v, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
return v
}

View File

@@ -0,0 +1,76 @@
package scraper
import (
"net/http"
"net/http/cookiejar"
"sync"
"time"
)
const BaseURL = "https://h1014.sol148.com"
var defaultHeaders = http.Header{
"User-Agent": {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"},
"Accept": {"text/html,application/xhtml+xml"},
"Accept-Language": {"zh-CN,zh;q=0.9"},
}
// Session 带预热机制的 HTTP 客户端(线程安全单例)
type Session struct {
mu sync.Mutex
client *http.Client
warmed bool
timeout time.Duration
}
// NewSession 创建 Session
func NewSession() *Session {
jar, _ := cookiejar.New(nil)
return &Session{
client: &http.Client{
Jar: jar,
Timeout: 30 * time.Second,
},
timeout: 30 * time.Second,
}
}
// Get 执行 GET 请求,自动预热
func (s *Session) Get(url string) (*http.Response, error) {
s.mu.Lock()
defer s.mu.Unlock()
if !s.warmed {
// 预热请求:获取 Cookie
req, _ := http.NewRequest("GET", BaseURL+"/", nil)
for k, vs := range defaultHeaders {
for _, v := range vs {
req.Header.Add(k, v)
}
}
resp, err := s.client.Do(req)
if err == nil {
resp.Body.Close()
}
s.warmed = true
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
for k, vs := range defaultHeaders {
for _, v := range vs {
req.Header.Add(k, v)
}
}
return s.client.Do(req)
}
// Reset 重置 Session用于隔离不同来源的请求
func (s *Session) Reset() {
s.mu.Lock()
defer s.mu.Unlock()
s.warmed = false
}

187
internal/server/server.go Normal file
View File

@@ -0,0 +1,187 @@
package server
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"api-server/internal/analytics"
"api-server/internal/config"
"api-server/internal/handler"
"api-server/internal/middleware"
"api-server/web"
"github.com/gin-gonic/gin"
)
// Server HTTP 服务
type Server struct {
cfg *config.Config
engine *gin.Engine
stats *analytics.Engine
geoip *analytics.GeoIP
srv *http.Server
}
// New 创建服务实例
func New(cfg *config.Config) (*Server, error) {
// 设置 gin 模式
gin.SetMode(cfg.Server.Mode)
// 初始化 IP 地理位置
geoip, err := analytics.NewGeoIP(cfg.GeoIP.DBPath, true)
if err != nil {
log.Printf("[server] 初始化 GeoIP 失败: %v", err)
geoip = nil
}
// 初始化统计引擎
statsEngine := analytics.NewEngine(geoip)
// 初始化 gin
router := gin.New()
// 全局中间件
router.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
return fmt.Sprintf("[%s] %s %s %d %s %s\n",
param.TimeStamp.Format("15:04:05"),
param.ClientIP,
param.Method,
param.StatusCode,
param.Latency,
param.Path,
)
}))
router.Use(gin.Recovery())
router.Use(middleware.Analytics(statsEngine))
// ── 注册核心业务 API ──────────────────────────────────
base := handler.New(cfg)
api := router.Group("/api")
{
api.GET("/health", base.Health)
api.GET("/ping", base.Ping)
}
// sol148-extractor 业务 API
extractHandler := handler.NewExtractHandler()
extractHandler.RegisterRoutes(api)
contentHandler := handler.NewContentHandler()
contentHandler.RegisterRoutes(api)
// 统计分析 API监控用
statsHandler := handler.NewStats(statsEngine)
statsHandler.RegisterRoutes(api)
// ── 嵌入式前端 ────────────────────────────────────────
fileServer := http.FileServer(http.FS(web.StaticFS))
// Dashboard 首页
router.GET("/", func(c *gin.Context) {
c.Request.URL.Path = "/index.html"
fileServer.ServeHTTP(c.Writer, c.Request)
})
// 视频网站入口
router.GET("/video", func(c *gin.Context) {
c.Request.URL.Path = "/videoapp/index.html"
fileServer.ServeHTTP(c.Writer, c.Request)
})
// NoRoute: SPA fallback
router.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
// 视频网站子路由 → videoapp/index.html
if path == "/video" || strings.HasPrefix(path, "/video/") {
c.Request.URL.Path = "/videoapp/index.html"
fileServer.ServeHTTP(c.Writer, c.Request)
return
}
// 尝试直接提供静态文件
if path != "/" {
trimmed := strings.TrimPrefix(path, "/")
if f, err := web.StaticFS.Open(trimmed); err == nil {
f.Close()
c.Request.URL.Path = path
fileServer.ServeHTTP(c.Writer, c.Request)
return
}
}
// 兜底Dashboard SPA
c.Request.URL.Path = "/index.html"
fileServer.ServeHTTP(c.Writer, c.Request)
})
srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port),
Handler: router,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
}
return &Server{
cfg: cfg,
engine: router,
stats: statsEngine,
geoip: geoip,
srv: srv,
}, nil
}
// Run 启动服务(阻塞直到收到信号)
func (s *Server) Run() error {
// 定期输出统计
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.stats.LogStats()
}
}
}()
// 启动 HTTP 服务
go func() {
log.Printf("🚀 服务启动: http://%s:%d", s.cfg.Server.Host, s.cfg.Server.Port)
log.Printf("📊 统计面板: http://%s:%d/", s.cfg.Server.Host, s.cfg.Server.Port)
log.Printf("🎬 视频网站: http://%s:%d/video", s.cfg.Server.Host, s.cfg.Server.Port)
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("服务启动失败: %v", err)
}
}()
// 等待退出信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("正在关闭服务...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.srv.Shutdown(ctx); err != nil {
return fmt.Errorf("服务关闭失败: %w", err)
}
s.stats.Stop()
if s.geoip != nil {
s.geoip.Close()
}
log.Println("服务已关闭")
return nil
}

7
main.go Normal file
View File

@@ -0,0 +1,7 @@
package main
import "api-server/cmd"
func main() {
cmd.Execute()
}

21
web/embed.go Normal file
View File

@@ -0,0 +1,21 @@
package web
import (
"embed"
"io/fs"
"log"
)
//go:embed static/*
var embedFS embed.FS
// StaticFS 嵌入式前端静态文件(根路径已去除 static/ 前缀)
var StaticFS fs.FS
func init() {
var err error
StaticFS, err = fs.Sub(embedFS, "static")
if err != nil {
log.Fatalf("web: 无法挂载嵌入式文件系统: %v", err)
}
}

341
web/static/app.js Normal file
View File

@@ -0,0 +1,341 @@
// ── 全局状态 ─────────────────────────────────────────────────
const API = '/api/stats';
let charts = {};
let refreshTimer = null;
// ── 工具函数 ─────────────────────────────────────────────────
async function fetchJSON(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
return json.data || json;
}
function formatDuration(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const parts = [];
if (d > 0) parts.push(`${d}d`);
if (h > 0) parts.push(`${h}h`);
if (m > 0) parts.push(`${m}m`);
if (d === 0 && h === 0) parts.push(`${s}s`);
return parts.join(' ') || '0s';
}
function formatNum(n) {
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
return '' + n;
}
// ── 图表创建 ─────────────────────────────────────────────────
function createOrUpdateChart(id, config) {
if (charts[id]) {
charts[id].data = config.data;
charts[id].options = config.options || {};
charts[id].update();
} else {
const ctx = document.getElementById(id).getContext('2d');
charts[id] = new Chart(ctx, config);
}
}
// ── 更新概览卡片 ────────────────────────────────────────────
async function refreshOverview() {
try {
const data = await fetchJSON(API + '/overview');
document.getElementById('totalRequests').textContent = formatNum(data.total_requests);
document.getElementById('qps').textContent = data.requests_per_sec;
document.getElementById('activeConn').textContent = data.active_conn;
document.getElementById('avgLatency').textContent = data.avg_latency_ms;
document.getElementById('errorRate').textContent = data.error_rate;
document.getElementById('uniqueIPs').textContent = formatNum(data.unique_ips);
// 运行时间
const uptime = data.uptime_seconds ? (data.uptime_seconds / 1e9) : 0;
document.getElementById('uptime').textContent = '运行 ' + formatDuration(uptime);
// 状态指示
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
dot.className = 'status-dot online';
text.textContent = '运行中';
} catch (e) {
document.getElementById('statusDot').className = 'status-dot offline';
document.getElementById('statusText').textContent = '离线';
console.error('overview fetch error:', e);
}
}
// ── QPS 时间序列图 ──────────────────────────────────────────
async function refreshTimeSeries() {
const period = document.getElementById('periodSelect').value || '1h';
try {
const data = await fetchJSON(API + `/requests?period=${period}&buckets=60`);
const labels = data.map(d => d.time);
const counts = data.map(d => d.count);
const errors = data.map(d => d.errors);
createOrUpdateChart('qpsChart', {
type: 'line',
data: {
labels,
datasets: [
{
label: '请求数',
data: counts,
borderColor: '#6366f1',
backgroundColor: 'rgba(99,102,241,0.1)',
fill: true,
tension: 0.3,
pointRadius: 0,
},
{
label: '错误数',
data: errors,
borderColor: '#ef4444',
backgroundColor: 'rgba(239,68,68,0.1)',
fill: true,
tension: 0.3,
pointRadius: 0,
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { labels: { color: '#8b8d9a' } } },
scales: {
x: { ticks: { color: '#8b8d9a', maxTicksLimit: 12 }, grid: { color: '#2a2d3e' } },
y: { ticks: { color: '#8b8d9a' }, grid: { color: '#2a2d3e' }, beginAtZero: true }
},
interaction: { intersect: false, mode: 'index' }
}
});
} catch (e) { console.error('timeseries error:', e); }
}
// ── 状态码分布图 ────────────────────────────────────────────
async function refreshStatus() {
try {
const data = await fetchJSON(API + '/status');
const labels = data.map(d => d.code);
const values = data.map(d => d.count);
const colors = {
'2xx': '#10b981',
'3xx': '#22d3ee',
'4xx': '#f59e0b',
'5xx': '#ef4444',
};
createOrUpdateChart('statusChart', {
type: 'doughnut',
data: {
labels,
datasets: [{
data: values,
backgroundColor: labels.map(l => colors[l] || '#6b7280'),
borderWidth: 0,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { color: '#8b8d9a', padding: 16 } }
}
}
});
} catch (e) { console.error('status error:', e); }
}
// ── 并发连接图 ──────────────────────────────────────────────
async function refreshConcurrency() {
try {
const data = await fetchJSON(API + '/concurrency');
document.getElementById('activeConn').textContent = data.current;
const samples = data.samples || [];
const labels = samples.map(s => s.time);
const values = samples.map(s => s.value);
createOrUpdateChart('connChart', {
type: 'line',
data: {
labels,
datasets: [{
label: '并发连接',
data: values,
borderColor: '#22d3ee',
backgroundColor: 'rgba(34,211,238,0.1)',
fill: true,
tension: 0.3,
pointRadius: 0,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { labels: { color: '#8b8d9a' } },
annotation: data.peak ? {
annotations: {
peakLine: {
type: 'line',
yMin: data.peak,
yMax: data.peak,
borderColor: '#ef4444',
borderDash: [4, 4],
label: { content: '峰值 ' + data.peak, display: true }
}
}
} : {}
},
scales: {
x: { ticks: { color: '#8b8d9a', maxTicksLimit: 10 }, grid: { color: '#2a2d3e' } },
y: { ticks: { color: '#8b8d9a' }, grid: { color: '#2a2d3e' }, beginAtZero: true }
}
}
});
} catch (e) { console.error('concurrency error:', e); }
}
// ── 延迟百分位图 ────────────────────────────────────────────
async function refreshLatency() {
try {
const data = await fetchJSON(API + '/latency');
const metrics = ['P50', 'P90', 'P95', 'P99', 'Avg', 'Max'];
const values = [data.p50_ms, data.p90_ms, data.p95_ms, data.p99_ms, data.avg_ms, data.max_ms];
const bgColors = ['#6366f1', '#6366f1', '#6366f1', '#f59e0b', '#10b981', '#ef4444'];
createOrUpdateChart('latencyChart', {
type: 'bar',
data: {
labels: metrics,
datasets: [{
label: '延迟 (ms)',
data: values,
backgroundColor: bgColors,
borderRadius: 6,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: '#8b8d9a' }, grid: { display: false } },
y: { ticks: { color: '#8b8d9a' }, grid: { color: '#2a2d3e' }, beginAtZero: true }
}
}
});
} catch (e) { console.error('latency error:', e); }
}
// ── 地理分布图 ──────────────────────────────────────────────
async function refreshGeo() {
try {
const data = await fetchJSON(API + '/geo?top=10');
const labels = data.map(d => d.region || '未知');
const values = data.map(d => d.count);
createOrUpdateChart('geoChart', {
type: 'bar',
data: {
labels,
datasets: [{
label: '请求数',
data: values,
backgroundColor: [
'#6366f1', '#22d3ee', '#10b981', '#f59e0b', '#ef4444',
'#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#64748b'
],
borderRadius: 6,
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: '#8b8d9a' }, grid: { color: '#2a2d3e' }, beginAtZero: true },
y: { ticks: { color: '#8b8d9a' }, grid: { display: false } }
}
}
});
} catch (e) { console.error('geo error:', e); }
}
// ── Top 端点表格 ────────────────────────────────────────────
async function refreshTopEndpoints() {
try {
const data = await fetchJSON(API + '/endpoints?top=10');
if (!data || data.length === 0) {
document.getElementById('topEndpoints').innerHTML = '<p style="color:#8b8d9a;text-align:center;padding:20px;">暂无数据</p>';
return;
}
const maxCount = data[0].count || 1;
let html = '<table><thead><tr><th>端点</th><th>请求数</th><th>占比</th><th>平均延迟</th><th>错误率</th></tr></thead><tbody>';
const total = data.reduce((s, d) => s + d.count, 0);
data.forEach(d => {
const pct = total > 0 ? (d.count / total * 100).toFixed(1) : '0';
html += `<tr>
<td title="${d.path}">${d.path.length > 40 ? d.path.slice(0, 37) + '...' : d.path}</td>
<td>${d.count}</td>
<td><div class="bar-wrap"><div class="bar-fill" style="width:${d.count/maxCount*100}px;"></div>${pct}%</div></td>
<td>${d.avg_latency_ms}ms</td>
<td style="color:${d.error_rate > 5 ? '#ef4444' : d.error_rate > 1 ? '#f59e0b' : '#10b981'}">${d.error_rate}%</td>
</tr>`;
});
html += '</tbody></table>';
document.getElementById('topEndpoints').innerHTML = html;
} catch (e) { console.error('endpoints error:', e); }
}
// ── Top IPs ─────────────────────────────────────────────────
async function refreshTopIPs() {
try {
const data = await fetchJSON(API + '/top-ips?top=15');
if (!data || data.length === 0) {
document.getElementById('topIPs').innerHTML = '<p style="color:#8b8d9a;text-align:center;padding:20px;">暂无数据</p>';
return;
}
const maxCount = data[0].count || 1;
let html = '<table><thead><tr><th>IP</th><th>地区</th><th>请求数</th><th>占比</th></tr></thead><tbody>';
const total = data.reduce((s, d) => s + d.count, 0);
data.forEach(d => {
const pct = total > 0 ? (d.count / total * 100).toFixed(1) : '0';
html += `<tr>
<td>${d.ip}</td>
<td>${d.region || '未知'}</td>
<td>${d.count}</td>
<td><div class="bar-wrap"><div class="bar-fill" style="width:${d.count/maxCount*80}px;"></div>${pct}%</div></td>
</tr>`;
});
html += '</tbody></table>';
document.getElementById('topIPs').innerHTML = html;
} catch (e) { console.error('top-ips error:', e); }
}
// ── 全部刷新 ────────────────────────────────────────────────
async function refreshAll() {
await Promise.all([
refreshOverview(),
refreshTimeSeries(),
refreshStatus(),
refreshConcurrency(),
refreshLatency(),
refreshGeo(),
refreshTopEndpoints(),
refreshTopIPs(),
]);
}
// ── 启动 ────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
refreshAll();
refreshTimer = setInterval(refreshAll, 10000); // 每10秒刷新
});

139
web/static/index.html Normal file
View File

@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>api-server - 运维监控面板</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="container">
<!-- Header -->
<header>
<h1>📊 api-server 运维监控</h1>
<div class="header-right">
<span class="status-dot online" id="statusDot"></span>
<span id="statusText">运行中</span>
<span class="uptime" id="uptime"></span>
</div>
</header>
<!-- 实时概览卡片 -->
<section class="overview-cards" id="overviewCards">
<div class="card">
<div class="card-icon">📨</div>
<div class="card-data">
<div class="card-value" id="totalRequests">--</div>
<div class="card-label">总请求数</div>
</div>
</div>
<div class="card">
<div class="card-icon"></div>
<div class="card-data">
<div class="card-value" id="qps">--</div>
<div class="card-label">QPS</div>
</div>
</div>
<div class="card">
<div class="card-icon">🔗</div>
<div class="card-data">
<div class="card-value" id="activeConn">--</div>
<div class="card-label">活跃连接</div>
</div>
</div>
<div class="card">
<div class="card-icon">⏱️</div>
<div class="card-data">
<div class="card-value" id="avgLatency">--</div>
<div class="card-label">平均延迟 ms</div>
</div>
</div>
<div class="card">
<div class="card-icon"></div>
<div class="card-data">
<div class="card-value" id="errorRate">--</div>
<div class="card-label">错误率 %</div>
</div>
</div>
<div class="card">
<div class="card-icon">🌐</div>
<div class="card-data">
<div class="card-value" id="uniqueIPs">--</div>
<div class="card-label">独立 IP</div>
</div>
</div>
</section>
<!-- 图表区 -->
<div class="charts-grid">
<!-- QPS 时间序列 -->
<div class="chart-panel">
<div class="chart-header">
<h3>📈 请求量趋势</h3>
<select id="periodSelect" onchange="refreshTimeSeries()">
<option value="1h">最近1小时</option>
<option value="6h">最近6小时</option>
<option value="24h">最近24小时</option>
</select>
</div>
<canvas id="qpsChart"></canvas>
</div>
<!-- 状态码分布 -->
<div class="chart-panel">
<div class="chart-header">
<h3>📊 状态码分布</h3>
</div>
<canvas id="statusChart"></canvas>
</div>
<!-- 并发连接 -->
<div class="chart-panel">
<div class="chart-header">
<h3>🔗 并发连接趋势</h3>
</div>
<canvas id="connChart"></canvas>
</div>
<!-- 延迟分布 -->
<div class="chart-panel">
<div class="chart-header">
<h3>⏱️ 延迟百分位</h3>
</div>
<canvas id="latencyChart"></canvas>
</div>
<!-- 地理分布 -->
<div class="chart-panel">
<div class="chart-header">
<h3>🌍 请求来源分布</h3>
</div>
<canvas id="geoChart"></canvas>
</div>
<!-- Top 端点 -->
<div class="chart-panel">
<div class="chart-header">
<h3>🔝 热门端点</h3>
</div>
<div class="table-wrap" id="topEndpoints"></div>
</div>
</div>
<!-- 高频 IP -->
<section class="section">
<h3>👥 高频访问 IP</h3>
<div class="table-wrap" id="topIPs"></div>
</section>
<footer>
<span>api-server v1.0.0</span>
<span>Gin + Cobra + ip2region + Chart.js</span>
<span>自动刷新 10s</span>
</footer>
</div>
<script src="/app.js"></script>
</body>
</html>

225
web/static/style.css Normal file
View File

@@ -0,0 +1,225 @@
/* ── 全局 ──────────────────────────────────────────────── */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #0f1119;
--card-bg: #1a1d2e;
--border: #2a2d3e;
--text: #e0e0e0;
--text-dim: #8b8d9a;
--accent: #6366f1;
--accent2: #22d3ee;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--radius: 12px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.6;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
/* ── Header ────────────────────────────────────────────── */
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: var(--card-bg);
border-radius: var(--radius);
margin-bottom: 20px;
border: 1px solid var(--border);
}
header h1 {
font-size: 1.4rem;
font-weight: 600;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
font-size: 0.9rem;
color: var(--text-dim);
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.status-dot.online { background: var(--success); }
.status-dot.offline { background: var(--danger); }
.uptime {
color: var(--accent2);
}
/* ── 概览卡片 ──────────────────────────────────────────── */
.overview-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 20px;
display: flex;
align-items: center;
gap: 14px;
transition: border-color 0.2s;
}
.card:hover {
border-color: var(--accent);
}
.card-icon {
font-size: 2rem;
opacity: 0.8;
}
.card-data {
flex: 1;
}
.card-value {
font-size: 1.6rem;
font-weight: 700;
color: var(--text);
}
.card-label {
font-size: 0.8rem;
color: var(--text-dim);
margin-top: 2px;
}
/* ── 图表网格 ──────────────────────────────────────────── */
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.chart-panel {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.chart-header h3 {
font-size: 1rem;
font-weight: 600;
}
.chart-header select {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 10px;
font-size: 0.8rem;
}
canvas {
width: 100% !important;
max-height: 280px;
}
/* ── 表格 ──────────────────────────────────────────────── */
.table-wrap {
max-height: 280px;
overflow-y: auto;
}
.table-wrap table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.table-wrap th {
text-align: left;
padding: 8px 10px;
border-bottom: 2px solid var(--border);
color: var(--text-dim);
font-weight: 600;
position: sticky;
top: 0;
background: var(--card-bg);
}
.table-wrap td {
padding: 6px 10px;
border-bottom: 1px solid var(--border);
}
.table-wrap tr:hover td {
background: rgba(99, 102, 241, 0.05);
}
.bar-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.bar-fill {
height: 6px;
border-radius: 3px;
background: var(--accent);
min-width: 2px;
}
/* ── Section ───────────────────────────────────────────── */
.section {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
margin-bottom: 20px;
}
.section h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 12px;
}
/* ── Footer ────────────────────────────────────────────── */
footer {
text-align: center;
padding: 16px;
color: var(--text-dim);
font-size: 0.8rem;
display: flex;
gap: 20px;
justify-content: center;
}

318
web/static/videoapp/app.js Normal file
View File

@@ -0,0 +1,318 @@
// ── API 基础 ─────────────────────────────────────────────────
const API = '/api';
async function apiGet(path) {
const res = await fetch(API + path);
const json = await res.json();
if (json.code !== 0) throw new Error(json.msg);
return json.data;
}
async function apiPost(path, body) {
const res = await fetch(API + path, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
const json = await res.json();
if (json.code !== 0) throw new Error(json.msg);
return json;
}
// ── 路由 ────────────────────────────────────────────────────
let currentCategory = 'rf';
let currentPage = 1;
let totalPages = 1;
let currentViewkey = null;
let extractedVideoURL = null;
function navigate(hash) {
window.location.hash = hash;
}
function handleRoute() {
const hash = window.location.hash || '#/home';
const pages = document.querySelectorAll('.page');
pages.forEach(p => p.classList.add('hidden'));
// 更新导航高亮
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
if (hash.startsWith('#/browse')) {
showPage('page-browse');
document.querySelector('.nav-link[href$="#/browse"]')?.classList.add('active');
loadCategories();
} else if (hash.startsWith('#/categories')) {
showPage('page-categories');
document.querySelector('.nav-link[href$="#/categories"]')?.classList.add('active');
loadCategoryList();
} else if (hash.startsWith('#/search')) {
showPage('page-search');
document.querySelector('.nav-link[href$="#/search"]')?.classList.add('active');
} else if (hash.startsWith('#/watch/')) {
const viewkey = hash.split('#/watch/')[1];
showPage('page-watch');
loadVideoDetail(viewkey);
} else if (hash.startsWith('#/author/')) {
const uid = hash.split('#/author/')[1];
showPage('page-author');
loadAuthorVideos(uid);
} else {
showPage('page-home');
loadHomeData();
}
}
function showPage(id) {
document.getElementById(id).classList.remove('hidden');
window.scrollTo(0, 0);
}
// ── 导航栏实时统计 ──────────────────────────────────────────
async function refreshNavStats() {
try {
const data = await apiGet('/stats/realtime');
document.getElementById('navQPS').textContent = data.qps;
document.getElementById('navConn').textContent = data.active_conn;
document.getElementById('navLat').textContent = data.avg_latency_ms;
} catch(e) { /* 静默 */ }
}
// ── 首页数据 ────────────────────────────────────────────────
async function loadHomeData() {
// 加载分类
try {
const cats = await apiGet('/categories');
const html = cats.map(c => `
<div class="cat-card" onclick="navigate('#/browse'); setTimeout(()=>{document.getElementById('categorySelect').value='${c.code}';loadCategoryPage();},100)">
<h4>${c.name}</h4>
<p>${c.desc}</p>
</div>
`).join('');
document.getElementById('homeCategories').innerHTML = html;
} catch(e) {}
// 加载实时统计
try {
const stats = await apiGet('/stats/overview');
const items = [
{lbl:'总请求数', val:formatNum(stats.total_requests)},
{lbl:'QPS', val:stats.requests_per_sec},
{lbl:'活跃连接', val:stats.active_conn},
{lbl:'平均延迟', val:stats.avg_latency_ms+'ms'},
{lbl:'错误率', val:stats.error_rate+'%'},
{lbl:'独立 IP', val:formatNum(stats.unique_ips)},
];
document.getElementById('homeStats').innerHTML = items.map(i =>
`<div class="stat-item"><div class="stat-val">${i.val}</div><div class="stat-lbl">${i.lbl}</div></div>`
).join('');
} catch(e) {}
}
// ── 分类列表页 ──────────────────────────────────────────────
async function loadCategoryList() {
try {
const cats = await apiGet('/categories');
const html = cats.map(c => `
<div class="cat-card" onclick="navigate('#/browse'); setTimeout(()=>{document.getElementById('categorySelect').value='${c.code}';loadCategoryPage();},100)">
<h4>${c.name}</h4>
<p>${c.desc}</p>
<p style="margin-top:4px;color:var(--accent2);font-size:0.8rem;">代码: ${c.code}</p>
</div>
`).join('');
document.getElementById('categoryList').innerHTML = `<div class="category-grid">${html}</div>`;
} catch(e) {}
}
// ── 分类浏览 ────────────────────────────────────────────────
async function loadCategories() {
try {
const cats = await apiGet('/categories');
const sel = document.getElementById('categorySelect');
sel.innerHTML = cats.map(c => `<option value="${c.code}">${c.name}</option>`).join('');
sel.value = currentCategory;
loadCategoryPage();
} catch(e) {}
}
async function loadCategoryPage() {
currentCategory = document.getElementById('categorySelect').value;
document.getElementById('browseTitle').textContent = getCategoryName(currentCategory);
document.getElementById('browseLoading').style.display = 'block';
try {
const data = await apiGet(`/category/${currentCategory}?page=${currentPage}`);
totalPages = data.total_pages || 1;
document.getElementById('pageInfo').textContent = `${currentPage} 页 / 共 ${totalPages}`;
document.getElementById('btnPrev').disabled = currentPage <= 1;
document.getElementById('btnNext').disabled = currentPage >= totalPages;
renderVideoGrid('videoGrid', data.items || [], true);
} catch(e) {
document.getElementById('videoGrid').innerHTML = `<p style="color:var(--accent);text-align:center;padding:40px;">加载失败: ${e.message}</p>`;
}
document.getElementById('browseLoading').style.display = 'none';
}
function prevPage() { if (currentPage > 1) { currentPage--; loadCategoryPage(); } }
function nextPage() { if (currentPage < totalPages) { currentPage++; loadCategoryPage(); } }
// ── 搜索 ────────────────────────────────────────────────────
async function doSearch() {
const q = document.getElementById('searchInput').value.trim();
if (!q) return;
document.getElementById('searchLoading').style.display = 'block';
document.getElementById('searchResults').innerHTML = '';
try {
// 搜索:加载 rf 分类全部视频,客户端过滤标题
const data = await apiGet('/category/rf?all=true&max_pages=3');
const items = (data.items || []).filter(v =>
(v.title || '').toLowerCase().includes(q.toLowerCase())
);
renderVideoGrid('searchResults', items, true);
} catch(e) {
document.getElementById('searchResults').innerHTML = `<p style="color:var(--accent);text-align:center;padding:40px;">搜索失败: ${e.message}</p>`;
}
document.getElementById('searchLoading').style.display = 'none';
}
// ── 视频详情 + 提取 ─────────────────────────────────────────
async function loadVideoDetail(viewkey) {
currentViewkey = viewkey;
const url = `https://h1014.sol148.com/view_video.php?viewkey=${viewkey}`;
document.getElementById('videoTitle').textContent = '加载中...';
document.getElementById('playerHint').textContent = ''+ viewkey;
document.getElementById('videoMeta').innerHTML = '';
document.getElementById('btnCopyURL').disabled = true;
document.getElementById('videoSourceBox').classList.add('hidden');
document.getElementById('playerStatus').classList.add('hidden');
extractedVideoURL = null;
// 自动提取
try {
const result = await apiPost('/extract', {url});
const d = result.data;
document.getElementById('videoTitle').textContent = d.title || '无标题';
document.getElementById('videoMeta').innerHTML = `
<span>🕐 ${d.duration || '--'}</span>
<span>👁 ${formatNum(d.views)}</span>
<span>⭐ ${formatNum(d.favorites)}</span>
${d.author_name ? `<span>👤 <a href="javascript:navigate('#/author/${d.author_url ? d.author_url.match(/UID=([^&]+)/)?.[1] || '' : ''}')" style="color:var(--accent2)">${d.author_name}</a></span>` : ''}
`;
if (d.video_url) {
extractedVideoURL = d.video_url;
document.getElementById('playerStatus').className = 'player-status success';
document.getElementById('playerStatus').textContent = '✅ 视频源提取成功';
document.getElementById('playerStatus').classList.remove('hidden');
document.getElementById('btnCopyURL').disabled = false;
document.getElementById('videoSourceBox').classList.remove('hidden');
document.getElementById('videoSourceURL').value = d.video_url;
document.getElementById('playerHint').textContent = '✅ 源已提取,点击按钮复制地址';
}
} catch(e) {
document.getElementById('videoTitle').textContent = '提取失败';
document.getElementById('playerStatus').className = 'player-status error';
document.getElementById('playerStatus').textContent = '❌ ' + e.message;
document.getElementById('playerStatus').classList.remove('hidden');
}
// 加载相关视频(同分类)
try {
const related = await apiGet('/category/rf?page=1');
const relatedItems = (related.items || []).filter(v => v.viewkey !== viewkey).slice(0, 6);
if (relatedItems.length > 0) {
document.getElementById('videoRelated').innerHTML = '<h2>📺 更多视频</h2>' + renderVideoGridHTML(relatedItems, false);
}
} catch(e) {}
}
async function extractCurrent() {
if (!currentViewkey) return;
document.getElementById('playerStatus').classList.remove('hidden');
document.getElementById('playerStatus').className = 'player-status';
document.getElementById('playerStatus').textContent = '⏳ 提取中...';
await loadVideoDetail(currentViewkey);
}
function copyVideoURL() {
if (extractedVideoURL) {
navigator.clipboard.writeText(extractedVideoURL).then(() => {
const el = document.getElementById('btnCopyURL');
const orig = el.textContent;
el.textContent = '✅ 已复制!';
setTimeout(() => el.textContent = orig, 1500);
});
}
}
// ── 作者页 ──────────────────────────────────────────────────
async function loadAuthorVideos(uid) {
document.getElementById('authorName').textContent = '作者: ' + uid.substring(0, 20) + '...';
document.getElementById('authorLoading').style.display = 'block';
try {
const data = await apiGet(`/author/${uid}?all=true`);
document.getElementById('authorName').textContent = '👤 ' + (data.author_name || uid);
renderVideoGrid('authorVideos', data.items || [], true);
} catch(e) {
document.getElementById('authorVideos').innerHTML = `<p style="color:var(--accent);text-align:center;padding:40px;">加载失败: ${e.message}</p>`;
}
document.getElementById('authorLoading').style.display = 'none';
}
// ── 视频网格渲染 ────────────────────────────────────────────
function renderVideoGrid(containerId, items, showAuthor) {
document.getElementById(containerId).innerHTML = renderVideoGridHTML(items, showAuthor);
}
function renderVideoGridHTML(items, showAuthor) {
if (!items || items.length === 0) return '<p style="text-align:center;color:var(--text2);padding:40px;">暂无视频</p>';
return items.map(v => {
const viewkey = v.viewkey || '';
const thumb = v.thumbnail || '';
return `
<div class="video-card" onclick="navigate('#/watch/${viewkey}')">
<div class="video-thumb" style="${thumb ? `background-image:url(${thumb});background-size:cover;background-position:center;` : ''}">
${thumb ? '' : '🎬'}
</div>
<div class="video-info">
<h4>${escHtml(v.title || '无标题')}</h4>
<div class="video-meta-row">
<span>🕐 ${v.duration || '--'}</span>
<span>👁 ${formatNum(v.views || 0)}</span>
</div>
<div class="video-meta-row">
${v.added ? `<span>📅 ${v.added}</span>` : '<span></span>'}
${showAuthor && v.author ? `<span>👤 ${escHtml(v.author)}</span>` : ''}
</div>
</div>
</div>`;
}).join('');
}
// ── 工具函数 ────────────────────────────────────────────────
function formatNum(n) {
if (n >= 1e6) return (n/1e6).toFixed(1)+'M';
if (n >= 1e3) return (n/1e3).toFixed(1)+'K';
return ''+(n||0);
}
function escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function getCategoryName(code) {
const map = {rf:'最近更新',top:'本月最热',ori:'原创',hot:'收藏最多',long:'10分钟以上',longer:'20分钟以上',tf:'最近讨论',hd:'高清',md:'每月讨论',mf:'收藏最多(月)'};
return map[code] || code;
}
// ── 事件 ────────────────────────────────────────────────────
window.addEventListener('hashchange', handleRoute);
window.addEventListener('load', () => {
handleRoute();
refreshNavStats();
setInterval(refreshNavStats, 10000);
});

View File

@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sol148 Video - 视频浏览</title>
<link rel="stylesheet" href="/videoapp/style.css">
</head>
<body>
<!-- 顶部导航 -->
<nav class="navbar">
<div class="nav-inner">
<a href="/video" class="logo">🎬 Sol148 Video</a>
<div class="nav-links">
<a href="/video#/browse" class="nav-link">浏览</a>
<a href="/video#/categories" class="nav-link">分类</a>
<a href="/video#/search" class="nav-link">搜索</a>
</div>
<div class="nav-stats" id="navStats">
<span title="QPS"><b id="navQPS">--</b></span>
<span title="活跃连接">🔗 <b id="navConn">--</b></span>
<span title="平均延迟">⏱️ <b id="navLat">--</b>ms</span>
</div>
</div>
</nav>
<!-- 主内容区SPA 路由切换) -->
<main id="app">
<!-- 首页 -->
<section id="page-home" class="page">
<div class="hero">
<h1>探索精彩视频内容</h1>
<p>通过 sol148-extractor API 驱动的视频浏览平台</p>
<div class="hero-actions">
<button class="btn btn-primary" onclick="navigate('#/browse')">开始浏览</button>
<button class="btn btn-outline" onclick="navigate('#/categories')">查看分类</button>
</div>
</div>
<div class="section">
<h2>🔥 热门分类</h2>
<div class="category-grid" id="homeCategories"></div>
</div>
<div class="section">
<h2>⚡ 实时服务状态</h2>
<div class="stats-bar" id="homeStats"></div>
</div>
</section>
<!-- 分类浏览页 -->
<section id="page-browse" class="page hidden">
<div class="browse-header">
<h2 id="browseTitle">最近更新</h2>
<div class="browse-controls">
<select id="categorySelect" onchange="loadCategoryPage()"></select>
<button class="btn btn-sm" onclick="prevPage()" id="btnPrev">◀ 上一页</button>
<span id="pageInfo">第 1 页</span>
<button class="btn btn-sm" onclick="nextPage()" id="btnNext">下一页 ▶</button>
</div>
</div>
<div class="video-grid" id="videoGrid"></div>
<div class="loading" id="browseLoading" style="display:none;">加载中...</div>
</section>
<!-- 分类列表页 -->
<section id="page-categories" class="page hidden">
<h2>📂 视频分类</h2>
<div class="category-list" id="categoryList"></div>
</section>
<!-- 搜索页 -->
<section id="page-search" class="page hidden">
<h2>🔍 搜索视频</h2>
<div class="search-bar">
<input type="text" id="searchInput" placeholder="输入关键词搜索(暂搜索标题)..." onkeydown="if(event.key==='Enter')doSearch()">
<button class="btn btn-primary" onclick="doSearch()">搜索</button>
</div>
<div class="video-grid" id="searchResults"></div>
<div class="loading" id="searchLoading" style="display:none;">搜索中...</div>
</section>
<!-- 视频详情页 -->
<section id="page-watch" class="page hidden">
<div class="watch-container">
<div class="video-player">
<div class="player-placeholder" id="playerBox">
<div class="play-icon"></div>
<p>点击提取视频源</p>
<p class="player-hint" id="playerHint"></p>
</div>
<div class="player-status hidden" id="playerStatus"></div>
</div>
<div class="video-detail">
<h2 id="videoTitle">--</h2>
<div class="video-meta" id="videoMeta"></div>
<div class="video-actions">
<button class="btn btn-primary" id="btnExtract" onclick="extractCurrent()">🔗 提取视频源</button>
<button class="btn btn-outline" id="btnCopyURL" onclick="copyVideoURL()" disabled>📋 复制源地址</button>
</div>
<div class="video-source hidden" id="videoSourceBox">
<label>视频源地址:</label>
<input type="text" id="videoSourceURL" readonly onclick="this.select()">
</div>
</div>
</div>
<div class="section" id="videoRelated"></div>
</section>
<!-- 作者页 -->
<section id="page-author" class="page hidden">
<h2 id="authorName">作者</h2>
<div class="video-grid" id="authorVideos"></div>
<div class="loading" id="authorLoading" style="display:none;">加载中...</div>
</section>
</main>
<footer>
<span>Sol148 Video Explorer</span>
<span>Powered by api-server (Gin + Cobra + ip2region)</span>
<span>Data via sol148-extractor API</span>
</footer>
<script src="/videoapp/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,159 @@
/* ── 全局 ──────────────────────────────────────────────── */
:root {
--bg: #0b0e14;
--bg2: #141820;
--card: #1a1e28;
--border: #2a3040;
--text: #e8eaed;
--text2: #8b92a0;
--accent: #e04060;
--accent2: #4a90d9;
--success: #2ecc71;
--warning: #e67e22;
--radius: 10px;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg); color: var(--text); min-height:100vh;
}
.hidden { display: none !important; }
.loading { text-align:center; padding:40px; color:var(--text2); font-size:1.1rem; }
.section { max-width:1200px; margin:0 auto; padding:30px 20px; }
.section h2 { margin-bottom:20px; font-size:1.3rem; }
/* ── 导航栏 ────────────────────────────────────────────── */
.navbar {
background: var(--bg2); border-bottom:1px solid var(--border);
position:sticky; top:0; z-index:100;
}
.nav-inner {
max-width:1200px; margin:0 auto; padding:0 20px;
display:flex; align-items:center; height:56px; gap:20px;
}
.logo { font-size:1.2rem; font-weight:700; color:var(--accent); text-decoration:none; }
.nav-links { display:flex; gap:4px; flex:1; }
.nav-link {
color:var(--text2); text-decoration:none; padding:6px 14px;
border-radius:6px; font-size:0.9rem; transition:.2s;
}
.nav-link:hover, .nav-link.active { color:var(--text); background:var(--card); }
.nav-stats { display:flex; gap:14px; font-size:0.8rem; color:var(--text2); }
.nav-stats b { color:var(--accent2); font-weight:600; }
/* ── 首页 Hero ─────────────────────────────────────────── */
.hero {
text-align:center; padding:80px 20px 50px;
background: linear-gradient(135deg, var(--bg2), var(--bg));
}
.hero h1 { font-size:2.4rem; margin-bottom:12px; }
.hero p { color:var(--text2); font-size:1.1rem; margin-bottom:30px; }
.hero-actions { display:flex; gap:12px; justify-content:center; }
/* ── 按钮 ──────────────────────────────────────────────── */
.btn {
padding:10px 24px; border:none; border-radius:8px; cursor:pointer;
font-size:0.95rem; font-weight:600; transition:.2s;
}
.btn-primary { background:var(--accent); color:#fff; }
.btn-primary:hover { opacity:.85; }
.btn-outline { background:transparent; color:var(--accent2); border:1px solid var(--accent2); }
.btn-outline:hover { background:var(--accent2); color:#fff; }
.btn-sm { padding:6px 14px; font-size:0.85rem; }
.btn:disabled { opacity:.4; cursor:not-allowed; }
/* ── 分类网格 ──────────────────────────────────────────── */
.category-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:12px; }
.cat-card {
background:var(--card); border:1px solid var(--border);
border-radius:var(--radius); padding:20px; cursor:pointer; transition:.2s;
}
.cat-card:hover { border-color:var(--accent); }
.cat-card h4 { font-size:1.05rem; margin-bottom:4px; }
.cat-card p { font-size:0.8rem; color:var(--text2); }
/* ── 视频网格 ──────────────────────────────────────────── */
.video-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:16px; max-width:1200px; margin:0 auto; padding:0 20px; }
.video-card {
background:var(--card); border:1px solid var(--border); border-radius:var(--radius);
overflow:hidden; cursor:pointer; transition:.2s;
}
.video-card:hover { border-color:var(--accent); transform:translateY(-2px); }
.video-thumb {
width:100%; aspect-ratio:16/9; object-fit:cover; background:var(--bg2);
display:flex; align-items:center; justify-content:center; color:var(--text2); font-size:3rem;
}
.video-info { padding:12px 14px; }
.video-info h4 { font-size:0.95rem; margin-bottom:4px; line-height:1.4; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
.video-meta-row { display:flex; justify-content:space-between; font-size:0.78rem; color:var(--text2); margin-top:6px; }
.video-meta-row span { display:flex; align-items:center; gap:3px; }
.duration-badge { background:rgba(0,0,0,.7); color:#fff; padding:2px 6px; border-radius:4px; font-size:0.75rem; }
/* ── 浏览页头 ──────────────────────────────────────────── */
.browse-header {
max-width:1200px; margin:0 auto; padding:20px 20px 10px;
display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:12px;
}
.browse-controls { display:flex; align-items:center; gap:12px; }
#categorySelect {
background:var(--bg2); color:var(--text); border:1px solid var(--border);
border-radius:6px; padding:6px 10px; font-size:0.9rem;
}
#pageInfo { font-size:0.85rem; color:var(--text2); min-width:60px; text-align:center; }
/* ── 搜索栏 ────────────────────────────────────────────── */
.search-bar { display:flex; gap:8px; max-width:600px; margin:20px auto; padding:0 20px; }
.search-bar input {
flex:1; padding:10px 16px; background:var(--bg2); border:1px solid var(--border);
border-radius:8px; color:var(--text); font-size:1rem;
}
.search-bar input:focus { outline:none; border-color:var(--accent); }
/* ── 视频播放页 ────────────────────────────────────────── */
.watch-container { max-width:1000px; margin:0 auto; padding:20px; display:grid; grid-template-columns:2fr 1fr; gap:24px; }
.player-placeholder {
width:100%; aspect-ratio:16/9; background:var(--bg2);
display:flex; flex-direction:column; align-items:center; justify-content:center;
border-radius:var(--radius); border:1px solid var(--border); cursor:pointer;
}
.play-icon { font-size:3rem; margin-bottom:8px; opacity:.6; }
.player-hint { font-size:0.8rem; color:var(--text2); margin-top:4px; }
.player-placeholder:hover { border-color:var(--accent); }
.player-status { padding:12px; background:var(--card); border-radius:8px; margin-top:8px; font-size:0.85rem; }
.player-status.success { border-left:3px solid var(--success); color:var(--success); }
.player-status.error { border-left:3px solid var(--accent); color:var(--accent); }
.video-detail h2 { font-size:1.3rem; margin-bottom:12px; }
.video-meta { display:flex; flex-wrap:wrap; gap:16px; margin-bottom:16px; font-size:0.85rem; color:var(--text2); }
.video-actions { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; }
.video-source { margin-top:8px; }
.video-source label { font-size:0.8rem; color:var(--text2); display:block; margin-bottom:4px; }
.video-source input {
width:100%; padding:8px 10px; background:var(--bg2); border:1px solid var(--border);
border-radius:6px; color:var(--accent); font-size:0.8rem;
}
/* ── 统计条 ────────────────────────────────────────────── */
.stats-bar {
display:flex; flex-wrap:wrap; gap:16px; justify-content:center;
}
.stat-item {
background:var(--card); border:1px solid var(--border); border-radius:var(--radius);
padding:16px 24px; text-align:center; min-width:120px;
}
.stat-item .stat-val { font-size:1.5rem; font-weight:700; color:var(--accent2); }
.stat-item .stat-lbl { font-size:0.78rem; color:var(--text2); margin-top:2px; }
/* ── Footer ─────────────────────────────────────────────── */
footer {
text-align:center; padding:20px; color:var(--text2); font-size:0.78rem;
border-top:1px solid var(--border); margin-top:40px;
display:flex; gap:16px; justify-content:center; flex-wrap:wrap;
}
/* ── 响应式 ────────────────────────────────────────────── */
@media (max-width:768px) {
.watch-container { grid-template-columns:1fr; }
.hero h1 { font-size:1.6rem; }
.video-grid { grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); }
.nav-stats { display:none; }
}