CakePHP: Code/Components/SeveralFlows

Загрузка в несколько потоков с multi_curl и PHP

Автор Владимир Лучанинов

Когда нужно скачать сотню страниц, то можно обойтись моим компонентом Browser. Но недавно мне надо было скачать столько информации, что нужно было ждать неделю. Проблема в том, что компонент загружает страницы по очереди. Пришлось придумывать как заставить его качать в несколько потоков.

Оказалось, что самое простое решение – это multi_curl. Но его на форумах очень критикуют за то, что он ест слишком много памяти, глючит и вообще безобразничает. Есть ещё вариант – неблокирующие сокеты, но мне они показались более сложными. Ну, а самое правильное, решение – это pkg_delete php, pkg_add perl :) Потому что Perl для этого задуман, а PHP – нет. Но ради единоразовой операции, хоть и длительной, пересиливать свою необъяснимую нелюбовь к Perl я не стал. Тем более пишут, что если ставить немного потоков (до 100), то multi_curl будет нормально работать.

К счастью, я не сторонник выдумывания велосипедов, поэтому взял библиотеку Вадима Тимофеева для работы с multi_curl. Чтобы она работала с компонентом Browser, скачайте последнюю версию, разархивируйте файл Multi Curl.class.php в папку vendors вашего и переименуйте его в multi_curl.php.

Папка vendors в CakePHP позволяет использовать сторонние разработки, которые не созданы специально для CakePHP. Эти файлы подключаются с помощью функции vendor()

Класс MultiCurl довольно интересно реализован. Он является абстрактным классом. Чтобы использовать его в своей программе, надо создать его наследника, в котором переопределить событие, которое происходит при загрузке. Вот что пришлось дописать в начало компонента Browser.

vendor('multi_curl');

class BrowserComponentMultiCurl extends MultiCurl {

    var $Browser = null;

    protected function onLoad($url, $content, $info) {

        $s = (serialize($info) . "\r\n\r\n" . $content);

        file_put_contents($this->Browser->_getCacheFilename($url), $s);

    }

}

Тут необходимо пояснить принцип работы multi_curl. При скачивании в несколько потоков multi_curl ставит в очередь закачки несколько страниц и возращает управление основной программе только после того как закачает все страницы. Но после закачки каждого файла выполняется callback-функция.

Я решил упростить себе задачу и сделал вместо метода getMulti($urls), метод cacheMulti($urls). Поэтому не забудьте созадать папку для кеширования – APP/tmp/cache/browser

/**

 * Downloads URLs in multiple threads

 *

 * @param array $urls a(url1, url2, ...)

 * @return boolean

 */

function cacheMulti($urls) {

    try {

        $mc = new BrowserComponentMultiCurl();

        $mc->Browser = $this;

        $mc->setMaxSessions(5); // limit 5 parallel sessions (by default 10)

        //$mc->setMaxSize(10240); // limit 10 Kb per session (by default 10 Mb)

        foreach ($urls as $url) {

            if (!$this->_isCached($url)) {

                $mc->addUrl($url);

            }

        }

        $mc->wait();

    } catch (Exception $e) {

        // dirty style, but good enough for my tasks

        echo('<h3 style="color:red">'.$e->getMessage().'</h3>');

        @flush();ob_flush();

    }

    return true;

}

Из основной программы этот метод я хотел вызвать сначала так:

$this->Browser->cacheMulti($urls);

foreach ($urls as $url) {

    $html = $this->Browser->get($url); // уже закешировано, поэтому выдаст сразу

    // process $html

}

Но не тут то было. Так как URLов действительно очень много, то ни Firefox, ни Lynx (я сервисные скрипты запускаю часто через него) не могли дождаться ответа от сервера и писалось 408 Request Timeout. Тогда я добавил в httpd.conf настройку Timeout 300000. Ошибка 408 Request Timeout всё равно показывалась, но насколько я понял, Apache продолжал выполнять скрипт ещё 300000 секунд. Я не нашёл правильного решения этой проблемы, поэтому немного подкорректировал скрипт, разбив список URLов на части.

$queueLength = 30;

for ($i=0; $i<count($urls); $i+=$queueLength) {

    $queue = array_slice($urls, $i, $queueLength);

    $this->Browser->cacheMulti($queue);

    foreach ($queue as $url) {

        $html = $this->Browser->get($url); // уже закешировано, поэтому выдаст сразу

        // process $html

    }

    echo(((!empty($i)?', ':'')) . $i); // показываем обработанный номер

    @flush();ob_flush(); // принудительное отображение. по одной работать не хотят. собака в начале строки - неизбежное зло

    usleep(500); // нечего пререгружать сильно чужие сервера - забанят

}

Можно было бы поставить обработку информации в onLoad, но мне так удобнее :)

Вот полная версия обновлённого компонента Browser

<?

vendor('multi_curl');

class BrowserComponentMultiCurl extends MultiCurl {

    var $Browser null;

    protected function onLoad($url$content$info) {

        $s = (serialize($info) . "\r\n\r\n" $content);

        file_put_contents($this->Browser->_getCacheFilename($url), $s);

    }

}

/**

 * Emulation of browser

 *

 * @version 1.3 (24 Oct 2007)

 * @author Vladimir Luchaninov - http://php.southpark.com.ua

 *

 */

class BrowserComponent extends Object {

    var $handle;

    var $header;

    var $body;

    /**

     * Name of browser you want to emulate. If 'random' then it will select from the large list.

     *

     * @var string

     */

    var $userAgent 'random';

    // if you need http auth

    var $username null;

    var $password null;

    var $proxy ''// 'ip:port'

    var $referer 'http://www.google.com/';

    var $timeout 30;

    /**

     * if you want to cache your requests you need to create folder APP/tmp/cache/browser

     *

     * @var string

     */

    var $cacheFolder null;

    var $symbolsNotFile = array( '~',  '!',  '@',  '#',  'http://''/',  "\\"':',  '*',  '?',  '"',  '<',  '>',  '|');

    var $symbolsFile = array('~~''!!''@@''##''#~',      '~!''~@''~#''!~''!@''!#''@~''@!''@#'); // still reserved '#!', '#@'

    /**

     * Init handle for connection

     *

     * @param AppController $controller

     */

    function startup(&$controller) {

        $cacheFolder APP 'tmp' DS 'cache' DS 'browser' DS;

        if (is_dir($cacheFolder)) {

            $this->cacheFolder $cacheFolder;

        }

        $this->_initUserAgent();

        $this->handle curl_init();

    }

    /**

     * Convert URL to the filename for caching

     *

     * @param string $url Like http://php.southpark.com.ua

     * @return string Filename of the cache file (withour full path)

     */

    function urlToFilename($url) {

        return r($this->symbolsNotFile$this->symbolsFile$url).'.txt';

    }

    /**

     * Convert filename from cache to URL

     *

     * @param string $filename Filename of cached file (without full path)

     * @return string URL

     */

    function filenameToUrl($filename) {

        return r($this->symbolsFile$this->symbolsNotFilesubstr($filename0strlen($filename)-4));

    }

    /**

     * Extract header and body from response to $this->header and $this->body

     *

     * @param string $response

     * @return string

     */

    function _setHeaderBody($response) {

        // You should see responses from some strange web-services

        // Check for \r\n\r\n is really not enough

        $regex '/(.*?)\n[\r\n]*?\n+(.*)/sm';

        $this->header '';

        if (!preg_match($regex$response$m)) {

            $this->body $response;

        } else {

            $this->header $m[1];

            $this->body ltrim($m[2], "\r");

            // sometimes there are several headers

            while (strpos($this->body'HTTP/')===&& preg_match($regex$this->body$m)) {

                $this->header .= "\n\n" $m[1];

                $this->body ltrim($m[2], "\r");

            }

        }

        return true;

    }

    /**

     * Get cache file filename for $url if possible. Otherwise null

     *

     * @param string $url Like http://php.southpark.com.ua

     * @return string Cache file filename with full path

     */

    function _getCacheFilename($url) {

        if (!empty($this->cacheFolder) && empty($postvars)) {

            return $this->cacheFolder $this->urlToFilename($url);

        } else {

            return null;

        }

    }

    /**

     * Check if $url is already downloaded and saved to cache file

     *

     * @param string $url Like http://php.southpark.com.ua

     * @return boolean True if $url exist in cache

     */

    function _isCached($url) {

        $cacheFile $this->_getCacheFilename($url);

        return (!empty($cacheFile) && file_exists($cacheFile));

    }

    /**

     * List all cached URLs

     *

     * @return array a(url1, url2, ...)

     */

    function getCachedUrls() {

        $folder = new Folder($this->cacheFolder);

        $files $folder->find('.*\.txt');

        $urls = array();

        foreach ($files as $filename) {

            $urls[] = $this->filenameToUrl($filename);

        }

        return $urls;

    }

    /**

     * Main function

     *

     * @param string $url

     * @param array $postvars

     * @return string body

     * after execution $this->header is accessible if needed

     */

    function get($url$postvars=null) {

        $cacheFile $this->_getCacheFilename($url);

        if ($this->_isCached($url)) {

            $response file_get_contents($cacheFile);

        } else {

            $this->prepare($url$postvars);

            $response curl_exec($this->handle);

            if (!empty($cacheFile)) {

                file_put_contents($cacheFile$response);

            }

        }

        $this->referer $url;

        $this->_setHeaderBody($response);

        return $this->body;

    }

    /**

     * Downloads URLs in multiple threads

     *

     * @param array $urls a(url1, url2, ...)

     * @return boolean

     */

    function cacheMulti($urls) {

        try {

            $mc = new BrowserComponentMultiCurl();

            $mc->Browser $this;

            $mc->setMaxSessions(5); // limit 5 parallel sessions (by default 10)

            //$mc->setMaxSize(10240); // limit 10 Kb per session (by default 10 Mb)

            foreach ($urls as $url) {

                if (!$this->_isCached($url)) {

                    $mc->addUrl($url);

                }

            }

            $mc->wait();

        } catch (Exception $e) {

            // dirty style, but good enough for my tasks

            echo('<h3 style="color:red">'.$e->getMessage().'</h3>');

            @flush();ob_flush();

        }

        return true;

    }

    /**

     * Set default options of curl

     *

     * @param string $url

     * @param array $postvars

     */

    function prepare($url$postvars=false){

        curl_setopt($this->handleCURLOPT_PROXY$this->proxy);

        curl_setopt($this->handleCURLOPT_REFERER$this->referer);

        curl_setopt($this->handleCURLOPT_USERAGENT$this->userAgent);

        curl_setopt($this->handleCURLOPT_URLstr_replace('&','&',$url));

        curl_setopt($this->handleCURLOPT_HEADER1);

        curl_setopt($this->handleCURLOPT_FOLLOWLOCATION,1);

        curl_setopt($this->handleCURLOPT_RETURNTRANSFER1);

        curl_setopt($this->handleCURLOPT_TIMEOUT$this->timeout);

        curl_setopt($this->handleCURLOPT_SSL_VERIFYPEERfalse);

        curl_setopt($this->handleCURLOPT_SSL_VERIFYHOST,  2);

        curl_setopt($this->handleCURLOPT_COOKIEJARAPP.'tmp/cookie.txt');

        curl_setopt($this->handleCURLOPT_COOKIEFILEAPP.'tmp/cookie.txt');

        if (!empty($postvars)){

            curl_setopt($this->handleCURLOPT_POST1);

            curl_setopt($this->handleCURLOPT_POSTFIELDS$postvars);

        }

        if (!empty($this->username)) {

            curl_setopt($this->handleCURLOPT_HTTPAUTHCURLAUTH_ANY);

            curl_setopt($this->handleCURLOPT_USERPWD$this->username.':'.$this->password); // $auth should be [username]:[password]

        }

        return true;

    }

    /**

     * Close current connection

     *

     */

    function close() {

        curl_close($this->handle);

        return true;

    }

    /**

     * Clears cache

     */

