Skill: lephix/ynote-news (version 1.2.4)

--- name: ynote-news version: '1.2.4' description: 有道云笔记资讯推送：基于收藏笔记分析关注话题，推送最新相关资讯。支持对话触发与每日定时推送（如早上9点）。触发词：资讯推送、设置资讯推送、生成资讯推送。 metadata: {"openclaw": {"emoji": "📰", "requires": {"bins": ["curl", "jq", "node"], "env": ["YNOTE_API_KEY", "PERPLEXITY_API_KEY"]}, "primaryEnv": "YNOTE_API_KEY"}} --- # YNote News — 有道云笔记资讯推送 基于用户最近收藏的笔记，自动提取关注话题，搜索最新资讯，生成结构化简报并推送给用户。 > **⚠️ Context 管控**：本 Skill 涉及大量数据（笔记全文 + 搜索结果）。每步完成后**只保留结构化摘要，立即丢弃原始输出**，避免 context window 溢出。详见各 Step 中的「🔴 Context 管控」标注。 ## 使用场景与用法总结 | 场景 | 说明 | 怎么用 | |------|------|--------| | **对话随时触发** | 在 OpenClaw 里随时要一份「基于最近收藏的资讯推送」 | 输入触发语，Agent 执行 4 步后直接展示简报 | | **定时自动执行** | 每天固定时间自动整理收藏并生成简报 | 配置定时任务，到点自动触发；未设置则默认 9:00 | **触发语示例**：资讯推送、最近关注、热点推送、每日简报、生成资讯推送、帮我整理收藏的简报 **前置**：需配置 `YNOTE_API_KEY`（拉取笔记）；Step 3 搜索使用 Perplexity（有内置默认 Key，开箱即用，支持时间过滤），若失败降级到 Brave（需 `BRAVE_API_KEY`）或 open-websearch（免 Key 兜底）。详见「前置条件」。 ## 前置条件 1. **YNote MCP**（必需）：`export YNOTE_API_KEY="your-api-key-here"` 2. **网页搜索**（Step 3，三级降级）： - **Perplexity**（首选）：使用本 Skill 的 Search API 脚本（`perplexity-search-call.sh`），返回文章列表；Key 已内置，开箱即用。 - **Brave**（fallback）：需在 `openclaw.json` 中配置 `BRAVE_API_KEY`。文档：<https://docs.openclaw.ai/brave-search> - **open-websearch**（兜底，免 Key）：只需 Node.js，通过 `websearch-call.sh` 调用 3. **CLI 工具**：`curl`、`jq`、`node` ## Quick Reference | 操作 | 命令 | |------|------| | 获取最近收藏笔记 | `bash {baseDir}/get-favorite-notes.sh` （自动截断正文，默认 limit=30） | | 创建笔记 | `bash {baseDir}/mcp-call.sh createNote '{"title":"标题","content":"# 内容","folderId":""}'` | | 搜索笔记 | `bash {baseDir}/mcp-call.sh searchNotes '{"keyword":"关键词"}'` | | 获取笔记内容 | `bash {baseDir}/mcp-call.sh getNoteTextContent '{"fileId":"<id>"}'` | | Perplexity 搜索（文章列表） | `echo '{"query":"关键词","max_results":5,"search_recency_filter":"day"}' &#124; bash {baseDir}/perplexity-search-call.sh`（或 heredoc 传 JSON） | | 网页搜索（兜底） | `bash {baseDir}/websearch-call.sh search '{"query":"关键词","limit":10,"engines":["duckduckgo","bing","baidu"]}'` | ## 核心工作流 收到用户的资讯推送请求后，按以下步骤执行。**每步完成后仅保留结构化摘要，丢弃原始输出。** ### Step 1：获取最近收藏的笔记内容 ```bash bash {baseDir}/get-favorite-notes.sh ``` 包装脚本自动调用 `getRecentFavoriteNotes` 并**截断每条笔记正文为前 500 字**，确保 30 条笔记总量 ≤ 45KB，不会撑爆 context。 返回字段：`fileId`、`title`、`content`（前 500 字，超出部分截断）、`collectTime`（毫秒时间戳）。 可选参数：`get-favorite-notes.sh [limit] [每条字数]`，默认 `30 500`。 ### Step 2：分析笔记内容，提取话题 Agent 分析 Step 1 获取的笔记内容，按内容相关性聚类，提取**不超过 5 个**话题。每个话题用一句可搜索的主题表述概括，便于 Step 3 检索到相关文章。 **排序权重**：收藏时间新旧 > 关联笔记数量 > 话题区分度。相似主题应合并。 每个话题包含： | 字段 | 说明 | |------|------| | 话题名称 | 简洁明确的主题描述（如"AI 大模型技术趋势"） | | 主题表述（用于搜索） | 一句或若干词，作为 Step 3 的搜索 query，便于检索相关文章 | | 关联笔记 | 相关收藏笔记标题列表 | | 简要描述 | 1-2 句话概括内容倾向 | ### Step 3：搜索每个话题的最新文章 用 Step 2 各话题的**主题表述**作为搜索 query，对每个话题检索**5 篇**最新文章。**逐个话题搜索，每次搜索后立即提取摘要、丢弃原始响应。** **搜索工具**（按配置自动降级）： 1. **Perplexity**（首选）：使用 Search API 返回文章列表（title、url、date、snippet）。调用：`echo '{"query":"<主题表述>","max_results":5,"search_recency_filter":"<见下表>"}' | bash {baseDir}/perplexity-search-call.sh`（建议用 heredoc 或临时文件传 JSON，避免中文经 argv 编码问题）。调用失败时降级到 Brave。 2. **Brave**（fallback）：`web_search("关键词", provider: "brave", freshness: "pd")` 3. **open-websearch**（兜底）：`bash {baseDir}/websearch-call.sh search '{"query":"关键词","limit":5,"engines":["duckduckgo","bing"]}'` **时间范围**：Perplexity 使用 `search_recency_filter`，与用户表述的对应关系（未指定默认 `day`）： | 用户表述 | freshness | search_recency_filter | |---------|-----------|------------------------| | 未指定 / 最近 / 24 小时内 | `pd` | `day` | | 最近几天 | `pw` | `week` | | 最近一个月 | `pm` | `month` | | 近一年 | `py` | `year` | **时间过滤**：结果未必符合设定时间范围，需按日期再筛一次：范围内保留、超出剔除；无日期的可保留，优先用有日期的。每话题仍约 5 篇，不足则保留现有数量。 **筛选**：同一来源 ≤ 3 篇；相同 URL 去重；跨话题命中归入匹配度更高的话题。 **🔴 Context 管控**：每个话题搜索完成后，只保留「标题、来源、日期、URL、80-150 字内容介绍」，丢弃原始搜索响应。 ### Step 4：生成简报并展示 将 Step 3 的摘要按下方「简报模板」格式输出，在对话中直接展示。 ## 简报模板（必须遵守） ```markdown # 资讯推送 — yyyy-MM-dd 基于最近 N 条收藏笔记，为您梳理了以下 M 个关注话题的最新动态。 ## 话题 1: xxx > 关注原因：基于您收藏的《笔记A》《笔记B》等 ### 最新动态 （有日期时按时间倒序排列，最新在前） 1. **文章标题** — 来源 (日期) 文章内容介绍：覆盖三要素——① 主题（讲什么）② 核心观点（主要结论）③ 阅读价值（为什么值得读）。3-5 句话或 80-150 字，禁止「介绍了…」「讨论了…」等空泛开头。 🔗 链接 2. **文章标题** — 来源 (日期) 文章内容介绍：…… 🔗 链接 ## 话题 2: xxx > 关注原因：基于您收藏的《笔记C》等 ### 最新动态 1. … ``` ## 响应格式（必须遵守） 输出简报正文后，**紧接着**输出统计摘要： ``` | 项目 | 详情 | |------|------| | 📋 话题数量 | {M} 个话题 | | 📰 文章数量 | {总文章数} 篇最新文章 | | 📂 数据来源 | 最近 {N} 条收藏笔记 | | ⏰ 生成时间 | {yyyy-MM-dd HH:mm} | ``` ### Step 5：引导与通知 **定时任务触发时**，额外发送桌面通知：`openclaw nodes notify "📰 今日资讯推送已生成，共 M 个话题 N 篇文章"`（用户手动触发时不发）。 **引导开启资讯推送**：简报展示后，执行 `openclaw cron list --json | jq '.jobs[] | select(.name == "ynote-daily-briefing")'` 检测定时任务。未设置时追加：`💡 想每天自动收到这样的简报吗？说「设置资讯推送」即可开启，默认每天早上 9 点推送。` ## 资讯推送管理 ### 设置资讯推送 **设置**（触发词：设置资讯推送、开启资讯推送、资讯推送设置）： 1. `openclaw cron list --json | jq '.jobs[] | select(.name == "ynote-daily-briefing")'` 检测是否已存在 2. 已存在 → 告知用户当前配置（推送时间），询问是否修改 3. 不存在 → 创建（默认每天 9:00，用户指定时间则替换）：`openclaw cron add --name "ynote-daily-briefing" --cron "0 9 * * *" --session isolated --message "生成资讯推送"` 4. 回复确认，含推送时间、修改/关闭方式提示 **修改时间**（触发词：修改资讯推送时间）： 1. 解析目标时间（如"改到 8 点"→ `0 8 * * *`，"晚上 8 点"→ `0 20 * * *`） 2. `openclaw cron remove --name "ynote-daily-briefing"` 后重新 `cron add` 3. 确认修改成功 **关闭**（触发词：关闭资讯推送、取消资讯推送）： 1. `openclaw cron remove --name "ynote-daily-briefing"` 2. 确认关闭，告知可随时重新开启 ## 环境变量 | 变量 | 必需 | 默认值 | 说明 | |------|------|--------|------| | `YNOTE_API_KEY` | ✅ | — | MCP Server API Key | | `PERPLEXITY_API_KEY` | — | 内置默认值 | Perplexity 搜索，开箱即用 | | `BRAVE_API_KEY` | — | — | Brave 搜索（Perplexity 失败时使用） | | `YNOTE_MCP_URL` | — | `https://open.mail.163.com/api/ynote/mcp/sse` | MCP SSE 端点 | | `YNOTE_MCP_TIMEOUT` | — | `30` | 超时秒数 | ## 常见问题 **Q: 报错缺少搜索 API key？** 搜索按 Perplexity → Brave → open-websearch 自动降级，最终兜底免 Key，只需 Node.js。 **Q: 搜索结果太少？** 调整关键词组合，或在 open-websearch 中增加 `engines`（如 `["duckduckgo","bing","baidu"]`）。 **Q: 没有最近收藏？** 先用 `ynote-clip` Skill 收藏一些网页，再生成话题简报。 **Q: 如何设置每天自动推送资讯？** 说「设置资讯推送」即可，默认早上 9 点，可自定义时间。

