Простой бот для сайта

от
PHP/MySQL    бот, scala, php, mysql

Наверняка, многие уже успели заметить некоторое нововведение на нашем сайте. Сейчас я расскажу, откуда пошли корни, как работает бот и как наполнялась база фраз. Сделать точно такого же бота не составит труда, уж поверьте.


Корни :oak:
Идею добавить на сайт бота подкинул благородный дон Virus-ON, так что все лавры ему.

Что же нужно для создания бота?
Во-первых, база данных фраз — память бота, чтобы он мог отвечать на сообщения.
Во-вторых, некоторый алгоритм выбора фраз — мозг бота, чтобы он отвечал более-менее в тему.


База данных. Часть 1
По воле случая, довелось познакомиться с одним человеком, программирующем под Android и жутко ненавидящем продукцию Apple. Одной из его разработок является VK iHA bot, исходники которого есть на GitHub.
Само приложение нам не интересно, а вот база данных на первых порах была бы весьма кстати. Её и возьмём.

Формат базы таков:
сообщение\ответ\релевантность
Пример
привет\Привет, рад тебя видеть!\3
как дела\Дела отлично!\2
я человек\А я робот))\2

Нам нужно распарсить этот файл, убрать лишние ссылки, имена, матерные слова (коих тут очень много) и сохранить всё в другом формате, понятном БД MySQL.
Скрипт на языке Scala:
  1. import scala.collection.mutable.HashSet
  2. import scala.io.Source
  3. import java.io.File
  4.  
  5. object VkIHAbotDbExtractor {
  6.   val filterWords = Array(// тут перечисление известных матерных слов для
  7.     "fuck", "suck", "http://", "www", // фильтрации (список намного больше ;)
  8.   )
  9.  
  10.   def main(args: Array[String]) {
  11.     var set = new HashSet[Phrase]
  12.     new File(args(0))
  13.       .list
  14.       .filter(_.endsWith(".bin"))
  15.       .foreach(f => set = set ++ open(f))
  16.  
  17.     set.toList.sorted.foreach(println)
  18.   }
  19.  
  20.   def open(file: String) = {
  21.     Source.fromFile(file, "UTF-8").getLines
  22.       .filter(_.split('\\').length == 3)
  23.       .map[Phrase](line => Phrase(line.trim))
  24.       .filter(_.censor)
  25.       .toSet
  26.   }
  27.  
  28.   case class Phrase(line: String) extends Ordered[Phrase] {
  29.     val arr = line.split('\\') // разделяем строку на массив по символу \
  30.     val message = messageFilter(arr(0).trim)
  31.     val answer = arr(1).trim.replace("'", "''")
  32.  
  33.     def messageFilter(str: String) = {
  34.       str.toLowerCase // фильтрация строки от лишних символов
  35.          .replaceAll("[^\\p{L}\\p{Nd} ]+", "")
  36.          .trim.replace("'", "''")
  37.     }
  38.  
  39.     def censor : Boolean = { // соответствует ли сообщение нормам
  40.       if (message.isEmpty || answer.isEmpty) return false
  41.       for (word <- filterWords)
  42.         if (line.toLowerCase.contains(word)) return false
  43.       true
  44.     }
  45.  
  46.     def compare(that: Phrase) = message.compareToIgnoreCase(that.message)
  47.  
  48.     override def hashCode = message.hashCode ^ answer.hashCode
  49.  
  50.     override def equals(obj: Any) = obj match {
  51.       case that: Phrase => this.message == that.message && this.answer == that.answer
  52.       case _ => false
  53.     }
  54.  
  55.     override def toString() = s"('$message', '$answer'),"
  56.   }
  57. }

В методе main мы перебираем все .bin файлы в директории и вызываем для каждого такого файла метод open. Метод open читает файл построчно и преобразует строку в класс Phrase. В классе Phrase мы разбиваем строку на сообщение — ответ.
По-сути спецсимволы для нашего бота — мусор. Ему необязательно знать, где во входящем сообщении стоит запятая, он должен ориентироваться по словам и цифрам. Строка
  1. .replaceAll("[^\\p{L}\\p{Nd} ]+", "")
