Skill: jaredforreal/glmocr (version 1.0.4)

--- name: glmocr description: Extract text from images using GLM-OCR API. Supports images and PDFs with high accuracy OCR, table recognition, formula extraction, and handwriting recognition. Use this skill whenever the user wants to extract text from images, perform OCR on pictures, scan documents, convert images to text, or process any image files to get their textual content. metadata: openclaw: requires: env: - ZHIPU_API_KEY - GLM_OCR_TIMEOUT primaryEnv: ZHIPU_API_KEY emoji: "📄" homepage: https://github.com/zai-org/GLM-OCR/tree/main/skills/glmocr --- # GLM-OCR Text Extraction Skill Extract text from images and PDFs using the GLM-OCR layout parsing API. ## When to Use - Extract text from images (PNG, JPG, PDF) - Convert screenshots to text - Process scanned documents - OCR photos containing text (including handwritten text) - Recognize tables and formulas in documents - User mentions "OCR", "文字识别", "文档解析" ## Key Features - **Table recognition**: Detects and converts tables to Markdown format - **Formula extraction**: LaTeX format output - **Handwriting support**: Strong recognition for handwritten text - **Local file & URL**: Supports both local files and remote URLs ## Resource Links | Resource | Link | |----------|------| | **Get API Key** | [https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **GitHub** | [https://github.com/zai-org/GLM-OCR](https://github.com/zai-org/GLM-OCR) | ## Prerequisites - ZHIPU_API_KEY configured (see Setup below) ## Security Notes - No runtime package installation is performed by the scripts. - OCR requests use the fixed official GLM endpoint and do not accept custom API URLs. - Only `ZHIPU_API_KEY` (and optional timeout) is read from environment variables. **⛔ MANDATORY RESTRICTIONS - DO NOT VIOLATE ⛔** 1. **ONLY use GLM-OCR API** - Execute the script `python scripts/glm_ocr_cli.py` 2. **NEVER parse documents directly** - Do NOT try to extract text yourself 3. **NEVER offer alternatives** - Do NOT suggest "I can try to analyze it" or similar 4. **IF API fails** - Display the error message and STOP immediately 5. **NO fallback methods** - Do NOT attempt text extraction any other way ## Setup 1. Get your API key: [https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys](https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys) 2. Configure: ```bash python scripts/config_setup.py setup --api-key YOUR_KEY ``` ## How to Use ### Extract from URL ```bash python scripts/glm_ocr_cli.py --file-url "URL provided by user" ``` ### Extract from Local File ```bash python scripts/glm_ocr_cli.py --file /path/to/image.jpg ``` ### Save result to file (recommended) ```bash python scripts/glm_ocr_cli.py --file-url "URL" --output result.json ``` ## CLI Reference ``` python {baseDir}/scripts/glm_ocr_cli.py (--file-url URL | --file PATH) [--output FILE] [--pretty] ``` | Parameter | Required | Description | |-----------|----------|-------------| | `--file-url` | One of | URL to image/PDF | | `--file` | One of | Local file path to image/PDF | | `--output`, `-o` | No | Save result JSON to file | | `--pretty` | No | Pretty-print JSON output | ## Response Format ```json { "ok": true, "text": "# Extracted text in Markdown...", "layout_details": [[...]], "result": { "raw_api_response": "..." }, "error": null, "source": "/path/to/file.jpg", "source_type": "file" } ``` Key fields: - `ok` — whether extraction succeeded - `text` — extracted text in Markdown (use this for display) - `layout_details` — layout analysis details - `result` — raw API response - `error` — error details on failure ## Error Handling **API key not configured:** ``` Error: ZHIPU_API_KEY not configured. Get your API key at: https://www.bigmodel.cn/usercenter/proj-mgmt/apikeys ``` → Show exact error to user, guide them to configure **Authentication failed (401/403):** API key invalid/expired → reconfigure **Rate limit (429):** Quota exhausted → inform user to wait **File not found:** Local file missing → check path ## Reference - `references/output_schema.md` — detailed output format specification

path: scripts/requirements.txt
content: requests>=2.31.0


path: scripts/glm_ocr_cli.py
content: #!/usr/bin/env python3
"""
GLM-OCR CLI

Command-line interface for GLM-OCR vision model.

Usage:
    python scripts/glm_ocr_cli.py --file-url "URL"
    python scripts/glm_ocr_cli.py --file-url "URL" --pretty
"""

