"""Search and install skills from public Agent Skills catalogs.""" from __future__ import annotations import asyncio import hashlib import os import re import shutil import stat import tempfile import time import zipfile from pathlib import Path, PurePosixPath from typing import Any, cast from urllib.parse import quote, urlparse import httpx from nanobot.agent.skills import SkillsLoader from nanobot.security.network import PinnedDNSAsyncTransport from nanobot.security.workspace_policy import WorkspaceBoundaryError, require_path_within _PROVIDER_ALL = "all" _PROVIDER_SKILLS_SH = "skills_sh" _PROVIDER_SKILLHUB = "skillhub" _PROVIDERS = {_PROVIDER_ALL, _PROVIDER_SKILLS_SH, _PROVIDER_SKILLHUB} _SEARCH_URL = "https://skills.sh/api/search" _TRENDING_URL = "https://skills.sh/api/skills/trending/0" _SKILL_PAGE_BASE_URL = "https://www.skills.sh" _SKILLHUB_API_BASE_URL = "https://api.skillhub.cn" _SKILLHUB_SEARCH_URL = f"{_SKILLHUB_API_BASE_URL}/api/v1/search" _SKILLHUB_TRENDING_URL = f"{_SKILLHUB_API_BASE_URL}/api/v1/showcase/trending" _SKILLHUB_DOWNLOAD_URL = f"{_SKILLHUB_API_BASE_URL}/api/v1/download" _SKILLHUB_PAGE_BASE_URL = "https://skillhub.cn" _ALL_TIME_URLS = ( "https://skills.sh/api/skills/all-time/0", "https://skills.sh/api/skills/all-time/1", ) _TREND_VALUES_RE = re.compile(r'\\"values\\":\s*\[([0-9,\s]+)\]') _SOURCE_RE = re.compile( r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?/" r"[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,98}[A-Za-z0-9])?$" ) _SKILL_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") _VERSION_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9._+-]{0,63})$") _ANSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") _INSTALL_TIMEOUT_SECONDS = 120 _WEEKLY_CACHE_TTL_SECONDS = 300 _SKILLHUB_MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024 _SKILLHUB_MAX_UNPACKED_BYTES = 100 * 1024 * 1024 _SKILLHUB_MAX_FILES = 1_000 # The skills CLI's OpenClaw adapter copies into /skills, nanobot's layout too. _CLI_AGENT = "openclaw" _weekly_cache: dict[tuple[str, str], list[int]] = {} _weekly_cache_expires_at = 0.0 def _response_json_object(response: httpx.Response) -> dict[str, Any] | None: """Narrow an untyped HTTP JSON response at the external-data boundary.""" payload = cast(object, response.json()) return cast(dict[str, Any], payload) if isinstance(payload, dict) else None class SkillsMarketplaceError(Exception): """A safe error that can be returned to the WebUI.""" def __init__(self, message: str, *, status: int = 400) -> None: super().__init__(message) self.message = message self.status = status def skills_install_supported() -> bool: """Return whether the official skills CLI can be launched.""" return shutil.which("npx") is not None async def trending_marketplace_skills( workspace_path: Path, *, limit: int = 8, provider: str = _PROVIDER_ALL, ) -> dict[str, Any]: """Return provider-aware marketplace rankings without mixing metric semantics.""" selected = _valid_provider(provider) if selected == _PROVIDER_SKILLHUB: return await _trending_skillhub_skills(workspace_path, limit=limit) if selected == _PROVIDER_SKILLS_SH: return await _trending_skills_sh_skills(workspace_path, limit=limit) results = await asyncio.gather( _trending_skills_sh_skills(workspace_path, limit=limit), _trending_skillhub_skills(workspace_path, limit=limit), return_exceptions=True, ) payloads = [result for result in results if isinstance(result, dict)] if not payloads: raise SkillsMarketplaceError( "skill marketplaces are temporarily unavailable", status=502, ) return { "skills": [ skill for payload in payloads for skill in payload.get("skills", []) if isinstance(skill, dict) ], "period": "mixed", "provider": _PROVIDER_ALL, "install_supported": any(bool(payload.get("install_supported")) for payload in payloads), } async def _trending_skills_sh_skills( workspace_path: Path, *, limit: int, ) -> dict[str, Any]: """Return a source-diverse snapshot of skills.sh's real 24-hour leaderboard.""" try: async with _skills_client() as client: response = await client.get(_TRENDING_URL) response.raise_for_status() payload = _response_json_object(response) or {} except (httpx.HTTPError, ValueError) as exc: raise SkillsMarketplaceError( "skills.sh trending skills are temporarily unavailable", status=502, ) from exc installed = _installed_skill_names(workspace_path) rows = payload.get("skills", []) skills: list[dict[str, Any]] = [] seen_sources: set[str] = set() for rank, row in enumerate(rows, start=1): if not isinstance(row, dict): continue row_payload = cast(dict[str, Any], row) source = row_payload.get("source") if not isinstance(source, str) or source in seen_sources: continue skill = _marketplace_skill(row_payload, installed, rank=rank) if skill is None: continue seen_sources.add(source) skills.append(skill) if len(skills) >= min(max(limit, 1), 20): break return { "skills": skills, "period": "24h", "provider": _PROVIDER_SKILLS_SH, "install_supported": skills_install_supported(), } async def search_marketplace_skills( query: str, workspace_path: Path, *, limit: int = 20, provider: str = _PROVIDER_ALL, ) -> dict[str, Any]: """Search one or all catalogs and annotate locally installed results.""" normalized = " ".join(query.split()) if len(normalized) < 2: raise SkillsMarketplaceError("search query must contain at least 2 characters") if len(normalized) > 100: raise SkillsMarketplaceError("search query is too long") selected = _valid_provider(provider) if selected == _PROVIDER_SKILLHUB: return await _search_skillhub_skills(normalized, workspace_path, limit=limit) if selected == _PROVIDER_SKILLS_SH: return await _search_skills_sh_skills(normalized, workspace_path, limit=limit) results = await asyncio.gather( _search_skills_sh_skills(normalized, workspace_path, limit=limit), _search_skillhub_skills(normalized, workspace_path, limit=limit), return_exceptions=True, ) payloads = [result for result in results if isinstance(result, dict)] if not payloads: raise SkillsMarketplaceError( "skill marketplaces are temporarily unavailable", status=502, ) return { "query": normalized, "skills": [ skill for payload in payloads for skill in payload.get("skills", []) if isinstance(skill, dict) ], "provider": _PROVIDER_ALL, "install_supported": any(bool(payload.get("install_supported")) for payload in payloads), } async def _search_skills_sh_skills( normalized: str, workspace_path: Path, *, limit: int, ) -> dict[str, Any]: try: async with _skills_client() as client: response = await client.get( _SEARCH_URL, params={"q": normalized, "limit": min(max(limit, 1), 50)}, ) response.raise_for_status() payload = _response_json_object(response) or {} except (httpx.HTTPError, ValueError) as exc: raise SkillsMarketplaceError( "skills.sh search is temporarily unavailable", status=502, ) from exc installed = _installed_skill_names(workspace_path) rows = payload.get("skills", []) skills: list[dict[str, Any]] = [] for row in rows: if not isinstance(row, dict): continue skill = _marketplace_skill(cast(dict[str, Any], row), installed) if skill is not None: skills.append(skill) return { "query": normalized, "skills": skills, "provider": _PROVIDER_SKILLS_SH, "install_supported": skills_install_supported(), } async def _search_skillhub_skills( normalized: str, workspace_path: Path, *, limit: int, ) -> dict[str, Any]: try: async with _skillhub_client() as client: response = await client.get( _SKILLHUB_SEARCH_URL, params={"q": normalized, "limit": min(max(limit, 1), 50)}, ) response.raise_for_status() payload = _response_json_object(response) or {} except (httpx.HTTPError, ValueError) as exc: raise SkillsMarketplaceError( "SkillHub search is temporarily unavailable", status=502, ) from exc installed = _installed_skill_names(workspace_path) rows = payload.get("results", []) skills = [ skill for row in rows if isinstance(row, dict) if (skill := _skillhub_skill(cast(dict[str, Any], row), installed)) is not None ] return { "query": normalized, "skills": skills, "provider": _PROVIDER_SKILLHUB, "install_supported": True, } async def _trending_skillhub_skills( workspace_path: Path, *, limit: int, ) -> dict[str, Any]: try: async with _skillhub_client() as client: response = await client.get(_SKILLHUB_TRENDING_URL) response.raise_for_status() payload = _response_json_object(response) or {} except (httpx.HTTPError, ValueError) as exc: raise SkillsMarketplaceError( "SkillHub trending skills are temporarily unavailable", status=502, ) from exc installed = _installed_skill_names(workspace_path) rows = payload.get("skills", []) skills: list[dict[str, Any]] = [] for rank, row in enumerate(rows, start=1): if not isinstance(row, dict): continue skill = _skillhub_skill(cast(dict[str, Any], row), installed, rank=rank) if skill is not None: skills.append(skill) if len(skills) >= min(max(limit, 1), 20): break return { "skills": skills, "period": "trending", "provider": _PROVIDER_SKILLHUB, "install_supported": True, } async def marketplace_skill_trends( skill_ids: list[str] | None = None, ) -> dict[str, dict[str, list[int]]]: """Return install history independently, filling requested cache misses.""" requested = _valid_skill_refs(skill_ids or []) async with _skills_client() as client: weekly_installs = await _load_weekly_installs(client) missing = [ref for ref in requested if ref not in weekly_installs] if missing: weekly_installs.update(await _load_skill_page_trends(client, missing)) selected = requested or list(weekly_installs) return { "trends": { f"{source}/{skill_id}": values for source, skill_id in selected if (values := weekly_installs.get((source, skill_id))) is not None } } async def install_marketplace_skill( source: str, skill_id: str, workspace_path: Path, *, provider: str = _PROVIDER_SKILLS_SH, version: str = "", ) -> dict[str, Any]: """Install one normalized marketplace result into ``/skills``.""" selected = _valid_provider(provider, allow_all=False) if selected == _PROVIDER_SKILLHUB: return await _install_skillhub_skill(skill_id, version, workspace_path) return await _install_skills_sh_skill(source, skill_id, workspace_path) async def _install_skills_sh_skill( source: str, skill_id: str, workspace_path: Path, ) -> dict[str, Any]: if not _SOURCE_RE.fullmatch(source): raise SkillsMarketplaceError("invalid skill source") if not _valid_skill_id(skill_id): raise SkillsMarketplaceError("invalid skill name") loader = SkillsLoader(workspace_path) existing = {entry["name"]: entry for entry in loader.list_skills(filter_unavailable=False)} if skill_id in existing: return {"installed": True, "already_installed": True, "name": skill_id} workspace = workspace_path.expanduser().resolve() workspace.mkdir(parents=True, exist_ok=True) try: require_path_within( workspace / "skills", workspace, message="skills directory must stay inside the workspace", ) except WorkspaceBoundaryError as exc: raise SkillsMarketplaceError(str(exc), status=403) from exc npx = shutil.which("npx") if npx is None: raise SkillsMarketplaceError( "Node.js with npx is required to install skills", status=503, ) env = os.environ.copy() env["DISABLE_TELEMETRY"] = "1" command = ( npx, "--yes", "skills@latest", "add", source, "--skill", skill_id, "--agent", _CLI_AGENT, "--copy", "--yes", ) process = await asyncio.create_subprocess_exec( *command, cwd=str(workspace), env=env, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, ) try: output, _ = await asyncio.wait_for( process.communicate(), timeout=_INSTALL_TIMEOUT_SECONDS, ) except TimeoutError as exc: process.kill() await process.communicate() raise SkillsMarketplaceError("skill installation timed out", status=504) from exc if process.returncode != 0: detail = _safe_output_tail(output) message = "skill installation failed" if detail: message = f"{message}: {detail}" raise SkillsMarketplaceError(message, status=502) installed = next( ( entry for entry in loader.list_skills(filter_unavailable=False) if entry["source"] == "workspace" and entry["name"] == skill_id ), None, ) if installed is None: raise SkillsMarketplaceError( "installer completed but the skill was not found in this workspace", status=502, ) return {"installed": True, "already_installed": False, "name": skill_id} async def _install_skillhub_skill( skill_id: str, requested_version: str, workspace_path: Path, ) -> dict[str, Any]: if not _valid_skill_id(skill_id): raise SkillsMarketplaceError("invalid SkillHub skill name") if requested_version and _VERSION_RE.fullmatch(requested_version) is None: raise SkillsMarketplaceError("invalid SkillHub skill version") loader = SkillsLoader(workspace_path) existing = {entry["name"]: entry for entry in loader.list_skills(filter_unavailable=False)} if skill_id in existing: return { "installed": True, "already_installed": True, "name": skill_id, "provider": _PROVIDER_SKILLHUB, } workspace = workspace_path.expanduser().resolve() workspace.mkdir(parents=True, exist_ok=True) try: skills_root = require_path_within( workspace / "skills", workspace, message="skills directory must stay inside the workspace", ) except WorkspaceBoundaryError as exc: raise SkillsMarketplaceError(str(exc), status=403) from exc skills_root.mkdir(parents=True, exist_ok=True) target = skills_root / skill_id try: async with _skillhub_client() as client: version = requested_version or await _skillhub_latest_version(client, skill_id) signature = await _skillhub_signature(client, skill_id, version) expected_hash = signature.get("content_hash") if not isinstance(expected_hash, str) or not re.fullmatch( r"[0-9a-fA-F]{64}", expected_hash, ): raise SkillsMarketplaceError( "SkillHub did not provide a valid package fingerprint", status=502, ) with tempfile.TemporaryDirectory( prefix=".skillhub-install-", dir=skills_root, ) as temporary: temporary_path = Path(temporary) archive_path = temporary_path / f"{skill_id}.zip" stage_path = temporary_path / "stage" await _download_skillhub_archive( client, skill_id, version, archive_path, ) actual_hash = _validate_skillhub_archive(archive_path) if actual_hash.lower() != expected_hash.lower(): raise SkillsMarketplaceError( "SkillHub package fingerprint did not match", status=502, ) _extract_skillhub_archive(archive_path, stage_path) if target.exists(): return { "installed": True, "already_installed": True, "name": skill_id, "provider": _PROVIDER_SKILLHUB, } os.replace(stage_path, target) except SkillsMarketplaceError: raise except (httpx.HTTPError, OSError, zipfile.BadZipFile) as exc: raise SkillsMarketplaceError( "SkillHub skill installation failed", status=502, ) from exc installed = next( ( entry for entry in loader.list_skills(filter_unavailable=False) if entry["source"] == "workspace" and entry["name"] == skill_id ), None, ) if installed is None: raise SkillsMarketplaceError( "installer completed but the skill was not found in this workspace", status=502, ) return { "installed": True, "already_installed": False, "name": skill_id, "provider": _PROVIDER_SKILLHUB, "version": version, } async def _skillhub_latest_version(client: httpx.AsyncClient, skill_id: str) -> str: response = await client.get( f"{_SKILLHUB_API_BASE_URL}/api/v1/skills/{quote(skill_id, safe='')}" ) response.raise_for_status() payload = _response_json_object(response) or {} raw_latest = payload.get("latestVersion", {}) latest = cast(dict[str, Any], raw_latest) if isinstance(raw_latest, dict) else {} version = latest.get("version") if not isinstance(version, str) or _VERSION_RE.fullmatch(version) is None: raise SkillsMarketplaceError( "SkillHub did not return a valid skill version", status=502, ) return version async def _skillhub_signature( client: httpx.AsyncClient, skill_id: str, version: str, ) -> dict[str, Any]: response = await client.get( f"{_SKILLHUB_API_BASE_URL}/api/v1/open/skills/" f"{quote(skill_id, safe='')}/versions/{quote(version, safe='')}/signature" ) response.raise_for_status() payload = _response_json_object(response) if payload is None: raise SkillsMarketplaceError( "SkillHub returned an invalid package fingerprint", status=502, ) return payload async def _download_skillhub_archive( client: httpx.AsyncClient, skill_id: str, version: str, destination: Path, ) -> None: redirect = await client.get( _SKILLHUB_DOWNLOAD_URL, params={"slug": skill_id, "version": version}, ) if redirect.status_code not in {301, 302, 303, 307, 308}: redirect.raise_for_status() raise SkillsMarketplaceError( "SkillHub returned an unexpected download response", status=502, ) location = redirect.headers.get("location", "") if not _valid_skillhub_download_url(location): raise SkillsMarketplaceError( "SkillHub returned an unsafe download location", status=502, ) received = 0 async with client.stream( "GET", location, headers={"Accept": "application/zip,application/octet-stream"}, ) as response: response.raise_for_status() declared = response.headers.get("content-length") if declared and declared.isdigit() and int(declared) > _SKILLHUB_MAX_DOWNLOAD_BYTES: raise SkillsMarketplaceError("SkillHub package is too large", status=413) with destination.open("wb") as output: async for chunk in response.aiter_bytes(): received += len(chunk) if received > _SKILLHUB_MAX_DOWNLOAD_BYTES: raise SkillsMarketplaceError("SkillHub package is too large", status=413) output.write(chunk) def _valid_skillhub_download_url(value: str) -> bool: try: parsed = urlparse(value) hostname = (parsed.hostname or "").lower() port = parsed.port except ValueError: return False return ( parsed.scheme == "https" and parsed.username is None and parsed.password is None and port in {None, 443} and hostname.endswith(".myqcloud.com") ) def _validated_skillhub_entries( archive: zipfile.ZipFile, ) -> list[tuple[zipfile.ZipInfo, str]]: entries: list[tuple[zipfile.ZipInfo, str]] = [] seen: set[str] = set() unpacked = 0 for info in archive.infolist(): raw_name = info.filename.replace("\\", "/") path = PurePosixPath(raw_name) normalized = path.as_posix() mode = info.external_attr >> 16 kind = stat.S_IFMT(mode) if ( not normalized or "\x00" in normalized or path.is_absolute() or ".." in path.parts or (path.parts and ":" in path.parts[0]) or kind == stat.S_IFLNK or kind not in {0, stat.S_IFREG, stat.S_IFDIR} ): raise SkillsMarketplaceError( f"SkillHub package contains an unsafe path: {raw_name}", status=422, ) if info.is_dir(): continue if normalized in seen: raise SkillsMarketplaceError( f"SkillHub package contains a duplicate path: {normalized}", status=422, ) seen.add(normalized) unpacked += info.file_size if len(entries) >= _SKILLHUB_MAX_FILES: raise SkillsMarketplaceError("SkillHub package contains too many files", status=413) if unpacked > _SKILLHUB_MAX_UNPACKED_BYTES: raise SkillsMarketplaceError( "SkillHub package expands beyond the size limit", status=413 ) entries.append((info, normalized)) if "SKILL.md" not in seen: raise SkillsMarketplaceError( "SkillHub package does not contain a root SKILL.md", status=422, ) return entries def _validate_skillhub_archive(archive_path: Path) -> str: hashed: list[tuple[str, str]] = [] with zipfile.ZipFile(archive_path, "r") as archive: for info, normalized in _validated_skillhub_entries(archive): if _skillhub_hash_ignored(normalized): continue digest = hashlib.sha256() with archive.open(info, "r") as source: for chunk in iter(lambda: source.read(1024 * 1024), b""): digest.update(chunk) hashed.append((normalized, digest.hexdigest())) combined = hashlib.sha256() for normalized, digest in sorted(hashed): combined.update(f"{normalized}:{digest}\n".encode()) return combined.hexdigest() def _skillhub_hash_ignored(path: str) -> bool: parts = PurePosixPath(path).parts basename = parts[-1] if parts else "" return ( path == "_meta.json" or "__MACOSX" in parts or basename == ".DS_Store" or basename.startswith("._") or basename.lower() == "thumbs.db" ) def _extract_skillhub_archive(archive_path: Path, destination: Path) -> None: destination.mkdir() with zipfile.ZipFile(archive_path, "r") as archive: for info, normalized in _validated_skillhub_entries(archive): target = destination.joinpath(*PurePosixPath(normalized).parts) target.parent.mkdir(parents=True, exist_ok=True) with archive.open(info, "r") as source, target.open("wb") as output: shutil.copyfileobj(source, output) mode = (info.external_attr >> 16) & 0o777 if mode: target.chmod(mode & 0o755) def _skills_client() -> httpx.AsyncClient: return httpx.AsyncClient( transport=PinnedDNSAsyncTransport(), timeout=10.0, follow_redirects=False, ) def _skillhub_client() -> httpx.AsyncClient: return httpx.AsyncClient( transport=PinnedDNSAsyncTransport(), timeout=httpx.Timeout(30.0, connect=10.0), follow_redirects=False, ) def _installed_skill_names(workspace_path: Path) -> set[str]: return { entry["name"] for entry in SkillsLoader(workspace_path).list_skills(filter_unavailable=False) } def _marketplace_skill( row: dict[str, Any], installed: set[str], *, rank: int | None = None, ) -> dict[str, Any] | None: source = row.get("source") skill_id = row.get("skillId") if not isinstance(source, str) or not _SOURCE_RE.fullmatch(source): return None if not isinstance(skill_id, str) or not _valid_skill_id(skill_id): return None display_name = row.get("name") if not isinstance(display_name, str) or not display_name.strip(): display_name = skill_id installs = row.get("installs") skill: dict[str, Any] = { "id": f"{source}/{skill_id}", "skill_id": skill_id, "name": display_name.strip(), "source": source, "provider": _PROVIDER_SKILLS_SH, "installs": installs if isinstance(installs, int) and installs >= 0 else 0, "url": f"https://skills.sh/{source}/{skill_id}", "installed": skill_id in installed, "install_supported": skills_install_supported(), "metric": "installs_24h" if rank is not None else "installs_total", } if rank is not None: skill["rank"] = rank return skill def _skillhub_skill( row: dict[str, Any], installed: set[str], *, rank: int | None = None, ) -> dict[str, Any] | None: skill_id = row.get("slug") if not isinstance(skill_id, str) or not _valid_skill_id(skill_id): return None display_name = row.get("displayName") or row.get("name") or skill_id if not isinstance(display_name, str) or not display_name.strip(): display_name = skill_id namespace = row.get("namespace") namespace_payload = cast(dict[str, Any], namespace) if isinstance(namespace, dict) else {} handle = namespace_payload.get("handle") if not isinstance(handle, str) or not handle.strip(): owner = row.get("owner_name") or row.get("ownerName") handle = owner if isinstance(owner, str) and owner.strip() else "community" source = f"@{handle.strip()}/{skill_id}" installs = row.get("installs") downloads = row.get("downloads") publisher = row.get("publisher") publisher_payload = cast(dict[str, Any], publisher) if isinstance(publisher, dict) else {} verified = publisher_payload.get("verified") is True labels = row.get("labels") labels_payload = cast(dict[str, Any], labels) if isinstance(labels, dict) else {} requires_api_key = str(labels_payload.get("requires_api_key", "")).lower() == "true" version = row.get("version") if not isinstance(version, str) or _VERSION_RE.fullmatch(version) is None: version = "" skill: dict[str, Any] = { "id": f"{_PROVIDER_SKILLHUB}:{skill_id}", "skill_id": skill_id, "name": display_name.strip(), "source": source, "provider": _PROVIDER_SKILLHUB, "installs": installs if isinstance(installs, int) and installs >= 0 else 0, "downloads": downloads if isinstance(downloads, int) and downloads >= 0 else 0, "url": f"{_SKILLHUB_PAGE_BASE_URL}/{quote(handle.strip(), safe='')}/" f"{quote(skill_id, safe='')}", "installed": skill_id in installed, "install_supported": True, "metric": "installs_total", "version": version, "verified": verified, "requires_api_key": requires_api_key, } if rank is not None: skill["rank"] = rank return skill async def _load_weekly_installs( client: httpx.AsyncClient, ) -> dict[tuple[str, str], list[int]]: global _weekly_cache, _weekly_cache_expires_at now = time.monotonic() if now < _weekly_cache_expires_at: return _weekly_cache responses = await asyncio.gather( *(client.get(url) for url in _ALL_TIME_URLS), return_exceptions=True, ) history: dict[tuple[str, str], list[int]] = {} successful = False for response in responses: if isinstance(response, BaseException): continue try: response.raise_for_status() payload = _response_json_object(response) or {} except (httpx.HTTPError, ValueError): continue successful = True rows = payload.get("skills", []) for row in rows: if not isinstance(row, dict): continue row_payload = cast(dict[str, Any], row) source = row_payload.get("source") skill_id = row_payload.get("skillId") values = row_payload.get("weeklyInstalls") if isinstance(source, str) and isinstance(skill_id, str) and isinstance(values, list): clean = [ value for value in cast(list[object], values) if isinstance(value, int) and not isinstance(value, bool) and value >= 0 ] if len(clean) >= 2: history[(source, skill_id)] = clean if successful: _weekly_cache = history _weekly_cache_expires_at = now + _WEEKLY_CACHE_TTL_SECONDS return history def _valid_skill_refs(skill_ids: list[str]) -> list[tuple[str, str]]: refs: list[tuple[str, str]] = [] for value in skill_ids[:20]: if "/" not in value: continue source, skill_id = value.rsplit("/", 1) ref = (source, skill_id) if _SOURCE_RE.fullmatch(source) and _valid_skill_id(skill_id) and ref not in refs: refs.append(ref) return refs async def _load_skill_page_trends( client: httpx.AsyncClient, refs: list[tuple[str, str]], ) -> dict[tuple[str, str], list[int]]: semaphore = asyncio.Semaphore(6) async def fetch(ref: tuple[str, str]) -> tuple[tuple[str, str], list[int]]: source, skill_id = ref try: async with semaphore: response = await client.get(f"{_SKILL_PAGE_BASE_URL}/{source}/{skill_id}") response.raise_for_status() except httpx.HTTPError: return ref, [] match = _TREND_VALUES_RE.search(response.text) if match is None: return ref, [] values = [int(value) for value in match.group(1).split(",") if value.strip()] return ref, values if len(values) >= 2 else [] return dict(await asyncio.gather(*(fetch(ref) for ref in refs))) def _valid_skill_id(value: str) -> bool: return len(value) <= 64 and _SKILL_RE.fullmatch(value) is not None def _valid_provider(value: str, *, allow_all: bool = True) -> str: normalized = value.strip().lower() or _PROVIDER_ALL allowed = _PROVIDERS if allow_all else _PROVIDERS - {_PROVIDER_ALL} if normalized not in allowed: raise SkillsMarketplaceError("invalid skill marketplace provider") return normalized def _safe_output_tail(output: bytes | None) -> str: if not output: return "" text = _ANSI_RE.sub("", output.decode("utf-8", errors="replace")) lines = [line.strip() for line in text.splitlines() if line.strip()] return " ยท ".join(lines[-3:])[-600:]