как раз это и делает.
К слову, наш бот не разбирается, что перед ним: вопрос или утверждение. Но он может понять это по совместным словам: что, где, как и т.д.

После выполнения скрипта, мы получим такой вывод в SQL-формате:
  1. ('0', 'Хорошее число ты написал.'),
  2. ('1', 'У меня с цифрами проблемы.'),
  3. ('1 декабря', 'С наступающим Новым Годом'),

Осталось только создать таблицу в БД и залить дамп.
  1. CREATE TABLE IF NOT EXISTS `bot` (
  2.   `id` int(11) NOT NULL AUTO_INCREMENT,
  3.   `message` text NOT NULL,
  4.   `answer` text NOT NULL,
  5.   PRIMARY KEY (`id`),
  6.   FULLTEXT KEY `message` (`message`)
  7. ) ENGINE=MyISAM  DEFAULT CHARSET=utf8;


Алгоритм выбора фраз
Алгоритм прост. Пользователь пишет сообщение, которое затем передаётся боту на обработку. Там мы убираем все лишние символы из сообщения и ищем в БД похожую запись. Нашли одну — выдаём в качестве ответа. Нашли несколько — выбираем случайную из найденных. Не нашли ничего похожего — выводим случайный ответ.

Теперь по порядку и с кодом.

1. Поступило сообщение, отдаём его на фильтрацию:
  1. private function filter($message) {
  2.      $msg = mb_strtolower($message, 'UTF-8'); // переводим текст в нижний регистр
  3.      $msg = str_replace(self::NAME . ',', '', $msg); // убираем обращение к боту
  4.      $msg = preg_replace('#\[c(.+?)\[/c\]#sui', '', $msg); // убираем цитату на случай, если Magatino лень нажать Ответить
  5.      $msg = preg_replace('/[^a-zа-я0-9 ]/u', '', $msg); // убираем все спецсимволы
  6.      $msg = preg_replace('/\s+/', ' ', $msg); // несколько подряд идущих пробелов заменяем на один
  7.      return trim($msg);
  8. }

2. Выбираем фразу по явному совпадению:
  1. SELECT `answer` FROM `bot` WHERE `message`="привет" ORDER BY RAND() LIMIT 1

3. Если такой не нашли, тогда воспользуемся более умным вариантом:
  1. SELECT `answer` FROM `bot`
  2.   WHERE MATCH (`message`) AGAINST ("привет" IN NATURAL LANGUAGE MODE) > 0
  3.   ORDER BY RAND() LIMIT 1

4. Если и здесь пусто — выбираем любой ответ из доступных:
  1. SELECT `answer` FROM `bot` ORDER BY RAND() LIMIT 1

Наиболее интересен, вероятно, второй запрос. Он ищет совпадения основываясь на естественном языке. Вот отличия:
sql_1.png sql_2.png

Вот, собственно, и весь мозг.
PHP класс Bot.class.php