import argparse
import base64
import io
import json
import logging
import mimetypes
import os
import sys
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

try:
    import requests
except ImportError:
    print(
        "Error: Missing dependency 'requests'. Install with: pip install -r scripts/requirements.txt",
        file=sys.stderr,
    )
    sys.exit(2)

# Fix Windows console encoding
if sys.platform == "win32":
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")

logger = logging.getLogger(__name__)

# =============================================================================
# Constants
# =============================================================================

DEFAULT_TIMEOUT = 60  # seconds
API_GUIDE_URL = "https://open.bigmodel.cn/usercenter/apikeys"
DEFAULT_API_URL = "https://open.bigmodel.cn/api/paas/v4/layout_parsing"

# =============================================================================
# Environment
# =============================================================================

_env_loaded = False


def _load_env():
    """Load .env file if available."""
    global _env_loaded
    if _env_loaded:
        return

    env_file = Path(__file__).parent.parent / ".env"
    if env_file.exists():
        try:
            with open(env_file, "r", encoding="utf-8") as f:
                for line in f:
                    line = line.strip()
                    if not line or line.startswith("#") or "=" not in line:
                        continue
                    key, value = line.split("=", 1)
                    key = key.strip()
                    value = value.strip()
                    if key and key not in os.environ:
                        os.environ[key] = value
        except OSError as e:
            logger.debug(f"Failed to load .env: {e}")

    _env_loaded = True


def _get_env(key: str, *fallback_keys: str) -> str:
    """Get environment variable with fallback keys."""
    _load_env()
    value = os.getenv(key, "").strip()
    if value:
        return value
    for fallback in fallback_keys:
        value = os.getenv(fallback, "").strip()
        if value:
            logger.debug(f"Using fallback env var: {fallback}")
            return value
    return ""


def get_config() -> tuple[str, str]:
    """
    Get API URL and key from environment.

    Returns:
        tuple of (api_url, api_key)

    Raises:
        ValueError: If not configured
    """
    [REDACTED_SECRET]"ZHIPU_API_KEY")

    if not api_key:
        raise ValueError(
            f"ZHIPU_API_KEY not configured. Get your API key at: {API_GUIDE_URL}"
        )

    # Security: use fixed official endpoint to avoid key exfiltration via custom URL.
    return DEFAULT_API_URL, api_key


# =============================================================================
# File Utilities
# =============================================================================


def _is_url(path: str) -> bool:
    """Check if the given path is a URL."""
    try:
        result = urlparse(path)
        return all([result.scheme, result.netloc]) and result.scheme in (
            "http",
            "https",
        )
    except Exception:
        return False


def _encode_file(file_path: str) -> tuple[str, str]:
    """
    Encode a local file to base64 data URI.

    Returns:
        tuple of (data_uri, error_message)
        On success, error_message is empty.
    """
    path = Path(file_path)
    if not path.exists():
        return "", f"File not found: {file_path}"
    if not path.is_file():
        return "", f"Not a regular file: {file_path}"

    # Guess MIME type, fallback to application/octet-stream
    mime, _ = mimetypes.guess_type(str(path))
    if not mime:
        mime = "application/octet-stream"

    try:
        data = path.read_bytes()
        b64 = base64.b64encode(data).decode("ascii")
        return f"data:{mime};base64,{b64}", ""
    except Exception as e:
        return "", f"Failed to read file: {e}"


# =============================================================================
# API Request
# =============================================================================


