Просмотр исходного кода

transplant util/search from crowi

Yuki Takei 7 лет назад
Родитель
Сommit
8b47b66465
2 измененных файлов с 368 добавлено и 246 удалено
  1. 25 44
      resource/search/mappings.json
  2. 343 202
      src/server/util/search.js

+ 25 - 44
resource/search/mappings.json

@@ -24,13 +24,6 @@
         }
       },
       "analyzer": {
-        "autocomplete": {
-          "tokenizer":  "keyword",
-          "filter": [
-            "lowercase",
-            "nGram"
-          ]
-        },
         "japanese": {
           "tokenizer": "kuromoji_tokenizer",
           "char_filter" : ["icu_normalizer"]
@@ -48,52 +41,40 @@
     }
   },
   "mappings": {
-    "users": {
-      "properties" : {
-        "name": {
-          "type": "text",
-          "analyzer": "autocomplete"
-        }
-      }
-    },
     "pages": {
       "properties" : {
         "path": {
           "type": "text",
-          "copy_to": ["path_raw", "path_ja", "path_en"],
-          "index": "false"
-        },
-        "path_raw": {
-          "type": "text",
-          "analyzer": "standard"
-        },
-        "path_ja": {
-          "type": "text",
-          "analyzer": "japanese"
-        },
-        "path_en": {
-          "type": "text",
-          "analyzer": "english"
+          "fields": {
+            "raw": {
+              "type": "text",
+              "analyzer": "keyword"
+            },
+            "ja": {
+              "type": "text",
+              "analyzer": "japanese"
+            },
+            "en": {
+              "type": "text",
+              "analyzer": "english"
+            }
+          }
         },
         "body": {
           "type": "text",
-          "copy_to": ["body_raw", "body_ja", "body_en"],
-          "index": "false"
-        },
-        "body_raw": {
-          "type": "text",
-          "analyzer": "standard"
-        },
-        "body_ja": {
-          "type": "text",
-          "analyzer": "japanese"
-        },
-        "body_en": {
-          "type": "text",
-          "analyzer": "english"
+          "fields": {
+            "ja": {
+              "type": "text",
+              "analyzer": "japanese"
+            },
+            "en": {
+              "type": "text",
+              "analyzer": "english"
+            }
+          }
         },
         "username": {
-          "type": "text"
+          "type": "keyword"
         },
         "comment_count": {
           "type": "integer"

+ 343 - 202
src/server/util/search.js

@@ -2,24 +2,52 @@
  * Search
  */
 
-var elasticsearch = require('elasticsearch'),
-  debug = require('debug')('growi:lib:search');
+const elasticsearch = require('elasticsearch');
+const debug = require('debug')('growi:lib:search');
+const logger = require('@alias/logger')('growi:lib:search');
 
 function SearchClient(crowi, esUri) {
   this.DEFAULT_OFFSET = 0;
   this.DEFAULT_LIMIT = 50;
 
+  this.esNodeName = '-';
+  this.esNodeNames = [];
+  this.esVersion = 'unknown';
+  this.esVersions = [];
+  this.esPlugin = [];
+  this.esPlugins = [];
   this.esUri = esUri;
   this.crowi = crowi;
+  this.searchEvent = crowi.event('search');
+
+  // In Elasticsearch RegExp, we don't need to used ^ and $.
+  // Ref: https://www.elastic.co/guide/en/elasticsearch/reference/5.6/query-dsl-regexp-query.html#_standard_operators
+  this.queries = {
+    PORTAL: {
+      regexp: {
+        'path.raw': '.*/',
+      },
+    },
+    PUBLIC: {
+      regexp: {
+        'path.raw': '.*[^/]',
+      },
+    },
+    USER: {
+      prefix: {
+        'path.raw': '/user/',
+      },
+    },
+  };
 
-  var uri = this.parseUri(this.esUri);
+  const uri = this.parseUri(this.esUri);
   this.host = uri.host;
-  this.index_name = uri.index_name;
+  this.indexName = uri.indexName;
 
   this.client = new elasticsearch.Client({
     host: this.host,
     requestTimeout: 5000,
-    //log: 'debug',
+    // log: 'debug',
   });
 
   this.registerUpdateEvent();
@@ -27,8 +55,25 @@ function SearchClient(crowi, esUri) {
   this.mappingFile = crowi.resourceDir + 'search/mappings.json';
 }
 
-SearchClient.prototype.getInfo = function() {
-  return this.client.info({});
+SearchClient.prototype.checkESVersion = async function() {
+  try {
+    const nodes = await this.client.nodes.info();
+    if (!nodes._nodes || !nodes.nodes) {
+      throw new Error('no nodes info');
+    }
+
+    for (const [nodeName, nodeInfo] of Object.entries(nodes.nodes)) {
+      this.esNodeName = nodeName;
+      this.esNodeNames.push(nodeName);
+      this.esVersion = nodeInfo.version;
+      this.esVersions.push(nodeInfo.version);
+      this.esPlugin = nodeInfo.plugins;
+      this.esPlugins.push(nodeInfo.plugins);
+    }
+  }
+  catch (error) {
+    logger.error('es check version error:', error);
+  }
 };
 
 SearchClient.prototype.registerUpdateEvent = function() {
@@ -36,6 +81,10 @@ SearchClient.prototype.registerUpdateEvent = function() {
   pageEvent.on('create', this.syncPageCreated.bind(this));
   pageEvent.on('update', this.syncPageUpdated.bind(this));
   pageEvent.on('delete', this.syncPageDeleted.bind(this));
+
+  const bookmarkEvent = this.crowi.event('bookmark');
+  bookmarkEvent.on('create', this.syncBookmarkChanged.bind(this));
+  bookmarkEvent.on('delete', this.syncBookmarkChanged.bind(this));
 };
 
 SearchClient.prototype.shouldIndexed = function(page) {
@@ -48,40 +97,42 @@ SearchClient.prototype.shouldIndexed = function(page) {
     return false;
   }
 
-  if (page.isDeleted()) {
+  // FIXME: use STATUS_DELETED
+  // isDeleted() couldn't use here because of lean()
+  if (page.status === 'deleted') {
     return false;
   }
 
   return true;
 };
 
-
 // BONSAI_URL is following format:
 // => https://{ID}:{PASSWORD}@{HOST}
 SearchClient.prototype.parseUri = function(uri) {
-  var index_name = 'crowi';
-  var host = uri;
-  if (m = uri.match(/^(https?:\/\/[^\/]+)\/(.+)$/)) {
+  let indexName = 'crowi';
+  let host = uri;
+  let m;
+  if ((m = uri.match(/^(https?:\/\/[^/]+)\/(.+)$/))) {
     host = m[1];
-    index_name = m[2];
+    indexName = m[2];
   }
 
   return {
     host,
-    index_name,
+    indexName,
   };
 };
 
 SearchClient.prototype.buildIndex = function(uri) {
   return this.client.indices.create({
-    index: this.index_name,
-    body: require(this.mappingFile)
+    index: this.indexName,
+    body: require(this.mappingFile),
   });
 };
 
 SearchClient.prototype.deleteIndex = function(uri) {
   return this.client.indices.delete({
-    index: this.index_name,
+    index: this.indexName,
   });
 };
 
@@ -90,20 +141,20 @@ SearchClient.prototype.prepareBodyForUpdate = function(body, page) {
     throw new Error('Body must be an array.');
   }
 
-  var command = {
+  let command = {
     update: {
-      _index: this.index_name,
+      _index: this.indexName,
       _type: 'pages',
       _id: page._id.toString(),
-    }
+    },
   };
 
-  var document = {
+  let document = {
     doc: {
       path: page.path,
       body: page.revision.body,
       comment_count: page.commentCount,
-      bookmark_count: 0, // todo
+      bookmark_count: page.bookmarkCount || 0,
       like_count: page.liker.length || 0,
       updated_at: page.updatedAt,
     },
@@ -119,20 +170,21 @@ SearchClient.prototype.prepareBodyForCreate = function(body, page) {
     throw new Error('Body must be an array.');
   }
 
-  var command = {
+  let command = {
     index: {
-      _index: this.index_name,
+      _index: this.indexName,
       _type: 'pages',
       _id: page._id.toString(),
-    }
+    },
   };
 
-  var document = {
+  const bookmarkCount = page.bookmarkCount || 0;
+  let document = {
     path: page.path,
     body: page.revision.body,
     username: page.creator.username,
     comment_count: page.commentCount,
-    bookmark_count: 0, // todo
+    bookmark_count: bookmarkCount,
     like_count: page.liker.length || 0,
     created_at: page.createdAt,
     updated_at: page.updatedAt,
@@ -147,117 +199,121 @@ SearchClient.prototype.prepareBodyForDelete = function(body, page) {
     throw new Error('Body must be an array.');
   }
 
-  var command = {
+  let command = {
     delete: {
-      _index: this.index_name,
+      _index: this.indexName,
       _type: 'pages',
       _id: page._id.toString(),
-    }
+    },
   };
 
   body.push(command);
 };
 
+SearchClient.prototype.addPages = async function(pages) {
+  const Bookmark = this.crowi.model('Bookmark');
+  const body = [];
 
-SearchClient.prototype.addPages = function(pages) {
-  var self = this;
-  var body = [];
-
-  pages.map(function(page) {
-    self.prepareBodyForCreate(body, page);
-  });
+  for (const page of pages) {
+    page.bookmarkCount = await Bookmark.countByPageId(page._id);
+    this.prepareBodyForCreate(body, page);
+  }
 
-  debug('addPages(): Sending Request to ES', body);
+  logger.info('addPages(): Sending Request to ES', body);
   return this.client.bulk({
     body: body,
   });
 };
 
 SearchClient.prototype.updatePages = function(pages) {
-  var self = this;
-  var body = [];
+  let self = this;
+  let body = [];
 
   pages.map(function(page) {
     self.prepareBodyForUpdate(body, page);
   });
 
-  debug('updatePages(): Sending Request to ES', body);
+  logger.info('updatePages(): Sending Request to ES', body);
   return this.client.bulk({
     body: body,
   });
 };
 
 SearchClient.prototype.deletePages = function(pages) {
-  var self = this;
-  var body = [];
+  let self = this;
+  let body = [];
 
   pages.map(function(page) {
     self.prepareBodyForDelete(body, page);
   });
 
-  debug('deletePages(): Sending Request to ES', body);
+  logger.info('deletePages(): Sending Request to ES', body);
   return this.client.bulk({
     body: body,
   });
 };
 
-SearchClient.prototype.addAllPages = function() {
-  var self = this;
-  var Page = this.crowi.model('Page');
-  var cursor = Page.getStreamOfFindAll();
-  var body = [];
-  var sent = 0;
-  var skipped = 0;
-
-  return new Promise(function(resolve, reject) {
-    cursor.on('data', function(doc) {
-      if (!doc.creator || !doc.revision || !self.shouldIndexed(doc)) {
-        //debug('Skipped', doc.path);
-        skipped++;
-        return ;
-      }
-
-      self.prepareBodyForCreate(body, doc);
-      //debug(body.length);
-      if (body.length > 2000) {
-        sent++;
-        debug('Sending request (seq, skipped)', sent, skipped);
-        self.client.bulk({
+SearchClient.prototype.addAllPages = async function() {
+  const self = this;
+  const Page = this.crowi.model('Page');
+  const allPageCount = await Page.allPageCount();
+  const Bookmark = this.crowi.model('Bookmark');
+  const cursor = Page.getStreamOfFindAll();
+  let body = [];
+  let sent = 0;
+  let skipped = 0;
+  let total = 0;
+
+  return new Promise((resolve, reject) => {
+    const bulkSend = body => {
+      self.client
+        .bulk({
           body: body,
           requestTimeout: Infinity,
-        }).then(res => {
-          debug('addAllPages add anyway (items, errors, took): ', (res.items || []).length, res.errors, res.took);
-        }).catch(err => {
-          debug('addAllPages error on add anyway: ', err);
+        })
+        .then(res => {
+          logger.info('addAllPages add anyway (items, errors, took): ', (res.items || []).length, res.errors, res.took, 'ms');
+        })
+        .catch(err => {
+          logger.error('addAllPages error on add anyway: ', err);
         });
+    };
+
+    cursor
+      .eachAsync(async doc => {
+        if (!doc.creator || !doc.revision || !self.shouldIndexed(doc)) {
+          // debug('Skipped', doc.path);
+          skipped++;
+          return;
+        }
+        total++;
 
-        body = [];
-      }
-    }).on('error', function(err) {
-      // TODO: handle err
-      debug('Error cursor:', err);
-    }).on('close', function() {
-      // all done
-
-      // return if body is empty
-      // see: https://github.com/weseek/growi/issues/228
-      if (body.length == 0) {
-        return resolve();
-      }
+        const bookmarkCount = await Bookmark.countByPageId(doc._id);
+        const page = { ...doc, bookmarkCount };
+        self.prepareBodyForCreate(body, page);
 
-      // 最後にすべてを送信
-      self.client.bulk({
-        body: body,
-        requestTimeout: Infinity,
+        if (body.length >= 4000) {
+          // send each 2000 docs. (body has 2 elements for each data)
+          sent++;
+          logger.debug('Sending request (seq, total, skipped)', sent, total, skipped);
+          bulkSend(body);
+          this.searchEvent.emit('addPageProgress', allPageCount, total, skipped);
+
+          body = [];
+        }
       })
-      .then(function(res) {
-        debug('Reponse from es (item length, errros, took):', (res.items || []).length, res.errors, res.took);
-        return resolve(res);
-      }).catch(function(err) {
-        debug('Err from es:', err);
-        return reject(err);
+      .then(() => {
+        // send all remaining data on body[]
+        logger.debug('Sending last body of bulk operation:', body.length);
+        bulkSend(body);
+        this.searchEvent.emit('finishAddPage', allPageCount, total, skipped);
+
+        resolve();
+      })
+      .catch(e => {
+        logger.error('Error wile iterating cursor.eachAsync()', e);
+        reject(e);
       });
-    });
   });
 };
 
@@ -269,45 +325,48 @@ SearchClient.prototype.addAllPages = function() {
  * }
  */
 SearchClient.prototype.search = function(query) {
-  var self = this;
+  let self = this;
 
   return new Promise(function(resolve, reject) {
-    self.client.search(query)
-    .then(function(data) {
-      var result = {
-        meta: {
-          took: data.took,
-          total: data.hits.total,
-          results: data.hits.hits.length,
-        },
-        data: data.hits.hits.map(function(elm) {
-          return {_id: elm._id, _score: elm._score};
-        })
-      };
-
-      resolve(result);
-    }).catch(function(err) {
-      reject(err);
-    });
+    self.client
+      .search(query)
+      .then(function(data) {
+        let result = {
+          meta: {
+            took: data.took,
+            total: data.hits.total,
+            results: data.hits.hits.length,
+          },
+          data: data.hits.hits.map(function(elm) {
+            return { _id: elm._id, _score: elm._score, _source: elm._source };
+          }),
+        };
+
+        resolve(result);
+      })
+      .catch(function(err) {
+        logger.error('Search error', err);
+        reject(err);
+      });
   });
 };
 
 SearchClient.prototype.createSearchQuerySortedByUpdatedAt = function(option) {
   // getting path by default is almost for debug
-  var fields = ['path'];
+  let fields = ['path', 'bookmark_count'];
   if (option) {
     fields = option.fields || fields;
   }
 
   // default is only id field, sorted by updated_at
-  var query = {
-    index: this.index_name,
+  let query = {
+    index: this.indexName,
     type: 'pages',
     body: {
-      sort: [{ updated_at: { order: 'desc'}}],
+      sort: [{ updated_at: { order: 'desc' } }],
       query: {}, // query
       _source: fields,
-    }
+    },
   };
   this.appendResultSize(query);
 
@@ -315,20 +374,20 @@ SearchClient.prototype.createSearchQuerySortedByUpdatedAt = function(option) {
 };
 
 SearchClient.prototype.createSearchQuerySortedByScore = function(option) {
-  var fields = ['path'];
+  let fields = ['path', 'bookmark_count'];
   if (option) {
     fields = option.fields || fields;
   }
 
   // sort by score
-  var query = {
-    index: this.index_name,
+  let query = {
+    index: this.indexName,
     type: 'pages',
     body: {
-      sort: [ {_score: { order: 'desc'} }],
+      sort: [{ _score: { order: 'desc' } }],
       query: {}, // query
       _source: fields,
-    }
+    },
   };
   this.appendResultSize(query);
 
@@ -340,21 +399,32 @@ SearchClient.prototype.appendResultSize = function(query, from, size) {
   query.size = size || this.DEFAULT_LIMIT;
 };
 
-SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keyword) {
+SearchClient.prototype.initializeBoolQuery = function(query) {
   // query is created by createSearchQuerySortedByScore() or createSearchQuerySortedByUpdatedAt()
   if (!query.body.query.bool) {
     query.body.query.bool = {};
   }
-  if (!query.body.query.bool.must || !Array.isArray(query.body.query.must)) {
+
+  const isInitialized = query => !!query && Array.isArray(query);
+
+  if (!isInitialized(query.body.query.bool.filter)) {
+    query.body.query.bool.filter = [];
+  }
+  if (!isInitialized(query.body.query.bool.must)) {
     query.body.query.bool.must = [];
   }
-  if (!query.body.query.bool.must_not || !Array.isArray(query.body.query.must_not)) {
+  if (!isInitialized(query.body.query.bool.must_not)) {
     query.body.query.bool.must_not = [];
   }
+  return query;
+};
 
-  var appendMultiMatchQuery = function(query, type, keywords) {
-    var target;
-    var operator = 'and';
+SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keyword) {
+  query = this.initializeBoolQuery(query);
+
+  const appendMultiMatchQuery = function(query, type, keywords) {
+    let target;
+    let operator = 'and';
     switch (type) {
       case 'not_match':
         target = query.body.query.bool.must_not;
@@ -369,21 +439,15 @@ SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keywor
       multi_match: {
         query: keywords.join(' '),
         // TODO: By user's i18n setting, change boost or search target fields
-        fields: [
-          'path_ja^2',
-          'path_en^2',
-          'body_ja',
-          // "path_en",
-          // "body_en",
-        ],
+        fields: ['path.ja^2', 'path.en^2', 'body.ja', 'body.en'],
         operator: operator,
-      }
+      },
     });
 
     return query;
   };
 
-  var parsedKeywords = this.getParsedKeywords(keyword);
+  let parsedKeywords = this.getParsedKeywords(keyword);
 
   if (parsedKeywords.match.length > 0) {
     query = appendMultiMatchQuery(query, 'match', parsedKeywords.match);
@@ -394,17 +458,18 @@ SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keywor
   }
 
   if (parsedKeywords.phrase.length > 0) {
-    var phraseQueries = [];
+    let phraseQueries = [];
     parsedKeywords.phrase.forEach(function(phrase) {
       phraseQueries.push({
         multi_match: {
           query: phrase, // each phrase is quoteted words
           type: 'phrase',
-          fields: [ // Not use "*.ja" fields here, because we want to analyze (parse) search words
-            'path_raw^2',
-            'body_raw',
+          fields: [
+            // Not use "*.ja" fields here, because we want to analyze (parse) search words
+            'path.raw^2',
+            'body',
           ],
-        }
+        },
       });
     });
 
@@ -412,17 +477,18 @@ SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keywor
   }
 
   if (parsedKeywords.not_phrase.length > 0) {
-    var notPhraseQueries = [];
+    let notPhraseQueries = [];
     parsedKeywords.not_phrase.forEach(function(phrase) {
       notPhraseQueries.push({
         multi_match: {
           query: phrase, // each phrase is quoteted words
           type: 'phrase',
-          fields: [ // Not use "*.ja" fields here, because we want to analyze (parse) search words
-            'path_raw^2',
-            'body_raw',
+          fields: [
+            // Not use "*.ja" fields here, because we want to analyze (parse) search words
+            'path.raw^2',
+            'body',
           ],
-        }
+        },
       });
     });
 
@@ -431,32 +497,91 @@ SearchClient.prototype.appendCriteriaForKeywordContains = function(query, keywor
 };
 
 SearchClient.prototype.appendCriteriaForPathFilter = function(query, path) {
-  // query is created by createSearchQuerySortedByScore() or createSearchQuerySortedByUpdatedAt()
-  if (!query.body.query.bool) {
-    query.body.query.bool = {};
-  }
-
-  if (!query.body.query.bool.filter || !Array.isArray(query.body.query.bool.filter)) {
-    query.body.query.bool.filter = [];
-  }
+  query = this.initializeBoolQuery(query);
 
   if (path.match(/\/$/)) {
     path = path.substr(0, path.length - 1);
   }
   query.body.query.bool.filter.push({
     wildcard: {
-      'path': path + '/*'
-    }
+      'path.raw': path + '/*',
+    },
   });
 };
 
+SearchClient.prototype.filterPortalPages = function(query) {
+  query = this.initializeBoolQuery(query);
+
+  query.body.query.bool.must_not.push(this.queries.USER);
+  query.body.query.bool.filter.push(this.queries.PORTAL);
+};
+
+SearchClient.prototype.filterPublicPages = function(query) {
+  query = this.initializeBoolQuery(query);
+
+  query.body.query.bool.must_not.push(this.queries.USER);
+  query.body.query.bool.filter.push(this.queries.PUBLIC);
+};
+
+SearchClient.prototype.filterUserPages = function(query) {
+  query = this.initializeBoolQuery(query);
+
+  query.body.query.bool.filter.push(this.queries.USER);
+};
+
+SearchClient.prototype.filterPagesByType = function(query, type) {
+  const Page = this.crowi.model('Page');
+
+  switch (type) {
+    case Page.TYPE_PORTAL:
+      return this.filterPortalPages(query);
+    case Page.TYPE_PUBLIC:
+      return this.filterPublicPages(query);
+    case Page.TYPE_USER:
+      return this.filterUserPages(query);
+    default:
+      return query;
+  }
+};
+
+SearchClient.prototype.appendFunctionScore = function(query) {
+  const User = this.crowi.model('User');
+  const count = User.count({}) || 1;
+  // newScore = oldScore + log(1 + factor * 'bookmark_count')
+  query.body.query = {
+    function_score: {
+      query: { ...query.body.query },
+      field_value_factor: {
+        field: 'bookmark_count',
+        modifier: 'log1p',
+        factor: 10000 / count,
+        missing: 0,
+      },
+      boost_mode: 'sum',
+    },
+  };
+};
+
 SearchClient.prototype.searchKeyword = function(keyword, option) {
-  /* eslint-disable no-unused-vars */
-  var from = option.offset || null;
-  /* eslint-enable */
-  var query = this.createSearchQuerySortedByScore();
+  const from = option.offset || null;
+  const size = option.limit || null;
+  const type = option.type || null;
+  const query = this.createSearchQuerySortedByScore();
   this.appendCriteriaForKeywordContains(query, keyword);
 
+  this.filterPagesByType(query, type);
+
+  this.appendResultSize(query, from, size);
+
+  this.appendFunctionScore(query);
+
+  const bool = query.body.query.function_score.query.bool;
+
+  debug('searching ...', keyword, type);
+  debug('filter', bool.filter);
+  debug('must', bool.must);
+  debug('must_not', bool.must_not);
+
   return this.search(query);
 };
 
@@ -465,30 +590,34 @@ SearchClient.prototype.searchByPath = function(keyword, prefix) {
 };
 
 SearchClient.prototype.searchKeywordUnderPath = function(keyword, path, option) {
-  var from = option.offset || null;
-  var query = this.createSearchQuerySortedByScore();
+  const from = option.offset || null;
+  const size = option.limit || null;
+  const type = option.type || null;
+  const query = this.createSearchQuerySortedByScore();
   this.appendCriteriaForKeywordContains(query, keyword);
   this.appendCriteriaForPathFilter(query, path);
 
-  if (from) {
-    this.appendResultSize(query, from);
-  }
+  this.filterPagesByType(query, type);
+
+  this.appendResultSize(query, from, size);
+
+  this.appendFunctionScore(query);
 
   return this.search(query);
 };
 
 SearchClient.prototype.getParsedKeywords = function(keyword) {
-  var matchWords = [];
-  var notMatchWords = [];
-  var phraseWords = [];
-  var notPhraseWords = [];
+  let matchWords = [];
+  let notMatchWords = [];
+  let phraseWords = [];
+  let notPhraseWords = [];
 
   keyword.trim();
   keyword = keyword.replace(/\s+/g, ' ');
 
   // First: Parse phrase keywords
-  var phraseRegExp = new RegExp(/(-?"[^"]+")/g);
-  var phrases = keyword.match(phraseRegExp);
+  let phraseRegExp = new RegExp(/(-?"[^"]+")/g);
+  let phrases = keyword.match(phraseRegExp);
 
   if (phrases !== null) {
     keyword = keyword.replace(phraseRegExp, '');
@@ -511,7 +640,7 @@ SearchClient.prototype.getParsedKeywords = function(keyword) {
     }
 
     if (word.match(/^-(.+)$/)) {
-      notMatchWords.push((RegExp.$1));
+      notMatchWords.push(RegExp.$1);
     }
     else {
       matchWords.push(word);
@@ -526,58 +655,70 @@ SearchClient.prototype.getParsedKeywords = function(keyword) {
   };
 };
 
-SearchClient.prototype.syncPageCreated = function(page, user) {
+SearchClient.prototype.syncPageCreated = function(page, user, bookmarkCount = 0) {
   debug('SearchClient.syncPageCreated', page.path);
 
   if (!this.shouldIndexed(page)) {
-    return ;
+    return;
   }
 
+  page.bookmarkCount = bookmarkCount;
   this.addPages([page])
-  .then(function(res) {
-    debug('ES Response', res);
-  })
-  .catch(function(err) {
-    debug('ES Error', err);
-  });
+    .then(function(res) {
+      debug('ES Response', res);
+    })
+    .catch(function(err) {
+      logger.error('ES Error', err);
+    });
 };
 
-SearchClient.prototype.syncPageUpdated = function(page, user) {
+SearchClient.prototype.syncPageUpdated = function(page, user, bookmarkCount = 0) {
   debug('SearchClient.syncPageUpdated', page.path);
   // TODO delete
   if (!this.shouldIndexed(page)) {
     this.deletePages([page])
-    .then(function(res) {
-      debug('deletePages: ES Response', res);
-    })
-    .catch(function(err) {
-      debug('deletePages:ES Error', err);
-    });
+      .then(function(res) {
+        debug('deletePages: ES Response', res);
+      })
+      .catch(function(err) {
+        logger.error('deletePages:ES Error', err);
+      });
 
-    return ;
+    return;
   }
 
+  page.bookmarkCount = bookmarkCount;
   this.updatePages([page])
-  .then(function(res) {
-    debug('ES Response', res);
-  })
-  .catch(function(err) {
-    debug('ES Error', err);
-  });
+    .then(function(res) {
+      debug('ES Response', res);
+    })
+    .catch(function(err) {
+      logger.error('ES Error', err);
+    });
 };
 
 SearchClient.prototype.syncPageDeleted = function(page, user) {
   debug('SearchClient.syncPageDeleted', page.path);
 
   this.deletePages([page])
-  .then(function(res) {
-    debug('deletePages: ES Response', res);
-  })
-  .catch(function(err) {
-    debug('deletePages:ES Error', err);
-  });
+    .then(function(res) {
+      debug('deletePages: ES Response', res);
+    })
+    .catch(function(err) {
+      logger.error('deletePages:ES Error', err);
+    });
+};
+
+SearchClient.prototype.syncBookmarkChanged = async function(pageId) {
+  const Page = this.crowi.model('Page');
+  const Bookmark = this.crowi.model('Bookmark');
+  const page = await Page.findPageById(pageId);
+  const bookmarkCount = await Bookmark.countByPageId(pageId);
 
-  return ;
+  page.bookmarkCount = bookmarkCount;
+  this.updatePages([page])
+    .then(res => debug('ES Response', res))
+    .catch(err => logger.error('ES Error', err));
 };
 
 module.exports = SearchClient;