diff --git a/README.md b/README.md index 422e18c..833450e 100644 --- a/README.md +++ b/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. 构建出站目录(部署用) diff --git a/docs/720yun_资源与鉴权说明.md b/docs/720yun_资源与鉴权说明.md index 1d37356..be63c45 100644 --- a/docs/720yun_资源与鉴权说明.md +++ b/docs/720yun_资源与鉴权说明.md @@ -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 个用占位图路径。 diff --git a/docs/部署说明.md b/docs/部署说明.md index c24701f..21d1b92 100644 --- a/docs/部署说明.md +++ b/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` 后显示顶部弹幕并拉取留言。 --- diff --git a/package.json b/package.json index a94537f..f7eeae0 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "node": ">=14" }, "dependencies": { - "better-sqlite3": "^11.6.0", "express": "^4.21.0" } } diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..72fa70c --- /dev/null +++ b/scripts/README.md @@ -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`)。 diff --git a/fetch_720yun.py b/scripts/fetch_720yun.py similarity index 82% rename from fetch_720yun.py rename to scripts/fetch_720yun.py index a3d70c0..ce4d1e3 100644 --- a/fetch_720yun.py +++ b/scripts/fetch_720yun.py @@ -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() diff --git a/parse_720yun_doc.py b/scripts/parse_720yun_doc.py similarity index 90% rename from parse_720yun_doc.py rename to scripts/parse_720yun_doc.py index 29ec0eb..c6aee13 100644 --- a/parse_720yun_doc.py +++ b/scripts/parse_720yun_doc.py @@ -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', []))): diff --git a/server/db.js b/server/db.js index 80d9219..28ddf8c 100644 --- a/server/db.js +++ b/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 = { diff --git a/server/package.json b/server/package.json index 90dd226..daea9b4 100644 --- a/server/package.json +++ b/server/package.json @@ -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" } }