Skill: moxunjinmu/openclaw-grok-search (version 1.0.0)

--- name: openclaw-grok-search description: Cross-platform real-time web research and search via an OpenAI-compatible Grok endpoint, returning JSON with content and sources. Use for version checks, API and docs verification, troubleshooting, and any time-sensitive facts on Windows, macOS, or Linux. --- # Openclaw Grok Search Run cross-platform web research and return structured JSON output with `content` and `sources`. This skill is project-local and should run directly from the downloaded project directory. ## When to Use Use this skill before answering when any of these apply: 1. The user asks for latest/current/today/recent information. 2. The answer depends on versions, releases, changelogs, or compatibility. 3. The task needs official docs, API references, or source URLs. 4. The user reports an error and root-cause analysis needs web evidence. 5. You are uncertain and need external confirmation before final output. ## Quick Start 1. Write config interactively (first run only). ```bash python scripts/configure.py ``` 2. Run a query. ```bash python scripts/grok_search.py --query "What changed in Python recently?" ``` ## Config Priority 1. CLI args such as `--base-url` and `--api-key` 2. Environment vars `GROK_*` 3. Config files Default config lookup order: 1. `config.json` 2. `config.local.json` ## Cross-Platform Rules 1. Prefer `python ...` commands, do not require PowerShell-only syntax. 2. Keep config in the project folder, do not install or copy into `~/.codex`. 3. Support `GROK_CONFIG_PATH` only when you explicitly want a custom path. ## Output Shape Always print JSON with: 1. `ok` 2. `content` 3. `sources` 4. `raw` ## Anti-Patterns | Prohibited | Correct | |------------|---------| | No source citation | Include `Source [<sup>1</sup>](URL)` | | Give up after one failure | Retry at least once | | Use built-in WebSearch/WebFetch | Use GrokSearch tools/CLI |

path: README.md
content: # openclaw-grok-search

一个基于 `grok-skill` 改编的跨平台联网搜索 Skill，面向 OpenClawd（龙虾）/Codex 场景，支持 Windows、macOS、Linux 在项目本地目录直接使用。

## 项目缘起

我最近在玩龙虾（OpenClawd），发现系统自带网络搜索比较鸡肋：

1. Brave API 申请要绑信用卡。
2. 免费版经常触发调用速率限制。
3. 可获取资源不稳定，检索覆盖也受限。

后来逛 L 站时发现了宝藏项目 `grok-skill`。本项目基于原始项目做了改编，重点增强了可移植性和跨平台兼容性。

原始项目参考：`grok-skill`（https://github.com/Frankieli123/grok-skill）

## 核心改造

1. 从 PowerShell 安装/配置改为 Python 脚本，兼容 Win/macOS/Linux。
2. 默认仅在项目下载目录内运行，不再强依赖 `~/.codex` 安装路径。
3. 保留 `grok_search.py` 的结构化 JSON 输出能力（`content` + `sources`）。
4. 保留可扩展参数：`--extra-body-json`、`--extra-headers-json`。

## 最近修复（2026-02-23）

为提升第三方 OpenAI 兼容中转站可用性，`scripts/grok_search.py` 已同步以下兼容增强：

1. SSE 回退解析：当中转站在 `stream=false` 下仍返回 `data: {...}` 分片时，自动拼接为标准 `chat.completion` 结构，避免 `json.loads` 直接失败。
2. 嵌入 JSON 提取：当模型输出为 `<think>...```json {...}```` 或“前置思考 + JSON”时，可提取最终 JSON，恢复 `content`/`sources` 结构化结果。
3. 解析兜底不破坏原行为：若本身就是标准 JSON 响应，仍走原路径，不影响既有使用方式。

## 快速开始

### 一、龙虾安装（OpenClaw）

在龙虾里直接让助手安装这个 skill：

- https://github.com/Stemmaker/openclaw-grok-search

可直接使用这句话：

```text
请帮我安装这里的 skill：https://github.com/Stemmaker/openclaw-grok-search
```

