Привет, Хабр! На связи снова Александр Пиманов (по-прежнему iOS-разработчик МТС Диджитал). Сегодня поделюсь своим опытом в одной интересной нишевой теме: фильтрации нецензурной лексики в приложении для iOS.

Да, мало кому может понадобиться фильтровать мат на клиенте, но  если у вас есть функция нейминга элементов в UI (добавление кастомного имени страницы, кнопки и так далее), запрос от бизнеса на такой фильтр и вы хотите сделать «проверку на дурака», то эта статья для вас. Прелюдия окончена, все подробности под катом.

Как я докатился до такой жизни

Решение делать первичную фильтрацию текста на клиенте пришло внезапно: бэк-разработчик закинул идею, мне она понравилась, а бизнес оценил положительно.

Возможно, сейчас в меня полетят помидоры: дорого по ресурсам, да и вообще, эта затея не имеет смысла. Но у нас большая часть сообщений летит с сокета, где уже есть фильтрация текста с бэка. Я же решил сделать первичную проверку мата на клиенте, чтобы избежать неприятных сюрпризов.

Регулярное выражение

Потратив N времени на ресерч, я нашел open-source регулярку под Java-машину. В этой регулярке берутся все популярные и часто используемые вариации мата (на одно слово несколько вариаций синтаксиса) и летят в основу фильтра, который я покажу чуть позже. Я переписал её на Swift с учетом всех особенностей языка и немного подправил.

Хочу отметить, что это первичная проверка на самые популярные слова и их разновидности. Никакая регулярка не покроет вам 100% кейсов, ибо на сцену вступает человеческий фактор: если юзер захочет выругаться, поверьте, он этo_!cделает, какая бы защита у вас не стояла. В нашем случае первичная фильтрация при отправке сообщений в чат покроет порядка 80–90% нецензурных выражений.

Проверка

Как я говорил выше, прогонять через фильтр по регулярке и искать совпадения мы будем только в пользовательских сообщениях:

func filterSwearWords(in message: String) -> String {
        do {
            let regex = try NSRegularExpression(pattern: swearWordsPattern, options: [.caseInsensitive, .allowCommentsAndWhitespace])
            let range = NSRange(location: 0, length: message.count)
            let matches = regex.matches(in: message, options: [], range: range)
            
            var filteredMessage = message as NSString
            
            // reversed() is to ensure that earlier replacements do not affect the positions of later matches.
            for match in matches.reversed() {
                let matchedWord = filteredMessage.substring(with: match.range)
                let isFirstWord = message.starts(with: matchedWord)
                
                guard matchedWord.count > 2,
                      let firstCharacter = isFirstWord ? matchedWord.first?.uppercased() : matchedWord.first?.lowercased(),
                      let lastCharacter = matchedWord.last else { return message }
                
                let replacement = "\(firstCharacter)***\(lastCharacter)"
                filteredMessage = filteredMessage.replacingCharacters(in: match.range, with: replacement) as NSString
            }
            
            return filteredMessage as String
            
        } catch {
            print("Creating regex error: \(error.localizedDescription)")
            return message
        }
    }

Обратите внимание на строчку, где мы входим в цикл. Я там поставил reversed() так как словил интересный баг: ранние замены влияют на позиции более поздних совпадений.

Затем создается подстрока с нашим совпадением. Ее содержимое проверяется на положение этого слова в контексте всего сообщения (в начале или нет).

Если слово удовлетворяет требованию регулярки и в нем больше двух букв, я беру первую и последнюю букву и вставляю между ними ***. Я решил не подгонять число звездочек под количество заменяемых символов. Это вкусовщина, но фиксированный вариант показался самым подходящим.

Ну вот, собственно, и все! Теперь просто вызываем этот метод в момент отправки сообщения, куда скармливаем текст из нашего text field, и наблюдаем магию:

Скрытый текст

Выводы

По-хорошему такую регулярку можно и нужно получать с бэкэнда, чтобы не хранить локально. Со своей задачей она справится и сделает общение в чате «чище».  На этом у меня все, надеюсь, статья была вам о***о полезна!