def _make_api_request(api_url: str, api_key: str, payload: dict) -> dict:
    """
    Make GLM-OCR API request.

    Args:
        api_url: API endpoint URL
        api_key: API key
        payload: Request payload

    Returns:
        API response dict

    Raises:
        RuntimeError: On API errors
    """
    headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}

    timeout = float(os.getenv("GLM_OCR_TIMEOUT", str(DEFAULT_TIMEOUT)))

    try:
        resp = requests.post(api_url, json=payload, headers=headers, timeout=timeout)
    except requests.Timeout:
        raise RuntimeError(f"API request timed out after {timeout}s")
    except requests.RequestException as e:
        raise RuntimeError(f"API request failed: {e}")

    # Handle HTTP errors
    if resp.status_code != 200:
        error_detail = ""
        try:
            error_body = resp.json()
            if isinstance(error_body, dict):
                error_detail = str(
                    error_body.get("error", {}).get("message", "")
                ).strip()
        except Exception:
            pass

        if not error_detail:
            error_detail = (resp.text[:200] or "No response body").strip()

        if resp.status_code == 401 or resp.status_code == 403:
            raise RuntimeError(
                f"Authentication failed ({resp.status_code}): {error_detail}"
            )
        elif resp.status_code == 429:
            raise RuntimeError(f"API rate limit exceeded (429): {error_detail}")
        elif resp.status_code >= 500:
            raise RuntimeError(
                f"API service error ({resp.status_code}): {error_detail}"
            )
        else:
            raise RuntimeError(f"API error ({resp.status_code}): {error_detail}")

    # Parse response
    try:
        result = resp.json()
    except Exception:
        raise RuntimeError(f"Invalid JSON response: {resp.text[:200]}")

    # Check API-level error
    if "error" in result:
        raise RuntimeError(f"API error: {result['error']}")

    return result


# =============================================================================
# Main API
# =============================================================================


def extract_text(
    image_source: str, model: str = "glm-ocr", **options
) -> dict[str, Any]:
    """
    Extract text from image or PDF using GLM-OCR layout parsing API.

    Args:
        image_source: URL or local file path to the image/PDF
        model: Model to use (glm-ocr, etc.)
        **options: Additional API options:
            - return_crop_images: bool - Whether to return cropped images (default: False)
            - need_layout_visualization: bool - Whether to need layout visualization
            - start_page_id: int - Start page ID for PDF
            - end_page_id: int - End page ID for PDF

    Returns:
        {
            "ok": True,
            "text": "OCR extracted text (md_results)...",
            "layout_details": [...],  # Layout analysis results
            "result": { raw API result },
            "error": None,
            "source": "original source",
            "source_type": "url" or "file"
        }
        or on error:
        {
            "ok": False,
            "text": "",
            "layout_details": None,
            "result": None,
            "error": {"code": "...", "message": "..."},
            "source": "original source",
            "source_type": "url" or "file"
        }
    """
    # Get config
    try:
        api_url, [REDACTED_SECRET])
    except ValueError as e:
        return _error("CONFIG_ERROR", str(e), image_source)

    # Determine if URL or local file
    is_url = _is_url(image_source)
    if is_url:
        file_payload = image_source
    else:
        file_payload, encode_err = _encode_file(image_source)
        if encode_err:
            return _error("INPUT_ERROR", encode_err, image_source)

    # Build request payload
    payload = {
        "model": model,
        "file": file_payload,
        "return_crop_images": options.get("return_crop_images", False),
        "need_layout_visualization": options.get("need_layout_visualization", False),
        "start_page_id": options.get("start_page_id", 1),
        "end_page_id": options.get("end_page_id", 2),
    }

    # Call API
    try:
        result = _make_api_request(api_url, api_key, payload)
    except RuntimeError as e:
        return _error("API_ERROR", str(e), image_source)

    # Extract text from response
    try:
        extracted_text = _extract_text(result)
    except ValueError as e:
        return _error("API_ERROR", str(e), image_source)

    # Extract layout details
    layout_details = result.get("layout_details")

    return {
        "ok": True,
        "text": extracted_text,
        "layout_details": layout_details,
        "result": result,
        "error": None,
        "source": image_source,
        "source_type": "url" if is_url else "file",
    }


def _extract_text(result) -> str:
    """Extract text from GLM-OCR layout parsing API response."""
    if not isinstance(result, dict):
        raise ValueError("Invalid response schema: response must be an object")

    # Format 1: Layout parsing API response (md_results field)
    if "md_results" in result:
        md_results = result["md_results"]
        if isinstance(md_results, str):
            return md_results

    # Format 2: Nested data field
    if "data" in result and isinstance(result["data"], dict):
        data = result["data"]
        if "md_results" in data and isinstance(data["md_results"], str):
            return data["md_results"]

    raise ValueError(
        f"Invalid response schema: unable to extract text from {list(result.keys())}"
    )