### 二、各大编程 CLI 安装（推荐）

```bash
npx skills add Stemmaker/openclaw-grok-search
```

### 三、手动安装（git clone）

```bash
git clone https://github.com/Stemmaker/openclaw-grok-search.git
cd openclaw-grok-search
```

然后在项目目录执行：

```bash
python scripts/configure.py
python scripts/grok_search.py --query "What changed in Python recently?"
```

## ⚙️ 配置说明

### 方式 A：交互式配置（推荐）

```bash
python scripts/configure.py
```

### 方式 B：手动编辑

编辑项目目录下的配置文件：

```
openclaw-grok-search/config.json
```

```json
{
  "base_url": "https://your-grok-endpoint.example",
  "api_key": "YOUR_API_KEY",
  "model": "grok-4.20-beta",
  "timeout_seconds": 60,
  "extra_body": {},
  "extra_headers": {}
}
```

| 字段 | 说明 |
|------|------|
| `base_url` | 你的 Grok API 端点地址 |
| `api_key` | 你的 API 密钥（**不要提交到 Git**） |
| `model` | 模型名称（如 `grok-4.20-beta`） |
| `timeout_seconds` | 请求超时时间（秒） |
| `extra_body` | 额外的请求体参数 |
| `extra_headers` | 额外的 HTTP 请求头 |

### 方式 C：环境变量

```bash
export GROK_BASE_URL="https://your-grok-endpoint.example"
export GROK_API_KEY="YOUR_API_KEY"
export GROK_MODEL="grok-4.20-beta"
```

## 公益中转站信息（第三方）

### 注册链接：

- https://ai.huan666.de/register?aff=eB8Z

### 站点特性：

