Skill: jlin53882/minimax-pdf-analysis-v2 (version 1.0.0)

--- name: minimax-pdf-analysis description: Analyze PDF files using MiniMax API. Supports text extraction, keyword search, and image-based VLM analysis (converts PDF pages to images first). Requires MiniMax Coding Plan API key (sk-cp-*). metadata: {"openclaw":{"emoji":"📄","requires":{"bins":["python","node"],"env":["MINIMAX_API_KEY"]}}} --- # MiniMax PDF Analysis 使用 MiniMax API 分析 PDF，支援三種模式。 ## 三種分析模式 ### 1. 萃取文字 `extract` ```bash python scripts/analyze_pdf.py extract input.pdf python scripts/analyze_pdf.py extract input.pdf --output out.txt python scripts/analyze_pdf.py extract input.pdf --pages 1-3 ``` ### 2. 搜尋關鍵字 `search` ```bash python scripts/analyze_pdf.py search input.pdf "PID" python scripts/analyze_pdf.py search input.pdf "error" --ignore-case python scripts/analyze_pdf.py search input.pdf "regex.*pattern" --regex ``` ### 3. VLM 圖片分析 `vision` ```bash # 需要 MINIMAX_API_KEY 環境變數 export MINIMAX_API_KEY=sk-cp-xxx # 分析整份 PDF（每頁轉圖片後分析） python scripts/analyze_pdf.py vision input.pdf "摘要這張投影片的內容" # 只分析特定頁面 python scripts/analyze_pdf.py vision input.pdf "摘要" --pages 1-3 --dpi 200 ``` ## 環境需求 | 套件 | 安裝方式 | |------|---------| | PyMuPDF | `pip install pymupdf` | | MiniMax API Key | `MINIMAX_API_KEY=sk-cp-xxx` |

path: README.md
content: # MiniMax PDF Analysis Skill

Analyze PDF files using MiniMax Coding Plan API — supports text extraction, keyword search, and VLM image-based analysis.

## Features

- **Extract** — pull readable text out of any PDF
- **Search** — find keyword snippets inside PDF pages
- **Vision** — convert PDF pages to images, then analyze with MiniMax VLM (great for scanned/image-based PDFs)

## Setup

```bash
# Install dependencies
pip install pymupdf

# Set API key
export MINIMAX_API_KEY=sk-cp-xxx
```

Get your Coding Plan API key at: https://platform.minimaxi.com

## Usage

```bash
# Extract text
python scripts/analyze_pdf.py extract report.pdf --output text.txt

# Search inside PDF
python scripts/analyze_pdf.py search report.pdf "revenue" --ignore-case

# VLM image analysis (converts pages to PNG first)
python scripts/analyze_pdf.py vision report.pdf "請摘要這張投影片的內容"
python scripts/analyze_pdf.py vision report.pdf "分析這份文件的結構" --pages 1-5 --dpi 200
```

## How Vision Mode Works

```
PDF → PyMuPDF → PNG images → MiniMax VLM API → Text analysis
```

Works even on scanned/image-only PDFs that have no extractable text.

## License

MIT


path: scripts/analyze_pdf.py
content: #!/usr/bin/env python3
"""analyze_pdf.py — PDF 分析整合工具

整合三種分析模式：
1. extract  — 萃取 PDF 文字內容
2. search    — 搜尋 PDF 內文關鍵字
3. vision    — 將 PDF 頁面轉圖片，用 MiniMax VLM 分析

使用方式：
  python analyze_pdf.py extract input.pdf [--output out.txt]
  python analyze_pdf.py search input.pdf "關鍵字" [--ignore-case]
  python analyze_pdf.py vision input.pdf "分析要求" [--api-key KEY]
"""

import argparse
import base64
import json
import os
import subprocess
import sys
import tempfile
import shutil
from pathlib import Path

MINIMAX_TOOL_JS = Path(__file__).parent / "minimax_coding_plan_tool.js"


