diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index d151bbe6f6..6cf47ec824 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path +from urllib.parse import urlparse import aiohttp import certifi @@ -737,8 +738,26 @@ async def on_plugin(self): async def get_plugin_readme(self): plugin_name = request.args.get("name") - logger.debug(f"正在获取插件 {plugin_name} 的README文件内容") + repo_url = request.args.get("repo") + logger.debug(f"正在获取插件 {plugin_name} 的README文件内容, repo: {repo_url}") + # 如果提供了 repo_url,优先从远程获取 + if repo_url: + try: + readme_content = await self._fetch_remote_readme(repo_url) + if readme_content: + return ( + Response() + .ok({"content": readme_content}, "成功获取README内容") + .__dict__ + ) + else: + return Response().error("无法从远程仓库获取README文件").__dict__ + except Exception as e: + logger.error(f"从远程获取README失败: {traceback.format_exc()}") + return Response().error(f"获取README失败: {e!s}").__dict__ + + # 否则从本地获取 if not plugin_name: logger.warning("插件名称为空") return Response().error("插件名称不能为空").__dict__ @@ -791,6 +810,53 @@ async def get_plugin_readme(self): logger.error(f"/api/plugin/readme: {traceback.format_exc()}") return Response().error(f"读取README文件失败: {e!s}").__dict__ + async def _fetch_remote_readme(self, repo_url: str) -> str | None: + """从远程GitHub仓库获取README内容""" + # 解析GitHub仓库URL + # 支持格式: https://github.com/owner/repo 或 https://github.com/owner/repo.git + repo_url = repo_url.rstrip("/").removesuffix(".git") + + # 使用 urlparse 严格解析 URL,校验域名和路径 + parsed = urlparse(repo_url) + + # 仅支持 GitHub 仓库链接 + if parsed.netloc.lower() != "github.com": + return None + + # 提取路径中的 owner 和 repo,要求至少有两个段 + path_parts = [part for part in parsed.path.strip("/").split("/") if part] + if len(path_parts) < 2: + return None + + owner, repo = path_parts[0], path_parts[1] + + # 尝试多种README文件名 + readme_names = ["README.md", "readme.md", "README.MD", "Readme.md"] + + ssl_context = ssl.create_default_context(cafile=certifi.where()) + connector = aiohttp.TCPConnector(ssl=ssl_context) + + async with aiohttp.ClientSession( + trust_env=True, connector=connector, timeout=aiohttp.ClientTimeout(total=10) + ) as session: + # 尝试从不同分支获取 + branches = ["main", "master"] + for branch in branches: + for readme_name in readme_names: + # 使用GitHub raw content URL + raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{readme_name}" + try: + async with session.get(raw_url) as response: + if response.status == 200: + content = await response.text() + logger.debug(f"成功从 {raw_url} 获取README") + return content + except Exception as e: + logger.debug(f"从 {raw_url} 获取失败: {e}") + continue + + return None + async def get_plugin_changelog(self): """获取插件更新日志 diff --git a/dashboard/src/components/extension/MarketPluginCard.vue b/dashboard/src/components/extension/MarketPluginCard.vue index 445f07b8cf..4ee7231035 100644 --- a/dashboard/src/components/extension/MarketPluginCard.vue +++ b/dashboard/src/components/extension/MarketPluginCard.vue @@ -20,7 +20,7 @@ const props = defineProps({ }, }); -const emit = defineEmits(["install"]); +const emit = defineEmits(["install", "viewReadme"]); const normalizePlatformList = (platforms) => { if (!Array.isArray(platforms)) return []; @@ -35,6 +35,30 @@ const handleInstall = (plugin) => { emit("install", plugin); }; +const handleViewReadme = (plugin) => { + emit("viewReadme", plugin); +}; + +// 从 repo URL 提取作者主页链接 +const authorHomepageUrl = computed(() => { + const repoUrl = props.plugin?.repo; + if (!repoUrl) return null; + + try { + // 解析 GitHub URL,提取 owner + const url = new URL(repoUrl); + if (url.hostname.toLowerCase() !== 'github.com') return null; + + const pathParts = url.pathname.split('/').filter(p => p); + if (pathParts.length < 1) return null; + + const owner = pathParts[0]; + return `https://github.com/${owner}`; + } catch { + return null; + } +}); +