1. 注册即送 10 刀。
2. `grok-4.20-beta` 每次搜索约消耗 0.01刀。
3. 若你有 [L 站](https://linux.do/t/topic/1627339)，可再领站点大佬红包 20 刀。
4. 新上线 `Claude Sonnet 4.6`（输入 2/M，输出 10/M）。
5. 每天签到可随机领金额。
6. 纯公益站，不能现金充值，只能使用L站的LDC充值，但是如果只使用grok搜索每天签到积分完全够用。

## 项目结构

```text
openclaw-grok-search/
├─ SKILL.md
├─ config.example.json
└─ scripts/
   ├─ configure.py
   └─ grok_search.py
```

## 隐私与安全

1. API Key 保存在本地配置文件中，请勿提交到公开仓库。
2. 建议把真实密钥写入 `config.local.json`，并加入忽略列表。
3. 第三方中转站属于外部服务，请注意账号和密钥安全。

## 开源协议

沿用上游项目协议（MIT）

## 作者

- 作者：橙家厨子
- 邮箱：[REDACTED_SECRET]

## 求个 Star

如果这个项目对你有帮助，欢迎 Star 支持，也欢迎提 Issue / PR 一起完善。


path: config.example.json
content: {
  "author": "橙家厨子",
  "email": "[REDACTED_SECRET]",
  "base_url": "https://your-grok-endpoint.example",
  "api_key": "YOUR_API_KEY",
  "model": "grok-4.20-beta",
  "timeout_seconds": 60,
  "extra_body": {},
  "extra_headers": {}
}


path: config.json
content: {
  "base_url": "https://ai.huan666.de",
  "api_key": "[REDACTED_SECRET]",
  "model": "grok-4.20-beta",
  "timeout_seconds": 60,
  "extra_body": {},
  "extra_headers": {}
}


path: scripts/configure.py
content: #!/usr/bin/env python3
"""
Author: 橙家厨子
Email: [REDACTED_SECRET]
"""

from __future__ import annotations

import argparse
import json
import os
from pathlib import Path
from typing import Any


def read_json(path: Path) -> dict[str, Any]:
    if not path.exists():
        return {}
    data = json.loads(path.read_text(encoding="utf-8-sig"))
    if not isinstance(data, dict):
        raise ValueError("config must be a JSON object")
    return data


def ask(prompt: str, default: str) -> str:
    suffix = f" [{default}]" if default else ""
    value = input(f"{prompt}{suffix}: ").strip()
    return value or default


def default_config_path() -> Path:
    custom = (os.environ.get("GROK_CONFIG_PATH") or "").strip()
    if custom:
        return Path(custom).expanduser()

    return Path(__file__).resolve().parent.parent / "config.json"


def main() -> int:
    parser = argparse.ArgumentParser(description="Interactive config for openclaw-grok-search (project-local)")
    parser.add_argument("--config", default="", help="Explicit config output path")
    args = parser.parse_args()

    config_path = Path(args.config).expanduser() if args.config else default_config_path()
    config_path.parent.mkdir(parents=True, exist_ok=True)

    existing = {}
    try:
        existing = read_json(config_path)
    except Exception:
        existing = {}

    base_url = ask("Grok base URL", str(existing.get("base_url") or "https://your-grok-endpoint.example"))
    api_key = ask("Grok API key", str(existing.get("api_key") or ""))
    model = ask("Model", str(existing.get("model") or "grok-2-latest"))
    timeout_raw = ask("Timeout seconds", str(existing.get("timeout_seconds") or "60"))

    try:
        timeout_seconds = int(timeout_raw)
    except ValueError:
        timeout_seconds = 60

    config = {
        "author": "橙家厨子",
        "email": "[REDACTED_SECRET]",
        "base_url": base_url,
        "api_key": api_key,
        "model": model,
        "timeout_seconds": timeout_seconds,
        "extra_body": existing.get("extra_body") if isinstance(existing.get("extra_body"), dict) else {},
        "extra_headers": existing.get("extra_headers") if isinstance(existing.get("extra_headers"), dict) else {},
    }

    config_path.write_text(json.dumps(config, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    print(f"Wrote config: {config_path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/grok_search.py
content: import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from typing import Any
from subprocess import CalledProcessError, run

"""
Author: 橙家厨子
Email: [REDACTED_SECRET]
"""


def _compact_json(data: Any) -> str:
    return json.dumps(data, ensure_ascii=False, separators=(",", ":"), sort_keys=False)


def _skill_root() -> str:
    return os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))


def _default_skill_config_paths() -> list[str]:
    root = _skill_root()
    return [
        os.path.join(root, "config.json"),
        os.path.join(root, "config.local.json"),
    ]


def _normalize_api_key(api_key: str) -> str:
    [REDACTED_SECRET])
    if not [REDACTED_SECRET] ""
    placeholder = {"YOUR_API_KEY", "API_KEY", "CHANGE_ME", "REPLACE_ME"}
    if api_key.upper() in placeholder:
        return ""
    return api_key


def _normalize_base_url_value(base_url: str) -> str:
    base_url = base_url.strip()
    if not base_url:
        return ""
    placeholder = {
        "https://your-grok-endpoint.example",
        "YOUR_BASE_URL",
        "BASE_URL",
        "CHANGE_ME",
        "REPLACE_ME",
    }
    if base_url.upper() in placeholder:
        return ""
    return base_url


def _load_json_file(path: str) -> dict[str, Any]:
    try:
        with open(path, "r", encoding="utf-8-sig") as f:
            value = json.load(f)
    except FileNotFoundError:
        return {}
    if not isinstance(value, dict):
        raise ValueError("config must be a JSON object")
    return value


def _has_any_config_file() -> bool:
    return any(os.path.exists(p) for p in _default_skill_config_paths())


def _should_run_configure(args: argparse.Namespace) -> bool:
    if args.base_url.strip() or args.api_key.strip():
        return False
    if (os.environ.get("GROK_BASE_URL", "").strip() and os.environ.get("GROK_API_KEY", "").strip()):
        return False
    if os.environ.get("GROK_CONFIG_PATH", "").strip():
        return os.path.exists(os.environ["GROK_CONFIG_PATH"].strip())
    return not _has_any_config_file()


def _run_configure() -> bool:
    configure_path = os.path.join(_skill_root(), "scripts", "configure.py")
    if not os.path.exists(configure_path):
        sys.stderr.write("configure.py not found; cannot auto-configure.\n")
        return False
    try:
        run([sys.executable, configure_path], check=True)
    except CalledProcessError:
        sys.stderr.write("configure.py failed; aborting.\n")
        return False
    return True


def _normalize_base_url(base_url: str) -> str:
    base_url = base_url.strip().rstrip("/")
    if base_url.endswith("/v1"):
        return base_url[: -len("/v1")]
    return base_url


def _coerce_json_object(text: str) -> dict[str, Any] | None:
    text = text.strip()
    if not text:
        return None
    if text.startswith("{") and text.endswith("}"):
        try:
            value = json.loads(text)
            return value if isinstance(value, dict) else None
        except json.JSONDecodeError:
            return None
    return None


def _extract_embedded_json_object(text: str) -> dict[str, Any] | None:
    decoder = json.JSONDecoder()
    candidates: list[dict[str, Any]] = []
    for i, ch in enumerate(text):
        if ch != "{":
            continue
        try:
            value, end = decoder.raw_decode(text[i:])
        except json.JSONDecodeError:
            continue
        if i + end < len(text):
            tail = text[i + end :].strip()
            if tail and tail not in {"```", "```json", "```JSON"}:
                continue
        if isinstance(value, dict):
            candidates.append(value)
    if not candidates:
        return None
    for item in reversed(candidates):
        if "content" in item or "sources" in item:
            return item
    return candidates[-1]


def _extract_urls(text: str) -> list[str]:
    urls = re.findall(r"https?://[^\s)\]}>\"']+", text)
    seen: set[str] = set()
    out: list[str] = []
    for url in urls:
        url = url.rstrip(".,;:!?'\"")
        if url and url not in seen:
            seen.add(url)
            out.append(url)
    return out


def _load_json_env(var_name: str) -> dict[str, Any]:
    raw = os.environ.get(var_name, "").strip()
    if not raw:
        return {}
    value = json.loads(raw)
    if not isinstance(value, dict):
        raise ValueError(f"{var_name} must be a JSON object")
    return value


def _parse_json_object(raw: str, *, label: str) -> dict[str, Any]:
    raw = raw.strip()
    if not raw:
        return {}
    value = json.loads(raw)
    if not isinstance(value, dict):
        raise ValueError(f"{label} must be a JSON object")
    return value


def _request_chat_completions(
    *,
    base_url: str,
    api_key: str,
    model: str,
    query: str,
    timeout_seconds: float,
    extra_headers: dict[str, Any],
    extra_body: dict[str, Any],
) -> dict[str, Any]:
    url = f"{_normalize_base_url(base_url)}/v1/chat/completions"

    system = (
        "You are a web research assistant. Use live web search/browsing when answering. "
        "Return ONLY a single JSON object with keys: "
        "content (string), sources (array of objects with url/title/snippet when possible). "
        "Keep content concise and evidence-backed."
    )

    body: dict[str, Any] = {
        "model": model,
        "messages": [
            {"role": "system", "content": system},
            {"role": "user", "content": query},
        ],
        "temperature": 0.2,
        "stream": False,
    }
    body.update(extra_body)

    headers: dict[str, str] = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {api_key}",
    }
    for key, value in extra_headers.items():
        headers[str(key)] = str(value)

    req = urllib.request.Request(
        url=url,
        data=_compact_json(body).encode("utf-8"),
        headers=headers,
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=timeout_seconds) as resp:
        raw = resp.read().decode("utf-8", errors="replace")
        try:
            return json.loads(raw)
        except json.JSONDecodeError:
            # Some OpenAI-compatible proxies return SSE chunks even when stream=false.
            content_parts: list[str] = []
            model_name = model
            created = int(time.time())
            for line in raw.splitlines():
                line = line.strip()
                if not line.startswith("data:"):
                    continue
                payload = line[len("data:") :].strip()
                if not payload or payload == "[DONE]":
                    continue
                try:
                    chunk = json.loads(payload)
                except json.JSONDecodeError:
                    continue
                if isinstance(chunk.get("model"), str) and chunk.get("model"):
                    model_name = chunk["model"]
                if isinstance(chunk.get("created"), int):
                    created = chunk["created"]
                choices = chunk.get("choices")
                if not isinstance(choices, list):
                    continue
                for choice in choices:
                    if not isinstance(choice, dict):
                        continue
                    delta = choice.get("delta")
                    if isinstance(delta, dict):
                        piece = delta.get("content")
                        if isinstance(piece, str) and piece:
                            content_parts.append(piece)
            if content_parts:
                return {
                    "id": "chatcmpl-sse-normalized",
                    "object": "chat.completion",
                    "created": created,
                    "model": model_name,
                    "choices": [
                        {
                            "index": 0,
                            "message": {"role": "assistant", "content": "".join(content_parts)},
                            "finish_reason": "stop",
                        }
                    ],
                }
            raise