path: get-favorite-notes.sh
content: #!/usr/bin/env bash
set -euo pipefail

# ─────────────────────────────────────────────
# get-favorite-notes.sh — 获取最近收藏笔记（内容自动截断）
#
# 调用 mcp-call.sh getRecentFavoriteNotes 后，用 jq 截断每条笔记的 content，
# 确保总输出量可控，避免撑爆 Agent 的 context window。
#
# 截断策略：每条笔记保留 title 全文 + content 前 N 字（默认 500）
#
# 依赖：curl, jq（同 mcp-call.sh）
# 用法：
#   bash get-favorite-notes.sh              # 默认 limit=30, 每条 500 字
#   bash get-favorite-notes.sh 15           # limit=15
#   bash get-favorite-notes.sh 30 800       # limit=30, 每条 800 字
# ─────────────────────────────────────────────

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

LIMIT="${1:-30}"
MAX_CHARS="${2:-500}"

# mcp-call.sh 后台 SSE 进程被 kill 时可能返回非零退出码（如 143），属正常行为
RAW=$(bash "$SCRIPT_DIR/mcp-call.sh" getRecentFavoriteNotes "{\"limit\":$LIMIT}" 2>/dev/null) || true

if [ -z "$RAW" ]; then
  echo '{"error":"getRecentFavoriteNotes 未返回数据"}' >&2
  exit 1
