├── .gitignore ├── LICENSE.txt ├── README.md ├── composer.json ├── config └── tntsearch.php ├── phpunit.xml ├── src ├── Console │ └── ImportCommand.php ├── Engines │ └── TNTSearchEngine.php ├── Highlighter.php ├── LumenServiceProvider.php ├── TNTSearchScoutServiceProvider.php ├── Tokenizers │ ├── JiebaTokenizer.php │ ├── PhpAnalysisTokenizer.php │ ├── ScwsTokenizer.php │ └── Tokenizer.php └── helpers.php └── tests └── TNTSearchEngineTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.lock 3 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | > 说明: `2.x` 版本只支持 `Laravel 5.5` 以上版本,`Laravel 5.5`以下版本请使用 [1.x版本](https://github.com/vanry/laravel-scout-tntsearch/tree/1.x)。 3 | 4 | ## 安装 5 | 6 | > 需安装并开启 `sqlite` 扩展 7 | 8 | ``` bash 9 | composer require vanry/laravel-scout-tntsearch 10 | ``` 11 | 12 | ### Laravel 13 | 14 | * 发布 `scout` 配置文件,已安装 `scout` 可省略。 15 | 16 | ```bash 17 | php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider" 18 | ``` 19 | 20 | * 如需修改 `tntsearch` 默认配置,发布 `tntsearch` 配置文件。 21 | 22 | ```bash 23 | php artisan vendor:publish --provider="Vanry\Scout\TNTSearchScoutServiceProvider" 24 | ``` 25 | 26 | ### Lumen 27 | 28 | `Lumen` 需将服务提供者添加到 `bootstrap/app.php` 29 | 30 | ```php 31 | // bootstrap/app.php 32 | 33 | // 取消注释 34 | $app->withFacades(); 35 | $app->withEloquent() 36 | 37 | // 注意先后顺序 38 | $app->register(Vanry\Scout\LumenServiceProvider::class); 39 | $app->register(Laravel\Scout\ScoutServiceProvider::class); 40 | ``` 41 | 42 | 在根目录中创建 `config` 文件夹, 将 `laravel scout` 配置文件 `scout.php` 复制到 `config` 中。 43 | 44 | 如需修改 `tntsearch` 默认配置,则将配置文件 `tntsearch.php` 复制 `config` 中进行修改。 45 | 46 | ### 启用 47 | 48 | 在 `.env` 文件中添加 49 | 50 | ```bash 51 | SCOUT_DRIVER=tntsearch 52 | ``` 53 | 54 | ## 用法 55 | 56 | 1. 模型添加 `Searchable Trait` 57 | 58 | ```php 59 | namespace App; 60 | 61 | use Illuminate\Database\Eloquent\Model; 62 | use Laravel\Scout\Searchable; 63 | 64 | class Post extends Model 65 | { 66 | use Searchable; 67 | 68 | /** 69 | * Get the indexable data array for the model. 70 | * 71 | * @return array 72 | */ 73 | public function toSearchableArray() 74 | { 75 | return [ 76 | 'id' => $this->id, 77 | 'title' => $this->title, 78 | 'body' => strip_tags($this->body), 79 | ]; 80 | } 81 | } 82 | ``` 83 | 84 | 2. 导入模型 创建索引 85 | 86 | ```php 87 | # scout 命令 88 | php artisan scout:import 'App\Post' 89 | 90 | # tntsearch 命令, 性能更好 91 | php artisan tntsearch:import 'App\Post' 92 | ``` 93 | 94 | 3. 使用索引进行搜索 95 | 96 | ```php 97 | Post::search('laravel教程')->get(); 98 | ``` 99 | 100 | 101 | ## 中文分词 102 | 103 | 目前支持 `scws`, `jieba` 和 `phpanalysis` 中文分词,默认使用 `phpanalysis` 分词。 104 | 105 | ### 对比 106 | 107 | * `scws` 是用 `C` 语言编写的 `php` 扩展,性能最好,分词效果好,但不支持 `Windows` 系统。 108 | * `jieba` 为 `python` 版本结巴分词的 `php` 实现,分词效果最好,尤其是新词发现,不足之处是性能较差,占用内存大。 109 | * `phpanalysis` 是 `php` 编写的一款轻量分词器,分词效果不错,性能介于 `scws` 和 `jieba` 两者之间。 110 | 111 | ### 安装 112 | 113 | 使用 `scws` 或者 `jieba`,需安装对应的分词驱动。 114 | 115 | - **scws** 116 | 117 | ```bash 118 | composer require vanry/scws 119 | ``` 120 | 121 | - **jieba** 122 | 123 | ```bash 124 | composer require fukuball/jieba-php 125 | ``` 126 | 127 | 在 `.env` 文件中配置 128 | 129 | ```bash 130 | # scws 131 | TNTSEARCH_TOKENIZER=scws 132 | 133 | # jieba 134 | TNTSEARCH_TOKENIZER=jieba 135 | ``` 136 | 137 | ### 问题 138 | 139 | 使用 `jieba` 分词可能会出现内存分配不足的错误信息: 140 | 141 | > PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 20480 bytes) 142 | 143 | 在代码中增加内存限制即可 144 | 145 | ```php 146 | ini_set('memory_limit', '1024M'); 147 | ``` 148 | 149 | ## 高亮 150 | 151 | 默认使用 `em` 作为高亮 `html` 标签,在 `css` 中设置高亮样式即可,也可以自定义高亮标签。 152 | 153 | * **@highlight 指令** 154 | 155 | ``` 156 | @highlight($text, $query, $tag); 157 | ``` 158 | > * $text: 要高亮的字段 159 | > * $query: 搜索词 160 | > * $tag: 高亮的 html 标签 161 | 162 | ```php 163 | // 高亮 title 字段 164 | @highlight($post->title, $query); 165 | 166 | // 用 strong 作为高亮标签 167 | @highlight($post->title, $query, 'strong'); 168 | ``` 169 | 170 | * **highlight 帮助函数** 171 | 172 | ```php 173 | highlight($post->title, $query); 174 | 175 | highlight($post->title, $query, 'strong'); 176 | ``` 177 | 178 | * **Highlighter 对象** 179 | 180 | ```php 181 | use Vanry\Scout\Highlighter; 182 | 183 | // ... 184 | 185 | app(Highlighter::class)->highlight($post->title, $query); 186 | ``` 187 | 188 | > `highlight` 帮助函数和 `Highlighter` 对象适合在 `api` 等非 `html` 视图中使用。 189 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vanry/laravel-scout-tntsearch", 3 | "description": "包含中文分词的 Laravel Scout TNTSearch 驱动,支持 scws, phpanalysis 和 jieba 分词。", 4 | "keywords": ["tntsearch", "search", "scout", "laravel"], 5 | "license": "MIT", 6 | "type": "library", 7 | "support": { 8 | "issues": "https://github.com/vanry/laravel-scout-tntsearch/issues", 9 | "source": "https://github.com/vanry/laravel-scout-tntsearch" 10 | }, 11 | "authors": [ 12 | { 13 | "name": "vanry", 14 | "email": "ivanry@163.com" 15 | } 16 | ], 17 | "require": { 18 | "php": "^7.0|^8.0", 19 | "illuminate/console": "~5.5|^6.0|^7.0|^8.0", 20 | "illuminate/database": "~5.5|^6.0|^7.0|^8.0", 21 | "illuminate/support": "~5.5|^6.0|^7.0|^8.0", 22 | "illuminate/view": "~5.5|^6.0|^7.0|^8.0", 23 | "laravel/scout": "^7.0|^8.0|^9.0", 24 | "lmz/phpanalysis": "^1.0", 25 | "teamtnt/tntsearch": "^2.0" 26 | }, 27 | "require-dev": { 28 | "mockery/mockery": "~0.9", 29 | "phpunit/phpunit": "~5.0" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "Vanry\\Scout\\": "src" 34 | }, 35 | "files": [ 36 | "src/helpers.php" 37 | ] 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "Tests\\": "tests/" 42 | } 43 | }, 44 | "extra": { 45 | "branch-alias": { 46 | "dev-master": "2.0-dev" 47 | }, 48 | "laravel": { 49 | "providers": [ 50 | "Vanry\\Scout\\TNTSearchScoutServiceProvider" 51 | ] 52 | } 53 | }, 54 | "suggest": { 55 | "fukuball/jieba-php": "Required to use the Jieba tokenizer (~0.25).", 56 | "vanry/scws": "Required to use the SCWS tokenizer (^2.0)." 57 | }, 58 | "config": { 59 | "sort-packages": true 60 | }, 61 | "minimum-stability": "dev" 62 | } 63 | -------------------------------------------------------------------------------- /config/tntsearch.php: -------------------------------------------------------------------------------- 1 | env('TNTSEARCH_TOKENIZER', 'phpanalysis'), 6 | 7 | 'storage' => storage_path('indices'), 8 | 9 | 'stemmer' => TeamTNT\TNTSearch\Stemmer\NoStemmer::class, 10 | 11 | 'tokenizers' => [ 12 | 'phpanalysis' => [ 13 | 'driver' => Vanry\Scout\Tokenizers\PhpAnalysisTokenizer::class, 14 | 'to_lower' => true, 15 | 'unit_word' => true, 16 | 'differ_max' => true, 17 | 'result_type' => 2, 18 | ], 19 | 20 | 'jieba' => [ 21 | 'driver' => Vanry\Scout\Tokenizers\JiebaTokenizer::class, 22 | 'dict' => 'small', 23 | //'user_dict' => resource_path('dicts/mydict.txt'), 24 | ], 25 | 26 | 'scws' => [ 27 | 'driver' => Vanry\Scout\Tokenizers\ScwsTokenizer::class, 28 | 'multi' => 1, 29 | 'ignore' => true, 30 | 'duality' => false, 31 | 'charset' => 'utf-8', 32 | 'dict' => '/usr/local/scws/etc/dict.utf8.xdb', 33 | 'rule' => '/usr/local/scws/etc/rules.utf8.ini', 34 | ], 35 | ], 36 | 37 | 'stopwords' => [ 38 | // 39 | ], 40 | 41 | ]; 42 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | ./tests/ 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Console/ImportCommand.php: -------------------------------------------------------------------------------- 1 | argument('model'); 33 | 34 | $model = new $class; 35 | 36 | $index = $this->initIndex($model); 37 | 38 | $index->query($this->getSql($model)); 39 | 40 | $index->run(); 41 | 42 | $this->info("All [{$class}] records have been imported."); 43 | } 44 | 45 | protected function initIndex($model) 46 | { 47 | $tnt = new TNTSearch; 48 | 49 | $tnt->loadConfig($this->getConfig($model)); 50 | 51 | $tnt->setDatabaseHandle($model->getConnection()->getPdo()); 52 | 53 | if (! file_exists($tnt->config['storage'])) { 54 | mkdir($tnt->config['storage'], 0777, true); 55 | } 56 | 57 | $index = $tnt->createIndex("{$model->searchableAs()}.index"); 58 | 59 | $index->inMemory = false; 60 | 61 | $index->setPrimaryKey($model->getKeyName()); 62 | 63 | return $index; 64 | } 65 | 66 | protected function getConfig($model) 67 | { 68 | $driver = $model->getConnectionName() ?: config('database.default'); 69 | 70 | $config = config('tntsearch') + config("database.connections.{$driver}"); 71 | 72 | if (! array_key_exists($config['default'], $config['tokenizers'])) { 73 | throw new InvalidArgumentException("Tokenizer [{$config['default']}] is not defined."); 74 | } 75 | 76 | return array_merge($config, ['tokenizer' => $config['tokenizers'][$config['default']]['driver']]); 77 | } 78 | 79 | protected function getSql($model) 80 | { 81 | $query = $model->newQueryWithoutScopes(); 82 | 83 | if ($fields = $this->getSearchableFields($model)) { 84 | $query->select($model->getKeyName())->addSelect($fields); 85 | } 86 | 87 | return $query->toSql(); 88 | } 89 | 90 | protected function getSearchableFields($model) 91 | { 92 | $availableColumns = $model->getConnection()->getSchemaBuilder()->getColumnListing($model->getTable()); 93 | 94 | $desiredColumns = array_keys($model->toSearchableArray()); 95 | 96 | return array_intersect($desiredColumns, $availableColumns); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Engines/TNTSearchEngine.php: -------------------------------------------------------------------------------- 1 | tnt = $tnt; 39 | } 40 | 41 | /** 42 | * Update the given model in the index. 43 | * 44 | * @param \Illuminate\Database\Eloquent\Collection $models 45 | * @return void 46 | */ 47 | public function update($models) 48 | { 49 | $model = $models->first(); 50 | 51 | $index = $this->initIndex($model->searchableAs()); 52 | 53 | $index->setPrimaryKey($model->getKeyName()); 54 | $index->setStopWords($this->tnt->config['stopwords'] ?? []); 55 | 56 | $index->indexBeginTransaction(); 57 | 58 | $models->each(function ($model) use ($index) { 59 | $array = $model->toSearchableArray(); 60 | 61 | if (empty($array)) { 62 | return; 63 | } 64 | 65 | if ($model->getKey()) { 66 | $index->update($model->getKey(), $array); 67 | } else { 68 | $index->insert($array); 69 | } 70 | }); 71 | 72 | $index->indexEndTransaction(); 73 | } 74 | 75 | /** 76 | * Remove the given model from the index. 77 | * 78 | * @param \Illuminate\Database\Eloquent\Collection $models 79 | * @return void 80 | */ 81 | public function delete($models) 82 | { 83 | $index = $this->initIndex($models->first()->searchableAs()); 84 | 85 | $models->each(function ($model) use ($index) { 86 | $index->delete($model->getKey()); 87 | }); 88 | } 89 | 90 | /** 91 | * Delete a search index. 92 | * 93 | * @param string $name 94 | * @return mixed 95 | */ 96 | public function deleteIndex($name) 97 | { 98 | $indexPath = $this->indexPath($name); 99 | 100 | if (file_exists($indexPath)) { 101 | unlink($indexPath); 102 | } 103 | } 104 | 105 | /** 106 | * Perform the given search on the engine. 107 | * 108 | * @param \Laravel\Scout\Builder $builder 109 | * @return mixed 110 | */ 111 | public function search(Builder $builder) 112 | { 113 | try { 114 | return $this->performSearch($builder); 115 | } catch (IndexNotFoundException $e) { 116 | $this->createIndex($builder->model); 117 | 118 | return $this->performSearch($builder); 119 | } 120 | } 121 | 122 | /** 123 | * Perform the given search on the engine. 124 | * 125 | * @param \Laravel\Scout\Builder $builder 126 | * @param int $perPage 127 | * @param int $page 128 | * @return mixed 129 | */ 130 | public function paginate(Builder $builder, $perPage, $page) 131 | { 132 | $results = $this->search($builder); 133 | 134 | $results['hits'] = $this->getFilteredTotalCount($builder, $results); 135 | 136 | if ($builder->limit) { 137 | $results['hits'] = min($results['hits'], $builder->limit); 138 | } 139 | 140 | $chunks = array_chunk($results['ids'], $perPage); 141 | 142 | $results['ids'] = $chunks[$page - 1] ?? []; 143 | 144 | return $results; 145 | } 146 | 147 | protected function getFilteredTotalCount(Builder $builder, $results) 148 | { 149 | $this->builder = $builder; 150 | 151 | $model = $builder->model; 152 | 153 | $query = $model->whereIn($model->getQualifiedKeyName(), $results['ids']); 154 | 155 | if ($this->usesSoftDelete($model) && config('scout.soft_delete')) { 156 | $query = $this->handleSoftDelete($query); 157 | } 158 | 159 | return $this->applyWheres($query)->count(); 160 | } 161 | 162 | /** 163 | * Perform the given search on the engine. 164 | * 165 | * @param Builder $builder 166 | * @return mixed 167 | */ 168 | protected function performSearch(Builder $builder, array $options = []) 169 | { 170 | $index = $builder->index ?: $builder->model->searchableAs(); 171 | 172 | $this->tnt->selectIndex("{$index}.index"); 173 | 174 | if ($builder->callback) { 175 | return call_user_func($builder->callback, $this->tnt, $builder->query, $options); 176 | } 177 | 178 | return $this->tnt->search($builder->query, $builder->limit ?: static::LIMIT); 179 | } 180 | 181 | /** 182 | * Map the given results to instances of the given model. 183 | * 184 | * @param \Laravel\Scout\Builder $builder 185 | * @param mixed $results 186 | * @param \Illuminate\Database\Eloquent\Model $model 187 | * @return \Illuminate\Database\Eloquent\Collection 188 | */ 189 | public function map(Builder $builder, $results, $model) 190 | { 191 | return (empty($results['ids'])) ? $model->newCollection() : $this->mapModels($builder, $results, $model); 192 | } 193 | 194 | /** 195 | * Map the given results to instances of the given model via a lazy collection. 196 | * 197 | * @param \Laravel\Scout\Builder $builder 198 | * @param mixed $results 199 | * @param \Illuminate\Database\Eloquent\Model $model 200 | * @return \Illuminate\Support\LazyCollection 201 | */ 202 | public function lazyMap(Builder $builder, $results, $model) 203 | { 204 | return (empty($results['ids'])) 205 | ? LazyCollection::make() 206 | : $this->mapModels($builder, $results, $model, function (Query $query) { 207 | return $query->cursor(); 208 | }); 209 | } 210 | 211 | /** 212 | * Map eloquent models with search results. 213 | * 214 | * @param \Laravel\Scout\Builder $builder 215 | * @param mixed $results 216 | * @param \Illuminate\Database\Eloquent\Model $model 217 | * @param callable $callback 218 | * @return \Illuminate\Database\Eloquent\Collection 219 | */ 220 | protected function mapModels(Builder $builder, $results, $model, callable $callback = null) 221 | { 222 | $this->builder = $builder; 223 | 224 | $query = $model->whereIn($model->getQualifiedKeyName(), $results['ids']); 225 | 226 | if ($this->usesSoftDelete($model) && config('scout.soft_delete')) { 227 | $query = $this->handleSoftDelete($query); 228 | } 229 | 230 | $query = $this->applyWheres($query); 231 | 232 | $query = $this->applyOrders($query); 233 | 234 | $models = is_null($callback) ? $query->get() : $callback($query); 235 | 236 | return empty($this->builder->orders) ? $models->sortBy(function ($model) use ($results) { 237 | return array_search($model->getKey(), $results['ids']); 238 | })->values() : $models; 239 | } 240 | 241 | /** 242 | * Determine if the given model uses soft deletes. 243 | * 244 | * @param \Illuminate\Database\Eloquent\Model $model 245 | * @return bool 246 | */ 247 | protected function usesSoftDelete($model) 248 | { 249 | return in_array(SoftDeletes::class, class_uses_recursive($model)); 250 | } 251 | 252 | protected function handleSoftDelete(Query $query) 253 | { 254 | if (! array_key_exists('__soft_deleted', $this->builder->wheres)) { 255 | $query->withTrashed(); 256 | } elseif ($this->builder->wheres['__soft_deleted'] == 1) { 257 | $query->onlyTrashed(); 258 | } 259 | 260 | return $query; 261 | } 262 | 263 | protected function applyWheres(Query $query) 264 | { 265 | unset($this->builder->wheres['__soft_deleted']); 266 | 267 | return $query->where($this->builder->wheres); 268 | } 269 | 270 | protected function applyOrders(Query $query) 271 | { 272 | return array_reduce($this->builder->orders, function ($query, $order) { 273 | return $query->orderBy($order['column'], $order['direction']); 274 | }, $query); 275 | } 276 | 277 | /** 278 | * Pluck and return the primary keys of the given results. 279 | * 280 | * @param mixed $results 281 | * @return \Illuminate\Support\Collection 282 | */ 283 | public function mapIds($results) 284 | { 285 | return collect($results['ids'])->values(); 286 | } 287 | 288 | /** 289 | * Get the total count from a raw result returned by the engine. 290 | * 291 | * @param mixed $results 292 | * @return int 293 | */ 294 | public function getTotalCount($results) 295 | { 296 | return $results['hits']; 297 | } 298 | 299 | protected function initIndex($name) 300 | { 301 | if (! file_exists($this->indexPath($name))) { 302 | $this->createIndex($name); 303 | } 304 | 305 | $this->tnt->selectIndex($this->indexName($name)); 306 | 307 | return $this->tnt->getIndex(); 308 | } 309 | 310 | protected function indexName($name) 311 | { 312 | return "{$name}.index"; 313 | } 314 | 315 | protected function indexPath($name) 316 | { 317 | return $this->tnt->config['storage'].$this->indexName($name); 318 | } 319 | 320 | public function createIndex($name, array $options = []) 321 | { 322 | if (! file_exists($this->tnt->config['storage'])) { 323 | mkdir($this->tnt->config['storage'], 0755, true); 324 | 325 | file_put_contents($this->tnt->config['storage'].'.gitignore', "*\n!.gitignore\n"); 326 | } 327 | 328 | $this->tnt->createIndex($this->indexName($name), $options['disableOutput'] ?? false); 329 | } 330 | 331 | /** 332 | * Flush all of the model's records from the engine. 333 | * 334 | * @param \Illuminate\Database\Eloquent\Model $model 335 | * @return void 336 | */ 337 | public function flush($model) 338 | { 339 | $this->deleteIndex($model->searchableAs()); 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /src/Highlighter.php: -------------------------------------------------------------------------------- 1 | tokenizer = $tokenizer ?: new Tokenizer; 15 | } 16 | 17 | public function highlight($text, $query, $tag = 'em') 18 | { 19 | $terms = $this->tokenizer->tokenize($query); 20 | 21 | $patterns = array_map(function ($term) { 22 | return "/{$term}/is"; 23 | }, array_unique($terms)); 24 | 25 | $replacement = "<{$tag}>\$0"; 26 | 27 | return preg_replace($patterns, $replacement, $text); 28 | } 29 | 30 | public function getTokenizer() 31 | { 32 | return $this->tokenizer; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/LumenServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->configure('scout'); 17 | $this->app->configure('tntsearch'); 18 | 19 | $this->mergeConfigFrom(__DIR__.'/../config/tntsearch.php', 'tntsearch'); 20 | 21 | $this->app->instance('path.config', $this->app->configPath()); 22 | $this->app->singleton(TokenizerInterface::class, $this->getConfig()['tokenizer']); 23 | 24 | if ($this->app->runningInConsole()) { 25 | $this->commands(ImportCommand::class); 26 | } 27 | } 28 | 29 | /** 30 | * Bootstrap any application services. 31 | * 32 | * @return void 33 | */ 34 | public function boot() 35 | { 36 | $this->app->make('view'); 37 | 38 | parent::boot(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/TNTSearchScoutServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 24 | $this->commands(ImportCommand::class); 25 | 26 | $this->publishes([ 27 | __DIR__.'/../config/tntsearch.php' => config_path('tntsearch.php'), 28 | ]); 29 | } 30 | 31 | $this->mergeConfigFrom(__DIR__.'/../config/tntsearch.php', 'tntsearch'); 32 | 33 | $this->app->singleton(TokenizerInterface::class, $this->getConfig()['tokenizer']); 34 | } 35 | 36 | /** 37 | * Bootstrap any application services. 38 | * 39 | * @return void 40 | */ 41 | public function boot() 42 | { 43 | $this->app[EngineManager::class]->extend('tntsearch', function ($app) { 44 | $tnt = new TNTSearch; 45 | 46 | $tnt->loadConfig($this->getConfig()); 47 | $tnt->setDatabaseHandle(app('db')->connection()->getPdo()); 48 | 49 | return new TNTSearchEngine($tnt); 50 | }); 51 | 52 | Blade::directive('highlight', function ($expression) { 53 | $segments = array_map('trim', explode(',', $expression)); 54 | 55 | list($text, $query) = $segments; 56 | 57 | $tag = $segments[2] ?? "'em'"; 58 | 59 | return "highlight($text, $query, $tag); ?>"; 60 | }); 61 | } 62 | 63 | protected function getConfig() 64 | { 65 | $driver = config('database.default'); 66 | 67 | $config = config('tntsearch') + config("database.connections.{$driver}"); 68 | 69 | if (! array_key_exists($config['default'], $config['tokenizers'])) { 70 | throw new InvalidArgumentException("Tokenizer [{$config['default']}] is not defined."); 71 | } 72 | 73 | return array_merge($config, ['tokenizer' => $config['tokenizers'][$config['default']]['driver']]); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Tokenizers/JiebaTokenizer.php: -------------------------------------------------------------------------------- 1 | getConfig('jieba'); 13 | 14 | Jieba::init($config); 15 | 16 | if (isset($config['user_dict'])) { 17 | Jieba::loadUserDict($config['user_dict']); 18 | } 19 | 20 | Finalseg::init($config); 21 | } 22 | 23 | public function getTokens($text) 24 | { 25 | return Jieba::cutForSearch($text); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Tokenizers/PhpAnalysisTokenizer.php: -------------------------------------------------------------------------------- 1 | analysis = new Phpanalysis; 15 | 16 | foreach ($this->getConfig('phpanalysis') as $key => $value) { 17 | $key = Str::camel($key); 18 | 19 | if (property_exists($this->analysis, $key)) { 20 | $this->analysis->$key = $value; 21 | } 22 | } 23 | } 24 | 25 | public function getTokens($text) 26 | { 27 | $this->analysis->SetSource($text); 28 | 29 | $this->analysis->StartAnalysis(); 30 | 31 | $result = $this->analysis->GetFinallyResult(); 32 | 33 | $result = str_replace(['(', ')'], '', trim($result)); 34 | 35 | return explode(' ', $result); 36 | } 37 | 38 | public function getAnalysis() 39 | { 40 | return $this->analysis; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Tokenizers/ScwsTokenizer.php: -------------------------------------------------------------------------------- 1 | scws = new Scws($this->getConfig('scws')); 14 | } 15 | 16 | public function getTokens($text) 17 | { 18 | $this->scws->sendText($text); 19 | 20 | $tokens = []; 21 | 22 | while ($result = $this->scws->getResult()) { 23 | $tokens = array_merge($tokens, array_column($result, 'word')); 24 | } 25 | 26 | return $tokens; 27 | } 28 | 29 | public function getScws() 30 | { 31 | return $this->scws; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Tokenizers/Tokenizer.php: -------------------------------------------------------------------------------- 1 | getTokens($text); 25 | 26 | $tokens = array_filter($tokens, 'trim'); 27 | 28 | return array_diff($tokens, $stopwords); 29 | } 30 | 31 | abstract protected function getTokens($text); 32 | } 33 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | highlight($text, $query, $tag); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/TNTSearchEngineTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive('createIndex') 17 | ->with('table.index') 18 | ->andReturn($index = Mockery::mock('TeamTNT\TNTSearch\Indexer\TNTIndexer')); 19 | $index->shouldReceive('setDatabaseHandle'); 20 | $index->shouldReceive('setPrimaryKey'); 21 | $index->shouldReceive('query'); 22 | $index->shouldReceive('run'); 23 | 24 | $client->shouldReceive('selectIndex'); 25 | $client->shouldReceive('getIndex') 26 | ->andReturn($index); 27 | 28 | $index->shouldReceive('indexBeginTransaction'); 29 | $index->shouldReceive('update'); 30 | $index->shouldReceive('indexEndTransaction'); 31 | 32 | $engine = new TNTSearchEngine($client); 33 | $engine->update(Collection::make([new TNTSearchEngineTestModel()])); 34 | } 35 | } 36 | 37 | class TNTSearchEngineTestModel 38 | { 39 | public $searchable = ['title']; 40 | 41 | public function searchableAs() 42 | { 43 | return 'table'; 44 | } 45 | 46 | public function getTable() 47 | { 48 | return 'table'; 49 | } 50 | 51 | public function getTablePrefix() 52 | { 53 | return ""; 54 | } 55 | 56 | public function getKey() 57 | { 58 | return 1; 59 | } 60 | 61 | public function getKeyName() 62 | { 63 | return 'id'; 64 | } 65 | 66 | public function toSearchableArray() 67 | { 68 | return ['id' => 1]; 69 | } 70 | 71 | public function getConnection() 72 | { 73 | $connection = Mockery::mock('Illuminate\Database\MySqlConnection'); 74 | $connection->shouldReceive('getPdo')->andReturn(Mockery::mock('PDO')); 75 | 76 | return $connection; 77 | } 78 | } 79 | --------------------------------------------------------------------------------