def main() -> int:
    parser = argparse.ArgumentParser(description="Aggressive web research via OpenAI-compatible Grok endpoint.")
    parser.add_argument("--query", required=True, help="Search query / research task.")
    parser.add_argument("--config", default="", help="Path to config JSON file.")
    parser.add_argument("--base-url", default="", help="Override base URL.")
    parser.add_argument("--api-key", default="", help="Override API key.")
    parser.add_argument("--model", default="", help="Override model.")
    parser.add_argument("--timeout-seconds", type=float, default=0.0, help="Override timeout (seconds).")
    parser.add_argument(
        "--extra-body-json",
        default="",
        help="Extra JSON object merged into request body.",
    )
    parser.add_argument(
        "--extra-headers-json",
        default="",
        help="Extra JSON object merged into request headers.",
    )
    args = parser.parse_args()

    if _should_run_configure(args):
        if not _run_configure():
            return 2

    env_config_path = os.environ.get("GROK_CONFIG_PATH", "").strip()
    explicit_config_path = args.config.strip() or env_config_path

    config_path = ""
    config: dict[str, Any] = {}

    if explicit_config_path:
        config_path = explicit_config_path
        try:
            config = _load_json_file(config_path)
        except Exception as e:
            sys.stderr.write(f"Invalid config ({config_path}): {e}\n")
            return 2
    else:
        fallback_path = ""
        fallback_config: dict[str, Any] = {}
        for candidate in _default_skill_config_paths():
            if not os.path.exists(candidate):
                continue
            try:
                candidate_config = _load_json_file(candidate)
            except Exception as e:
                sys.stderr.write(f"Invalid config ({candidate}): {e}\n")
                return 2

            if not fallback_path:
                fallback_path = candidate
                fallback_config = candidate_config

            candidate_key = _normalize_api_key(str(candidate_config.get("api_key") or ""))
            if candidate_key:
                config_path = candidate
                config = candidate_config
                break

        if not config_path and fallback_path:
            config_path = fallback_path
            config = fallback_config

        if not config_path:
            config_path = _default_skill_config_paths()[0]

    base_url = _normalize_base_url_value(
        args.base_url.strip() or os.environ.get("GROK_BASE_URL", "").strip() or str(config.get("base_url") or "").strip()
    )
    [REDACTED_SECRET]
        args.api_key.strip() or os.environ.get("GROK_API_KEY", "").strip() or str(config.get("api_key") or "").strip()
    )
    model = args.model.strip() or os.environ.get("GROK_MODEL", "").strip() or str(config.get("model") or "").strip() or "grok-2-latest"

    timeout_seconds = args.timeout_seconds
    if not timeout_seconds:
        timeout_seconds = float(os.environ.get("GROK_TIMEOUT_SECONDS", "0") or "0")
    if not timeout_seconds:
        timeout_seconds = float(config.get("timeout_seconds") or 0) or 60.0

    if not base_url:
        sys.stderr.write(
            "Missing base URL: set GROK_BASE_URL, write it to config, or pass --base-url\n"
            f"Config path: {config_path}\n"
        )
        return 2

    if not [REDACTED_SECRET]
            "Missing API key: set GROK_API_KEY, write it to config, or pass --api-key\n"
            f"Config path: {config_path}\n"
        )
        return 2

    try:
        extra_body: dict[str, Any] = {}
        cfg_extra_body = config.get("extra_body")
        if isinstance(cfg_extra_body, dict):
            extra_body.update(cfg_extra_body)
        extra_body.update(_load_json_env("GROK_EXTRA_BODY_JSON"))
        extra_body.update(_parse_json_object(args.extra_body_json, label="--extra-body-json"))

        extra_headers: dict[str, Any] = {}
        cfg_extra_headers = config.get("extra_headers")
        if isinstance(cfg_extra_headers, dict):
            extra_headers.update(cfg_extra_headers)
        extra_headers.update(_load_json_env("GROK_EXTRA_HEADERS_JSON"))
        extra_headers.update(_parse_json_object(args.extra_headers_json, label="--extra-headers-json"))
    except Exception as e:
        sys.stderr.write(f"Invalid JSON: {e}\n")
        return 2

    started = time.time()
    try:
        resp = _request_chat_completions(
            base_url=base_url,
            [REDACTED_SECRET],
            model=model,
            query=args.query,
            timeout_seconds=timeout_seconds,
            extra_headers=extra_headers,
            extra_body=extra_body,
        )
    except urllib.error.HTTPError as e:
        raw = e.read().decode("utf-8", errors="replace") if hasattr(e, "read") else ""
        out = {
            "ok": False,
            "error": f"HTTP {getattr(e, 'code', None)}",
            "detail": raw or str(e),
            "config_path": config_path,
            "base_url": base_url,
            "model": model,
            "elapsed_ms": int((time.time() - started) * 1000),
        }
        sys.stdout.write(_compact_json(out))
        return 1
    except Exception as e:
        out = {
            "ok": False,
            "error": "request_failed",
            "detail": str(e),
            "config_path": config_path,
            "base_url": base_url,
            "model": model,
            "elapsed_ms": int((time.time() - started) * 1000),
        }
        sys.stdout.write(_compact_json(out))
        return 1

    message = ""
    try:
        choice0 = (resp.get("choices") or [{}])[0]
        msg = choice0.get("message") or {}
        message = msg.get("content") or ""
    except Exception:
        message = ""

    parsed = _coerce_json_object(message)
    if parsed is None:
        parsed = _extract_embedded_json_object(message)
    sources: list[dict[str, Any]] = []
    content = ""
    raw = ""

    if parsed is not None:
        content = str(parsed.get("content") or "")
        src = parsed.get("sources")
        if isinstance(src, list):
            for item in src:
                if isinstance(item, dict) and item.get("url"):
                    sources.append(
                        {
                            "url": str(item.get("url")),
                            "title": str(item.get("title") or ""),
                            "snippet": str(item.get("snippet") or ""),
                        }
                    )
        if not sources:
            for url in _extract_urls(content):
                sources.append({"url": url, "title": "", "snippet": ""})
    else:
        raw = message
        for url in _extract_urls(message):
            sources.append({"url": url, "title": "", "snippet": ""})

    out = {
        "ok": True,
        "query": args.query,
        "config_path": config_path,
        "base_url": base_url,
        "model": resp.get("model") or model,
        "content": content,
        "sources": sources,
        "raw": raw,
        "usage": resp.get("usage") or {},
        "elapsed_ms": int((time.time() - started) * 1000),
    }
    sys.stdout.write(_compact_json(out))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)