diff --git a/miniprogram/pages/read/read.wxml b/miniprogram/pages/read/read.wxml index 4c7533eb..9626a6df 100644 --- a/miniprogram/pages/read/read.wxml +++ b/miniprogram/pages/read/read.wxml @@ -78,10 +78,14 @@ + + + {{seg.text}}{{seg.mentionDisplay}}#{{seg.label}} + @@ -153,7 +157,8 @@ {{hdr}}{{cell}} {{item[0].number}}.•{{item[0].text}} - {{seg.text}}{{seg.mentionDisplay}}#{{seg.label}} + + {{seg.text}}{{seg.mentionDisplay}}#{{seg.label}} @@ -245,7 +250,8 @@ {{hdr}}{{cell}} {{item[0].number}}.•{{item[0].text}} - {{seg.text}}{{seg.mentionDisplay}}#{{seg.label}} + + {{seg.text}}{{seg.mentionDisplay}}#{{seg.label}} diff --git a/miniprogram/pages/read/read.wxss b/miniprogram/pages/read/read.wxss index 41d21aa8..6722188e 100644 --- a/miniprogram/pages/read/read.wxss +++ b/miniprogram/pages/read/read.wxss @@ -290,6 +290,22 @@ display: block; } +.content-video-wrap { + width: 100%; + margin: 24rpx 0; +} + +.content-video { + width: 100%; + display: block; + border-radius: 12rpx; + background: #111; +} + +.content-video--inline { + margin: 24rpx 0; +} + /* 正文内表格 */ .table-scroll { margin: 24rpx 0 36rpx; diff --git a/miniprogram/utils/contentParser.js b/miniprogram/utils/contentParser.js index 9299a909..b74bb6cd 100644 --- a/miniprogram/utils/contentParser.js +++ b/miniprogram/utils/contentParser.js @@ -7,6 +7,7 @@ * { type: 'mention', userId, nickname } — @某人,点击加好友(提交存客宝见 utils/soulBridge.submitCkbLead) * { type: 'linkTag', label, url, ... } — #链接标签,点击跳转(阅读页 onLinkTagTap:外链→link-preview、小程序→navigateToMiniProgram) * { type: 'image', src, alt } — 图片 + * { type: 'video', src } — 内嵌视频(管理端 rich-video-wrap / ) */ /** 判断内容是否为 HTML */ @@ -56,6 +57,24 @@ function extractImgSrcFromTag(tag) { return '' } +/** 从 或包含 video 的 HTML 片段取出首个 src */ +function extractVideoSrcFromTag(tag) { + const vm = tag.match(/]*>/i) + const chunk = vm ? vm[0] : tag + return extractImgSrcFromTag(chunk) +} + +function pushResolvedVideoSrc(videos, rawSrc, config) { + if (!rawSrc || typeof rawSrc !== 'string') return + const decoded = decodeEntities(rawSrc.trim()) + if (!decoded) return + const src = + config && config.assetBase + ? resolveArticleImageSrc(decoded, config.assetBase) + : resolveArticleImageSrc(decoded, '') + videos.push({ src }) +} + /** * 单行展示用:昵称、#标签文案、章节外标题类字段 — 合并换行、、连续空白(避免 TipTap/粘贴带入异常断行) */ @@ -81,7 +100,7 @@ function stripTrailingAtForMention(before) { /** * 将一个 HTML block 字符串解析为 segments 数组 - * 处理三种内联元素:mention / linkTag(span) / linkTag(a) / img + * 处理内联元素:mention / linkTag(span) / linkTag(a) / img / video */ function parseBlockToSegments(block, config) { const segs = [] @@ -92,7 +111,7 @@ function parseBlockToSegments(block, config) { if (token) personTokenSet.add(token) } // 合并匹配所有内联元素 - const tokenRe = /]*data-type="mention"[^>]*>[\s\S]*?<\/span>|]*data-type="linkTag"[^>]*>[\s\S]*?<\/span>|]*href="([^"]*)"[^>]*>(#[^<]*)<\/a>|]*\/?>/gi + const tokenRe = /]*data-type="mention"[^>]*>[\s\S]*?<\/span>|]*data-type="linkTag"[^>]*>[\s\S]*?<\/span>|]*href="([^"]*)"[^>]*>(#[^<]*)<\/a>|]*>[\s\S]*?<\/video>|]*\/>|]*\/?>/gi let lastEnd = 0 let m @@ -150,6 +169,16 @@ function parseBlockToSegments(block, config) { // 旧格式没有 tagType,在 onLinkTagTap 中会按 label 匹配缓存的 linkTags 配置降级处理 segs.push({ type: 'linkTag', label: label || '#', url, tagType: '', pagePath: '', tagId: '' }) + } else if (/^ video +(可选)caption,须在剥离 div 前整体替换 + const videos = [] + const videoWrapRe = + /]*(?:class="[^"]*rich-video-wrap[^"]*"|class='[^']*rich-video-wrap[^']*')[^>]*>\s*]*>\s*<\/video>\s*(?:]*(?:class="[^"]*rich-video-caption[^"]*"|class='[^']*rich-video-caption[^']*')[^>]*>[\s\S]*?<\/div>)?\s*<\/div>/gi + text = text.replace(videoWrapRe, (match) => { + const rawSrc = extractVideoSrcFromTag(match) + if (!rawSrc) return match + const idx = videos.length + pushResolvedVideoSrc(videos, rawSrc, config) + return '\n__VIDEO_' + idx + '__\n' + }) + // 未包在 rich-video-wrap 内的裸 (兼容粘贴或其它导出) + text = text.replace(/]*\/>/gi, (match) => { + const rawSrc = extractVideoSrcFromTag(match) + if (!rawSrc) return match + const idx = videos.length + pushResolvedVideoSrc(videos, rawSrc, config) + return '\n__VIDEO_' + idx + '__\n' + }) + text = text.replace(/]*>\s*<\/video>/gi, (match) => { + const rawSrc = extractVideoSrcFromTag(match) + if (!rawSrc) return match + const idx = videos.length + pushResolvedVideoSrc(videos, rawSrc, config) + return '\n__VIDEO_' + idx + '__\n' + }) + // 1. 提取 / → heading 占位 const headings = [] text = text.replace(/]*>([\s\S]*?)<\/h\1>/gi, function (_, lvl, inner) { @@ -292,6 +348,17 @@ function parseHtmlToSegments(html, config) { continue } + // video(整块占位) + var vidM = block.trim().match(/^__VIDEO_(\d+)__$/) + if (vidM) { + var v = videos[parseInt(vidM[1], 10)] + if (v && v.src) { + lines.push('') + segments.push([{ type: 'video', src: v.src }]) + } + continue + } + // heading var hM = block.trim().match(/^__H_(\d+)__$/) if (hM) { @@ -345,7 +412,10 @@ function parseHtmlToSegments(html, config) { var blockSegs = parseBlockToSegments(block, config) if (!blockSegs.length) continue - if (blockSegs.length === 1 && blockSegs[0].type === 'image') { + if ( + blockSegs.length === 1 && + (blockSegs[0].type === 'image' || blockSegs[0].type === 'video') + ) { lines.push('') segments.push(blockSegs) continue diff --git a/soul-api/master.py b/soul-api/master.py index 850678af..46e403e2 100644 --- a/soul-api/master.py +++ b/soul-api/master.py @@ -65,7 +65,7 @@ def get_cfg(): return { "host": host, "user": os.environ.get("DEPLOY_USER", "root"), - "password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"), + "password": os.environ.get("DEPLOY_PASSWORD", ""), "ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""), "project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH), "bt_panel_url": bt_url, @@ -206,14 +206,14 @@ def pack_deploy(root, binary_path, include_env=True): # ==================== 宝塔 API 重启 ==================== -def _bt_signed_post(base_url, key, path, extra_data): +def _bt_signed_post(base_url, key, path, extra_data, files=None, timeout=20): """单次宝塔签名 POST(每请求独立 request_time/token)。""" req_time = int(time.time()) sk_md5 = hashlib.md5(key.encode()).hexdigest() req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest() data = {"request_time": req_time, "request_token": req_token} data.update(extra_data or {}) - return requests.post(base_url + path, data=data, timeout=20, verify=False) + return requests.post(base_url + path, data=data, files=files, timeout=timeout, verify=False) def _bt_parse_json_response(r): @@ -361,22 +361,141 @@ def restart_via_bt_api(cfg): # ==================== SSH 上传 ==================== +def _bt_upload_tarball(cfg, tarball_path, remote_dir, remote_name): + """通过宝塔 API 上传文件到指定目录。""" + if not requests: + print(" [失败] 未安装 requests,无法使用宝塔 API 上传。pip install requests") + return False + url = (cfg.get("bt_panel_url") or "").rstrip("/") + key = cfg.get("bt_api_key") or "" + if not url or not key: + print(" [失败] 未配置 BT_PANEL_URL / BT_API_KEY,无法使用宝塔 API 上传") + return False + if not os.path.isfile(tarball_path): + print(" [失败] 本地文件不存在: %s" % tarball_path) + return False + + filename = remote_name or os.path.basename(tarball_path) + with open(tarball_path, "rb") as f: + files = {"file": (filename, f, "application/gzip")} + for path, payload in ( + ("/files?action=UploadFile", {"path": remote_dir}), + ("/files?action=upload", {"path": remote_dir}), + ("/files?action=Upload", {"path": remote_dir}), + ("/files?action=UploadFile", {"f_path": remote_dir}), + ("/files?action=upload", {"f_path": remote_dir}), + ("/files?action=Upload", {"f_path": remote_dir}), + ): + try: + r = _bt_signed_post(url, key, path, payload, files=files, timeout=120) + j = _bt_parse_json_response(r) + if isinstance(j, dict): + if j.get("status") is True: + return True + msg = str(j.get("msg", "") or j.get("message", "")) + if msg and ("success" in msg.lower() or "ok" in msg.lower()): + return True + elif r is not None and r.status_code == 200: + text = (r.text or "").lower() + if "success" in text or "\"status\":true" in text: + return True + except Exception: + continue + return False + + +def _bt_exec_shell(cfg, shell_cmd): + """通过宝塔 API 执行 shell 命令。""" + url = (cfg.get("bt_panel_url") or "").rstrip("/") + key = cfg.get("bt_api_key") or "" + if not url or not key: + return False, "" + for path in ("/system?action=ExecShell", "/ajax?action=ExecShell"): + try: + r = _bt_signed_post(url, key, path, {"shell": shell_cmd}, timeout=120) + j = _bt_parse_json_response(r) + if isinstance(j, dict): + if j.get("status") is True: + out = j.get("msg") or j.get("data") or "" + return True, str(out) + msg = str(j.get("msg", "") or j.get("message", "")) + if msg and "denied" not in msg.lower(): + return False, msg + if r is not None and r.status_code == 200: + text = (r.text or "").strip() + if text: + return True, text + except Exception: + continue + return False, "" + + +def upload_and_extract_via_bt_api(cfg, tarball_path, project_path, remote_tar): + """通过宝塔 API 上传 tar.gz 并在服务器解压。""" + print("[3/4] 宝塔API 上传并解压 ...") + remote_dir = os.path.dirname(remote_tar) or "/tmp" + remote_name = os.path.basename(remote_tar) or "soul_api_deploy.tar.gz" + if not _bt_upload_tarball(cfg, tarball_path, remote_dir, remote_name): + print(" [失败] 宝塔 API 上传失败") + return False + cmd = ( + "mkdir -p %s && cd %s && tar -xzf %s && chmod +x soul-api && rm -f %s && echo OK" + ) % (project_path, project_path, remote_tar, remote_tar) + ok, out = _bt_exec_shell(cfg, cmd) + if (not ok) or ("OK" not in (out or "")): + print(" [失败] 宝塔 API 解压失败") + if out: + print(" [宝塔API] %s" % str(out)[:300]) + return False + print(" [成功] 已通过宝塔 API 解压到: %s" % project_path) + return True + + def _connect_ssh(cfg): """建立 SSH 连接,启用 keepalive 防大文件上传时断连""" client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]): + host = cfg["host"] + user = cfg["user"] + port = DEFAULT_SSH_PORT + password = (cfg.get("password") or "").strip() + ssh_key = (cfg.get("ssh_key") or "").strip() + + # 优先使用显式配置的私钥文件 + if ssh_key and os.path.isfile(ssh_key): client.connect( - cfg["host"], port=DEFAULT_SSH_PORT, - username=cfg["user"], key_filename=cfg["ssh_key"], + host, + port=port, + username=user, + key_filename=ssh_key, timeout=15, + look_for_keys=False, + allow_agent=False, ) else: - client.connect( - cfg["host"], port=DEFAULT_SSH_PORT, - username=cfg["user"], password=cfg["password"], - timeout=15, - ) + # 未显式指定 key 时,先尝试 ssh-agent / 默认 ~/.ssh 密钥 + try: + client.connect( + host, + port=port, + username=user, + timeout=15, + look_for_keys=True, + allow_agent=True, + ) + except Exception: + if not password: + raise + # 公钥不可用时,最后回退密码认证 + client.connect( + host, + port=port, + username=user, + password=password, + timeout=15, + look_for_keys=False, + allow_agent=False, + ) transport = client.get_transport() if transport: transport.set_keepalive(15) @@ -404,107 +523,133 @@ def _get_port_pids(client, port): return set() -def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto"): +def _restart_via_ssh(client, project_path): + """通过 SSH 重启并健康检查。""" + start_cmd = ( + "cd %s && (fuser -k %d/tcp 2>/dev/null || true) && sleep 2 && " + "( setsid nohup ./soul-api >> soul-api.log 2>&1 /dev/null | grep -q '\"status\"' " + "&& echo RESTART_OK || echo RESTART_FAIL" + ) % DEPLOY_PORT + stdin, stdout, stderr = client.exec_command( + "timeout 25 bash -c " + shlex.quote(health_cmd), + timeout=35, + get_pty=True, + ) + out = stdout.read().decode("utf-8", errors="replace").strip() + ok = "RESTART_OK" in out + if ok: + print(" [成功] soulApi 已通过 SSH 重启") + else: + print(" [警告] SSH 重启状态未知,请到宝塔 Go 项目里手动点击启动,或执行: cd %s && ./soul-api" % project_path) + return ok + + +def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto", upload_method="auto"): """上传 tar.gz 到服务器并解压、重启""" - print("[3/4] SSH 上传并解压 ...") - if not cfg.get("password") and not cfg.get("ssh_key"): - print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY") + if upload_method not in ("auto", "ssh", "btapi"): + print(" [失败] upload_method 仅支持 auto/ssh/btapi") return False + remote_tar = "/tmp/soul_api_deploy.tar.gz" project_path = cfg["project_path"] client = None try: - # SFTP 上传易因网络抖动 EOF,失败时重连并重试最多 3 次 - for attempt in range(1, 4): + uploaded = False + if upload_method in ("auto", "btapi"): + uploaded = upload_and_extract_via_bt_api(cfg, tarball_path, project_path, remote_tar) + if (not uploaded) and upload_method == "btapi": + print(" [失败] 已指定 --upload-method btapi,但宝塔 API 上传/解压未成功") + return False + + if not uploaded: + print("[3/4] SSH 上传并解压 ...") + # SFTP 上传易因网络抖动 EOF,失败时重连并重试最多 3 次 + for attempt in range(1, 4): + try: + if client: + try: + client.close() + except Exception: + pass + client = _connect_ssh(cfg) + sftp = client.open_sftp() + sftp.put(tarball_path, remote_tar) + sftp.close() + break + except (EOFError, ConnectionResetError, OSError) as e: + if attempt < 3: + print(" [重试 %d/3] 上传中断: %s,5 秒后重连 ..." % (attempt, e)) + time.sleep(5) + else: + raise + + cmd = ( + "mkdir -p %s && cd %s && tar -xzf %s && " + "chmod +x soul-api && rm -f %s && echo OK" + ) % (project_path, project_path, remote_tar, remote_tar) + stdin, stdout, stderr = client.exec_command(cmd, timeout=120) + ex_err = [] + + def _drain_tar_stderr(): + try: + ex_err.append(stderr.read().decode("utf-8", errors="replace")) + except Exception: + ex_err.append("") + + t_tar = threading.Thread(target=_drain_tar_stderr) + t_tar.daemon = True + t_tar.start() + out = stdout.read().decode("utf-8", errors="replace").strip() + t_tar.join(timeout=10) + exit_status = stdout.channel.recv_exit_status() + if exit_status != 0 or "OK" not in out: + print(" [失败] 解压失败,退出码:", exit_status) + return False + print(" [成功] 已解压到: %s" % project_path) + else: + print(" [提示] 上传方式: 宝塔 API") + + if not client and restart_method in ("auto", "ssh"): try: - if client: - try: - client.close() - except Exception: - pass client = _connect_ssh(cfg) - sftp = client.open_sftp() - sftp.put(tarball_path, remote_tar) - sftp.close() - break - except (EOFError, ConnectionResetError, OSError) as e: - if attempt < 3: - print(" [重试 %d/3] 上传中断: %s,5 秒后重连 ..." % (attempt, e)) - time.sleep(5) - else: - raise - - cmd = ( - "mkdir -p %s && cd %s && tar -xzf %s && " - "chmod +x soul-api && rm -f %s && echo OK" - ) % (project_path, project_path, remote_tar, remote_tar) - stdin, stdout, stderr = client.exec_command(cmd, timeout=120) - ex_err = [] - - def _drain_tar_stderr(): - try: - ex_err.append(stderr.read().decode("utf-8", errors="replace")) - except Exception: - ex_err.append("") - - t_tar = threading.Thread(target=_drain_tar_stderr) - t_tar.daemon = True - t_tar.start() - out = stdout.read().decode("utf-8", errors="replace").strip() - t_tar.join(timeout=10) - exit_status = stdout.channel.recv_exit_status() - if exit_status != 0 or "OK" not in out: - print(" [失败] 解压失败,退出码:", exit_status) - return False - print(" [成功] 已解压到: %s" % project_path) + except Exception as e: + if restart_method == "ssh": + print(" [失败] 指定 SSH 重启,但无法建立 SSH 连接: %s" % str(e)) + return False if not no_restart: print("[4/4] 重启 soulApi 服务 ...") ok = False - pids_before = _get_port_pids(client, DEPLOY_PORT) + pids_before = _get_port_pids(client, DEPLOY_PORT) if client else set() if restart_method in ("auto", "btapi") and (cfg.get("bt_panel_url") and cfg.get("bt_api_key")): ok = restart_via_bt_api(cfg) # 宝塔接口有时返回成功但进程未真正重启:若 PID 未变化,视为 btapi 未生效 - if ok: + if ok and client: time.sleep(2) pids_after = _get_port_pids(client, DEPLOY_PORT) if pids_before and pids_after and pids_before == pids_after: print(" [宝塔API] 检测到监听 %d 的 PID 未变化(%s),判定 btapi 未真正重启,转 SSH 兜底。" % (DEPLOY_PORT, ",".join(sorted(pids_after)))) ok = False if not ok and restart_method in ("auto", "ssh"): - # SSH:正式环境固定监听 DEPLOY_PORT(默认 8080)。用 fuser 释放端口,避免宝塔守护 - # 启动的进程 cwd 与项目目录不一致导致 pgrep+cwd 校验永远失败。 - # 拆成两次 exec:先短命令起进程,本机 sleep 后再 curl,避免单条远程命令+管道偶发拖死 Paramiko。 - start_cmd = ( - "cd %s && (fuser -k %d/tcp 2>/dev/null || true) && sleep 2 && " - "( setsid nohup ./soul-api >> soul-api.log 2>&1 /dev/null | grep -q '\"status\"' " - "&& echo RESTART_OK || echo RESTART_FAIL" - ) % DEPLOY_PORT - stdin, stdout, stderr = client.exec_command( - "timeout 25 bash -c " + shlex.quote(health_cmd), - timeout=35, - get_pty=True, - ) - out = stdout.read().decode("utf-8", errors="replace").strip() - ok = "RESTART_OK" in out - if ok: - print(" [成功] soulApi 已通过 SSH 重启") - else: - print(" [警告] SSH 重启状态未知,请到宝塔 Go 项目里手动点击启动,或执行: cd %s && ./soul-api" % project_path) + if client: + ok = _restart_via_ssh(client, project_path) + elif restart_method == "ssh": + print(" [失败] 未能建立 SSH 连接,无法执行 SSH 重启") + return False if restart_method == "btapi" and not ok: print(" [失败] 已指定 --restart-method btapi,但宝塔 API 重启未成功(请核对 API 白名单含本机出口 IP、BT_GO_PROJECT_NAME/BT_GO_SITE_ID)") return False @@ -514,7 +659,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto return True except Exception as e: err_msg = str(e) or repr(e) or type(e).__name__ - print(" [失败] SSH 错误:", err_msg) + print(" [失败] 部署错误:", err_msg) import traceback traceback.print_exc() return False @@ -543,6 +688,12 @@ def main(): default="auto", help="重启方式: auto=先试宝塔API再SSH, btapi=仅宝塔API, ssh=仅SSH (默认 auto)", ) + parser.add_argument( + "--upload-method", + choices=("auto", "btapi", "ssh"), + default="auto", + help="上传方式: auto=先试宝塔API再SSH, btapi=仅宝塔API, ssh=仅SSH (默认 auto)", + ) args = parser.parse_args() script_dir = os.path.dirname(os.path.abspath(__file__)) @@ -571,7 +722,13 @@ def main(): if not tarball: return 1 - if not upload_and_extract(cfg, tarball, no_restart=args.no_restart, restart_method=args.restart_method): + if not upload_and_extract( + cfg, + tarball, + no_restart=args.no_restart, + restart_method=args.restart_method, + upload_method=args.upload_method, + ): return 1 try: