From dc5b301adfd3f8091b96dd70ddc369c6da5740a3 Mon Sep 17 00:00:00 2001 From: RedHood Date: Wed, 16 Sep 2020 03:14:44 +1000 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=BD=D0=BE=D1=81=20?= =?UTF-8?q?=D0=B2=20=D0=BC=D0=B8=D0=BA=D1=80=D0=BE=D0=BF=D1=80=D0=B8=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + README.md | 22 + composer.json | 15 + composer.lock | 641 +++++++++++++++++++++++++ config/console.php | 18 + config/schedule.php | 6 + controllers/DumpController.php | 16 + models/Dump.php | 851 +++++++++++++++++++++++++++++++++ yii | 14 + 9 files changed, 1586 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/console.php create mode 100644 config/schedule.php create mode 100644 controllers/DumpController.php create mode 100644 models/Dump.php create mode 100644 yii diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70be326 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +vendor/ +dumps/ +runtime/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8818306 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# YII Dumper +*** +Parsing HTML pages at a given depth +```sh +$ yii dump https://domain.zone/foo $DEPTH $BUFFER $FORCE $EXTERNAL $ANOTHER_PATH +``` + - The larger the buffer size, the faster the program runs + - Logs are stored in the directory /runtime/logs + + +## Sample +```sh +$ yii dump https://domain.zone/foo 2 50 +``` +## Requirements + * **PHP ^7.2** + * **CURL** + +## TODO + 1. $searchExternal to $depthExterntal + 2. Fix processing of the link: zakupki.gov.ru/data/common-info.html?regNumber=0816500000619001511 + 3. The construction needs to be modified to check an existing tag diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d027cab --- /dev/null +++ b/composer.json @@ -0,0 +1,15 @@ +{ + "require": { + "php": "^7.2", + "ext-curl": "*", + "yiisoft/yii2": "~2.0.0", + "linslin/yii2-curl": "*", + "omnilight/yii2-scheduling": "*" + }, + "repositories": [ + { + "type": "composer", + "url": "https://asset-packagist.org" + } + ] +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..99e1458 --- /dev/null +++ b/composer.lock @@ -0,0 +1,641 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "6556bad2e96cc66bf6aed5a9fdd8cd57", + "packages": [ + { + "name": "bower-asset/inputmask", + "version": "3.3.11", + "source": { + "type": "git", + "url": "https://github.com/RobinHerbots/Inputmask.git", + "reference": "5e670ad62f50c738388d4dcec78d2888505ad77b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/RobinHerbots/Inputmask/zipball/5e670ad62f50c738388d4dcec78d2888505ad77b", + "reference": "5e670ad62f50c738388d4dcec78d2888505ad77b" + }, + "require": { + "bower-asset/jquery": ">=1.7" + }, + "type": "bower-asset", + "license": [ + "http://opensource.org/licenses/mit-license.php" + ] + }, + { + "name": "bower-asset/jquery", + "version": "3.5.1", + "source": { + "type": "git", + "url": "git@github.com:jquery/jquery-dist.git", + "reference": "4c0e4becb8263bb5b3e6dadc448d8e7305ef8215" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jquery/jquery-dist/zipball/4c0e4becb8263bb5b3e6dadc448d8e7305ef8215", + "reference": "4c0e4becb8263bb5b3e6dadc448d8e7305ef8215" + }, + "type": "bower-asset", + "license": [ + "MIT" + ] + }, + { + "name": "bower-asset/punycode", + "version": "v1.3.2", + "source": { + "type": "git", + "url": "git@github.com:bestiejs/punycode.js.git", + "reference": "38c8d3131a82567bfef18da09f7f4db68c84f8a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bestiejs/punycode.js/zipball/38c8d3131a82567bfef18da09f7f4db68c84f8a3", + "reference": "38c8d3131a82567bfef18da09f7f4db68c84f8a3" + }, + "type": "bower-asset" + }, + { + "name": "bower-asset/yii2-pjax", + "version": "2.0.7.1", + "source": { + "type": "git", + "url": "https://github.com/yiisoft/jquery-pjax.git", + "reference": "aef7b953107264f00234902a3880eb50dafc48be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/yiisoft/jquery-pjax/zipball/aef7b953107264f00234902a3880eb50dafc48be", + "reference": "aef7b953107264f00234902a3880eb50dafc48be" + }, + "require": { + "bower-asset/jquery": ">=1.8" + }, + "type": "bower-asset", + "license": [ + "MIT" + ] + }, + { + "name": "cebe/markdown", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/cebe/markdown.git", + "reference": "9bac5e971dd391e2802dca5400bbeacbaea9eb86" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cebe/markdown/zipball/9bac5e971dd391e2802dca5400bbeacbaea9eb86", + "reference": "9bac5e971dd391e2802dca5400bbeacbaea9eb86", + "shasum": "" + }, + "require": { + "lib-pcre": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "cebe/indent": "*", + "facebook/xhprof": "*@dev", + "phpunit/phpunit": "4.1.*" + }, + "bin": [ + "bin/markdown" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2.x-dev" + } + }, + "autoload": { + "psr-4": { + "cebe\\markdown\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Carsten Brandt", + "email": "mail@cebe.cc", + "homepage": "http://cebe.cc/", + "role": "Creator" + } + ], + "description": "A super fast, highly extensible markdown parser for PHP", + "homepage": "https://github.com/cebe/markdown#readme", + "keywords": [ + "extensible", + "fast", + "gfm", + "markdown", + "markdown-extra" + ], + "time": "2018-03-26T11:24:36+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v1.2.1", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "9504fa9ea681b586028adaaa0877db4aecf32bad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/9504fa9ea681b586028adaaa0877db4aecf32bad", + "reference": "9504fa9ea681b586028adaaa0877db4aecf32bad", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.0|~5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "time": "2017-01-23T04:29:33+00:00" + }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.13.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "08e27c97e4c6ed02f37c5b2b20488046c8d90d75" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/08e27c97e4c6ed02f37c5b2b20488046c8d90d75", + "reference": "08e27c97e4c6ed02f37c5b2b20488046c8d90d75", + "shasum": "" + }, + "require": { + "php": ">=5.2" + }, + "require-dev": { + "simpletest/simpletest": "dev-master#72de02a7b80c6bb8864ef9bf66d41d2f58f826bd" + }, + "type": "library", + "autoload": { + "psr-0": { + "HTMLPurifier": "library/" + }, + "files": [ + "library/HTMLPurifier.composer.php" + ], + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "time": "2020-06-29T00:56:53+00:00" + }, + { + "name": "linslin/yii2-curl", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/linslin/Yii2-Curl.git", + "reference": "38f2c28efb4c200b7ad1c2decb262806bb6b5cea" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/linslin/Yii2-Curl/zipball/38f2c28efb4c200b7ad1c2decb262806bb6b5cea", + "reference": "38f2c28efb4c200b7ad1c2decb262806bb6b5cea", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "yidas/yii2-bower-asset": "^2.0.13.1", + "yiisoft/yii2": "^2.0.0" + }, + "require-dev": { + "codeception/base": "^2.2.3", + "codeception/specify": "~0.4.3", + "codeception/verify": "~0.3.1", + "codeclimate/php-test-reporter": "dev-master", + "guzzlehttp/guzzle": ">=4.1.4 <7.0", + "mcustiel/phiremock-codeception-extension": "1.2.4" + }, + "type": "yii2-extension", + "autoload": { + "psr-4": { + "linslin\\yii2\\curl\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Gajsek", + "email": "info@linslin.org" + } + ], + "description": "Easy and nice cURL extension with RESTful support for Yii2", + "keywords": [ + " curl", + "extension", + "restful", + "yii2" + ], + "time": "2020-05-03T11:44:34+00:00" + }, + { + "name": "omnilight/yii2-scheduling", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/omnilight/yii2-scheduling.git", + "reference": "c7ffcd3b26143d47455ddd8e0cdf1611dbd9b74c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/omnilight/yii2-scheduling/zipball/c7ffcd3b26143d47455ddd8e0cdf1611dbd9b74c", + "reference": "c7ffcd3b26143d47455ddd8e0cdf1611dbd9b74c", + "shasum": "" + }, + "require": { + "dragonmantank/cron-expression": "1.*", + "php": ">=5.4.0", + "symfony/process": "2.6.* || 3.* || 4.*", + "yiisoft/yii2": "2.0.*" + }, + "require-dev": { + "phpunit/phpunit": "4.8.36" + }, + "suggest": { + "guzzlehttp/guzzle": "Required to use the thenPing method on schedules (~5.0)." + }, + "type": "yii2-extension", + "extra": { + "bootstrap": "omnilight\\scheduling\\Bootstrap" + }, + "autoload": { + "psr-4": { + "omnilight\\scheduling\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "authors": [ + { + "name": "Pavel Agalecky", + "email": "pavel.agalecky@gmail.com" + } + ], + "description": "Scheduling extension for Yii2 framework", + "keywords": [ + "cron", + "scheduling", + "yii" + ], + "time": "2020-03-31T21:02:32+00:00" + }, + { + "name": "symfony/process", + "version": "v4.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "65e70bab62f3da7089a8d4591fb23fbacacb3479" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/65e70bab62f3da7089a8d4591fb23fbacacb3479", + "reference": "65e70bab62f3da7089a8d4591fb23fbacacb3479", + "shasum": "" + }, + "require": { + "php": ">=7.1.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.4-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Process Component", + "homepage": "https://symfony.com", + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-07-23T08:31:43+00:00" + }, + { + "name": "yidas/yii2-bower-asset", + "version": "2.0.13.1", + "source": { + "type": "git", + "url": "https://github.com/yidas/yii2-bower-asset.git", + "reference": "056dd55087e0b945c01c91c99eb346ef6e28a42e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/yidas/yii2-bower-asset/zipball/056dd55087e0b945c01c91c99eb346ef6e28a42e", + "reference": "056dd55087e0b945c01c91c99eb346ef6e28a42e", + "shasum": "" + }, + "provide": { + "bower-asset/bootstrap": "*", + "bower-asset/inputmask": "*", + "bower-asset/jquery": "*", + "bower-asset/punycode": "*", + "bower-asset/typeahead.js": "*", + "bower-asset/yii2-pjax": "*" + }, + "type": "yii2-extension", + "autoload": { + "psr-4": { + "yidas\\yii2BowerAsset\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Bower Assets for Yii 2 app provided via Composer repository", + "keywords": [ + "bower", + "bower asset", + "framework", + "yii2" + ], + "time": "2019-09-19T11:33:03+00:00" + }, + { + "name": "yiisoft/yii2", + "version": "2.0.38", + "source": { + "type": "git", + "url": "https://github.com/yiisoft/yii2-framework.git", + "reference": "fd01e747cc66a049ec105048f0ab8dfbdf60bf4b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/yiisoft/yii2-framework/zipball/fd01e747cc66a049ec105048f0ab8dfbdf60bf4b", + "reference": "fd01e747cc66a049ec105048f0ab8dfbdf60bf4b", + "shasum": "" + }, + "require": { + "bower-asset/inputmask": "~3.2.2 | ~3.3.5", + "bower-asset/jquery": "3.5.*@stable | 3.4.*@stable | 3.3.*@stable | 3.2.*@stable | 3.1.*@stable | 2.2.*@stable | 2.1.*@stable | 1.11.*@stable | 1.12.*@stable", + "bower-asset/punycode": "1.3.*", + "bower-asset/yii2-pjax": "~2.0.1", + "cebe/markdown": "~1.0.0 | ~1.1.0 | ~1.2.0", + "ext-ctype": "*", + "ext-mbstring": "*", + "ezyang/htmlpurifier": "~4.6", + "lib-pcre": "*", + "php": ">=5.4.0", + "yiisoft/yii2-composer": "~2.0.4" + }, + "bin": [ + "yii" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "yii\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Qiang Xue", + "email": "qiang.xue@gmail.com", + "homepage": "http://www.yiiframework.com/", + "role": "Founder and project lead" + }, + { + "name": "Alexander Makarov", + "email": "sam@rmcreative.ru", + "homepage": "http://rmcreative.ru/", + "role": "Core framework development" + }, + { + "name": "Maurizio Domba", + "homepage": "http://mdomba.info/", + "role": "Core framework development" + }, + { + "name": "Carsten Brandt", + "email": "mail@cebe.cc", + "homepage": "http://cebe.cc/", + "role": "Core framework development" + }, + { + "name": "Timur Ruziev", + "email": "resurtm@gmail.com", + "homepage": "http://resurtm.com/", + "role": "Core framework development" + }, + { + "name": "Paul Klimov", + "email": "klimov.paul@gmail.com", + "role": "Core framework development" + }, + { + "name": "Dmitry Naumenko", + "email": "d.naumenko.a@gmail.com", + "role": "Core framework development" + }, + { + "name": "Boudewijn Vahrmeijer", + "email": "info@dynasource.eu", + "homepage": "http://dynasource.eu", + "role": "Core framework development" + } + ], + "description": "Yii PHP Framework Version 2", + "homepage": "http://www.yiiframework.com/", + "keywords": [ + "framework", + "yii2" + ], + "funding": [ + { + "url": "https://github.com/yiisoft", + "type": "github" + }, + { + "url": "https://opencollective.com/yiisoft", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/yiisoft/yii2", + "type": "tidelift" + } + ], + "time": "2020-09-14T21:52:10+00:00" + }, + { + "name": "yiisoft/yii2-composer", + "version": "2.0.10", + "source": { + "type": "git", + "url": "https://github.com/yiisoft/yii2-composer.git", + "reference": "94bb3f66e779e2774f8776d6e1bdeab402940510" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/yiisoft/yii2-composer/zipball/94bb3f66e779e2774f8776d6e1bdeab402940510", + "reference": "94bb3f66e779e2774f8776d6e1bdeab402940510", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 | ^2.0" + }, + "require-dev": { + "composer/composer": "^1.0 | ^2.0@dev", + "phpunit/phpunit": "<7" + }, + "type": "composer-plugin", + "extra": { + "class": "yii\\composer\\Plugin", + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "yii\\composer\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Qiang Xue", + "email": "qiang.xue@gmail.com" + }, + { + "name": "Carsten Brandt", + "email": "mail@cebe.cc" + } + ], + "description": "The composer plugin for Yii extension installer", + "keywords": [ + "composer", + "extension installer", + "yii2" + ], + "funding": [ + { + "url": "https://github.com/yiisoft", + "type": "github" + }, + { + "url": "https://opencollective.com/yiisoft", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/yiisoft/yii2-composer", + "type": "tidelift" + } + ], + "time": "2020-06-24T00:04:01+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^7.2", + "ext-curl": "*" + }, + "platform-dev": [], + "plugin-api-version": "1.1.0" +} diff --git a/config/console.php b/config/console.php new file mode 100644 index 0000000..3df4fab --- /dev/null +++ b/config/console.php @@ -0,0 +1,18 @@ + 'Dumper', + 'controllerNamespace' => 'app\controllers', + 'basePath' => dirname(__DIR__), + 'params' => [ + 'basePath' => dirname(__DIR__) . '/dumps', + 'pagesPath' => '/pages', + 'docsPath' => '/data', + 'imgPath' => '/img', + 'cssPath' => '/css', + 'jsPath' => '/js', + 'externalLinksPath' => '/sites', + 'timezone' => 'Asia/Vladivostok', + 'useragent' => 'Dumper', + 'regBlackList' => '(instagram|whatsapp|appdv)' + ] +]; \ No newline at end of file diff --git a/config/schedule.php b/config/schedule.php new file mode 100644 index 0000000..9bc8757 --- /dev/null +++ b/config/schedule.php @@ -0,0 +1,6 @@ +command('dump http://expertise.27pro.ru// 5 50')->dailyAt('03:00'); diff --git a/controllers/DumpController.php b/controllers/DumpController.php new file mode 100644 index 0000000..c30df47 --- /dev/null +++ b/controllers/DumpController.php @@ -0,0 +1,16 @@ +download($link, $depth, $buffer, $force, $searchExternal, $path); + + return 0; + } +} diff --git a/models/Dump.php b/models/Dump.php new file mode 100644 index 0000000..aaa2931 --- /dev/null +++ b/models/Dump.php @@ -0,0 +1,851 @@ + надо доработать на проверку уже существующего тега + */ +class Dump extends \yii\base\Component +{ + /** + * Глубина поиска страниц относительно первичной + */ + protected $depth = 0; + + /** + * Буфер страниц для скачивания + */ + protected $buffer; + + /** + * Флаг форсированного выполнения (перезаписи файлов) + */ + protected $force; + + /** + * Путь для сохранения файлов + */ + protected $path; + + /** + * Буфер страниц для скачивания + */ + protected $searchExternal; + + /** + * Регистр обработанных ссылок + * + * 'Ссылка' => [ + * [0] => 'URN файла' + * [1] => 'URL файла' + * [2] => 'URI файла для конвертации страниц' + * ] + */ + protected $links = []; + + /** + * Буфер скачанных файлов + * + * 'Файл (URN)' => [ + * [0] => 'Тип' + * [1] => 'Данные' + * ] + * + * Типы: + * [0] - HTML страница + * [1] - документ ('.css', '.js', '.png'...) + */ + protected $filesBuffer = []; + + /** + * Регистр сохранённых файлов + * + * 'Файл (URN)' => [ + * [0] => 'Тип' + * [1] => 'Данные' + * ] + * + * Типы: + * [0] - HTML страница + * [1] - документ ('.css', '.js', '.png'...) + */ + protected $files = []; + + /** + * Блокировка циклов + * + * Указывает работает основное скачивание или рекурсивное + */ + protected $subdownload = false; + + /** + * Количество новых найденных ссылок + */ + protected $linksNew = 0; + + /** + * Запрашиваемая ссылка + */ + protected $target; + + /** + * SCHEME/PROTOCOL запроса + */ + public $connectionProtocol; + + /** + * HOST запроса + */ + public $connectionHost; + + /** + * Собранная информация о выполнении + * + * [0] => 'Запрошенный URI' + * [1] => 'Статус выполнения (завершен или ошибка)' + * [2] => [ + * [0] => 'Количество найденных ссылок' + * [1] => 'Количество найденных ссылок без дубликатов' + * [2] => 'Количество обработанных ссылок' + * ] + * [3] => [ + * [0] => 'Количество найденных HTML страниц' + * [1] => 'Количество конвертированных страниц' + * ] + * [4] => [ + * [0] => 'Количество найденных документов (.png, .css, .pdf)' + * [1] => 'удалено' + * [2] => 'Найдено: Изображения', + * [3] => 'Найдено: Видеозаписи', + * [4] => 'Найдено: Аудиозаписи', + * [5] => 'Найдено: CSS', + * [6] => 'Найдено: JS', + * [7] => 'Найдено: Не опознано', + * ] + */ + public $statistics = [ + '', + 1, + [ + 0, + 0, + 0 + ], + [ + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ] + ]; + + /** + * Время начала выполнения скрипта + * + * Используется для вычисления времени выполнения и записи в статистику + */ + public $timeStart; + + public function __construct() + { + // Начало отсчёта синтетического теста времени выполнеия для записи в статистику + $this->timeStart = microtime(true); + + // if (YII_DEBUG) { + register_shutdown_function(array(&$this, 'saveStatistics')); + // } + } + + /** + * Скачивание страницы + * + * @param string $link Ссылка + * @param int $depth Глубина скачивания вложенных ссылок + * @param int $buffer Буфер файлов + * @param bool $force Флаг форсированного выполнения (с перезаписью существующих файлов) + * @param string $path Свой путь для сохранения + * @param bool $searchExternal Флаг поиска ссылок во внешних сайтах + * + * @return Dump + */ + public function download($link, $depth = 0, $buffer = 0, $force = false, $searchExternal = false, $path = '') + { + if (!$link = $this->filterLink($link)) { + // Если ссылка не прошла фильтрацию + return; + } + + // Инициализация свойств + if (!isset($this->buffer)) { + $this->buffer = $buffer; + } + + if (!isset($this->force)) { + $this->force = $force; + } + + if (!isset(Yii::$app->params['basePath'])) { + Yii::$app->params['basePath'] = $path; + } + + if (!isset($this->searchExternal)) { + $this->searchExternal = $searchExternal; + } + + if (!isset($this->connectionProtocol)) { + // Проверка наличия domain.zone в ссылке на подобие: 'https://domain.zone/foo/bar' + preg_match_all('/(.*)?:?(\/\/|\\\\)(.*)((\/|\\\|$).*$)/U', $link, $linkMatch); + + $this->connectionProtocol = $linkMatch[1][0] ?? $_SERVER['REQUEST_SCHEME'] ?? $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? 'http'; + } + + if (!isset($this->connectionHost)) { + // Проверка наличия domain.zone в ссылке на подобие: 'https://domain.zone/foo/bar' + preg_match_all('/(.*)?:?(\/\/|\\\\)(.*)((\/|\\\|$).*$)/U', $link, $linkMatch); + + // Проверка наличия domain.zone в ссылке на подобие: 'domain.zone/' или 'domain.zone' или 'subdomain.domain.zone' + preg_match_all('/^([^\\\|\/|\\s]+\.[^\\\|\/|\\s]+)(\\\|\/)?$/', $link, $domainMatch); + + $this->connectionHost = $linkMatch[3][0] ?? $domainMatch[1][0] ?? $_SERVER['REQUEST_HOST'] ?? $_SERVER['HTTP_HOST'] ?? '127.0.0.1'; + } + + // Обработка буфера (это весь его код) + if (count($this->filesBuffer) >= $this->buffer) { + $this->save(); + } + + // Если скачивание является первым (ручной запрос) + if (!isset($this->target)) { + // Инициализация стартовой ссылки + $this->target = $link; + + // Добавление стартовой ссылки в регистр + array_unshift($this->links, $link); + + // Инициализация цели в статистике + // if (YII_DEBUG) { + $this->statistics[0] = $this->target; + // } + + // Прибавление стартовой ссылки для вывода в статистике + // if (YII_DEBUG) { + // if (!isset($this->statistics[2][1])) { + // $this->statistics[2][1] = 0; + // } + // $this->statistics[2][1]++; + $this->statistics[2][1] = 1; + // } + } else if ($this->subdownload && $depth === 0) { + // Иначе если это дополнительное скачивание и глубина равна нулю + + $this->linksNew++; + // Добавление стартовой ссылки в регистр + array_unshift($this->links, $link); + } + + // Обработка стартовой ссылки + if (isset($this->target)) { + // Внимание: $targetMatch не очищается и используется в коде ниже + preg_match_all('/(.*)?:?(\/\/|\\\\)(.*)((\/|\\\|$).*$)/U', $this->target, $targetMatch); + } + + if (preg_match('/^[^\/|\\\]+\..+$/', $link)) { + // Паттерн: 'domain.zone', 'domain.zone/foo/bar/index.html' + $request = ($this->connectionProtocol ?? 'http') . '://' . $link; + } else if (preg_match_all('/(^http.*(\/\/|\\\\)|^(\/\/|\\\\))(.*)(\/|\\\|$)(.*$)/iU', $link, $match)) { + // Паттерн: 'https://domain.zone/foo/index.html', '//domain.zone/foo' + $request = ($this->connectionProtocol ?? 'http') . '://' . $match[4][0] . '/' . $match[6][0]; + } else if (preg_match('/(^\/[^\/\\\\\s]+[^\s]*$|^\\\[^\/\\\\\s]+[^\\s]*$|^\/$|^\\\$)/', $link)) { + // Паттерн: '/', '/foo/bar', '/foo/bar/index.html' + $request = ($this->connectionProtocol ?? 'http') . '://' . ($targetMatch[3][0] ?? $this->connectionHost) . $link; + } else if (preg_match('/(^[^\/\\\\\s\.]+(\/|\\\|$)([^\/\\\\\s]*$|[^\/\\\\\s]+(\/|\\\).*))/', $link)) { + // Паттерн: 'foo/index.html', 'foo/bar/index.html', 'foo', 'foo/' + // Не помню почему регулярное выражение такое сложное, разбираться сейчас не стал, так как главное, что работает + $request = ($this->connectionProtocol ?? 'http') . '://' . ($targetMatch[3][0] ?? $this->connectionHost) . '/' . $link; + } else { + unset($this->links[$link]); + // throw new Exception('Не удалось идентифицировать запрос'); + return $this; + } + unset($match); // Очистка на всякий случай, так как переменные остаются + + // Выполнение запроса + $this->filesBuffer[$link][1] = (new Curl())->setOption(CURLOPT_RETURNTRANSFER, true) + ->setOption(CURLOPT_FOLLOWLOCATION, true) + ->setOption(CURLOPT_SSL_VERIFYPEER, true) + ->setOption(CURLOPT_USERAGENT, Yii::$app->params['useragent']) + ->get($request); + + if (preg_match('/(https?:(\/\/|\\\\).+(\/|\\\).+|^(\/|\\\).+)(\.(?!php|htm)[A-z0-9]+)[^\/\\\s]*$/i', $link)) { + // Если ссылка является документом ('.css', '.js', '.png'...) + $this->filesBuffer[$link][0] = 1; + } else { + // Иначе расценивается как HTML страница для продолжения поиска + preg_match_all('/(.*)?:?(\/\/|\\\\)(.*)((\/|\\\|$).*$)/U', $link, $linkMatch); + + // Если это внутренний URL или domain.zone цели сходится с domain.zone обрабатываемой ссылки или есть разрешение на проход внешних ссылок + if (empty($linkMatch[3][0]) || $targetMatch[3][0] === $linkMatch[3][0] || $this->searchExternal === true) { + $this->filesBuffer[$link][0] = 0; + $file = $this->filesBuffer[$link][1]; + } else { + $this->filesBuffer[$link][0] = 0; + } + } + unset($targetMatch, $linkMatch, $link); // Очистка на всякий случай, так как переменные остаются + + // Извлечение ссылок из страницы по свойствам href='' и src='' + // Единственное место где добавляются найденные ссылки + if (!empty($file) && $depth > 0 && preg_match_all('/(href|src)\\s?=\\s*[\"\']?((?!(\"|\'))(?!tel)(?!mailto)[^\"\']+)[\"\']/i', $file, $match)) { + // Если файл скачан, глубина больше нуля и были найдены ссылки + + // Прибавление количества новых ссылок для обработки + $this->linksNew += count($match[2]); + + // Прибавление количества новых ссылок для вывода в статистике + // if (YII_DEBUG) { + $this->statistics[2][0] += count($match[2]); + // } + + // Добавление ссылок в общий регистр + foreach ($match[2] as $link) { + if (!$link = $this->filterLink($link)) { + // Если ссылка не прошла фильтрацию + continue; + } + array_unshift($this->links, $link); + } + } + unset($link, $file, $match); // Очистка на всякий случай, так как переменные остаются + + // Конвертация ссылок + if (isset($this->links) && $this->linksNew) { + // Если глубина больше ноля, ссылки существуют и счетчик новых ссылок больше ноля + + $this->convertLinks(); + } + + if (!$this->subdownload) { + // Если это не дополнительное скачивание и текущая глубина не равна нулю + + // Устанавливается для того, чтобы работать в цикле + $this->depth = $depth; + + // Скачиваем найденные страницы по установленной глубине поиска + while ($this->depth-- > 0) { + foreach ($this->links as $link => $type) { + $this->subdownload = true; + $this->download($link, $this->depth); + $this->subdownload = false; + } + + $this->convertFiles($this->save(), true); + } + unset($link, $type); + } + + // Сохранение остатков ссылок после обработки + $this->save(); + + // Конвертация сохранённых файлов + $this->convertFiles(); + + return $this; + } + + /** + * Сохранение страницы + * + * Получает на вход файлы из буфера и сохраняет на диске + * На выходе будут перенесены в массив $this->files + * + * @return array + */ + public function save($files = null) + { + if (empty($files)) { + $files = &$this->filesBuffer; + } + + if (isset($this->links)) { + $this->convertLinks(); + } + + $savedFiles = []; + + foreach ($files as $link => $file) { + if (!empty($this->links[$link][1]) && !preg_match('/^(\/|\\\)/', $this->links[$link][1])) { + // Если в начале ссылки нет слеша и ссылка не, то добавить слеш + $this->links[$link][1] = '/' . $this->links[$link][1]; + } + + // if (!isset($this->links[$link])) { + // $this->convertLinks(); + // } + + // Проверка существования каталога и его создание + if (/** isset($this->links[$link] && */ !file_exists(Yii::$app->params['basePath'] . $this->links[$link][1])) { + if (!mkdir(Yii::$app->params['basePath'] . $this->links[$link][1], 0755, true)) { + // throw new Exception('Не удалось создать каталог'); + } + } + + // Сохранение файла + if (!file_exists(Yii::$app->params['basePath'] . $this->links[$link][1] . $this->links[$link][0]) || $this->force) { + if (file_put_contents(Yii::$app->params['basePath'] . $this->links[$link][1] . $this->links[$link][0], $file[1])) { + $this->files[$link][0] = $file[0]; + $this->files[$link][1] = $file[1]; + } else { + // throw new Exception('Не удалось сохранить файл'); + } + } + $savedFiles[$link] = $files[$link]; + unset($files[$link]); + } + unset($link, $file); // Очистка на всякий случай, так как переменные остаются + + // Указание сборщику статистики, что парсер успешно завершил свою работу + // if (YII_DEBUG) { + $this->statistics[1] = 0; + // } + + return $savedFiles; + } + + /** + * Сохранить статистику + * + * Возвращает статус сохранения (true/false) + * + * @return bool + */ + public function saveStatistics() + { + // Запись времени окончания работы скрипта + $timeFinish = microtime(true); + + $i = new DateTime(Yii::$app->params['timezone'] ?? 'Europe/Moscow'); + + $date = date_format($i, 'Y-m-d'); + $dateFull = date_format($i, 'Y.m.d H:i:s'); + + $request = $this->statistics[0] ?? 'Ошибка'; + $time = ($timeFinish - $this->timeStart) ?? 'Ошибка'; + $status = $this->statistics[1] === 0 ? 'Успех' : 'Ошибка'; + + $linksCount = $this->statistics[2][0] ?? 'Ошибка'; + $linksProcessed = $this->statistics[2][1] ?? 'Ошибка'; + $linksProcessedReal = $this->statistics[2][2] ?? 'Ошибка'; + + $pagesCount = $this->statistics[3][0] ?? 'Ошибка'; + $pagesProcessed = $this->statistics[3][1] ?? 'Ошибка'; + + $filesCount = $this->statistics[4][0] ?? 'Ошибка'; + + $imagesCount = $this->statistics[4][2] ?? 'Ошибка'; + $videosCount = $this->statistics[4][3] ?? 'Ошибка'; + $audiosCount = $this->statistics[4][4] ?? 'Ошибка'; + $cssCount = $this->statistics[4][5] ?? 'Ошибка'; + $jsCount = $this->statistics[4][6] ?? 'Ошибка'; + $unidentifiedCount = $this->statistics[4][7] ?? 'Ошибка'; + + if (!file_exists(Yii::getAlias('@runtime/logs'))) { + if (!mkdir(Yii::getAlias('@runtime/logs'), 0755, true)) { + // throw new Exception('Не удалось создать каталог'); + } + } + $file = fopen(Yii::getAlias('@runtime/logs') . '/' . $date . uniqid('_DUMPER_', true) . '.log', 'a+'); + + if (!fwrite($file, <<links; + } + + while ($this->linksNew >= 0) { + // Инициализация ссылки и копии для будущего поиска в файлах + if (!array_key_exists($this->linksNew, $links)) { + // Подготовка к следующей итерации цикла + $this->linksNew--; + continue; + } + $link = $rawLink = $links[$this->linksNew]; + + if (is_array($link)) { + // Если это уже конвертированная ссылка + continue; + } + + $uri = $this->initLink($link); + + // !!!!!!!!!!!!!!!!!!!!!!! + preg_match_all('/\/\/(.*)((\/|$).*$)/U', $uri, $uriMatch); + preg_match_all('/\/\/(.*)((\/|$).*$)/U', $this->target, $targetMatch); + + if ($targetMatch[1][0] === $uriMatch[1][0]) { + $location = ''; + } else { + $location = Yii::$app->params['externalLinksPath'] . '/' . $uriMatch[1][0]; + } + unset($uriMatch, $targetMatch); + + // Инициализация ссылки + if ($uri === $this->target || $uri . '/' === $this->target || $uri === $this->target . '/' || $uri . '\\' === $this->target || $uri === $this->target . '\\' || $uri === '/' || $uri === '\\') { + // Если это первый запуск (запрошенная, главная ссылка) + + // Создание ссылки + $links[$rawLink][0] = '/index.html'; + $links[$rawLink][1] = ''; + $links[$rawLink][2] = '/index.html'; + } else if (preg_match('/\\.css/i', $uri)) { + // Если это CSS файл + + // Получение последнего каталога (имени файла с расширением), например: '/index.html' + if (preg_match_all('/[^\/\\\\\s]+$/', $uri, $file)) { + // Создание ссылки + $links[$rawLink][0] = '/' . $file[0][0]; + $links[$rawLink][1] = $location . Yii::$app->params['cssPath']; + $links[$rawLink][2] = $links[$rawLink][1] . $links[$rawLink][0]; + } + + // Обновление статистики + // if (YII_DEBUG) { + $this->statistics[4][0]++; + $this->statistics[4][5]++; + // } + } else if (preg_match('/\\.js/i', $uri)) { + // Если это JS файл + + // Получение последнего каталога (имени файла с расширением), например: '/index.html' + if (preg_match_all('/[^\/\\\\\s]+$/', $uri, $file)) { + // Создание ссылки + $links[$rawLink][0] = '/' . $file[0][0]; + $links[$rawLink][1] = $location . Yii::$app->params['jsPath']; + $links[$rawLink][2] = $links[$rawLink][1] . $links[$rawLink][0]; + } + + // Обновление статистики + // if (YII_DEBUG) { + $this->statistics[4][0]++; + $this->statistics[4][6]++; + // } + } else if (preg_match('/(\\.png|\\.jpeg|\\.jpg|\\.webp|\\.gif|\\.svg|\\.ico)/i', $uri)) { + // Если это изображение + + // Получение последнего каталога (имени файла с расширением), например: '/index.html' + if (preg_match_all('/[^\/\\\\\s]+$/', $uri, $file)) { + // Создание ссылки + $links[$rawLink][0] = '/' . $file[0][0]; + $links[$rawLink][1] = $location . Yii::$app->params['imgPath']; + $links[$rawLink][2] = $links[$rawLink][1] . $links[$rawLink][0]; + } + + // Обновление статистики + // if (YII_DEBUG) { + $this->statistics[4][0]++; + $this->statistics[4][2]++; + // } + } else if (preg_match('/(https?:(\/\/|\\\\).+(\/|\\\).+|^(\/|\\\).+)(\.(?!php|htm)[A-z0-9]+)[^\/\\\s]*$/i', $uri)) { + // Если это неопознанный документ (очень затратное выражение, но по другому никак) + + // Получение последнего каталога (имени файла с расширением), например: '/index.html' + if (preg_match_all('/[^\/\\\\\s]+$/', $uri, $file)) { + // Создание ссылки + $links[$rawLink][0] = '/' . $file[0][0]; + $links[$rawLink][1] = $location . Yii::$app->params['docsPath']; + $links[$rawLink][2] = $links[$rawLink][1] . $links[$rawLink][0]; + } + + // Обновление статистики + // if (YII_DEBUG) { + $this->statistics[4][0]++; + $this->statistics[4][7]++; + // } + } else if (preg_match_all('/(\/\/|\\\\)(.*)((\/|\\|$).*$)/U', $uri, $uriMatch)) { + // Иначе, если это обрабатывается универсально или как HTML документ + + if (isset($uriMatch[3][0])) { + // Если есть путь к файлу, например 'https://domain.zone/это/обязательно/index.html' + if (preg_match_all('/^([^\/\\\\\s\.]*[^\\s\.]+)([^\/\\\]*\.html|[^\/\\\]*\.php|[^\/\\\]*\.htm)?$/U', $uriMatch[3][0], $uriSplit)) { + // Если в URI не найден URN (файл с расширением, например: 'index.php') + + if (empty($uriSplit[2][0])) { + $uriSplit[2][0] = '/index.html'; + } + + // Создание ссылки + $links[$rawLink][0] = $uriSplit[2][0]; + $links[$rawLink][1] = $location . Yii::$app->params['pagesPath'] . $uriSplit[1][0]; + $links[$rawLink][2] = $links[$rawLink][1] . $links[$rawLink][0]; + } + } else { + // Иначе обрабатывается как пустая ссылка, например 'https://domain.zone' + + // Создание ссылки + $links[$rawLink][0] = '/index.html'; + $links[$rawLink][1] = $location . Yii::$app->params['pagesPath'] . '/' . $uriMatch[1][0]; + $links[$rawLink][2] = $links[$rawLink][1] . '/index.html'; + } + + // Прибавление количеству найденных страниц + // if (YII_DEBUG) { + $this->statistics[3][0]++; + // } + } else { + // Иначе, ссылку не удалось инициализировать, пропуск + + // throw new Exception('Не удалось идентифицировать сохранённую ссылку: '.$link) + } + + // Удаление обработанной ссылки и оставшихся переменных + unset($links[$this->linksNew], $rawLink, $location, $uriSplit, $match, $file, $uri); + + // Прибавление количеству обработанных ссылок + // if (YII_DEBUG) { + $this->statistics[2][1]++; + // } + + // Подготовка к следующей итерации цикла + $this->linksNew--; + } + + // Количество обработанных ссылок без дубликатов + // if (YII_DEBUG) { + // Количество обработанных ссылок без дубликатов + $this->statistics[2][2] = count($this->links); + // } + + return $links; + } + + /** + * Конвертер страниц + * + * Преобразует ссылки в тексте (HTML документе) + * Возвращает массив не найденных файлов + * + * @param array $files Файлы для конвертации + * + * @return array + */ + private function convertFiles($files = null) + { + if (empty($files)) { + $files = &$this->files; + } + + foreach ($files as $link => &$file) { + if ($file[0] === 0 && isset($this->links[$link]) && file_exists(Yii::$app->params['basePath'] . $this->links[$link][1] . $this->links[$link][0]) && $content = file_get_contents(Yii::$app->params['basePath'] . $this->links[$link][1] . $this->links[$link][0])) { + // Если метаданные файла указывают, что он является HTML документом + + if (preg_match_all('/(href|src)\\s?=\\s*[\"\']?((?!(\"|\'))(?!tel)(?!mailto)[^\"\']+)[\"\']/i', $content, $match)) { + // Если найдены ссылки + + // Конвертация + foreach ($match[2] as $rawLink) { + if (!$rawLink = $this->filterLink($rawLink)) { + // Если ссылка не прошла фильтрацию + continue; + } + + if (!array_key_exists($rawLink, $this->links)) { + continue; + } + + $content = preg_replace('/(\"|\')' . preg_quote($rawLink, '/') . '(\"|\')/', '".' . $this->links[$rawLink][2] . '"', $content); + } + + // Инъекция тега в страницу, чтобы работали относительные пути + if (preg_match_all('/(.*)(.*)/si', $content, $contentMatch)) { + // Если удалось найти в странице + + // Определяем вложенность страницы + if (preg_match_all('/([^\\\|\/|\\s]+)/', $this->links[$link][1], $urlMatch)) { + $catalogsDepth = count($urlMatch[1]); + } else { + // throw new Exception('Ошибка при инъекции : не удалось посчитать вложенность страницы'); + } + + $content = $contentMatch[1][0] . "\n'. $contentMatch[2][0]; + } + + // Сохранение файла + if (file_put_contents(Yii::$app->params['basePath'] . $this->links[$link][1] . $this->links[$link][0], $content)) { + unset($this->files[$link]); + + // Прибавление количеству конвертированных страниц + // if (YII_DEBUG) { + $this->statistics[3][1]++; + // } + } else { + // throw new Exception('Не удалось сохранить файл'); + } + } + } else if ($file[0] !== 0) { + // Если файл не является HTML документом + + unset($this->files[$link]); + } else { + // Иначе воспринимается как не HTML документ, который не требует конвертацию + continue; + } + } + unset($link, $file); // Очистка на всякий случай, так как переменные остаются + + return $files; + } + + /** + * Фильтрация ссылки + * + * Перед инициализацией ссылка проверяется фильтрами + * + * @return ?string + */ + private function filterLink($link) + { + // Проверка существования URL в чёрном списке + if (!empty(Yii::$app->params['regBlackList']) && preg_match('/' . Yii::$app->params['regBlackList'] . '/', $link)) { + return null; + } + + // Преобразование слешей в Unix стиль, унификация + $link = preg_replace('/\\\/', '/', $link); + + return $link; + } + + /** + * Инициализация ссылки + * + * Подготовка к конвертации + * + * @return string + */ + private function initLink($link) + { + // Подготовка ссылок перед обработкой + // Разбиение ссылки на каталоги: 'https://domain.zone/foo/bar/index.html' на 'https:', 'domain.zone', 'foo', 'bar', 'index.html' + if (preg_match_all('/([^\\\|\/|\\s]+)/', $link, $uriMatch)) { + // Определение того, что URI является полноценным и имеет протокол подключения, например: 'https:', 'ssh:', 'mail:' + if (preg_match('/^.*:$/', $uriMatch[0][0])) { + $uri = $uriMatch[0][0] . '//' . $uriMatch[0][1]; + $uriMatch[0] = array_slice($uriMatch[0], 2); + } else { + $uri = $this->connectionProtocol . ':' . '//' . $this->connectionHost; + } + + // Замена всех фрагментов URI от символов, которые Windows не даёт записывать в именах файлов и каталогов на '@' + // На данный момент достаточно заменять все символы на один, так как обратная конвертация не потребуется, а шанс конфликта имён минимален + foreach ($uriMatch[0] as &$piece) { + $piece = preg_replace('/(\\\|\/|\:|\*|\?|\"|\<|\>|\|)/', '@', $piece); + } + unset($piece); + + // Сборка новой ссылки из фрагментов оригинальной + foreach ($uriMatch[0] as $piece) { + $uri .= '/' . $piece; + } + unset($piece); + + } else if ($link === '/' || $link === '\\') { + // Иначе, если ссылка ведёт на главную страницу сайта + + // [!!!] Это надо переработать, так как ссылка '/' можеть быть и на стороннем хосте [!!!] + $uri = $this->connectionProtocol . ':' . '//' . $this->connectionHost; + + // Иначе всё обрабатывается как ссылка на текущего хоста + } else { + if (!preg_match('/^(\/|\\\)/', $link)) { + $link = '/' . $link; + } + $link = preg_replace('/(\/|\\\)$/', '', $link); + + $uri = $this->connectionProtocol . ':' . '//' . $this->connectionHost . $link; + } + unset($link, $uriMatch, $site); + + return $uri; + } +} diff --git a/yii b/yii new file mode 100644 index 0000000..c438aaf --- /dev/null +++ b/yii @@ -0,0 +1,14 @@ +#!/usr/bin/env php +run(); +exit($exitCode);