def _error(code: str, message: str, source: str) -> dict:
    """Create error response."""
    return {
        "ok": False,
        "text": "",
        "layout_details": None,
        "result": None,
        "error": {"code": code, "message": message},
        "source": source,
        "source_type": "url" if _is_url(source) else "file",
    }


# =============================================================================
# CLI Interface
# =============================================================================


def main():
    parser = argparse.ArgumentParser(
        description="GLM-OCR - Extract text from images and PDFs",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  # Extract from URL
  python scripts/glm_ocr_cli.py --file-url "https://example.com/image.jpg"

  # Extract from local file
  python scripts/glm_ocr_cli.py --file /path/to/image.jpg

  # Save result to file
  python scripts/glm_ocr_cli.py --file photo.png --output result.json --pretty

Configuration:
  Run: python scripts/config_setup.py setup
    Or set in .env: ZHIPU_API_KEY
        """,
    )

    # Input (one of --file-url or --file required)
    input_group = parser.add_mutually_exclusive_group(required=True)
    input_group.add_argument("--file-url", help="URL to image/PDF")
    input_group.add_argument("--file", help="Path to local image/PDF file")

    # Output options
    parser.add_argument(
        "--pretty", action="store_true", help="Pretty-print JSON output"
    )
    parser.add_argument(
        "--output", "-o", metavar="FILE", help="Save result to JSON file"
    )

    args = parser.parse_args()

    # Determine source
    source = args.file_url or args.file

    # Extract text
    result = extract_text(image_source=source)

    # Format output
    indent = 2 if args.pretty else None
    json_output = json.dumps(result, indent=indent, ensure_ascii=False)

    # Save to file or print
    if args.output:
        try:
            output_path = Path(args.output).resolve()
            output_path.parent.mkdir(parents=True, exist_ok=True)
            output_path.write_text(json_output, encoding="utf-8")
            print(f"Result saved to: {output_path}", file=sys.stderr)
        except (PermissionError, OSError) as e:
            print(f"Error: Cannot write to {args.output}: {e}", file=sys.stderr)
            sys.exit(5)
    else:
        print(json_output)
        if result["ok"]:
            print("Tip: Use --output result.json to save the result", file=sys.stderr)

    # Exit code based on result
    sys.exit(0 if result["ok"] else 1)


if __name__ == "__main__":
    main()


path: scripts/config_setup.py
content: #!/usr/bin/env python3
"""
Environment Variable Configuration Setup
Helps users set up their .env file for GLM-OCR skill
"""

import sys
from pathlib import Path
from typing import Optional


def _get_skill_root() -> Path:
    """Get the skill root directory"""
    # Get the directory where this script is located
    script_dir = Path(__file__).resolve().parent
    return script_dir.parent


def _get_env_path(skill_root: Optional[Path] = None) -> Path:
    """Get the path to the .env file"""
    if skill_root is None:
        skill_root = _get_skill_root()
    return skill_root / ".env"


def _env_exists(skill_root: Optional[Path] = None) -> bool:
    """Check if .env file exists"""
    return _get_env_path(skill_root).exists()


def _read_env_file(skill_root: Optional[Path] = None) -> dict:
    """
    Read existing .env file and return as dictionary

    Returns:
        Dictionary of environment variables
    """
    env_path = _get_env_path(skill_root)
    env_vars = {}

    if not env_path.exists():
        return env_vars

    with open(env_path, "r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            # Skip comments and empty lines
            if not line or line.startswith("#"):
                continue
            # Parse KEY=VALUE
            if "=" in line:
                key, value = line.split("=", 1)
                env_vars[key.strip()] = value.strip()

    return env_vars


def _write_env_file(env_vars: dict, skill_root: Optional[Path] = None) -> None:
    """
    Write environment variables to .env file

    Args:
        env_vars: Dictionary of environment variables to write
        skill_root: Skill root directory path
    """
    env_path = _get_env_path(skill_root)

    with open(env_path, "w", encoding="utf-8") as f:
        # Write header
        f.write("# GLM-OCR API Configuration\n")
        f.write("# Auto-generated by config_setup.py\n")
        f.write("# DO NOT commit this file to version control\n\n")

        # Write environment variables
        for key, value in env_vars.items():
            f.write(f"{key}={value}\n")


def _setup_interactive() -> dict:
    """
    Interactive setup for environment variables

    Returns:
        Dictionary of configured environment variables
    """
    env_vars = {}
    existing = _read_env_file()

    print("=" * 60)
    print("GLM-OCR Skill - Environment Configuration")
    print("=" * 60)
    print()

    # API Key
    current_key = existing.get("ZHIPU_API_KEY", "")
    if current_key:
        print(f"Current API Key: {current_key[:8]}...{current_key[-4:]}")

    [REDACTED_SECRET]
        f"Enter GLM-OCR API Key{' (press Enter to keep current)' if current_key else ''}: "
    ).strip()

    if not api_key and current_key:
        [REDACTED_SECRET]
    elif not api_key and not current_key:
        print("Error: API Key is required!")
        sys.exit(1)

    env_vars["ZHIPU_API_KEY"] = api_key

    print()
    print("=" * 60)
    print("Configuration Summary:")
    print("=" * 60)
    print(
        f"API Key: {env_vars['ZHIPU_API_KEY'][:8]}...{env_vars['ZHIPU_API_KEY'][-4:]}"
    )
    print()

    return env_vars


def _setup_from_args(api_key: str) -> dict:
    """
    Setup environment variables from command line arguments

    Args:
        [REDACTED_SECRET] API key
        api_url: Optional API URL

    Returns:
        Dictionary of environment variables
    """
    env_vars = {"ZHIPU_API_KEY": api_key}
    return env_vars


def _validate_config(env_vars: dict) -> bool:
    """
    Validate the configuration

    Args:
        env_vars: Dictionary of environment variables

    Returns:
        True if valid, False otherwise
    """
    if "ZHIPU_API_KEY" not in env_vars or not env_vars["ZHIPU_API_KEY"]:
        print("Error: ZHIPU_API_KEY is required")
        return False

    [REDACTED_SECRET]"ZHIPU_API_KEY"]
    if len(api_key) < 10:
        print("Warning: API key seems too short")

    return True


def _show_config() -> None:
    """Display current configuration"""
    env_path = _get_env_path()

    if not env_path.exists():
        print("No .env file found. Run 'setup' to create one.")
        return

    env_vars = _read_env_file()

    if not env_vars:
        print(".env file exists but is empty.")
        return

    print("Current Configuration:")
    print("=" * 60)

    for key, value in env_vars.items():
        if "KEY" in key or "SECRET" in key or "TOKEN" in key:
            # Show only partial value for security
            if len(value) > 12:
                display = f"{value[:8]}...{value[-4:]}"
            else:
                display = "***"
        else:
            display = value
        print(f"{key} = {display}")

    print("=" * 60)


def main():
    """Main CLI interface"""
    import argparse

    parser = argparse.ArgumentParser(
        description="Configure environment variables for GLM-OCR skill",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  # Interactive setup
  python config_setup.py setup

  # Setup with arguments
  python config_setup.py setup --api-key YOUR_KEY

  # Show current configuration
  python config_setup.py show
        """,
    )

    subparsers = parser.add_subparsers(dest="command", help="Available commands")

    # Setup command
    setup_parser = subparsers.add_parser(
        "setup", help="Set up environment configuration"
    )
    setup_parser.add_argument("--api-key", "-k", help="GLM-OCR API key")
    setup_parser.add_argument(
        "--non-interactive",
        "-n",
        action="store_true",
        help="Non-interactive mode (requires --api-key)",
    )

    # Show command
    subparsers.add_parser("show", help="Show current configuration")

    # Validate command
    subparsers.add_parser("validate", help="Validate current configuration")

    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        sys.exit(1)

    skill_root = _get_skill_root()

    if args.command == "setup":
        # Get configuration
        if args.[REDACTED_SECRET] = _setup_from_args(args.api_key)
        elif args.non_interactive:
            print("Error: --api-key is required in non-interactive mode")
            sys.exit(1)
        else:
            env_vars = _setup_interactive()

        # Validate
        if not _validate_config(env_vars):
            sys.exit(1)

        # Write to .env
        _write_env_file(env_vars, skill_root)

        print()
        print(f"Configuration saved to: {_get_env_path(skill_root)}")
        print()

        # Remind about .gitignore
        gitignore_path = skill_root / ".gitignore"
        if gitignore_path.exists():
            with open(gitignore_path, "r") as f:
                gitignore_content = f.read()
            if ".env" not in gitignore_content:
                print("Tip: Add '.env' to your .gitignore to keep API keys secure")
        else:
            print("Tip: Create a .gitignore file with '.env' to keep API keys secure")

    elif args.command == "show":
        _show_config()

    elif args.command == "validate":
        env_vars = _read_env_file(skill_root)

        if not env_vars:
            print("No configuration found. Run 'setup' first.")
            sys.exit(1)

        print("Validating configuration...")
        if _validate_config(env_vars):
            print("Configuration is valid!")
        else:
            print("Configuration is invalid!")
            sys.exit(1)