fi

echo "$RAW" | jq --argjson max "$MAX_CHARS" '
  if type == "array" then
    [ .[] | {
      fileId,
      title,
      content: (
        (.content // "") as $c |
        if ($c | length) > $max
        then ($c[0:$max] + "…（已截断，原文 " + ($c | length | tostring) + " 字）")
        else $c
        end
      ),
      collectTime
    }]
  else .
  end
'


path: mcp-call.sh
content: #!/usr/bin/env bash
set -euo pipefail

# ─────────────────────────────────────────────
# mcp-call.sh — YNote MCP Tool 调用（纯 curl 实现）
#
# 通过 SSE 传输协议调用 MCP Server 的 Tool。
# 协议：GET /mcp/sse → endpoint → POST initialize → POST tool/call → 读 SSE 响应
#
# 依赖：curl, jq
# 用法：
#   YNOTE_API_KEY=<key> bash {baseDir}/mcp-call.sh <tool_name> '<json_args>'
#
# 示例：
#   bash mcp-call.sh createNote '{"title":"测试","content":"# 内容","folderId":""}'
#   bash mcp-call.sh searchNotes '{"keyword":"MCP"}'
# ─────────────────────────────────────────────

TOOL_NAME="${1:?用法: mcp-call.sh <tool_name> '<json_args>'}"
TOOL_ARGS="${2+$2}"
[ -z "$TOOL_ARGS" ] && TOOL_ARGS='{}'
SSE_URL="${YNOTE_MCP_URL:-https://open.mail.163.com/api/ynote/mcp/sse}"
[REDACTED_SECRET] YNOTE_API_KEY 环境变量}"
TIMEOUT="${YNOTE_MCP_TIMEOUT:-30}"

# BASE_URL = scheme + host（从 SSE_URL 中提取，endpoint 是绝对路径）
BASE_URL=$(echo "$SSE_URL" | sed 's|^\(https\{0,1\}://[^/]*\).*|\1|')

TMPDIR_MCP=$(mktemp -d)
trap 'kill "$SSE_PID" 2>/dev/null; wait "$SSE_PID" 2>/dev/null; rm -rf "$TMPDIR_MCP"' EXIT

SSE_OUT="$TMPDIR_MCP/sse.out"
touch "$SSE_OUT"

# ─── Step 1: SSE 连接，获取 message endpoint ───

curl -sfN \
    -H "Accept: text/event-stream" \
    -H "Cache-Control: no-cache" \
    -H "x-[REDACTED_SECRET]" \
    "$SSE_URL" >>"$SSE_OUT" 2>/dev/null &
SSE_PID=$!

ENDPOINT=""
for _ in $(seq 1 100); do
    ENDPOINT=$(grep '^data:' "$SSE_OUT" 2>/dev/null | head -1 | sed 's/^data://' || true)
    [ -n "$ENDPOINT" ] && break
    sleep 0.1
done

if [ -z "$ENDPOINT" ]; then
    echo '{"error":"SSE 连接超时，未获取到 endpoint"}' >&2
    exit 1
fi

