From d861a9937bf0739fc601865508fb7504a4195389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A0=91=E8=90=8C=E8=8A=BD?= <3205788256@qq.com> Date: Thu, 12 Mar 2026 18:58:53 +0800 Subject: [PATCH] chore: sync local changes (2026-03-12) --- AGENTS.md | 47 ++ README.md | 110 +++ mengyaping-backend/.dockerignore | 94 +-- mengyaping-backend/Dockerfile | 112 +-- mengyaping-backend/config/config.go | 352 ++++----- mengyaping-backend/docker-compose.yml | 80 +- mengyaping-backend/handlers/website.go | 398 +++++----- mengyaping-backend/main.go | 94 +-- mengyaping-backend/models/website.go | 211 ++--- mengyaping-backend/router/router.go | 102 +-- mengyaping-backend/services/monitor.go | 720 ++++++++++-------- mengyaping-backend/services/website.go | 269 ++++--- mengyaping-backend/storage/storage.go | 587 +++++++------- mengyaping-backend/utils/dns.go | 60 ++ mengyaping-backend/utils/http.go | 304 ++++---- mengyaping-backend/utils/id.go | 62 +- mengyaping-frontend/index.html | 6 +- mengyaping-frontend/package.json | 4 +- mengyaping-frontend/public/icons/icon-192.png | Bin 0 -> 70651 bytes .../public/icons/icon-512-maskable.png | Bin 0 -> 471553 bytes mengyaping-frontend/public/icons/icon-512.png | Bin 0 -> 471553 bytes .../public/manifest.webmanifest | 32 + mengyaping-frontend/public/sw.js | 70 ++ mengyaping-frontend/src/App.jsx | 14 +- .../src/components/StatsCard.jsx | 290 +++---- .../src/components/UptimeChart.jsx | 424 +++++------ .../src/components/WebsiteCard.jsx | 473 +++++++----- .../src/components/WebsiteModal.jsx | 477 ++++++------ mengyaping-frontend/src/hooks/useMonitor.js | 166 ++-- mengyaping-frontend/src/index.css | 136 ++-- mengyaping-frontend/src/main.jsx | 8 + mengyaping-frontend/src/pages/Dashboard.jsx | 554 +++++++------- mengyaping-frontend/src/services/api.js | 154 ++-- mengyaping-frontend/tailwind.config.js | 16 +- mengyaping-frontend/vite.config.js | 10 + 开启前端.bat | 18 +- 开启后端.bat | 22 +- 构建前端.bat | 20 +- 38 files changed, 3570 insertions(+), 2926 deletions(-) create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 mengyaping-backend/utils/dns.go create mode 100644 mengyaping-frontend/public/icons/icon-192.png create mode 100644 mengyaping-frontend/public/icons/icon-512-maskable.png create mode 100644 mengyaping-frontend/public/icons/icon-512.png create mode 100644 mengyaping-frontend/public/manifest.webmanifest create mode 100644 mengyaping-frontend/public/sw.js diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f5733d8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,47 @@ +# Repository Guidelines + +## Project Structure & Module Organization +This repository is split into two apps: +- `mengyaping-frontend/`: React + Vite UI. Main code is in `src/` (`components/`, `pages/`, `hooks/`, `services/`), with static assets in `public/`. +- `mengyaping-backend/`: Go + Gin API and monitor service. Core folders are `handlers/`, `services/`, `router/`, `models/`, `storage/`, `config/`, and `utils/`. + +Runtime data is persisted under `mengyaping-backend/data/` (`websites.json`, `records.json`, `groups.json`, `config.json`). Keep data format changes backward-compatible. + +## Build, Test, and Development Commands +Frontend (run inside `mengyaping-frontend/`): +- `npm install`: install dependencies. +- `npm run dev`: start Vite dev server. +- `npm run build`: create production build in `dist/`. +- `npm run lint`: run ESLint checks. + +Backend (run inside `mengyaping-backend/`): +- `go mod tidy`: sync Go modules. +- `go run main.go`: start API server (default `0.0.0.0:8080`). +- `go test ./...`: run all backend tests. +- `docker compose up -d --build`: build and run containerized backend. + +## Coding Style & Naming Conventions +- Frontend: 2-space indentation, ES module imports, React component files in PascalCase (for example `WebsiteCard.jsx`), hooks in `useXxx.js`, utility/service functions in camelCase. +- Backend: format with `gofmt`; keep package names lowercase; exported identifiers in PascalCase, internal helpers in camelCase. +- Keep handlers thin and place business logic in `services/`. + +## Testing Guidelines +There are currently no committed frontend tests and minimal backend test coverage. Add tests for every non-trivial change: +- Backend: `*_test.go` next to implementation; focus on handlers and service logic. +- Frontend: if introducing test tooling, prefer Vitest + Testing Library with `*.test.jsx` naming. + +## Commit & Pull Request Guidelines +Current history uses short, imperative commit text (for example `first commit`). Continue with concise, scoped messages such as: +- `feat(frontend): add status filter` +- `fix(backend): validate monitor interval` + +Each PR should include: +- Clear summary and impacted area (`frontend`, `backend`, or both). +- Validation steps and commands run. +- Screenshots/GIFs for UI changes. +- Linked issue/ticket when available. + +## Security & Configuration Tips +- Do not commit secrets, tokens, or private endpoints. +- Frontend dev API target is `http://localhost:8080/api` in `mengyaping-frontend/src/services/api.js`. +- Commit only sanitized sample data in `mengyaping-backend/data/`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb0e817 --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# 萌芽Ping(MengYaPing) + +一个轻量、可自部署的网站可用性监控面板。 +支持多网站/多 URL 监控、分组管理、实时状态查看、24h/7d 可用率统计与延迟展示。 + +## 功能特性 + +- 多网站监控:每个网站可配置多个 URL,分别检测 +- 自动巡检:默认每 5 分钟检测一次(可配置) +- 状态面板:在线/离线、状态码、响应延迟、最后检测时间 +- 可用率统计:按 24 小时与 7 天维度聚合 +- 分组与检索:支持分组筛选与关键词搜索 +- 手动触发:支持单网站“立即检测” + +## 技术栈 + +- 前端:React 19 + Vite 7 + Tailwind CSS 4 +- 后端:Go 1.25 + Gin +- 存储:本地 JSON 文件(`mengyaping-backend/data/`) +- 部署:Docker / Docker Compose(后端) + +## 项目结构 + +```text +. +├─ mengyaping-frontend/ # 前端应用 +│ ├─ src/components/ # 卡片、图表、弹窗等组件 +│ ├─ src/pages/ # 页面(Dashboard) +│ ├─ src/services/api.js # API 请求封装 +│ └─ public/ # 静态资源(logo、favicon) +├─ mengyaping-backend/ # 后端服务 +│ ├─ handlers/ services/ router/ +│ ├─ models/ storage/ config/ utils/ +│ └─ data/ # 配置与监控数据(JSON) +├─ 开启前端.bat +├─ 开启后端.bat +└─ 构建前端.bat +``` + +## 快速开始(本地开发) + +### 1) 启动后端 + +```bash +cd mengyaping-backend +go mod tidy +go run main.go +``` + +默认地址:`http://localhost:8080` + +### 2) 启动前端 + +```bash +cd mengyaping-frontend +npm install +npm run dev +``` + +前端开发地址通常为:`http://localhost:5173` + +## 常用命令 + +```bash +# 前端 +cd mengyaping-frontend +npm run dev # 开发模式 +npm run build # 生产构建 +npm run lint # 代码检查 + +# 后端 +cd mengyaping-backend +go test ./... # 运行测试 +``` + +Windows 用户也可直接使用仓库根目录下的 `开启前端.bat`、`开启后端.bat`、`构建前端.bat`。 + +## API 概览 + +基础前缀:`/api` + +- `GET /health`:健康检查 +- `GET /websites`:获取全部网站状态 +- `GET /websites/:id`:获取单网站状态 +- `POST /websites`:创建网站 +- `PUT /websites/:id`:更新网站 +- `DELETE /websites/:id`:删除网站 +- `POST /websites/:id/check`:立即检测 +- `GET /groups`:获取分组 +- `POST /groups`:新增分组 + +## 配置说明 + +后端支持环境变量配置(如 `SERVER_PORT`、`MONITOR_INTERVAL`、`MONITOR_TIMEOUT` 等),并会读取 `mengyaping-backend/data/config.json`。 +当前实现中,`config.json` 的值会覆盖环境变量同名项。 + +## Docker 部署(后端) + +```bash +cd mengyaping-backend +docker compose up -d --build +``` + +`docker-compose.yml` 默认映射端口 `6161 -> 8080`。 + +## 展示建议(GitHub) + +- 建议在仓库中新增 `docs/images/` 并放置页面截图 +- 可在本 README 顶部补充截图、动图或在线演示链接,提升展示效果 + diff --git a/mengyaping-backend/.dockerignore b/mengyaping-backend/.dockerignore index 058f085..f582e7a 100644 --- a/mengyaping-backend/.dockerignore +++ b/mengyaping-backend/.dockerignore @@ -1,47 +1,47 @@ -# Git 相关 -.git -.gitignore -.gitattributes - -# 编辑器和 IDE -.vscode -.idea -*.swp -*.swo -*~ - -# 操作系统文件 -.DS_Store -Thumbs.db - -# 数据文件(运行时生成) -data/*.json - -# 日志文件 -*.log - -# 临时文件 -tmp/ -temp/ - -# 文档 -README.md -LICENSE -*.md - -# Docker 相关 -Dockerfile -.dockerignore -docker-compose.yml - -# 测试文件 -*_test.go -test/ -tests/ - -# 构建产物 -*.exe -*.exe~ -*.dll -*.so -*.dylib +# Git 相关 +.git +.gitignore +.gitattributes + +# 编辑器和 IDE +.vscode +.idea +*.swp +*.swo +*~ + +# 操作系统文件 +.DS_Store +Thumbs.db + +# 数据文件(运行时生成) +data/*.json + +# 日志文件 +*.log + +# 临时文件 +tmp/ +temp/ + +# 文档 +README.md +LICENSE +*.md + +# Docker 相关 +Dockerfile +.dockerignore +docker-compose.yml + +# 测试文件 +*_test.go +test/ +tests/ + +# 构建产物 +*.exe +*.exe~ +*.dll +*.so +*.dylib diff --git a/mengyaping-backend/Dockerfile b/mengyaping-backend/Dockerfile index 45530e2..f1566ec 100644 --- a/mengyaping-backend/Dockerfile +++ b/mengyaping-backend/Dockerfile @@ -1,56 +1,56 @@ -# 多阶段构建 - 使用官方 Golang 镜像作为构建环境 -FROM golang:1.25-alpine AS builder - -# 设置工作目录 -WORKDIR /app - -# 安装必要的构建工具 -RUN apk add --no-cache git ca-certificates tzdata - -# 复制 go.mod 和 go.sum 文件 -COPY go.mod go.sum ./ - -# 下载依赖 -RUN go mod download - -# 复制源代码 -COPY . . - -# 构建应用程序 -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -o mengyaping-backend . - -# 使用轻量级的 alpine 镜像作为运行环境 -FROM alpine:latest - -# 安装必要的运行时依赖 -RUN apk --no-cache add ca-certificates tzdata - -# 设置时区为上海 -RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ - echo "Asia/Shanghai" > /etc/timezone - -# 创建非 root 用户 -RUN addgroup -g 1000 appuser && \ - adduser -D -u 1000 -G appuser appuser - -# 设置工作目录 -WORKDIR /app - -# 从构建阶段复制编译好的二进制文件 -COPY --from=builder /app/mengyaping-backend . - -# 创建数据目录 -RUN mkdir -p /app/data && chown -R appuser:appuser /app - -# 切换到非 root 用户 -USER appuser - -# 暴露端口 -EXPOSE 8080 - -# 健康检查 -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health || exit 1 - -# 运行应用程序 -CMD ["./mengyaping-backend"] +# 多阶段构建 - 使用官方 Golang 镜像作为构建环境 +FROM golang:1.25-alpine AS builder + +# 设置工作目录 +WORKDIR /app + +# 安装必要的构建工具 +RUN apk add --no-cache git ca-certificates tzdata + +# 复制 go.mod 和 go.sum 文件 +COPY go.mod go.sum ./ + +# 下载依赖 +RUN go mod download + +# 复制源代码 +COPY . . + +# 构建应用程序 +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -o mengyaping-backend . + +# 使用轻量级的 alpine 镜像作为运行环境 +FROM alpine:latest + +# 安装必要的运行时依赖 +RUN apk --no-cache add ca-certificates tzdata + +# 设置时区为上海 +RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ + echo "Asia/Shanghai" > /etc/timezone + +# 创建非 root 用户 +RUN addgroup -g 1000 appuser && \ + adduser -D -u 1000 -G appuser appuser + +# 设置工作目录 +WORKDIR /app + +# 从构建阶段复制编译好的二进制文件 +COPY --from=builder /app/mengyaping-backend . + +# 创建数据目录 +RUN mkdir -p /app/data && chown -R appuser:appuser /app + +# 切换到非 root 用户 +USER appuser + +# 暴露端口 +EXPOSE 8080 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/health || exit 1 + +# 运行应用程序 +CMD ["./mengyaping-backend"] diff --git a/mengyaping-backend/config/config.go b/mengyaping-backend/config/config.go index b1813b8..1f66719 100644 --- a/mengyaping-backend/config/config.go +++ b/mengyaping-backend/config/config.go @@ -1,176 +1,176 @@ -package config - -import ( - "encoding/json" - "fmt" - "os" - "sync" - "time" -) - -// Config 应用配置 -type Config struct { - Server ServerConfig `json:"server"` - Monitor MonitorConfig `json:"monitor"` - DataPath string `json:"data_path"` -} - -// ServerConfig 服务器配置 -type ServerConfig struct { - Port string `json:"port"` - Host string `json:"host"` -} - -// MonitorConfig 监控配置 -type MonitorConfig struct { - Interval time.Duration `json:"interval"` // 检测间隔 - Timeout time.Duration `json:"timeout"` // 请求超时时间 - RetryCount int `json:"retry_count"` // 重试次数 - HistoryDays int `json:"history_days"` // 保留历史天数 -} - -var ( - cfg *Config - once sync.Once -) - -// GetConfig 获取配置单例 -func GetConfig() *Config { - once.Do(func() { - cfg = &Config{ - Server: ServerConfig{ - Port: getEnv("SERVER_PORT", "8080"), - Host: getEnv("SERVER_HOST", "0.0.0.0"), - }, - Monitor: MonitorConfig{ - Interval: parseDuration(getEnv("MONITOR_INTERVAL", "5m"), 5*time.Minute), - Timeout: parseDuration(getEnv("MONITOR_TIMEOUT", "10s"), 10*time.Second), - RetryCount: parseInt(getEnv("MONITOR_RETRY_COUNT", "3"), 3), - HistoryDays: parseInt(getEnv("MONITOR_HISTORY_DAYS", "7"), 7), - }, - DataPath: getEnv("DATA_PATH", "./data"), - } - - // 尝试从配置文件加载(会覆盖环境变量配置) - loadConfigFromFile() - }) - return cfg -} - -// getEnv 获取环境变量,如果不存在则返回默认值 -func getEnv(key, defaultValue string) string { - if value := os.Getenv(key); value != "" { - return value - } - return defaultValue -} - -// parseInt 解析整数环境变量 -func parseInt(value string, defaultValue int) int { - if value == "" { - return defaultValue - } - var result int - if _, err := fmt.Sscanf(value, "%d", &result); err != nil { - return defaultValue - } - return result -} - -// parseDuration 解析时间间隔环境变量 -func parseDuration(value string, defaultValue time.Duration) time.Duration { - if value == "" { - return defaultValue - } - if duration, err := time.ParseDuration(value); err == nil { - return duration - } - return defaultValue -} - -// loadConfigFromFile 从文件加载配置 -func loadConfigFromFile() { - configFile := "./data/config.json" - if _, err := os.Stat(configFile); os.IsNotExist(err) { - return - } - - data, err := os.ReadFile(configFile) - if err != nil { - return - } - - var fileCfg struct { - Server ServerConfig `json:"server"` - Monitor struct { - IntervalMinutes int `json:"interval_minutes"` - TimeoutSeconds int `json:"timeout_seconds"` - RetryCount int `json:"retry_count"` - HistoryDays int `json:"history_days"` - } `json:"monitor"` - DataPath string `json:"data_path"` - } - - if err := json.Unmarshal(data, &fileCfg); err != nil { - return - } - - if fileCfg.Server.Port != "" { - cfg.Server.Port = fileCfg.Server.Port - } - if fileCfg.Server.Host != "" { - cfg.Server.Host = fileCfg.Server.Host - } - if fileCfg.Monitor.IntervalMinutes > 0 { - cfg.Monitor.Interval = time.Duration(fileCfg.Monitor.IntervalMinutes) * time.Minute - } - if fileCfg.Monitor.TimeoutSeconds > 0 { - cfg.Monitor.Timeout = time.Duration(fileCfg.Monitor.TimeoutSeconds) * time.Second - } - if fileCfg.Monitor.RetryCount > 0 { - cfg.Monitor.RetryCount = fileCfg.Monitor.RetryCount - } - if fileCfg.Monitor.HistoryDays > 0 { - cfg.Monitor.HistoryDays = fileCfg.Monitor.HistoryDays - } - if fileCfg.DataPath != "" { - cfg.DataPath = fileCfg.DataPath - } -} - -// SaveConfig 保存配置到文件 -func SaveConfig() error { - configFile := cfg.DataPath + "/config.json" - - fileCfg := struct { - Server ServerConfig `json:"server"` - Monitor struct { - IntervalMinutes int `json:"interval_minutes"` - TimeoutSeconds int `json:"timeout_seconds"` - RetryCount int `json:"retry_count"` - HistoryDays int `json:"history_days"` - } `json:"monitor"` - DataPath string `json:"data_path"` - }{ - Server: cfg.Server, - Monitor: struct { - IntervalMinutes int `json:"interval_minutes"` - TimeoutSeconds int `json:"timeout_seconds"` - RetryCount int `json:"retry_count"` - HistoryDays int `json:"history_days"` - }{ - IntervalMinutes: int(cfg.Monitor.Interval.Minutes()), - TimeoutSeconds: int(cfg.Monitor.Timeout.Seconds()), - RetryCount: cfg.Monitor.RetryCount, - HistoryDays: cfg.Monitor.HistoryDays, - }, - DataPath: cfg.DataPath, - } - - data, err := json.MarshalIndent(fileCfg, "", " ") - if err != nil { - return err - } - - return os.WriteFile(configFile, data, 0644) -} +package config + +import ( + "encoding/json" + "fmt" + "os" + "sync" + "time" +) + +// Config 应用配置 +type Config struct { + Server ServerConfig `json:"server"` + Monitor MonitorConfig `json:"monitor"` + DataPath string `json:"data_path"` +} + +// ServerConfig 服务器配置 +type ServerConfig struct { + Port string `json:"port"` + Host string `json:"host"` +} + +// MonitorConfig 监控配置 +type MonitorConfig struct { + Interval time.Duration `json:"interval"` // 检测间隔 + Timeout time.Duration `json:"timeout"` // 请求超时时间 + RetryCount int `json:"retry_count"` // 重试次数 + HistoryDays int `json:"history_days"` // 保留历史天数 +} + +var ( + cfg *Config + once sync.Once +) + +// GetConfig 获取配置单例 +func GetConfig() *Config { + once.Do(func() { + cfg = &Config{ + Server: ServerConfig{ + Port: getEnv("SERVER_PORT", "8080"), + Host: getEnv("SERVER_HOST", "0.0.0.0"), + }, + Monitor: MonitorConfig{ + Interval: parseDuration(getEnv("MONITOR_INTERVAL", "1h"), 1*time.Hour), + Timeout: parseDuration(getEnv("MONITOR_TIMEOUT", "10s"), 10*time.Second), + RetryCount: parseInt(getEnv("MONITOR_RETRY_COUNT", "3"), 3), + HistoryDays: parseInt(getEnv("MONITOR_HISTORY_DAYS", "90"), 90), + }, + DataPath: getEnv("DATA_PATH", "./data"), + } + + // 尝试从配置文件加载(会覆盖环境变量配置) + loadConfigFromFile() + }) + return cfg +} + +// getEnv 获取环境变量,如果不存在则返回默认值 +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// parseInt 解析整数环境变量 +func parseInt(value string, defaultValue int) int { + if value == "" { + return defaultValue + } + var result int + if _, err := fmt.Sscanf(value, "%d", &result); err != nil { + return defaultValue + } + return result +} + +// parseDuration 解析时间间隔环境变量 +func parseDuration(value string, defaultValue time.Duration) time.Duration { + if value == "" { + return defaultValue + } + if duration, err := time.ParseDuration(value); err == nil { + return duration + } + return defaultValue +} + +// loadConfigFromFile 从文件加载配置 +func loadConfigFromFile() { + configFile := "./data/config.json" + if _, err := os.Stat(configFile); os.IsNotExist(err) { + return + } + + data, err := os.ReadFile(configFile) + if err != nil { + return + } + + var fileCfg struct { + Server ServerConfig `json:"server"` + Monitor struct { + IntervalMinutes int `json:"interval_minutes"` + TimeoutSeconds int `json:"timeout_seconds"` + RetryCount int `json:"retry_count"` + HistoryDays int `json:"history_days"` + } `json:"monitor"` + DataPath string `json:"data_path"` + } + + if err := json.Unmarshal(data, &fileCfg); err != nil { + return + } + + if fileCfg.Server.Port != "" { + cfg.Server.Port = fileCfg.Server.Port + } + if fileCfg.Server.Host != "" { + cfg.Server.Host = fileCfg.Server.Host + } + if fileCfg.Monitor.IntervalMinutes > 0 { + cfg.Monitor.Interval = time.Duration(fileCfg.Monitor.IntervalMinutes) * time.Minute + } + if fileCfg.Monitor.TimeoutSeconds > 0 { + cfg.Monitor.Timeout = time.Duration(fileCfg.Monitor.TimeoutSeconds) * time.Second + } + if fileCfg.Monitor.RetryCount > 0 { + cfg.Monitor.RetryCount = fileCfg.Monitor.RetryCount + } + if fileCfg.Monitor.HistoryDays > 0 { + cfg.Monitor.HistoryDays = fileCfg.Monitor.HistoryDays + } + if fileCfg.DataPath != "" { + cfg.DataPath = fileCfg.DataPath + } +} + +// SaveConfig 保存配置到文件 +func SaveConfig() error { + configFile := cfg.DataPath + "/config.json" + + fileCfg := struct { + Server ServerConfig `json:"server"` + Monitor struct { + IntervalMinutes int `json:"interval_minutes"` + TimeoutSeconds int `json:"timeout_seconds"` + RetryCount int `json:"retry_count"` + HistoryDays int `json:"history_days"` + } `json:"monitor"` + DataPath string `json:"data_path"` + }{ + Server: cfg.Server, + Monitor: struct { + IntervalMinutes int `json:"interval_minutes"` + TimeoutSeconds int `json:"timeout_seconds"` + RetryCount int `json:"retry_count"` + HistoryDays int `json:"history_days"` + }{ + IntervalMinutes: int(cfg.Monitor.Interval.Minutes()), + TimeoutSeconds: int(cfg.Monitor.Timeout.Seconds()), + RetryCount: cfg.Monitor.RetryCount, + HistoryDays: cfg.Monitor.HistoryDays, + }, + DataPath: cfg.DataPath, + } + + data, err := json.MarshalIndent(fileCfg, "", " ") + if err != nil { + return err + } + + return os.WriteFile(configFile, data, 0644) +} diff --git a/mengyaping-backend/docker-compose.yml b/mengyaping-backend/docker-compose.yml index 8e15cca..b64fe0b 100644 --- a/mengyaping-backend/docker-compose.yml +++ b/mengyaping-backend/docker-compose.yml @@ -1,40 +1,40 @@ -version: '3.8' - -services: - mengyaping-backend: - build: - context: . - dockerfile: Dockerfile - container_name: mengyaping-backend - restart: unless-stopped - ports: - - "6161:8080" - volumes: - # 持久化数据目录 - - /shumengya/docker/mengyaping-backend/data/:/app/data - environment: - # 服务器配置 - - SERVER_PORT=8080 - - SERVER_HOST=0.0.0.0 - # 监控配置 - - MONITOR_INTERVAL=5m - - MONITOR_TIMEOUT=10s - - MONITOR_RETRY_COUNT=3 - - MONITOR_HISTORY_DAYS=7 - networks: - - mengyaping-network - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/health"] - interval: 30s - timeout: 3s - retries: 3 - start_period: 5s - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - -networks: - mengyaping-network: - driver: bridge +version: '3.8' + +services: + mengyaping-backend: + build: + context: . + dockerfile: Dockerfile + container_name: mengyaping-backend + restart: unless-stopped + ports: + - "6161:8080" + volumes: + # 持久化数据目录 + - /shumengya/docker/mengyaping-backend/data/:/app/data + environment: + # 服务器配置 + - SERVER_PORT=8080 + - SERVER_HOST=0.0.0.0 + # 监控配置 + - MONITOR_INTERVAL=5m + - MONITOR_TIMEOUT=10s + - MONITOR_RETRY_COUNT=3 + - MONITOR_HISTORY_DAYS=7 + networks: + - mengyaping-network + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 5s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + +networks: + mengyaping-network: + driver: bridge diff --git a/mengyaping-backend/handlers/website.go b/mengyaping-backend/handlers/website.go index d69b3e3..258eb9e 100644 --- a/mengyaping-backend/handlers/website.go +++ b/mengyaping-backend/handlers/website.go @@ -1,199 +1,199 @@ -package handlers - -import ( - "net/http" - - "github.com/gin-gonic/gin" - - "mengyaping-backend/models" - "mengyaping-backend/services" -) - -// WebsiteHandler 网站处理器 -type WebsiteHandler struct { - websiteService *services.WebsiteService - monitorService *services.MonitorService -} - -// NewWebsiteHandler 创建网站处理器 -func NewWebsiteHandler() *WebsiteHandler { - return &WebsiteHandler{ - websiteService: services.NewWebsiteService(), - monitorService: services.GetMonitorService(), - } -} - -// GetWebsites 获取所有网站状态 -func (h *WebsiteHandler) GetWebsites(c *gin.Context) { - statuses := h.monitorService.GetAllWebsiteStatuses() - - c.JSON(http.StatusOK, models.APIResponse{ - Code: 0, - Message: "success", - Data: statuses, - }) -} - -// GetWebsite 获取单个网站状态 -func (h *WebsiteHandler) GetWebsite(c *gin.Context) { - id := c.Param("id") - - status := h.monitorService.GetWebsiteStatus(id) - if status == nil { - c.JSON(http.StatusNotFound, models.APIResponse{ - Code: 404, - Message: "网站不存在", - }) - return - } - - c.JSON(http.StatusOK, models.APIResponse{ - Code: 0, - Message: "success", - Data: status, - }) -} - -// CreateWebsite 创建网站 -func (h *WebsiteHandler) CreateWebsite(c *gin.Context) { - var req models.CreateWebsiteRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.APIResponse{ - Code: 400, - Message: "参数错误: " + err.Error(), - }) - return - } - - website, err := h.websiteService.CreateWebsite(req) - if err != nil { - c.JSON(http.StatusInternalServerError, models.APIResponse{ - Code: 500, - Message: "创建失败: " + err.Error(), - }) - return - } - - c.JSON(http.StatusOK, models.APIResponse{ - Code: 0, - Message: "创建成功", - Data: website, - }) -} - -// UpdateWebsite 更新网站 -func (h *WebsiteHandler) UpdateWebsite(c *gin.Context) { - id := c.Param("id") - - var req models.UpdateWebsiteRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.APIResponse{ - Code: 400, - Message: "参数错误: " + err.Error(), - }) - return - } - - website, err := h.websiteService.UpdateWebsite(id, req) - if err != nil { - c.JSON(http.StatusInternalServerError, models.APIResponse{ - Code: 500, - Message: "更新失败: " + err.Error(), - }) - return - } - - if website == nil { - c.JSON(http.StatusNotFound, models.APIResponse{ - Code: 404, - Message: "网站不存在", - }) - return - } - - c.JSON(http.StatusOK, models.APIResponse{ - Code: 0, - Message: "更新成功", - Data: website, - }) -} - -// DeleteWebsite 删除网站 -func (h *WebsiteHandler) DeleteWebsite(c *gin.Context) { - id := c.Param("id") - - if err := h.websiteService.DeleteWebsite(id); err != nil { - c.JSON(http.StatusInternalServerError, models.APIResponse{ - Code: 500, - Message: "删除失败: " + err.Error(), - }) - return - } - - c.JSON(http.StatusOK, models.APIResponse{ - Code: 0, - Message: "删除成功", - }) -} - -// CheckWebsiteNow 立即检测网站 -func (h *WebsiteHandler) CheckWebsiteNow(c *gin.Context) { - id := c.Param("id") - - website := h.websiteService.GetWebsite(id) - if website == nil { - c.JSON(http.StatusNotFound, models.APIResponse{ - Code: 404, - Message: "网站不存在", - }) - return - } - - h.monitorService.CheckWebsiteNow(id) - - c.JSON(http.StatusOK, models.APIResponse{ - Code: 0, - Message: "检测任务已提交", - }) -} - -// GetGroups 获取所有分组 -func (h *WebsiteHandler) GetGroups(c *gin.Context) { - groups := h.websiteService.GetGroups() - - c.JSON(http.StatusOK, models.APIResponse{ - Code: 0, - Message: "success", - Data: groups, - }) -} - -// AddGroup 添加分组 -func (h *WebsiteHandler) AddGroup(c *gin.Context) { - var req struct { - Name string `json:"name" binding:"required"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, models.APIResponse{ - Code: 400, - Message: "参数错误: " + err.Error(), - }) - return - } - - group, err := h.websiteService.AddGroup(req.Name) - if err != nil { - c.JSON(http.StatusInternalServerError, models.APIResponse{ - Code: 500, - Message: "添加失败: " + err.Error(), - }) - return - } - - c.JSON(http.StatusOK, models.APIResponse{ - Code: 0, - Message: "添加成功", - Data: group, - }) -} +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "mengyaping-backend/models" + "mengyaping-backend/services" +) + +// WebsiteHandler 网站处理器 +type WebsiteHandler struct { + websiteService *services.WebsiteService + monitorService *services.MonitorService +} + +// NewWebsiteHandler 创建网站处理器 +func NewWebsiteHandler() *WebsiteHandler { + return &WebsiteHandler{ + websiteService: services.NewWebsiteService(), + monitorService: services.GetMonitorService(), + } +} + +// GetWebsites 获取所有网站状态 +func (h *WebsiteHandler) GetWebsites(c *gin.Context) { + statuses := h.monitorService.GetAllWebsiteStatuses() + + c.JSON(http.StatusOK, models.APIResponse{ + Code: 0, + Message: "success", + Data: statuses, + }) +} + +// GetWebsite 获取单个网站状态 +func (h *WebsiteHandler) GetWebsite(c *gin.Context) { + id := c.Param("id") + + status := h.monitorService.GetWebsiteStatus(id) + if status == nil { + c.JSON(http.StatusNotFound, models.APIResponse{ + Code: 404, + Message: "网站不存在", + }) + return + } + + c.JSON(http.StatusOK, models.APIResponse{ + Code: 0, + Message: "success", + Data: status, + }) +} + +// CreateWebsite 创建网站 +func (h *WebsiteHandler) CreateWebsite(c *gin.Context) { + var req models.CreateWebsiteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.APIResponse{ + Code: 400, + Message: "参数错误: " + err.Error(), + }) + return + } + + website, err := h.websiteService.CreateWebsite(req) + if err != nil { + c.JSON(http.StatusInternalServerError, models.APIResponse{ + Code: 500, + Message: "创建失败: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, models.APIResponse{ + Code: 0, + Message: "创建成功", + Data: website, + }) +} + +// UpdateWebsite 更新网站 +func (h *WebsiteHandler) UpdateWebsite(c *gin.Context) { + id := c.Param("id") + + var req models.UpdateWebsiteRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.APIResponse{ + Code: 400, + Message: "参数错误: " + err.Error(), + }) + return + } + + website, err := h.websiteService.UpdateWebsite(id, req) + if err != nil { + c.JSON(http.StatusInternalServerError, models.APIResponse{ + Code: 500, + Message: "更新失败: " + err.Error(), + }) + return + } + + if website == nil { + c.JSON(http.StatusNotFound, models.APIResponse{ + Code: 404, + Message: "网站不存在", + }) + return + } + + c.JSON(http.StatusOK, models.APIResponse{ + Code: 0, + Message: "更新成功", + Data: website, + }) +} + +// DeleteWebsite 删除网站 +func (h *WebsiteHandler) DeleteWebsite(c *gin.Context) { + id := c.Param("id") + + if err := h.websiteService.DeleteWebsite(id); err != nil { + c.JSON(http.StatusInternalServerError, models.APIResponse{ + Code: 500, + Message: "删除失败: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, models.APIResponse{ + Code: 0, + Message: "删除成功", + }) +} + +// CheckWebsiteNow 立即检测网站 +func (h *WebsiteHandler) CheckWebsiteNow(c *gin.Context) { + id := c.Param("id") + + website := h.websiteService.GetWebsite(id) + if website == nil { + c.JSON(http.StatusNotFound, models.APIResponse{ + Code: 404, + Message: "网站不存在", + }) + return + } + + h.monitorService.CheckWebsiteNow(id) + + c.JSON(http.StatusOK, models.APIResponse{ + Code: 0, + Message: "检测任务已提交", + }) +} + +// GetGroups 获取所有分组 +func (h *WebsiteHandler) GetGroups(c *gin.Context) { + groups := h.websiteService.GetGroups() + + c.JSON(http.StatusOK, models.APIResponse{ + Code: 0, + Message: "success", + Data: groups, + }) +} + +// AddGroup 添加分组 +func (h *WebsiteHandler) AddGroup(c *gin.Context) { + var req struct { + Name string `json:"name" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, models.APIResponse{ + Code: 400, + Message: "参数错误: " + err.Error(), + }) + return + } + + group, err := h.websiteService.AddGroup(req.Name) + if err != nil { + c.JSON(http.StatusInternalServerError, models.APIResponse{ + Code: 500, + Message: "添加失败: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, models.APIResponse{ + Code: 0, + Message: "添加成功", + Data: group, + }) +} diff --git a/mengyaping-backend/main.go b/mengyaping-backend/main.go index 8b4fca3..3286ce9 100644 --- a/mengyaping-backend/main.go +++ b/mengyaping-backend/main.go @@ -1,47 +1,47 @@ -package main - -import ( - "fmt" - "log" - "os" - "os/signal" - "syscall" - - "mengyaping-backend/config" - "mengyaping-backend/router" - "mengyaping-backend/services" -) - -func main() { - // 获取配置 - cfg := config.GetConfig() - - // 确保数据目录存在 - os.MkdirAll(cfg.DataPath, 0755) - - // 启动监控服务 - monitorService := services.GetMonitorService() - go monitorService.Start() - - // 设置路由 - r := router.SetupRouter() - - // 优雅关闭 - go func() { - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - <-sigCh - - log.Println("正在关闭服务...") - monitorService.Stop() - os.Exit(0) - }() - - // 启动服务器 - addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port) - log.Printf("🌱 萌芽Ping 监控服务已启动,监听地址: %s\n", addr) - - if err := r.Run(addr); err != nil { - log.Fatalf("服务器启动失败: %v", err) - } -} +package main + +import ( + "fmt" + "log" + "os" + "os/signal" + "syscall" + + "mengyaping-backend/config" + "mengyaping-backend/router" + "mengyaping-backend/services" +) + +func main() { + // 获取配置 + cfg := config.GetConfig() + + // 确保数据目录存在 + os.MkdirAll(cfg.DataPath, 0755) + + // 启动监控服务 + monitorService := services.GetMonitorService() + go monitorService.Start() + + // 设置路由 + r := router.SetupRouter() + + // 优雅关闭 + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + + log.Println("正在关闭服务...") + monitorService.Stop() + os.Exit(0) + }() + + // 启动服务器 + addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port) + log.Printf("🌱 萌芽Ping 监控服务已启动,监听地址: %s\n", addr) + + if err := r.Run(addr); err != nil { + log.Fatalf("服务器启动失败: %v", err) + } +} diff --git a/mengyaping-backend/models/website.go b/mengyaping-backend/models/website.go index 792700c..e3a281a 100644 --- a/mengyaping-backend/models/website.go +++ b/mengyaping-backend/models/website.go @@ -1,98 +1,113 @@ -package models - -import ( - "time" -) - -// Website 网站信息 -type Website struct { - ID string `json:"id"` - Name string `json:"name"` // 网站名称 - Group string `json:"group"` // 所属分组 - URLs []URLInfo `json:"urls"` // 网站访问地址列表 - Favicon string `json:"favicon"` // 网站图标URL - Title string `json:"title"` // 网站标题 - CreatedAt time.Time `json:"created_at"` // 创建时间 - UpdatedAt time.Time `json:"updated_at"` // 更新时间 -} - -// URLInfo 单个URL的信息 -type URLInfo struct { - ID string `json:"id"` - URL string `json:"url"` // 访问地址 - Remark string `json:"remark"` // 备注说明 -} - -// MonitorRecord 监控记录 -type MonitorRecord struct { - WebsiteID string `json:"website_id"` - URLID string `json:"url_id"` - URL string `json:"url"` - StatusCode int `json:"status_code"` // HTTP状态码 - Latency int64 `json:"latency"` // 延迟(毫秒) - IsUp bool `json:"is_up"` // 是否可访问 - Error string `json:"error"` // 错误信息 - CheckedAt time.Time `json:"checked_at"` // 检测时间 -} - -// WebsiteStatus 网站状态(用于前端展示) -type WebsiteStatus struct { - Website Website `json:"website"` - URLStatuses []URLStatus `json:"url_statuses"` - Uptime24h float64 `json:"uptime_24h"` // 24小时可用率 - Uptime7d float64 `json:"uptime_7d"` // 7天可用率 - LastChecked time.Time `json:"last_checked"` // 最后检测时间 -} - -// URLStatus 单个URL的状态 -type URLStatus struct { - URLInfo URLInfo `json:"url_info"` - CurrentState MonitorRecord `json:"current_state"` // 当前状态 - History24h []MonitorRecord `json:"history_24h"` // 24小时历史 - History7d []HourlyStats `json:"history_7d"` // 7天按小时统计 - Uptime24h float64 `json:"uptime_24h"` // 24小时可用率 - Uptime7d float64 `json:"uptime_7d"` // 7天可用率 - AvgLatency int64 `json:"avg_latency"` // 平均延迟 -} - -// HourlyStats 每小时统计 -type HourlyStats struct { - Hour time.Time `json:"hour"` - TotalCount int `json:"total_count"` - UpCount int `json:"up_count"` - AvgLatency int64 `json:"avg_latency"` - Uptime float64 `json:"uptime"` -} - -// Group 分组 -type Group struct { - ID string `json:"id"` - Name string `json:"name"` -} - -// DefaultGroups 默认分组 -var DefaultGroups = []Group{ - {ID: "normal", Name: "普通网站"}, - {ID: "admin", Name: "管理员网站"}, -} - -// CreateWebsiteRequest 创建网站请求 -type CreateWebsiteRequest struct { - Name string `json:"name" binding:"required"` - Group string `json:"group" binding:"required"` - URLs []string `json:"urls" binding:"required,min=1"` -} - -// UpdateWebsiteRequest 更新网站请求 -type UpdateWebsiteRequest struct { - Name string `json:"name"` - Group string `json:"group"` - URLs []string `json:"urls"` -} - -// APIResponse API响应 -type APIResponse struct { - Code int `json:"code"` - Message string `json:"message"` - Data interface{} `json:"data,omitempty"` -} +package models + +import ( + "time" +) + +// Website 网站信息 +type Website struct { + ID string `json:"id"` + Name string `json:"name"` // 网站名称 + Groups []string `json:"groups"` // 所属分组列表(支持多分组) + Group string `json:"group,omitempty"` // 已废弃,仅用于旧数据兼容 + URLs []URLInfo `json:"urls"` // 网站访问地址列表 + IPAddresses []string `json:"ip_addresses,omitempty"` // 域名解析的IP地址 + Favicon string `json:"favicon"` // 网站图标URL + Title string `json:"title"` // 网站标题 + CreatedAt time.Time `json:"created_at"` // 创建时间 + UpdatedAt time.Time `json:"updated_at"` // 更新时间 +} + +// URLInfo 单个URL的信息 +type URLInfo struct { + ID string `json:"id"` + URL string `json:"url"` // 访问地址 + Remark string `json:"remark"` // 备注说明 +} + +// MonitorRecord 监控记录 +type MonitorRecord struct { + WebsiteID string `json:"website_id"` + URLID string `json:"url_id"` + URL string `json:"url"` + StatusCode int `json:"status_code"` // HTTP状态码 + Latency int64 `json:"latency"` // 延迟(毫秒) + IsUp bool `json:"is_up"` // 是否可访问 + Error string `json:"error"` // 错误信息 + CheckedAt time.Time `json:"checked_at"` // 检测时间 +} + +// WebsiteStatus 网站状态(用于前端展示) +type WebsiteStatus struct { + Website Website `json:"website"` + URLStatuses []URLStatus `json:"url_statuses"` + DailyHistory []DailyStats `json:"daily_history"` // 90天逐日统计 + Uptime24h float64 `json:"uptime_24h"` // 24小时可用率 + Uptime7d float64 `json:"uptime_7d"` // 7天可用率 + Uptime90d float64 `json:"uptime_90d"` // 90天可用率 + LastChecked time.Time `json:"last_checked"` // 最后检测时间 +} + +// URLStatus 单个URL的状态 +type URLStatus struct { + URLInfo URLInfo `json:"url_info"` + CurrentState MonitorRecord `json:"current_state"` // 当前状态 + History24h []MonitorRecord `json:"history_24h"` // 24小时历史 + History7d []HourlyStats `json:"history_7d"` // 7天按小时统计 + Uptime24h float64 `json:"uptime_24h"` // 24小时可用率 + Uptime7d float64 `json:"uptime_7d"` // 7天可用率 + AvgLatency int64 `json:"avg_latency"` // 平均延迟 +} + +// HourlyStats 每小时统计 +type HourlyStats struct { + Hour time.Time `json:"hour"` + TotalCount int `json:"total_count"` + UpCount int `json:"up_count"` + AvgLatency int64 `json:"avg_latency"` + Uptime float64 `json:"uptime"` +} + +// DailyStats 每日统计 +type DailyStats struct { + Date time.Time `json:"date"` + TotalCount int `json:"total_count"` + UpCount int `json:"up_count"` + AvgLatency int64 `json:"avg_latency"` + Uptime float64 `json:"uptime"` +} + +// Group 分组 +type Group struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// DefaultGroups 默认分组 +var DefaultGroups = []Group{ + {ID: "normal", Name: "普通网站"}, + {ID: "admin", Name: "管理员网站"}, +} + +// CreateWebsiteRequest 创建网站请求 +type CreateWebsiteRequest struct { + Name string `json:"name" binding:"required"` + Groups []string `json:"groups" binding:"required,min=1"` + Group string `json:"group"` + URLs []string `json:"urls" binding:"required,min=1"` +} + +// UpdateWebsiteRequest 更新网站请求 +type UpdateWebsiteRequest struct { + Name string `json:"name"` + Groups []string `json:"groups"` + Group string `json:"group"` + URLs []string `json:"urls"` +} + +// APIResponse API响应 +type APIResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} diff --git a/mengyaping-backend/router/router.go b/mengyaping-backend/router/router.go index 25a559b..e6177d8 100644 --- a/mengyaping-backend/router/router.go +++ b/mengyaping-backend/router/router.go @@ -1,51 +1,51 @@ -package router - -import ( - "github.com/gin-contrib/cors" - "github.com/gin-gonic/gin" - - "mengyaping-backend/handlers" -) - -// SetupRouter 设置路由 -func SetupRouter() *gin.Engine { - r := gin.Default() - - // CORS配置 - r.Use(cors.New(cors.Config{ - AllowOrigins: []string{"*"}, - AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"}, - ExposeHeaders: []string{"Content-Length"}, - AllowCredentials: true, - })) - - // 创建处理器 - websiteHandler := handlers.NewWebsiteHandler() - - // API路由组 - api := r.Group("/api") - { - // 健康检查 - api.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{ - "status": "ok", - "message": "服务运行正常", - }) - }) - - // 网站相关 - api.GET("/websites", websiteHandler.GetWebsites) - api.GET("/websites/:id", websiteHandler.GetWebsite) - api.POST("/websites", websiteHandler.CreateWebsite) - api.PUT("/websites/:id", websiteHandler.UpdateWebsite) - api.DELETE("/websites/:id", websiteHandler.DeleteWebsite) - api.POST("/websites/:id/check", websiteHandler.CheckWebsiteNow) - - // 分组相关 - api.GET("/groups", websiteHandler.GetGroups) - api.POST("/groups", websiteHandler.AddGroup) - } - - return r -} +package router + +import ( + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" + + "mengyaping-backend/handlers" +) + +// SetupRouter 设置路由 +func SetupRouter() *gin.Engine { + r := gin.Default() + + // CORS配置 + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + })) + + // 创建处理器 + websiteHandler := handlers.NewWebsiteHandler() + + // API路由组 + api := r.Group("/api") + { + // 健康检查 + api.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{ + "status": "ok", + "message": "服务运行正常", + }) + }) + + // 网站相关 + api.GET("/websites", websiteHandler.GetWebsites) + api.GET("/websites/:id", websiteHandler.GetWebsite) + api.POST("/websites", websiteHandler.CreateWebsite) + api.PUT("/websites/:id", websiteHandler.UpdateWebsite) + api.DELETE("/websites/:id", websiteHandler.DeleteWebsite) + api.POST("/websites/:id/check", websiteHandler.CheckWebsiteNow) + + // 分组相关 + api.GET("/groups", websiteHandler.GetGroups) + api.POST("/groups", websiteHandler.AddGroup) + } + + return r +} diff --git a/mengyaping-backend/services/monitor.go b/mengyaping-backend/services/monitor.go index 46f5876..757ceb5 100644 --- a/mengyaping-backend/services/monitor.go +++ b/mengyaping-backend/services/monitor.go @@ -1,302 +1,418 @@ -package services - -import ( - "log" - "sync" - "time" - - "mengyaping-backend/config" - "mengyaping-backend/models" - "mengyaping-backend/storage" - "mengyaping-backend/utils" -) - -// MonitorService 监控服务 -type MonitorService struct { - httpClient *utils.HTTPClient - storage *storage.Storage - stopCh chan struct{} - running bool - mu sync.Mutex -} - -var ( - monitorService *MonitorService - monitorOnce sync.Once -) - -// GetMonitorService 获取监控服务单例 -func GetMonitorService() *MonitorService { - monitorOnce.Do(func() { - cfg := config.GetConfig() - monitorService = &MonitorService{ - httpClient: utils.NewHTTPClient(cfg.Monitor.Timeout), - storage: storage.GetStorage(), - stopCh: make(chan struct{}), - } - }) - return monitorService -} - -// Start 启动监控服务 -func (s *MonitorService) Start() { - s.mu.Lock() - if s.running { - s.mu.Unlock() - return - } - s.running = true - s.mu.Unlock() - - log.Println("监控服务已启动") - - // 立即执行一次检测 - go s.checkAll() - - // 定时检测 - cfg := config.GetConfig() - ticker := time.NewTicker(cfg.Monitor.Interval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - go s.checkAll() - case <-s.stopCh: - log.Println("监控服务已停止") - return - } - } -} - -// Stop 停止监控服务 -func (s *MonitorService) Stop() { - s.mu.Lock() - defer s.mu.Unlock() - - if s.running { - close(s.stopCh) - s.running = false - } -} - -// checkAll 检查所有网站 -func (s *MonitorService) checkAll() { - websites := s.storage.GetWebsites() - - var wg sync.WaitGroup - semaphore := make(chan struct{}, 10) // 限制并发数 - - for _, website := range websites { - for _, urlInfo := range website.URLs { - wg.Add(1) - go func(w models.Website, u models.URLInfo) { - defer wg.Done() - semaphore <- struct{}{} - defer func() { <-semaphore }() - - s.checkURL(w, u) - }(website, urlInfo) - } - } - - wg.Wait() - - // 保存记录 - s.storage.SaveAll() -} - -// checkURL 检查单个URL -func (s *MonitorService) checkURL(website models.Website, urlInfo models.URLInfo) { - result := s.httpClient.CheckWebsite(urlInfo.URL) - - record := models.MonitorRecord{ - WebsiteID: website.ID, - URLID: urlInfo.ID, - URL: urlInfo.URL, - StatusCode: result.StatusCode, - Latency: result.Latency.Milliseconds(), - IsUp: result.Error == nil && utils.IsSuccessStatus(result.StatusCode), - CheckedAt: time.Now(), - } - - if result.Error != nil { - record.Error = result.Error.Error() - } - - s.storage.AddRecord(record) - - // 更新网站信息(标题和Favicon) - if result.Title != "" || result.Favicon != "" { - w := s.storage.GetWebsite(website.ID) - if w != nil { - needUpdate := false - if result.Title != "" && w.Title != result.Title { - w.Title = result.Title - needUpdate = true - } - if result.Favicon != "" && w.Favicon != result.Favicon { - w.Favicon = result.Favicon - needUpdate = true - } - if needUpdate { - w.UpdatedAt = time.Now() - s.storage.UpdateWebsite(*w) - } - } - } - - log.Printf("检测 [%s] %s - 状态码: %d, 延迟: %dms, 可用: %v", - website.Name, urlInfo.URL, result.StatusCode, result.Latency.Milliseconds(), record.IsUp) -} - -// CheckWebsiteNow 立即检查指定网站 -func (s *MonitorService) CheckWebsiteNow(websiteID string) { - website := s.storage.GetWebsite(websiteID) - if website == nil { - return - } - - for _, urlInfo := range website.URLs { - go s.checkURL(*website, urlInfo) - } -} - -// GetWebsiteStatus 获取网站状态 -func (s *MonitorService) GetWebsiteStatus(websiteID string) *models.WebsiteStatus { - website := s.storage.GetWebsite(websiteID) - if website == nil { - return nil - } - - status := &models.WebsiteStatus{ - Website: *website, - URLStatuses: []models.URLStatus{}, - } - - now := time.Now() - since24h := now.Add(-24 * time.Hour) - since7d := now.Add(-7 * 24 * time.Hour) - - var totalUptime24h, totalUptime7d float64 - var urlCount int - - for _, urlInfo := range website.URLs { - urlStatus := s.getURLStatus(website.ID, urlInfo, since24h, since7d) - status.URLStatuses = append(status.URLStatuses, urlStatus) - - totalUptime24h += urlStatus.Uptime24h - totalUptime7d += urlStatus.Uptime7d - urlCount++ - } - - if urlCount > 0 { - status.Uptime24h = totalUptime24h / float64(urlCount) - status.Uptime7d = totalUptime7d / float64(urlCount) - } - - // 获取最后检测时间 - for _, urlStatus := range status.URLStatuses { - if urlStatus.CurrentState.CheckedAt.After(status.LastChecked) { - status.LastChecked = urlStatus.CurrentState.CheckedAt - } - } - - return status -} - -// getURLStatus 获取URL状态 -func (s *MonitorService) getURLStatus(websiteID string, urlInfo models.URLInfo, since24h, since7d time.Time) models.URLStatus { - urlStatus := models.URLStatus{ - URLInfo: urlInfo, - } - - // 获取最新记录 - latest := s.storage.GetLatestRecord(websiteID, urlInfo.ID) - if latest != nil { - urlStatus.CurrentState = *latest - } - - // 获取24小时记录 - records24h := s.storage.GetRecords(websiteID, urlInfo.ID, since24h) - urlStatus.History24h = records24h - - // 计算24小时可用率 - if len(records24h) > 0 { - upCount := 0 - var totalLatency int64 - for _, r := range records24h { - if r.IsUp { - upCount++ - } - totalLatency += r.Latency - } - urlStatus.Uptime24h = float64(upCount) / float64(len(records24h)) * 100 - urlStatus.AvgLatency = totalLatency / int64(len(records24h)) - } - - // 获取7天记录并按小时统计 - records7d := s.storage.GetRecords(websiteID, urlInfo.ID, since7d) - urlStatus.History7d = s.aggregateByHour(records7d) - - // 计算7天可用率 - if len(records7d) > 0 { - upCount := 0 - for _, r := range records7d { - if r.IsUp { - upCount++ - } - } - urlStatus.Uptime7d = float64(upCount) / float64(len(records7d)) * 100 - } - - return urlStatus -} - -// aggregateByHour 按小时聚合记录 -func (s *MonitorService) aggregateByHour(records []models.MonitorRecord) []models.HourlyStats { - hourlyMap := make(map[string]*models.HourlyStats) - - for _, r := range records { - hourKey := r.CheckedAt.Truncate(time.Hour).Format(time.RFC3339) - - if _, exists := hourlyMap[hourKey]; !exists { - hourlyMap[hourKey] = &models.HourlyStats{ - Hour: r.CheckedAt.Truncate(time.Hour), - } - } - - stats := hourlyMap[hourKey] - stats.TotalCount++ - if r.IsUp { - stats.UpCount++ - } - stats.AvgLatency += r.Latency - } - - var result []models.HourlyStats - for _, stats := range hourlyMap { - if stats.TotalCount > 0 { - stats.AvgLatency /= int64(stats.TotalCount) - stats.Uptime = float64(stats.UpCount) / float64(stats.TotalCount) * 100 - } - result = append(result, *stats) - } - - return result -} - -// GetAllWebsiteStatuses 获取所有网站状态 -func (s *MonitorService) GetAllWebsiteStatuses() []models.WebsiteStatus { - websites := s.storage.GetWebsites() - var statuses []models.WebsiteStatus - - for _, website := range websites { - status := s.GetWebsiteStatus(website.ID) - if status != nil { - statuses = append(statuses, *status) - } - } - - return statuses -} +package services + +import ( + "log" + "sort" + "sync" + "time" + + "mengyaping-backend/config" + "mengyaping-backend/models" + "mengyaping-backend/storage" + "mengyaping-backend/utils" +) + +// MonitorService 监控服务 +type MonitorService struct { + httpClient *utils.HTTPClient + storage *storage.Storage + stopCh chan struct{} + running bool + mu sync.Mutex +} + +var ( + monitorService *MonitorService + monitorOnce sync.Once +) + +// GetMonitorService 获取监控服务单例 +func GetMonitorService() *MonitorService { + monitorOnce.Do(func() { + cfg := config.GetConfig() + monitorService = &MonitorService{ + httpClient: utils.NewHTTPClient(cfg.Monitor.Timeout), + storage: storage.GetStorage(), + stopCh: make(chan struct{}), + } + }) + return monitorService +} + +// Start 启动监控服务 +func (s *MonitorService) Start() { + s.mu.Lock() + if s.running { + s.mu.Unlock() + return + } + s.running = true + s.mu.Unlock() + + log.Println("监控服务已启动") + + // 立即执行一次检测 + go s.checkAll() + + // 定时检测 + cfg := config.GetConfig() + ticker := time.NewTicker(cfg.Monitor.Interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + go s.checkAll() + case <-s.stopCh: + log.Println("监控服务已停止") + return + } + } +} + +// Stop 停止监控服务 +func (s *MonitorService) Stop() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.running { + close(s.stopCh) + s.running = false + } +} + +// checkAll 检查所有网站(错峰执行,避免并发暴涨) +func (s *MonitorService) checkAll() { + websites := s.storage.GetWebsites() + semaphore := make(chan struct{}, 3) // 最多 3 个并发检测 + + for i, website := range websites { + // 每个网站之间间隔 1 秒,把检测分散开 + if i > 0 { + time.Sleep(1 * time.Second) + } + + var wg sync.WaitGroup + for _, urlInfo := range website.URLs { + wg.Add(1) + go func(w models.Website, u models.URLInfo) { + defer wg.Done() + semaphore <- struct{}{} + defer func() { <-semaphore }() + s.checkURL(w, u) + }(website, urlInfo) + } + wg.Wait() + } + + // 检测完毕后,逐个解析 DNS + s.resolveAllWebsiteIPs(websites) + + // 保存记录 + s.storage.SaveAll() + log.Printf("本轮检测完成,共 %d 个网站", len(websites)) +} + +// resolveAllWebsiteIPs 逐个解析所有网站域名 IP(每次都刷新) +func (s *MonitorService) resolveAllWebsiteIPs(websites []models.Website) { + for i, website := range websites { + if len(website.URLs) == 0 { + continue + } + if i > 0 { + time.Sleep(500 * time.Millisecond) + } + s.resolveWebsiteIP(website) + } +} + +// resolveWebsiteIP 解析单个网站的域名 IP +func (s *MonitorService) resolveWebsiteIP(website models.Website) { + if len(website.URLs) == 0 { + return + } + + ips, err := utils.ResolveDomainIPs(website.URLs[0].URL) + if err != nil { + log.Printf("DNS解析失败 [%s]: %v", website.Name, err) + return + } + + if len(ips) == 0 { + return + } + + w := s.storage.GetWebsite(website.ID) + if w == nil { + return + } + + w.IPAddresses = ips + w.UpdatedAt = time.Now() + s.storage.UpdateWebsite(*w) + log.Printf("DNS解析 [%s] → %v", website.Name, ips) +} + +// checkURL 检查单个URL(带重试) +func (s *MonitorService) checkURL(website models.Website, urlInfo models.URLInfo) { + cfg := config.GetConfig() + maxRetries := cfg.Monitor.RetryCount + + var result utils.CheckResult + + for attempt := 0; attempt <= maxRetries; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt) * 2 * time.Second) + log.Printf("重试 [%s] %s - 第 %d 次重试", website.Name, urlInfo.URL, attempt) + } + + result = s.httpClient.CheckWebsiteStatus(urlInfo.URL) + if result.Error == nil && utils.IsSuccessStatus(result.StatusCode) { + break + } + } + + record := models.MonitorRecord{ + WebsiteID: website.ID, + URLID: urlInfo.ID, + URL: urlInfo.URL, + StatusCode: result.StatusCode, + Latency: result.Latency.Milliseconds(), + IsUp: result.Error == nil && utils.IsSuccessStatus(result.StatusCode), + CheckedAt: time.Now(), + } + + if result.Error != nil { + record.Error = result.Error.Error() + } + + s.storage.AddRecord(record) + + // 仅当网站无标题时才做完整检测来获取元数据 + if record.IsUp && website.Title == "" { + fullResult := s.httpClient.CheckWebsite(urlInfo.URL) + if fullResult.Title != "" { + w := s.storage.GetWebsite(website.ID) + if w != nil { + w.Title = fullResult.Title + w.UpdatedAt = time.Now() + s.storage.UpdateWebsite(*w) + } + } + } + + log.Printf("检测 [%s] %s - 状态码: %d, 延迟: %dms, 可用: %v", + website.Name, urlInfo.URL, result.StatusCode, result.Latency.Milliseconds(), record.IsUp) +} + +// CheckWebsiteNow 立即检查指定网站(状态 + DNS,等待完成后保存) +func (s *MonitorService) CheckWebsiteNow(websiteID string) { + website := s.storage.GetWebsite(websiteID) + if website == nil { + return + } + + // 逐个检测该网站的所有 URL + for _, urlInfo := range website.URLs { + s.checkURL(*website, urlInfo) + } + + // 刷新 DNS + s.resolveWebsiteIP(*website) + + s.storage.SaveAll() +} + +// GetWebsiteStatus 获取网站状态 +func (s *MonitorService) GetWebsiteStatus(websiteID string) *models.WebsiteStatus { + website := s.storage.GetWebsite(websiteID) + if website == nil { + return nil + } + + status := &models.WebsiteStatus{ + Website: *website, + URLStatuses: []models.URLStatus{}, + } + + now := time.Now() + since24h := now.Add(-24 * time.Hour) + since7d := now.Add(-7 * 24 * time.Hour) + since90d := now.Add(-90 * 24 * time.Hour) + + var totalUptime24h, totalUptime7d float64 + var urlCount int + var allRecords90d []models.MonitorRecord + + for _, urlInfo := range website.URLs { + urlStatus := s.getURLStatus(website.ID, urlInfo, since24h, since7d) + status.URLStatuses = append(status.URLStatuses, urlStatus) + + totalUptime24h += urlStatus.Uptime24h + totalUptime7d += urlStatus.Uptime7d + urlCount++ + + records90d := s.storage.GetRecords(website.ID, urlInfo.ID, since90d) + allRecords90d = append(allRecords90d, records90d...) + } + + if urlCount > 0 { + status.Uptime24h = totalUptime24h / float64(urlCount) + status.Uptime7d = totalUptime7d / float64(urlCount) + } + + // 90 天逐日统计 + status.DailyHistory = s.aggregateByDay(allRecords90d) + if len(allRecords90d) > 0 { + upCount := 0 + for _, r := range allRecords90d { + if r.IsUp { + upCount++ + } + } + status.Uptime90d = float64(upCount) / float64(len(allRecords90d)) * 100 + } + + // 获取最后检测时间 + for _, urlStatus := range status.URLStatuses { + if urlStatus.CurrentState.CheckedAt.After(status.LastChecked) { + status.LastChecked = urlStatus.CurrentState.CheckedAt + } + } + + return status +} + +// getURLStatus 获取URL状态 +func (s *MonitorService) getURLStatus(websiteID string, urlInfo models.URLInfo, since24h, since7d time.Time) models.URLStatus { + urlStatus := models.URLStatus{ + URLInfo: urlInfo, + } + + // 获取最新记录 + latest := s.storage.GetLatestRecord(websiteID, urlInfo.ID) + if latest != nil { + urlStatus.CurrentState = *latest + } + + // 获取24小时记录 + records24h := s.storage.GetRecords(websiteID, urlInfo.ID, since24h) + urlStatus.History24h = records24h + + // 计算24小时可用率 + if len(records24h) > 0 { + upCount := 0 + var totalLatency int64 + for _, r := range records24h { + if r.IsUp { + upCount++ + } + totalLatency += r.Latency + } + urlStatus.Uptime24h = float64(upCount) / float64(len(records24h)) * 100 + urlStatus.AvgLatency = totalLatency / int64(len(records24h)) + } + + // 获取7天记录并按小时统计 + records7d := s.storage.GetRecords(websiteID, urlInfo.ID, since7d) + urlStatus.History7d = s.aggregateByHour(records7d) + + // 计算7天可用率 + if len(records7d) > 0 { + upCount := 0 + for _, r := range records7d { + if r.IsUp { + upCount++ + } + } + urlStatus.Uptime7d = float64(upCount) / float64(len(records7d)) * 100 + } + + return urlStatus +} + +// aggregateByHour 按小时聚合记录 +func (s *MonitorService) aggregateByHour(records []models.MonitorRecord) []models.HourlyStats { + hourlyMap := make(map[string]*models.HourlyStats) + + for _, r := range records { + hourKey := r.CheckedAt.Truncate(time.Hour).Format(time.RFC3339) + + if _, exists := hourlyMap[hourKey]; !exists { + hourlyMap[hourKey] = &models.HourlyStats{ + Hour: r.CheckedAt.Truncate(time.Hour), + } + } + + stats := hourlyMap[hourKey] + stats.TotalCount++ + if r.IsUp { + stats.UpCount++ + } + stats.AvgLatency += r.Latency + } + + var result []models.HourlyStats + for _, stats := range hourlyMap { + if stats.TotalCount > 0 { + stats.AvgLatency /= int64(stats.TotalCount) + stats.Uptime = float64(stats.UpCount) / float64(stats.TotalCount) * 100 + } + result = append(result, *stats) + } + + return result +} + +// aggregateByDay 按天聚合记录 +func (s *MonitorService) aggregateByDay(records []models.MonitorRecord) []models.DailyStats { + dayMap := make(map[string]*models.DailyStats) + + for _, r := range records { + dayTime := time.Date(r.CheckedAt.Year(), r.CheckedAt.Month(), r.CheckedAt.Day(), 0, 0, 0, 0, r.CheckedAt.Location()) + dayKey := dayTime.Format("2006-01-02") + + if _, exists := dayMap[dayKey]; !exists { + dayMap[dayKey] = &models.DailyStats{ + Date: dayTime, + } + } + + stats := dayMap[dayKey] + stats.TotalCount++ + if r.IsUp { + stats.UpCount++ + } + stats.AvgLatency += r.Latency + } + + result := make([]models.DailyStats, 0, len(dayMap)) + for _, stats := range dayMap { + if stats.TotalCount > 0 { + stats.AvgLatency /= int64(stats.TotalCount) + stats.Uptime = float64(stats.UpCount) / float64(stats.TotalCount) * 100 + } + result = append(result, *stats) + } + + sort.Slice(result, func(i, j int) bool { + return result[i].Date.Before(result[j].Date) + }) + + return result +} + +// GetAllWebsiteStatuses 获取所有网站状态 +func (s *MonitorService) GetAllWebsiteStatuses() []models.WebsiteStatus { + websites := s.storage.GetWebsites() + var statuses []models.WebsiteStatus + + for _, website := range websites { + status := s.GetWebsiteStatus(website.ID) + if status != nil { + statuses = append(statuses, *status) + } + } + + return statuses +} diff --git a/mengyaping-backend/services/website.go b/mengyaping-backend/services/website.go index 23d85fb..4ae914d 100644 --- a/mengyaping-backend/services/website.go +++ b/mengyaping-backend/services/website.go @@ -1,127 +1,142 @@ -package services - -import ( - "time" - - "mengyaping-backend/models" - "mengyaping-backend/storage" - "mengyaping-backend/utils" -) - -// WebsiteService 网站服务 -type WebsiteService struct { - storage *storage.Storage -} - -// NewWebsiteService 创建网站服务 -func NewWebsiteService() *WebsiteService { - return &WebsiteService{ - storage: storage.GetStorage(), - } -} - -// CreateWebsite 创建网站 -func (s *WebsiteService) CreateWebsite(req models.CreateWebsiteRequest) (*models.Website, error) { - website := models.Website{ - ID: utils.GenerateID(), - Name: req.Name, - Group: req.Group, - URLs: make([]models.URLInfo, 0), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - for _, url := range req.URLs { - urlInfo := models.URLInfo{ - ID: utils.GenerateShortID(), - URL: url, - } - website.URLs = append(website.URLs, urlInfo) - } - - if err := s.storage.AddWebsite(website); err != nil { - return nil, err - } - - // 立即检测该网站 - go GetMonitorService().CheckWebsiteNow(website.ID) - - return &website, nil -} - -// GetWebsite 获取网站 -func (s *WebsiteService) GetWebsite(id string) *models.Website { - return s.storage.GetWebsite(id) -} - -// GetAllWebsites 获取所有网站 -func (s *WebsiteService) GetAllWebsites() []models.Website { - return s.storage.GetWebsites() -} - -// UpdateWebsite 更新网站 -func (s *WebsiteService) UpdateWebsite(id string, req models.UpdateWebsiteRequest) (*models.Website, error) { - website := s.storage.GetWebsite(id) - if website == nil { - return nil, nil - } - - if req.Name != "" { - website.Name = req.Name - } - if req.Group != "" { - website.Group = req.Group - } - if len(req.URLs) > 0 { - // 保留已有URL的ID,添加新URL - existingURLs := make(map[string]models.URLInfo) - for _, u := range website.URLs { - existingURLs[u.URL] = u - } - - newURLs := make([]models.URLInfo, 0) - for _, url := range req.URLs { - if existing, ok := existingURLs[url]; ok { - newURLs = append(newURLs, existing) - } else { - newURLs = append(newURLs, models.URLInfo{ - ID: utils.GenerateShortID(), - URL: url, - }) - } - } - website.URLs = newURLs - } - - website.UpdatedAt = time.Now() - - if err := s.storage.UpdateWebsite(*website); err != nil { - return nil, err - } - - return website, nil -} - -// DeleteWebsite 删除网站 -func (s *WebsiteService) DeleteWebsite(id string) error { - return s.storage.DeleteWebsite(id) -} - -// GetGroups 获取所有分组 -func (s *WebsiteService) GetGroups() []models.Group { - return s.storage.GetGroups() -} - -// AddGroup 添加分组 -func (s *WebsiteService) AddGroup(name string) (*models.Group, error) { - group := models.Group{ - ID: utils.GenerateShortID(), - Name: name, - } - - if err := s.storage.AddGroup(group); err != nil { - return nil, err - } - - return &group, nil -} +package services + +import ( + "time" + + "mengyaping-backend/models" + "mengyaping-backend/storage" + "mengyaping-backend/utils" +) + +// WebsiteService 网站服务 +type WebsiteService struct { + storage *storage.Storage +} + +// NewWebsiteService 创建网站服务 +func NewWebsiteService() *WebsiteService { + return &WebsiteService{ + storage: storage.GetStorage(), + } +} + +// CreateWebsite 创建网站 +func (s *WebsiteService) CreateWebsite(req models.CreateWebsiteRequest) (*models.Website, error) { + groups := req.Groups + if len(groups) == 0 && req.Group != "" { + groups = []string{req.Group} + } + + website := models.Website{ + ID: utils.GenerateID(), + Name: req.Name, + Groups: groups, + URLs: make([]models.URLInfo, 0), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + for _, url := range req.URLs { + urlInfo := models.URLInfo{ + ID: utils.GenerateShortID(), + URL: url, + } + website.URLs = append(website.URLs, urlInfo) + } + + // 创建前先解析域名 IP + if len(req.URLs) > 0 { + if ips, err := utils.ResolveDomainIPs(req.URLs[0]); err == nil { + website.IPAddresses = ips + } + } + + if err := s.storage.AddWebsite(website); err != nil { + return nil, err + } + + // 立即检测该网站 + go GetMonitorService().CheckWebsiteNow(website.ID) + + return &website, nil +} + +// GetWebsite 获取网站 +func (s *WebsiteService) GetWebsite(id string) *models.Website { + return s.storage.GetWebsite(id) +} + +// GetAllWebsites 获取所有网站 +func (s *WebsiteService) GetAllWebsites() []models.Website { + return s.storage.GetWebsites() +} + +// UpdateWebsite 更新网站 +func (s *WebsiteService) UpdateWebsite(id string, req models.UpdateWebsiteRequest) (*models.Website, error) { + website := s.storage.GetWebsite(id) + if website == nil { + return nil, nil + } + + if req.Name != "" { + website.Name = req.Name + } + if len(req.Groups) > 0 { + website.Groups = req.Groups + } else if req.Group != "" { + website.Groups = []string{req.Group} + } + if len(req.URLs) > 0 { + // 保留已有URL的ID,添加新URL + existingURLs := make(map[string]models.URLInfo) + for _, u := range website.URLs { + existingURLs[u.URL] = u + } + + newURLs := make([]models.URLInfo, 0) + for _, url := range req.URLs { + if existing, ok := existingURLs[url]; ok { + newURLs = append(newURLs, existing) + } else { + newURLs = append(newURLs, models.URLInfo{ + ID: utils.GenerateShortID(), + URL: url, + }) + } + } + website.URLs = newURLs + website.IPAddresses = nil // URL 变更后清空 IP,等下次检测重新解析 + } + + website.UpdatedAt = time.Now() + + if err := s.storage.UpdateWebsite(*website); err != nil { + return nil, err + } + + return website, nil +} + +// DeleteWebsite 删除网站 +func (s *WebsiteService) DeleteWebsite(id string) error { + return s.storage.DeleteWebsite(id) +} + +// GetGroups 获取所有分组 +func (s *WebsiteService) GetGroups() []models.Group { + return s.storage.GetGroups() +} + +// AddGroup 添加分组 +func (s *WebsiteService) AddGroup(name string) (*models.Group, error) { + group := models.Group{ + ID: utils.GenerateShortID(), + Name: name, + } + + if err := s.storage.AddGroup(group); err != nil { + return nil, err + } + + return &group, nil +} diff --git a/mengyaping-backend/storage/storage.go b/mengyaping-backend/storage/storage.go index a027e36..d4737ac 100644 --- a/mengyaping-backend/storage/storage.go +++ b/mengyaping-backend/storage/storage.go @@ -1,285 +1,302 @@ -package storage - -import ( - "encoding/json" - "os" - "path/filepath" - "sync" - "time" - - "mengyaping-backend/config" - "mengyaping-backend/models" -) - -// Storage 数据存储 -type Storage struct { - dataPath string - mu sync.RWMutex - websites []models.Website - records map[string][]models.MonitorRecord // key: websiteID_urlID - groups []models.Group -} - -var ( - store *Storage - once sync.Once -) - -// GetStorage 获取存储单例 -func GetStorage() *Storage { - once.Do(func() { - cfg := config.GetConfig() - store = &Storage{ - dataPath: cfg.DataPath, - websites: []models.Website{}, - records: make(map[string][]models.MonitorRecord), - groups: models.DefaultGroups, - } - store.ensureDataDir() - store.load() - }) - return store -} - -// ensureDataDir 确保数据目录存在 -func (s *Storage) ensureDataDir() { - os.MkdirAll(s.dataPath, 0755) -} - -// load 加载数据 -func (s *Storage) load() { - s.loadWebsites() - s.loadRecords() - s.loadGroups() -} - -// loadWebsites 加载网站数据 -func (s *Storage) loadWebsites() { - filePath := filepath.Join(s.dataPath, "websites.json") - data, err := os.ReadFile(filePath) - if err != nil { - return - } - json.Unmarshal(data, &s.websites) -} - -// loadRecords 加载监控记录 -func (s *Storage) loadRecords() { - filePath := filepath.Join(s.dataPath, "records.json") - data, err := os.ReadFile(filePath) - if err != nil { - return - } - json.Unmarshal(data, &s.records) - - // 清理过期记录 - s.cleanOldRecords() -} - -// loadGroups 加载分组 -func (s *Storage) loadGroups() { - filePath := filepath.Join(s.dataPath, "groups.json") - data, err := os.ReadFile(filePath) - if err != nil { - // 使用默认分组 - s.groups = models.DefaultGroups - s.saveGroups() - return - } - json.Unmarshal(data, &s.groups) -} - -// saveWebsites 保存网站数据 -func (s *Storage) saveWebsites() error { - filePath := filepath.Join(s.dataPath, "websites.json") - data, err := json.MarshalIndent(s.websites, "", " ") - if err != nil { - return err - } - return os.WriteFile(filePath, data, 0644) -} - -// saveRecords 保存监控记录 -func (s *Storage) saveRecords() error { - filePath := filepath.Join(s.dataPath, "records.json") - data, err := json.MarshalIndent(s.records, "", " ") - if err != nil { - return err - } - return os.WriteFile(filePath, data, 0644) -} - -// saveGroups 保存分组 -func (s *Storage) saveGroups() error { - filePath := filepath.Join(s.dataPath, "groups.json") - data, err := json.MarshalIndent(s.groups, "", " ") - if err != nil { - return err - } - return os.WriteFile(filePath, data, 0644) -} - -// cleanOldRecords 清理过期记录 -func (s *Storage) cleanOldRecords() { - cfg := config.GetConfig() - cutoff := time.Now().AddDate(0, 0, -cfg.Monitor.HistoryDays) - - for key, records := range s.records { - var newRecords []models.MonitorRecord - for _, r := range records { - if r.CheckedAt.After(cutoff) { - newRecords = append(newRecords, r) - } - } - s.records[key] = newRecords - } -} - -// GetWebsites 获取所有网站 -func (s *Storage) GetWebsites() []models.Website { - s.mu.RLock() - defer s.mu.RUnlock() - - result := make([]models.Website, len(s.websites)) - copy(result, s.websites) - return result -} - -// GetWebsite 获取单个网站 -func (s *Storage) GetWebsite(id string) *models.Website { - s.mu.RLock() - defer s.mu.RUnlock() - - for _, w := range s.websites { - if w.ID == id { - website := w - return &website - } - } - return nil -} - -// AddWebsite 添加网站 -func (s *Storage) AddWebsite(website models.Website) error { - s.mu.Lock() - defer s.mu.Unlock() - - s.websites = append(s.websites, website) - return s.saveWebsites() -} - -// UpdateWebsite 更新网站 -func (s *Storage) UpdateWebsite(website models.Website) error { - s.mu.Lock() - defer s.mu.Unlock() - - for i, w := range s.websites { - if w.ID == website.ID { - s.websites[i] = website - return s.saveWebsites() - } - } - return nil -} - -// DeleteWebsite 删除网站 -func (s *Storage) DeleteWebsite(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - - for i, w := range s.websites { - if w.ID == id { - s.websites = append(s.websites[:i], s.websites[i+1:]...) - // 删除相关记录 - for key := range s.records { - if len(key) > len(id) && key[:len(id)] == id { - delete(s.records, key) - } - } - s.saveRecords() - return s.saveWebsites() - } - } - return nil -} - -// AddRecord 添加监控记录 -func (s *Storage) AddRecord(record models.MonitorRecord) error { - s.mu.Lock() - defer s.mu.Unlock() - - key := record.WebsiteID + "_" + record.URLID - s.records[key] = append(s.records[key], record) - - // 每100条记录保存一次 - if len(s.records[key])%100 == 0 { - return s.saveRecords() - } - return nil -} - -// GetRecords 获取监控记录 -func (s *Storage) GetRecords(websiteID, urlID string, since time.Time) []models.MonitorRecord { - s.mu.RLock() - defer s.mu.RUnlock() - - key := websiteID + "_" + urlID - records := s.records[key] - - var result []models.MonitorRecord - for _, r := range records { - if r.CheckedAt.After(since) { - result = append(result, r) - } - } - return result -} - -// GetLatestRecord 获取最新记录 -func (s *Storage) GetLatestRecord(websiteID, urlID string) *models.MonitorRecord { - s.mu.RLock() - defer s.mu.RUnlock() - - key := websiteID + "_" + urlID - records := s.records[key] - - if len(records) == 0 { - return nil - } - - latest := records[len(records)-1] - return &latest -} - -// GetGroups 获取所有分组 -func (s *Storage) GetGroups() []models.Group { - s.mu.RLock() - defer s.mu.RUnlock() - - result := make([]models.Group, len(s.groups)) - copy(result, s.groups) - return result -} - -// AddGroup 添加分组 -func (s *Storage) AddGroup(group models.Group) error { - s.mu.Lock() - defer s.mu.Unlock() - - s.groups = append(s.groups, group) - return s.saveGroups() -} - -// SaveAll 保存所有数据 -func (s *Storage) SaveAll() error { - s.mu.Lock() - defer s.mu.Unlock() - - if err := s.saveWebsites(); err != nil { - return err - } - if err := s.saveRecords(); err != nil { - return err - } - return s.saveGroups() -} +package storage + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "time" + + "mengyaping-backend/config" + "mengyaping-backend/models" +) + +// Storage 数据存储 +type Storage struct { + dataPath string + mu sync.RWMutex + websites []models.Website + records map[string][]models.MonitorRecord // key: websiteID_urlID + groups []models.Group +} + +var ( + store *Storage + once sync.Once +) + +// GetStorage 获取存储单例 +func GetStorage() *Storage { + once.Do(func() { + cfg := config.GetConfig() + store = &Storage{ + dataPath: cfg.DataPath, + websites: []models.Website{}, + records: make(map[string][]models.MonitorRecord), + groups: models.DefaultGroups, + } + store.ensureDataDir() + store.load() + }) + return store +} + +// ensureDataDir 确保数据目录存在 +func (s *Storage) ensureDataDir() { + os.MkdirAll(s.dataPath, 0755) +} + +// load 加载数据 +func (s *Storage) load() { + s.loadWebsites() + s.loadRecords() + s.loadGroups() +} + +// loadWebsites 加载网站数据 +func (s *Storage) loadWebsites() { + filePath := filepath.Join(s.dataPath, "websites.json") + data, err := os.ReadFile(filePath) + if err != nil { + return + } + json.Unmarshal(data, &s.websites) + s.migrateWebsiteGroups() +} + +// migrateWebsiteGroups 将旧的单分组字段迁移到多分组数组 +func (s *Storage) migrateWebsiteGroups() { + migrated := false + for i := range s.websites { + w := &s.websites[i] + if len(w.Groups) == 0 && w.Group != "" { + w.Groups = []string{w.Group} + w.Group = "" + migrated = true + } + } + if migrated { + s.saveWebsites() + } +} + +// loadRecords 加载监控记录 +func (s *Storage) loadRecords() { + filePath := filepath.Join(s.dataPath, "records.json") + data, err := os.ReadFile(filePath) + if err != nil { + return + } + json.Unmarshal(data, &s.records) + + // 清理过期记录 + s.cleanOldRecords() +} + +// loadGroups 加载分组 +func (s *Storage) loadGroups() { + filePath := filepath.Join(s.dataPath, "groups.json") + data, err := os.ReadFile(filePath) + if err != nil { + // 使用默认分组 + s.groups = models.DefaultGroups + s.saveGroups() + return + } + json.Unmarshal(data, &s.groups) +} + +// saveWebsites 保存网站数据 +func (s *Storage) saveWebsites() error { + filePath := filepath.Join(s.dataPath, "websites.json") + data, err := json.MarshalIndent(s.websites, "", " ") + if err != nil { + return err + } + return os.WriteFile(filePath, data, 0644) +} + +// saveRecords 保存监控记录 +func (s *Storage) saveRecords() error { + filePath := filepath.Join(s.dataPath, "records.json") + data, err := json.MarshalIndent(s.records, "", " ") + if err != nil { + return err + } + return os.WriteFile(filePath, data, 0644) +} + +// saveGroups 保存分组 +func (s *Storage) saveGroups() error { + filePath := filepath.Join(s.dataPath, "groups.json") + data, err := json.MarshalIndent(s.groups, "", " ") + if err != nil { + return err + } + return os.WriteFile(filePath, data, 0644) +} + +// cleanOldRecords 清理过期记录 +func (s *Storage) cleanOldRecords() { + cfg := config.GetConfig() + cutoff := time.Now().AddDate(0, 0, -cfg.Monitor.HistoryDays) + + for key, records := range s.records { + var newRecords []models.MonitorRecord + for _, r := range records { + if r.CheckedAt.After(cutoff) { + newRecords = append(newRecords, r) + } + } + s.records[key] = newRecords + } +} + +// GetWebsites 获取所有网站 +func (s *Storage) GetWebsites() []models.Website { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]models.Website, len(s.websites)) + copy(result, s.websites) + return result +} + +// GetWebsite 获取单个网站 +func (s *Storage) GetWebsite(id string) *models.Website { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, w := range s.websites { + if w.ID == id { + website := w + return &website + } + } + return nil +} + +// AddWebsite 添加网站 +func (s *Storage) AddWebsite(website models.Website) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.websites = append(s.websites, website) + return s.saveWebsites() +} + +// UpdateWebsite 更新网站 +func (s *Storage) UpdateWebsite(website models.Website) error { + s.mu.Lock() + defer s.mu.Unlock() + + for i, w := range s.websites { + if w.ID == website.ID { + s.websites[i] = website + return s.saveWebsites() + } + } + return nil +} + +// DeleteWebsite 删除网站 +func (s *Storage) DeleteWebsite(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + for i, w := range s.websites { + if w.ID == id { + s.websites = append(s.websites[:i], s.websites[i+1:]...) + // 删除相关记录 + for key := range s.records { + if len(key) > len(id) && key[:len(id)] == id { + delete(s.records, key) + } + } + s.saveRecords() + return s.saveWebsites() + } + } + return nil +} + +// AddRecord 添加监控记录 +func (s *Storage) AddRecord(record models.MonitorRecord) error { + s.mu.Lock() + defer s.mu.Unlock() + + key := record.WebsiteID + "_" + record.URLID + s.records[key] = append(s.records[key], record) + + // 每100条记录保存一次 + if len(s.records[key])%100 == 0 { + return s.saveRecords() + } + return nil +} + +// GetRecords 获取监控记录 +func (s *Storage) GetRecords(websiteID, urlID string, since time.Time) []models.MonitorRecord { + s.mu.RLock() + defer s.mu.RUnlock() + + key := websiteID + "_" + urlID + records := s.records[key] + + var result []models.MonitorRecord + for _, r := range records { + if r.CheckedAt.After(since) { + result = append(result, r) + } + } + return result +} + +// GetLatestRecord 获取最新记录 +func (s *Storage) GetLatestRecord(websiteID, urlID string) *models.MonitorRecord { + s.mu.RLock() + defer s.mu.RUnlock() + + key := websiteID + "_" + urlID + records := s.records[key] + + if len(records) == 0 { + return nil + } + + latest := records[len(records)-1] + return &latest +} + +// GetGroups 获取所有分组 +func (s *Storage) GetGroups() []models.Group { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]models.Group, len(s.groups)) + copy(result, s.groups) + return result +} + +// AddGroup 添加分组 +func (s *Storage) AddGroup(group models.Group) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.groups = append(s.groups, group) + return s.saveGroups() +} + +// SaveAll 保存所有数据 +func (s *Storage) SaveAll() error { + s.mu.Lock() + defer s.mu.Unlock() + + if err := s.saveWebsites(); err != nil { + return err + } + if err := s.saveRecords(); err != nil { + return err + } + return s.saveGroups() +} diff --git a/mengyaping-backend/utils/dns.go b/mengyaping-backend/utils/dns.go new file mode 100644 index 0000000..18b4e04 --- /dev/null +++ b/mengyaping-backend/utils/dns.go @@ -0,0 +1,60 @@ +package utils + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "time" +) + +const dnsAPIBase = "https://cf-dns.smyhub.com/api/dns?domain=" + +type dnsResponse struct { + Status string `json:"status"` + IPv4 []string `json:"ipv4"` + IPv6 []string `json:"ipv6"` +} + +// ResolveDomainIPs 通过 DNS API 解析域名的 IPv4 + IPv6 地址 +func ResolveDomainIPs(rawURL string) ([]string, error) { + parsed, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + + hostname := parsed.Hostname() + if hostname == "" { + return nil, fmt.Errorf("no hostname in URL") + } + + if net.ParseIP(hostname) != nil { + return []string{hostname}, nil + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(dnsAPIBase + hostname) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*10)) + if err != nil { + return nil, err + } + + var dnsResp dnsResponse + if err := json.Unmarshal(body, &dnsResp); err != nil { + return nil, err + } + + if dnsResp.Status != "success" { + return nil, fmt.Errorf("DNS lookup failed for %s", hostname) + } + + ips := append(dnsResp.IPv4, dnsResp.IPv6...) + return ips, nil +} diff --git a/mengyaping-backend/utils/http.go b/mengyaping-backend/utils/http.go index a398703..aef1015 100644 --- a/mengyaping-backend/utils/http.go +++ b/mengyaping-backend/utils/http.go @@ -1,131 +1,173 @@ -package utils - -import ( - "crypto/tls" - "fmt" - "io" - "net/http" - "net/url" - "regexp" - "strings" - "time" -) - -// HTTPClient HTTP客户端工具 -type HTTPClient struct { - client *http.Client -} - -// NewHTTPClient 创建HTTP客户端 -func NewHTTPClient(timeout time.Duration) *HTTPClient { - return &HTTPClient{ - client: &http.Client{ - Timeout: timeout, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - }, - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if len(via) >= 10 { - return fmt.Errorf("too many redirects") - } - return nil - }, - }, - } -} - -// CheckResult 检查结果 -type CheckResult struct { - StatusCode int - Latency time.Duration - Title string - Favicon string - Error error -} - -// CheckWebsite 检查网站 -func (c *HTTPClient) CheckWebsite(targetURL string) CheckResult { - result := CheckResult{} - - start := time.Now() - - req, err := http.NewRequest("GET", targetURL, nil) - if err != nil { - result.Error = err - return result - } - - req.Header.Set("User-Agent", "MengYaPing/1.0 (Website Monitor)") - req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") - - resp, err := c.client.Do(req) - if err != nil { - result.Error = err - result.Latency = time.Since(start) - return result - } - defer resp.Body.Close() - - result.Latency = time.Since(start) - result.StatusCode = resp.StatusCode - - // 读取响应体获取标题 - body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*100)) // 限制100KB - if err == nil { - result.Title = extractTitle(string(body)) - result.Favicon = extractFavicon(string(body), targetURL) - } - - return result -} - -// extractTitle 提取网页标题 -func extractTitle(html string) string { - re := regexp.MustCompile(`(?i)
IpD>h7#wihsIewr*EL|2@YmgH7c9SZKK)dfJoX-BE{9eySnr6!A z9;3@tjL~;<-gEL6w=|%GUoEl#Bj_L%Zu4m5mTGoCx8z^4xo&AG?(5} z+qWFQH#R<`XOsb7i5y(SFiZqlAngAvc zB31)K*9h{okpKZ!Dp*#$uza$C2%tiefc8?cE?s88bN=@0E|l8acjk)uAu`3#zYD4x zKr#@6*aNc34&)qOwfnLi1TyNo%vacc_PK|du#ee*5Lv$|1%FEQR$WN=xabWWy|`?V z`R}vSEeD|U#{JtjMBJzrWLwCGl_I+v;&cl~Y8Pj6r6sLxy6eGs%E%$o+1$bP$7}qQ z{hnz$W@H&TLmh!di;EaCAi*g%%0CQIbIpGOst0lR%Qv&e90P-FxB6zFHprg{L)u>Q zz?Fr|hpVIf@~@CjN{N^OBUJ$s$eTNBOu=jjBU?jqbtYl?6L!#)jAuP1xrh(EB<>{w ziV5_duL#%rHOmmrAaP6adW&hUA!(8CNkL)=^3;_V01i!5MGIo+2TXww?f@k0q k*` V@mgww?b=t+V4&M)871c@pEo-@C9=8tc5JJ;0X+1d* Ezp<*`2>r)KM#^wlhXKg|L u@NaQwGPDJVSJHGpfd|ZzNjYx}CF@R=N%YPMM&n((v zrf+u$@p|Auj1zvp%sCm?B 7rvbdpgDtG?!FMXG;9@5n8 ! zX(H2+&$P(&1@jXQh*n&C%c<6L!m>Z{_X1%WM_SDhGmw{D0am@+{yojkY4evPkb4kl zNW|D?PQ4{a2s$HGuiSSMz0}B1egpe6s)|TwJ>4jpiDp9}m$m`+6GWXs(lw3Cp*{a% zEN9nW0Q$NOKfT=$;vCx4p%12B9NsXqEjg= }N1fCXwtv()ni4*Y)f7WO=*wb5AzVf?# z;ita^mo&1e-$J}Kb5pm|R(ppt g QZdU!xV?M%w0UpYxH`}b*GdCcsp zj2ay#h(T (al!cU%Jk^!%mKSM=C zWDV4~ycznwAY!;c&N_iwcw!~9^NDshbJ1dH6}1`B+qj!d0)iauZa_zUzoFayVxg)S z{@#@RfQntNtk9($ule|Ci^#lV^Bk?xu^a;YV-EjyFFJhg_9)F_Pjd^o99r?m2ouD7 z@#~q8cQ}chYE)URSq~n=p$fYZOj)SMbo8h3wR$0dp4hbN6@7@}dyLZERcah2PA(T7 z7jd-+Ygk)bN_*P+-TG=JZ@#1Aul0Gz#x-Eg@Yjxvyx%c?@R@|Uyl0UgN ?q z;7-A_X<)3u=G64@ EU^$!zi~%~ zgKnf=m @9MeyH8I}WuKe=Rn*-)v zZ8wl8M&p(d>!@wL9)JGfCa;f@X!{JMsGFGVDf8RfJ?o}~+#KdlTr-b|p@Yh`--iks z@|0SCgr@(gKhQfGap8@4!sM#|T`iB$5@72nT3{u5Tk2S0;xx~BzO@Oz7Zri2hPMvT z17^)G+CNznU`k$9Oc2Y
U-zTYU?+NN-Lq@4a(MR+ z@YG43br?bbS2;MK(W0ks-o9f3b81Fvpf7uWZ_+F1=(jvun!B8ZO|(Jt4ty r! C4+290fPc&8O{4czCGyRyM1x&Pwitph(2CfRp z2s!fj-AXwW$bLJjx_T;8i)$#lK32D~cz}!Ds(f2ea3l3+l{a<5`CXkc4a1}wMjW=> zThDSr$gqAcG9rYu0il9aeY49Cy3E`MAd1KB<-@~AXa8I|g-)RVA9p6NEo}9U#UpyW zgs`9a?u&C#yB3*0H7y?^{P|)vUci(Ll%p_n6(M_NB5g=9ux0hk08*v)s^C2I_U%za zW6GyAYvYI3?WNi(;|^=WxG7ZoCETA$_c8gn#PFDTger#CDc@+@E{uax7)0i4I(PH& z0B sAww%aSc3qqR5Wn>^?RR6$A=ZeXhGDtU}1 zb)GigcIf)}8-q_EAEdM+-*Q81OPFJcq$QNv+>ryyF7S!XbX+2H66D5{LJe36qq~jO zZfgrWZk&k$CeMe54dTFW-k$R2xYRwRf)@%Np@}24peZmqmh=Y)tdSyP8D>HMlC1|m zp8xl+dU8~BG`S;xkO^NBha3k|B>1cp7WJvxnF!i4{wgZw33=z`t|H}6td4P1K=02G zA^OV}XNyvKV4ia$LeDY{tWB7YteR{q+SpVwLDAV|we>zvN7u$QrzYI+R-fJfZcgVu z7*9@fm^qMww%wK5tG4kQ%CHAqyQWR3oPl?3w|U-=&w3HNEG|cVnBA>ZtzY^uu=5Mo zGsd8Iugs39`2W5A_GhPMnHWS*sI$*YFn{F6<-W}vJ5aw4-%kG8c!HB+sV;ao5Vr@| zO@!Dc %v2hsY m-DvqjXc^;M#vau%b$6f8(_gzlPN6;c;vvDTvQM%K3d<0Th^v`K zrveh(fI%9nWL(ql>e2Z?8Fwv!ZZtDT(;W5`DL|d8+cQVdKg>Po9S*;$Lx9SWk4HBx z6C_~9P(QT9TfzaBkS))f=$9%#d)@IE`)g2rVdT}>bXkg&;G%BRo&P*c$@?AB(~j2~ zNc%44$Z2+yE=k&QE>^ (&W4&1odv4Bf+)eGFo0kpw|=F;Z8Sd|bmp z6N0;I_w>N_d|BzhHA$XG+H7DTene@ZOItMMTw7iMbI$^j8C&cpO>VFGD3P>aMi2E- zjP1N=@8wi=Hyq(-!I$*K ;05&Kr2Y~%%+#tIhp&5JJ8WNTR+`6Gt z=s5bW_TL;0(pO2rfc|G)mzS4mvq4-N*LXWM!<+`IH(6J{R;PzMwtgoW-?HEK$s}(y z?$My~R3+-;Ta7yly!q6rBR~ilZ^neJwJQ*$c<2!Jr3HX|jxNsX`y{a~>gl0NZV6VI zx-3WwZH3^0h@#!TY!J59e3z`nDk+@DApm5`5y)tg0+wRQr^s@a;Q99kU(8Y!Q$KdYI$V4%1XE zxN%@AF)J^bV)#uC`cRu3%q>peByaAh-wLG%uL6QlrdmhPZ5!TB!^xVC>fn;kobmF` z^<2Wxz%t6p`E6vL#SKxuZd$eOq3vuigD3xk3Oxt#GmPmx<@OkVW55+jmyLwrji7bB zI*t&Fa-VC^6E|W@9hVKSgiR*NI}7@|sFe1xKP?q$ngAgY7=S)7ZBaYp7#mpmv_R9G zOTVf3`YLwuu+&+%dGoD~gz@(3D;#1(^&-l;XXQkFY Gjdqpy7(_A$QQ>@g%$ z>*_ ?u-~xRRVA zY?KzE%amNq*=){_nj~wXyUg=SdVgJ%`Bf=#qY=!kt4(7+osDK&F)UtiW}8ohxH-Kt zduo5S@Y!o%3;BZBko``NuLrM$w_VMs4e)+ #M0lPA|1MQof5PT;u3V<(KdY0bB*ELkj -(RAfP3}*?J6>2zVMTC-JNK^PJ!SaYj+83bX(DYJZt1r zpxJ+ufng?NaNu6P#oOC?^zPdx^kxj$Py(7lEAHm-LI%&CgK9BK>6@~9^&&^8TTLHk zOdaE#gvI!zJ#5|A`=kBIi#6skmiHRFm{TUJe4kr-O(*nb;rrJ%!^7sxI<=dsm^-k+ zl+#wCg4Jh@bdzs=i;~vw|MB|~V+j+}D&OVTE`;*;1yk`ekgjmaO`yJ3j_fH|CRy<5 zyVvNJP}`9IOPl;(jXtNgelJar(DZ+ehTgCfc{kyBG*v`WOl*6>y=ghK`X!H@E(F^o zE)H=5@G?=6+Vi`0LTqeoWY)-%m o*M-M~frhU~{z?riY)wlMul1>sePwGmL(D!RmtIc^dU{V9XI!K1 z(*@8+>g^}~Rzt|2ncKU}h0xpIZ@l)rs n<{NK}R^RsZA@Ki#< z1z@mj$_&mq$;aV`-044L1}x%bpeeL{vtMaI^^*nYnjP Xbu|}J(Ltn21NOcP`6~l` zex0F#@Zp8|P9nK;xl@CqW5>)5^L1aUWS=eS(nl-cDJ-c zvT3z%UOqXDcksq*-(8}A$3en3GQVQNvh6;m@1=`S>`;l9ZS3Zm=lh@@zoLzDoQt=a zimD59No$6qJAmHAsky}x|AL?gop_y#2?t9bE;#*)A BmA=SfJBL*aL zn%$rO(C%g$-q9IO8WV9C0=x*PuKD)1P9+8hpETXi(=amdl{U~k5+FCL3h91O95yfI z3sOyIvLJKK&Fawcs^+Wzz=H{XG0oN@Z7hKGZXkU{{#`CfE@G+@SUsrtD$McQ&9Syq zwGolBdIOQb{GJVS&zcPUIgTs#qEA;(id_z|S-F2kdm#NSekTO)wYwm!TGueyi^+WN z`!u^`qIvE3Y0TCM?i%~nXhZ+@`OSS^yPjOM9<(04L)B8fe7EcJtyK9ceR J` zVj1d3KI`|6IHEGAkUd2=WKWksjWNb9M%H$=3h^cEtDsPAeS#1aSt0z`YR~NxI-)$N zMw_Zy(q-OAfWP~GRk*Cvi#^Xi f4vPZ9*|_XKwj;A5 zd3TO4W$ufdrn%%AJ`c4PvrCluCjnU&JbSlK+H*-yNj%~Bli4%_SqtCr3;o&GWWF!f z00Y`=ls55=3NiHFyF2R+k$Q@)+_M-k4`Ei)ZKkruIpUKPd;GPJQqn`guc-z9g?rDH z!y^J6O(T2Nb?#FcKTppK5AJ#8k`$|OgeoaoE;jQL^yewH(`VjyUpT_piW(b-eXkVl z@y7=p|7fwAE7k^eI=s9sv{*EHv3tt}85XGY;HGwpvES@gbRfrF=DoJ~89<#pb|hCE z(kb!kz@X%stxBnh;tRVmZySv|xPJWfxd9Bzp~DpWS;6Pdc~aHPvd @=QSFPhgwrr8!SfxXz3p%?^MxGb5@!$-9o8$@w!J3c-RnWm zW6xa)b*XdA+u_-A+2=4cD0#taidMDJW%OaXGxWn^Q&{cn?d%Q;TpyAz^<;YnW^Gk1 zWwsXa+H*K@*Qj?m!sb)x1;Tt@8#fR+^|kGgqr&d9xDQ=bGq)qNB4JKuvea)hW-(dnzmri#jmCx93C+eY6$R^q? dO*R6vtaFXOsTT~q^l-VA&%&q^zrtxogWU<#Qm)K#7-#@_Y->PRs;-$!_u&N>m% z2M+VptghS~40IR|x%d5g@p8a J#4`F;^C9JOPtK#T0s=}ry#^lwT#xv76m$Ep1hm;FPQuL10vYb@N} z58g|psPbPEB9LvuKXYRx6JaR`yFdhw?vnd-VWnpkC9r8`dq&d#*jSW{GV(Y|9Yv#` zKpCUH(NB=^-xD-do{j1wZjFcJyH{P~&=X02a#d6Dr%tN}jQx%xd7wzVy|_eBb=QK~ zO{tr;19VIXCvDefXgNbK-`&Gr@r2pF$jvQDRnfMO1FkUVg|B-5;uX9WbN}_Cv$yl9 zq>~E4T4{Nxn*5=o-C^{*=U1=ST&f4KWA~X13gbrL>tK3cRa3^)ht>vDv5|fl(H^2B z-tQD=|Gw4tM%?e=vKxngWp*`k{mq9%5{S2c9n2K?&Dp4OmupBs{doCKo~}Y4yZraZ zhoU33>16^v@*js`hZ;1PUcM@bp0V%?I!0F|d6FV?9Ix&vnSvNrue`A1Tkgf35f2M! zs@hlHPlr`Tq CuMbRWoLXw@$nDhIOKJAUV+Ds&Tq{I?XK&D76iPE- zemVsMT(EYNb75BZ1 8;zJk&8Oz`(lqrpVX}OG5#tKsV&-U{ZhiuR`xZ2)GU~Ax|#80I4!65 zz@K&Zf^vpd3~(AU*FI=l3s+__bv0zk$L}`XC1r1J>et&svCbye+2>LkKXMZ7VG0+w z-v6_xg1REh1Vf1$lcBHEpFe$J$9dfLxgvY0=hFQc<4oh{euL$SfF%1dkfPIna $Lceb;_2Wy3e*$x;;^1a9mO zZ zQ{f+< ^czn!W73I(bcKb|KR-81b- zDipMH!R))}+ja0%)8x)Xhq`z#O}lo;Y~j|CE05i}1bX@zvPhQ?`>siC`nUZTrpKzB z@x99pO1T?$A&_E{;D2+{!1LB`+~ME$%K{&NWotjb*tTBwCxCy9h3k?^`nP?!XZbS$ z8W@?=v|haC*SjM}-URP#pBQCaO#`-B7nA7FcjX%RlQ>IedG(<*Cr}|hA$PTRC=LNY zaRBFjQa-fwZOcyM>SrXw0IDGLaRo-CeJK+`>U=hLEz27T`|v{G9{p*ae%(6u^s|bJ z1F}U}p;0}c8hmstdQOI58qd!!?Q03O+zdDlP%L`j6h`6w@dWyM)y9SY&c{|%sGjCA zqj~Lm)dr(`BikYeY6jeE!w5^R#EC9 luA5V6`C#_RZU9%khuVH||=f8=P`jbhG!(&vqk zBflGWNwziaKkK%;LsU(_o+aF0^Vkf{#zmzY+&ESDV}RuomPnhgzUQ5udn+*H_5sby z!GeFTl#5>V`MpzEdn@awrcXLkRmSk?f0AjsbveAM0j^F= >`ETQJk#uPCfL#C^=&3iD}s`kh(SL$~_7UFcPS2va*`m-k^j`2zqUsl4V z$v4HxLhHCamM0O8k=!E0$}6E+fi)A*;Uw;wemrZ}MpOo}?Wd3@=hu|Xg47@fcoF;@>wn9eihR=1T}xNy8ZajFn)k7&5Q?> zDJC*^dw+JiX7xUtj9^E-Kff8EedpNSmqye#xYLTy?Ig#NH{E#TBfWTWrT-qUtzB%o zp94Z!w&Pq7qQ}%{>e-k9A-^h8u1uZv%1Q(cX|NbR^Q9e^F?Ky3zVB=5g4fVCk=~sc zmUB1c2Xjnzj~^sApCR8VhJhIa$^A#sFZO1)sLR>7`xCD{`-Q&LLr=m^btQuilU`pQ z&X$$ z-^WY-p`$kwjkXCp0iwx$|0zvv<&ct!0@+rVvon#30ha&7stC>g3wZZ{A00-5(kSBX z)M~Ab$%VO P4qn&w({aw@GaPw`Lb;W44X&h$IOLe zfTaDUd))`&L?D#)Kx(rT?4jHOy|V5E9z1(bbC$nnCzNT=^Hms|Nys8|EYwac8C_ax zoqcDrb~L&FotUk^_j|q1yKcdu3}w%MH{cTMCn!=sxnn!RJ>JMNEDNC?`KZ(lN1Nq4 zQRH-2A!xC|O|Y!d>m w>M$B!45d=rTjJ$6o(lCHBy4v?ptoU(A1&x8E>SRt6kZ%=apWncdlo#_i|hCQWi zMfTBwIqG-Iewq!zP`RVZ@3kuNjTgF?uJ}1U-$x5LX8~>Zd9)spus#$Ox 4mO}lkOE> zxQh8jad_JSF^F1j-d;ae!^}lTy*-P&*8#+bBAA)qz3wxndj_R%QlV3?^Zw1%;oElJ z-hRF>$+2sv mw1W-L7bY_DUM+kdmnMyEKdudP zddhZdi~F$hQw*p+zYQO`?f4CQ P z)}s%!T%L0U3?RNzvl+QQtoQc-B6CUc_?3F2hYzdiXUCOue$N+{xa6o;WN1|Hbv1RB zs39=Qc-`}o<81L@|2CJEKkteP
7-dYcl88T7^2qBPApzo7*JhY-VZ!>ag(G zd{#naEudeEmnG;-)ETVT!)xWq!6bv-2bfpFm{+ h+^T+OyMsc}hmjC|YRS%N=5*#n0)SVVY&{ (bAg{mcEkg zT#?5Z2eHiA%_oh@L|!opL$5(IO>@0IZQ;Y4 vWkshJwoxA3zK(hSLvgKa~Q z2lq9;Pn%c4L`M8}Zv?&m4s;&@vH(Y=^T(vlUh8Y?9qxM8nKhA9EyK6if+`cIPuvvD zW`+|V%@1@mz3}aLF6-OAe7#;pLpNQm`cp&6Kkv77Zn=!Y-st~30mbn8`fckK_p+1T z9sh<63fWlsuvD7VM#~NUsd#Mqp-y?f%CFvWM+Mk!Cp^FX0E?>sX-Zq;lRN?D(2-OW z98m>^hq^01hac+s*K$I9C$INe&q0XymT*wm1{^E8@wQ=WMLv1LMh2JF6`_1K;dt8l zLsjPPQf~X|^yb-AgTJc4IEsF6qVDNk%z2l9)Ss}y`18N8#Hjlrx`rkf-n&XV(N;)5 zJZv0>)UHWJaNGR5pEEjKvK* wDp4!9*Q ztL$0o0ZSq_vNzNRH3&fVR}_DN|6f$HRzPeJdDm QQ-Bf8*KW zG~ZgTfauQBZ~wOEr5)$@Nj^Ej7?T}Y-P8WCSA1;b<9)mep9QD>tS)Xry|=3idv!00 zAF>cT7f41qSu9W%d8mP~o?^D&RlIxXV(Pz#0?bcL)~DkZl(jVpO0 )l ztEq$&wvF~~J2EJqU9#-n=-b<-r-yVpkoRy#&saZed3wEpk~i~Ezl?**7e{{VN5wj| z{<8=Om6PC6GT|L-?LX={?|K04^y4<+CAZ|~-$L|TMWL^M9KtrS*F$A7%VM4$#Setv zL>YPT+UhXt_}fjXmnPlEWvf+ob4Rxt$2$ZVzva0V9As>ZVhNh3VshS}8Pa_Fyp1P# z9G~1St{=EnIW|I)(PYM!hQ?!PA(5%T`xgJ0(-&|MkM*rj-swE#ec>0Ft!6-^$PY-H zvh)!MkvHjn{+^dOtN6#jYf zSlR^Gk<2!>9Fs((NTUD=Agb{>;fNzFZSg9O-%r51N1~Myj?~E6%H8tfzUSqY!>>wS z!x4OE)%Y&>)E6YUI=VA3@J%Pd8~AxH5^01t#``e&)#;C3{@CybMgS7P9*P0aA0Pl* zdiPLY6FxVGP7FgPQSY;#Ac`njDow47ECfto7{p~saF!HVVsQSzP?*GMm!9qx`PHgF z%I)|q?_d67*pKAi55Ff<=Y2y;Ti6f61o?6)lm)GA8+b`Lu1uXu;j^Y9Fn$jII)5!+ z=S)f9??=MY9LYXOfR!M?#}Z8d> 8|W3t@Z9=@MfKkF CQ78K{gO1a=D{u@+T|na6-%hU z3KF9p;35&|t$XmlRDiUIA_RTXS;qU@`v3M!V3+)I$wNkhG{HZF->OF{s*$v(m<;zp zuMfc30cIqqZz_XPrV&t|))6)mfR=PJijWkP7Y48wcTGnm|E4^={HO8|?7n5+mxmVr zNZNJW5kVRRFSi|l1K`!;>h(ERh{jSa3BhCj8uvO)keywAPk-&@1c)X+as&Wqy~XMA z-ApTh+CN|}0s>@=z{WW<5`rzgJV9@@d@ptMq15X234WM9uXin?&U)AeeEFO3qwJI? zHa{+7-nmb1p8qwO@ZLl6=m+1IidL@X#zznl{2aS)fT!dk=necq0+{ztQUXEumsYI> zU^o?$;OBf^YJDcUlsO#da~`tjsMIuY3_UA3oYYQ9#E4)MT688?Dm%2n>iP7*^q^I8 zq;!+aJw8WHRP2JJx*&Ber=_{=jC6Eel>WW~iG+)BPY!g0XN45vYpC MmqZtyTQThgJr3VD)2$f4)H>*a~Qrws$>(75AHJz;g_uzB!>5ujVNG*Hv z*}q!-;bTyJ(rfzPBEXPT*5pCgBU)fPk?g~DB!H2DJ;9VG4NWCd-$)X0JWh}R4wTw* zpWq)!4kLm{PUTs7XyK3L)&<{_I~IRWZhG$#S#ge4KJWeQ^>(j+&+NWh$A|aV_sf7E z=5&3Q01ov2T;@JByOw_e0>Bd|09FM*TX<@EdmOU|O#3JRkgGQ*3A~o)agu}Ho%=L* z4nS9PUSBOv;G4xA0iyAZ_&7VIwso^CJu^%GyzS32=h&;#4C&L*&J`Uv;bkAZuxWal zPGEt>K?H5#@d^AGh@?+&N$iuN@|Du1;AfAoTfR^UVD)|V_FTakIYCY19srh$+_IL> zQm0L0q~_OYj43aB3UEJ$a7vjX>Fmmp)u-N jhFdO#F;m?c+i~`(dBxq`>&`OR0 zHVg19n6gAj>2ZQTg=iI(+WHdd3$;sqSDnl~vQ*waFkd#F*(}{%ly;s}_L`CRq &)~# >VLr`IR&IgODX*E+CzD0v!>03fHNudh(L2J+;1*=A|uz&4!E zJl$O%cI`U1^eK ~b{rxV# zC(MIY_jx{WPR)b)(}4de*9NbH@y`)pH3+aCK!;3$#S#E)VjcF`k{AFoj*}qj !Omj@JUu |m0H z^K%D>_en+VYRDNI#Fo^t`<%MC7x%G8mU1FX3g^me*1_}RUa7-)y&Ko>JwNroBY-b& zrji_yc@YEyu FY 3>c1w8OS4g7vTc;12jQ@7D73Alf``D>kd0XX`ybhceLEn+xfa-Y(VkOCUek`^yfmz0kXx zq083;IMn=3fV)cTY^ojqryy`54BUQeY?IrMTN-t&aiw;Yvl*a1K7S8*uE7fBgY zjU@IA-jkhP9NSO1L+Qg69iaKuY+xH}{FFUip#l({xdiL?jR2*4)-TDRi(d814tV|l z^5ST@Zsy%`{k!+c4YTi;>*w4r*S&Y2Tr=k``O2(Y<%=_?$`!M2kume`m%m^AOIeZg zF%nI_o`0;;3;~>_Iv9@Is{1pZ-w2Qd0cfnWWXPZ`!JGtnilwoM#3iH&Dg!2g9ElD~ zMNP4^0O$t%Y+k_EHn-Lx`mhpZxj}V9r8MicBmwW^J$w+TLGYp-L5iTc>v=vyfXlCS z*97+YXAvMq0&IjA_n`Nnb--016Vx8;9HV2V(+~ALlUjOytLpGxY$ my+bDR&rPc5HH>;(w7!kezecehKy&pEps$(f2XVLXQOd<4-N=^s3Re*=lE zc9xxzhSqJ;*1?FuN0TyfrcwVp@Vojxdv-@IUd2@$&f^Q=s?8-*({xnMR#MV1k gc``WT!4&|V*q;g?5g+Ygcf#cxlx_ExUEfW>F(w9 zMv}}3;3EKYfRyF{VVQksrhN9*i8AWlhh)_3hh_AfM`X<0ugTc?Uz2h3AC_?o9+L43 zAC&P69*{8$?vZQWyIsCA^H!O<;Jfndp6OE3UMi{Bp!E0ieuENsiEcImj0AhuMn(k! zyR5PRIS=oX{p$&JN<~#6ehcOp3EUUC1uHhLdc `viP46_bLpJmd;2v3{Ba z`jqVv1PE^XYcGEd?&(XP8S_MN1YiV+$JT)Wn*dsX)d4>W(_zL+07rr-JUAaK#1{M6 zsfTtf`^X{OLyh34m-paz+in0G>-JoazoO;?cvwy@*=gP$1PJx*fQMKI@Ni`|62R8y zr0n5@6-gh3M>&kd6$x_d7TH<6L=Kd!(1C4y8jFFSa>p4YPeP@Sb#7(BPh-Ms!y9bX z=3+TeuwGu>@|?VX{9WnR8C$I0SmlSq8jJ)07T4`@Yxi*FhH$uCn%XFX>T#VL(59m~ z*}+8;82D|1dkf@GF(LpfN$t1~J-?SeNP;!_9h75bhvc)b+#*-a1OeuJL&m)KO&K@u zTLAnwWWvI4$VA$rM_>=b9#WgM07RL8zkKE0TV?FL2j!1jpOfNd$~gYf(;N78x})lS z2YPKC@KpxEwCsqepk>aal-HKXzT$&&wE8&mpi1ooOC}BI_S|5C&)4o*t%)FVRNx-I zKY{l_R@2jts7m?h6JiJ25v>Qh2d>{{8@$$=y*JH13y$e$aK|&rGovEjIUVcy3_uG4 zY%y=`EdXAE*`AzkL@?0%Z5xYkB!So0hfEvzl~4q{4qPMXv*71`I<*~1>RNmWT)mmA zF^~Y9pSw>62X{$B^ICw2px+JflL%axmIScri>V|#k50&f!*9y-yI+uJcfBO}&0JoD zi7kQeJ-&H;uH3AvH2cI?A74cI!w5hEa7Bj#{FsHZ=hSMMzvXS&a*-2nAaB^at3A9l zdVd;$Ph(Fnq5$+gJH2X~&f@=uw|7r+v>9A(M5ltEonl6S-hp!I8{q5OajXT_3S&<% zw_!Lg0O`Dw%Vq4GZ_2grJS Cl&i(S$cOQ`J=RTrDn6&ttGI{YgU|*NXi@qk4 z7d{MoMB51q9+azR-zwuEo!;0#TLvSRjM}4+0CbuIx~>E2iQtAp(5wTB!y`lvlpd7l z_Pi#~?R!l&=fa8MN?kOU*Ny-oqW~Az8bn@2l9Ld;r-7ZHpal6sILglh!0KyxF4)ox zup`aChrjmIjGrgCkN4L95!;%u &>B_VH$D)=zF9RRddar_F*;qX4>1#190=zd+D znc!#j$H4C=u|#9XWyz`8^1`m? G<20qZbOb7EI%U4k?=mh zZ?^n+-IH?nlAp<__r8XF;653>;1QX$2qakeb(yjNM8N(ONTews1BvieB+rj5{h6Gq zVx;3AK?;u#aA-?_0wmVSM4$xCJolc0z4Fw~m*vF+(`A1-*F+^CaXte|4^RVeB-*EA zhj<-3v4XilcW)c=0p?F>dvAS>zfaKoJ=tD<&A&wgf80KYZu7k%(J}-`g8%?72(T3d z09X?oQq0~}n@D0rz_BR6tRyfHE4ZT^dSD)1$v^^Np99sRI2YCH>@lOfm)(!!^Ecy* z*a3~d2HtW7{&%j%;8te<(77r@9LZ}Lz1{*4II=4@b~oQAb~!F&(5k0e-ua@0KKB-2tK!YeFHzu zd3~+&fCQNtT-Ep FTM3B&w6X!A2R5wjx=zG5qmVr$i#`D5Do9czkbbSh|K<<#^3$dHukAd3f0`<+^w8 zmm5HaNg%+K1t0>zKLte4r~sLC-JCn++8KAs+@s6!dRB{A!zTe~0Zs0_zk$e--Hz^N zscx>2{iTOw!}%@JGr-Tp>v>%elxF;#gTvj@f*gR`N!*W-gd|{|pppvt!*H0>IXzfC zsCiHKSu)2-07{_Xbu_;Aa`U92`D6Z`o&YL=Hh}=!K>z?Y0g%Vpqe^ptje;D`ln6mk zlLQXz3VIlc5#{wU+>;$sxYoAO#%bH(fp=-yGt5q_%$htd0>Ha-LP}jTK1+lIaHbtu zt}_d5I&~aCIfUydd$ n>wOhKMNSu_zu7&ZT1 z`P^HR &T%`?@-jS~(@XS*a^nLxE z(%ET4A1ISZ0?QBVGl%-xb^TEi%t+ut>w(Q}e^0;d&(pZ=-akWFf6iYUG&{~eO@aY( z5MU>O3GmVs`~ R&3L7-j1{N#9)hK5nfBf{rDQZma8$)2>e)l2fX4gY42Ju zHBIaBEyA0t_6G@~o&X2% K2!F%H-Da=-P`5&@s=eK{~Q6f `LN= %K0 z7#%XNXYRsnZ~QK3+_ul>f$raDqy-27=>Y=lLkI?7698)*t%9DdZTkUcdR%3UHv-;3 zPtTtPyBneauWr(YrOe&%ntLVGzg?=DHsI~qLrc&z2VfNlp8>|@2-C?U($c;QVV~oF zSiZDUrgmPXP9jM@B4yPZaX)rE5&Sf+)^G+tB(%LggNt6JKv!++4)H1N1Tu)g1!|>Q znmf JBAJ75+y)$eQ;z;13Q~N>j^4Y46O#|ITCd@& mbph3AlZLS-aGcb z>?qhH<*j@=JmnAn_=9pmTkG|;@&1D G4{GPx z=iZns&+mALH^N^W?;Qv55gnUAb_Ejw4~e7z41z2Q`8y;P)de_50uWpVRLUF@ptapP z!T32wf-`6dTY^1lIZoz1nU`qgC{UeZ1ag*HoY$GnU+|yL8}F6%{J!O%Bfw64I`*Uj ztVzfb=yQECBD^)_i7qGwVDrIooOWnZ#t}eC5hRS$^LV|1ngBQBx|^JlRnf2!iQY~C zjy<^M_2ZiO(hlGrlsU(wqjMjm(q3H0@+CdL)$m#6scB#b+ED !GO2H2rx$>$nx3AXz=sy~OO)evMgr@_CFqBS zt023MsjhG9$cJVp@B!wu8^;4}&-HrJls7%4kPQSky*WF&4E$QAho1p;IvfDK;` z8f8#NZ %pjyb=N&*nZ~wqL`OfM)m4S~eo^Pm^GP6$F45hj&gS0Z1K5NdkCtG6vwMOtQsf zZQFoPbATtH<6Lggyt!X&)|MJSh;Y7N@+#K?DAe`7qH_S(9fTc1#6TORw?75|9gwE> zJ@}L?U0OMl51;D^9g^Bc4s|#$i71Yf036Pcsqiq-Wl~IFnbe>s9T=?E)t1AtN@?w+ z3}OOF&CN;+!N%IWCA#YQDRq r=DDmV Jz zsd!BC8s3z~t_?CAVfm7zpyp<07QpYcerv}iX= zoBh20{j?tdz{NWPfC_#aS*h#e6TIrh32u7$Y)(M)d4Zib8DoRe%;@oxi32c#{jk)u zZ -#o9jpvTweP~>2+#7WM@NtlZ5{L^<@i|%-q`74uKnPN#FKASt?8$n5 zc5JZ @S>A#hi&VF8|o&B=RICq82%(+5l<$OtIo&UVd zIsaK%aQ<&)>4h)J+DjAUX!$=&eHZ5+5V)+w0Q`s;T|JB_^xSO{jx^wN8@oFVYt zAU5}ax+HZzuInABhQy(KV#$(A4-(XjJhXl-ZE|gX0$meVmNfx>TfU@EYyXvPr7~{D zUGl|gljMeX@0PLi?vn{1z~n{H^R$HzLeoD0O%FMP_L|vKWz4&`$^0Yl |k|0gwyKdql?1dk_)ke);U1lVs^BuEYm*sNfHb7gF$n0BQh| znt_U-t*Yhfb0jyTKnywM5F&t;PP1SPZWD|&MvJ5j4E4yl(qqyZvO#qQcD^?V;6bk7 z=W!2s62M5RLpGG8jszM70`HlL4E((8e2XvwkO1(+3G?^{X!Ga>d^37_B><~CXtU=C zelKT?1nT`2{G9N8LiSynD=%;VvrOCftdur$bq9KRf}bsCYH{Qyflo;Tt&cVyJ&B}q z7l2M+pM&1ck&>z{_y `R;W~Ni z{Pi*uHtYO#G6(x}FJ3S6FI_K-@^6qO1=q{6{HtYI-sfdy-WOy~>BCahzEb-8YVh8S z3Y0H4PKUal<;#vPH p$?b;yGS`mTTpqIO~vcvH@y#thGNkp Ap*ESj(=YM7rAEoT`~zd0VBaU z5a7Dmcgd(3_en!HWgopbz%>GNo&Z_1s1QT68<4Up$V&$}c5pSd@;;P2-Xjx)zBMo! z0Ypxg9+ublydu-~zbWM{jK+9>C14QbFeNiT6F(ct?MSM}{AV5c`D~xW;5L(OPk_XJ zjR2 t(s1C1TLftC0tvOQPmIvVUATe*n@m3I{QF{BD zq_pNF>;!%!dTd*Rhua+>(23ep^45`g^3c+Mlxt?(E~DSOSEerHkO!EH43j~GuTGyR zKm71F(m!lv(Ilb@{|`P3AQWnnmYycLYxWOh%xp-ZxsQMV56YPL9+1zzF;Sk}`l@?R z4?KEX8UaQS5lr0o1mO3RmZlbV-qA@Uq;H^G=K>f3f?nSUK!BU4kH|2Hy7Jg^dE>wv z^2)wf ;MQr?QJq9 zBWI#aPHjIH-LE#{yn1^AouKECV;gp?+cYG)?669w&qy;8*TsjY%f|Byq@i=aG{c%Y z_DfG62eh5SHRte6bEpEx>$rp$p1!Gry~3=v brnTFzK`CuckeFjnSV9EB(V66B7OMIgbF z{L%7Z;VAj2cr-{bR@Rq|mNf-mmW`JtNmCDh5dMIWJ&Xp>@tk~K+r;Hb*ue$alVmBh zT~}@B=%&7}l+wD>Qs2U|EIl#xt(QO{3H8?jBm@Y47(Gw0(;`ycfSjYP2(qAEhQgfV z%k$K(re8^l&|sUa&)q3sU-~a{?K^iP3Oo$(KcwKNL}GsMm3OAdKdydUA~CB5b#&E8 zb1R<#OfraU&)Xx{O}j@X&V5)3FdBKr4e#C~*S>X+RCJK|B#r@%5<|hKAfiWw2iMkv z8Xh|tk(#;^ybcO}C UaDHkWySFiWy9HZ+N;g? zND$;89$THf??_-oaNb`*j?cuL!F{$Y5tta0tOkA`1^DL(a0tK3ad>rBVhDOBq}=uv z0KaRqz$f5?X5hEcnC!7VhBrS6fSi@OMwUU@o5~TGr=X)xKp!8K=5|(l_Dg5a5depe z9p$J@dh!CPKm@4oJSNR;#{dMY?j+(D0e&uKWrNsgeyNgFisO2!q_q8G`R&24%AZeM zCr_WgQC>JZMqWBIR$fIr?c7-80ppPajFWfIj{+I40U55B1xkX^kU}6q(P&u(60Cu( zM>JSle3g8Rh|$o+`3I~|`$V=&2KYGAHuf#_;s0#K|5T}~HVh3jA0W_srM-hyCHB^` z)M*pgnGbaLR>KJ}2k6In<{MnP1V1d=&K{1Rp=TXL!W~i~^bfQ pJBAi!w=+o~}3Oe}y~iC|L-<1QM+TuZ5=$!!MstkV Ehq3HfB`tji UR$hP^0Y*ax83|^epD3#? zeN*1gxkcVN{bl3@SIYZ{0v{BNkrjnwWVJ?uQL?UVjI1lZPCm|=C|&&|8NWzYbx4AK zM5+=wTf_%#S3}Nl=mV17G&{3wnew*wVuW}QLg2}9ERpO07~mZN{!o9t-p__H@P`Sj zB {q{rAhb_a2h5b2#tdLAmN3M1^<0g($<%!CqYcpGF zfcH8+Xy)NNk<+}nYr6bp$8+-9-nZe%5Dhz j?0x*Ei#|PR?fIXJ?xL_;qHROYjxs9_VO|7%+D%Llk`<9h=ninMBM8V2P+n z8R~fhe%4e5F+!-;chws=G$CV2X!s;scRXnwSCw_!@WUMfP}!LUUCW`$eAommonr4Q zt2gla>0G?W1$e(x^2VN* sO z487$@HuL4Vqc_X%4qqjIIXPOMIyFv7@bcNw@+JuI*12&q?d*7Y>+D$h;KDcLOw}wY zY+5B73x6bY&s~jZaHT9O91STnR#umembK+KDhXB=epU7q{0RRS%amPeDf|fa&4qF* ze+K|+xq)_g(aY;P{oNpR9dZDd4C9zRo-~Xku@~g$j{$##Fda{JgXkSdek+g@Gy Tsh+o83O{`2)lmvgYq{o-y|C^a 4^Yx|5OAe2fR0+i8(-$g`PJFE4f9drncR3>>y z0t3Dgz#@P}0gD7AfhEW^1AnGf5;y|rcNHh$NPy$`2F?J`04x*I*fD_LM+0A*H;ZWt z;A^ekJiG$l_0kgXHsQp#h%88GORG8kkptFvEn6G5z`A}~M{C-uYe^1KrUa5=S$txS z{AKqu^8A4p -g=ir`bc!N}EwE#$Sr+8{On$lli}I)A zqvbCr#>>+u$5|AB6ng#4IC=BT1bGuAc tfS^XEh zKZiSnlmIk>a(D4Q`SP2$;584(jqid0vmTbuy?LAbY|RsT-T>>tp7s7DkAXU-n!dH8 zNw%KbDu3Maj6A#N75T%KCvk5^f4ptrwF-XT6U4Vfmjqy>8^-_GAvF!mKf(^=*(i`9 zg0?<6=IdbqxRQy4P!cezpvAuB3RzO{^Wypd0SIQ2Aa()(HzLrI3V0)c5yEv^X$g~n zb_7t*PZC%XOYn1WTK~W?sjT0MH)Pd@;HTL_vpROi5kS}6OmHAup7agoNJ--nS$=+z z%sl$G -K@0DhaG-XZ%dX3EcZeO?|vGFtw8 zbeueSe7rn+a)P{gYJ$9cdZN5~dK|z%4rD+iKty=`#Fu2=u_@Bn&Zs5GJr2qG{6}Tp z#jnV+;_GE)$&IqQ3~lk3WpC+skeK%%QgMtBzz>k~QKUn|4Q|OouEb{GPiexNlvt!+ zx(5rSxOKK1uK2cWFS-s9=ReDa!v9S+7ylo!z3lU{yYkDjuj&dpP<6E&thzyt)!rZ{ zYOj=IHD8t^RbK>2u934f-$pL720t`4GwwWs0F+SxB>=wW%m%sYt=pmNAA+<&ZZHSH zo^!8!@%5V^FWIjW2XN?Jt%Ga;^6IYn- 7aK_CibJbo7Ze2NNJU2E%fLmKow%=>HoUV-nu|9R{a{P_s~NSKlI zvM08GAWz!5c$_n JBSEp lNJO? zQ|N#ks{DzpD)@@5DEM0>*I$**<=4yBiW_7b |IwCt(5K@I@)`)WtYfts;$ zq;|X &ME8`q9|FQI1q!B?m#2occ#4G|Y8~5Dfr+%M*s-fonlb0yZtb-}Jm( zG5uE6_G3T-YWvSlpCW(Q`l7xniNxEyDFGq!%=WkCbFbY3@IM0kn%n^U{A;(%jso-G zrrG0=di^?5l+OjLs^$vW4ZsAeN(8qh#r(gy&M3cw;o%-BD&wP^JPDXf(9G+b{BZyV zsQH?jhH@Dg?!adR!0|ns)FY@3&{<;8+uL!y2fhcS;kd@UKO=%CiIK}lpb;R&2oNU$ zIHcIg7&W)81Yk!Mqkx&oBbt%I@&Nx@4}KUtJgYu+&3lj>vKqwW?76iXzdmLN3Ghhp z=SU3_;S{9}WKe6@X?PU&18}uvB*D7oK0K_Ffu6n`I=VpG5e*tzPT|MTk*>ZxIaR$) z+Iq`m$;Cg(kGFqWezR|!Ja%wANH7*87%$J97z-kd1rf#~A|N+_G ze?615`38VMJZ`rf-@}pwW*~NZTO*B-3cK&fvC{# z9eo#MU+K?fS>ET6Q~aH*D!mc}x #mgJ)z?XDXuW>Mcmm0IOF5iDg93{Htn4h6 zQPBOP;QhxS5{#Mmpj 6h zdDk>Voib_fX_2P327K4m_#YeLh)8mRo%eBob717LwvIX^>&$l|kVoxOSaQxq0#?$P zlzPzfwQLDuBT`daHGWqs5Ew~0qyyKuNXPd`cmnJDI)FPVsAXFoBk&!`GuNOc5nT@8 z2;kSt$q*nA0aWhLNCqC~F(;9f09o*R51)j0=VDWp^}DsaNx+i;3Vin5;yi)x!QVG{ zUMd^7s+&z#NlU3Tw4DSgID?B7AbaFc=24?MKNm#cC{B$G)sRV*Qr2=*7N46VCu?>~ zMf)-N`S$DN7rRHxZ}yLo#}Enrd}O>ld2F2g_4pWh`ow6+qOtM 1rfe1r)r t%V#4YIQIMvwsc1QTnLplqCME*mdf%g4#~igB_Nw!3Pa?5!Fv`>H3X zkqC$C#()GcfS-08+b0^vgACWpk@BnHD0axe;D8K;nRG`aisYF;H2 (`fW09 z{v*1K%2*`EpMQIbY`DbbDANiol7M7x3f0M&cOF*hb3MR+-OPvNi*Mc~|M1Z-WqaNl zscK>Jovq%e#)gOW`a4^C jyxxJI4j@}uVgxWV0(c2Df&i2!9>m_T4^kKj%)=*? z6aj*0>t3hYo&+GUq3Pk>WASrRTE|7L{BQ*uvS4OOFExMv04qg%aX&6iatX%)bby)? zXE@AqCkYUE@`_Gb^d#UU7F(i(BuFId wM{ z83`sJFBmI-IW`K(@fdjya)^@X6-0v95D6F!UIPi9+5H7MQ_ctT*-Bz%ZJTBBg|EsI zM1Z3;Pb&dpv3h)7E^ET!5=rRgv$Fcqz4C6(|1Jv)u9781H_8V^qvXTl(XzbQI@wm0 zjFpc|#>x8939_+ttdd|0BEk+3VORMC* EjdRIphW}oth}GAOgGwdxJ^x$s6RQz1K-q8+(CmIKqL7=Vj)( z{|Eqbs6Z5-vlXA2m6^C)YFr`jp1KO)|Dr6oG)flc-5`q#M$3{y_Arl; s1OaK$@!%J*0N6Fzf7 zdI8Ap-bP58S{WMZmS3&?t9 D&EsZ4=YXc( z*GxeMqwJ|`rWa>96W1r{c`1k50PZ3H+T@cF$Go-OkM|vv(&{{1!%{6HfN%M}zV+L% zG|%(1ktE(c5qO=uuYN`n)Ds|a4g_G2s+T3K$nbHWeDE_pc90D0FJM$439uixV@4cY z%i#<(l{I i1z`=B7 zIEI`c?m=H_t)IfE ^F;5 % dLiFaTaq`OXYh~t H-h} A%OmH{UmsQXV@nJ zjv+c6uf0)@;`Mz)_)b7d{^)~{L&G|VPvo@&bL8`H-i& T0z68JaTco%kKo45$zaZFc zo5u-wY-^9Z9b=m%gMD64QaUeuRz`pv5P&_Zti~Al{U9@Y^gqYm&@4hEW1NKrBCyq` zqmv*sPaf9tZ74$vlGu)rTXI}iVPLN<>->2Lx4BYUvmHQp;ExmhBtbs*3lTjoNdqJh z^?fSNCoSTORNxy4QaX!^;OC?jS|zTnmHOsWGCYi^z$FOXf>iu6&(!RYf8IDwez9w` zJbqv*qQDgS rXy3V4Dmy@! z)DXzRUS)zpWZto5@?Tz>B-g%uuU!51eIUSH^5r*fl{3|+B@94&0;pampcJqk{LbU5 zC+0N+QdL(hd=^=pV8!QRYn^YyNZ9M7tOAZvhg6dUo&cQEIV{zUh~|BD*bhle3D5;I zBE;F7t=n!NZ9>gRk|Drl5y1#R60#p)kOVl7Ps0S0GRT>dfZ)%zLF0flf?o%wF;e(z zc@F~J&B?88Iff*bv!^&2onv(_0WkFLc~VfZ4eTZv@ZJi1MhOrETAy=wyL&H4OB?Ie z@a_P;t+-6@U#6DQ#b;?1xHqRQazc6?WD`G6weH)K7X9K>VMKzX^2E-2 4C(r56Ms z_=DD|ZH(I_gZ6CmI0?b`Fw>#s>iKE*Sw|2c5x)Qe6exhrNP;ZbEn)R79}nuyU87G& zhmkZoAdM1-Kq;2W#v{;`kVB9=T# h!n1DGWq5opNm$#+AC+@-NdUJbAqZfRpkKNM%4FW@-^wp` zj*(yPK{PlxS^f-gKZ%wkcp4;l=GY`iqVY
umq7|;l- zS6(S+svef%2=bl*4$5nS#3`0R= &$G+8BZ zNq|-7H^|?;j0i9tM1fre(tP2K+a;&&g7k!%04jJ^2T~&dTay(T0`R;8cwKXu^!4-p zJ?6#La~nA6xp@uyE~;x-+UEL1yr)g-?+7(WeG43(Fal&jKY|2FTDK7)a~=^O?V 8dr=6Q1 z({m=vjGXZr31;Ptk@s@O$^z`qJ9mT3K69mfaN$ ujbwYXY%h9L zR$RPNR^?qGYm0A`jb)=`bH!-cR*B@eW}NJ<9WVQ6kVX4y#zGpw``0i6TrY=9CJ7hY zjl>AR0pYlzVaTA;))KjX`W=ux_dy0dB-bIi{UT(I5g;X aO#i;uy@;QYA-9S^q5U%`Tq2>d=h0992tJ6jEtetW0L@8 zjLuE1<%k5V`Y;mkwS$OEtx|~`f@7#+0J?#^1C}KMkDKS`F?XB`jRE-CmPA0*0r>56 zlbjrc*Uu#Qk|Tf{SD$GqOFZBW _<6fFNxrAQLK0Dq*%^}JwqvsD(o6E%k#EVL_f7$*Z;;3KUN4XDzh0g= zc#S*-&;P=)>yZ#ogtz_|{9l7o(SBSW+x->!^O0+{Q|);W;H5L;HF `M^XZ z#N&0;>GU(#%B+*u%F0WR!jmqP-eFd(*jZ(NRc$+D)1`-G!MV@NlDw;sAB>Szu+>F3 z%G%N!WkWehFh;gkjh5}z<78*`c-dV&7Sd>(?5iFl2dhWP!HR3;Q1KLipA&W>@O~ya zdU_kAsi#iH%z8kz{|)axtSdu&>5V%9YD$l!boDd>*zB+pS#xfy{2wntF3tM7Ts!M) z@|776Kr%fdog9S<0&wCEhi+RA02xG&t>(+1j1D|c5W~B7)ksSRelvjB0O|-p@9d%g z!O3gun~J5Xmh+0)&wxZe(g`ODJW01ilWhPy!B1<0wQCz2aoii 0YB?4rO( z0>tw{0P1Uk*8|@fJvx%o;1FcnXUk)@ZQ!RTH}I!epGQ(!Q_B_CSfT_O0RGNy_72mN zBcXMG&(`v7TbCvw0a|kqJs2hI=__d(fzKtzX?&P8Jij*%fNSq6#E(gjVJom@=j%e_ zx{2#7jka}iAzan~;Xoe0r&%T8fUYETeW$eb7fDI`KFE>va >V*k6kTG z&;Pv?G;frsKIxHPK7a6tw02*V%@_Vz=AQa8@&p@-y)=K6EXyCG3G(W~F|xK~l&nV- z*ib%3HkFT+t(D_sM-?OyY 2MFO@VSHt;i;%d@_rAyQ3Fo2VhptiA4 z>X7`B1gscwEmD4GMgRgIZK~BwkF}ePhzu4j4E)^p@9E!< @K-l zPE>pgKdSe_rop@V>fznH@V^hq>JzKv$< w{LD2C`O+KWlQO$y+r?eTjcnn71b(Y5_*<0}QAC2M0-o04tTS+o#t49%z`e(a z2$0YSU|yNYrnTOa6V%Y>y6=zkm^V)tl7W)OJpa<;@5=oP{$6fh^nLm9>c`|#EywaV zY66^n5A*yafR-~|MMrd*@h0Q|AQRW!(Uq5(>;}Al#!Tl^;!xUjbrnl^7+)6G*w-hm za9qAKK2RFQMX6d50TSF! <}9&CwR=A0nx^R0%Y%_VOl>Ozx7Oto?%gE0X8O z4zM$AlKdG-^IwkLB+nk7illk6JbU5>dG-(hSv(UzAiXN<+yobgDa53@{gBK(algED z^mC9$*UKCv#dCAV$%0ErlmUG98N82n86?okqOr23WGrORSXoy#9+6-iNH9*ekOVLe zpV(10Ms}5sk?n<7NKQ4YKwMbLyfMMlH`J -;nF59u)Fp<(_Xtn>g(K_jp xqEAj*A22P1@; !t?$bdVd xI JWOvb< z^5XvM UM(T>}mB z$owD3l=r?a_bvXh+`jO8ay@d9T_v2H!+f9#*6aw-Vy%Lo#%)gmiv}?`u$U&qo(S$Y zO?N{A#VCP%1h9lu3E)ZKlT|f(ZLi ZmF73U=GXMB@kz{`k z3H1%~`pL0KlE)xP9w##|OprMsz}!5LAb)}^E|>@cjF+V#z=t5fa?VUELQ>3V06FwA z$gs9#ysU$51n{@QID}$D{#T`}X#+lgmkw>{f^lqqS=~9gSbkXeNL8-q-_zeA-F@vi z-y$8*`|aJ$Qdo6fj$eY*Kr-A8nbRI>P`T3)YSL0 oRNXs>CrQy*)KJUn?~r%(VUQ%3tw&zP|4*scJ8m_P!=*?X88?NOKQgQy~qV z< BR=aoEKgccf9+k5ddl8B&@dFgX@{xF 0E`^1Sy6vU zE|wkwL2#_U(OPKz5RBV=O yb`anp zxqHzM<(~I{B=;=&k=!`*etGZce0--p5 &*TIFBBVzWLURM=e2|H; zWCsY4ECm6mud|gHj 8Nkc1;-v2%kt3z^ `N0Ng(e~rOf)Hk2tX1nr5pkQmVp4vVH`rS68oAU zgA5yrCd!)ptK>-eAMu`Cr=Q6-r5h#15F!{7TxfSn6B`CV-OtH0W;`B2ve~Ckd+`#5 z#&bbw5=LY#0hw5)#QzE)ayfQ0CcKvPYRjZpzq=tsgK{}kupN<+GL{;jvak~ovQyiv z*>V03(RjD~X6;|)#_12pO$)v!V`e=p-&*o>X$ry7BC>h{WUa*v81qIc6jAVd!#;j< z8p)u23n?W)8GdyDkYLt?)Ky~=0JiqNk{AR`0PtKqRtKQjHWS$-6ImURStsSKC*{>W zZ_4BApOSSsYotl13AW)imbhx?Rpyzw{`tA=EUCedLNatn2SA!iut%51Ro7U{XUp=Y zl{vW(m0xz8_wT}&JqS aa+L3=I(NZ2Toz+Y7Iz& zBYc1z$rh2JNUQwIhI{1EjT7VtTW^&gZNF81y6a~77kKr5+ J6Yb&nIi98m?E<-PL_8s zQ4UR%d5}X3^2f>|*kXX*B+*1f10w-ftXRoaE3m!3aFVRJcn#zQXKisfv`a)`1bC>w z0pMoefE&c-hAH@MUc+(vJ&;TUyS<(OHuJrxBtaLveRnVK#rnOK8?Cicqsk4}D;s0U z6MB0X`BYaoogN&E10Mky12|)BVFpkpw#eo1JF+TN*;pv=pZrk%u Le0UPqGXkjLJUhJ1%eP5&J$q$)p+(E3rjbJ*2t;;X83D|Eg@*=$r1{z= zX=u)saG0dvc>Cn&})l-oaIfvUq@H5bV2iTaVic`HWIYszMcsD$GfDXy9{2B zQH4W>34AMsa(W}BOC-iem@+yE64&L(W3G|!Zn{~1u;n)S;kMi4Cp&MI@9p@i%s=-B zT*28*Os)uic+2##e0cf^`Ps(L$?x`#mdEywmB$Z^aU>Wk&m0>oFPz|%%t?p@Q{|1b zH_6-Qr#cc$0tqI{97KS5@cawm?H54~X<`g%w6tJ?EXxNGuy37Z987l|BGh*zmaw|N z2e1)fNIId#*?C2;?8o3_Nnix%hR*MWBw_`~mkJg9x=mx!*e6XL<(gZtbZHe9tH0Ao z0umsKj_6{?0waRd+ zUWSwP06eV;fTlGo(7lC$2L(Dl6C*%`5d#h|jB~?kMg*?Q6iKx)2Pg*t*r^0SYXl&m zK>`jo%M3Upu+76~2%se3aD)m+0v n`}3jk@>fKGXO4}RXLb1X z6nW+JP4dRMsWSciWSMz!3i1I)g7NYm0KWjnoPf3nwiw`lAK+gKg0Qn}MZqX}|J+xk zq?xn3_{Tl~>xj})BtBDXXN^h<0$vBHrL&QM!zCzz+B>VYBP>e*8teM5Oo^8Fu5T&T zjzE?%qe%juK=%gEPT+gPZ xa>i}AUtyP*qf`(R>HVIlcRKbCt zp4|qmk@;;P3|AV)eemHqgdv%*)@u0%{t*Tc_93xqXrae|o>T&Gx+0&a1>nPU{eAr7 zJxI8EkSH?@vc|8vR1Mp>hPpNx@0Zind*zNL*U2MmZkBJYzfHcg={ETR2=L8Kqvc2q zSGq`K!H>VSXJ3}@ulbz(eD_%Sm)#TOS9>PNulJ3Y-|d?qf7m|(xxfTOf${Rxk%?M1 zeeMK1&nC$$XC}*=ImidjPeLRB5pt0jlLUwc^I;2M3jzLx`IA6|iLi09g#87%SICCk z?;*MEfoE=29dD7qCY%%OZ5&MrFe~8E>Tm@GH;va0N^5&15=pKz T_BJ7-E#NaXN z{5~CA7mm~Gv!jV1_btH>u)Nw3>_>0b05$q^-Lg+r0L2;5VzqC{gjdl-95+Scvz+ z>v61es75MVNB{!7UIu}ap=2!#Ey=)$0An;50ttpe2r~oyum``hVMYRgf7lZsNdm+z z0+{3h;I-q)ZL XaOI3{JxOwQ2LvzjtETmw(q1S7CHLns#{V6C3O zB>@0>${J^BczKeg9WsR;o!bijL<_)-56 wk3c%9?h+LJe4v|Ca@AUq-)(F53 zEgWMD8ysrFkIsArIv2;8kTLX{5zy=@jLFM;AD5e!UMCN&zE!@q_BP}Jx5+m)Pn0JQ z{uKWY2Ytb_;=TB{1BvqYR(?r-vTcm~Y{vxor=1f(f{BO(lR$zA@_P{A@dFd&Plrf? zN%9njUZ0pO&!3zqFKRw8Nv54+PB00Q$f`p#FG3FGPLR3G3ocDUZU8cn2ze9a{k$AY}k&Yfd1*Z$ZYt-QjnxnW8o8Ab1l7NHYNB~9)1v^2n wi5{tGDpIMVz*jF%`9rTxbHEP}fVo3MYl%JtB#z&a-q~A*001VRg kmO{DcEE3E rkf4WDrq zfevOhAtk4)w#YY@e;E>Kl>B(>MES?<6Xj<+C&@qUnk>KAJqaY3D8Bet%#hqQGQG zp~;X!QxF9vfdu0b32eP09Y+KrusZZs&SV!6CL%XrsdS>uX1O$%!zRYdtg~N~1Ep`` z{Sl}MSdBtlw`WMA?ED&Nm0f4o$;qMv(mw#eCU~C1Iv5>n>^^e_&KzT&U`aASpNRlo z{#Ya!47b9$6ybeXho`3}=#2zE3Rs0D>*eVj>RO7Vcd$kQ?IlXqI2@%zN`Z%RsD%Ws z8G^K-7w7eLodvKwjQ~T)4b>P0fHAYnw~YiOfEgl(YmERP0~|B=8IgvQAV4x+1p+`@ zC#oF@*isS^{55(kYd8nclNh#L3u9G>W UBsH4}LY4J= @xrXegOjfY9C3!>6q+6 z1_^W~+5~w@muZ=x1L|Hp6&O&bM3@XBOhH7LBr{+$&rgs!7sksw=dYC2`9FtOW=Ycq zzGV|z^XvqBeM^Z9ggdoz!(qwoUA4kBN8_2rXruaUg8o5zVZPSnj!Gc~J_&&DjfIT9 zI%$QRQ3BxmHv$BW)fRentGbW?F@UVPDNhEY4e<0$IPSH2qXR$ABVw1 oZZ%bg$IB6oju zt4v)!MxH+O8=R#kMiLnBlgBrHNA6huCHcm>@$&5r6A=X_g9KCM$J?i7NboOvCdn`N zviEqh{1*26gHz=XIwf 1TUQd5zbCjBD}`@;OrE63uKrHdGyxV zt7Xoa`=vL`l5R>pzv+3?`6K|N03X27tqWvv7=x|m9EtVfyJ^$)c6vaH?!G2DTX72V z$!hincuRUSExtqMIIW$P+W8d+Xrn4~2y#jt=kf8I+BT9*{(ek2L5#B63!=4p1-~Nz zerNu!jc{<~^%oTUgX+DV7f+J_tx{ZnMmh#c@X0|61wYSuumku6yb^)H_r~+?K1wER z1K$dMC6ZYj_yplt2CW4m09sBTOu&;n%MySF;4ApO(etxBNw05}8-02T{y{z#)XI&V znN)?a$;uBkHXr@Z(j OCLagu4u1pILV%7$TM9fnO>3C@Y ~@@TAl?c)jZ&Gi%IyBnv< z_qR+138sJqQ{<;RZh{n=3KC2O38u=wf&jnSKSh3fa1w~X$@voy4JODFM?nH4#?PLd zB+r2a9A5nrqJb^rI9aBF2(O&FM&_J-Kw5fBr8m@psKqEmN#hA%5rFe^>zmluK`(CS zsQa}{i|a{-SbRu|0nWJx=1NJ!CG87f?VjHe0ne(9Cjs{ l|>+bl$Dr+vnW3gNc5)kb0 z?DXn<_(T+6x?fV nma_NKj zWAA5u3-SPfgV9JwFM=$*z88| vtL>*ekhHy}x#Adh@JNxr^riafgECi&i`TjU2I!4J3HBtO}Hlak=)J8zbM-hDIV z&`t8M`=-dR4}b`;-yfJFe}E+7$kHc{AU8NRRh~Y<+yK!4rYlvP0x^yO{AV7O&H)w} zxG*neTrDCfrG)8vN+C+0p57LnaL)flb-gCQe!_|wBLjy))Ix%AQhW=XMm-YNmJSXt zfrnHw@H(p+F;Qj*n8}^2)X_RS@M9%LR#~j(9>H(U&ssqz1~6O65}3iYtvw}DRCP>= z5mv8E0uZe9-1OiLG5|T0UvmN=u2!IX8%$R^u+y-ea+W!6t&)YUlSYGr`-{mV-k*IA z$TvIzSY3fPPcRptH9&5##W5bohf% N;R&a~}mf+HZN8(+wJ$I8F zE88ob@bs*LGy?dX85Pr24LC~Ch%q#TWLp=0VUf69^jxlFNu&FAUS^W=p;*!k8`Dpaq{G$E9Ik0 zkIA48&xql3`l!ao9YNo>RY=s$Uto2rxfNPHkQZ>9QNk)fzJ)JrEWz)^4l|qBLT}G8 zL~W3VjUYpNSG9EYH_5)zJ+e7>s|>|ja19e)62NNgT4!gSURx%eR%MCO%lDK?eS4uJ z0C0o0v%g%y9|a+X0Z5ZGbpWb)Yl4-Mr(>uB1ULyI)j3dmfP0YB$^%y64BLWp0^(?k z>(xLAvG-yWU{wlaV@FuMMgW330l+65z_n$#(&W_>gR`p$d=GwlYa@UTCs05W_#9fm z3Q4z?C9TtmiK+qLyg13=>+bN}iC!tM%E7hl)Z&i`Pu@`qFU8jqoR-KM_ d5HFjw*b9{6Ca5;Ybhuk1EKyHjse>;@b89w4|02i2pAP z-xKAK5(ha#YdgnU^+|9h6{D&~M0~Fi$i)j)CuGjf>GJB<*X1G{1AjLgyVIeGDha?+ zCOtVVqLmlODPXF)Jzq({jx2in?x9*KsyPK21StehY%+yjx>|wnEeaylbrnci^I81I z^#FAc@NQci*wxBlMu2_?b_GAlf_)zA*JF4uwHkb;7Q8nIkcc-12;hytC)f${ChX%M z&|~lb3VQ1KEb#q!8hU&_I*195RY`iJxuaO0hs#8l6&r$Hjov%}Xq__(EneG@j|7;5 zqK2h!kX`ib7r>8*knBNE$-`sRwEVGKJF29ixl9_7$kw-%%bvm=vcG5-&YQ01k55mR zS;at)xnvQMfs?jLk|g94$J0E&>v{R|j8QUn{v9%I(OoiO$=x!3$(?f32X{(qUxWN< z?LW)tIoHYUNRaPZezV;3(M=%1E%MN+TjUWX!A(etZ$dPfif8~ywD}g~1U8Q7Cp&JD zpYFO9k>FPO)!tj=*ZV*Kkl-;ygFiwJ{rS)odHldw`TgFjWcKMFNJR&ihQYTN0x;qn zwqc3ANgMlLjNmCbRj^M^;s3Vb8T|k>31CDp5?F `XMaeq z^h3w*Ke<6pmF$xGu1YC~L#uAhm$ndqhlmiz@5+h_$v`4R7zJP)(^K7+Cq3*3!ggP{ zUW)3@L3UU&>h-x+a+Clxcxdd$5CKY?&PjcDF0O^vC*V^6cZy&K(7CO^C+H3Q{oE!g z0DL0>&*2)h{YscDA!u>^3^gPGBLF J_Mqm(lZGZvs)w9`cCT;eB>7>Gi$Ao1emK&hCCl0d zt{ztmEnFiJ9OHS@tzlhigR3I&bs6BRaT^jKb381yb@@8%nfn~o`SZ2Ek}DvAM$fxb z#w@rC+W&4D0|MOk;eE34{A#)B-AOWe!A)|@lH26=4{w#bR Zj#^ay%hwwS$?~Bocwy%_43A% zN9B0+Dts4=Y@FdmY1bod(C5~70WhoiD8PqGbV*Z3g>?5d;(xCOS*%Amj{lj`#t6Xg zcv$lTKCzPbVy`YiuXAVd`xzm4jE<~JBQPbFCC4}itn`H&rKzh}Dx1zrRm(*r n94_QvaQy*qAEpc*720jfcOL^Hk>cw^_~)zbs;>fzO#x2L%Q zW-4I-W~<~F==o)FIDzVMfSvVXs~LAlRU?Nd&~s&h&yu8RSs2TT^wKJQxSU6y6jh&= zC1;n&8;9PKO?m5etxI;;)HW4MZG91bz-okk4|;-v_cvhJC`g-&${$>Tj{9cJ_lg4S zBnux;THnMzhZu;|B@e&xovLFA(hq)(@YXGxO49RL{l^hqZ8h*mBF*yryAIjZ&lHc8Yajm;&} z*U$Sz@H_BYe!u-XRHCuDSif(2dL;lsPb0vU1OPAx2;z6FX)KiCI3hPB4842|UY_!S z!xea}FH$SbNNTG=gsPTH(%x4gor5*<{;8$%%#Igi?uq$w0a|`2R*z2x*+VU^l^S|> zfRRbAlQm3m843E6l~PoH9Bq~KCIR3Sz@5VXlLF9{1n}%61iZglsq|*H%cK`1v29*c zj&s~M^O1mk3j=8o0I~+(Za2IQ$C2QlgvGT~Nm-P&IF9QvYIl_{H1GAzJOMNcC<&N! zf|qr9I=Mv4l_ZE>!}1!vKTHWgvqYA&sMefcDbMVEUfwu5O`h5Pq8upPgC7(LSd8+B zo{<2?4-461;P(Vz*r(hXk}7!i7?MLuoh$)@)et*IkdQ2cgB{X4)Gkx!JR(=kx>>HD zbB8@!Yd(mu;2s&h@D4kIZka5PZk{0D**IE$wgt)V-bdwwv#&~S<575Reipc+ZpZ*i zKIgTqBx$)!Q(LJF471Ek@(xLNs2Sj|!T&pm>nL%|0q_A49PZH$d8iME*6$UOu^j*y zf|J8NP3kz{69d{B0qD?rhZ>}*y#&{G;u~rOXqfbx&Sx7=L(2n5Oejf?P_#-q`^sd~ zg|+hdwkPD7eb33Sw?2*po{yC3fGc7mYWkd7+$XduU(_n~n%04Q=zov~@~2lp?+H+% zK<~`}yG%kjXI3nsbO{XG&iu7zo*axM6-q`#780Nh1PB4(2wxe1a~sBE83PG2=NSRA zEyu#Ev{7(pL2cv62c@C4Of{?xHQ+Uj7$krtqy~C+)(mKEySuwdmYw`Sp4 }T#0U+t@Zj^F- z)*ENsEmzIFO|G4Nhd!14hPeR$JVb$cAjG`eWenO03+|Swi|+*i?v-1RAm6&|X1R6w zRJmit6hwo`awn4GyH<>oyFMBt_pcr!U;lW#{9xm4^84N2l^G|Vk?kcb +})7RWzF$qxK~_E73!oO26O@j7=O9`6Y?NOLQP zst@D4?Zj)U^_ u~JVcT5j{6+x34c}G^jMW~NGpXaPx7A4CdS218 z cti>A GKD3NOap{@r8jJNI&g`CV z?{ARBrxs~NXEQts>)r->wgf$X I^!}WYAh@0-!9rVo zVx@fLty|>kcW#$!X5A^*%)SSa;6AzWy?Y>u?lf5ho4DX!88!cQ8M_ejXZeHj=<07l z#{8o^zWZP0$$h_<*N!|ZbI(kZHTmz$j^efO@JFPfr&w&oW==Lo41?5R?=wl5T{5s& zXQQ?1di(^o13yWxjv^_w%>}ai tty?RyqX(?|xAvGOmrERECBLD$E=tw||;PqvVC!{+Dpwg2&@7|LtLR+LD z&yc}c56%<#-P+nUN&*r@&%1L;M-UK((Iu2DM-Gte00HP(2xJ=#sjYT4xiCo*Xhgs@ zG`*JBkpyf3{v@?M!!|+hzzQ<(2jq`XR-cd1O-%~V3s1$h<6GLRG>L6!#s>}6YtJP= zi+QiGcIb5VHX+Ha0#KZ%^PXNymoy}2x)zQHzxVJ2bX2-~TXBDaiBZ54zyqJ4F$oln z^-Bmj#=3JK%iq2>QLdbRC!)aJx|H}ev+vbNaQ&RSK!m#y5pevy+vRsVpOyVJN2I8! zK-zm7Bn09P4fFpU`8Pbk|BDaVU_QdKBb`Ja{x=TWs8>&KB}-O!KyF;q+EIq@)uNec z`(Y5H3*SY(CZ)wyr?AiAA0RUB#rrAvX-XL052hptBd0npIc2+~v$saCNdV-WByM`2 z1aPyoVElZARp(?VPVY~zZ{ug!V@t`S-k$`(zIy(&?_gpHvr+20a-}<5E!7?8k;s z8on75egfQNO}7yX&SgBp#-n1m4?to$*(<%`_~$f30ehik%G7< z4S>UfBp}EQ{ItT%b{Dq0aE~tC#_N-g1Tac55P<~fGy>3ryQ&OZTWXqwFfy2#)Uj>+ zSpFCZfa|PxmLxDDu;mX_(N-vnPArfmCl*Tyz|z`YEsd@CGISV%X vn9{7wZ)V$m#aAH!|RPLTt<>ti___zma |jN-R1kyYu(Re|}+%eD$r{<*V=9g=F|1x$52fK!UsF+Bq-~;Oh78hpt_V*K*yQ zIEcf+e|SG7b}OV5ry;UR76wT9l=n7(m}N=lP*?;22)p~>g%IhC04!IUx7Q>d#NdM@ z`1v@em(HTC0~%Q$gG3??NPV*>3BEI$0-yJAWWqMzmq~BGPVr>@zgFs73M7o2j5!#S z-K_L!#l4U~cfcuiumh`IC69sJ%N!$s_Nrz)zxB3qI~1*yVkEE$5Tv=cP#XI3Ro?VD z58ewf4nURw6)JC<2QNx_#|bHKLxif>D(@biCbJH`ErspJaa}opTOeIY02yZBhNTNL zMCcI!8~fe7&RM5g;bpDcP8Q)_MYwmRbR|kCfx18dCQYWzN0NXb_dus1QPShQ2G?TW zlO+4PAOQ&EK}eX7W%}Xiu$i*6aHj$}1A6{w?5t7nn|CJ=c)qC(;W5k(wFm%Eu9XgM zTgMfF>_N}`A);;8sPP#EI0&pysvApj4eR5SK8zOhti#d*Z_26?A2P?kbvYa5e|>JW zd}SI4Fyk(e;BNUUNO0x5w0q@mrr(6zWV&9<&))!WMctSidR(pCK(29Y4TmY5DLn`< zmEaoAqh(aI_vZgMFvKMZ320j%-~SJsiN^6v2hVTDcTlNiUY35f1d8vL??cjX6lXJ} zqyB!HTM_)(;mnN04NdTXNUFKbiVw*$6mEu$$_4oteR`erArYYU<1JEJn A?W3G zAewr9lRN}GD>?L}R>QA?t7(>^hLh6Te+mCdtu*%+NNv{zd{QK#3VwQQBSHwE<#xXm z E%86 z{nN{ROAs0O%}6x-v|V*b#5qcpe+lg&u4{mF>11+_$f8n6WYxJ1@;^T_M!xjs?Q#WV z&{vQUe;HEf3fNa>-7Wul+9cUsYL5+VM?%TDv^pf&eQtuE{|~)5pNf)Oaa>xE)VBB3 z>pGq7JvER%4e-eAa-s6DoUb^fPfO|I`a4K!`FT3J>Op{7IbU+ntu9B>npZamWG#O; zE >g_q?nf)|W0*!3|+CT-q z#b&%KM`8MrnC9(=33`?>0etIKW%;rS-(Zcj4VFm}68i|wvF6@7TmTt#2A_ 0>%U1h(O~$Bg2q(?;D`3O4NZLyQZF ?+$W>C~{!z6!qH%>HM}uik+cT~b^L4}$SQaS<-L677;mq(?4R9M%C} zL&IGf3Ahj!>-0>{`FXn$K|+0XxEJ#V0^dy6*W~vOM?=}AQx)-AdUJla`dv4b>B!g& z_@V7NYEuWVr8s<{Rr7%MzH({oESA!?Tsc&JNQWS>lZalO+Mb@Ca>qzu9^aQQt#rvw zsd9YNwUSqJOnRb_7f3$YIn^F1gtVcQVIoVfpEZ>)0I(*lAOuNL-*-t0n|OUCK6N2L zoGa}Bay!7?j&1IDW-RFKNfLjIugR4*zK(zw8VH~*q5`ZF#A9!9S5gVU2*4i>i4eff z*;1P5Dgo%(3F;u&4df$_c{BZPT9oF)v6bX>p{5olr223IT X?3y7si-zVR+za>D*P8b?5?~tyAm}-C zqzU-}{~r>IYwZt)TOhZ(o!9qc_1%a~9mr-w7nlb$g-T`C(f8!_Bk#$f>iu{fXJGm9 zIR<{~yrS2)PAe^2rYLRjUhw{v?Kx7~#KCKos`FW@?1~pjam!KZQ;$!{1A}HKxRGW< z!kCO98F=6GwFjjod=Xg)zzP7 E7G7~sv@Ga?Z5pCkk;F!98&Y`?fw z7M@%zI}12Yh;oT~oRuI0EeWu@=#cy`Pmh<+zIuy%9#P=4)9#edy>*v-{_VTvb8p`z z|I;hu(GV=smS|kI$Qp07fJZOXeKi#+Gt?CVGBEMH;?4yM_*7K)QSDRK74L&<94S zkpxC29 kU-0n^!nCwtKer(E37wJE}>+F^u#LV zVLPnH3J|n>9m!zZ1V7t;$dguGRhSXL zAE%yAf*7?<&)Z}7KP?9^;Bz)q5}vjlq=EJv=#UO*_U6t8XlHs}CdH0~`uS3Ftn!5X zuTM{uzkB&s`P^%_%V%G|Q$G9V-ST(S?v}rW{ZFq Bh|pj8vV z?+D<=<=faC5kl1Q-B8Dq7D-WA8a^muXF2b$+T;b#`IPF=>xoPIu3ANk~RRHW<)Rov=iVN0s!R699)y5fG7BQzCDFw*l)wOni2u~AcC4F z7NUR>AXPvD^nw6PN)6n;H6zfoB=Ba}kQ4y|;NMpO14tfFR;P#2JLAV>8&3{MNp%6D z0X%nx05o`Ux6jYR`hA bS(Y2R%V(THjg{h1>WETPl(4mLdX} zgffeRgj^?-r9mSBjk!f(bH4oTb2rQ1ym*Uz_LV#2v#$aCuiYts^ZH%#H*ef2|J!S~ z$TjagBq7MTP%k^IAPb=3jd=E0f6AZcwn}Jy$RC#n@pv!-FiGwT@k#3S`rcXZ@2&^X zS+5@qLpC-s?_r{j?;pnKp*hB2yH VP!!{xnLTenf=!;UdYe-;Ynq&MJ8H zB =O)<9Twfb~mnpb_cjyVIIT=Y -OlOYOp0@lc2QNX~@YY4KsX8vgO;4z#h5eR;^9=tJly=Ld-`NIcE z!h6J}xBvfH-g`&Onw{mHZ8Kh;KW3IKV9a9KUWS?F1V&^e%LpMMVGKee2^fqK9_+Ek zumBq*Ss)Y|MH?i9)ZOZ&j_OvY+uir}%{ixY&OJHjoKKyct4_W1{N8u(sye4z;PE&x zGqvus_E%qh^;Ok9&+~@8-@W&=jZTq-61X6M@B;ZWHn&%;f2iGNW$j}SOkOVE!>`%0 zk%=DvoOnGxcXDaOzT@t<+Uq{{M*Fruc#8z^Hv6_u%lb yNQQ zLQFi)`(nGJ*@a2v8f~_|LD!yg1s7-y(s$zXhsU}+q>6iNi-0TkhR?s({)dnJusndQ z|EJ$(f9*3s$Dff16b^mU$G+eG_49Y@`m)|diS~vBx8Plu6C&%C&a+nU8_?lh_|EaU zv3f(0o9A k2cchp5d&krTb19NrOGksY^Z?n0gEIb0B>(RR=Np`!fYxbfIE?v_xuQf?1Rq`VW zMtkdqPr8N2l0)vW-hv1}DGMJYzwShza9mHgDqIn+Iq4z%gxZNcy~eTL$Mr4@d4fsP zXNde&0W@Y0pm^NLUloCeG6(^(WJ``1k^rJEC#+iaI{whK8R@x`KU?Vdqe5Qxri#yo z{P7ysLE7;txn0HB+#`}>CAYdJ-v_I|ySrw?BVAV9C9EmyANN(rGtuk!L=Zu~5a51r z9wBYp&}gSk==1no?(<8J{TutsAO3!M09k)|fWL+aKK<6fXWptX=uP%F|KO)=#=k#1 zpAfQyRi_K`RMU%gz3+leD+i!(4NJeIkZD~a*_Obl(ML2hitXe7U`PKWYqq1`x#=5R zSC=wjlFzx;Blh*H4^(<~+~Sv6eIX~dOhl(nhww%TZ)w*0#u{w17@-dHaake3Yk72S z=hzJ!FEsJXo%m6Q1NoJ>A^?GwIG)*6OT`{*9lc z-35S^PXdq_7^UrBxGr1~;ZlFSV*DKXtSW5kp1)=-vnS+_>J!luVV(Gy#3FPNT=RBZ zgwNz3S38afMEdbPolmWU_qrf-A9;ZZ2>>CG8z9K(y*q`wL|hkuTX!dVkUipfkvCgk zCt;A^1yFO05TZ7RYZEHOi;yG8&oQLjvMq%u-M%d*0>H{wJpt#&Cgt8mm_3qTPk^~c zB)K`{;n6(YU|+fPupMeTY@I{;PhX;MpX4}yHZ9*Nz6a0ACvr}~Yg|7$O&HVX_xz(X z57;+-@b&gT-7Vt(!Q1UyKlu~(EuVU;@D}?Pg+t&jz5a!>kNfplZMrTI0=uG*PsOBs z&aH3Fdc`M$W|OnMHZj}d6VRvU`+X0!vyIQ%^z4X@E7={JVpdzXzDKvu%Ux7Bv$-?l zMrLH9)kkuv)ghh_i=TUSu<()CB7$vMer8||PSi;-GkPADe|cxvI!ABVw5