Skill: saaak/docker-xunlei-downloader (version 1.0.0)

# Xunlei Docker Downloader Skill ## Description A skill that allows OpenClaw to interact with Docker-deployed Xunlei services for downloading magnet links and managing download tasks. ## Features - Detect and submit magnet links to Xunlei Docker service - Monitor download tasks (in-progress and completed) - View task status including progress and speed - Configure Xunlei service connection settings - **NEW**: Intelligent content filtering to identify and prioritize main content over advertisements ## Configuration The skill requires the following configuration: - `xunlei_host`: The host address of the Docker Xunlei service - `xunlei_port`: The port of the Docker Xunlei service - `xunlei_ssl`: Whether to use SSL (true/false, default: false) ## Commands - `xunlei status` - Show current download tasks - `xunlei submit <magnet_link>` - Submit a magnet link for download (with intelligent content filtering) - `xunlei submit <magnet_link> --name <task_name>` - Submit with custom task name - `xunlei config set <host> <port>` - Configure Xunlei service connection - `xunlei config show` - Show current configuration - `xunlei completed` - Show completed tasks - `xunlei version` - Show Xunlei service version ## Intelligent Content Filtering The skill now includes smart analysis of magnet links to: - Identify main content vs. advertisement files based on size and filename - Prioritize large files with content indicators (e.g., "hhd800.com@", "FHD", "1080p") - Filter out small files with ad indicators (e.g., "game", "demo", "trailer", "合集", "大全") - Automatically select the most relevant files for download ## Dependencies - Node.js - Chrome browser (for authentication methods) ## Implementation Notes Based on the xunlei-docker-ext project by saaak, this skill provides server-side functionality to interact with Xunlei Docker services without requiring a browser extension.

path: test.js
content: // Test script for Xunlei Docker Downloader skill
const XunleiDockerSkill = require('./index');

async function testSkill() {
  console.log('Testing Xunlei Docker Downloader skill...\n');
  
  const skill = new XunleiDockerSkill();
  
  // Test help command
  console.log('1. Testing help command:');
  console.log(skill.help());
  console.log('\n');
  
  // Test config show (should show current config or lack thereof)
  console.log('2. Testing config show:');
  try {
    const configResult = await skill.showConfig();
    console.log(configResult);
  } catch (error) {
    console.log('Error showing config:', error.message);
  }
  console.log('\n');
  
  // Test status command (should fail without proper config)
  console.log('3. Testing status command (expected to fail without config):');
  try {
    const statusResult = await skill.getStatus();
    console.log(statusResult);
  } catch (error) {
    console.log('Expected error for status (no config):', error.message);
  }
  console.log('\n');
  
  console.log('Test completed. The skill is properly structured and can be loaded by OpenClaw.');
}

testSkill().catch(console.error);

path: config.json
content: host: 192.168.1.40
port: 2345
ssl: no

path: index.js
content: const XunleiDockerClient = require('./xunlei_docker_client');
const fs = require('fs');
const path = require('path');

class XunleiDockerSkill {
  constructor() {
    this.client = null;
    this.configPath = path.join(__dirname, 'config.json');
    this.loadConfig();
  }

  /**
   * Load configuration from file
   */
  loadConfig() {
    try {
      if (fs.existsSync(this.configPath)) {
        const configData = fs.readFileSync(this.configPath, 'utf8');
        const config = JSON.parse(configData);
        
        this.client = new XunleiDockerClient();
        this.client.configure(config.host, config.port, config.ssl || false);
        console.log('Xunlei Docker skill configuration loaded');
      } else {
        this.client = new XunleiDockerClient();
        console.log('Created new Xunlei Docker client instance');
      }
    } catch (error) {
      console.error('Error loading Xunlei Docker skill configuration:', error);
      this.client = new XunleiDockerClient();
    }
  }

