fix:优化代码

This commit is contained in:
Daniel
2026-03-07 23:35:08 +08:00
parent 382aa955ef
commit c3d219efc1
9 changed files with 179 additions and 158 deletions

View File

@@ -20,10 +20,13 @@
├── package.json # 根目录start / build / preview
├── server/ # 后端(可单独部署)
│ ├── server.js # Express API 路由
│ ├── db.js # SQLite统计、留言、settings 弹幕开关)
│ ├── db.js # JSON 存储(统计、留言、弹幕开关)
│ ├── index.js # 单独启动 APInode 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. 构建出站目录(部署用)

View File

@@ -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 已使用正确 CDNssl-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 个用占位图路径。

View File

@@ -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` 后显示顶部弹幕并拉取留言。
---

View File

@@ -11,7 +11,6 @@
"node": ">=14"
},
"dependencies": {
"better-sqlite3": "^11.6.0",
"express": "^4.21.0"
}
}

26
scripts/README.md Normal file
View 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`)。

View File

@@ -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 中提取可能是全景图的 URL720yun 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()

View File

@@ -2,10 +2,12 @@
"""
text.md720yun 页面保存的文档中解析 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', []))):

View File

@@ -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 = {

View File

@@ -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"
}
}