is_allowed_by_robots($url)) { throw new Exception('This URL is disallowed by the website\'s robots.txt.'); } $response = wp_remote_get($url, $this->get_request_args($url)); if (is_wp_error($response)) { throw new Exception('Failed to fetch URL: ' . esc_html($response->get_error_message())); } $status_code = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); $headers = wp_remote_retrieve_headers($response); // Check for bot protection/captcha indicators if ($this->detect_bot_protection($body, $headers, $status_code)) { throw new Exception('Bot protection detected. Unable to crawl this URL.'); } if ($status_code !== 200) { throw new Exception('HTTP error: ' . esc_html($status_code)); } $content_type = wp_remote_retrieve_header($response, 'content-type'); if (!preg_match('/(text\/html|application\/xhtml\+xml)/i', $content_type)) { return false; } if (empty($body)) { return false; } return $body; } /** * Request arguments used for crawl requests * * @param string $url URL being requested * @return array */ public function get_request_args($url) { return [ 'timeout' => 30, 'user-agent' => self::USER_AGENT, 'sslverify' => false, 'headers' => [ 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language' => 'en-US,en;q=0.5', 'Referer' => wp_parse_url($url, PHP_URL_SCHEME) . '://' . wp_parse_url($url, PHP_URL_HOST) ] ]; } /** * Detect if the response is a bot protection / challenge page. * * Only real challenge signals are checked. A CDN header such as * "Server: cloudflare" or ordinary words in the page text (e.g. "blocked", * "please wait") are not treated as protection. * * @param string $body Response body * @param array|ArrayAccess $headers Response headers * @param int $status_code HTTP status code * @return bool True if bot protection detected */ public function detect_bot_protection($body, $headers, $status_code) { // Explicit challenge headers (Cloudflare managed challenge, Sucuri, Incapsula) if (isset($headers['cf-mitigated']) && stripos((string) $headers['cf-mitigated'], 'challenge') !== false) { return true; } if (isset($headers['x-sucuri-block']) || isset($headers['x-iinfo'])) { return true; } // Status codes typically returned by firewalls and rate limiters if (in_array((int) $status_code, [403, 429, 503], true)) { return true; } $body = (string) $body; // Titles used by known challenge / block pages if (preg_match('/]*>(.*?)<\/title>/is', $body, $matches)) { $title = trim(html_entity_decode(wp_strip_all_tags($matches[1]))); $challenge_titles = [ 'just a moment', 'attention required! | cloudflare', 'ddos-guard', 'pardon our interruption', 'access denied | sucuri', 'sucuri website firewall', 'human verification', 'are you a robot', 'verify you are human', ]; foreach ($challenge_titles as $challenge_title) { if (stripos($title, $challenge_title) === 0) { return true; } } } // Markup that only appears on challenge pages $challenge_markers = [ 'cf_chl_opt', 'cf-browser-verification', 'cf-challenge-running', 'checking your browser before accessing', '_Incapsula_Resource', 'Incapsula incident ID', 'captcha-delivery.com', 'id="px-captcha"', 'ddos-guard.net/check', ]; foreach ($challenge_markers as $marker) { if (stripos($body, $marker) !== false) { return true; } } return false; } /** * Check whether robots.txt allows AiskBot to crawl a URL. * * Rules from a group naming AiskBot take priority over the "*" group. * The most specific (longest) matching rule wins, with Allow winning ties. * A missing or unreachable robots.txt allows crawling. * * @param string $url URL to check * @return bool */ public function is_allowed_by_robots($url) { $parts = wp_parse_url($url); if (empty($parts['host'])) { return true; } $rules = $this->get_robots_rules($parts); if (empty($rules)) { return true; } $path = (isset($parts['path']) ? $parts['path'] : '/') . (isset($parts['query']) ? '?' . $parts['query'] : ''); $matched_length = -1; $allowed = true; foreach ($rules as $rule) { if (!$this->robots_path_matches($rule['path'], $path)) { continue; } $length = strlen($rule['path']); if ($length > $matched_length || ($length === $matched_length && $rule['allow'])) { $matched_length = $length; $allowed = $rule['allow']; } } return $allowed; } /** * Fetch and parse robots.txt rules for a host, cached per request and for 12 hours * * @param array $parts Parsed URL parts * @return array List of ['allow' => bool, 'path' => string] */ private function get_robots_rules($parts) { $scheme = isset($parts['scheme']) ? $parts['scheme'] : 'https'; $origin = $scheme . '://' . $parts['host'] . (isset($parts['port']) ? ':' . $parts['port'] : ''); if (isset(self::$robots_rules[$origin])) { return self::$robots_rules[$origin]; } $transient_key = 'aisk_robots_' . md5($origin); $rules = get_transient($transient_key); if (false === $rules) { $rules = []; $response = wp_remote_get($origin . '/robots.txt', [ 'timeout' => 10, 'user-agent' => self::USER_AGENT, 'sslverify' => false, ]); if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) { $rules = $this->parse_robots(wp_remote_retrieve_body($response)); } set_transient($transient_key, $rules, 12 * HOUR_IN_SECONDS); } self::$robots_rules[$origin] = $rules; return $rules; } /** * Parse robots.txt and return the rules that apply to AiskBot * * @param string $content robots.txt body * @return array */ private function parse_robots($content) { $groups = []; $current = null; $last_was_agent = false; foreach (preg_split('/\r\n|\r|\n/', (string) $content) as $line) { $line = trim(preg_replace('/#.*$/', '', $line)); if ($line === '' || strpos($line, ':') === false) { continue; } list($field, $value) = array_map('trim', explode(':', $line, 2)); $field = strtolower($field); if ($field === 'user-agent') { // Consecutive User-agent lines share one group if (!$last_was_agent) { $groups[] = ['agents' => [], 'rules' => []]; $current = count($groups) - 1; } $groups[$current]['agents'][] = strtolower($value); $last_was_agent = true; continue; } $last_was_agent = false; if ($current === null || !in_array($field, ['allow', 'disallow'], true)) { continue; } // An empty Disallow means everything is allowed if ($value === '') { continue; } $groups[$current]['rules'][] = [ 'allow' => $field === 'allow', 'path' => $value, ]; } $specific = []; $wildcard = []; $has_specific = false; foreach ($groups as $group) { foreach ($group['agents'] as $agent) { if ($agent === '*') { $wildcard = array_merge($wildcard, $group['rules']); } elseif ($agent === self::ROBOTS_TOKEN) { $specific = array_merge($specific, $group['rules']); $has_specific = true; } } } return $has_specific ? $specific : $wildcard; } /** * Match a robots.txt path pattern (supports * and $) against a URL path * * @param string $pattern Rule path * @param string $path URL path with query * @return bool */ private function robots_path_matches($pattern, $path) { $anchored = substr($pattern, -1) === '$'; if ($anchored) { $pattern = substr($pattern, 0, -1); } $regex = str_replace('\*', '.*', preg_quote($pattern, '#')); return (bool) preg_match('#^' . $regex . ($anchored ? '$' : '') . '#', $path); } }