  /**
   * Save configuration to file
   */
  saveConfig(config) {
    try {
      fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2));
      console.log('Xunlei Docker skill configuration saved');
    } catch (error) {
      console.error('Error saving Xunlei Docker skill configuration:', error);
    }
  }

  /**
   * Execute a command based on the message
   */
  async execute(message) {
    const args = message.trim().split(/\s+/);
    const command = args[0].toLowerCase();

    switch (command) {
      case 'xunlei':
        return this.handleXunleiCommand(args.slice(1));
      default:
        return this.help();
    }
  }

  /**
   * Handle xunlei commands
   */
  async handleXunleiCommand(args) {
    if (args.length === 0) {
      return this.help();
    }

    const subCommand = args[0].toLowerCase();

    switch (subCommand) {
      case 'status':
        return this.getStatus();
      case 'submit':
        return this.submitTask(args.slice(1));
      case 'config':
        return this.handleConfigCommand(args.slice(1));
      case 'completed':
        return this.getCompletedTasks();
      case 'version':
        return this.getVersion();
      case 'help':
        return this.help();
      default:
        return `Unknown command: xunlei ${subCommand}. Use 'xunlei help' for available commands.`;
    }
  }

  /**
   * Show help information
   */
  help() {
    return `
Xunlei Docker Downloader Skill Help:

Commands:
  xunlei status                    - Show current download tasks
  xunlei submit <magnet_link>      - Submit a magnet link for download
  xunlei submit <mangle_link> --name <task_name>  - Submit with custom task name
  xunlei config set <host> <port>  - Configure Xunlei service connection
  xunlei config show               - Show current configuration
  xunlei completed                 - Show completed tasks
  xunlei version                   - Show Xunlei service version
  xunlei help                      - Show this help message
    `;
  }

  /**
   * Show current download status
   */
  async getStatus() {
    try {
      const tasks = await this.client.getUncompletedTasks();
      
      if (tasks.length === 0) {
        return 'No active download tasks.';
      }

      let response = `📥 Active Download Tasks (${tasks.length}):\n\n`;
      tasks.forEach((task, index) => {
        const progress = task.progress || 0;
        const speed = task.params?.speed ? Math.round(task.params.speed / 1024 / 1024 * 100) / 100 : 0; // Convert to MB/s
        const fileSize = this.formatFileSize(task.file_size);
        
        response += `${index + 1}. ${task.name}\n`;
        response += `   Progress: ${progress}% | Speed: ${speed} MB/s | Size: ${fileSize}\n`;
        response += `   Updated: ${new Date(task.updated_time).toLocaleString()}\n\n`;
      });

      return response;
    } catch (error) {
      return `Error getting status: ${error.message}`;
    }
  }

  /**
   * Format file size in human-readable form
   */
  formatFileSize(bytes) {
    if (bytes === undefined || bytes === null) return 'Unknown';
    
    const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
    if (bytes === 0) return '0 Bytes';
    const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
    return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
  }

  /**
   * Submit a download task
   */
  async submitTask(args) {
    if (args.length === 0) {
      return 'Please provide a magnet link. Usage: xunlei submit <magnet_link>';
    }

    // Extract magnet link and optional task name
    let magnetLink = '';
    let taskName = '';
    
    for (let i = 0; i < args.length; i++) {
      if (args[i] === '--name' && i + 1 < args.length) {
        taskName = args[i + 1];
        i++; // Skip the next argument as it's the task name
      } else if (args[i].startsWith('magnet:?')) {
        magnetLink = args[i];
      }
    }

    if (!magnetLink) {
      return 'No valid magnet link provided. Usage: xunlei submit <magnet_link>';
    }

    try {
      // Analyze the magnet link to identify main content vs ads
      console.log('Analyzing magnet link for content filtering...');
      const fileTree = await this.client.extractFileList(magnetLink);
      
      if (!fileTree.resources || fileTree.resources.length === 0) {
        return 'No files found in magnet link';
      }

      // Get all files from the tree structure
      const allResources = this.getAllFilesFromTree(fileTree.resources);
      
      // Classify files as main content or ads
      const classified = this.classifyFiles(allResources);
      
      console.log(`File analysis complete: ${classified.mainContent.length} main content, ${classified.ads.length} ads`);
      
      // Select only main content files for download
      let selectedFiles = classified.mainContent;
      
      // If no main content identified, use largest files as fallback
      if (selectedFiles.length === 0) {
        console.log('No main content files identified, using largest files as fallback');
        const sortedFiles = classified.all.sort((a, b) => 
          parseInt(b.file_size) - parseInt(a.file_size)
        );
        // Take up to 3 largest files
        selectedFiles = sortedFiles.slice(0, 3);
        console.log(`Selected ${selectedFiles.length} largest files as likely main content`);
      }

      // Prepare file indices for submission
      const selectedFileIndices = selectedFiles.map(file => ({
        index: file.file_index !== undefined ? file.file_index : 0,
        file_size: parseInt(file.file_size),
        file_name: file.name
      }));

      console.log(`Submitting ${selectedFileIndices.length} main content files for download`);
      
      // Create a descriptive task name if not provided
      if (!taskName) {
        taskName = `Filtered Download - ${selectedFiles.length} main content files`;
      }

      const result = await this.client.submitTask(magnetLink, taskName, selectedFileIndices);
      return result.message;
    } catch (error) {
      return `Error submitting task: ${error.message}`;
    }
  }

  /**
   * Get all files recursively from the tree structure
   */
  getAllFilesFromTree(resources, parentPath = '') {
    let allFiles = [];

    for (const resource of resources) {
      if (resource.is_dir && resource.dir && resource.dir.resources) {
        // This is a directory, recursively get files
        const childPath = parentPath ? `${parentPath}/${resource.name}` : resource.name;
        allFiles = allFiles.concat(this.getAllFilesFromTree(resource.dir.resources, childPath));
      } else {
        // This is a file
        allFiles.push({
          ...resource,
          fullPath: parentPath ? `${parentPath}/${resource.name}` : resource.name
        });
      }
    }

    return allFiles;
  }

  /**
   * Classify files as main content, ads, or unknown
   */
  classifyFiles(files) {
    const classified = {
      mainContent: [],
      ads: [],
      unknown: [],
      all: []
    };

    for (const file of files) {
      const classification = this.classifySingleFile(file);
      file.type = classification;
      classified.all.push(file);
      
      if (classification === 'main_content') {
        classified.mainContent.push(file);
      } else if (classification === 'ad') {
        classified.ads.push(file);
      } else {
        classified.unknown.push(file);
      }
    }

    return classified;
  }

  /**
   * Classify a single file as main content, ad, or unknown
   */
  classifySingleFile(file) {
    const fileName = file.name.toLowerCase();
    const fileSize = parseInt(file.file_size) || 0;

    // Check for ad indicators in filename
    const adIndicators = [
      'game', '游戏', '996gg', 'ad', 'advert', 'sample', 'preview', 'trailer', 
      'promo', '宣传', 'demo', '试玩', 'hdwallpaper', '壁纸', '合集', '大全', '龙珠H版', '三国志H版'
    ];

    // Check for main content indicators in filename
    const contentIndicators = [
      'hhd800', 'fhd', 'hd', '1080', '720', '@', '#', '.com@', '.net@', '.org@'
    ];

    // Flag if file name contains ad indicators
    const hasAdIndicator = adIndicators.some(indicator => 
      fileName.includes(indicator.toLowerCase())
    );

    // Flag if file name contains content indicators
    const hasContentIndicator = contentIndicators.some(indicator => 
      fileName.includes(indicator.toLowerCase())
    );

    // Small files (under 10MB) are likely ads or samples if they have ad indicators
    if (fileSize < 10 * 1024 * 1024 && hasAdIndicator) {
      return 'ad';
    }

    // Large files with content indicators are likely main content
    if (fileSize > 100 * 1024 * 1024 && hasContentIndicator) {
      return 'main_content';
    }

    // Files with clear ad indicators regardless of size
    if (hasAdIndicator) {
      return 'ad';
    }

    // Large files without ad indicators are likely main content
    if (fileSize > 500 * 1024 * 1024) {
      return 'main_content';
    }

    // Medium-sized files with content indicators
    if (fileSize > 50 * 1024 * 1024 && hasContentIndicator) {
      return 'main_content';
    }

    // Default to unknown
    return 'unknown';
  }

  /**
   * Handle configuration commands
   */
  async handleConfigCommand(args) {
    if (args.length === 0) {
      return 'Please specify a config sub-command. Usage: xunlei config [set|show]';
    }

    const subCmd = args[0].toLowerCase();

    switch (subCmd) {
      case 'set':
        if (args.length < 3) {
          return 'Usage: xunlei config set <host> <port> [ssl]';
        }
        return this.setConfig(args.slice(1));
      case 'show':
        return this.showConfig();
      default:
        return `Unknown config command: ${subCmd}. Use 'xunlei config set' or 'xunlei config show'.`;
    }
  }

  /**
   * Set configuration
   */
  async setConfig(args) {
    if (args.length < 2) {
      return 'Usage: xunlei config set <host> <port> [ssl]';
    }

    const host = args[0];
    const port = parseInt(args[1]);
    const ssl = args[2] ? args[2].toLowerCase() === 'true' : false;

    if (isNaN(port) || port <= 0 || port > 65535) {
      return 'Invalid port number. Port must be between 1 and 65535.';
    }

    // Test the connection first
    const testClient = new XunleiDockerClient();
    testClient.configure(host, port, ssl);

    try {
      const testResult = await testClient.testConnection();
      if (!testResult.success) {
        return `Connection test failed: ${testResult.message}`;
      }

      // If test passes, save the configuration
      this.client.configure(host, port, ssl);
      this.saveConfig({
        host: host,
        port: port,
        ssl: ssl
      });

      return `Configuration saved successfully. Connected to Xunlei service at ${host}:${port}`;
    } catch (error) {
      return `Failed to connect to Xunlei service: ${error.message}`;
    }
  }

  /**
   * Show current configuration
   */
  showConfig() {
    if (!this.client.host || !this.client.port) {
      return 'Xunlei service is not configured. Use "xunlei config set <host> <port>" to configure.';
    }

    return `Current Xunlei configuration:\n- Host: ${this.client.host}\n- Port: ${this.client.port}\n- SSL: ${this.client.ssl}`;
  }

  /**
   * Get completed tasks
   */
  async getCompletedTasks() {
    try {
      const tasks = await this.client.getCompletedTasks();
      
      if (tasks.length === 0) {
        return 'No completed download tasks.';
      }

      let response = `✅ Completed Download Tasks (${tasks.length}):\n\n`;
      // Show only the most recent 10 tasks
      const recentTasks = tasks.slice(0, 10);
      recentTasks.forEach((task, index) => {
        const fileSize = this.formatFileSize(task.file_size);
        const completedTime = new Date(task.created_time).toLocaleString();
        
        response += `${index + 1}. ${task.name}\n`;
        response += `   Size: ${fileSize} | Completed: ${completedTime}\n\n`;
      });

      if (tasks.length > 10) {
        response += `... and ${tasks.length - 10} more tasks`;
      }

      return response;
    } catch (error) {
      return `Error getting completed tasks: ${error.message}`;
    }
  }

  /**
   * Get Xunlei service version
   */
  async getVersion() {
    try {
      const version = await this.client.getServiceVersion();
      return `Xunlei Service Version: ${version}`;
    } catch (error) {
      return `Error getting service version: ${error.message}`;
    }
  }
}