if __name__ == "__main__":
    main()


path: references/output_schema.md
content: # GLM-Doc-Parser Output Schema

This document describes the output format returned by the GLM-OCR API wrapper.

## API Response Envelope

The `extract_text()` function returns a standardized response envelope:

```json
{
  "ok": true,
  "text": "Extracted text content in Markdown format...",
  "layout_details": [...],
  "result": {
    "raw": "API response object"
  },
  "error": null,
  "source": "https://example.com/image.jpg",
  "source_type": "url"
}
```

## Fields

### `ok` (boolean)
- Indicates whether the OCR operation was successful
- `true`: Extraction completed successfully
- `false`: An error occurred

### `text` (string)
- The extracted text content from the image in Markdown format
- Only present when `ok` is `true`
- Contains the full text extracted from the image/PDF
- Tables are formatted as Markdown tables
- Formulas are formatted as LaTeX

### `layout_details` (array | null)
- Layout analysis results from the GLM-OCR API
- Only present when `ok` is `true`
- Contains detailed information about detected elements:
  - Tables with cell positions
  - Formulas with positions
  - Text blocks with layout information
- Only present when `need_layout_visualization` is enabled in the API request

### `result` (object | null)
- The raw API response from GLM-OCR
- Only present when `ok` is `true`
- Contains the complete API response for advanced use cases
- Includes fields like `md_results`, `layout_details`, `usage`, etc.

