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 ├── package.json # 根目录start / build / preview
├── server/ # 后端(可单独部署) ├── server/ # 后端(可单独部署)
│ ├── server.js # Express API 路由 │ ├── server.js # Express API 路由
│ ├── db.js # SQLite统计、留言、settings 弹幕开关) │ ├── db.js # JSON 存储(统计、留言、弹幕开关)
│ ├── index.js # 单独启动 APInode index.js │ ├── index.js # 单独启动 APInode index.js
│ ├── package.json # 后端依赖 │ ├── 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 # 前后端分离部署与弹幕配置 ├── docs/部署说明.md # 前后端分离部署与弹幕配置
└── README.md └── README.md
``` ```
@@ -36,7 +39,7 @@
```bash ```bash
cd 720yun-offline 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。 若输出「未在页面 HTML 中发现全景图 URL」说明该页由前端 JS 请求接口加载数据,请用方式 B。
@@ -58,7 +61,7 @@ python3 fetch_720yun.py "https://www.720yun.com/vr/c8525usOunr"
## 二、Node 构建与部署(推荐) ## 二、Node 构建与部署(推荐)
项目已用 Node 方式组织,**默认使用 `image/` 下六面图**(由 `parse_720yun_doc.py --download` 拉取),打开页面即自动加载。 项目已用 Node 方式组织,**默认使用 `image/` 下六面图**(由 `scripts/parse_720yun_doc.py --download` 拉取),打开页面即自动加载。
### 1. 安装与运行(开发/本地) ### 1. 安装与运行(开发/本地)
@@ -69,7 +72,7 @@ npm start
``` ```
浏览器访问 **http://localhost:3000**,会自动加载 `config.json` 中的立方体六面图(`image/mobile_*.jpg`)。 浏览器访问 **http://localhost:3000**,会自动加载 `config.json` 中的立方体六面图(`image/mobile_*.jpg`)。
服务端会创建 **`data/pano.db`**SQLite),用于存储:**累积播放、实时在看、点赞数、分享数、留言(弹幕)**。留言以弹幕形式在画面上方滚动播放。 服务端会创建 **`server/data/store.json`**(纯 JSON 存储,无原生依赖),用于存储:**累积播放、实时在看、点赞数、分享数、留言(弹幕)**。留言以弹幕形式在画面上方滚动播放。
### 2. 构建出站目录(部署用) ### 2. 构建出站目录(部署用)

View File

@@ -25,7 +25,7 @@
- **解决办法**:脚本里请求 720static.com 的 URL 时,必须加上与浏览器一致的请求头,例如: - **解决办法**:脚本里请求 720static.com 的 URL 时,必须加上与浏览器一致的请求头,例如:
- `Referer: https://www.720yun.com/` - `Referer: https://www.720yun.com/`
- `User-Agent: Mozilla/5.0 (Macintosh; ...) Chrome/120.0.0.0 Safari/537.36` - `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。 - 解析出的 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 个用占位图路径。 - 若仍只有 5 张(没有 d可用一张纯黑或占位图作为第 6 张,或在 config 的 cubemap 里第 6 个用占位图路径。

View File

@@ -45,7 +45,7 @@
npm start npm start
``` ```
默认端口 3000可通过环境变量 `PORT` 修改。 默认端口 3000可通过环境变量 `PORT` 修改。
数据文件为 `server/data/pano.db`(首次运行自动创建)。 数据文件为 `server/data/store.json`(首次运行自动创建,纯 JSON无原生依赖)。
2. **生产环境建议** 2. **生产环境建议**
- 使用 pm2、systemd 等保活。 - 使用 pm2、systemd 等保活。
@@ -65,26 +65,26 @@
"danmakuPosition": "top" "danmakuPosition": "top"
} }
``` ```
- 数据来源:SQLite 表 `settings`(在 `server/data/pano.db` 所在库) - 数据来源:`server/data/store.json` 中的 `settings` 对象
- `danmaku_enabled``1` 开启弹幕,`0` 关闭(默认)。 - `danmaku_enabled``"1"` 开启弹幕,`"0"` 关闭(默认)。
- `danmaku_position`:弹幕区域位置,目前仅使用 `top`(顶部)。 - `danmaku_position`:弹幕区域位置,目前仅使用 `top`(顶部)。
**开启弹幕**:在部署后端的机器上执行(或通过自建管理接口写入) **开启弹幕**:在部署后端的机器上任选一种方式
1. 直接编辑 `server/data/store.json`,将 `settings.danmaku_enabled` 改为 `"1"`。
2. 或执行(需在 `server` 目录下):
```bash ```bash
cd server cd server
node -e " node -e "
const db = require('better-sqlite3')('data/pano.db'); const fs=require('fs'),path=require('path');
db.prepare(\"INSERT OR REPLACE INTO settings (key, value) VALUES ('danmaku_enabled', '1')\").run(); const p=path.join(process.cwd(),'data','store.json');
db.close(); 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` 后显示顶部弹幕并拉取留言。 前端会周期性/首次请求 `/api/config`,收到 `danmakuEnabled: true` 后显示顶部弹幕并拉取留言。
--- ---