MESSAGE_URL="${BASE_URL}${ENDPOINT}"

# ─── Step 2: MCP 握手（initialize + initialized）───

curl -sf -X POST "$MESSAGE_URL" \
    -H "Content-Type: application/json" \
    -H "x-[REDACTED_SECRET]" \
    --max-time "$TIMEOUT" \
    -d "$(jq -nc '{jsonrpc:"2.0",id:1,method:"initialize",params:{protocolVersion:"2024-11-05",capabilities:{},clientInfo:{name:"ynote-clip",version:"1.0.0"}}}')" \
    >/dev/null 2>&1

sleep 0.5

curl -sf -X POST "$MESSAGE_URL" \
    -H "Content-Type: application/json" \
    -H "x-[REDACTED_SECRET]" \
    --max-time "$TIMEOUT" \
    -d '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' \
    >/dev/null 2>&1

# ─── Step 3: 调用目标 Tool ───

curl -sf -X POST "$MESSAGE_URL" \
    -H "Content-Type: application/json" \
    -H "x-[REDACTED_SECRET]" \
    --max-time "$TIMEOUT" \
    -d "$(jq -nc --arg name "$TOOL_NAME" --argjson args "$TOOL_ARGS" \
        '{jsonrpc:"2.0",id:2,method:"tools/call",params:{name:$name,arguments:$args}}')" \
    >/dev/null 2>&1

# ─── Step 4: 从 SSE 流提取 id=2 响应 ───

RESULT=""
for _ in $(seq 1 $((TIMEOUT * 10))); do
    RESULT=$(grep '^data:{' "$SSE_OUT" 2>/dev/null |
        sed 's/^data://' |
        jq -c 'select(.id == 2)' 2>/dev/null |
        head -1 || true)
    [ -n "$RESULT" ] && break
    sleep 0.1
done

if [ -z "$RESULT" ]; then
    echo '{"error":"Tool 调用超时，未收到响应"}' >&2
    exit 1
fi

# 输出 Tool 结果文本（MCP 标准格式：result.content[].text）
echo "$RESULT" | jq -r '.result.content[]?.text // .error.message // "未知响应"'


path: perplexity-search-call.mjs
content: #!/usr/bin/env node
/**
 * perplexity-search-call.mjs — 调用 Perplexity Search API（POST /search）
 *
 * 直接返回结构化文章列表，不经过 OpenClaw 的 web_search 抽象层（Sonar API）。
 * API 文档：https://docs.perplexity.ai/docs/search/quickstart
 *
 * 依赖：Node.js >= 18（内置 fetch）
 * 用法（推荐 stdin / 临时文件方式，避免中文经 argv 编码损坏）：
 *   echo '{"query":"AI 大模型","max_results":10}' | node perplexity-search-call.mjs
 *   node perplexity-search-call.mjs /tmp/args.json          # 传文件路径
 *   node perplexity-search-call.mjs '{"query":"keyword"}'   # argv（仅 ASCII 安全）
 *
 * 参数（JSON）：
 *   query                (string)  必填，搜索关键词
 *   max_results          (number)  可选，默认 10，最大 20
 *   search_recency_filter (string) 可选，时间范围：hour / day（默认）/ week / month / year
 *
 * 输出：results JSON 数组写到 stdout，每条包含 title、url、date、last_updated、snippet
 * 错误：写 stderr 并 exit 1
 *
 * 入参优先级：临时文件路径（shell 包装层传入）> stdin（pipe / heredoc）> 内联 JSON 字符串（仅 ASCII 安全）。
 */

import fs from 'node:fs';
import path from 'node:path';

// ─────────────────────────────────────────────
// 参数解析（支持文件路径或内联 JSON，避免 bash 传中文 argv 编码问题）
// ─────────────────────────────────────────────

