* @author Maximum Software Inc * @license GPL-3.0-only * @link https://agenistrator.com/ * * SPDX-FileCopyrightText: 2025-2026 Alex Ruban * SPDX-FileCopyrightText: 2025-2026 Maximum Software Inc * SPDX-License-Identifier: GPL-3.0-only */ namespace Agenistrator; use League\HTMLToMarkdown\HtmlConverter; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Fetch_Url_Tool class. */ class Fetch_Url_Tool extends Tool { private const DEFAULT_MAX_SIZE = 10240; // 10KB /** * Get the tool ID. * * @return string The tool ID. * @since 1.0.0 */ public function get_id() { return 'fetch_url'; } /** * Get the tool name. * * @return string The tool name. * @since 1.0.0 */ public function get_name() { return __( 'Fetch URL', 'agenistrator' ); } /** * Get the tool description. * * @return string The tool description. * @since 1.0.0 */ public function get_description() { return 'Fetch text-based content from a URL for reading.'; } /** * Get usage instructions for the LLM. * * @return string Instructions for using this tool. * @since 1.0.0 */ public function get_instructions() { return 'Supports HTML, JSON, XML, plain text, and similar formats. Binary content (images, PDFs, etc.) is not supported. HTML is automatically converted to Markdown for readability.'; } /** * Check if this tool is read-only. * * @return bool True if the tool only reads data. * @since 1.0.0 */ public function is_read_only() { return true; } /** * Get the parameters schema for this tool. * * @return array Parameters schema in JSON Schema format. * @since 1.0.0 */ public function get_parameters() { return array( 'type' => 'object', 'properties' => array( 'url' => array( 'type' => 'string', 'description' => 'The URL to fetch', ), 'raw' => array( 'type' => 'boolean', 'description' => 'Return raw HTML without Markdown conversion (default: false)', ), 'max_size' => array( 'type' => 'integer', 'description' => 'Maximum content size in bytes to return (default: 10KB)', ), 'offset' => array( 'type' => 'integer', 'description' => 'Byte offset to start reading from, for pagination (default: 0)', ), ), 'required' => array( 'url' ), ); } /** * Check if the current user can execute this tool. * * @return bool True if the current user has permission. * @since 1.0.0 */ public function current_user_can_execute() { return current_user_can( 'manage_options' ); } /** * Execute the tool with the given arguments. * * @param array $args The tool arguments. * * @return array The tool execution result. * @since 1.0.0 */ protected function execute( $args ) { $url = esc_url_raw( $args['url'] ); $raw = $args['raw'] ?? false; $max_size = $args['max_size'] ?? self::DEFAULT_MAX_SIZE; $offset = max( 0, $args['offset'] ?? 0 ); if ( empty( $url ) ) { return $this->error_response( 'URL is required' ); } $response = wp_remote_get( $url, array( 'timeout' => 30, 'redirection' => 5, 'user-agent' => 'Agenistrator/' . AGENISTRATOR_VERSION, ) ); if ( is_wp_error( $response ) ) { return $this->error_response( 'Request failed: ' . $response->get_error_message() ); } $status_code = wp_remote_retrieve_response_code( $response ); $content_type = wp_remote_retrieve_header( $response, 'content-type' ); if ( $this->is_binary_content_type( $content_type ) ) { return $this->error_response( 'Binary content types are not supported. This tool only fetches text-based content.' ); } $body = wp_remote_retrieve_body( $response ); // Convert content to UTF-8 to ensure valid JSON encoding $body = $this->ensure_utf8( $body, $content_type ); if ( ! $raw && stripos( $content_type, 'text/html' ) !== false ) { $body = $this->html_to_markdown( $body ); } $total_size = strlen( $body ); // Use mb_strcut to avoid breaking multi-byte UTF-8 sequences $body = mb_strcut( $body, $offset, $max_size, 'UTF-8' ); $has_more = ( $offset + strlen( $body ) ) < $total_size; return $this->success_response( 'URL fetched successfully', array( 'url' => $url, 'status_code' => $status_code, 'content_type' => $content_type, 'total_size' => $total_size, 'offset' => $offset, 'size' => strlen( $body ), 'has_more' => $has_more, 'content' => $body, ) ); } private function html_to_markdown( $html ) { $converter = new HtmlConverter( array( 'strip_tags' => true, 'remove_nodes' => 'script style head nav footer', 'hard_break' => true, 'header_style' => 'atx', ) ); return $converter->convert( $html ); } /** * Ensure content is valid UTF-8. * * Detects the source encoding from Content-Type header charset, * converts to UTF-8, and sanitizes any remaining invalid sequences. * * @param string $content The raw content bytes. * @param string $content_type The Content-Type header value. * @return string Valid UTF-8 content. */ private function ensure_utf8( $content, $content_type ) { $source_encoding = $this->detect_encoding( $content, $content_type ); // Convert to UTF-8 if source encoding is known and different if ( $source_encoding && strtoupper( $source_encoding ) !== 'UTF-8' ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- encoding conversion may fail silently for unknown encodings $converted = @mb_convert_encoding( $content, 'UTF-8', $source_encoding ); if ( false !== $converted ) { $content = $converted; } } // Sanitize: remove any remaining invalid UTF-8 sequences // This handles cases where encoding detection failed or was incorrect $content = mb_convert_encoding( $content, 'UTF-8', 'UTF-8' ); return $content; } /** * Check if the content type indicates binary content. * * @param string $content_type The Content-Type header value. * @return bool True if the content type is binary. */ private function is_binary_content_type( $content_type ) { // Extract the MIME type (before any parameters like charset) $mime_type = strtolower( trim( explode( ';', $content_type )[0] ) ); // Text-based MIME types if ( strncmp( $mime_type, 'text/', 5 ) === 0 ) { return false; } // Common text-based application types $text_application_types = array( 'application/json', 'application/xml', 'application/xhtml+xml', 'application/javascript', 'application/ecmascript', 'application/x-javascript', 'application/rss+xml', 'application/atom+xml', 'application/soap+xml', 'application/xslt+xml', 'application/mathml+xml', 'application/ld+json', 'application/manifest+json', 'application/schema+json', 'application/x-www-form-urlencoded', ); if ( in_array( $mime_type, $text_application_types, true ) ) { return false; } // Treat +xml and +json suffixes as text if ( preg_match( '/\+(xml|json)$/', $mime_type ) ) { return false; } // Everything else (image/*, audio/*, video/*, application/octet-stream, etc.) is binary return true; } /** * Detect the character encoding of content. * * Checks Content-Type header charset and falls back to mb_detect_encoding. * * @param string $content The raw content bytes. * @param string $content_type The Content-Type header value. * @return string|null The detected encoding or null if unknown. */ private function detect_encoding( $content, $content_type ) { // Check Content-Type header for charset if ( preg_match( '/charset\s*=\s*["\']?([^"\'\s;]+)/i', $content_type, $matches ) ) { return $this->normalize_encoding_name( $matches[1] ); } // Fall back to mb_detect_encoding using PHP's default detection order $detected = mb_detect_encoding( $content, mb_detect_order(), true ); return $detected ? $detected : null; } /** * Normalize encoding name to PHP's expected format. * * @param string $encoding The encoding name from headers/meta tags. * @return string The normalized encoding name. */ private function normalize_encoding_name( $encoding ) { $encoding = strtoupper( trim( $encoding ) ); // Common aliases that may appear in HTTP headers $aliases = array( 'UTF8' => 'UTF-8', 'LATIN1' => 'ISO-8859-1', 'LATIN-1' => 'ISO-8859-1', 'ASCII' => 'ASCII', ); return $aliases[ $encoding ] ?? $encoding; } }