    function clearCache() {

        if (empty($this->cacheFolder)) return false;

        $dir dir($this->cacheFolder);

        while (($file $dir->read()) !== false) {

            if (in_array($file, array('''.''..'))) continue;

            unlink($dir->path $file);

        }

        return true;

    }

    /**

     * What browser should be emulated

     *

     * @return string browser name

     */

    function _initUserAgent() {

        if ($this->userAgent!='random') return true;

        $browsers = array(

            'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT 5.0)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows 98)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; .NET CLR 1.0.3705)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; .NET CLR 2.0.50727; .NET CLR 1.1.4322)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; Avant Browser; .NET CLR 2.0.50727)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; en) Opera 9.10',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; FunWebProducts)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; MRA 4.8 (build 01709); Maxthon; .NET CLR 1.1.4322; .NET CLR 2.0.50727)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; ru) Opera 8.50',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; ru) Opera 8.54',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.0.3705)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.0.3705; .NET CLR 1.1.4322; Media Center PC 4.0; .NET CLR 2.0.50727)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.30)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 1.1.4322; .NET CLR 2.0.50727; MAXTHON 2.0)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 2.0.50727)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 2.0.50727; .NET CLR 1.1.4322; InfoPath.1)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 2.0.50727; .NET CLR 3.0.04506.30)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 2.0.50727; .NET CLR 3.0.04506.30; InfoPath.2; .NET CLR 1.1.4322; MAXTHON 2.0)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; InfoPath.1)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; InfoPath.2)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; MRA 4.7 (build 01670); .NET CLR 1.1.4322)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; MRA 4.7 (build 01670); InfoPath.1)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; MRA 4.8 (build 01709))',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; MRA 4.8 (build 01709); .NET CLR 1.1.4322)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; MRA 4.8 (build 01709); .NET CLR 2.0.50727; InfoPath.2; .NET CLR 1.1.4322; .NET CLR 3.0.04506.30)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; MRA 4.8 (build 01709); Maxthon; .NET CLR 2.0.50727; .NET CLR 1.1.4322; .NET CLR 3.0.04506.30)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; MyIE2; InfoPath.1)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; MyIE2; MRA 4.8 (build 01709); .NET CLR 1.1.4322; InfoPath.1)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.2; .NET CLR 1.1.4322)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.2; SV1; .NET CLR 1.1.4322)',

            'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.2; SV1; .NET CLR 1.1.4322; .NET CLR 2.0.50727)',

            'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)',

            'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; .NET CLR 1.1.4322)',

            'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; .NET CLR 1.1.4322; .NET CLR 2.0.50727; InfoPath.1)',

            'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; .NET CLR 2.0.50727)',

            'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; .NET CLR 2.0.50727; .NET CLR 1.1.4322; InfoPath.1)',

            'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Avant Browser; Avant Browser; .NET CLR 1.1.4322)',

            'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Maxthon)',

            'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Maxthon; Avant Browser; InfoPath.2)',

            'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Maxthon; MyIE2; .NET CLR 1.0.3705; .NET CLR 2.0.50727; InfoPath.2)',

            'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; MRA 4.6 (build 01425); InfoPath.1)',

            'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; MRA 4.8 (build 01709))',

            'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; MRA 4.8 (build 01709); .NET CLR 1.1.4322; InfoPath.1)',

            'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; MRA 4.8 (build 01709); Avant Browser)',

            'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; MRA 4.9 (build 01863); .NET CLR 2.0.50727)',

            'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; MyIE2)',

            'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; MyIE2; .NET CLR 2.0.50727; InfoPath.1; .NET CLR 1.1.4322; MEGAUPLOAD 1.0)',

            'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0; SLCC1; .NET CLR 2.0.50727; Media Center PC 5.0; .NET CLR 3.0.04506; InfoPath.2; .NET CLR 1.1.4322)',

            'Mozilla/5.0 (Windows; U; Windows NT 5.1; bg; rv:1.8.1.3) Gecko/20070309 Firefox/2.0.0.3',

            'Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.8.0.11) Gecko/20070312 Firefox/1.5.0.11',

            'Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.8.1.3) Gecko/20070309 Firefox/2.0.0.3',

            'Mozilla/5.0 (Windows; U; Windows NT 5.1; de; rv:1.8.1.4) Gecko/20070515 Firefox/2.0.0.4',

            'Mozilla/5.0 (Windows; U; Windows NT 5.1; en-GB; rv:1.8.1.3) Gecko/20070309 Firefox/2.0.0.3',

            'Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.3) Gecko/20070309 Firefox/2.0.0.3',

            'Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.4) Gecko/20070515 Firefox/2.0.0.4',

            'Mozilla/5.0 (Windows; U; Windows NT 5.1; ru; rv:1.8.0.11) Gecko/20070312 Firefox/1.5.0.11',

            'Mozilla/5.0 (Windows; U; Windows NT 5.1; ru; rv:1.8.1.3) Gecko/20070309 Firefox/2.0.0.3',

            'Mozilla/5.0 (Windows; U; Windows NT 5.1; ru; rv:1.8.1.4) Gecko/20070515 Firefox/2.0.0.4',

            'Mozilla/5.0 (Windows; U; Windows NT 5.1; ru-RU; rv:1.7.12) Gecko/20050919 Firefox/1.0.7',

            'Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.8.0.1) Gecko/20060313 Fedora/1.5.0.1-9 Firefox/1.5.0.1 pango-text',

            'Mozilla/5.0 (X11; U; Linux i686; ru; rv:1.8) Gecko/20060112 ASPLinux/1.5-1.2am Firefox/1.5',

            'Opera/8.54 (Windows NT 5.1; U; en)',

            'Opera/9.00 (Windows NT 5.1; U; ru)',

            'Opera/9.01 (Windows NT 5.1; U; ru)',

            'Opera/9.02 (Windows NT 5.0; U; ru)',

            'Opera/9.02 (Windows NT 5.1; U; ru)',

            'Opera/9.02 (Windows NT 5.2; U; en)',

            'Opera/9.10 (Windows NT 5.1; U; ru)',

            'Opera/9.20 (Windows NT 5.1; U; en)',

            'Opera/9.20 (Windows NT 5.1; U; ru)',

            'Opera/9.21 (Windows NT 5.1; U; ru)',

            );

        $this->userAgent $browsers[array_rand($browsers)];

        return true;

    }

}

?>