# ─────────────────────────────────────────
# 1. extract — 萃取文字
# ─────────────────────────────────────────
def cmd_extract(pdf_path: str, output: str | None, pages: str | None):
    script = Path(__file__).parent / "extract_pdf_text.py"
    cmd = [sys.executable, str(script), pdf_path]
    if output:
        cmd.extend(["--output", output])
    if pages:
        cmd.extend(["--pages", pages])
    result = subprocess.run(cmd, capture_output=True, text=True)
    return result.stdout, result.stderr, result.returncode


# ─────────────────────────────────────────
# 2. search — 搜尋內文
# ─────────────────────────────────────────
def cmd_search(pdf_path: str, query: str, ignore_case: bool = False,
               regex: bool = False, context: int = 80):
    script = Path(__file__).parent / "search_pdf.py"
    cmd = [sys.executable, str(script), pdf_path, query]
    if ignore_case:
        cmd.append("--ignore-case")
    if regex:
        cmd.append("--regex")
    if context != 80:
        cmd.extend(["--context", str(context)])
    result = subprocess.run(cmd, capture_output=True, text=True)
    return result.stdout, result.stderr, result.returncode


# ─────────────────────────────────────────
# 3. vision — VLM 圖片分析
# ─────────────────────────────────────────
def cmd_vision(pdf_path: str, prompt: str, api_key: str,
               pages: str | None = None, dpi: int = 200):
    """
    將 PDF 轉成圖片，再用 MiniMax VLM 分析。
    """
    # 1. PDF → PNG
    tmp_dir = Path(tempfile.mkdtemp(prefix="pdf_vision_"))
    try:
        conv_script = Path(__file__).parent / "pdf_to_images.py"
        page_arg = ["--pages", pages] if pages else []
        conv_result = subprocess.run(
            [sys.executable, str(conv_script), str(pdf_path), str(tmp_dir),
             "--dpi", str(dpi)] + page_arg,
            capture_output=True, text=True
        )
        if conv_result.returncode != 0:
            return "", conv_result.stderr, 1

        # 找出所有圖片
        images = sorted(tmp_dir.glob("*.png"))
        if not images:
            return "", "No images generated from PDF", 1

        # 2. 逐頁 VLM 分析
        tool_js = Path(__file__).parent / "minimax_coding_plan_tool.js"
        if not tool_js.exists():
            return "", "minimax_coding_plan_tool.js not found", 1

        all_results = []
        for img in images:
            with open(img, "rb") as f:
                img_b64 = base64.b64encode(f.read()).decode()
            # 呼叫 MiniMax VLM（直接 POST，不走 CLI 以便取得完整回應）
            import urllib.request
            data = json.dumps({
                "prompt": prompt,
                "image_url": f"data:image/png;base64,{img_b64}"
            }).encode()
            req = urllib.request.Request(
                "https://api.minimax.io/v1/coding_plan/vlm",
                data=data,
                headers={
                    "Authorization": f"Bearer {api_key}",
                    "Content-Type": "application/json"
                },
                method="POST"
            )
            try:
                with urllib.request.urlopen(req, timeout=60) as resp:
                    result = json.loads(resp.read())
                    content = result.get("content", "")
                    if content:
                        all_results.append(f"=== {img.name} ===\n{content}")
                    else:
                        all_results.append(f"=== {img.name} ===\n[No content returned]")
            except Exception as e:
                all_results.append(f"=== {img.name} ===\n[Error: {e}]")

        return "\n\n".join(all_results), "", 0

    finally:
        shutil.rmtree(tmp_dir, ignore_errors=True)


