web-counter 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mikigo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.4
2
+ Name: web-counter
3
+ Version: 0.1.0
4
+ Summary: A self-hosted website visit counter with zero-config frontend integration
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: fastapi>=0.100.0
10
+ Requires-Dist: uvicorn[standard]
11
+ Requires-Dist: aiosqlite
12
+ Requires-Dist: bcrypt
13
+ Requires-Dist: python-multipart
14
+ Dynamic: license-file
15
+
16
+ # web-counter
17
+
18
+ 一个开源、可自托管的网站访问计数服务。一行命令部署,一行 `<script>` 接入,数据完全由你掌控。
19
+
20
+ ## 统计指标
21
+
22
+ | 指标 | 含义 |
23
+ |------|------|
24
+ | 今日访问量 (PV Today) | 今天所有页面的总访问次数 |
25
+ | 今日访客数 (UV Today) | 今天的独立访客数(IP + salt 哈希去重) |
26
+ | 总访问量 (PV Site) | 建站以来所有页面的累计访问次数 |
27
+ | 总访客数 (UV Site) | 建站以来的累计独立访客数 |
28
+ | 页面阅读量 (PV Page) | 当前页面的累计访问次数 |
29
+
30
+ ## 快速开始
31
+
32
+ ```bash
33
+ # 1. 安装
34
+ pip install web-counter
35
+
36
+ # 2. 设置 salt(必填,用于哈希访客 IP 保护隐私)
37
+ export COUNTER_SALT="$(openssl rand -hex 16)"
38
+
39
+ # 3. 启动
40
+ web-counter start
41
+
42
+ # 4. 创建管理员(用于访问统计看板)
43
+ web-counter createsuperuser
44
+
45
+ # 5. 将终端输出的代码粘贴到网页 </body> 前,完成接入
46
+ ```
47
+
48
+ ## 前端接入
49
+
50
+ 在网页 `</body>` 前添加以下代码:
51
+
52
+ ```html
53
+ <script async src="https://你的域名/counter.js"></script>
54
+
55
+ <!-- 显示计数(放在 display:none 容器中,JS 加载后自动显示) -->
56
+ <span style="display:none" class="counter-container" data-counter-style="card">
57
+ 本站访问次数 <span data-pv-site></span> 次 ·
58
+ 今日访问量 <span data-pv-today></span> 次 ·
59
+ 今日访客 <span data-uv-today></span> 人 ·
60
+ 总访客 <span data-uv-site></span> 人
61
+ </span>
62
+ ```
63
+
64
+ ### 数据属性一览
65
+
66
+ | 属性 | 含义 |
67
+ |------|------|
68
+ | `data-pv-today` | 今日访问量 |
69
+ | `data-uv-today` | 今日访客数 |
70
+ | `data-pv-site` | 总访问量 |
71
+ | `data-uv-site` | 总访客数 |
72
+ | `data-pv-page` | 当前页面阅读量 |
73
+ | `data-counter-style` | 展示风格:`default` / `badge` / `card` / `bordered` |
74
+ | `data-counter-api` | 手动指定 API 基地址(JS 独立部署时使用) |
75
+
76
+ ### SPA 支持
77
+
78
+ 对于 Vue/React 等单页应用,路由切换不会触发计数。可在路由切换后手动上报:
79
+
80
+ ```js
81
+ fetch('https://你的域名/api/visit', {
82
+ method: 'POST',
83
+ headers: { 'Content-Type': 'application/json' },
84
+ body: JSON.stringify({ path: window.location.pathname })
85
+ })
86
+ ```
87
+
88
+ ## CLI 命令
89
+
90
+ ```bash
91
+ web-counter start # 后台启动服务
92
+ web-counter restart # 重启服务
93
+ web-counter stop # 停止服务
94
+ web-counter createsuperuser # 创建管理员账号
95
+ web-counter export-js # 导出 counter.js 到 stdout
96
+ ```
97
+
98
+ ## 配置
99
+
100
+ 优先级:**CLI 参数 > 环境变量 > .env 文件**
101
+
102
+ | 参数 | 环境变量 | 默认值 | 说明 |
103
+ |------|------|--------|------|
104
+ | `--salt` | `COUNTER_SALT` | 无(必填) | IP 哈希盐值 |
105
+ | `--host` | `COUNTER_HOST` | `0.0.0.0` | 监听地址 |
106
+ | `--port` | `COUNTER_PORT` | `8000` | 监听端口 |
107
+ | `--db-path` | `COUNTER_DB_PATH` | `./data/counter.db` | 数据库路径 |
108
+ | `--pid-file` | `COUNTER_PID_FILE` | `./data/counter.pid` | PID 文件路径 |
109
+ | `--allowed-origins` | `COUNTER_ALLOWED_ORIGINS` | `*` | CORS 允许域名 |
110
+ | `--rate-limit` | `COUNTER_RATE_LIMIT` | `60` | 每 IP 每分钟限流 |
111
+
112
+ ## API 接口
113
+
114
+ | 方法 | 路径 | 说明 |
115
+ |------|------|------|
116
+ | POST | `/api/visit` | 记录访问 |
117
+ | GET | `/api/count?paths=/page1,/page2` | 批量查询计数 |
118
+ | GET | `/counter.js` | 获取前端 JS 脚本 |
119
+ | GET | `/api/health` | 健康检查 |
120
+ | GET | `/dashboard` | 统计看板(需登录) |
121
+ | POST | `/api/admin/login` | 管理员登录(JSON) |
122
+ | POST | `/api/admin/logout` | 退出登录 |
123
+ | POST | `/api/admin/reset` | 重置数据(需登录) |
124
+ | GET/POST | `/api/admin/offset` | 查看/设置起始值(需登录) |
125
+
126
+ ## 生产环境部署
127
+
128
+ ### 使用反代 (Caddy)
129
+
130
+ ```
131
+ your-domain.com {
132
+ reverse_proxy localhost:8000
133
+ }
134
+ ```
135
+
136
+ ### 使用 systemd
137
+
138
+ ```ini
139
+ [Unit]
140
+ Description=web-counter
141
+ After=network.target
142
+
143
+ [Service]
144
+ Type=forking
145
+ PIDFile=/opt/web-counter/data/counter.pid
146
+ Environment="COUNTER_SALT=your-random-salt"
147
+ ExecStart=/usr/bin/web-counter start
148
+ ExecStop=/usr/bin/web-counter stop
149
+ ExecReload=/usr/bin/web-counter restart
150
+ Restart=on-failure
151
+
152
+ [Install]
153
+ WantedBy=multi-user.target
154
+ ```
155
+
156
+ ### 使用 Docker
157
+
158
+ ```bash
159
+ docker compose up -d
160
+ ```
161
+
162
+ ## 数据管理
163
+
164
+ 后台登录 `/dashboard` 后可进行:
165
+
166
+ - **数据重置**:支持重置今日/全部/指定页面的访问数据
167
+ - **起始值设置**:为累计指标设置偏移量,适用于从其他统计工具迁移
168
+
169
+ ## 隐私设计
170
+
171
+ - 不存储原始 IP 地址
172
+ - 不设置 Cookie(后台 session cookie 除外)
173
+ - 不做浏览器指纹
174
+ - 访客唯一性通过 `SHA256(IP + salt)` 标识
175
+ - salt 由部署者自行设置,无法被外部反向破解
176
+
177
+ > **注意**:salt 设定后不要更换,否则历史访客标识失联,UV 统计将清零重来。
178
+
179
+ ## License
180
+
181
+ MIT
@@ -0,0 +1,166 @@
1
+ # web-counter
2
+
3
+ 一个开源、可自托管的网站访问计数服务。一行命令部署,一行 `<script>` 接入,数据完全由你掌控。
4
+
5
+ ## 统计指标
6
+
7
+ | 指标 | 含义 |
8
+ |------|------|
9
+ | 今日访问量 (PV Today) | 今天所有页面的总访问次数 |
10
+ | 今日访客数 (UV Today) | 今天的独立访客数(IP + salt 哈希去重) |
11
+ | 总访问量 (PV Site) | 建站以来所有页面的累计访问次数 |
12
+ | 总访客数 (UV Site) | 建站以来的累计独立访客数 |
13
+ | 页面阅读量 (PV Page) | 当前页面的累计访问次数 |
14
+
15
+ ## 快速开始
16
+
17
+ ```bash
18
+ # 1. 安装
19
+ pip install web-counter
20
+
21
+ # 2. 设置 salt(必填,用于哈希访客 IP 保护隐私)
22
+ export COUNTER_SALT="$(openssl rand -hex 16)"
23
+
24
+ # 3. 启动
25
+ web-counter start
26
+
27
+ # 4. 创建管理员(用于访问统计看板)
28
+ web-counter createsuperuser
29
+
30
+ # 5. 将终端输出的代码粘贴到网页 </body> 前,完成接入
31
+ ```
32
+
33
+ ## 前端接入
34
+
35
+ 在网页 `</body>` 前添加以下代码:
36
+
37
+ ```html
38
+ <script async src="https://你的域名/counter.js"></script>
39
+
40
+ <!-- 显示计数(放在 display:none 容器中,JS 加载后自动显示) -->
41
+ <span style="display:none" class="counter-container" data-counter-style="card">
42
+ 本站访问次数 <span data-pv-site></span> 次 ·
43
+ 今日访问量 <span data-pv-today></span> 次 ·
44
+ 今日访客 <span data-uv-today></span> 人 ·
45
+ 总访客 <span data-uv-site></span> 人
46
+ </span>
47
+ ```
48
+
49
+ ### 数据属性一览
50
+
51
+ | 属性 | 含义 |
52
+ |------|------|
53
+ | `data-pv-today` | 今日访问量 |
54
+ | `data-uv-today` | 今日访客数 |
55
+ | `data-pv-site` | 总访问量 |
56
+ | `data-uv-site` | 总访客数 |
57
+ | `data-pv-page` | 当前页面阅读量 |
58
+ | `data-counter-style` | 展示风格:`default` / `badge` / `card` / `bordered` |
59
+ | `data-counter-api` | 手动指定 API 基地址(JS 独立部署时使用) |
60
+
61
+ ### SPA 支持
62
+
63
+ 对于 Vue/React 等单页应用,路由切换不会触发计数。可在路由切换后手动上报:
64
+
65
+ ```js
66
+ fetch('https://你的域名/api/visit', {
67
+ method: 'POST',
68
+ headers: { 'Content-Type': 'application/json' },
69
+ body: JSON.stringify({ path: window.location.pathname })
70
+ })
71
+ ```
72
+
73
+ ## CLI 命令
74
+
75
+ ```bash
76
+ web-counter start # 后台启动服务
77
+ web-counter restart # 重启服务
78
+ web-counter stop # 停止服务
79
+ web-counter createsuperuser # 创建管理员账号
80
+ web-counter export-js # 导出 counter.js 到 stdout
81
+ ```
82
+
83
+ ## 配置
84
+
85
+ 优先级:**CLI 参数 > 环境变量 > .env 文件**
86
+
87
+ | 参数 | 环境变量 | 默认值 | 说明 |
88
+ |------|------|--------|------|
89
+ | `--salt` | `COUNTER_SALT` | 无(必填) | IP 哈希盐值 |
90
+ | `--host` | `COUNTER_HOST` | `0.0.0.0` | 监听地址 |
91
+ | `--port` | `COUNTER_PORT` | `8000` | 监听端口 |
92
+ | `--db-path` | `COUNTER_DB_PATH` | `./data/counter.db` | 数据库路径 |
93
+ | `--pid-file` | `COUNTER_PID_FILE` | `./data/counter.pid` | PID 文件路径 |
94
+ | `--allowed-origins` | `COUNTER_ALLOWED_ORIGINS` | `*` | CORS 允许域名 |
95
+ | `--rate-limit` | `COUNTER_RATE_LIMIT` | `60` | 每 IP 每分钟限流 |
96
+
97
+ ## API 接口
98
+
99
+ | 方法 | 路径 | 说明 |
100
+ |------|------|------|
101
+ | POST | `/api/visit` | 记录访问 |
102
+ | GET | `/api/count?paths=/page1,/page2` | 批量查询计数 |
103
+ | GET | `/counter.js` | 获取前端 JS 脚本 |
104
+ | GET | `/api/health` | 健康检查 |
105
+ | GET | `/dashboard` | 统计看板(需登录) |
106
+ | POST | `/api/admin/login` | 管理员登录(JSON) |
107
+ | POST | `/api/admin/logout` | 退出登录 |
108
+ | POST | `/api/admin/reset` | 重置数据(需登录) |
109
+ | GET/POST | `/api/admin/offset` | 查看/设置起始值(需登录) |
110
+
111
+ ## 生产环境部署
112
+
113
+ ### 使用反代 (Caddy)
114
+
115
+ ```
116
+ your-domain.com {
117
+ reverse_proxy localhost:8000
118
+ }
119
+ ```
120
+
121
+ ### 使用 systemd
122
+
123
+ ```ini
124
+ [Unit]
125
+ Description=web-counter
126
+ After=network.target
127
+
128
+ [Service]
129
+ Type=forking
130
+ PIDFile=/opt/web-counter/data/counter.pid
131
+ Environment="COUNTER_SALT=your-random-salt"
132
+ ExecStart=/usr/bin/web-counter start
133
+ ExecStop=/usr/bin/web-counter stop
134
+ ExecReload=/usr/bin/web-counter restart
135
+ Restart=on-failure
136
+
137
+ [Install]
138
+ WantedBy=multi-user.target
139
+ ```
140
+
141
+ ### 使用 Docker
142
+
143
+ ```bash
144
+ docker compose up -d
145
+ ```
146
+
147
+ ## 数据管理
148
+
149
+ 后台登录 `/dashboard` 后可进行:
150
+
151
+ - **数据重置**:支持重置今日/全部/指定页面的访问数据
152
+ - **起始值设置**:为累计指标设置偏移量,适用于从其他统计工具迁移
153
+
154
+ ## 隐私设计
155
+
156
+ - 不存储原始 IP 地址
157
+ - 不设置 Cookie(后台 session cookie 除外)
158
+ - 不做浏览器指纹
159
+ - 访客唯一性通过 `SHA256(IP + salt)` 标识
160
+ - salt 由部署者自行设置,无法被外部反向破解
161
+
162
+ > **注意**:salt 设定后不要更换,否则历史访客标识失联,UV 统计将清零重来。
163
+
164
+ ## License
165
+
166
+ MIT
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "web-counter"
7
+ version = "0.1.0"
8
+ description = "A self-hosted website visit counter with zero-config frontend integration"
9
+ license = "MIT"
10
+ readme = "README.md"
11
+ requires-python = ">=3.9"
12
+ dependencies = [
13
+ "fastapi>=0.100.0",
14
+ "uvicorn[standard]",
15
+ "aiosqlite",
16
+ "bcrypt",
17
+ "python-multipart",
18
+ ]
19
+
20
+ [project.scripts]
21
+ web-counter = "web_counter.cli:main"
22
+
23
+ [tool.setuptools.packages.find]
24
+ include = ["web_counter*"]
25
+
26
+ [tool.setuptools.package-data]
27
+ web_counter = ["static/*.js", "templates/*.html"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ """web-counter: A self-hosted website visit counter."""
@@ -0,0 +1,44 @@
1
+ """Authentication: bcrypt password hashing and session management."""
2
+
3
+ import secrets
4
+ import time
5
+
6
+ import bcrypt
7
+
8
+ # In-memory session store: token -> {"username": str, "created_at": float}
9
+ _sessions: dict[str, dict] = {}
10
+
11
+ SESSION_MAX_AGE = 86400 # 24 hours
12
+
13
+
14
+ def hash_password(password: str) -> str:
15
+ """Hash a password with bcrypt."""
16
+ return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
17
+
18
+
19
+ def verify_password(password: str, password_hash: str) -> bool:
20
+ """Verify a password against its bcrypt hash."""
21
+ return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
22
+
23
+
24
+ def create_session(username: str) -> str:
25
+ """Create a new session token for the given username."""
26
+ token = secrets.token_hex(32)
27
+ _sessions[token] = {"username": username, "created_at": time.time()}
28
+ return token
29
+
30
+
31
+ def validate_session(token: str) -> str | None:
32
+ """Validate a session token. Returns username if valid, None otherwise."""
33
+ if token not in _sessions:
34
+ return None
35
+ session = _sessions[token]
36
+ if time.time() - session["created_at"] > SESSION_MAX_AGE:
37
+ del _sessions[token]
38
+ return None
39
+ return session["username"]
40
+
41
+
42
+ def destroy_session(token: str):
43
+ """Destroy a session token."""
44
+ _sessions.pop(token, None)
@@ -0,0 +1,249 @@
1
+ """CLI: start, restart, stop, createsuperuser, export-js."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+
12
+
13
+ def _read_pid(pid_file: str) -> int | None:
14
+ """Read PID from file."""
15
+ try:
16
+ with open(pid_file, "r") as f:
17
+ return int(f.read().strip())
18
+ except (FileNotFoundError, ValueError):
19
+ return None
20
+
21
+
22
+ def _is_running(pid: int | None) -> bool:
23
+ """Check if a process with given PID is running."""
24
+ if pid is None:
25
+ return False
26
+ try:
27
+ os.kill(pid, 0)
28
+ return True
29
+ except OSError:
30
+ return False
31
+
32
+
33
+ def start_command(args):
34
+ """Start the web-counter server in the background."""
35
+ from .config import Config
36
+
37
+ config = Config(
38
+ salt=args.salt,
39
+ host=args.host,
40
+ port=args.port,
41
+ db_path=args.db_path,
42
+ pid_file=args.pid_file,
43
+ allowed_origins=args.allowed_origins,
44
+ rate_limit=args.rate_limit,
45
+ )
46
+ config.validate()
47
+
48
+ # Check if already running
49
+ pid = _read_pid(config.pid_file)
50
+ if _is_running(pid):
51
+ print(f"✗ web-counter 已在运行中 (PID: {pid})")
52
+ sys.exit(1)
53
+
54
+ # Ensure data directory exists
55
+ Path(config.db_path).parent.mkdir(parents=True, exist_ok=True)
56
+ Path(config.pid_file).parent.mkdir(parents=True, exist_ok=True)
57
+
58
+ # Build command
59
+ cmd = [
60
+ sys.executable, "-m", "uvicorn", "web_counter.main:app",
61
+ "--host", config.host,
62
+ "--port", str(config.port),
63
+ "--log-level", "info",
64
+ ]
65
+
66
+ # Set environment variables for the subprocess
67
+ env = os.environ.copy()
68
+ env["COUNTER_SALT"] = config.salt
69
+ env["COUNTER_HOST"] = config.host
70
+ env["COUNTER_PORT"] = str(config.port)
71
+ env["COUNTER_DB_PATH"] = config.db_path
72
+ env["COUNTER_PID_FILE"] = config.pid_file
73
+ env["COUNTER_ALLOWED_ORIGINS"] = config.allowed_origins
74
+ env["COUNTER_RATE_LIMIT"] = str(config.rate_limit)
75
+
76
+ # Start process (background)
77
+ proc = subprocess.Popen(
78
+ cmd,
79
+ env=env,
80
+ stdout=subprocess.DEVNULL,
81
+ stderr=subprocess.DEVNULL,
82
+ creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
83
+ )
84
+
85
+ # Write PID file
86
+ with open(config.pid_file, "w") as f:
87
+ f.write(str(proc.pid))
88
+
89
+ origin = f"http://{config.host}:{config.port}"
90
+ print(f"✓ web-counter 已启动,监听 {origin}")
91
+ print()
92
+ print("---------- 复制以下代码到网页 </body> 前 ----------")
93
+ print()
94
+ print(f'<script async src="{origin}/counter.js"></script>')
95
+ print()
96
+ print('<span style="display:none" class="counter-container" data-counter-style="card">')
97
+ print(" 本站访问次数 <span data-pv-site></span> 次 ·")
98
+ print(" 今日访问量 <span data-pv-today></span> 次 ·")
99
+ print(" 今日访客 <span data-uv-today></span> 人 ·")
100
+ print(" 总访问量 <span data-pv-site></span> 次 ·")
101
+ print(" 总访客 <span data-uv-site></span> 人")
102
+ print("</span>")
103
+ print()
104
+ print("------------------------------------------------------")
105
+
106
+
107
+ def stop_command(args):
108
+ """Stop the running web-counter server."""
109
+ from .config import Config
110
+
111
+ config = Config(pid_file=args.pid_file)
112
+
113
+ pid = _read_pid(config.pid_file)
114
+ if not _is_running(pid):
115
+ print("✗ web-counter 未在运行")
116
+ if Path(config.pid_file).exists():
117
+ Path(config.pid_file).unlink()
118
+ sys.exit(1)
119
+
120
+ # Send SIGTERM for graceful shutdown
121
+ os.kill(pid, signal.SIGTERM)
122
+
123
+ # Wait for process to exit (max 10 seconds)
124
+ for _ in range(100):
125
+ if not _is_running(pid):
126
+ break
127
+ time.sleep(0.1)
128
+
129
+ if _is_running(pid):
130
+ print(f"✗ 无法停止进程 (PID: {pid}),尝试强制终止...")
131
+ try:
132
+ os.kill(pid, signal.SIGKILL)
133
+ except OSError:
134
+ pass
135
+ time.sleep(0.5)
136
+
137
+ # Clean up PID file
138
+ if Path(config.pid_file).exists():
139
+ Path(config.pid_file).unlink()
140
+
141
+ print("✓ web-counter 已停止")
142
+
143
+
144
+ def restart_command(args):
145
+ """Restart the web-counter server."""
146
+ print("正在重启 web-counter...")
147
+ stop_command(args)
148
+ start_command(args)
149
+
150
+
151
+ def createsuperuser_command(args):
152
+ """Create an admin user interactively."""
153
+ from .config import Config
154
+ from .database import init_db, create_admin
155
+ from .auth import hash_password
156
+
157
+ config = Config(
158
+ salt="dummy",
159
+ db_path=args.db_path,
160
+ )
161
+
162
+ username = input("Username: ").strip()
163
+ if not username:
164
+ print("✗ 用户名不能为空")
165
+ sys.exit(1)
166
+
167
+ password = input("Password: ").strip()
168
+ if not password:
169
+ print("✗ 密码不能为空")
170
+ sys.exit(1)
171
+
172
+ confirm = input("Confirm password: ").strip()
173
+ if password != confirm:
174
+ print("✗ 两次输入的密码不一致")
175
+ sys.exit(1)
176
+
177
+ async def _create():
178
+ await init_db(config.db_path)
179
+ password_hash = hash_password(password)
180
+ await create_admin(config.db_path, username, password_hash)
181
+
182
+ asyncio.run(_create())
183
+ print(f"✓ 管理员 {username} 创建成功")
184
+
185
+
186
+ def export_js_command(args):
187
+ """Export counter.js to stdout."""
188
+ js_path = Path(__file__).parent / "static" / "counter.js"
189
+ print(js_path.read_text(encoding="utf-8"))
190
+
191
+
192
+ def main():
193
+ """CLI entry point."""
194
+ parser = argparse.ArgumentParser(
195
+ prog="web-counter",
196
+ description="A self-hosted website visit counter",
197
+ )
198
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
199
+
200
+ # start
201
+ p = subparsers.add_parser("start", help="Start the server in background")
202
+ p.add_argument("--salt", default=None, help="IP hash salt (required)")
203
+ p.add_argument("--host", default=None, help="Listen address (default: 0.0.0.0)")
204
+ p.add_argument("--port", type=int, default=None, help="Listen port (default: 8000)")
205
+ p.add_argument("--db-path", default=None, help="SQLite database path")
206
+ p.add_argument("--pid-file", default=None, help="PID file path")
207
+ p.add_argument("--allowed-origins", default=None, help="CORS allowed origins")
208
+ p.add_argument("--rate-limit", type=int, default=None, help="Rate limit per IP per minute")
209
+
210
+ # restart
211
+ p = subparsers.add_parser("restart", help="Restart the server")
212
+ p.add_argument("--salt", default=None, help="IP hash salt")
213
+ p.add_argument("--host", default=None, help="Listen address")
214
+ p.add_argument("--port", type=int, default=None, help="Listen port")
215
+ p.add_argument("--db-path", default=None, help="SQLite database path")
216
+ p.add_argument("--pid-file", default=None, help="PID file path")
217
+ p.add_argument("--allowed-origins", default=None, help="CORS allowed origins")
218
+ p.add_argument("--rate-limit", type=int, default=None, help="Rate limit per IP per minute")
219
+
220
+ # stop
221
+ p = subparsers.add_parser("stop", help="Stop the server")
222
+ p.add_argument("--pid-file", default=None, help="PID file path")
223
+
224
+ # createsuperuser
225
+ p = subparsers.add_parser("createsuperuser", help="Create an admin user")
226
+ p.add_argument("--db-path", default=None, help="SQLite database path")
227
+
228
+ # export-js
229
+ subparsers.add_parser("export-js", help="Export counter.js to stdout")
230
+
231
+ args = parser.parse_args()
232
+
233
+ if args.command == "start":
234
+ start_command(args)
235
+ elif args.command == "restart":
236
+ restart_command(args)
237
+ elif args.command == "stop":
238
+ stop_command(args)
239
+ elif args.command == "createsuperuser":
240
+ createsuperuser_command(args)
241
+ elif args.command == "export-js":
242
+ export_js_command(args)
243
+ else:
244
+ parser.print_help()
245
+ sys.exit(1)
246
+
247
+
248
+ if __name__ == "__main__":
249
+ main()