View File

@@ -11,7 +11,6 @@
"node": ">=14" "node": ">=14"
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^11.6.0",
"express": "^4.21.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 #!/usr/bin/env python3
""" """
720yun 页面抓取全景资源并本地化 720yun 页面抓取全景资源并本地化
用法: python3 fetch_720yun.py "https://www.720yun.com/vr/c8525usOunr" 脚本位于 scripts/输出到项目根目录的 panorama/config.json
若页面由 JS 动态加载请使用手动获取方式 README 若页面由 JS 动态加载请使用手动获取方式 README
用法在项目根目录执行:
python3 scripts/fetch_720yun.py "https://www.720yun.com/vr/c8525usOunr"
""" """
import re import re
import sys import sys
@@ -11,6 +14,10 @@ import urllib.request
import urllib.error import urllib.error
from pathlib import Path from pathlib import Path
# 项目根目录
ROOT = Path(__file__).resolve().parent.parent
def fetch_html(url): def fetch_html(url):
req = urllib.request.Request(url, headers={ req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' '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: with urllib.request.urlopen(req, timeout=15) as r:
return r.read().decode('utf-8', errors='replace') return r.read().decode('utf-8', errors='replace')
def _extract_balanced_json(html, start_marker, open_char='{', close_char='}'): def _extract_balanced_json(html, start_marker, open_char='{', close_char='}'):
"""从 html 中查找 start_marker 后紧跟的完整 JSON匹配括号"""
results = [] results = []
i = 0 i = 0
if open_char == '{': if open_char == '{':
@@ -31,7 +38,6 @@ def _extract_balanced_json(html, start_marker, open_char='{', close_char='}'):
if pos < 0: if pos < 0:
break break
start = pos + len(start_marker) start = pos + len(start_marker)
# 跳过空白与等号
while start < len(html) and html[start] in ' \t\n=': while start < len(html) and html[start] in ' \t\n=':
start += 1 start += 1
if start >= len(html): if start >= len(html):
@@ -114,7 +120,6 @@ def _extract_balanced_json(html, start_marker, open_char='{', close_char='}'):
def find_json_assignments(html): def find_json_assignments(html):
"""查找页面中常见的 __INITIAL_STATE__、window.__DATA__ 等 JSON 赋值(支持嵌套)。"""
markers = [ markers = [
'window.__INITIAL_STATE__', 'window.__INITIAL_STATE__',
'__INITIAL_STATE__', '__INITIAL_STATE__',
@@ -124,20 +129,17 @@ def find_json_assignments(html):
results = [] results = []
for marker in markers: for marker in markers:
results.extend(_extract_balanced_json(html, marker, '{', '}')) results.extend(_extract_balanced_json(html, marker, '{', '}'))
# 也尝试匹配 "panorama":"url" 或 "scenes":[...] 的简单模式
for m in re.finditer(r'"panorama"\s*:\s*"([^"]+)"', html): for m in re.finditer(r'"panorama"\s*:\s*"([^"]+)"', html):
results.append(m.group(1)) results.append(m.group(1))
return results return results
def find_image_urls(html): def find_image_urls(html):
"""从 HTML 中提取可能是全景图的 URL720yun CDN 等)。"""
# 常见 720 云图片域名
url_pattern = re.compile( url_pattern = re.compile(
r'https?://[^\s"\'<>]+?\.(?:720yun\.com|qpic\.cn|gtimg\.com)[^\s"\'<>]*\.(?:jpg|jpeg|png|webp)', r'https?://[^\s"\'<>]+?\.(?:720yun\.com|qpic\.cn|gtimg\.com)[^\s"\'<>]*\.(?:jpg|jpeg|png|webp)',
re.I re.I
) )
urls = list(set(url_pattern.findall(html))) urls = list(set(url_pattern.findall(html)))
# 也匹配任意包含 panorama / scene / photo 的图片 URL
alt_pattern = re.compile( alt_pattern = re.compile(
r'https?://[^\s"\'<>]+?/(?:panorama|scene|photo|pano|vr)[^\s"\'<>]*\.(?:jpg|jpeg|png|webp)', r'https?://[^\s"\'<>]+?/(?:panorama|scene|photo|pano|vr)[^\s"\'<>]*\.(?:jpg|jpeg|png|webp)',
re.I re.I
@@ -147,7 +149,7 @@ def find_image_urls(html):
urls.append(u) urls.append(u)
return urls return urls
# 720yun CDN 会校验 Referer脚本请求需与浏览器一致才能拿到正确数据
def _browser_headers(url=''): def _browser_headers(url=''):
h = { 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', '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: with urllib.request.urlopen(req, timeout=30) as r:
dest_path.write_bytes(r.read()) dest_path.write_bytes(r.read())
def main(): def main():
if len(sys.argv) < 2: if len(sys.argv) < 2:
print('用法: python3 fetch_720yun.py <720yun页面URL>') print('用法: python3 scripts/fetch_720yun.py <720yun页面URL>')
print('例: python3 fetch_720yun.py "https://www.720yun.com/vr/c8525usOunr"') print('例: python3 scripts/fetch_720yun.py "https://www.720yun.com/vr/c8525usOunr"')
sys.exit(1) sys.exit(1)
url = sys.argv[1].strip() url = sys.argv[1].strip()
base = Path(__file__).resolve().parent panorama_dir = ROOT / 'panorama'
panorama_dir = base / 'panorama'
panorama_dir.mkdir(exist_ok=True) panorama_dir.mkdir(exist_ok=True)
config_path = base / 'config.json' config_path = ROOT / 'config.json'
print('正在请求页面...') print('正在请求页面...')
try: try:
@@ -185,7 +187,6 @@ def main():
image_urls = find_image_urls(html) image_urls = find_image_urls(html)
json_candidates = find_json_assignments(html) json_candidates = find_json_assignments(html)
# 尝试从 JSON 中解析 panorama 或 scenes
for raw in json_candidates: for raw in json_candidates:
try: try:
if raw.startswith('http'): if raw.startswith('http'):
@@ -200,7 +201,7 @@ def main():
continue continue
else: else:
continue continue
# 递归查找 url / panorama / image 字段
def collect_urls(obj, out): def collect_urls(obj, out):
if isinstance(obj, dict): if isinstance(obj, dict):
for k, v in obj.items(): for k, v in obj.items():
@@ -219,11 +220,7 @@ def main():
if not image_urls: if not image_urls:
print('未在页面 HTML 中发现全景图 URL页面可能由 JavaScript 动态加载)。') print('未在页面 HTML 中发现全景图 URL页面可能由 JavaScript 动态加载)。')
print('请按 README 使用浏览器开发者工具手动获取') 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"')
sys.exit(0) sys.exit(0)
print('发现可能的全景图 URL:', len(image_urls)) print('发现可能的全景图 URL:', len(image_urls))
@@ -235,10 +232,8 @@ def main():
print('已保存到:', local_path) print('已保存到:', local_path)
except Exception as e: except Exception as e:
print('下载失败:', e) print('下载失败:', e)
print('请手动将上面列出的任一 URL 在浏览器中打开并另存为 panorama/panorama.jpg')
sys.exit(1) sys.exit(1)
# 确保 config 指向本地
if config_path.exists(): if config_path.exists():
with open(config_path, 'r', encoding='utf-8') as f: with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f) config = json.load(f)
@@ -249,7 +244,8 @@ def main():
config['title'] = config.get('title', '本地全景') config['title'] = config.get('title', '本地全景')
with open(config_path, 'w', encoding='utf-8') as f: with open(config_path, 'w', encoding='utf-8') as f:
json.dump(config, f, ensure_ascii=False, indent=2) json.dump(config, f, ensure_ascii=False, indent=2)
print('已更新 config.json。运行本地服务器后打开 index.html 即可离线查看。') print('已更新 config.json。运行 npm start 后即可离线查看。')
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View File

@@ -2,10 +2,12 @@
""" """
text.md720yun 页面保存的文档中解析 window.data / window.json text.md720yun 页面保存的文档中解析 window.data / window.json
并解析出最终的全景图片资源 URL 并解析出最终的全景图片资源 URL
脚本位于 scripts/读写路径均相对于项目根目录
用法: 用法在项目根目录执行:
python3 parse_720yun_doc.py [text.md] python3 scripts/parse_720yun_doc.py [text.md]
python3 parse_720yun_doc.py --fetch # 并请求场景 JSON,解析出所有图片 URL python3 scripts/parse_720yun_doc.py --fetch # 并请求场景 JSON
python3 scripts/parse_720yun_doc.py --download # 下载六面图到 image/
""" """
import re import re
import sys import sys
@@ -13,6 +15,9 @@ import json
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
# 项目根目录(脚本所在目录的上一级)
ROOT = Path(__file__).resolve().parent.parent
def read_doc(path): def read_doc(path):
with open(path, 'r', encoding='utf-8', errors='replace') as f: 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): def build_resource_base(thumb_url):
"""从 thumbUrl 得到资源目录的 base URL用于拼立方体等。使用实际 CDN 域名以便脚本拉取与浏览器一致。""" """从 thumbUrl 得到资源目录的 base URL用于拼立方体等。使用实际 CDN 域名以便脚本拉取与浏览器一致。"""
# thumbUrl 可能是 "/resource/prod/4ca3fae5e7x/d22jkguytw6/59446768/imgs/thumb.jpg"
# 全景图实际在 ssl-panoimg130.720static.com用 resource-t 会拿不到或异常
if thumb_url.startswith('http'): if thumb_url.startswith('http'):
base = re.sub(r'^https?://[^/]+', 'https://' + RESOURCE_CDN_HOST, thumb_url) base = re.sub(r'^https?://[^/]+', 'https://' + RESOURCE_CDN_HOST, thumb_url)
else: else:
@@ -88,7 +91,6 @@ def infer_cube_urls(resource_base):
return [resource_base + 'mobile_' + face + '.jpg' for face in faces] return [resource_base + 'mobile_' + face + '.jpg' for face in faces]
# 与浏览器一致的请求头720yun CDN 校验 Referer否则拿不到正确数据
def _browser_headers(referer='https://www.720yun.com/'): def _browser_headers(referer='https://www.720yun.com/'):
return { 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', '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: try:
with urllib.request.urlopen(req, timeout=15) as r: with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read().decode('utf-8', errors='replace')) return json.loads(r.read().decode('utf-8', errors='replace'))
except Exception as e: except Exception:
return None return None
@@ -141,7 +143,7 @@ def extract_image_urls_from_tour(tour_data, resource_base):
def main(): 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('-'): if len(sys.argv) >= 2 and not sys.argv[1].startswith('-'):
doc_path = Path(sys.argv[1]) doc_path = Path(sys.argv[1])
do_fetch = '--fetch' in sys.argv do_fetch = '--fetch' in sys.argv
@@ -159,7 +161,6 @@ def main():
print('未能从文档中解析出 window.data 或 window.json') print('未能从文档中解析出 window.data 或 window.json')
sys.exit(1) sys.exit(1)
# 解析结果
result = { result = {
'window_data': data, 'window_data': data,
'window_json_path': json_path, 'window_json_path': json_path,
@@ -193,13 +194,11 @@ def main():
else: else:
print('请求场景 JSON 失败:', result['tour_json_url'], file=sys.stderr) print('请求场景 JSON 失败:', result['tour_json_url'], file=sys.stderr)
# 输出:先写 JSON 汇总,再列最终图片列表 out_path = ROOT / 'parsed_720yun_resources.json'
out_path = Path(__file__).resolve().parent / 'parsed_720yun_resources.json'
with open(out_path, 'w', encoding='utf-8') as f: with open(out_path, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2) json.dump(result, f, ensure_ascii=False, indent=2)
print('已写入:', out_path) print('已写入:', out_path)
# 最终图片资源列表(去重、合并)
all_urls = [] all_urls = []
if result.get('thumb_url'): if result.get('thumb_url'):
all_urls.append(('thumb', result['thumb_url'])) all_urls.append(('thumb', result['thumb_url']))
@@ -215,7 +214,7 @@ def main():
print('\n', len(all_urls), '个 URL') print('\n', len(all_urls), '个 URL')
if do_download and result.get('resource_base'): 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) out_dir.mkdir(exist_ok=True)
print('\n--- 使用浏览器头下载到 image/ ---') 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', []))): 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 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 DEFAULT_STORE = {
const db = new Database(DB_PATH); stats: {
db.pragma('journal_mode = WAL'); view_count: 0,
return db; 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() { function initDb() {
const fs = require('fs'); load();
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();
} }
function getConfig() { function getConfig() {
const db = getDb(); const s = load().settings;
const rows = db.prepare('SELECT key, value FROM settings').all();
db.close();
const map = {};
rows.forEach((r) => { map[r.key] = r.value; });
return { return {
danmakuEnabled: map.danmaku_enabled === '1', danmakuEnabled: s.danmaku_enabled === '1',
danmakuPosition: (map.danmaku_position || 'top').toLowerCase(), danmakuPosition: (s.danmaku_position || 'top').toLowerCase(),
}; };
} }
function getStats() { function getStats() {
const db = getDb(); const st = load().stats;
const row = db.prepare('SELECT view_count, like_count, share_count, watching_now FROM stats WHERE id = 1').get(); const commentCount = load().comments.length;
const commentRow = db.prepare('SELECT COUNT(*) as n FROM comments').get();
db.close();
return { return {
viewCount: row.view_count, viewCount: st.view_count,
commentCount: commentRow.n, commentCount,
likeCount: row.like_count, likeCount: st.like_count,
shareCount: row.share_count, shareCount: st.share_count,
watchingNow: row.watching_now, watchingNow: st.watching_now,
}; };
} }
function incView() { function incView() {
const db = getDb(); const s = load();
db.prepare('UPDATE stats SET view_count = view_count + 1 WHERE id = 1').run(); s.stats.view_count += 1;
const out = getStats(); save();
db.close(); return getStats();
return out;
} }
function incLike() { function incLike() {
const db = getDb(); const s = load();
db.prepare('UPDATE stats SET like_count = like_count + 1 WHERE id = 1').run(); s.stats.like_count += 1;
const row = db.prepare('SELECT like_count FROM stats WHERE id = 1').get(); save();
db.close(); return { likeCount: s.stats.like_count };
return { likeCount: row.like_count };
} }
function incShare() { function incShare() {
const db = getDb(); const s = load();
db.prepare('UPDATE stats SET share_count = share_count + 1 WHERE id = 1').run(); s.stats.share_count += 1;
const row = db.prepare('SELECT share_count FROM stats WHERE id = 1').get(); save();
db.close(); return { shareCount: s.stats.share_count };
return { shareCount: row.share_count };
} }
function joinViewer(viewerId) { function joinViewer(viewerId) {
const db = getDb(); const s = load();
const now = Date.now(); const now = Date.now();
db.prepare('INSERT OR REPLACE INTO viewers (viewer_id, updated_at) VALUES (?, ?)').run(viewerId, now); s.viewers[viewerId] = now;
db.prepare('DELETE FROM viewers WHERE updated_at < ?').run(now - 120000); const cutoff = now - 120000;
const row = db.prepare('SELECT COUNT(*) as n FROM viewers').get(); Object.keys(s.viewers).forEach((id) => {
db.prepare('UPDATE stats SET watching_now = ? WHERE id = 1').run(row.n); if (s.viewers[id] < cutoff) delete s.viewers[id];
db.close(); });
return row.n; s.stats.watching_now = Object.keys(s.viewers).length;
save();
return s.stats.watching_now;
} }
function leaveViewer(viewerId) { function leaveViewer(viewerId) {
const db = getDb(); const s = load();
db.prepare('DELETE FROM viewers WHERE viewer_id = ?').run(viewerId); delete s.viewers[viewerId];
const row = db.prepare('SELECT COUNT(*) as n FROM viewers').get(); s.stats.watching_now = Object.keys(s.viewers).length;
db.prepare('UPDATE stats SET watching_now = ? WHERE id = 1').run(row.n); save();
db.close(); return s.stats.watching_now;
return row.n;
} }
function getComments(limit = 100) { function getComments(limit = 100) {
const db = getDb(); const list = load().comments;
const rows = db.prepare( const slice = list.slice(-Math.min(limit, list.length));
'SELECT id, content, nickname, created_at FROM comments ORDER BY id DESC LIMIT ?' return slice.map((r) => ({
).all(limit);
db.close();
return rows.reverse().map((r) => ({
id: r.id, id: r.id,
content: r.content, content: r.content,
nickname: r.nickname || '游客', nickname: r.nickname || '游客',
@@ -134,15 +125,23 @@ function getComments(limit = 100) {
} }
function addComment(content, nickname) { function addComment(content, nickname) {
const db = getDb(); const s = load();
const now = Date.now(); const now = Date.now();
const r = db.prepare('INSERT INTO comments (content, nickname, created_at) VALUES (?, ?, ?)').run( const id = s.comments.length ? Math.max(...s.comments.map((c) => c.id)) + 1 : 1;
String(content).trim().slice(0, 200) || '(空)', const row = {
nickname ? String(nickname).trim().slice(0, 32) : null, id,
now content: String(content).trim().slice(0, 200) || '(空)',
); nickname: nickname ? String(nickname).trim().slice(0, 32) : null,
db.close(); created_at: now,
return { id: r.lastInsertRowid, content: content.trim().slice(0, 200), nickname: nickname || '游客', createdAt: now }; };
s.comments.push(row);
save();
return {
id: row.id,
content: row.content,
nickname: row.nickname || '游客',
createdAt: row.created_at,
};
} }
module.exports = { module.exports = {

View File

@@ -1,7 +1,7 @@
{ {
"name": "720yun-offline-api", "name": "720yun-offline-api",
"version": "1.0.0", "version": "1.0.0",
"description": "全景查看器后端 API可单独部署", "description": "全景查看器后端 API可单独部署(纯 JS无原生依赖",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node index.js" "start": "node index.js"
@@ -10,7 +10,6 @@
"node": ">=14" "node": ">=14"
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^11.6.0",
"express": "^4.21.0" "express": "^4.21.0"
} }
} }