Использовать вот так (forum/say.php):
  1. if ($id == 226621) { // id темы, где будет отвечать бот
  2.     require_once '../modules/Bot.class.php';
  3.     $answer = Bot::byMessage($msg)->getAnswer();
  4.     $realtime++;
  5.     mysql_query("INSERT INTO `forum` SET
  6.        `refid` = '$id',
  7.        `type` = 'm' ,
  8.        `time` = '$realtime',
  9.        `user_id` = '" . Bot::ID . "',
  10.        `from` = '" . Bot::NAME . "',
  11.        `ip` = '0',
  12.        `soft` = '',
  13.        `text` = '" . mysql_real_escape_string($llogin . ', '.  $answer) . "'");
  14.     mysql_query("UPDATE `users` SET `lastdate` = '$realtime'  WHERE `id` = '" . Bot::ID . "'");
  15. }


База данных. Часть 2
Четырёх тысяч сообщений явно мало, нужно пополнить базу чем-нибудь таким, что соответствует тематике сайта. Парсить каждое сообщение на форуме не лучший вариант — не ясно где вопрос, где ответ. Но вот сообщения, содержащие цитату, нам вполне подходят. В них уже есть вопрос и прям в том же сообщении ответ.

Сперва достаём из базы форума все сообщения, содержащие цитату:
  1. SELECT `text` FROM `forum` WHERE `text` REGEXP '^\\[c(=|\])*'

Затем сохраняем дамп. У меня получился файл в 12 Мб, содержащий 38 тысяч записей.
Теперь всё это дело нужно распарсить. Снова зовём на помощь Scala.
  1. import scala.collection.mutable.HashSet
  2. import scala.io.Source
  3. import java.io.File
  4.  
  5. object PhrasesExtractor {
  6.   val filterWords = Array( // снова фильтр с матами
  7.     "samobot", "http"
  8.   )
  9.  
  10.   def main(args: Array[String]) {
  11.     var set = Source.fromFile(args(0), "UTF-8").getLines
  12.       .filter(_.startsWith("('"))
  13.       .map[Phrase](line => Phrase(line.trim))
  14.       .filter(_.censor)
  15.       .toSet
  16.     set.toList.sorted.foreach(println)
  17.   }
  18.  
  19.   case class Phrase(line: String) extends Ordered[Phrase] {
  20.     val pattern = """^\('\[c[^\u0666]+\\r\\n([^\u0666]+)\[\/c\]([^\u0666]+)'\)[,|;]$""".r
  21.     val t = line match {
  22.         case pattern(msg, ans) => (msg, ans)
  23.         case _ => ("", "")
  24.     }
  25.     val message = messageFilter(t._1.trim)
  26.     val answer = answerFilter(t._2.trim)
  27.  
  28.     def messageFilter(str: String) = { // Фильтр сообщений
  29.       var s = str.toLowerCase
  30.       // Убираем ники в начале
  31.       s = s.replaceAll("^[\\p{L}\\p{Nd}\\\\_]+, ", "")
  32.       // Убираем спецсимволы и цифры, кроме пробела
  33.       s = s.replaceAll("[^\\p{L} ]+", " ")
  34.       // Убираем одиночные латинские символы, оставшиеся после смайлов
  35.       s = s.replaceAll("\\b\\w\\b", " ")
  36.       s = s.replaceAll("\\s+", " ");
  37.       s = s.trim
  38.       s.replace("'", "''")
  39.     }
  40.  
  41.     def answerFilter(str: String) = { // Фильтр ответов
  42.       // Убираем ники в начале
  43.       var s = str.replaceAll("^[\\p{L}\\p{Nd}\\\\]+, ", "")
  44.       s = s.trim
  45.       s.replace("'", "''")
  46.     }
  47.  
  48.     def censor : Boolean = {
  49.       if (message.isEmpty || answer.isEmpty) return false
  50.       if (5 > message.length || message.length > 150) return false
  51.       if (3 > answer.length || answer.length > 200) return false
  52.       for (word <- filterWords)
  53.         if (line.toLowerCase.contains(word)) return false
  54.       true
  55.     }
  56.  
  57.     // дальше всё как и в первом примере
  58. }

В итоге мы получили около 16000 пар фраза-ответ, которые можно залить в БД.

На всякий случай скажу с какой проблемой столкнулся в регулярках Scala (и Java). Дело в том, что обычное .* не хочет работать для символов юникода. Пришлось использовать специальные выражения \p{?}. В результате маленькое выражение:
  1. [.*]+
растаращивает до:
  1. [\p{L}\p{Nd}\p{C}\p{P} ]+

В итоге имеем такого монстра:
  1. ^\('\[c[]+\\r\\n([\p{L}\p{Nd}\p{C}\p{P} ]+)\[\/c\]([\p{L}\p{Nd}\p{C}\p{P} ]+)'\),$
\p{L} — Letter — буква в юникоде
\p{N} — Number — любое представление числа в юникоде (целое, вещественное и т.д.)
\p{P} — Punctuation — знаки пунктуации
\p{C} — Colobok — прочие символы в юникоде

Подробнее об этом можно почитать здесь

Кстати, если вы, как и я, валенок в регулярных выражениях, вам поможет сайт https://regex101.com/ (за ссылку благодарим Ксакепа)

Всем спасибо за прочтение. Теперь интерес к боту пропадёт.
  • +22
  • views 11925