diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6dbc27e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitignore +.vscode +docs +sdk + +**/__pycache__ +**/.pytest_cache +**/.venv +**/*.pyc + +backend/.env +backend/media_crawler.db + +frontend/node_modules +frontend/dist diff --git a/.gitignore b/.gitignore index 36b13f1..f01dbd3 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,32 @@ cython_debug/ # PyPI configuration file .pypirc +# --- Node / Vite --- +node_modules/ +dist/ +dist-ssr/ +*.local + +# --- Project runtime files --- +backend/media_crawler.db +backend/media_crawler.db-shm +backend/media_crawler.db-wal + +# --- Env files --- +.env.* +!.env.example +backend/.env +frontend/.env + +# --- OS / editor temp --- +.DS_Store +Thumbs.db +*.swp +*.swo + +# --- Frontend cache --- +frontend/.vite +npm-debug.log* +yarn-debug.log* +yarn-error.log* + diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..697daf3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM node:20-bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 python3-pip ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install backend dependencies. +COPY backend/requirements.txt /app/backend/requirements.txt +RUN python3 -m pip install --no-cache-dir -r /app/backend/requirements.txt + +# Install frontend dependencies and build. +COPY frontend/package*.json /app/frontend/ +WORKDIR /app/frontend +RUN npm install +COPY frontend /app/frontend +RUN npm run build + +# Copy backend source and runtime launcher. +WORKDIR /app +COPY backend /app/backend +COPY docker/start.sh /app/docker/start.sh +RUN chmod +x /app/docker/start.sh + +EXPOSE 14620 14621 + +CMD ["/app/docker/start.sh"] diff --git a/README.md b/README.md index 84eb477..a9d3aca 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,81 @@ # media_crawler +资源爬取自动入库项目,采用前后端分层: + +- `frontend/`: Vue + JavaScript 页面 +- `backend/`: Python Flask API(编排 TMDB -> HDHIVE -> Emby -> CMS) + +## 本地启动 + +### 1) 启动后端(Flask) + +```bash +cd backend +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +python app.py +``` + +默认端口: +- 后端:`14620` +- 前端:`14621` + +后端 `.env` 中 `HDHIVE` 相关参数按 OpenAPI 文档配置: + +- `HDHIVE_BASE_URL` 例如 `https://hdhive.com` +- `HDHIVE_API_KEY` 对应请求头 `X-API-Key`(必填) +- `HDHIVE_ACCESS_TOKEN` 对应 `Authorization: Bearer ...`(按授权场景可选) + +后端 `CMS` 入库参数支持两种模式: + +- 直接给固定 token:`CMS_TOKEN` +- 自动登录获取 token:`CMS_LOGIN_URL` + `CMS_USERNAME` + `CMS_PASSWORD` + +入库接口地址使用 `CMS_ADD_SHARE_URL`(或 `CMS_BASE_URL` 自动拼接 `/api/cloud/add_share_down`)。 + +### 2) 启动前端(Vite) + +```bash +cd frontend +cp .env.example .env +npm install +npm run dev +``` + +## 页面能力 + +- 主页面支持按关键词在 TMDB 搜索影视 +- 点击影视封面进入详情页,展示 HDHive 资源列表 +- 点击资源链接触发入库任务(按资源 slug 入库) +- 任务中心位于二级页面 `/tasks` +- 前端调用 Flask API:`POST /api/tasks`、`GET /api/tasks`、`GET /api/tasks/{taskId}/logs` +- 前端新增接口:`GET /api/media/search`、`GET /api/media/{type}/{tmdbId}` +- 任务状态、结果、错误和步骤日志展示 +- 后端使用 SQLite 保存任务、资源和日志,并按 `tmdb_id` 做幂等控制 +- 后端统一错误分类:`validation`、`authentication`、`authorization`、`rate_limit`、`not_found`、`business_rule`、`network`、`timeout`、`upstream`、`internal` + +## Docker 单容器运行 + +项目提供了单容器运行前后端的 `Dockerfile`,容器内会同时启动: + +- Flask 后端:`14620` +- 前端预览服务:`14621` + +### 构建镜像 + +```bash +docker build -t media-crawler:latest . +``` + +### 运行容器 + +```bash +docker run --rm -it \ + -p 14620:14620 \ + -p 14621:14621 \ + --env-file backend/.env \ + media-crawler:latest +``` + diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..8ef38c9 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,24 @@ +FLASK_RUN_PORT=14620 +FLASK_DEBUG=1 + +TMDB_BASE_URL=https://api.themoviedb.org/3 +TMDB_TOKEN= + +HDHIVE_BASE_URL=https://hdhive.com +HDHIVE_API_KEY= +HDHIVE_ACCESS_TOKEN= + +CMS_BASE_URL= +CMS_TOKEN= +CMS_LOGIN_URL= +CMS_ADD_SHARE_URL= +CMS_USERNAME= +CMS_PASSWORD= + +EMBY_BASE_URL= +EMBY_TOKEN= + +TLS_INSECURE_SKIP_VERIFY=0 + +MAX_RETRY=3 +RETRY_DELAY_MS=500 diff --git a/backend/adapters/__init__.py b/backend/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/adapters/cms_adapter.py b/backend/adapters/cms_adapter.py new file mode 100644 index 0000000..5a17a1d --- /dev/null +++ b/backend/adapters/cms_adapter.py @@ -0,0 +1,146 @@ +import time + +from config import Config +from http_client import request_json +from error_handling import AppServiceError + +_CMS_TOKEN_CACHE = {"token": "", "expires_at": 0} + + +def _resolve_login_url(): + if Config.CMS_LOGIN_URL: + return Config.CMS_LOGIN_URL + if Config.CMS_BASE_URL: + return f"{Config.CMS_BASE_URL.rstrip('/')}/api/auth/login" + raise AppServiceError( + "CMS login url is not configured", + category="validation", + code="CMS_CONFIG_MISSING", + provider="cms", + ) + + +def _resolve_add_share_url(): + if Config.CMS_ADD_SHARE_URL: + return Config.CMS_ADD_SHARE_URL + if Config.CMS_BASE_URL: + return f"{Config.CMS_BASE_URL.rstrip('/')}/api/cloud/add_share_down" + raise AppServiceError( + "CMS add share url is not configured", + category="validation", + code="CMS_CONFIG_MISSING", + provider="cms", + ) + + +def _headers(token): + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def _extract_cms_token(login_result): + data = login_result.get("data") or {} + token = (data.get("data") or {}).get("token") + if not token: + raise AppServiceError( + "CMS login succeeded but token missing", + category="upstream", + code="CMS_TOKEN_MISSING", + provider="cms", + detail={"response": data}, + ) + return token + + +def _login_and_get_token(): + if Config.CMS_TOKEN: + return Config.CMS_TOKEN + if not Config.CMS_USERNAME or not Config.CMS_PASSWORD: + raise AppServiceError( + "CMS username/password is required when CMS_TOKEN is not provided", + category="validation", + code="CMS_CONFIG_MISSING", + provider="cms", + ) + login_result = request_json( + _resolve_login_url(), + method="POST", + payload={"username": Config.CMS_USERNAME, "password": Config.CMS_PASSWORD}, + headers={"Content-Type": "application/json"}, + max_retry=Config.MAX_RETRY, + retry_delay_ms=Config.RETRY_DELAY_MS, + provider="cms", + ) + token = _extract_cms_token(login_result) + _CMS_TOKEN_CACHE["token"] = token + _CMS_TOKEN_CACHE["expires_at"] = int(time.time()) + 3500 + return token + + +def _get_cached_token(): + if Config.CMS_TOKEN: + return Config.CMS_TOKEN + if _CMS_TOKEN_CACHE["token"] and _CMS_TOKEN_CACHE["expires_at"] > int(time.time()): + return _CMS_TOKEN_CACHE["token"] + return _login_and_get_token() + + +def _should_refresh_token(response_data): + code = (response_data or {}).get("code") + msg = (response_data or {}).get("msg") or (response_data or {}).get("message") or "" + return code != 200 and msg != "提取分享链接失败" + + +def _add_share(url_value, token): + return request_json( + _resolve_add_share_url(), + method="POST", + payload={"url": url_value}, + headers=_headers(token), + max_retry=Config.MAX_RETRY, + retry_delay_ms=Config.RETRY_DELAY_MS, + provider="cms", + ) + + +def create_resource(payload): + resource = payload.get("resource") or {} + share_url = resource.get("unlockUrl") or payload.get("url") + if not share_url: + raise AppServiceError( + "CMS ingest requires unlockUrl", + category="validation", + code="CMS_INPUT_INVALID", + provider="cms", + ) + + token = _get_cached_token() + first_result = _add_share(share_url, token) + first_data = first_result.get("data") or {} + if _should_refresh_token(first_data): + refreshed_token = _login_and_get_token() + second_result = _add_share(share_url, refreshed_token) + second_data = second_result.get("data") or {} + if (second_data.get("code") or 0) != 200: + raise AppServiceError( + second_data.get("msg") or second_data.get("message") or "CMS ingest failed", + category="upstream", + code=str(second_data.get("code") or "CMS_INGEST_FAILED"), + provider="cms", + detail={"response": second_data}, + ) + return second_result + + if (first_data.get("code") or 0) != 200: + raise AppServiceError( + first_data.get("msg") or first_data.get("message") or "CMS ingest failed", + category="business_rule" + if (first_data.get("msg") == "提取分享链接失败") + else "upstream", + code=str(first_data.get("code") or "CMS_INGEST_FAILED"), + provider="cms", + detail={"response": first_data}, + ) + return first_result diff --git a/backend/adapters/emby_adapter.py b/backend/adapters/emby_adapter.py new file mode 100644 index 0000000..2bea977 --- /dev/null +++ b/backend/adapters/emby_adapter.py @@ -0,0 +1,23 @@ +from config import Config +from http_client import request_json + + +def _headers(): + headers = {"Content-Type": "application/json"} + if Config.EMBY_TOKEN: + headers["X-Emby-Token"] = Config.EMBY_TOKEN + return headers + + +def exists_by_tmdb_id(tmdb_id): + url = f"{Config.EMBY_BASE_URL}/Items?AnyProviderIdEquals=Tmdb.{tmdb_id}&Recursive=true&Limit=1" + result = request_json( + url, + headers=_headers(), + max_retry=Config.MAX_RETRY, + retry_delay_ms=Config.RETRY_DELAY_MS, + provider="emby", + ) + total = ((result.get("data") or {}).get("TotalRecordCount")) or 0 + result["exists"] = total > 0 + return result diff --git a/backend/adapters/hdhive_adapter.py b/backend/adapters/hdhive_adapter.py new file mode 100644 index 0000000..8a28caf --- /dev/null +++ b/backend/adapters/hdhive_adapter.py @@ -0,0 +1,60 @@ +from config import Config +from http_client import request_json + + +def _headers(): + headers = { + "Accept": "application/json", + "X-API-Key": Config.HDHIVE_API_KEY, + } + if Config.HDHIVE_ACCESS_TOKEN: + headers["Authorization"] = f"Bearer {Config.HDHIVE_ACCESS_TOKEN}" + return headers + + +def search_resource(media_type, tmdb_id): + url = f"{Config.HDHIVE_BASE_URL}/api/open/resources/{media_type}/{tmdb_id}" + return request_json( + url, + headers=_headers(), + max_retry=Config.MAX_RETRY, + retry_delay_ms=Config.RETRY_DELAY_MS, + provider="hdhive", + ) + + +def unlock_link(slug): + url = f"{Config.HDHIVE_BASE_URL}/api/open/resources/unlock" + return request_json( + url, + method="POST", + payload={"slug": slug}, + headers={**_headers(), "Content-Type": "application/json"}, + max_retry=Config.MAX_RETRY, + retry_delay_ms=Config.RETRY_DELAY_MS, + provider="hdhive", + ) + + +def normalize_resource(search_data, unlock_data): + resolution = (search_data or {}).get("video_resolution") + source = (search_data or {}).get("source") + subtitle_language = (search_data or {}).get("subtitle_language") + return { + "resourceTitle": (search_data or {}).get("title", ""), + "quality": ", ".join(resolution) if isinstance(resolution, list) else "", + "size": (search_data or {}).get("share_size", ""), + "diskType": (search_data or {}).get("pan_type", ""), + "source": ", ".join(source) if isinstance(source, list) else "", + "subtitleLanguage": ", ".join(subtitle_language) + if isinstance(subtitle_language, list) + else "", + "slug": (search_data or {}).get("slug", ""), + "unlockUrl": (unlock_data or {}).get("full_url") + or (unlock_data or {}).get("url") + or "", + "availability": "available" + if ((unlock_data or {}).get("full_url") or (unlock_data or {}).get("url")) + else "unknown", + "raw": {"searchData": search_data, "unlockData": unlock_data}, + } diff --git a/backend/adapters/tmdb_adapter.py b/backend/adapters/tmdb_adapter.py new file mode 100644 index 0000000..047d2d3 --- /dev/null +++ b/backend/adapters/tmdb_adapter.py @@ -0,0 +1,56 @@ +from config import Config +from http_client import request_json +from urllib.parse import quote + + +def _headers(): + headers = {"Content-Type": "application/json"} + if Config.TMDB_TOKEN: + headers["Authorization"] = f"Bearer {Config.TMDB_TOKEN}" + return headers + + +def search_media(query, media_type="movie", page=1): + normalized_type = "tv" if media_type == "tv" else "movie" + url = ( + f"{Config.TMDB_BASE_URL}/search/{normalized_type}" + f"?language=zh-CN&query={quote(str(query))}&page={page}" + ) + result = request_json( + url, + headers=_headers(), + max_retry=Config.MAX_RETRY, + retry_delay_ms=Config.RETRY_DELAY_MS, + provider="tmdb", + ) + data = result.get("data") or {} + result["items"] = data.get("results") if isinstance(data, dict) else [] + return result + + +def get_media_detail(tmdb_id, media_type): + normalized_type = "tv" if media_type == "tv" else "movie" + url = f"{Config.TMDB_BASE_URL}/{normalized_type}/{tmdb_id}?language=zh-CN" + result = request_json( + url, + headers=_headers(), + max_retry=Config.MAX_RETRY, + retry_delay_ms=Config.RETRY_DELAY_MS, + provider="tmdb", + ) + data = result.get("data") or {} + normalized = { + "tmdbId": tmdb_id, + "type": normalized_type, + "title": data.get("title") or data.get("name") or "", + "originalTitle": data.get("original_title") or data.get("original_name") or "", + "overview": data.get("overview") or "", + "year": (data.get("release_date") or data.get("first_air_date") or "")[:4], + "rating": data.get("vote_average"), + "posterPath": data.get("poster_path") or "", + "genres": [g.get("name") for g in data.get("genres", []) if g.get("name")], + "seasons": len(data.get("seasons", [])) if isinstance(data.get("seasons"), list) else 0, + "raw": data, + } + result["normalized"] = normalized + return result diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..3360dd1 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,102 @@ +from flask import Flask, jsonify, request +from flask_cors import CORS + +from config import Config +from error_handling import AppServiceError, normalize_exception +from services.media_service import ( + get_media_resources, + search_media_by_keyword, + validate_media_query, + validate_media_type, +) +from services.orchestrator import run_ingest_task +from storage import init_db, list_logs, list_tasks + + +def create_app(): + def error_response(error, fallback_status=400): + normalized = normalize_exception(error) + status = normalized.status or fallback_status + return jsonify({"error": normalized.to_dict()}), status + + app = Flask(__name__) + CORS(app) + init_db() + + @app.get("/api/health") + def health(): + return jsonify({"ok": True}) + + @app.post("/api/tasks") + def create_task(): + data = request.get_json(silent=True) or {} + tmdb_id = str(data.get("tmdbId", "")).strip() + media_type = data.get("type", "movie") + keyword = str(data.get("keyword", "")).strip() + if not tmdb_id: + return error_response( + AppServiceError( + "tmdbId is required", + category="validation", + code="INVALID_INPUT", + status=400, + provider="api", + ), + 400, + ) + if media_type not in ("movie", "tv"): + return error_response( + AppServiceError( + "type must be movie or tv", + category="validation", + code="INVALID_INPUT", + status=400, + provider="api", + ), + 400, + ) + + task = run_ingest_task( + { + "tmdbId": tmdb_id, + "type": media_type, + "keyword": keyword, + "slug": str(data.get("slug", "")).strip(), + } + ) + return jsonify(task) + + @app.get("/api/media/search") + def search_media_handler(): + query = str(request.args.get("query", "")).strip() + media_type = str(request.args.get("type", "movie")).strip() + try: + validate_media_query(query, media_type) + return jsonify(search_media_by_keyword(query, media_type)) + except Exception as error: + return error_response(error) + + @app.get("/api/media//") + def media_detail_handler(media_type, tmdb_id): + try: + validate_media_type(media_type) + return jsonify(get_media_resources(media_type, str(tmdb_id))) + except Exception as error: + return error_response(error) + + @app.get("/api/tasks") + def get_tasks(): + return jsonify({"items": list_tasks()}) + + @app.get("/api/tasks//logs") + def get_task_logs(task_id): + return jsonify({"items": list_logs(task_id)}) + + return app + + +app = create_app() + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=Config.FLASK_RUN_PORT, debug=Config.FLASK_DEBUG) diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..41f6c37 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,31 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + + +class Config: + FLASK_RUN_PORT = int(os.getenv("FLASK_RUN_PORT", "14620")) + FLASK_DEBUG = os.getenv("FLASK_DEBUG", "1") == "1" + + TMDB_BASE_URL = os.getenv("TMDB_BASE_URL", "https://api.themoviedb.org/3") + TMDB_TOKEN = os.getenv("TMDB_TOKEN", "") + + HDHIVE_BASE_URL = os.getenv("HDHIVE_BASE_URL", "https://hdhive.com") + HDHIVE_API_KEY = os.getenv("HDHIVE_API_KEY", "") + HDHIVE_ACCESS_TOKEN = os.getenv("HDHIVE_ACCESS_TOKEN", "") + + CMS_BASE_URL = os.getenv("CMS_BASE_URL", "") + CMS_TOKEN = os.getenv("CMS_TOKEN", "") + CMS_LOGIN_URL = os.getenv("CMS_LOGIN_URL", "") + CMS_ADD_SHARE_URL = os.getenv("CMS_ADD_SHARE_URL", "") + CMS_USERNAME = os.getenv("CMS_USERNAME", "") + CMS_PASSWORD = os.getenv("CMS_PASSWORD", "") + + EMBY_BASE_URL = os.getenv("EMBY_BASE_URL", "") + EMBY_TOKEN = os.getenv("EMBY_TOKEN", "") + + TLS_INSECURE_SKIP_VERIFY = os.getenv("TLS_INSECURE_SKIP_VERIFY", "0") == "1" + + MAX_RETRY = int(os.getenv("MAX_RETRY", "3")) + RETRY_DELAY_MS = int(os.getenv("RETRY_DELAY_MS", "500")) diff --git a/backend/error_handling.py b/backend/error_handling.py new file mode 100644 index 0000000..5e0d720 --- /dev/null +++ b/backend/error_handling.py @@ -0,0 +1,64 @@ +class AppServiceError(Exception): + def __init__( + self, + message, + *, + category="internal", + code="INTERNAL_ERROR", + status=None, + retryable=False, + provider="system", + detail=None, + ): + super().__init__(message) + self.category = category + self.code = code + self.status = status + self.retryable = retryable + self.provider = provider + self.detail = detail or {} + + def to_dict(self): + return { + "message": str(self), + "category": self.category, + "code": self.code, + "status": self.status, + "retryable": self.retryable, + "provider": self.provider, + "detail": self.detail, + } + + +def classify_http_error(status, code, retryable=False): + code = str(code or "").upper() + if status == 429 or code in {"RATE_LIMIT_EXCEEDED"}: + return "rate_limit" + if status == 401 or code in { + "MISSING_API_KEY", + "INVALID_API_KEY", + "DISABLED_API_KEY", + "EXPIRED_API_KEY", + "INVALID_OPENAPI_USER_TOKEN", + "OPENAPI_TOKEN_APP_MISMATCH", + }: + return "authentication" + if status == 403 or code in {"OPENAPI_USER_REQUIRED", "SCOPE_NOT_ALLOWED", "USER_SCOPE_NOT_ALLOWED"}: + return "authorization" + if status == 404: + return "not_found" + if status == 400: + return "validation" + if status == 402 or code in {"INSUFFICIENT_POINTS", "VIP_REQUIRED"}: + return "business_rule" + if status and status >= 500: + return "upstream" + if retryable: + return "upstream" + return "unknown" + + +def normalize_exception(error): + if isinstance(error, AppServiceError): + return error + return AppServiceError(str(error), category="internal", code="INTERNAL_ERROR") diff --git a/backend/http_client.py b/backend/http_client.py new file mode 100644 index 0000000..f0d2d47 --- /dev/null +++ b/backend/http_client.py @@ -0,0 +1,96 @@ +import time +import requests + +from config import Config +from error_handling import AppServiceError, classify_http_error + +RETRYABLE_STATUS = {408, 425, 429, 500, 502, 503, 504} + +if Config.TLS_INSECURE_SKIP_VERIFY: + requests.packages.urllib3.disable_warnings() # type: ignore[attr-defined] + + +def request_json( + url, + method="GET", + headers=None, + payload=None, + max_retry=3, + retry_delay_ms=500, + provider="external", +): + attempt = 0 + headers = headers or {} + + while attempt <= max_retry: + start = time.perf_counter() + try: + response = requests.request( + method=method, + url=url, + headers=headers, + json=payload, + timeout=20, + verify=not Config.TLS_INSECURE_SKIP_VERIFY, + ) + cost_ms = round((time.perf_counter() - start) * 1000) + data = None + if response.text: + try: + data = response.json() + except ValueError: + data = response.text + + if not response.ok: + retryable = response.status_code in RETRYABLE_STATUS + code = str((data or {}).get("code", response.status_code)) if isinstance(data, dict) else str(response.status_code) + message = (data or {}).get("message", f"HTTP {response.status_code}") if isinstance(data, dict) else f"HTTP {response.status_code}" + category = classify_http_error(response.status_code, code, retryable=retryable) + raise AppServiceError( + message, + category=category, + code=code, + status=response.status_code, + retryable=retryable, + provider=provider, + detail={"data": data, "costMs": cost_ms, "url": url}, + ) + + return {"data": data, "status": response.status_code, "cost_ms": cost_ms} + except requests.Timeout as error: + retryable = True + if not retryable or attempt == max_retry: + raise AppServiceError( + "request timeout", + category="timeout", + code="REQUEST_TIMEOUT", + status=None, + retryable=True, + provider=provider, + detail={"url": url, "reason": str(error)}, + ) from error + delay = (2**attempt) * retry_delay_ms / 1000.0 + time.sleep(delay) + attempt += 1 + except requests.RequestException as error: + retryable = True + if attempt == max_retry: + raise AppServiceError( + "network request failed", + category="network", + code="NETWORK_ERROR", + status=None, + retryable=True, + provider=provider, + detail={"url": url, "reason": str(error)}, + ) from error + delay = (2**attempt) * retry_delay_ms / 1000.0 + time.sleep(delay) + attempt += 1 + except AppServiceError as error: + retryable = error.retryable + if not retryable or attempt == max_retry: + raise + delay = (2**attempt) * retry_delay_ms / 1000.0 + time.sleep(delay) + attempt += 1 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..513c2fd --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,4 @@ +Flask==3.0.3 +Flask-Cors==4.0.1 +python-dotenv==1.0.1 +requests==2.32.3 diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/media_service.py b/backend/services/media_service.py new file mode 100644 index 0000000..4a209d6 --- /dev/null +++ b/backend/services/media_service.py @@ -0,0 +1,73 @@ +from adapters.hdhive_adapter import normalize_resource, search_resource, unlock_link +from adapters.tmdb_adapter import get_media_detail, search_media +from error_handling import AppServiceError + + +def search_media_by_keyword(query, media_type): + result = search_media(query, media_type) + raw_items = result.get("items") or [] + items = [] + for item in raw_items: + items.append( + { + "id": item.get("id"), + "type": media_type, + "title": item.get("title") or item.get("name"), + "overview": item.get("overview") or "", + "posterPath": item.get("poster_path") or "", + "releaseDate": item.get("release_date") or item.get("first_air_date") or "", + "voteAverage": item.get("vote_average"), + } + ) + return {"items": items} + + +def get_media_resources(media_type, tmdb_id): + detail = get_media_detail(tmdb_id, media_type) + hdhive = search_resource(media_type, tmdb_id) + search_data = hdhive.get("data") or [] + if isinstance(search_data, dict): + search_data = search_data.get("items") or [] + + resources = [] + for item in search_data: + slug = (item or {}).get("slug") + unlock_data = {} + unlock_error = None + if slug: + try: + unlock = unlock_link(slug) + unlock_data = unlock.get("data") or {} + except Exception as error: + unlock_error = str(error) + normalized = normalize_resource(item, unlock_data) + normalized["unlockError"] = unlock_error + resources.append(normalized) + + return { + "media": detail.get("normalized"), + "resources": resources, + } + + +def validate_media_query(query, media_type): + if not query: + raise AppServiceError( + "query is required", + category="validation", + code="INVALID_INPUT", + status=400, + provider="api", + ) + validate_media_type(media_type) + + +def validate_media_type(media_type): + if media_type not in ("movie", "tv"): + raise AppServiceError( + "type must be movie or tv", + category="validation", + code="INVALID_INPUT", + status=400, + provider="api", + ) diff --git a/backend/services/orchestrator.py b/backend/services/orchestrator.py new file mode 100644 index 0000000..3969477 --- /dev/null +++ b/backend/services/orchestrator.py @@ -0,0 +1,176 @@ +import time +from datetime import datetime + +from adapters.cms_adapter import create_resource +from adapters.emby_adapter import exists_by_tmdb_id +from adapters.hdhive_adapter import normalize_resource, search_resource, unlock_link +from adapters.tmdb_adapter import get_media_detail +from error_handling import AppServiceError, normalize_exception +from storage import find_media_item, insert_log, upsert_media_item, upsert_task + + +def now_iso(): + return datetime.utcnow().isoformat() + "Z" + + +def new_task_id(): + return f"task_{int(time.time() * 1000)}" + + +def log(task_id, step, level, message, detail=None): + insert_log(task_id, step, level, message, detail or {}, now_iso()) + + +def run_ingest_task(payload): + task_id = new_task_id() + trace_id = f"{task_id}_{hex(int(time.time() * 1000))[-6:]}" + task = { + "taskId": task_id, + "traceId": trace_id, + "status": "RUNNING", + "inputPayload": payload, + "startedAt": now_iso(), + "finishedAt": None, + "summary": None, + } + upsert_task(task) + log(task_id, "START", "INFO", "任务开始", {"payload": payload, "traceId": trace_id}) + + try: + deduped = find_media_item(payload["tmdbId"]) + if deduped and deduped.get("ingest_status") == "SUCCESS": + task["status"] = "SUCCESS" + task["finishedAt"] = now_iso() + task["summary"] = { + "result": "SKIPPED_ALREADY_EXISTS", + "tmdbId": payload["tmdbId"], + "cmsId": deduped.get("cms_id"), + } + upsert_task(task) + log(task_id, "DEDUPE", "INFO", "命中本地幂等,跳过", task["summary"]) + return task + + tmdb_result = get_media_detail(payload["tmdbId"], payload["type"]) + log(task_id, "TMDB_DETAIL", "INFO", "TMDB 元数据获取成功", {"status": tmdb_result["status"]}) + + hdhive_search = search_resource(payload["type"], payload["tmdbId"]) + hdhive_first = None + search_data = hdhive_search.get("data") or {} + preferred_slug = str(payload.get("slug") or "").strip() + if isinstance(search_data, list) and search_data: + if preferred_slug: + hdhive_first = next( + (item for item in search_data if (item or {}).get("slug") == preferred_slug), + None, + ) + if not hdhive_first: + hdhive_first = search_data[0] + elif isinstance(search_data, dict): + items = search_data.get("items") or [] + if preferred_slug: + hdhive_first = next( + (item for item in items if (item or {}).get("slug") == preferred_slug), + None, + ) + if items: + hdhive_first = hdhive_first or items[0] + if not hdhive_first: + raise AppServiceError( + "HDHIVE 未检索到可用资源", + category="not_found", + code="HDHIVE_RESOURCE_NOT_FOUND", + provider="hdhive", + ) + log(task_id, "HDHIVE_SEARCH", "INFO", "HDHIVE 检索成功") + + slug = hdhive_first.get("slug") + if not slug: + raise AppServiceError( + "HDHIVE 返回资源缺少 slug,无法解锁", + category="validation", + code="HDHIVE_INVALID_RESOURCE", + provider="hdhive", + ) + hdhive_unlock = unlock_link(slug) + normalized_resource = normalize_resource(hdhive_first, hdhive_unlock.get("data")) + log(task_id, "HDHIVE_UNLOCK", "INFO", "HDHIVE 解锁成功", {"unlockUrl": normalized_resource["unlockUrl"]}) + + emby_exists = exists_by_tmdb_id(payload["tmdbId"]) + log(task_id, "EMBY_EXISTS", "INFO", "Emby 查询完成", {"exists": emby_exists["exists"]}) + if emby_exists["exists"]: + upsert_media_item( + { + "tmdbId": payload["tmdbId"], + "type": payload["type"], + "title": tmdb_result["normalized"]["title"], + "year": tmdb_result["normalized"]["year"], + "tmdbRaw": tmdb_result["data"], + "hdhiveRaw": hdhive_search["data"], + "cmsId": None, + "ingestStatus": "SKIPPED_ALREADY_EXISTS", + } + ) + task["status"] = "SUCCESS" + task["finishedAt"] = now_iso() + task["summary"] = {"result": "SKIPPED_ALREADY_EXISTS", "source": "EMBY"} + upsert_task(task) + return task + + cms_payload = { + "tmdbId": payload["tmdbId"], + "mediaType": payload["type"], + "title": tmdb_result["normalized"]["title"], + "originalTitle": tmdb_result["normalized"]["originalTitle"], + "year": tmdb_result["normalized"]["year"], + "overview": tmdb_result["normalized"]["overview"], + "posterPath": tmdb_result["normalized"]["posterPath"], + "rating": tmdb_result["normalized"]["rating"], + "genres": tmdb_result["normalized"]["genres"], + "resource": { + "title": normalized_resource["resourceTitle"], + "quality": normalized_resource["quality"], + "size": normalized_resource["size"], + "diskType": normalized_resource["diskType"], + "slug": normalized_resource["slug"], + "source": normalized_resource["source"], + "subtitleLanguage": normalized_resource["subtitleLanguage"], + "unlockUrl": normalized_resource["unlockUrl"], + }, + "traceId": trace_id, + } + cms_result = create_resource(cms_payload) + cms_data = cms_result.get("data") or {} + cms_id = ( + cms_data.get("id") + or cms_data.get("resourceId") + or (cms_data.get("data") or {}).get("id") + or (cms_data.get("data") or {}).get("resourceId") + or normalized_resource["slug"] + ) + log(task_id, "CMS_CREATE", "INFO", "CMS 入库成功", {"cmsId": cms_id}) + + upsert_media_item( + { + "tmdbId": payload["tmdbId"], + "type": payload["type"], + "title": tmdb_result["normalized"]["title"], + "year": tmdb_result["normalized"]["year"], + "tmdbRaw": tmdb_result["data"], + "hdhiveRaw": hdhive_search["data"], + "cmsId": cms_id, + "ingestStatus": "SUCCESS", + } + ) + task["status"] = "SUCCESS" + task["finishedAt"] = now_iso() + task["summary"] = {"result": "CREATED", "cmsId": cms_id} + upsert_task(task) + return task + except Exception as error: + normalized_error = normalize_exception(error) + log(task_id, "FAILED", "ERROR", str(normalized_error), normalized_error.to_dict()) + task["status"] = "FAILED" + task["finishedAt"] = now_iso() + task["summary"] = {"error": normalized_error.to_dict()} + upsert_task(task) + return task diff --git a/backend/storage.py b/backend/storage.py new file mode 100644 index 0000000..7e1f3ec --- /dev/null +++ b/backend/storage.py @@ -0,0 +1,182 @@ +import json +import sqlite3 +from pathlib import Path + +DB_PATH = Path(__file__).resolve().parent / "media_crawler.db" + + +def get_conn(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(): + conn = get_conn() + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE IF NOT EXISTS tasks ( + task_id TEXT PRIMARY KEY, + trace_id TEXT NOT NULL, + status TEXT NOT NULL, + input_payload TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT, + summary TEXT + ) + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS media_items ( + tmdb_id TEXT PRIMARY KEY, + type TEXT NOT NULL, + title TEXT, + year TEXT, + tmdb_raw TEXT, + hdhive_raw TEXT, + cms_id TEXT, + ingest_status TEXT NOT NULL + ) + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS task_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + step TEXT NOT NULL, + level TEXT NOT NULL, + message TEXT NOT NULL, + detail TEXT, + created_at TEXT NOT NULL + ) + """ + ) + conn.commit() + conn.close() + + +def upsert_task(task): + conn = get_conn() + conn.execute( + """ + INSERT INTO tasks(task_id, trace_id, status, input_payload, started_at, finished_at, summary) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(task_id) DO UPDATE SET + status=excluded.status, + finished_at=excluded.finished_at, + summary=excluded.summary + """, + ( + task["taskId"], + task["traceId"], + task["status"], + json.dumps(task["inputPayload"], ensure_ascii=False), + task["startedAt"], + task.get("finishedAt"), + json.dumps(task.get("summary"), ensure_ascii=False), + ), + ) + conn.commit() + conn.close() + + +def insert_log(task_id, step, level, message, detail, created_at): + conn = get_conn() + conn.execute( + """ + INSERT INTO task_logs(task_id, step, level, message, detail, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + task_id, + step, + level, + message, + json.dumps(detail, ensure_ascii=False), + created_at, + ), + ) + conn.commit() + conn.close() + + +def upsert_media_item(item): + conn = get_conn() + conn.execute( + """ + INSERT INTO media_items(tmdb_id, type, title, year, tmdb_raw, hdhive_raw, cms_id, ingest_status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(tmdb_id) DO UPDATE SET + type=excluded.type, + title=excluded.title, + year=excluded.year, + tmdb_raw=excluded.tmdb_raw, + hdhive_raw=excluded.hdhive_raw, + cms_id=excluded.cms_id, + ingest_status=excluded.ingest_status + """, + ( + str(item["tmdbId"]), + item["type"], + item.get("title"), + item.get("year"), + json.dumps(item.get("tmdbRaw"), ensure_ascii=False), + json.dumps(item.get("hdhiveRaw"), ensure_ascii=False), + item.get("cmsId"), + item["ingestStatus"], + ), + ) + conn.commit() + conn.close() + + +def find_media_item(tmdb_id): + conn = get_conn() + row = conn.execute( + "SELECT * FROM media_items WHERE tmdb_id = ?", + (str(tmdb_id),), + ).fetchone() + conn.close() + if not row: + return None + return dict(row) + + +def list_tasks(limit=50): + conn = get_conn() + rows = conn.execute( + "SELECT * FROM tasks ORDER BY started_at DESC LIMIT ?", + (limit,), + ).fetchall() + conn.close() + result = [] + for row in rows: + item = dict(row) + item["inputPayload"] = json.loads(item.pop("input_payload") or "{}") + item["startedAt"] = item.pop("started_at") + item["finishedAt"] = item.pop("finished_at") + item["taskId"] = item.pop("task_id") + item["traceId"] = item.pop("trace_id") + item["summary"] = json.loads(item["summary"]) if item.get("summary") else None + result.append(item) + return result + + +def list_logs(task_id): + conn = get_conn() + rows = conn.execute( + "SELECT task_id, step, level, message, detail, created_at FROM task_logs WHERE task_id = ? ORDER BY id ASC", + (task_id,), + ).fetchall() + conn.close() + logs = [] + for row in rows: + item = dict(row) + item["taskId"] = item.pop("task_id") + item["createdAt"] = item.pop("created_at") + item["detail"] = json.loads(item["detail"]) if item.get("detail") else {} + logs.append(item) + return logs diff --git a/docker/start.sh b/docker/start.sh new file mode 100644 index 0000000..4de3cf9 --- /dev/null +++ b/docker/start.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env sh +set -eu + +BACKEND_PORT="${FLASK_RUN_PORT:-14620}" +FRONTEND_PORT="${FRONTEND_PORT:-14621}" + +cd /app/backend +python3 app.py & +BACKEND_PID=$! + +cd /app/frontend +npm run preview -- --host 0.0.0.0 --port "${FRONTEND_PORT}" & +FRONTEND_PID=$! + +cleanup() { + kill "${BACKEND_PID}" "${FRONTEND_PID}" 2>/dev/null || true +} + +trap cleanup INT TERM EXIT + +wait "${BACKEND_PID}" "${FRONTEND_PID}" diff --git a/docs/cms/[MEDIA]CMS入库115链接.json b/docs/cms/[MEDIA]CMS入库115链接.json new file mode 100644 index 0000000..69f42ff --- /dev/null +++ b/docs/cms/[MEDIA]CMS入库115链接.json @@ -0,0 +1,505 @@ +{ + "name": "[MEDIA]CMS入库115链接", + "nodes": [ + { + "parameters": { + "workflowInputs": { + "values": [ + { + "name": "url" + } + ] + } + }, + "type": "n8n-nodes-base.executeWorkflowTrigger", + "typeVersion": 1.1, + "position": [ + 0, + 0 + ], + "id": "fa49a99a-e52d-48ee-a126-c7ad2a128f35", + "name": "When Executed by Another Workflow" + }, + { + "parameters": { + "operation": "get", + "propertyName": "token", + "key": "CMS_TOKEN", + "keyType": "string", + "options": {} + }, + "type": "n8n-nodes-base.redis", + "typeVersion": 1, + "position": [ + 224, + -80 + ], + "id": "01719d30-77b8-411d-a7d7-0116ffde8725", + "name": "Redis", + "credentials": { + "redis": { + "id": "0auGFn4EkjXee2ul", + "name": "Redis account" + } + } + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "166573d5-cf9c-43a1-9337-39acf4338cb7", + "leftValue": "={{ $json.token }}", + "rightValue": "", + "operator": { + "type": "string", + "operation": "empty", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 432, + -80 + ], + "id": "490b5aa2-2ba7-4037-af2a-f5407e51984e", + "name": "If" + }, + { + "parameters": { + "workflowId": { + "__rl": true, + "value": "R9CpsJ3lkHFdx-Z3XMNnE", + "mode": "list", + "cachedResultUrl": "/workflow/R9CpsJ3lkHFdx-Z3XMNnE", + "cachedResultName": "CMS获取Token" + }, + "workflowInputs": { + "mappingMode": "defineBelow", + "value": {}, + "matchingColumns": [], + "schema": [], + "attemptToConvertTypes": false, + "convertFieldsToString": true + }, + "options": {} + }, + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.3, + "position": [ + 640, + -176 + ], + "id": "aa9f03a2-4640-4b23-bc83-4b679a308daa", + "name": "Call 'CMS获取Token'" + }, + { + "parameters": { + "mode": "raw", + "jsonOutput": "={\n \"token\": \"{{ $json.output }}\"\n}", + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 848, + -176 + ], + "id": "06971068-c7c4-414f-931c-e8cd4035e3c9", + "name": "Edit Fields" + }, + { + "parameters": { + "numberInputs": 3 + }, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 976, + -48 + ], + "id": "dc5ab1a6-e58c-4c7a-8d36-bf3c79465eb2", + "name": "Merge" + }, + { + "parameters": { + "jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nlet token = \"\";\nlet url = \"\";\nfor (const item of $input.all()) {\n if (\"token\" in item.json) {\n token = item.json.token;\n }\n if (\"url\" in item.json) {\n url = item.json.url;\n }\n}\n\nreturn {\n \"token\": token,\n \"url\": url\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1168, + -32 + ], + "id": "a06796c0-0572-438e-a13f-12a216bc283b", + "name": "Code in JavaScript" + }, + { + "parameters": { + "method": "POST", + "url": "https://cms.rc707blog.top/api/cloud/add_share_down", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "User-Agent", + "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0" + }, + { + "name": "Accept", + "value": "application/json, text/plain, */*" + }, + { + "name": "Accept-Language", + "value": "zh-CN,zh;q=0.9,zh-TW;q=0.8,zh-HK;q=0.7,en-US;q=0.6,en;q=0.5" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br, zstd" + }, + { + "name": "Authorization", + "value": "=Bearer {{ $json.token }}" + }, + { + "name": "Origin", + "value": "https://cms.rc707blog.top" + }, + { + "name": "DNT", + "value": "1" + }, + { + "name": "Alt-Used", + "value": "cms.rc707blog.top" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Referer", + "value": "https://cms.rc707blog.top/incremental-sync" + }, + { + "name": "Priority", + "value": "u=0" + } + ] + }, + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "url", + "value": "={{ $json.url }}" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 1376, + -32 + ], + "id": "a57cdbfc-dfea-4516-88ae-559dba2d71d8", + "name": "HTTP Request" + }, + { + "parameters": { + "jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nlet success = $input.first().json.code === 200;\nreturn {\n \"output\": success\n};" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2224, + 32 + ], + "id": "3161bf30-aa02-46a3-aa27-5e93f51083a6", + "name": "Code in JavaScript1" + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict", + "version": 3 + }, + "conditions": [ + { + "id": "e738a07c-9884-4ca3-a8de-079818a95c90", + "leftValue": "={{ $json.code }}", + "rightValue": 200, + "operator": { + "type": "number", + "operation": "notEquals" + } + }, + { + "id": "f2ec4f79-1c9e-47be-a39c-48dfe19fbd7b", + "leftValue": "={{ $json.msg }}", + "rightValue": "提取分享链接失败", + "operator": { + "type": "string", + "operation": "notEquals" + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "type": "n8n-nodes-base.if", + "typeVersion": 2.3, + "position": [ + 1552, + 128 + ], + "id": "737632fb-47ed-4108-8bb2-560cb5daeb86", + "name": "If1" + }, + { + "parameters": { + "operation": "delete", + "key": "CMS_TOKEN" + }, + "type": "n8n-nodes-base.redis", + "typeVersion": 1, + "position": [ + 1776, + 112 + ], + "id": "8287fa40-9f93-49a4-87f3-8b653ac2785d", + "name": "Redis1", + "credentials": { + "redis": { + "id": "0auGFn4EkjXee2ul", + "name": "Redis account" + } + } + }, + { + "parameters": { + "operation": "set", + "key": "CMS_TOKEN", + "value": "={{ $json.output }}", + "keyType": "string", + "expire": true, + "ttl": 3600 + }, + "type": "n8n-nodes-base.redis", + "typeVersion": 1, + "position": [ + 848, + -336 + ], + "id": "1534f994-2207-402d-814a-5eda492e9437", + "name": "Redis2", + "credentials": { + "redis": { + "id": "0auGFn4EkjXee2ul", + "name": "Redis account" + } + } + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.2, + "position": [ + 2000, + 32 + ], + "id": "72d832a4-f7ae-4d91-9d55-0dddd1f4c878", + "name": "Merge1" + } + ], + "pinData": { + "When Executed by Another Workflow": [ + { + "json": { + "url": "https://115cdn.com/s/swfl9s73zfr?password=0000" + } + } + ] + }, + "connections": { + "When Executed by Another Workflow": { + "main": [ + [ + { + "node": "Redis", + "type": "main", + "index": 0 + }, + { + "node": "Merge", + "type": "main", + "index": 2 + } + ] + ] + }, + "Redis": { + "main": [ + [ + { + "node": "If", + "type": "main", + "index": 0 + } + ] + ] + }, + "If": { + "main": [ + [ + { + "node": "Call 'CMS获取Token'", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Merge", + "type": "main", + "index": 1 + } + ] + ] + }, + "Call 'CMS获取Token'": { + "main": [ + [ + { + "node": "Edit Fields", + "type": "main", + "index": 0 + }, + { + "node": "Redis2", + "type": "main", + "index": 0 + } + ] + ] + }, + "Edit Fields": { + "main": [ + [ + { + "node": "Merge", + "type": "main", + "index": 0 + } + ] + ] + }, + "Merge": { + "main": [ + [ + { + "node": "Code in JavaScript", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code in JavaScript": { + "main": [ + [ + { + "node": "HTTP Request", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP Request": { + "main": [ + [ + { + "node": "If1", + "type": "main", + "index": 0 + }, + { + "node": "Merge1", + "type": "main", + "index": 0 + } + ] + ] + }, + "If1": { + "main": [ + [ + { + "node": "Redis1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Code in JavaScript1": { + "main": [ + [] + ] + }, + "Redis1": { + "main": [ + [ + { + "node": "Merge1", + "type": "main", + "index": 1 + } + ] + ] + }, + "Merge1": { + "main": [ + [ + { + "node": "Code in JavaScript1", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": true, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate", + "availableInMCP": false + }, + "versionId": "b2e168e5-9970-4116-ac54-b2644ac5c4dc", + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "66cf2dfa889c11bef16af6baad527d2d3bcad1ee4e7726db9de95c31e876ed5e" + }, + "id": "pH51j4qlAcf_gNk2gYEgH", + "tags": [] +} \ No newline at end of file diff --git a/docs/cms/[MEDIA]CMS获取Token.json b/docs/cms/[MEDIA]CMS获取Token.json new file mode 100644 index 0000000..8b99ce6 --- /dev/null +++ b/docs/cms/[MEDIA]CMS获取Token.json @@ -0,0 +1,147 @@ +{ + "name": "[MEDIA]CMS获取Token", + "nodes": [ + { + "parameters": { + "inputSource": "passthrough" + }, + "type": "n8n-nodes-base.executeWorkflowTrigger", + "typeVersion": 1.1, + "position": [ + 0, + 0 + ], + "id": "8b0db7d2-9dfa-43f6-abc1-04751f022c67", + "name": "When Executed by Another Workflow" + }, + { + "parameters": { + "method": "POST", + "url": "https://cms.rc707blog.top/api/auth/login", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "User-Agent", + "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0" + }, + { + "name": "Accept", + "value": "application/json, text/plain, */*" + }, + { + "name": "Accept-Language", + "value": "zh-CN,zh;q=0.9,zh-TW;q=0.8,zh-HK;q=0.7,en-US;q=0.6,en;q=0.5" + }, + { + "name": "Accept-Encoding", + "value": "gzip, deflate, br, zstd" + }, + { + "name": "Origin", + "value": "https://cms.rc707blog.top" + }, + { + "name": "DNT", + "value": "1" + }, + { + "name": "Alt-Used", + "value": "cms.rc707blog.top" + }, + { + "name": "Connection", + "value": "keep-alive" + }, + { + "name": "Referer", + "value": "https://cms.rc707blog.top/login" + }, + { + "name": "Priority", + "value": "u=0" + }, + { + "name": "TE", + "value": "trailers" + } + ] + }, + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "username", + "value": "rose_cat707" + }, + { + "name": "password", + "value": "ro983364" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 208, + 0 + ], + "id": "06e9804f-6bbd-43d7-a192-081394f9ee9e", + "name": "HTTP Request" + }, + { + "parameters": { + "mode": "raw", + "jsonOutput": "={\n\"output\": \"{{ $json.data.token }}\"\n}", + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 416, + 0 + ], + "id": "c93efa65-9381-403a-8d2c-3d3446e79bfa", + "name": "Edit Fields" + } + ], + "pinData": {}, + "connections": { + "When Executed by Another Workflow": { + "main": [ + [ + { + "node": "HTTP Request", + "type": "main", + "index": 0 + } + ] + ] + }, + "HTTP Request": { + "main": [ + [ + { + "node": "Edit Fields", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": true, + "settings": { + "executionOrder": "v1", + "binaryMode": "separate", + "availableInMCP": false + }, + "versionId": "683c7ff7-4b28-454f-8215-98f5d6b055aa", + "meta": { + "instanceId": "66cf2dfa889c11bef16af6baad527d2d3bcad1ee4e7726db9de95c31e876ed5e" + }, + "id": "R9CpsJ3lkHFdx-Z3XMNnE", + "tags": [] +} \ No newline at end of file diff --git a/docs/hdhive-openapi-docs/README.md b/docs/hdhive-openapi-docs/README.md new file mode 100644 index 0000000..7cd2053 --- /dev/null +++ b/docs/hdhive-openapi-docs/README.md @@ -0,0 +1,19 @@ +# HDHive OpenAPI 公开文档 + +本压缩包由当前前端 OpenAPI 文档内容生成,面向个人脚本、内部工具和第三方应用接入方。 + +## 文件 + +- [概述](./overview.md) +- [认证与授权](./authentication.md) +- [限制与错误处理](./limits.md) +- [通用响应格式](./response-format.md) +- [接口列表](./endpoints.md) + +## SDK 下载 + +SDK 下载页:`/api-docs/sdk` + +## 限制说明 + +本文档不公开具体 QPS 或每日额度。接入方应按 `429`、`Retry-After`、`limit_scope` 和 `retry_after_seconds` 做退避处理。 diff --git a/docs/hdhive-openapi-docs/authentication.md b/docs/hdhive-openapi-docs/authentication.md new file mode 100644 index 0000000..7963a55 --- /dev/null +++ b/docs/hdhive-openapi-docs/authentication.md @@ -0,0 +1,112 @@ +# 认证与授权 + +## API Key 认证 + +所有 OpenAPI 请求都必须携带 `X-API-Key`。 + +```http +X-API-Key: your-api-key +``` + +### 请求示例 + +```bash +curl -H "X-API-Key: your-api-key" https://hdhive.com/api/open/ping +``` + +## 用户身份来源 + +业务接口需要能确定调用用户。系统按以下顺序识别用户: + +1. `Authorization: Bearer `:第三方应用授权后获得,必须属于当前应用。 +2. `Authorization: Bearer <站内用户 JWT>`:站内登录态 Token。 +3. API Key 绑定用户:个人 API Key 或管理员绑定用户的 OpenAPI 应用可回退到绑定用户。 + +非 `meta` 类接口如果无法确定用户,会返回 `OPENAPI_USER_REQUIRED`。 + +## 第三方应用授权流程 + +第三方应用应使用管理员创建的 OpenAPI 应用完成授权: + +1. 管理员创建 OpenAPI 应用,配置应用名称、绑定用户、scope、IP 策略、回调地址等。 +2. 第三方应用引导用户进入前端授权页 `/openapi/authorize`,并传入 `client_id`、`redirect_uri`、`scope`、`state`。 +3. 用户确认授权后,HDHive 前端调用 `POST /api/public/openapi/oauth/authorize`,后端以 JSON 返回一次性授权码。 +4. HDHive 前端把用户重定向到第三方 `redirect_uri`,并在 query 中追加 `code` 和原始 `state`。 +5. 第三方应用服务端用授权码调用 `POST /api/public/openapi/oauth/token` 换取用户 Access Token 和 Refresh Token。 +6. 调用业务接口时同时传入应用 Secret 和用户 Access Token。 + +```http +X-API-Key: app-secret +Authorization: Bearer user-access-token +``` + +### 用户授权页 + +第三方应用应把用户跳转到站点前端授权页,而不是直接展示后端 API: + +```text +https://hdhive.com/openapi/authorize?client_id=app_xxx&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback&scope=query%20unlock&state=opaque-state +``` + +| 参数 | 必填 | 说明 | +|---|---|---| +| `client_id` | 是 | OpenAPI 应用公开 Client ID,不是 Secret | +| `redirect_uri` | 是 | 回调地址,必须在应用配置的回调地址列表内 | +| `scope` | 否 | 请求的用户授权 scope,空格分隔;为空时按应用允许范围处理 | +| `state` | 否 | 第三方应用自定义状态,建议用于 CSRF 防护和回跳上下文 | + +### 授权码换 Token + +后端换取 Token 时必须使用应用 Secret: + +```bash +curl -X POST https://hdhive.com/api/public/openapi/oauth/token \\ + -H "X-API-Key: app-secret" \\ + -H "Content-Type: application/json" \\ + -d '{ + "grant_type": "authorization_code", + "code": "authorization-code", + "redirect_uri": "https://client.example.com/callback" + }' +``` + +返回的 `access_token` 用于业务 OpenAPI 调用,`refresh_token` 用于刷新用户授权 Token。 + +`redirect_uri` 在换取 Token 时不会触发跳转,它是授权码安全校验字段,必须与创建授权码时传入的 `redirect_uri` 完全一致。后端会用它确认授权码确实来自同一次授权请求,避免授权码被截获后在其他回调上下文中兑换。 + +### 授权码回调形式 + +用户确认授权后,第三方应用收到的是浏览器跳转请求: + +```text +https://client.example.com/callback?code=authorization-code&state=opaque-state +``` + +`code` 是一次性授权码,第三方应用应该在自己的服务端用应用 Secret 换取 Token,不要在浏览器里暴露应用 Secret。 + +## Scope + +| scope | 说明 | 典型能力 | +|---|---|---| +| `meta` | 元信息接口 | 健康检查、配额、用量查询 | +| `query` | 查询接口 | 资源查询、分享详情、资源链接检查 | +| `write` | 写入接口 | 创建、更新、删除分享 | +| `unlock` | 解锁接口 | 解锁资源并获取链接 | +| `vip` | VIP 接口 | 用户信息、签到、VIP 用量 | + +应用必须拥有对应 scope,用户授权 Token 如果限制了 scope,也必须包含对应 scope。 + +## 认证错误码 + +| 错误码 | HTTP 状态码 | 说明 | +|---|---|---| +| `MISSING_API_KEY` | 401 | 未提供 `X-API-Key` | +| `INVALID_API_KEY` | 401 | API Key 无效或不存在 | +| `DISABLED_API_KEY` | 401 | API Key 已被禁用 | +| `EXPIRED_API_KEY` | 401 | API Key 已过期 | +| `INVALID_OPENAPI_USER_TOKEN` | 401 | OpenAPI 用户 Token 无效 | +| `OPENAPI_TOKEN_APP_MISMATCH` | 401 | 用户 Token 不属于当前应用 | +| `OPENAPI_USER_REQUIRED` | 403 | 业务接口缺少用户身份 | +| `SCOPE_NOT_ALLOWED` | 403 | 应用未授权该接口组 | +| `USER_SCOPE_NOT_ALLOWED` | 403 | 用户授权 Token scope 不足 | +| `VIP_REQUIRED` | 403 | 当前用户不满足 Premium 要求 | diff --git a/docs/hdhive-openapi-docs/endpoints.md b/docs/hdhive-openapi-docs/endpoints.md new file mode 100644 index 0000000..a3812f0 --- /dev/null +++ b/docs/hdhive-openapi-docs/endpoints.md @@ -0,0 +1,518 @@ +# 接口列表 + +OpenAPI 分为两类接口: + +- **用户授权接口**:以 `/api/public/openapi/oauth` 为前缀,用于授权预览、确认授权、换取 Token 和撤销 Token。 +- **业务接口**:以 `/api/open` 为前缀,请求必须携带 `X-API-Key`。需要代表具体用户执行的接口,还应携带用户 Access Token;个人 API Key 或绑定用户的应用可回退到绑定用户。 + +## 接口总览 + +### 用户授权接口 + +| 方法 | 路径 | 认证 | 说明 | +|---|---|---|---| +| GET | `/api/public/openapi/oauth/authorize` | 站内登录 Cookie | 授权页预览信息 | +| POST | `/api/public/openapi/oauth/authorize` | 站内登录 Cookie | 用户确认授权并生成授权码 | +| POST | `/api/public/openapi/oauth/token` | `X-API-Key` 应用 Secret | 授权码或刷新令牌换取用户 Token | +| POST | `/api/public/openapi/oauth/revoke` | `X-API-Key` 应用 Secret | 撤销刷新令牌 | + +### 业务接口 + +| scope | 方法 | 路径 | 说明 | +|---|---|---|---| +| `meta` | GET | `/api/open/ping` | 健康检查 | +| `meta` | GET | `/api/open/quota` | 查询当前凭证配额状态 | +| `meta` | GET | `/api/open/usage` | 查询历史用量 | +| `meta` | GET | `/api/open/usage/today` | 查询今日用量 | +| `query` | GET | `/api/open/resources/:type/:tmdb_id` | 根据 TMDB ID 获取资源列表 | +| `query` | GET | `/api/open/shares/:slug` | 获取分享详情 | +| `query` | POST | `/api/open/check/resource` | 检查资源链接类型 | +| `unlock` | POST | `/api/open/resources/unlock` | 解锁资源 | +| `write` | GET | `/api/open/shares` | 获取我的分享列表 | +| `write` | POST | `/api/open/shares` | 创建分享 | +| `write` | PATCH | `/api/open/shares/:slug` | 更新分享 | +| `write` | DELETE | `/api/open/shares/:slug` | 删除分享 | +| `vip` | GET | `/api/open/me` | 获取当前用户信息 | +| `vip` | POST | `/api/open/checkin` | 每日签到 | +| `vip` | GET | `/api/open/vip/weekly-free-quota` | 获取长期 Premium 每周免费解锁状态 | + +--- + +## 用户授权流程接口 + +第三方应用的用户授权入口应使用前端页面: + +```text +GET /openapi/authorize?client_id=app_xxx&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback&scope=query%20unlock&state=opaque-state +``` + +前端页面会要求用户登录,并调用下面的后端授权接口。 + +--- + +## GET /api/public/openapi/oauth/authorize + +获取授权页展示信息。该接口需要站内登录 Cookie;未登录会返回 `401`,前端授权页会跳转到登录页。 + +### Query 参数 + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `client_id` | string | 是 | OpenAPI 应用公开 Client ID | +| `redirect_uri` | string | 是 | 授权完成回调地址,必须在应用配置中 | +| `scope` | string | 否 | 请求 scope,空格分隔,例如 `query unlock` | +| `state` | string | 否 | 第三方应用自定义状态 | + +### 成功响应字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `data.client_id` | string | 应用 Client ID | +| `data.app_name` | string | 应用名称 | +| `data.description` | string | 应用描述 | +| `data.auth_level` | string | 应用授权等级 | +| `data.redirect_uri` | string | 本次授权回调地址 | +| `data.requested_scopes` | string[] | 本次请求 scope | +| `data.allowed_scopes` | string[] | 应用实际允许 scope | +| `data.user` | object | 当前登录用户摘要 | +| `data.expires_in` | integer | 授权码有效期秒数 | + +--- + +## POST /api/public/openapi/oauth/authorize + +用户确认授权并生成授权码。该接口需要站内登录 Cookie。 + +该接口本身返回 JSON 给 HDHive 前端授权页。HDHive 前端收到 `data.code` 后,会重定向到第三方 `redirect_uri`,并追加 `code` 和原始 `state`: + +```text +https://client.example.com/callback?code=authorization-code&state=opaque-state +``` + +### Body 参数 + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `client_id` | string | 是 | OpenAPI 应用公开 Client ID | +| `redirect_uri` | string | 是 | 授权完成回调地址 | +| `scope` | string | 否 | 请求 scope,空格分隔 | +| `state` | string | 否 | 第三方应用自定义状态 | + +### 请求示例 + +```bash +curl -X POST https://hdhive.com/api/public/openapi/oauth/authorize \\ + -H "Content-Type: application/json" \\ + -d '{ + "client_id": "app_xxx", + "redirect_uri": "https://client.example.com/callback", + "scope": "query unlock", + "state": "opaque-state" + }' +``` + +### 成功响应 + +```json +{ + "success": true, + "code": "200", + "message": "success", + "data": { + "code": "authorization-code", + "redirect_uri": "https://client.example.com/callback", + "expires_in": 600 + } +} +``` + +第三方应用通过自己的回调地址收到授权码后,应在服务端用应用 Secret 换取用户 Token。 + +--- + +## POST /api/public/openapi/oauth/token + +使用授权码或刷新令牌换取 OpenAPI 用户 Token。该接口必须携带 OpenAPI 应用 Secret。 + +`redirect_uri` 只在 `authorization_code` 模式下需要。它不是跳转地址,而是授权码校验字段,必须与用户授权阶段传入的 `redirect_uri` 完全一致;后端会同时校验授权码、应用 Secret 和 `redirect_uri` 的绑定关系。 + +### Header + +```http +X-API-Key: app-secret +Content-Type: application/json +``` + +### authorization_code Body + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `grant_type` | string | 是 | 固定为 `authorization_code` | +| `code` | string | 是 | 用户确认授权后得到的授权码 | +| `redirect_uri` | string | 是 | 安全校验字段,必须与授权码创建时传入的回调地址完全一致;不会触发跳转 | + +### refresh_token Body + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `grant_type` | string | 是 | 固定为 `refresh_token` | +| `refresh_token` | string | 是 | 刷新令牌 | + +### 请求示例 + +```bash +curl -X POST https://hdhive.com/api/public/openapi/oauth/token \\ + -H "X-API-Key: app-secret" \\ + -H "Content-Type: application/json" \\ + -d '{ + "grant_type": "authorization_code", + "code": "authorization-code", + "redirect_uri": "https://client.example.com/callback" + }' +``` + +### 成功响应字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `data.access_token` | string | OpenAPI 用户 Access Token | +| `data.refresh_token` | string | Refresh Token,刷新时可能返回新值 | +| `data.token_type` | string | 固定为 `Bearer` | +| `data.expires_in` | integer | Access Token 有效期秒数 | +| `data.refresh_expires_in` | integer | Refresh Token 有效期秒数 | +| `data.scope` | string | 空格分隔 scope | +| `data.scopes` | string[] | scope 数组 | + +--- + +## POST /api/public/openapi/oauth/revoke + +撤销某个 Refresh Token。撤销后该刷新令牌不能再换取新的用户 Access Token。 + +### Header + +```http +X-API-Key: app-secret +Content-Type: application/json +``` + +### Body 参数 + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `refresh_token` | string | 是 | 要撤销的刷新令牌 | + +### 请求示例 + +```bash +curl -X POST https://hdhive.com/api/public/openapi/oauth/revoke \\ + -H "X-API-Key: app-secret" \\ + -H "Content-Type: application/json" \\ + -d '{"refresh_token": "refresh-token"}' +``` + +--- + +## GET /api/open/ping + +健康检查接口,用于验证 API Key 是否有效。 + +### 请求示例 + +```bash +curl -H "X-API-Key: your-api-key" https://hdhive.com/api/open/ping +``` + +### 成功响应 + +```json +{ + "success": true, + "code": "200", + "message": "success", + "data": { + "message": "pong", + "api_key_id": 1, + "name": "My App" + } +} +``` + +--- + +## GET /api/open/quota + +查询当前 API Key 的配额状态。该接口用于展示服务端返回的当前状态,不应在客户端写死限制值。 + +### 请求示例 + +```bash +curl -H "X-API-Key: your-api-key" https://hdhive.com/api/open/quota +``` + +### 响应字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `data.daily_reset` | integer | 配额重置时间 | +| `data.endpoint_limit` | integer / null | 当前接口限制状态,未配置时为空 | +| `data.endpoint_remaining` | integer / null | 当前接口剩余状态,未配置时为空 | + +--- + +## GET /api/open/usage + +查询当前 API Key 的历史用量统计。 + +### 请求参数 + +| 参数 | 位置 | 类型 | 必填 | 说明 | +|---|---|---|---|---| +| `start_date` | query | string | 否 | 开始日期,格式 `YYYY-MM-DD` | +| `end_date` | query | string | 否 | 结束日期,格式 `YYYY-MM-DD` | + +### 请求示例 + +```bash +curl -H "X-API-Key: your-api-key" \\ + "https://hdhive.com/api/open/usage?start_date=2026-04-01&end_date=2026-04-25" +``` + +### 响应字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `data.daily_stats` | array | 每日调用统计 | +| `data.endpoint_stats` | array | 按接口聚合的统计 | +| `data.summary` | object | 汇总统计 | + +--- + +## GET /api/open/resources/:type/:tmdb_id + +根据媒体类型和 TMDB ID 获取资源列表。 + +### 请求参数 + +| 参数 | 位置 | 类型 | 必填 | 说明 | +|---|---|---|---|---| +| `type` | path | string | 是 | 媒体类型,`movie` 或 `tv` | +| `tmdb_id` | path | string | 是 | TMDB ID | + +### 请求示例 + +```bash +curl -H "X-API-Key: your-api-key" \\ + -H "Authorization: Bearer user-access-token" \\ + https://hdhive.com/api/open/resources/movie/550 +``` + +### 响应字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `data[].slug` | string | 资源唯一标识 | +| `data[].title` | string / null | 资源标题 | +| `data[].pan_type` | string / null | 网盘类型 | +| `data[].share_size` | string / null | 分享文件大小 | +| `data[].video_resolution` | string[] | 视频分辨率 | +| `data[].source` | string[] | 片源 | +| `data[].subtitle_language` | string[] | 字幕语言 | +| `data[].subtitle_type` | string[] | 字幕类型 | +| `data[].unlock_points` | integer / null | 解锁所需积分 | +| `data[].is_unlocked` | boolean | 当前用户是否已解锁 | +| `data[].user` | object / null | 分享者信息 | +| `meta.total` | integer | 返回数量 | + +--- + +## POST /api/open/resources/unlock + +代表当前用户解锁资源并获取链接。第三方应用调用时应携带用户 Access Token。 + +### 请求参数 + +| 参数 | 位置 | 类型 | 必填 | 说明 | +|---|---|---|---|---| +| `slug` | body | string | 是 | 资源 slug,支持带横杠或不带横杠的 UUID | + +### 请求示例 + +```bash +curl -X POST \\ + -H "X-API-Key: app-secret" \\ + -H "Authorization: Bearer user-access-token" \\ + -H "Content-Type: application/json" \\ + -d '{"slug": "a1b2c3d4e5f647898765432112345678"}' \\ + https://hdhive.com/api/open/resources/unlock +``` + +### 成功响应 + +```json +{ + "success": true, + "code": "200", + "message": "解锁成功", + "data": { + "url": "https://pan.example.com/s/abc123", + "access_code": "x1y2", + "full_url": "https://pan.example.com/s/abc123?pwd=x1y2", + "already_owned": false + } +} +``` + +### 错误响应 + +| 场景 | HTTP 状态码 | 错误码 | +|---|---|---| +| 参数无效 | 400 | `400` | +| 缺少用户身份 | 403 | `OPENAPI_USER_REQUIRED` | +| 资源不存在 | 404 | `404` | +| 积分不足 | 402 | `INSUFFICIENT_POINTS` | +| 用户请求过快 | 429 | `RATE_LIMIT_EXCEEDED` | + +> unlock 接口保留用户阶梯风控。第三方应用触发时,处罚对象是当前授权用户,不是整个应用。 + +--- + +## POST /api/open/check/resource + +检查资源链接所属网盘类型,并尝试解析部分网盘的访问码。 + +### 请求参数 + +| 参数 | 位置 | 类型 | 必填 | 说明 | +|---|---|---|---|---| +| `url` | body | string | 是 | 资源分享链接 | + +### 请求示例 + +```bash +curl -X POST \\ + -H "X-API-Key: your-api-key" \\ + -H "Authorization: Bearer user-access-token" \\ + -H "Content-Type: application/json" \\ + -d '{"url": "https://115.com/s/example 访问码:1234"}' \\ + https://hdhive.com/api/open/check/resource +``` + +--- + +## GET /api/open/me + +获取当前用户信息。该接口需要 `vip` scope,并要求当前用户满足 Premium 条件。 + +### 请求示例 + +```bash +curl -H "X-API-Key: app-secret" \\ + -H "Authorization: Bearer user-access-token" \\ + https://hdhive.com/api/open/me +``` + +### 响应字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `data.id` | number | 用户 ID | +| `data.nickname` | string | 昵称 | +| `data.username` | string | 用户名 | +| `data.email` | string | 邮箱 | +| `data.avatar_url` | string | 头像 | +| `data.is_vip` | boolean | 是否 Premium | +| `data.user_meta` | object | 用户积分、签到、分享等元信息 | + +--- + +## POST /api/open/checkin + +代表当前用户执行每日签到。该接口需要 `vip` scope,并要求当前用户满足 Premium 条件。 + +### 请求参数 + +| 参数 | 位置 | 类型 | 必填 | 说明 | +|---|---|---|---|---| +| `is_gambler` | body | boolean | 否 | 是否使用高波动签到模式 | + +--- + +## GET /api/open/vip/weekly-free-quota + +获取当前用户长期 Premium 每周免费解锁状态。该接口只返回状态,不建议客户端假设配置固定。 + +### 响应字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `is_forever_vip` | boolean | 是否长期 Premium | +| `limit` | int | 服务端当前配置的基础状态值 | +| `used` | int | 本周已使用次数 | +| `remaining` | int | 当前可用状态 | +| `unlimited` | boolean | 是否不限制 | +| `bonus_quota` | int | 累积状态值 | +| `bonus_quota_max` | int | 累积上限状态值 | + +--- + +## GET /api/open/shares + +获取当前用户的分享列表。需要 `write` scope。 + +### 请求参数 + +| 参数 | 位置 | 类型 | 必填 | 说明 | +|---|---|---|---|---| +| `page` | query | integer | 否 | 页码 | +| `page_size` | query | integer | 否 | 每页数量 | + +--- + +## GET /api/open/shares/:slug + +获取指定分享详情。该接口不返回资源下载链接和访问码,需要调用 unlock 接口解锁后获取。 + +--- + +## POST /api/open/shares + +创建新的资源分享。需要 `write` scope。 + +### 关键参数 + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `url` | string | 是 | 分享链接 | +| `tmdb_id` | string | 条件必填 | TMDB ID,需配合 `media_type` | +| `media_type` | string | 条件必填 | `movie` 或 `tv` | +| `movie_id` | integer | 条件必填 | 系统电影 ID | +| `tv_id` | integer | 条件必填 | 系统电视剧 ID | +| `collection_id` | integer | 否 | 系统合集 ID | +| `title` | string | 否 | 资源标题 | +| `access_code` | string | 否 | 访问码 | +| `unlock_points` | integer | 否 | 解锁积分,未传或 0 表示免费 | +| `is_anonymous` | boolean | 否 | 是否匿名分享 | +| `hide_link` | boolean | 否 | 是否在通知中隐藏链接 | + +> 影视关联至少需要提供 `tmdb_id + media_type`、`movie_id`、`tv_id` 或 `collection_id` 之一。 + +--- + +## PATCH /api/open/shares/:slug + +部分更新已有分享。需要 `write` scope。只能更新自己创建的分享,或当前用户具备对应权限的分享。 + +--- + +## DELETE /api/open/shares/:slug + +删除分享。需要 `write` scope。只能删除自己创建的分享,或当前用户具备对应权限的分享。 + +--- + +# 变更日志 + +| 日期 | 变更内容 | +|---|---| +| 2026-04-25 | 文档更新为新版 OpenAPI 应用授权、用户授权 Token、scope、限制与错误码口径 | diff --git a/docs/hdhive-openapi-docs/limits.md b/docs/hdhive-openapi-docs/limits.md new file mode 100644 index 0000000..9a676d6 --- /dev/null +++ b/docs/hdhive-openapi-docs/limits.md @@ -0,0 +1,78 @@ +# 限制与错误处理 + +## 限制维度 + +OpenAPI 会按多个维度保护系统稳定性: + +- **应用级限制**:按 API Key / OpenAPI 应用维度限制请求频率和突发流量。 +- **用户级限制**:按实际调用用户维度限制请求频率和每日成功业务请求。 +- **接口组限制**:按 `meta/query/write/unlock/vip` 分组控制访问频率和是否计量。 +- **IP 策略**:支持不限制 IP、固定 IP 白名单、动态 IP 风控。 +- **unlock 风控**:资源解锁接口保留旧的用户阶梯处罚监测,处罚对象是实际授权用户,不是整个第三方应用。 + +具体阈值由管理员配置,不在开发者文档中公开。接入方只需要按响应头和错误码处理退避。 + +## 429 处理规则 + +当请求返回 `429` 时,客户端应读取 `Retry-After` 头,并在等待后重试。不要在冷却期内持续重试。 + +```http +HTTP/1.1 429 Too Many Requests +Retry-After: 300 +``` + +429 错误响应会通过 `limit_scope` 明确说明限制对象: + +```json +{ + "success": false, + "code": "USER_RATE_LIMIT_EXCEEDED", + "message": "User OpenAPI rate limit exceeded", + "description": "用户 OpenAPI 请求频率过高,当前授权用户已进入冷却,请稍后重试", + "limit_scope": "user", + "limit_scope_label": "授权用户", + "retry_after_seconds": 300 +} +``` + +`limit_scope=app` 表示当前应用或 API Key 被限制,所有使用该应用的请求都应退避;`limit_scope=user` 表示当前授权用户被限制,不代表整个第三方应用不可用。 + +## 常见限制错误码 + +| 错误码 | HTTP 状态码 | 说明 | +|---|---|---| +| `OPENAPI_COOLDOWN` | 429 | 应用或用户处于 OpenAPI 冷却期,具体对象读取 `limit_scope` | +| `IP_COUNT_EXCEEDED` | 429 | 动态 IP 风控触发 | +| `IP_NOT_ALLOWED` | 403 | 请求 IP 不在白名单内 | +| `APP_RATE_LIMIT_EXCEEDED` | 429 | 应用请求频率过高 | +| `APP_BURST_LIMIT_EXCEEDED` | 429 | 应用突发请求过高 | +| `APP_IP_RATE_LIMIT_EXCEEDED` | 429 | 应用单 IP 请求频率过高 | +| `ENDPOINT_GROUP_RATE_LIMIT_EXCEEDED` | 429 | 接口组请求频率过高 | +| `USER_RATE_LIMIT_EXCEEDED` | 429 | 用户 OpenAPI 请求频率过高 | +| `APP_DAILY_QUOTA_EXCEEDED` | 429 | 应用今日成功业务请求额度耗尽 | +| `USER_DAILY_QUOTA_EXCEEDED` | 429 | 用户今日成功业务请求额度耗尽 | +| `RATE_LIMIT_EXCEEDED` | 429 | unlock 用户阶梯风控触发 | + +## 配额响应头 + +部分响应会返回当前用户或应用的剩余信息。客户端可以展示这些信息,但不能假设阈值固定不变。 + +| Header | 说明 | +|---|---| +| `Retry-After` | 建议等待秒数 | +| `X-OpenAPI-User-Daily-Limit` | 用户当日额度上限,存在时返回 | +| `X-OpenAPI-User-Daily-Remaining` | 用户当日剩余额度,存在时返回 | +| `X-OpenAPI-App-Daily-Limit` | 应用当日额度上限,存在时返回 | +| `X-OpenAPI-App-Daily-Remaining` | 应用当日剩余额度,存在时返回 | + +## 限制字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `limit_scope` | string | 限制对象,`app` 表示应用,`user` 表示授权用户 | +| `limit_scope_label` | string | 限制对象中文说明 | +| `retry_after_seconds` | integer | 建议等待秒数,与 `Retry-After` Header 对齐 | + +## 计量说明 + +`meta` 类接口用于健康检查、配额和用量查询,通常不计入用户每日业务额度。非成功响应会回滚本次已预占的每日额度。 diff --git a/docs/hdhive-openapi-docs/overview.md b/docs/hdhive-openapi-docs/overview.md new file mode 100644 index 0000000..0a8061d --- /dev/null +++ b/docs/hdhive-openapi-docs/overview.md @@ -0,0 +1,32 @@ +# HDHive OpenAPI 文档 + +## 概述 + +HDHive OpenAPI 面向个人脚本、内部工具和第三方应用,提供资源查询、资源解锁、分享管理、用户信息和用量查询能力。 + +新版 OpenAPI 使用统一平台凭证: + +- **个人 API Key**:用户自己创建,适合个人脚本和自用自动化。 +- **OpenAPI 应用**:管理员创建,适合第三方应用或合作方系统。 +- **用户授权 Token**:用户通过第三方应用授权后获得,代表具体用户执行业务操作。 + +## 基本信息 + +- **业务接口 Base URL**:`/api/open` +- **用户授权接口 Base URL**:`/api/public/openapi/oauth` +- **用户授权页**:`/openapi/authorize` +- **认证方式**:`X-API-Key` 必填,业务接口可附加 `Authorization: Bearer ` +- **响应格式**:JSON +- **权限模型**:应用 scope + 用户授权 scope + 用户身份共同决定可访问范围 +- **SDK 与文档下载**:[打开 SDK 下载页](/api-docs/sdk),可下载 Go / Python / Node SDK 和公开版 API 文档 ZIP + +## 文档原则 + +本文档只说明接入方式、接口语义、认证流程和错误码,不展示具体 QPS 或每日额度。具体限制由管理员在「OpenAPI 策略」和「OpenAPI 应用」中配置,接入方应按 `429` 响应和 `Retry-After` 头进行退避重试。 + +## 目录 + +- **认证与授权** — API Key、用户 Token、scope、用户身份来源 +- **限制与错误处理** — 429、冷却、IP 策略、额度响应头 +- **通用响应格式** — 成功/错误响应结构、错误码汇总 +- **接口列表** — 主要 OpenAPI 接口说明、请求参数与响应示例 diff --git a/docs/hdhive-openapi-docs/response-format.md b/docs/hdhive-openapi-docs/response-format.md new file mode 100644 index 0000000..743b15a --- /dev/null +++ b/docs/hdhive-openapi-docs/response-format.md @@ -0,0 +1,63 @@ +# 通用响应格式 + +所有 OpenAPI 接口均采用统一 JSON 响应结构。 + +## 成功响应 + +```json +{ + "success": true, + "code": "200", + "message": "success", + "data": { "...": "..." }, + "meta": { "...": "..." } +} +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `success` | boolean | 是否成功 | +| `code` | string | 状态码或业务码 | +| `message` | string | 响应消息 | +| `data` | object / array / null | 响应数据 | +| `meta` | object / null | 分页、统计等元信息 | + +## 错误响应 + +```json +{ + "success": false, + "code": "ERROR_CODE", + "message": "Error message", + "description": "详细错误描述", + "limit_scope": "app", + "limit_scope_label": "应用", + "retry_after_seconds": 300 +} +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `success` | boolean | 固定为 `false` | +| `code` | string | 错误码 | +| `message` | string | 错误消息 | +| `description` | string | 面向用户或开发者的中文说明 | +| `limit_scope` | string / undefined | 限制类错误的对象,可能为 `app` 或 `user` | +| `limit_scope_label` | string / undefined | 限制对象中文说明 | +| `retry_after_seconds` | integer / undefined | 建议等待秒数 | + +## 业务错误码 + +| 错误码 | HTTP 状态码 | 说明 | +|---|---|---| +| `400` | 400 | 请求参数错误 | +| `401` | 401 | 未授权 | +| `403` | 403 | 无权访问 | +| `404` | 404 | 资源不存在 | +| `500` | 500 | 服务内部错误 | +| `INSUFFICIENT_POINTS` | 402 | 积分不足 | +| `VIP_REQUIRED` | 403 | 需要 Premium | +| `SCOPE_NOT_ALLOWED` | 403 | 应用 scope 不足 | +| `USER_SCOPE_NOT_ALLOWED` | 403 | 用户授权 scope 不足 | +| `OPENAPI_USER_REQUIRED` | 403 | 缺少用户身份 | +| `RATE_LIMIT_EXCEEDED` | 429 | 请求触发限制,应按 `Retry-After` 退避 | diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..86b0ad3 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_BACKEND_BASE_URL=http://127.0.0.1:14620 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..2b56de4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..65e36b7 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1546 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "vue": "^3.5.32", + "vue-router": "^5.0.6" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.6", + "vite": "^8.0.10" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@jridgewell/resolve-uri/download/@jridgewell/resolve-uri-3.1.2.tgz", + "integrity": "sha1-eg7mAfYPmaIMfHxf8MgDiMEYm9Y=", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.128.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@oxc-project/types/-/types-0.128.0.tgz", + "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.18", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.18", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.18", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz", + "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.18", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.18", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.18", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz", + "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.18", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.18", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.13", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.6", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz", + "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.13" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue-macros/common": { + "version": "3.1.2", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@vue-macros/common/-/common-3.1.2.tgz", + "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.34", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@vue/compiler-core/-/compiler-core-3.5.34.tgz", + "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.34", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.34", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", + "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.34", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", + "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.34", + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.14", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.34", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", + "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.1.2", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@vue/devtools-api/-/devtools-api-8.1.2.tgz", + "integrity": "sha512-vA0O112YqyDuNA1s7Yb2gCgToQ/OxOWiFDO5ThLCcDy0ldHnSd1dUTaSYhOldbqoNgumE4dxtGAoAaSUKUD1Zg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.1.2" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.1.2", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@vue/devtools-kit/-/devtools-kit-8.1.2.tgz", + "integrity": "sha512-f75/upc+GCyjXErpgPGz4582ujS0L/adAltGy+tqXMGUJpgAcfGr6CxnnhpZY8BHuMYt6KpbF8uaFrrQG66rGQ==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.1.2", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.1.2", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@vue/devtools-shared/-/devtools-shared-8.1.2.tgz", + "integrity": "sha512-X9RyVFYAdkBe4IUf5v48TxBF/6QPmF8CmWrDAjXzfUHrgQ/HGfTC1A6TqgXqZ03ye66l3AD51BAGD69IvKM9sw==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.34", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@vue/reactivity/-/reactivity-3.5.34.tgz", + "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.34", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@vue/runtime-core/-/runtime-core-3.5.34.tgz", + "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.34", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", + "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/runtime-core": "3.5.34", + "@vue/shared": "3.5.34", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.34", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@vue/server-renderer/-/server-renderer-3.5.34.tgz", + "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "vue": "3.5.34" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.34", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@vue/shared/-/shared-3.5.34.tgz", + "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.8.3", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz", + "integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "ast-kit": "^2.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/estree-walker/download/estree-walker-2.0.2.tgz", + "integrity": "sha1-UvAQF4wqTBF6d1fP6UKtt9LaTKw=", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/fsevents/download/fsevents-2.3.3.tgz", + "integrity": "sha1-ysZAd4XQNnWipeGlMFxpezR9kNY=", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/hookable/download/hookable-5.5.3.tgz", + "integrity": "sha1-bPw1iYSh75keJRjLntSneLvTIV0=", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/json5/download/json5-2.2.3.tgz", + "integrity": "sha1-eM1vGhm9wStz21rQxh79ZsHikoM=", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/nanoid/download/nanoid-3.3.12.tgz", + "integrity": "sha1-qz2RLiF6bQpRTwCnKhZUOiiYLAU=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/picocolors/download/picocolors-1.1.1.tgz", + "integrity": "sha1-PTIa8+q5ObCDyPkpodEs2oHCa2s=", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.18", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/rolldown/-/rolldown-1.0.0-rc.18.tgz", + "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.128.0", + "@rolldown/pluginutils": "1.0.0-rc.18" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-x64": "1.0.0-rc.18", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.18", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", + "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", + "dev": true, + "license": "MIT" + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/source-map-js/download/source-map-js-1.2.1.tgz", + "integrity": "sha1-HOVlD93YerwJnto33P8CTCZnrkY=", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/tslib/download/tslib-2.8.1.tgz", + "integrity": "sha1-YS7+TtI11Wfoq6Xypfq3AoCt6D8=", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/vite": { + "version": "8.0.11", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/vite/-/vite-8.0.11.tgz", + "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.0-rc.18", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.34", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/vue/-/vue-3.5.34.tgz", + "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-sfc": "3.5.34", + "@vue/runtime-dom": "3.5.34", + "@vue/server-renderer": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "5.0.6", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/vue-router/-/vue-router-5.0.6.tgz", + "integrity": "sha512-9+kmUTGbKMyW9Asoy98IXXYIzrTMT7JDAdpDDeEkorHvybpUvBI2wsrSM5jFOXrFydpzRFJ9vAh+80DN2PGu9w==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.28.6", + "@vue-macros/common": "^3.1.1", + "@vue/devtools-api": "^8.0.6", + "ast-walker-scope": "^0.8.3", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "muggle-string": "^0.4.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "scule": "^1.3.0", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1", + "yaml": "^2.8.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.17", + "pinia": "^3.0.4", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + } + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://artifactory.devops.xiaohongshu.com/artifactory/api/npm/npm-public/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d79dd0f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.32", + "vue-router": "^5.0.6" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.6", + "vite": "^8.0.10" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..e470f86 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,13 @@ + diff --git a/frontend/src/api/backendApi.js b/frontend/src/api/backendApi.js new file mode 100644 index 0000000..0ea30b7 --- /dev/null +++ b/frontend/src/api/backendApi.js @@ -0,0 +1,42 @@ +const API_BASE_URL = import.meta.env.VITE_BACKEND_BASE_URL || "http://127.0.0.1:14620"; + +async function request(path, options = {}) { + const response = await fetch(`${API_BASE_URL}${path}`, options); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error( + data?.error?.message || data?.message || `Request failed: ${response.status}`, + ); + } + return data; +} + +export function createTask(payload) { + return request("/api/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export function searchMedia(query, type = "movie") { + return request( + `/api/media/search?query=${encodeURIComponent(query)}&type=${encodeURIComponent(type)}`, + ); +} + +export function fetchMediaDetail(type, tmdbId) { + return request(`/api/media/${encodeURIComponent(type)}/${encodeURIComponent(tmdbId)}`); +} + +export function ingestMediaResource(payload) { + return createTask(payload); +} + +export function fetchTasks() { + return request("/api/tasks"); +} + +export function fetchTaskLogs(taskId) { + return request(`/api/tasks/${taskId}/logs`); +} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/frontend/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue new file mode 100644 index 0000000..f91553d --- /dev/null +++ b/frontend/src/components/HelloWorld.vue @@ -0,0 +1,95 @@ + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..7b19912 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,6 @@ +import { createApp } from 'vue' +import './style.css' +import App from './App.vue' +import router from './router' + +createApp(App).use(router).mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..9f72519 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,15 @@ +import { createRouter, createWebHistory } from "vue-router"; +import SearchPage from "../views/SearchPage.vue"; +import MediaDetailPage from "../views/MediaDetailPage.vue"; +import TasksPage from "../views/TasksPage.vue"; + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: "/", name: "search", component: SearchPage }, + { path: "/media/:type/:tmdbId", name: "media-detail", component: MediaDetailPage }, + { path: "/tasks", name: "tasks", component: TasksPage }, + ], +}); + +export default router; diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..24fef10 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,200 @@ +:root { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #1b1f23; + background: #f6f8fa; + line-height: 1.5; + font-weight: 400; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; +} + +#app { + max-width: 1080px; + margin: 0 auto; + padding: 20px; +} + +.container { + display: flex; + flex-direction: column; + gap: 16px; +} + +.card { + background: #fff; + border: 1px solid #d8dee4; + border-radius: 10px; + padding: 16px; +} + +.desc { + color: #57606a; + margin-top: 0; +} + +.tabs { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.tab { + text-decoration: none; + border: 1px solid #d0d7de; + border-radius: 8px; + color: #1f6feb; + padding: 6px 10px; +} + +.tab.router-link-active { + background: #1f6feb; + color: #fff; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 14px; +} + +input, +select { + border: 1px solid #d0d7de; + border-radius: 8px; + padding: 8px 10px; +} + +button { + border: 1px solid #1f6feb; + background: #1f6feb; + color: #fff; + border-radius: 8px; + padding: 8px 14px; + cursor: pointer; +} + +button.small { + margin-bottom: 8px; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.task-list, +.log-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.task-list li { + border: 1px solid #d0d7de; + border-radius: 8px; + padding: 10px; + cursor: pointer; +} + +.task-list li.active { + border-color: #1f6feb; + background: #f0f6ff; +} + +.meta { + display: flex; + gap: 8px; + font-size: 12px; + color: #57606a; +} + +pre { + background: #f6f8fa; + border-radius: 8px; + padding: 12px; + overflow: auto; +} + +.error { + color: #cf222e; +} + +.log-time { + color: #57606a; + margin-left: 8px; + font-size: 12px; +} + +.poster-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 12px; +} + +.poster-item img, +.no-poster { + width: 100%; + height: 210px; + border-radius: 8px; + border: 1px solid #d0d7de; + cursor: pointer; + object-fit: cover; +} + +.no-poster { + display: flex; + justify-content: center; + align-items: center; + color: #57606a; + background: #f6f8fa; +} + +.poster-title { + margin-top: 6px; + font-size: 13px; +} + +.resource-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.resource-list li { + border: 1px solid #d0d7de; + border-radius: 8px; + padding: 10px; +} + +@media (max-width: 900px) { + .grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/views/MediaDetailPage.vue b/frontend/src/views/MediaDetailPage.vue new file mode 100644 index 0000000..d094d01 --- /dev/null +++ b/frontend/src/views/MediaDetailPage.vue @@ -0,0 +1,90 @@ + + + diff --git a/frontend/src/views/SearchPage.vue b/frontend/src/views/SearchPage.vue new file mode 100644 index 0000000..70aac0f --- /dev/null +++ b/frontend/src/views/SearchPage.vue @@ -0,0 +1,77 @@ + + + diff --git a/frontend/src/views/TasksPage.vue b/frontend/src/views/TasksPage.vue new file mode 100644 index 0000000..5a129b4 --- /dev/null +++ b/frontend/src/views/TasksPage.vue @@ -0,0 +1,116 @@ + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..0c59831 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue()], + server: { + port: 14621, + }, +}) diff --git a/sdk/hdhive-openapi-sdk-python.zip b/sdk/hdhive-openapi-sdk-python.zip new file mode 100644 index 0000000..e9bec94 Binary files /dev/null and b/sdk/hdhive-openapi-sdk-python.zip differ