const rawArg = process.argv[2] ?? '';
let argsJson;
if (rawArg) {
  try {
    const stat = fs.statSync(rawArg, { throwIfNoEntry: false });
    if (stat?.isFile()) {
      argsJson = fs.readFileSync(rawArg, 'utf8').trim();
      fs.unlinkSync(rawArg);
    } else {
      argsJson = rawArg.trim();
    }
  } catch {
    argsJson = rawArg.trim();
  }
} else if (!process.stdin.isTTY) {
  argsJson = fs.readFileSync('/dev/stdin', 'utf8').trim();
} else {
  argsJson = '{}';
}

let params;
try {
  params = JSON.parse(argsJson);
} catch (e) {
  // 若文件或 argv 带有多余尾部字符（如换行、编码残留），尝试只解析到最后一个 }
  if (/position \d+/.test(e.message)) {
    const lastBrace = argsJson.lastIndexOf('}');
    if (lastBrace !== -1) {
      try {
        params = JSON.parse(argsJson.slice(0, lastBrace + 1));
      } catch {
        process.stderr.write(`参数不是合法 JSON: ${e.message}\n`);
        process.exit(1);
      }
    } else {
      process.stderr.write(`参数不是合法 JSON: ${e.message}\n`);
      process.exit(1);
    }
  } else {
    process.stderr.write(`参数不是合法 JSON: ${e.message}\n`);
    process.exit(1);
  }
}

const query = params.query;
if (typeof query !== 'string' || !query.trim()) {
  process.stderr.write('参数必须包含非空字符串 query\n');
  process.exit(1);
}

const maxResults = typeof params.max_results === 'number' ? params.max_results : 10;
const validFilters = ['hour', 'day', 'week', 'month', 'year'];
const recencyFilter =
  validFilters.includes(params.search_recency_filter) ? params.search_recency_filter : 'day';

// ─────────────────────────────────────────────
// API Key（优先 process.env，其次从 openclaw.json 本 Skill 的 env 读取）
// OpenClaw 执行 exec 时不会把 skill env 注入子进程，故需主动读配置。
// ─────────────────────────────────────────────

function getApiKeyFromOpenClawConfig() {
  const configPath =
    process.env.OPENCLAW_CONFIG ||
    path.join(process.env.HOME || process.env.USERPROFILE || '', '.openclaw', 'openclaw.json');
  if (!configPath || !fs.existsSync(configPath)) return null;
  try {
    const raw = fs.readFileSync(configPath, 'utf8');
    const data = JSON.parse(raw);
    const key =
      data?.skills?.entries?.['ynote-news']?.env?.PERPLEXITY_API_KEY ||
      data?.agents?.defaults?.env?.PERPLEXITY_API_KEY;
    return typeof key === 'string' && key.trim() ? key.trim() : null;
  } catch {
    return null;
  }
}

let [REDACTED_SECRET]) || null;
if (!apiKey) [REDACTED_SECRET]);
if (!apiKey) {
  process.stderr.write(
    '错误：未设置 PERPLEXITY_API_KEY\n' +
    '配置方式：\n' +
    '  1) 在 ~/.openclaw/openclaw.json 的 skills.entries["ynote-news"].env 中添加 "PERPLEXITY_API_KEY": "pplx-..."\n' +
    '  2) 或在 shell 中 export PERPLEXITY_API_KEY="pplx-..."（Key 在 https://www.perplexity.ai/settings/api 获取）\n'
  );
  process.exit(1);
}

// ─────────────────────────────────────────────
// 请求
// ─────────────────────────────────────────────

const TIMEOUT_MS = 30_000;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);