module.exports = XunleiDockerSkill;

path: README.md
content: # Xunlei Docker Downloader Skill

This OpenClaw skill allows you to interact with Docker-deployed Xunlei services for downloading magnet links and managing download tasks.

## Prerequisites

- A running Xunlei service in Docker (e.g., using [cnk3x/xunlei](https://github.com/cnk3x/xunlei))
- Access to the Xunlei web interface from your OpenClaw instance

## Installation

1. Place the entire `xunlei-docker-downloader` folder in your OpenClaw skills directory
2. Install dependencies: `npm install` in the skill directory
3. The skill will be automatically detected by OpenClaw

## Configuration

Before using the skill, you need to configure the connection to your Xunlei service:

```
xunlei config set <host> <port> [ssl]
```

For example:
```
xunlei config set 192.168.1.100 2345 false
```

## Usage

### View Current Download Tasks
```
xunlei status
```

### Submit a Magnet Link for Download
```
xunlei submit magnet:?xt=urn:btih:...
```

### Submit with Custom Task Name
```
xunlei submit magnet:?xt=urn:btih:... --name "My Custom Task"
```

### View Completed Tasks
```
xunlei completed
```

### Check Xunlei Service Version
```
xunlei version
```

### View Current Configuration
```
xunlei config show
```

## Commands Summary

- `xunlei status` - Show current download tasks
- `xunlei submit <magnet_link>` - Submit a magnet link for download
- `xunlei submit <magnet_link> --name <task_name>` - Submit with custom task name
- `xunlei config set <host> <port> [ssl]` - Configure Xunlei service connection
- `xunlei config show` - Show current configuration
- `xunlei completed` - Show completed tasks
- `xunlei version` - Show Xunlei service version
- `xunlei help` - Show help information

## Technical Details

This skill is based on the [xunlei-docker-ext](https://github.com/saaak/xunlei-docker-ext) Chrome extension by saaak, adapted for server-side use. It communicates with the Xunlei service using the same API endpoints and authentication mechanisms as the original extension.

path: CONFIG.md
content: # Xunlei Docker Downloader Skill Configuration

## Required Configuration

This skill requires the following configuration to connect to your Docker-deployed Xunlei service:

```yaml
xunlei:
  host: "192.168.1.100"    # IP address of your Xunlei Docker container
  port: 2345               # Port of your Xunlei web interface
  ssl: false               # Whether to use HTTPS (default: false)
```

## Configuration Methods

### Method 1: Using Commands
You can configure the skill directly using OpenClaw commands:
```
xunlei config set <host> <port> [ssl]
```

### Method 2: Manual Configuration
Create a `config.json` file in the skill directory with the following structure:
```json
{
  "host": "192.168.1.100",
  "port": 2345,
  "ssl": false
}
```

## Finding Your Xunlei Service Details

1. Find your Xunlei Docker container IP address:
   - On the same host: usually `localhost` or `127.0.0.1`
   - On a different machine: use the machine's IP address
   - In Docker: you can use `docker inspect <container_name>` to find the IP

2. Find your Xunlei service port:
   - Default port for cnk3x/xunlei is typically 2345
   - Check your Docker run command or compose file for the port mapping

## Troubleshooting

### Connection Issues
- Verify that the Xunlei service is running and accessible
- Check firewall settings on both the client and server
- Ensure the port is correctly mapped in Docker

### Authentication Issues
- Make sure you're using the correct authentication method
- Some versions of Xunlei may require different authentication approaches

### Network Issues
- Ensure that OpenClaw can reach the Xunlei service
- Test connectivity with a simple ping or curl command

path: package-lock.json
content: {
  "name": "xunlei-docker-downloader",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "xunlei-docker-downloader",
      "version": "1.0.0",
      "license": "MIT",
      "dependencies": {
        "axios": "^1.13.4"
      }
    },
    "node_modules/asynckit": {
      "version": "0.4.0",
      "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/axios": {
      "version": "1.13.4",
      "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "follow-redirects": "^1.15.6",
        "form-data": "^4.0.4",
        "proxy-from-env": "^1.1.0"
      }
    },
    "node_modules/call-bind-apply-helpers": {
      "version": "1.0.2",
      "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0",
        "function-bind": "^1.1.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/combined-stream": {
      "version": "1.0.8",
      "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "delayed-stream": "~1.0.0"
      },
      "engines": {
        "node": ">= 0.8"
      }
    },
    "node_modules/delayed-stream": {
      "version": "1.0.0",
      "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=0.4.0"
      }
    },
    "node_modules/dunder-proto": {
      "version": "1.0.1",
      "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "call-bind-apply-helpers": "^1.0.1",
        "es-errors": "^1.3.0",
        "gopd": "^1.2.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-define-property": {
      "version": "1.0.1",
      "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-errors": {
      "version": "1.3.0",
      "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-object-atoms": {
      "version": "1.1.1",
      "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-set-tostringtag": {
      "version": "2.1.0",
      "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0",
        "get-intrinsic": "^1.2.6",
        "has-tostringtag": "^1.0.2",
        "hasown": "^2.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/follow-redirects": {
      "version": "1.15.11",
      "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
      "integrity": [REDACTED_SECRET],
      "funding": [
        {
          "type": "individual",
          "url": "https://github.com/sponsors/RubenVerborgh"
        }
      ],
      "license": "MIT",
      "engines": {
        "node": ">=4.0"
      },
      "peerDependenciesMeta": {
        "debug": {
          "optional": true
        }
      }
    },
    "node_modules/form-data": {
      "version": "4.0.5",
      "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "asynckit": "^0.4.0",
        "combined-stream": "^1.0.8",
        "es-set-tostringtag": "^2.1.0",
        "hasown": "^2.0.2",
        "mime-types": "^2.1.12"
      },
      "engines": {
        "node": ">= 6"
      }
    },
    "node_modules/function-bind": {
      "version": "1.1.2",
      "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/get-intrinsic": {
      "version": "1.3.0",
      "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "call-bind-apply-helpers": "^1.0.2",
        "es-define-property": "^1.0.1",
        "es-errors": "^1.3.0",
        "es-object-atoms": "^1.1.1",
        "function-bind": "^1.1.2",
        "get-proto": "^1.0.1",
        "gopd": "^1.2.0",
        "has-symbols": "^1.1.0",
        "hasown": "^2.0.2",
        "math-intrinsics": "^1.1.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/get-proto": {
      "version": "1.0.1",
      "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "dunder-proto": "^1.0.1",
        "es-object-atoms": "^1.0.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/gopd": {
      "version": "1.2.0",
      "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/has-symbols": {
      "version": "1.1.0",
      "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/has-tostringtag": {
      "version": "1.0.2",
      "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "has-symbols": "^1.0.3"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/hasown": {
      "version": "2.0.2",
      "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "function-bind": "^1.1.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/math-intrinsics": {
      "version": "1.1.0",
      "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/mime-db": {
      "version": "1.52.0",
      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/mime-types": {
      "version": "2.1.35",
      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "mime-db": "1.52.0"
      },
      "engines": {
        "node": ">= 0.6"
      }
    },
    "node_modules/proxy-from-env": {
      "version": "1.1.0",
      "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    }
  }
}


path: package.json
content: {
  "name": "xunlei-docker-downloader",
  "version": "1.0.0",
  "description": "OpenClaw skill for interacting with Docker-deployed Xunlei services",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [
    "openclaw",
    "skill",
    "xunlei",
    "docker",
    "downloader",
    "magnet",
    "torrent"
  ],
  "author": "OpenClaw",
  "license": "MIT",
  "dependencies": {
    "axios": "^1.13.4"
  }
}


path: xunlei_docker_client.js
content: const crypto = require('crypto');
const axios = require('axios');

class XunleiDockerClient {
  constructor(config = {}) {
    this.host = config.host || null;
    this.port = config.port || null;
    this.ssl = config.ssl || false;
    this.deviceId = null;
    this.parentFolderId = null;
    this.baseUrl = null;
    
    // Cache for high version token
    this.cachedHighVersionToken = null;
    this.highVersionTokenCacheTime = 0;
    this.HIGH_VERSION_TOKEN_CACHE_DURATION = 10 * 60 * 1000; // 10 minutes
  }

  /**
   * Initialize the client with configuration
   */
  configure(host, port, ssl = false) {
    this.host = host;
    this.port = port;
    this.ssl = ssl;
    this.baseUrl = `${this.ssl ? 'https' : 'http'}://${host}:${port}`;
  }

  /**
   * Generate MD5 hash
   */
  hex_md5(input) {
    return crypto.createHash('md5').update(input).digest('hex');
  }

  /**
   * Get Xunlei service version directly without using request method
   */
  async getServiceVersion() {
    if (!this.baseUrl) {
      throw new Error('Xunlei service not configured. Please use configure() first.');
    }

    try {
      const headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36 Edg/90.0.818.66',
        'Accept': '*/*',
        'Accept-Language': 'zh-CN,zh;q=0.9'
      };

      const url = `${this.baseUrl}/webman/3rdparty/pan-xunlei-com/index.cgi/launcher/status`;
      const options = {
        method: 'GET',
        url: url,
        headers,
        timeout: 10000
      };

      const response = await axios(options);
      return response.data.running_version || 'unknown';
    } catch (error) {
      throw new Error(`Failed to get service version: ${error.message}`);
    }
  }

  /**
   * Compare version numbers
   */
  isVersionAtLeast(currentVersion, targetVersion) {
    if (!currentVersion || !targetVersion) return false;
    const currentParts = currentVersion.split('.').map(Number);
    const targetParts = targetVersion.split('.').map(Number);

    for (let i = 0; i < targetParts.length; i++) {
      const current = currentParts[i] || 0;
      const target = targetParts[i] || 0;
      if (current > target) return true;
      if (current < target) return false;
    }
    return currentParts.length >= targetParts.length;
  }

  /**
   * Get high version token from the homepage
   */
  async getHighVersionToken() {
    if (this.cachedHighVersionToken && 
        (Date.now() - this.highVersionTokenCacheTime < this.HIGH_VERSION_TOKEN_CACHE_DURATION)) {
      return this.cachedHighVersionToken;
    }

    try {
      const url = `${this.baseUrl}/webman/3rdparty/pan-xunlei-com/index.cgi/`;
      
      const headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36 Edg/90.0.818.66'
      };

      const options = {
        method: 'GET',
        url: url,
        headers,
        timeout: 10000
      };

      const response = await axios(options);
      const htmlContent = response.data;
      
      const uiauthRegex = /function uiauth\(value\)\s*{\s*return\s*"([^"]+)"\s*}/;
      const match = htmlContent.match(uiauthRegex);

      if (match && match[1]) {
        this.cachedHighVersionToken = match[1];
        this.highVersionTokenCacheTime = Date.now();
        return this.cachedHighVersionToken;
      }
      return null;
    } catch (error) {
      console.error('Error getting high version token:', error.message);
      return null;
    }
  }

  /**
   * Generate authentication token for Xunlei service
   */
  async generatePanAuth() {
    if (!this.baseUrl) {
      throw new Error('Xunlei service not configured. Please use configure() first.');
    }

    // First try to get the service version
    try {
      const serverVersion = await this.getServiceVersion();
      
      if (serverVersion && this.isVersionAtLeast(serverVersion, "3.21.0")) {
        console.log(`Detected Xunlei version ${serverVersion}, using high version auth method`);
        const token = await this.getHighVersionToken();
        if (token) {
          return token;
        }
        console.warn("High version token not found, falling back to low version method");
      } else if (serverVersion) {
        console.log(`Detected Xunlei version ${serverVersion}, using low version auth method`);
      } else {
        console.log("Cannot determine Xunlei version, using low version auth method");
      }
    } catch (error) {
      console.warn("Error detecting server version, using low version auth method:", error.message);
    }

    // Fallback to low-version method
    const timestamp = Math.floor(Date.now() / 1000);
    const randomString = `${timestamp}yrjmxtpovrzzdqgtbjdncmsywlpmyqcaawbnruddxucykfebpkuseypjegajzzpplmzrejnavcwtvciupgigyrtomdljhtmsljegvutunuizvatwtqdjheituaizfjyfzpbcvhhlaxzfatpgongrqadvixrnvastczwnolznfavqrvmjseiosmvrtcqiapmtzjfihdysqmhaijlpsrssovkpqnjbxuwkhjpfxpoldvqrnlhgdbcpnsilsmydxaxrxjzbdekzmshputmgkedetrcbmcdgljfkpbprvqncixfkavyxoibbuuyqzvcbzdgvipozeplohmcyfornhxzsadavvimivbzexfzhlndddnbywhsvjrotwzarbycpwydvpeqtuigfwzcvoswgpoakuvgdbykdjdcsdlnqskogpbsyceeyaigbgmrbnzixethpvqvvfvdcvjbilxikvklfbkcnfprzhijjnuoovulvigiqvbosnbixeplvnewmyipxuzpvocbvidnzgsrdfkejghvvyizkjlofndcuzvlhdhovpeolsyroljurbplpwbbihmdloahicnqehgjnbthmrljtzovltnlpeibodpjvemhhybmanskbtvdrgkrzoyhsjcexfrcpddoemazkfjwmrbrcloitmdzzkgxwlhnbfpjffrpryljdzdqsbacrjgohzwgbvzgevnqvxppsxqzczfgpuvigjbuhzweyeinukeurkogpotdegqhtsztdinmijjowivciviunhcjhtufzhjlmpqlngslimksdeezdzxihtmaywfvipjctuealhlovmzdodruperyysdhwjbtidwdzusifeepywsmkqbknlgdhextvlheufxivphskqvdtbcjfryxlolujmennakdqjdhtcxwnhknhzlaatuhyofenhdigojyxrluijjxeywnmopsuicglfcqyybbpynpcsnizupumtakwwnjlkfkuooqoqxhjnryylklokmzvmmgjsbbvgmwoucpvzedmqpkmazwhhvxqygrexopkmcdyniqocguykphlngjesqohhuvnkcliuawkzcmvevdbouwzvgmhtavwyhstvqwhcwjluzjopnhuisbsrloavcieskcyqftdhieduduhowgvrkimgdhyszsiknmuzvnrqqlbykbdlixosgxrdunymbixakkmgppteayqmqivxcwawyidpltevotwoxlkrucmluuluatgeskhfsrsebhniwhujpwrpknjxylidtjwebvwmbwayoepootybnlcaoixlgvjmpquxnyomoiopsjxtnorhwnlmonllastiezyvfbbgngjybtgbkxuaqdmkuqwupgzhffuyzgdnahdifaqtfmpysnlesvfoiofxvbtqkiqvdniejbyzugbkursumqddaslhqpkdrjnnsdqfthxtghxhaylgeqnknhqwpammlfnlkjuqevnxesyqsnpufvrbeohphxfabcduuklpkfoiifsqrrbsxkkmdrnkeboprnksfzwmjymjspzsrfjlwneuwzjjwejruubhhqaktxhygtjuhjmtvrklrmxdbbwooxsucmynwgcxhzdctgtchaevmpfiqfwydultmgqnionuendspvdrcctxldnyjlgnsqxaddadxeyvlcifdxksgdhaatsslhcofnxmilljpzdlumfjvcwvjrxegwbwuuwkguydhozqqnuselsoojnsefquuhpijdguofwrcjbuaugyzphkenbyhdstsldybdqsfxjhpgnerbdosbtyzdtrhyvwkzkurnmbgjtzlzcpfsuxussguelnjttmwejhreptwogekfvdsemlkvklcxeuzlboqwbngddexhsmyzqkztvlbgybbfmzbjroajaucykiqvhjrirlgawaessusvulngosviecmbpfgevxqptalguchfzkrrpruwxspggiqokepqpocezcewhyajsgxrqqqeuhwvc`;
    const md5 = this.hex_md5(randomString);
    return `${timestamp}.${md5}`;
  }

  /**
   * Make a request to the Xunlei service
   */
  async request(method, url, data = null, useAuth = true) {
    if (!this.baseUrl) {
      throw new Error('Xunlei service not configured. Please use configure() first.');
    }

    const headers = {
      'DNT': '1',
      'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36 Edg/90.0.818.66',
      'device-space': '',
      'content-type': 'application/json',
      'Accept': '*/*',
      'Accept-Language': 'zh-CN,zh;q=0.9'
    };

    // Add auth header only if required (some endpoints like version check don't need it)
    if (useAuth) {
      const panAuth = await this.generatePanAuth();
      if (!panAuth) {
        throw new Error('Unable to generate auth token for Xunlei service.');
      }
      headers['pan-auth'] = panAuth;
    }

    const fullUrl = this.baseUrl + url;
    const options = {
      method,
      url: fullUrl,
      headers,
      timeout: 10000 // 10 seconds timeout
    };

    if (method === 'POST' && data) {
      options.data = data;
    }

    try {
      const response = await axios(options);
      return response.data;
    } catch (error) {
      if (error.response) {
        throw new Error(`Request failed with status ${error.response.status}: ${error.response.statusText}`);
      } else if (error.request) {
        throw new Error(`Network error: Unable to reach Xunlei service at ${this.baseUrl}`);
      } else {
        throw new Error(`Error: ${error.message}`);
      }
    }
  }

  /**
   * Get device ID from Xunlei service
   */
  async getDeviceId() {
    try {
      const response = await this.request('GET', '/webman/3rdparty/pan-xunlei-com/index.cgi/drive/v1/tasks?type=user%23runner&device_space=');
      const tasks = response.tasks || [];
      
      if (tasks.length === 0) {
        throw new Error('No remote device is bound to the Xunlei service');
      }

      this.deviceId = tasks[0].params?.target;
      return this.deviceId;
    } catch (error) {
      throw new Error(`Failed to get device ID: ${error.message}`);
    }
  }

  /**
   * Get uncompleted download tasks
   */
  async getUncompletedTasks() {
    if (!this.deviceId) {
      await this.getDeviceId();
    }

    const url = `/webman/3rdparty/pan-xunlei-com/index.cgi/drive/v1/tasks?space=${encodeURIComponent(this.deviceId)}&page_token=&filters=%7B%22phase%22%3A%7B%22in%22%3A%22PHASE_TYPE_PENDING%2CPHASE_TYPE_RUNNING%2CPHASE_TYPE_PAUSED%2CPHASE_TYPE_ERROR%22%7D%2C%22type%22%3A%7B%22in%22%3A%22user%23download-url%2Cuser%23download%22%7D%7D&limit=200&device_space=`;
    
    try {
      const response = await this.request('GET', url);
      return response.tasks ? response.tasks : [];
    } catch (error) {
      throw new Error(`Failed to get uncompleted tasks: ${error.message}`);
    }
  }

  /**
   * Get completed download tasks
   */
  async getCompletedTasks() {
    if (!this.deviceId) {
      await this.getDeviceId();
    }

    const url = `/webman/3rdparty/pan-xunlei-com/index.cgi/drive/v1/tasks?space=${encodeURIComponent(this.deviceId)}&page_token=&filters=%7B%22phase%22%3A%7B%22in%22%3A%22PHASE_TYPE_COMPLETE%22%7D%2C%22type%22%3A%7B%22in%22%3A%22user%23download-url%2Cuser%23download%22%7D%7D&limit=200&device_space=`;
    
    try {
      const response = await this.request('GET', url);
      return response.tasks ? response.tasks : [];
    } catch (error) {
      throw new Error(`Failed to get completed tasks: ${error.message}`);
    }
  }

  /**
   * Extract file list from a magnet link
   */
  async extractFileList(magnetLink) {
    try {
      const response = await this.request('POST', '/webman/3rdparty/pan-xunlei-com/index.cgi/drive/v1/resource/list?device_space=', {
        urls: magnetLink
      });
      return response.list ? response.list : {};
    } catch (error) {
      throw new Error(`Failed to extract file list: ${error.message}`);
    }
  }

  /**
   * Submit a download task
   */
  async submitTask(magnetLink, taskName = '', selectedFiles = []) {
    if (!this.deviceId) {
      await this.getDeviceId();
    }

    // If no files are specified, download all files in the torrent
    let finalTaskFiles = selectedFiles;
    if (selectedFiles.length === 0) {
      try {
        const fileTree = await this.extractFileList(magnetLink);
        const resources = fileTree.resources || [];
        finalTaskFiles = resources.map((resource, index) => ({
          index: resource.file_index || index,
          file_size: resource.file_size || 0,
          file_name: resource.name || `file_${index}`
        }));
      } catch (error) {
        console.warn('Could not extract file list, submitting with all files:', error.message);
        // If we can't extract files, submit with default values
        finalTaskFiles = [{ index: 0, file_size: 0, file_name: 'all_files' }];
      }
    }

    const name = taskName || finalTaskFiles[0]?.file_name || 'Download Task';
    const taskFileCount = finalTaskFiles.length;

    // Get parent folder ID if not already cached
    if (!this.parentFolderId) {
      try {
        const res = await this.request('GET', `/webman/3rdparty/pan-xunlei-com/index.cgi/drive/v1/files?space=${encodeURIComponent(this.deviceId)}&limit=200&parent_id=&filters=%7B%22kind%22%3A%7B%22eq%22%3A%22drive%23folder%22%7D%7D&page_token=&device_space=`);
        this.parentFolderId = res?.files?.[0]?.parent_id || '';
      } catch (error) {
        console.warn('Could not get parent folder ID:', error.message);
        this.parentFolderId = '';
      }
    }

    const submitBody = {
      type: "user#download-url",
      name: name,
      file_name: name,
      file_size: finalTaskFiles.reduce((sum, f) => sum + f.file_size, 0).toString(),
      space: this.deviceId,
      params: {
        target: this.deviceId,
        url: magnetLink,
        total_file_count: taskFileCount.toString(),
        parent_folder_id: this.parentFolderId,
        sub_file_index: finalTaskFiles.map(f => f.index.toString()).join(','),
        file_id: ""
      }
    };

    try {
      const response = await this.request('POST', '/webman/3rdparty/pan-xunlei-com/index.cgi/drive/v1/task?device_space=', submitBody);
      if (response.HttpStatus !== 0) {
        throw new Error(response.error_description || 'Submission failed');
      }
      return { success: true, taskId: response.id, message: 'Task submitted successfully' };
    } catch (error) {
      throw new Error(`Failed to submit task: ${error.message}`);
    }
  }

  /**
   * Test connection to Xunlei service
   */
  async testConnection() {
    try {
      await this.getDeviceId();
      return { success: true, message: 'Connected to Xunlei service successfully' };
    } catch (error) {
      return { success: false, message: error.message };
    }
  }
}

module.exports = XunleiDockerClient;

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