### `error` (object | null)
- Error details when operation fails
- Only present when `ok` is `false`
- Structure:
  ```json
  {
    "code": "ERROR_CODE",
    "message": "Human readable error message"
  }
  ```

### `source` (string)
- The original input source (URL)
- Always present

### `source_type` (string)
- Type of the input source
- Value: `"url"` or `"file"`

## Error Codes

| Code | Description |
|------|-------------|
| `CONFIG_ERROR` | API key or URL not configured |
| `INPUT_ERROR` | Invalid input (invalid URL, unsupported format, etc.) |
| `API_ERROR` | API request failed (network error, authentication, rate limit, etc.) |

## Raw API Response

The `result` field contains the raw GLM-OCR API response from the layout parsing endpoint:

```json
{
  "md_results": "Extracted text content in Markdown format...",
  "layout_details": [
    {
      "type": "table",
      "bbox": [x1, y1, x2, y2],
      "cells": [...]
    },
    {
      "type": "formula",
      "bbox": [x1, y1, x2, y2],
      "text": "LaTeX formula..."
    }
  ],
  "usage": {
    "prompt_tokens": 1000,
    "completion_tokens": 500,
    "total_tokens": 1500
  }
}
```

The raw response structure follows the GLM-OCR Layout Parsing API format and may include:
- `md_results`: Markdown formatted text extraction results
- `layout_details`: Detailed layout analysis with bounding boxes and element classifications
- `usage`: Token usage statistics

## Usage Examples

### Basic Text Extraction

```python
from glm_ocr_cli import extract_text

result = extract_text("https://example.com/image.jpg")

if result["ok"]:
    print(result["text"])
else:
    print(f"Error: {result['error']['message']}")
```

### Accessing Raw API Response

```python
result = extract_text("https://example.com/image.jpg")

if result["ok"]:
    raw_response = result["result"]
    usage = raw_response.get("usage", {})
    print(f"Tokens used: {usage.get('total_tokens', 0)}")
```

### URL Processing

```python
result = extract_text("https://example.com/document.jpg")

if result["ok"]:
    print(f"Source type: {result['source_type']}")
    print(f"Text: {result['text']}")
```


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