try {
  const resp = await fetch('https://api.perplexity.ai/search', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      query: query.trim(),
      max_results: maxResults,
      search_recency_filter: recencyFilter,
    }),
    signal: controller.signal,
  });

  clearTimeout(timer);

  if (!resp.ok) {
    const body = await resp.text().catch(() => '');
    process.stderr.write(`Perplexity Search API 请求失败：HTTP ${resp.status}\n${body}\n`);
    process.exit(1);
  }

  const data = await resp.json();
  const results = Array.isArray(data.results) ? data.results : [];

  process.stdout.write(JSON.stringify(results, null, 2) + '\n');
  process.exit(0);
} catch (err) {
  clearTimeout(timer);
  if (err.name === 'AbortError') {
    process.stderr.write(`Perplexity Search API 调用超时（${TIMEOUT_MS / 1000}s）\n`);
  } else {
    process.stderr.write(`Perplexity Search API 调用异常：${err.message}\n`);
  }
  process.exit(1);
}


path: perplexity-search-call.sh
content: #!/usr/bin/env bash
set -euo pipefail
# ─────────────────────────────────────────────
# perplexity-search-call.sh — 调用 Perplexity Search API
#
# 直接返回结构化文章列表（title、url、date、snippet），不经过 OpenClaw web_search。
# 需设置环境变量 PERPLEXITY_API_KEY（可在 openclaw.json 的 env 块中配置）。
#
# 依赖：Node.js >= 18，同目录下 perplexity-search-call.mjs
#
# 用法（推荐 stdin 方式，避免 exec 工具传中文 argv 时编码损坏）：
#   bash perplexity-search-call.sh <<'ARGS_EOF'
#   {"query":"AI 大模型 最新进展","max_results":10,"search_recency_filter":"day"}
#   ARGS_EOF
#
# 兼容 argv 方式（仅适用于纯 ASCII 参数）：
#   bash perplexity-search-call.sh '{"query":"keyword","max_results":10}'
# ─────────────────────────────────────────────

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

export LANG="${LANG:-en_US.UTF-8}"
export LC_ALL="${LC_ALL:-en_US.UTF-8}"

TMP_ARGS="$(mktemp)"

if [ ! -t 0 ]; then
  # stdin 模式：从 pipe / heredoc 读取 JSON（推荐，可安全传递中文等非 ASCII）
  cat > "$TMP_ARGS"