# ─────────────────────────────────────────
# CLI 入口
# ─────────────────────────────────────────
def main():
    parser = argparse.ArgumentParser(description="PDF 分析工具")
    sub = parser.add_subparsers(dest="command", required=True)

    # extract
    e = sub.add_parser("extract", help="萃取 PDF 文字")
    e.add_argument("pdf", help="PDF 檔案路徑")
    e.add_argument("--output", "-o", help="輸出文字檔")
    e.add_argument("--pages", help="頁面範圍，如 1-3,5")

    # search
    s = sub.add_parser("search", help="搜尋 PDF 內文")
    s.add_argument("pdf", help="PDF 檔案路徑")
    s.add_argument("query", help="搜尋關鍵字")
    s.add_argument("--ignore-case", action="store_true")
    s.add_argument("--regex", action="store_true")
    s.add_argument("--context", type=int, default=80)

    # vision
    v = sub.add_parser("vision", help="用 MiniMax VLM 分析 PDF 圖片")
    v.add_argument("pdf", help="PDF 檔案路徑")
    v.add_argument("prompt", help="分析要求")
    v.add_argument("--api-key", default=os.environ.get("MINIMAX_API_KEY", ""))
    v.add_argument("--pages", help="頁面範圍，如 1-3")
    v.add_argument("--dpi", type=int, default=200)

    args = parser.parse_args()

    if args.command == "extract":
        out, err, rc = cmd_extract(args.pdf, args.output, args.pages)
        if err:
            print("STDERR:", err, file=sys.stderr)
        print(out)
        sys.exit(rc)

    elif args.command == "search":
        out, err, rc = cmd_search(args.pdf, args.query,
                                    args.ignore_case, args.regex, args.context)
        if err:
            print("STDERR:", err, file=sys.stderr)
        print(out)
        sys.exit(rc)

    elif args.command == "vision":
        if not args.[REDACTED_SECRET]"Error: MINIMAX_API_KEY not set. Use --api-key or set MINIMAX_API_KEY env var.", file=sys.stderr)
            sys.exit(1)
        out, err, rc = cmd_vision(args.pdf, args.prompt, args.api_key,
                                    args.pages, args.dpi)
        if err:
            print("STDERR:", err, file=sys.stderr)
        print(out)
        sys.exit(rc)


if __name__ == "__main__":
    main()


path: scripts/extract_pdf_text.py
content: #!/usr/bin/env python3
"""Extract readable text from a PDF, optionally preserving page boundaries.

Requirements:
  python -m pip install pymupdf

Usage:
  python tools/pdf/extract_pdf_text.py input.pdf
  python tools/pdf/extract_pdf_text.py input.pdf --output out.txt
  python tools/pdf/extract_pdf_text.py input.pdf --pages 1-3,5 --page-breaks
"""

from __future__ import annotations

import argparse
from pathlib import Path
import sys

import fitz  # PyMuPDF


def parse_pages(spec: str | None, page_count: int) -> list[int]:
    if not spec:
        return list(range(page_count))

    selected: set[int] = set()
    for chunk in spec.split(","):
        chunk = chunk.strip()
        if not chunk:
            continue
        if "-" in chunk:
            start_s, end_s = chunk.split("-", 1)
            start = int(start_s)
            end = int(end_s)
            if start > end:
                start, end = end, start
            for p in range(start, end + 1):
                if 1 <= p <= page_count:
                    selected.add(p - 1)
        else:
            p = int(chunk)
            if 1 <= p <= page_count:
                selected.add(p - 1)
    return sorted(selected)


def extract_text(pdf_path: Path, pages: str | None = None, page_breaks: bool = False) -> str:
    if not pdf_path.exists():
        raise FileNotFoundError(pdf_path)

    doc = fitz.open(str(pdf_path))
    try:
        page_indexes = parse_pages(pages, doc.page_count)
        chunks: list[str] = []
        for idx in page_indexes:
            page = doc.load_page(idx)
            text = page.get_text("text").strip()
            if page_breaks:
                chunks.append(f"\n===== PAGE {idx + 1} =====\n")
            chunks.append(text)
        return "\n\n".join(c for c in chunks if c != "")
    finally:
        doc.close()


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("pdf", type=Path, help="Input PDF path")
    ap.add_argument("--output", "-o", type=Path, help="Optional output text file path")
    ap.add_argument("--pages", help="Page selection, e.g. 1-3,5")
    ap.add_argument("--page-breaks", action="store_true", help="Insert visible page separators")
    args = ap.parse_args()

    text = extract_text(args.pdf, pages=args.pages, page_breaks=args.page_breaks)

    if args.output:
        args.output.parent.mkdir(parents=True, exist_ok=True)
        args.output.write_text(text, encoding="utf-8")
        print(f"saved {args.output}")
    else:
        sys.stdout.write(text)

    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/minimax_coding_plan_tool.js
