fix:优化代码
This commit is contained in:
13
README.md
13
README.md
@@ -20,10 +20,13 @@
|
||||
├── package.json # 根目录:start / build / preview
|
||||
├── server/ # 后端(可单独部署)
|
||||
│ ├── server.js # Express API 路由
|
||||
│ ├── db.js # SQLite(统计、留言、settings 弹幕开关)
|
||||
│ ├── db.js # JSON 存储(统计、留言、弹幕开关)
|
||||
│ ├── index.js # 单独启动 API:node index.js
|
||||
│ ├── package.json # 后端依赖
|
||||
│ └── data/ # pano.db(自动创建)
|
||||
│ └── data/ # store.json(自动创建)
|
||||
├── scripts/ # 720 云资源提取脚本(Python)
|
||||
│ ├── fetch_720yun.py # 按 URL 抓取并下载全景图到 panorama/
|
||||
│ └── parse_720yun_doc.py # 从 text.md 解析资源,--download 下载到 image/
|
||||
├── docs/部署说明.md # 前后端分离部署与弹幕配置
|
||||
└── README.md
|
||||
```
|
||||
@@ -36,7 +39,7 @@
|
||||
|
||||
```bash
|
||||
cd 720yun-offline
|
||||
python3 fetch_720yun.py "https://www.720yun.com/vr/c8525usOunr"
|
||||
python3 scripts/fetch_720yun.py "https://www.720yun.com/vr/c8525usOunr"
|
||||
```
|
||||
|
||||
若输出「未在页面 HTML 中发现全景图 URL」,说明该页由前端 JS 请求接口加载数据,请用方式 B。
|
||||
@@ -58,7 +61,7 @@ python3 fetch_720yun.py "https://www.720yun.com/vr/c8525usOunr"
|
||||
|
||||
## 二、Node 构建与部署(推荐)
|
||||
|
||||
项目已用 Node 方式组织,**默认使用 `image/` 下六面图**(由 `parse_720yun_doc.py --download` 拉取),打开页面即自动加载。
|
||||
项目已用 Node 方式组织,**默认使用 `image/` 下六面图**(由 `scripts/parse_720yun_doc.py --download` 拉取),打开页面即自动加载。
|
||||
|
||||
### 1. 安装与运行(开发/本地)
|
||||
|
||||
@@ -69,7 +72,7 @@ npm start
|
||||
```
|
||||
|
||||
浏览器访问 **http://localhost:3000**,会自动加载 `config.json` 中的立方体六面图(`image/mobile_*.jpg`)。
|
||||
服务端会创建 **`data/pano.db`**(SQLite),用于存储:**累积播放、实时在看、点赞数、分享数、留言(弹幕)**。留言以弹幕形式在画面上方滚动播放。
|
||||
服务端会创建 **`server/data/store.json`**(纯 JSON 存储,无原生依赖),用于存储:**累积播放、实时在看、点赞数、分享数、留言(弹幕)**。留言以弹幕形式在画面上方滚动播放。
|
||||
|
||||
### 2. 构建出站目录(部署用)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
- **解决办法**:脚本里请求 720static.com 的 URL 时,必须加上与浏览器一致的请求头,例如:
|
||||
- `Referer: https://www.720yun.com/`
|
||||
- `User-Agent: Mozilla/5.0 (Macintosh; ...) Chrome/120.0.0.0 Safari/537.36`
|
||||
- 本仓库已在 `fetch_720yun.py` 的 `download_file` 和 `parse_720yun_doc.py` 的下载逻辑里使用上述头,用 `--download` 拉取时与浏览器行为一致。
|
||||
- 本仓库已在 `scripts/fetch_720yun.py` 的 `download_file` 和 `scripts/parse_720yun_doc.py` 的下载逻辑里使用上述头,用 `--download` 拉取时与浏览器行为一致。
|
||||
|
||||
---
|
||||
|
||||
@@ -45,5 +45,5 @@
|
||||
## 三、在本项目里怎么用(含底面)
|
||||
|
||||
- 解析出的 6 面 URL 已使用正确 CDN(ssl-panoimg130.720static.com),且含底面 mobile_d.jpg。
|
||||
- 在项目根目录执行 **`python3 parse_720yun_doc.py --download`**,会用与浏览器一致的 Referer/User-Agent 把 6 面 + 缩略图下载到 `image/`,再在页面上选「选择六面体(6张)」按顺序选 image 下 6 张即可。
|
||||
- 在项目根目录执行 **`python3 scripts/parse_720yun_doc.py --download`**,会用与浏览器一致的 Referer/User-Agent 把 6 面 + 缩略图下载到 `image/`,再在页面上选「选择六面体(6张)」按顺序选 image 下 6 张即可。
|
||||
- 若仍只有 5 张(没有 d),可用一张纯黑或占位图作为第 6 张,或在 config 的 cubemap 里第 6 个用占位图路径。
|
||||
|
||||
24
docs/部署说明.md
24
docs/部署说明.md
@@ -45,7 +45,7 @@
|
||||
npm start
|
||||
```
|
||||
默认端口 3000,可通过环境变量 `PORT` 修改。
|
||||
数据库文件为 `server/data/pano.db`(首次运行自动创建)。
|
||||
数据文件为 `server/data/store.json`(首次运行自动创建,纯 JSON,无原生依赖)。
|
||||
|
||||
2. **生产环境建议**:
|
||||
- 使用 pm2、systemd 等保活。
|
||||
@@ -65,26 +65,26 @@
|
||||
"danmakuPosition": "top"
|
||||
}
|
||||
```
|
||||
- 数据来源:SQLite 表 `settings`(在 `server/data/pano.db` 所在库):
|
||||
- `danmaku_enabled`:`1` 开启弹幕,`0` 关闭(默认)。
|
||||
- 数据来源:`server/data/store.json` 中的 `settings` 对象:
|
||||
- `danmaku_enabled`:`"1"` 开启弹幕,`"0"` 关闭(默认)。
|
||||
- `danmaku_position`:弹幕区域位置,目前仅使用 `top`(顶部)。
|
||||
|
||||
**开启弹幕**:在部署后端的机器上执行(或通过自建管理接口写入):
|
||||
**开启弹幕**:在部署后端的机器上任选一种方式:
|
||||
|
||||
1. 直接编辑 `server/data/store.json`,将 `settings.danmaku_enabled` 改为 `"1"`。
|
||||
2. 或执行(需在 `server` 目录下):
|
||||
```bash
|
||||
cd server
|
||||
node -e "
|
||||
const db = require('better-sqlite3')('data/pano.db');
|
||||
db.prepare(\"INSERT OR REPLACE INTO settings (key, value) VALUES ('danmaku_enabled', '1')\").run();
|
||||
db.close();
|
||||
const fs=require('fs'),path=require('path');
|
||||
const p=path.join(process.cwd(),'data','store.json');
|
||||
const s=JSON.parse(fs.readFileSync(p,'utf8'));
|
||||
s.settings.danmaku_enabled='1';
|
||||
fs.writeFileSync(p,JSON.stringify(s,null,0));
|
||||
console.log('已开启弹幕');
|
||||
"
|
||||
```
|
||||
|
||||
或使用 SQLite 客户端执行:
|
||||
```sql
|
||||
INSERT OR REPLACE INTO settings (key, value) VALUES ('danmaku_enabled', '1');
|
||||
```
|
||||
|
||||
前端会周期性/首次请求 `/api/config`,收到 `danmakuEnabled: true` 后显示顶部弹幕并拉取留言。
|
||||
|
||||
---
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"node": ">=14"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"express": "^4.21.0"
|
||||
}
|
||||
}
|
||||
|
||||
26
scripts/README.md
Normal file
26
scripts/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 720 云资源提取脚本
|
||||
|
||||
本目录为 Python 脚本,用于从 720yun 页面或保存的 HTML 中提取并下载全景图资源。**请在项目根目录下执行**,脚本会自动读写根目录下的 `text.md`、`image/`、`panorama/`、`config.json` 等。
|
||||
|
||||
## 脚本说明
|
||||
|
||||
| 脚本 | 用途 |
|
||||
|------|------|
|
||||
| **fetch_720yun.py** | 根据 720yun 页面 URL 抓取 HTML,解析其中的全景图 URL 并下载到 `panorama/panorama.jpg`,同时更新根目录 `config.json`。适用于页面内直接包含图片链接的情况。 |
|
||||
| **parse_720yun_doc.py** | 从项目根目录的 `text.md`(720yun 页面另存为的文档)解析 `window.data` / `window.json`,得到六面图、缩略图等 URL;可选 `--fetch` 请求场景 JSON,`--download` 将六面图 + 缩略图下载到根目录 `image/`。 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
```bash
|
||||
# 在项目根目录 720yun-offline/ 下执行
|
||||
|
||||
# 方式一:按 URL 抓取(若页面由 JS 动态加载可能无结果)
|
||||
python3 scripts/fetch_720yun.py "https://www.720yun.com/vr/xxxxx"
|
||||
|
||||
# 方式二:先浏览器打开 720 链接,整页另存为 text.md 放到项目根目录,再解析并下载六面图
|
||||
python3 scripts/parse_720yun_doc.py # 仅解析,输出 parsed_720yun_resources.json
|
||||
python3 scripts/parse_720yun_doc.py --fetch # 解析并请求场景 JSON
|
||||
python3 scripts/parse_720yun_doc.py --download # 解析并将六面图、缩略图下载到 image/
|
||||
```
|
||||
|
||||
下载到 `image/` 的文件可直接被前端使用(`config.json` 中已配置 `image/mobile_*.jpg`)。
|
||||
@@ -1,8 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从 720yun 页面抓取全景资源并本地化。
|
||||
用法: python3 fetch_720yun.py "https://www.720yun.com/vr/c8525usOunr"
|
||||
脚本位于 scripts/,输出到项目根目录的 panorama/、config.json。
|
||||
若页面由 JS 动态加载,请使用「手动获取」方式(见 README)。
|
||||
|
||||
用法(在项目根目录执行):
|
||||
python3 scripts/fetch_720yun.py "https://www.720yun.com/vr/c8525usOunr"
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
@@ -11,6 +14,10 @@ import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
# 项目根目录
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def fetch_html(url):
|
||||
req = urllib.request.Request(url, headers={
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
||||
@@ -18,8 +25,8 @@ def fetch_html(url):
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return r.read().decode('utf-8', errors='replace')
|
||||
|
||||
|
||||
def _extract_balanced_json(html, start_marker, open_char='{', close_char='}'):
|
||||
"""从 html 中查找 start_marker 后紧跟的完整 JSON(匹配括号)。"""
|
||||
results = []
|
||||
i = 0
|
||||
if open_char == '{':
|
||||
@@ -31,7 +38,6 @@ def _extract_balanced_json(html, start_marker, open_char='{', close_char='}'):
|
||||
if pos < 0:
|
||||
break
|
||||
start = pos + len(start_marker)
|
||||
# 跳过空白与等号
|
||||
while start < len(html) and html[start] in ' \t\n=':
|
||||
start += 1
|
||||
if start >= len(html):
|
||||
@@ -114,7 +120,6 @@ def _extract_balanced_json(html, start_marker, open_char='{', close_char='}'):
|
||||
|
||||
|
||||
def find_json_assignments(html):
|
||||
"""查找页面中常见的 __INITIAL_STATE__、window.__DATA__ 等 JSON 赋值(支持嵌套)。"""
|
||||
markers = [
|
||||
'window.__INITIAL_STATE__',
|
||||
'__INITIAL_STATE__',
|
||||
@@ -124,20 +129,17 @@ def find_json_assignments(html):
|
||||
results = []
|
||||
for marker in markers:
|
||||
results.extend(_extract_balanced_json(html, marker, '{', '}'))
|
||||
# 也尝试匹配 "panorama":"url" 或 "scenes":[...] 的简单模式
|
||||
for m in re.finditer(r'"panorama"\s*:\s*"([^"]+)"', html):
|
||||
results.append(m.group(1))
|
||||
return results
|
||||
|
||||
|
||||
def find_image_urls(html):
|
||||
"""从 HTML 中提取可能是全景图的 URL(720yun CDN 等)。"""
|
||||
# 常见 720 云图片域名
|
||||
url_pattern = re.compile(
|
||||
r'https?://[^\s"\'<>]+?\.(?:720yun\.com|qpic\.cn|gtimg\.com)[^\s"\'<>]*\.(?:jpg|jpeg|png|webp)',
|
||||
re.I
|
||||
)
|
||||
urls = list(set(url_pattern.findall(html)))
|
||||
# 也匹配任意包含 panorama / scene / photo 的图片 URL
|
||||
alt_pattern = re.compile(
|
||||
r'https?://[^\s"\'<>]+?/(?:panorama|scene|photo|pano|vr)[^\s"\'<>]*\.(?:jpg|jpeg|png|webp)',
|
||||
re.I
|
||||
@@ -147,7 +149,7 @@ def find_image_urls(html):
|
||||
urls.append(u)
|
||||
return urls
|
||||
|
||||
# 720yun CDN 会校验 Referer,脚本请求需与浏览器一致才能拿到正确数据
|
||||
|
||||
def _browser_headers(url=''):
|
||||
h = {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
@@ -163,16 +165,16 @@ def download_file(url, dest_path):
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
dest_path.write_bytes(r.read())
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print('用法: python3 fetch_720yun.py <720yun页面URL>')
|
||||
print('例: python3 fetch_720yun.py "https://www.720yun.com/vr/c8525usOunr"')
|
||||
print('用法: python3 scripts/fetch_720yun.py <720yun页面URL>')
|
||||
print('例: python3 scripts/fetch_720yun.py "https://www.720yun.com/vr/c8525usOunr"')
|
||||
sys.exit(1)
|
||||
url = sys.argv[1].strip()
|
||||
base = Path(__file__).resolve().parent
|
||||
panorama_dir = base / 'panorama'
|
||||
panorama_dir = ROOT / 'panorama'
|
||||
panorama_dir.mkdir(exist_ok=True)
|
||||
config_path = base / 'config.json'
|
||||
config_path = ROOT / 'config.json'
|
||||
|
||||
print('正在请求页面...')
|
||||
try:
|
||||
@@ -185,7 +187,6 @@ def main():
|
||||
image_urls = find_image_urls(html)
|
||||
json_candidates = find_json_assignments(html)
|
||||
|
||||
# 尝试从 JSON 中解析 panorama 或 scenes
|
||||
for raw in json_candidates:
|
||||
try:
|
||||
if raw.startswith('http'):
|
||||
@@ -200,7 +201,7 @@ def main():
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
# 递归查找 url / panorama / image 字段
|
||||
|
||||
def collect_urls(obj, out):
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
@@ -219,11 +220,7 @@ def main():
|
||||
|
||||
if not image_urls:
|
||||
print('未在页面 HTML 中发现全景图 URL(页面可能由 JavaScript 动态加载)。')
|
||||
print('请按 README 使用浏览器开发者工具手动获取:')
|
||||
print(' 1. 打开该 720yun 链接')
|
||||
print(' 2. F12 -> Network -> 刷新 -> 筛选 Img 或 XHR')
|
||||
print(' 3. 找到全景图或 scene 接口返回的图片 URL,下载到 panorama/ 并命名为 panorama.jpg')
|
||||
print(' 4. 确保 config.json 中 panorama 为 "panorama/panorama.jpg"')
|
||||
print('请按 README 使用浏览器开发者工具手动获取。')
|
||||
sys.exit(0)
|
||||
|
||||
print('发现可能的全景图 URL:', len(image_urls))
|
||||
@@ -235,10 +232,8 @@ def main():
|
||||
print('已保存到:', local_path)
|
||||
except Exception as e:
|
||||
print('下载失败:', e)
|
||||
print('请手动将上面列出的任一 URL 在浏览器中打开并另存为 panorama/panorama.jpg')
|
||||
sys.exit(1)
|
||||
|
||||
# 确保 config 指向本地
|
||||
if config_path.exists():
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
@@ -249,7 +244,8 @@ def main():
|
||||
config['title'] = config.get('title', '本地全景')
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2)
|
||||
print('已更新 config.json。运行本地服务器后打开 index.html 即可离线查看。')
|
||||
print('已更新 config.json。运行 npm start 后即可离线查看。')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -2,10 +2,12 @@
|
||||
"""
|
||||
从 text.md(720yun 页面保存的文档)中解析 window.data / window.json,
|
||||
并解析出最终的全景图片资源 URL。
|
||||
脚本位于 scripts/,读写路径均相对于项目根目录。
|
||||
|
||||
用法:
|
||||
python3 parse_720yun_doc.py [text.md]
|
||||
python3 parse_720yun_doc.py --fetch # 并请求场景 JSON,解析出所有图片 URL
|
||||
用法(在项目根目录执行):
|
||||
python3 scripts/parse_720yun_doc.py [text.md]
|
||||
python3 scripts/parse_720yun_doc.py --fetch # 并请求场景 JSON
|
||||
python3 scripts/parse_720yun_doc.py --download # 下载六面图到 image/
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
@@ -13,6 +15,9 @@ import json
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
# 项目根目录(脚本所在目录的上一级)
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def read_doc(path):
|
||||
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
@@ -72,8 +77,6 @@ RESOURCE_CDN_HOST = 'ssl-panoimg130.720static.com'
|
||||
|
||||
def build_resource_base(thumb_url):
|
||||
"""从 thumbUrl 得到资源目录的 base URL(用于拼立方体等)。使用实际 CDN 域名以便脚本拉取与浏览器一致。"""
|
||||
# thumbUrl 可能是 "/resource/prod/4ca3fae5e7x/d22jkguytw6/59446768/imgs/thumb.jpg"
|
||||
# 全景图实际在 ssl-panoimg130.720static.com,用 resource-t 会拿不到或异常
|
||||
if thumb_url.startswith('http'):
|
||||
base = re.sub(r'^https?://[^/]+', 'https://' + RESOURCE_CDN_HOST, thumb_url)
|
||||
else:
|
||||
@@ -88,7 +91,6 @@ def infer_cube_urls(resource_base):
|
||||
return [resource_base + 'mobile_' + face + '.jpg' for face in faces]
|
||||
|
||||
|
||||
# 与浏览器一致的请求头,720yun CDN 校验 Referer,否则拿不到正确数据
|
||||
def _browser_headers(referer='https://www.720yun.com/'):
|
||||
return {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
@@ -104,7 +106,7 @@ def fetch_tour_json(json_path, base_url='https://www.720yun.com/'):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return json.loads(r.read().decode('utf-8', errors='replace'))
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@@ -141,7 +143,7 @@ def extract_image_urls_from_tour(tour_data, resource_base):
|
||||
|
||||
|
||||
def main():
|
||||
doc_path = Path(__file__).resolve().parent / 'text.md'
|
||||
doc_path = ROOT / 'text.md'
|
||||
if len(sys.argv) >= 2 and not sys.argv[1].startswith('-'):
|
||||
doc_path = Path(sys.argv[1])
|
||||
do_fetch = '--fetch' in sys.argv
|
||||
@@ -159,7 +161,6 @@ def main():
|
||||
print('未能从文档中解析出 window.data 或 window.json')
|
||||
sys.exit(1)
|
||||
|
||||
# 解析结果
|
||||
result = {
|
||||
'window_data': data,
|
||||
'window_json_path': json_path,
|
||||
@@ -193,13 +194,11 @@ def main():
|
||||
else:
|
||||
print('请求场景 JSON 失败:', result['tour_json_url'], file=sys.stderr)
|
||||
|
||||
# 输出:先写 JSON 汇总,再列最终图片列表
|
||||
out_path = Path(__file__).resolve().parent / 'parsed_720yun_resources.json'
|
||||
out_path = ROOT / 'parsed_720yun_resources.json'
|
||||
with open(out_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
print('已写入:', out_path)
|
||||
|
||||
# 最终图片资源列表(去重、合并)
|
||||
all_urls = []
|
||||
if result.get('thumb_url'):
|
||||
all_urls.append(('thumb', result['thumb_url']))
|
||||
@@ -215,7 +214,7 @@ def main():
|
||||
print('\n共', len(all_urls), '个 URL')
|
||||
|
||||
if do_download and result.get('resource_base'):
|
||||
out_dir = Path(__file__).resolve().parent / 'image'
|
||||
out_dir = ROOT / 'image'
|
||||
out_dir.mkdir(exist_ok=True)
|
||||
print('\n--- 使用浏览器头下载到 image/ ---')
|
||||
for face, url in [('thumb', result.get('thumb_url'))] + list(zip(['mobile_f', 'mobile_r', 'mobile_b', 'mobile_l', 'mobile_u', 'mobile_d'], result.get('inferred_cube_urls', []))):
|
||||
197
server/db.js
197
server/db.js
@@ -1,131 +1,122 @@
|
||||
/**
|
||||
* SQLite:统计、留言、后端配置(弹幕开关与位置)
|
||||
* 纯 JSON 文件存储,替代 SQLite。无 node-gyp / 原生依赖,任意环境可运行。
|
||||
*/
|
||||
const Database = require('better-sqlite3');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DB_PATH = path.join(__dirname, 'data', 'pano.db');
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
const STORE_PATH = path.join(DATA_DIR, 'store.json');
|
||||
|
||||
function getDb() {
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
return db;
|
||||
const DEFAULT_STORE = {
|
||||
stats: {
|
||||
view_count: 0,
|
||||
like_count: 0,
|
||||
share_count: 0,
|
||||
watching_now: 0,
|
||||
},
|
||||
viewers: {},
|
||||
comments: [],
|
||||
settings: {
|
||||
danmaku_enabled: '0',
|
||||
danmaku_position: 'top',
|
||||
},
|
||||
};
|
||||
|
||||
let _store = null;
|
||||
|
||||
function load() {
|
||||
if (_store) return _store;
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
if (fs.existsSync(STORE_PATH)) {
|
||||
try {
|
||||
_store = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
||||
if (!_store.stats) _store.stats = DEFAULT_STORE.stats;
|
||||
if (!_store.viewers) _store.viewers = {};
|
||||
if (!Array.isArray(_store.comments)) _store.comments = [];
|
||||
if (!_store.settings) _store.settings = DEFAULT_STORE.settings;
|
||||
return _store;
|
||||
} catch (e) {}
|
||||
}
|
||||
_store = JSON.parse(JSON.stringify(DEFAULT_STORE));
|
||||
save();
|
||||
return _store;
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!_store) return;
|
||||
fs.writeFileSync(STORE_PATH, JSON.stringify(_store, null, 0), 'utf8');
|
||||
}
|
||||
|
||||
function initDb() {
|
||||
const fs = require('fs');
|
||||
const dir = path.join(__dirname, 'data');
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const db = getDb();
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS stats (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
view_count INTEGER NOT NULL DEFAULT 0,
|
||||
like_count INTEGER NOT NULL DEFAULT 0,
|
||||
share_count INTEGER NOT NULL DEFAULT 0,
|
||||
watching_now INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT OR IGNORE INTO stats (id, view_count, like_count, share_count, watching_now) VALUES (1, 0, 0, 0, 0);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS viewers (
|
||||
viewer_id TEXT PRIMARY KEY,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content TEXT NOT NULL,
|
||||
nickname TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('danmaku_enabled', '0');
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('danmaku_position', 'top');
|
||||
`);
|
||||
db.close();
|
||||
load();
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
const db = getDb();
|
||||
const rows = db.prepare('SELECT key, value FROM settings').all();
|
||||
db.close();
|
||||
const map = {};
|
||||
rows.forEach((r) => { map[r.key] = r.value; });
|
||||
const s = load().settings;
|
||||
return {
|
||||
danmakuEnabled: map.danmaku_enabled === '1',
|
||||
danmakuPosition: (map.danmaku_position || 'top').toLowerCase(),
|
||||
danmakuEnabled: s.danmaku_enabled === '1',
|
||||
danmakuPosition: (s.danmaku_position || 'top').toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
function getStats() {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT view_count, like_count, share_count, watching_now FROM stats WHERE id = 1').get();
|
||||
const commentRow = db.prepare('SELECT COUNT(*) as n FROM comments').get();
|
||||
db.close();
|
||||
const st = load().stats;
|
||||
const commentCount = load().comments.length;
|
||||
return {
|
||||
viewCount: row.view_count,
|
||||
commentCount: commentRow.n,
|
||||
likeCount: row.like_count,
|
||||
shareCount: row.share_count,
|
||||
watchingNow: row.watching_now,
|
||||
viewCount: st.view_count,
|
||||
commentCount,
|
||||
likeCount: st.like_count,
|
||||
shareCount: st.share_count,
|
||||
watchingNow: st.watching_now,
|
||||
};
|
||||
}
|
||||
|
||||
function incView() {
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE stats SET view_count = view_count + 1 WHERE id = 1').run();
|
||||
const out = getStats();
|
||||
db.close();
|
||||
return out;
|
||||
const s = load();
|
||||
s.stats.view_count += 1;
|
||||
save();
|
||||
return getStats();
|
||||
}
|
||||
|
||||
function incLike() {
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE stats SET like_count = like_count + 1 WHERE id = 1').run();
|
||||
const row = db.prepare('SELECT like_count FROM stats WHERE id = 1').get();
|
||||
db.close();
|
||||
return { likeCount: row.like_count };
|
||||
const s = load();
|
||||
s.stats.like_count += 1;
|
||||
save();
|
||||
return { likeCount: s.stats.like_count };
|
||||
}
|
||||
|
||||
function incShare() {
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE stats SET share_count = share_count + 1 WHERE id = 1').run();
|
||||
const row = db.prepare('SELECT share_count FROM stats WHERE id = 1').get();
|
||||
db.close();
|
||||
return { shareCount: row.share_count };
|
||||
const s = load();
|
||||
s.stats.share_count += 1;
|
||||
save();
|
||||
return { shareCount: s.stats.share_count };
|
||||
}
|
||||
|
||||
function joinViewer(viewerId) {
|
||||
const db = getDb();
|
||||
const s = load();
|
||||
const now = Date.now();
|
||||
db.prepare('INSERT OR REPLACE INTO viewers (viewer_id, updated_at) VALUES (?, ?)').run(viewerId, now);
|
||||
db.prepare('DELETE FROM viewers WHERE updated_at < ?').run(now - 120000);
|
||||
const row = db.prepare('SELECT COUNT(*) as n FROM viewers').get();
|
||||
db.prepare('UPDATE stats SET watching_now = ? WHERE id = 1').run(row.n);
|
||||
db.close();
|
||||
return row.n;
|
||||
s.viewers[viewerId] = now;
|
||||
const cutoff = now - 120000;
|
||||
Object.keys(s.viewers).forEach((id) => {
|
||||
if (s.viewers[id] < cutoff) delete s.viewers[id];
|
||||
});
|
||||
s.stats.watching_now = Object.keys(s.viewers).length;
|
||||
save();
|
||||
return s.stats.watching_now;
|
||||
}
|
||||
|
||||
function leaveViewer(viewerId) {
|
||||
const db = getDb();
|
||||
db.prepare('DELETE FROM viewers WHERE viewer_id = ?').run(viewerId);
|
||||
const row = db.prepare('SELECT COUNT(*) as n FROM viewers').get();
|
||||
db.prepare('UPDATE stats SET watching_now = ? WHERE id = 1').run(row.n);
|
||||
db.close();
|
||||
return row.n;
|
||||
const s = load();
|
||||
delete s.viewers[viewerId];
|
||||
s.stats.watching_now = Object.keys(s.viewers).length;
|
||||
save();
|
||||
return s.stats.watching_now;
|
||||
}
|
||||
|
||||
function getComments(limit = 100) {
|
||||
const db = getDb();
|
||||
const rows = db.prepare(
|
||||
'SELECT id, content, nickname, created_at FROM comments ORDER BY id DESC LIMIT ?'
|
||||
).all(limit);
|
||||
db.close();
|
||||
return rows.reverse().map((r) => ({
|
||||
const list = load().comments;
|
||||
const slice = list.slice(-Math.min(limit, list.length));
|
||||
return slice.map((r) => ({
|
||||
id: r.id,
|
||||
content: r.content,
|
||||
nickname: r.nickname || '游客',
|
||||
@@ -134,15 +125,23 @@ function getComments(limit = 100) {
|
||||
}
|
||||
|
||||
function addComment(content, nickname) {
|
||||
const db = getDb();
|
||||
const s = load();
|
||||
const now = Date.now();
|
||||
const r = db.prepare('INSERT INTO comments (content, nickname, created_at) VALUES (?, ?, ?)').run(
|
||||
String(content).trim().slice(0, 200) || '(空)',
|
||||
nickname ? String(nickname).trim().slice(0, 32) : null,
|
||||
now
|
||||
);
|
||||
db.close();
|
||||
return { id: r.lastInsertRowid, content: content.trim().slice(0, 200), nickname: nickname || '游客', createdAt: now };
|
||||
const id = s.comments.length ? Math.max(...s.comments.map((c) => c.id)) + 1 : 1;
|
||||
const row = {
|
||||
id,
|
||||
content: String(content).trim().slice(0, 200) || '(空)',
|
||||
nickname: nickname ? String(nickname).trim().slice(0, 32) : null,
|
||||
created_at: now,
|
||||
};
|
||||
s.comments.push(row);
|
||||
save();
|
||||
return {
|
||||
id: row.id,
|
||||
content: row.content,
|
||||
nickname: row.nickname || '游客',
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "720yun-offline-api",
|
||||
"version": "1.0.0",
|
||||
"description": "全景查看器后端 API,可单独部署",
|
||||
"description": "全景查看器后端 API,可单独部署(纯 JS,无原生依赖)",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
@@ -10,7 +10,6 @@
|
||||
"node": ">=14"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"express": "^4.21.0"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user