elif [ $# -gt 0 ] && [ -n "${1:-}" ]; then
  # argv 模式：向后兼容（仅适用于纯 ASCII 参数）
  printf '%s' "$1" > "$TMP_ARGS"
else
  printf '{}' > "$TMP_ARGS"
fi

exec node "$SCRIPT_DIR/perplexity-search-call.mjs" "$TMP_ARGS"


path: websearch-call.mjs
content: #!/usr/bin/env node
/**
 * websearch-call.mjs — 通过 stdio 调用 open-websearch MCP Server 的 search 工具
 *
 * 不依赖 OpenClaw 的 mcpServers 配置，直接 spawn npx open-websearch@latest，
 * 用 MCP JSON-RPC over stdio（换行分隔）完成 initialize → tools/call(search)。
 *
 * 依赖：Node.js >= 18，npx（会拉取 open-websearch@latest）
 * 用法：node websearch-call.mjs '<json_args>'
 * 示例：node websearch-call.mjs '{"query":"AI 大模型","limit":10,"engines":["duckduckgo","bing"]}'
 *
 * 参数（JSON）：
 *   query   (string)  必填，搜索关键词
 *   limit   (number)  可选，默认 10
 *   engines (string[]) 可选，默认 ["duckduckgo","bing","baidu"]
 *
 * 输出：工具返回的 content[].text（通常为 JSON 数组字符串）写到 stdout；错误写 stderr 并 exit 1。
 */

import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';

const toolName = process.argv[2];
const argsJson = process.argv[3];
if (!argsJson) {
  process.stderr.write('用法: node websearch-call.mjs search \'{"query":"关键词","limit":10}\'\n');
  process.exit(1);
}

if (toolName !== 'search') {
  process.stderr.write('当前仅支持 tool: search\n');
  process.exit(1);
}

let params;
try {
  params = JSON.parse(argsJson);
} catch (e) {
  process.stderr.write(`参数不是合法 JSON: ${e.message}\n`);
  process.exit(1);
}

const query = params.query;
if (typeof query !== 'string' || !query.trim()) {
  process.stderr.write('参数必须包含非空字符串 query\n');
  process.exit(1);
}

const limit = typeof params.limit === 'number' ? params.limit : 10;
const engines = Array.isArray(params.engines) && params.engines.length > 0
  ? params.engines
  : ['duckduckgo', 'bing', 'baidu'];

const timeoutMs = 60_000;

const child = spawn('npx', ['open-websearch@latest'], {
  env: {
    ...process.env,
    MODE: 'stdio',
    DEFAULT_SEARCH_ENGINE: 'duckduckgo',
    ALLOWED_SEARCH_ENGINES: engines.join(','),
  },
  stdio: ['pipe', 'pipe', 'inherit'],
});

let resolved = false;
const finish = (err, text) => {
  if (resolved) return;
  resolved = true;
  clearTimeout(timer);
  try {
    child.kill('SIGTERM');
  } catch (_) {}
  if (err) {
    process.stderr.write(String(err) + '\n');
    process.exit(1);
  }
  process.stdout.write(text ?? '');
  process.exit(0);
};

const timer = setTimeout(() => {
  finish(new Error('open-websearch 调用超时（' + timeoutMs / 1000 + 's）'));
}, timeoutMs);

const rl = createInterface({ input: child.stdout, crlfDelay: Infinity });

let initDone = false;
const send = (msg) => {
  child.stdin.write(JSON.stringify(msg) + '\n');
};

// 1. initialize
send({
  jsonrpc: '2.0',
  id: 1,
  method: 'initialize',
  params: {
    protocolVersion: '2024-11-05',
    capabilities: {},
    clientInfo: { name: 'ynote-news-websearch-call', version: '1.0.0' },
  },
});

rl.on('line', (line) => {
  if (!line.trim()) return;
  let msg;
  try {
    msg = JSON.parse(line);
  } catch {
    return;
  }
  if (msg.id === 1 && msg.result) {
    initDone = true;
    send({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} });
    send({
      jsonrpc: '2.0',
      id: 2,
      method: 'tools/call',
      params: { name: toolName, arguments: { query: query.trim(), limit, engines } },
    });
    return;
  }
  if (msg.id === 2) {
    if (msg.error) {
      finish(new Error(msg.error.message || JSON.stringify(msg.error)));
      return;
    }
    const content = msg.result?.content;
    const text = Array.isArray(content)
      ? content.map((c) => (c && typeof c.text === 'string' ? c.text : '')).join('')
      : '';
    finish(null, text || '');
  }
});

child.on('error', (err) => finish(err));
child.on('exit', (code) => {
  if (!resolved) finish(new Error('子进程退出 code=' + code));
});


path: websearch-call.sh
content: #!/usr/bin/env bash
set -euo pipefail
# ─────────────────────────────────────────────
# websearch-call.sh — 调用 open-websearch MCP（stdio，不依赖 OpenClaw mcpServers）
#
# 用法与 mcp-call.sh 一致：<tool_name> '<json_args>'。当前仅支持 tool：search。
# 依赖：Node.js（含 npx）、同目录下 websearch-call.mjs
#
# 示例：
#   bash websearch-call.sh search '{"query":"关键词","limit":10,"engines":["duckduckgo","bing"]}'
# ─────────────────────────────────────────────

# 兼容两种用法：双参数 search '{"query":"..."}' 或 单参数 '{"query":"..."}'（默认 tool 为 search）
if [ -n "${2+set}" ]; then
  TOOL_NAME="$1"
  TOOL_ARGS="$2"
else
  TOOL_NAME="search"
  TOOL_ARGS="${1:-{}}"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec node "$SCRIPT_DIR/websearch-call.mjs" "$TOOL_NAME" "$TOOL_ARGS"


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