content: #!/usr/bin/env node

/**
 * MiniMax Coding Plan Tool - Network Search & Image Understanding
 *
 * Provides two tools:
 * - minimax_web_search: Web search using MiniMax Coding Plan API
 * - minimax_understand_image: Image understanding using MiniMax VLM API
 *
 * Uses api.minimax.io (MINIMAX_API_KEY = sk-cp-* format)
 */

const https = require('https');
const fs = require('fs');
const path = require('path');
const { URL } = require('url');

// Get API key and host from environment
const [REDACTED_SECRET];
// Use minimax.io (Coding Plan key format sk-cp-*) instead of api.minimax.chat
const API_HOST = 'api.minimax.io';

if (!API_KEY) {
  console.error('⚠️ Note: You need a Coding Plan API Key, not a regular MiniMax API key');
  process.exit(1);
}

/**
 * Make request to MiniMax API
 */
function makeRequest(endpoint, data) {
  return new Promise((resolve, reject) => {
    const url = new URL(endpoint, `https://${API_HOST}`);

    const options = {
      hostname: API_HOST,
      path: url.pathname,
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${API_KEY}`
      }
    };

    const req = https.request(options, (res) => {
      let body = '';
      res.on('data', chunk => body += chunk);
      res.on('end', () => {
        try {
          const json = JSON.parse(body);
          resolve(json);
        } catch (e) {
          reject(new Error(`Failed to parse response: ${body}`));
        }
      });
    });

    req.on('error', reject);
    req.write(JSON.stringify(data));
    req.end();
  });
}

/**
 * Process image URL - convert local file to base64 if needed
 */
function processImageUrl(imageSource) {
  // If it's an HTTP URL, pass through
  if (imageSource.startsWith('http://') || imageSource.startsWith('https://')) {
    return imageSource;
  }

  // It's a local file - convert to base64
  // Strip @ prefix if present
  let filePath = imageSource.startsWith('@') ? imageSource.slice(1) : imageSource;

  if (!fs.existsSync(filePath)) {
    throw new Error(`File not found: ${filePath}`);
  }

  const ext = path.extname(filePath).toLowerCase();
  const mimeTypes = {
    '.jpg': 'image/jpeg',
    '.jpeg': 'image/jpeg',
    '.png': 'image/png',
    '.gif': 'image/gif',
    '.webp': 'image/webp'
  };
  const mimeType = mimeTypes[ext];
  if (!mimeType) {
    throw new Error(`Unsupported image type: ${ext || '(no extension)'}. Supported types: .jpg, .jpeg, .png, .gif, .webp`);
  }
  const fileContent = fs.readFileSync(filePath);
  const base64 = fileContent.toString('base64');

  return `data:${mimeType};base64,${base64}`;
}

/**
 * Web Search Tool using Coding Plan API
 *
 * Uses MiniMax's coding plan search endpoint for real-time web search
 */
async function webSearch(query) {
  if (!query || typeof query !== 'string') {
    return { error: 'Missing required parameter: query' };
  }

  try {
    // Use MiniMax Coding Plan search API
    const response = await makeRequest('/v1/coding_plan/search', {
      q: query
    });

    // Parse response
    if (response.organic) {
      return {
        success: true,
        query: query,
        results: response.organic.map(item => ({
          title: item.title || '',
          link: item.link || item.url || '',
          snippet: item.snippet || item.content || '',
          date: item.date || ''
        })),
        related_searches: response.related_searches || []
      };
    }

    return { success: true, response };
  } catch (error) {
    return { error: error.message };
  }
}

/**
 * Understand Image Tool using Coding Plan VLM API
 *
 * Uses MiniMax's vision model to analyze images
 */
async function understandImage(imageSource, prompt) {
  if (!prompt || typeof prompt !== 'string') {
    return { error: 'Missing required parameter: prompt' };
  }

  if (!imageSource || typeof imageSource !== 'string') {
    return { error: 'Missing required parameter: image_source' };
  }

  try {
    // Process image: convert HTTP URL or local file to base64
    const processedImageUrl = processImageUrl(imageSource);

    // Use MiniMax Coding Plan VLM API
    const response = await makeRequest('/v1/coding_plan/vlm', {
      prompt: prompt,
      image_url: processedImageUrl
    });

    // Extract content from response
    const content = response.content;

    if (!content) {
      return { error: 'No content returned from VLM API' };
    }

    return {
      success: true,
      prompt: prompt,
      image_source: imageSource,
      analysis: content
    };
  } catch (error) {
    return { error: error.message };
  }
}

// CLI interface
const args = process.argv.slice(2);
const command = args[0];

async function main() {
  if (command === 'web_search') {
    const query = args[1];
    const result = await webSearch(query);
    console.log(JSON.stringify(result, null, 2));
  } else if (command === 'understand_image') {
    const imageSource = args[1];
    const prompt = args.slice(2).join(' ') || 'Describe this image';
    const result = await understandImage(imageSource, prompt);
    console.log(JSON.stringify(result, null, 2));
  } else if (command === 'tools') {
    // Return available tools in OpenClaw skill format
    console.log(JSON.stringify({
      tools: [
        {
          name: 'minimax_web_search',
          description: 'Web search using MiniMax Coding Plan API - search real-time or external information',
          inputSchema: {
            type: 'object',
            properties: {
              query: {
                type: 'string',
                description: 'Search query. 3-5 keywords work best'
              }
            },
            required: ['query']
          }
        },
        {
          name: 'minimax_understand_image',
          description: 'Image understanding using MiniMax Coding Plan VLM API',
          inputSchema: {
            type: 'object',
            properties: {
              image_source: {
                type: 'string',
                description: 'Image source - supports HTTP/HTTPS URL or local file path (strip @ prefix)'
              },
              prompt: {
                type: 'string',
                description: 'Question or analysis request for the image'
              }
            },
            required: ['image_source', 'prompt']
          }
        }
      ]
    }, null, 2));
  } else {
    console.error('Usage:');
    console.error('  minimax-coding-plan-mcp web_search <query>');
    console.error('  minimax-coding-plan-mcp understand_image <image_source> <prompt>');
    console.error('  minimax-coding-plan-mcp tools');
    process.exit(1);
  }
}

main().catch(err => {
  console.error(err);
  process.exit(1);
});

path: scripts/pdf_to_images.py
content: #!/usr/bin/env python3
"""pdf_to_images.py

Convert a PDF into per-page PNG images.

Requirements:
  python -m pip install pymupdf

Usage:
  python pdf_to_images.py input.pdf out_dir --dpi 200
"""

from __future__ import annotations

import argparse
from pathlib import Path

import fitz  # PyMuPDF


def convert(pdf_path: Path, out_dir: Path, dpi: int = 200, fmt: str = "png", page_range: str | None = None) -> list[int]:
    """Convert PDF pages to images. Returns list of exported page numbers (1-indexed)."""
    if not pdf_path.exists():
        raise FileNotFoundError(pdf_path)

    fmt = fmt.lower().lstrip(".")
    if fmt not in {"png", "jpg", "jpeg"}:
        raise ValueError(f"Unsupported --fmt: {fmt}. Use png/jpg/jpeg.")

    out_dir.mkdir(parents=True, exist_ok=True)

    doc = fitz.open(str(pdf_path))
    try:
        zoom = dpi / 72.0
        mat = fitz.Matrix(zoom, zoom)

        # Parse page range (e.g. "1-3,5,7-9")
        selected: set[int] | None = None
        if page_range:
            selected = set()
            for part in page_range.split(","):
                part = part.strip()
                if "-" in part:
                    start, end = part.split("-", 1)
                    selected.update(range(int(start), int(end) + 1))
                else:
                    selected.add(int(part))

        exported = []
        for i in range(doc.page_count):
            page_num = i + 1  # 1-indexed
            if selected is not None and page_num not in selected:
                continue
            page = doc.load_page(i)
            pix = page.get_pixmap(matrix=mat)
            ext = "jpg" if fmt == "jpeg" else fmt
            out_path = out_dir / f"page_{page_num:02d}.{ext}"
            pix.save(str(out_path))
            print(f"saved {out_path}")
            exported.append(page_num)

        print(f"done: exported {len(exported)} pages -> {out_dir}")
        return exported
    finally:
        doc.close()


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("pdf", type=Path, help="Input PDF path")
    ap.add_argument("out_dir", type=Path, help="Output directory")
    ap.add_argument("--dpi", type=int, default=200, help="Render DPI (default: 200)")
    ap.add_argument("--fmt", default="png", choices=["png", "jpg", "jpeg"], help="Output image format (default: png)")
    ap.add_argument("--pages", help="Page range, e.g. 1-3,5 (default: all pages)")
    args = ap.parse_args()

    convert(args.pdf, args.out_dir, dpi=args.dpi, fmt=args.fmt, page_range=args.pages)


if __name__ == "__main__":
    main()


if __name__ == "__main__":
    main()


path: scripts/search_pdf.py
content: #!/usr/bin/env python3
"""Search text inside a PDF and print matching snippets.

Requirements:
  python -m pip install pymupdf

Usage:
  python tools/pdf/search_pdf.py input.pdf "transformer"
  python tools/pdf/search_pdf.py input.pdf "error code" --ignore-case --context 80
  python tools/pdf/search_pdf.py input.pdf "foo|bar" --regex
"""

from __future__ import annotations

import argparse
from pathlib import Path
import re

import fitz  # PyMuPDF


def make_pattern(query: str, regex: bool, ignore_case: bool) -> re.Pattern[str]:
    flags = re.IGNORECASE if ignore_case else 0
    source = query if regex else re.escape(query)
    return re.compile(source, flags)


def snippet(text: str, start: int, end: int, context: int) -> str:
    left = max(0, start - context)
    right = min(len(text), end + context)
    s = text[left:right].replace("\n", " ").strip()
    return re.sub(r"\s+", " ", s)


def search_pdf(pdf_path: Path, query: str, regex: bool = False, ignore_case: bool = False, context: int = 80) -> int:
    if not pdf_path.exists():
        raise FileNotFoundError(pdf_path)

    pattern = make_pattern(query, regex=regex, ignore_case=ignore_case)
    matches = 0

    doc = fitz.open(str(pdf_path))
    try:
        for i in range(doc.page_count):
            page = doc.load_page(i)
            text = page.get_text("text")
            if not text.strip():
                continue
            for m in pattern.finditer(text):
                matches += 1
                print(f"page {i + 1}: {snippet(text, m.start(), m.end(), context)}")
    finally:
        doc.close()

    print(f"total_matches={matches}")
    return 0 if matches else 1


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("pdf", type=Path, help="Input PDF path")
    ap.add_argument("query", help="Search string or regex")
    ap.add_argument("--regex", action="store_true", help="Treat query as regex")
    ap.add_argument("--ignore-case", action="store_true", help="Case-insensitive search")
    ap.add_argument("--context", type=int, default=80, help="Snippet chars around each match (default: 80)")
    args = ap.parse_args()

    return search_pdf(
        args.pdf,
        args.query,
        regex=args.regex,
        ignore_case=args.ignore_case,
        context=args.context,
    )


if __name__ == "__main__":
    raise SystemExit(main())


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