Сайт Романа ПарпалакаБлог

Как разработать систему рекомендаций

16 февраля 2023 года, 01:17

Продолжим разговор о системе рекомендаций в S2. Эта система подбирает к каждой заметке набор других заметок, которые посетитель может почитать дальше. В прошлый раз я рассказал об этой системе в целом, сейчас же опишу алгоритм подбора самих рекомендаций.

За рекомендации в моем случае отвечает движок полнотекстового поиска Rose. Структура данных в полнотекстовом индексе как раз подходит для задачи подбора похожих заметок. Если совсем упростить, то получается, что обычный поиск — это подбор подходящих заметок к словам из поискового запроса, а рекомендации к заметке — это подбор других заметок по словам из нее. А теперь давайте погрузимся в детали.

Теория рекомендаций

Для начала давайте поймем, как вообще могут работать системы рекомендаций. Рассуждать будем на примере существовавшего когда-то сайта «Имхонет». Я выставлял на нем оценки просмотренным фильмам, а сайт говорил, какие еще фильмы мне стоит посмотреть. Понятно, что такие рекомендации основаны на оценках других пользователей, которые уже успели посмотреть эти фильмы. Качество таких рекомендаций зависит от количества пользователей сервиса: чем больше пользователей, тем с большей вероятностью среди них найдутся похожие на меня по предпочтениям.

Оценки пользователей сохраняются как веса связи «многие-ко-многим» между пользователями и фильмами. Через эту связь по пользователю можно найти фильмы, которые ему понравились. По фильмам — других пользователей, которым понравились эти фильмы. И в качестве рекомендаций выдать другие понравившиеся пользователю фильмы. Но где на шкале оценок провести границу, отделяющую понравившиеся фильмы? И что делать с фильмами, которые не понравились?

Математика позволяет предложить более формальный и универсальный подход. Будем рассматривать оценки к фильмам как координаты некоторой точки во многомерном пространстве всех фильмов. Тогда всех пользователей можно представить как множество точек в таком пространстве. При достаточном количестве они начнут группироваться в кластеры по разным предпочтениям. После этого задача подбора рекомендаций сведется к поиску соответствующего кластера.

На самом деле, картина сложнее, так как никто не может поставить оценки всем фильмам. Поэтому вместо точек мы имеем дело с некоторыми подпространствами (прямыми, плоскостями и т. д.). Чтобы сформировать мои рекомендации, система проецирует все оценки на подпространство просмотренных мной фильмов, находит кластер в проекции, и по нему уже пытается восстановить кластер в полном пространстве.

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

Эта теория прекрасно выглядит на листе бумаги. Но я уверен, что при практической реализации разработчики столкнулись с кучей проблем. Очевидная проблема — нормировка оценок. Например, у меня средняя оценка была около 7. Оценки меньше 4 я практически не ставил. Задумывался над тем, чем отличается оценка 9 от 10. Оценки других пользователей наверняка отличались по характеристикам. Кто-то, например, мог ставить только две оценки, 1 и 10. Чтобы рекомендации работали, оценки нужно нормализовать — привести к одному масштабу.

Вы наверняка встречались с другим примером системы рекомендаций: поиском друзей в соцсетях. Здесь тоже работает связь «многие-ко-многим». Добавление друга — это и есть создание такой связи между двумя пользователями. На её основе соцсеть рекомендует добавить в друзья тех людей, которые есть в друзьях у ваших друзей.

Теперь давайте посмотрим, как можно эти знания применить для подбора рекомендаций заметок.

Рекомендации на основе тегов

Как видно из предыдущего рассмотрения, систему рекомендаций можно сделать везде, где есть связь «многие-ко-многим». Именно так связаны заметки и теги. Если вы проставляете заметкам теги, то по тегам можно найти другие заметки с такими же тегами.

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

Рекомендации на основе похожих текстов

В движке S2 есть другая связь «многие-ко-многим» — поисковый полнотекстовый индекс. Эта структура данных может вернуть по слову список проиндексированных элементов, содержащих такое слово. В библиотеке Rose полнотекстовый индекс хранится в отдельной таблице БД из трех колонок. Вот пример:

word_id   toc_id   positions
1 1 0,37
3 4 0,15,74,193,614
3 8 94
3 9 73
4 1 3,16,57

В первой колонке хранится id «слова», во второй — внутренний id проиндексированного элемента (ToC — это сокращение от table of contents), в третьей — положения соответствующего слова в проиндексированном тексте.

При индексации исходный текст заметок очищается от html-тегов, разбивается на предложения и слова. Слова переводятся в нижний регистр. У слов удаляется окончание с помощью эвристического алгоритма (стеммер Портера). Оставшиеся основы слов заменяются на идентификаторы word_id и попадают в полнотекстовый индекс, при этом неизвестные основы добавляются в справочную таблицу word.

При поиске запрос преобразуется по такой же схеме: слова заменяются на идентификаторы word_id, и из поискового индекса мы получаем информацию о том, в каких проиндексированных текстах (toc_id) встречались эти слова. Положения слов (positions) нужны для вычисления релевантности: чем ближе слова из запроса друг к другу в тексте, тем выше окажется этот текст в выдаче.

Рекомендации на основе близости текста тоже используют эту таблицу. У меня получилось уместить все существенные вычисления в один SQL-запрос.

SELECT
    relevance_info.*, -- информация из подзапроса
    m.images, -- добавляем к ней информацию о картинках
    t.*, -- добавляем к ней оглавление
    -- и первые 2 предложения из текста
    (SELECT snippet FROM snippet AS sn WHERE sn.toc_id = t.id ORDER BY sn.max_word_pos LIMIT 1) AS snippet,
    (SELECT snippet FROM snippet AS sn WHERE sn.toc_id = t.id ORDER BY sn.max_word_pos LIMIT 1 OFFSET 1) AS snippet2
FROM (
    SELECT -- Перебираем все возможные заметки и вычисляем релевантность каждой для подбора рекомендаций
        i.toc_id,
        round(sum(
            original_repeat + -- доп. 1 за каждый повтор слова в оригинальной заметке
            exp( - abn/30.0 ) -- понижение веса у распространенных слов
                * (1 + length(positions) - length(replace(positions, ',', ''))) -- повышение при повторе в рекомендуемой заметке, конструкция тождественна count(explode(',', positions))
        ) * pow(m.word_count, -0.5), 3) AS relevance, -- тут нормировка на корень из размера рекомендуемой заметки. Не знаю, почему именно корень, но так работает хорошо.
        m.word_count
    FROM fulltext_index AS i
        JOIN metadata AS m FORCE INDEX FOR JOIN(PRIMARY) ON m.toc_id = i.toc_id
    JOIN (
        SELECT -- достаем информацию по словам из оригинальной заметки
            word_id,
            toc_id,
            (SELECT count(*) FROM fulltext_index WHERE word_id = x.word_id) AS abn, -- распространенность текущего слова по всем заметкам
            length(positions) - length(replace(positions, ',', '')) AS original_repeat -- сколько раз слово повторяется в оригинальной заметке. Выше используется как доп. важность
        FROM fulltext_index AS x FORCE INDEX FOR JOIN(toc_id)
        JOIN toc AS t ON t.id = x.toc_id
        WHERE t.external_id = :external_id AND t.instance_id = :instance_id
            AND length(positions) - length(replace(positions, ',', '')) < 200 -- отсекаем слишком частые слова. Хотя 200 слишком завышенный порог, чтобы на что-то влиять
        HAVING abn < 100 -- если слово встречается более чем в 100 заметках, выкидываем его, так как слишком частое. Помогает с производительностью
    ) AS original_info ON original_info.word_id = i.word_id AND original_info.toc_id <> i.toc_id
    GROUP BY 1
    HAVING count(*) >= :min_word_count -- количество общих слов, иначе отбрасываем
) AS relevance_info
JOIN toc AS t FORCE INDEX FOR JOIN(PRIMARY) on t.id = relevance_info.toc_id
JOIN metadata AS m FORCE INDEX FOR JOIN(PRIMARY) on m.toc_id = t.id
ORDER BY relevance DESC
LIMIT :limit

Опишу ключевые шаги, которые здесь выполняются.

1. Взять все слова в заметке, к которой подбираем рекомендации. Я называю эту заметку оригинальной. Выбор слов происходит в самом внутреннем подзапросе.

2. Выкинуть распространенные слова. Это нужно делать для повышения точности и при поиске, и при подборе рекомендаций. Можно даже составить список игнорируемых слов вроде союзов или предлогов. Но вместо составления такого неизменяемого списка я вычисляю распространенность (abundance, сокращенно abn — количество заметок, в которых встречается это слово) для каждого слова в индексе. Например, в блоге о дизайне в каждой заметке будет слово «дизайн», и его тоже надо игнорировать.

Слова с распространенностью больше 100 наверняка окажутся слишком общими, и я отбрасываю их по соображениям производительности. Скорее всего порог должен как-то зависеть от общего количества заметок (их в моем случае около 1000), но я не придумал, как именно.

3. Найти одинаковые слова у оригинальной заметки с остальными заметками. Это происходит в промежуточном подзапросе. У заметок при этом должно быть достаточное количество общих слов (порог определяется параметром min_word_count).

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

4. По повторяющимся словам вычислить релевантность. Это тоже происходит в промежуточном подзапросе в выражении в селекте благодаря group by. Релевантность я вычисляю как количество повторений общих слов. Чтобы понизить влияние распространенных слов, я добавил ослабление за счет веса exp(-abn/30.0). Хотел было использовать колоколообразную функцию типа exp(-sqr(abn/30.0)), но на практике линейное уменьшение веса при малых значениях распространенности повысило качество рекомендаций.

Кроме того, повторы в оригинальной заметке (original_repeat) и в рекомендуемых заметках влияют на релевантность несимметрично: повторяющиеся слова в оригинальной заметке не ослабляются, даже если они распространены. Объяснение можно предложить такое: если автор пишет одинаково часто о шахматах и шашках, то к оригинальной заметке с пятью словами «шахматы» и одним словом «шашки» лучше рекомендовать заметку с одним словом «шахматы», чем с пятью словами «шашки». Эффект несимметричности я не закладывал специально. Практика показала, что отсутствие ослабления у original_repeat субъективно улучшает качество рекомендаций.

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

Последний множитель в релевантности pow(m.word_count, -0.5) учитывает размер рекомендуемой заметки в словах. Без него в моем случае среди рекомендуемых оказывались очень длинные заметки, набиравшие релевантность за счет большого количества повторяющихся слов средней распространенности. Тогда я подумал, что сортировать рекомендации нужно не по абсолютному количеству общих слов, а по относительному, то есть надо поделить вычисленную релевантность на количество слов в рекомендуемой заметке. В рекомендации стали попадать короткие заметки всего из нескольких слов, а у нормальных заметок из нескольких сотен слов релевантность сильно просела. Чтобы было ни нашим ни вашим, я попробовал поделить абсолютную релевантность на корень из длины рекомендуемой заметки, и это сработало: с первых мест рекомендаций ушли как очень короткие, так и очень длинные заметки. Изменение показателя степени −0,5 в обе стороны приводило к некоторому повышению ранга одних и понижению ранга других таких нерелевантных заметок.

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

5. Получить заголовок, картинки и фрагмент текста. Это неинтересная техническая задача, решаемая во внешней части запроса. Для «сниппетов» — коротких фрагментов текста — я достаю первые два предложения из заметок. Сначала думал выводить те предложения из текста, которые содержат общие слова. Зависимость сниппетов от контекста как раз бы показала, почему рекомендуется именно эта заметка. Но sql-запрос и так получился достаточно объемным, и пока я остановился на упрощенном варианте. Возможно движок как продукт с таким упрощенным вариантом будет даже лучше. Если писать заметки так, чтобы первые предложения давали понять, о чем будет заметка, то показывать лучше их, а не случайные предложения из самого текста.

Вопросы производительности

В запросе вы видите явное указание использовать конкретные индексы. Без них планировщик не использовал часть индексов. Почему он так решал — непонятно. За счет расстановки хинтов я оптимизировал запрос раз в 20 до нескольких десятков миллисекунд. Я последние 6 лет работаю с PostgreSQL, и он даже думать отучил, что в запросы можно добавить хинты. Но тут пришлось.

Производительность в несколько десятков миллисекунд я посчитал достаточной, чтобы выполнять sql-запрос подбора рекомендаций на лету, без какого-либо кеширования. Да и с алгоритмической, и с продуктовой точек зрения сохранять подобранные рекомендации смысла нет, потому что алгоритм детерминирован: при одинаковых входных данных всегда будет выдаваться одинаковый результат, и рекомендации всегда можно пересчитать.

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

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

Я добавил кеширование подбора рекомендаций. Кеш инвалидируется не по времени, а по обновлению заметок. Правда, мне пришлось инвалидировать весь кеш рекомендаций при любом изменении опубликованных заметок. Действительно, изменение хотя бы одного слова в какой-либо заметке может привести к тому, что эта заметка может появиться или исчезнуть из рекомендаций к произвольному количеству заметок. Формально можно было бы уже знакомым нам образом пройтись по индексу и по словам из измененной заметки понять, в каких других заметках они встречаются. Но всё же проще инвалидировать весь кеш.

Кеш хорошо решает проблему с редкими выбросами, даже если заметки часто обновляются. Если в кеше есть устаревшие рекомендации, всё равно выводятся они, а в фоне в это время просчитываются новые рекомендации.

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

Направления развития

Дополнение о нормальной форме

Внимательный читатель отметил, что таблица полнотекстового индекса не находится даже в первой нормальной форме: в одной ячейке positions через запятую перечислен список положений слова. Что хорошо в теории, не всегда хорошо в настоящем работающем софте. Раньше действительно структура этой таблицы была другой, и каждый элемент из positions располагался на своей строке. Для корректной работы алгоритма мне нужно было обеспечить уникальность строк, поэтому элементы (word_id, toc_id, position) я еще добавил в уникальный индекс.

Достаточно быстро в целях оптимизации я отказался от индекса по word_id и повесил первичный ключ сразу на все колонки (word_id, toc_id, position). В этом есть смысл, так как первичный индекс в InnoDB кластерный, то есть данные строк хранятся на диске вместе с первичным индексом.

Сейчас я пошел в оптимизации дальше и отказался от нормальной формы для хранения положений. Базы данных устроены так, что в таблицах в каждой строке хранится дополнительная служебная информация. Объединение нескольких строк с одинаковыми word_id и toc_id в одну дало экономию места в полтора раза (поисковый индекс в целом уменьшился с 22 до 14 мегабайт при суммарном объеме заметок 2,8 мегабайт). Кроме того, скорость индексации тоже выросла примерно в полтора раза, так как сократилось количество выполняемых запросов. Я не обнаружил какого-либо заметного влияния формата поля positions на объем (кроме строки через запятую пробовал json и бинарную последовательность int4).

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

    Оставить комментарий

Запрет висячих заголовков в CSS

11 февраля 2023 года, 00:38

Пару лет назад я сделал стили для печати в своем редакторе математических текстов Upmath. Тогда же я отмечал проблемы подхода, в частности, «висячие заголовки». Не уверен, что такой термин существует, под ним я имею в виду заголовок, расположенный последним на странице и оторванный от следующего текста.

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

Есть два варианта синтаксиса, сейчас работают оба:

h1, h2, h3 {
    page-break-after: avoid;
    break-after: avoid;
}

Результат применения выглядит так (было — стало):

Проверил еще Firefox, в нем запрет разбиения не заработал. А больше браузеров-то и не осталось.

    Оставить комментарий

Cистема рекомендаций на сайте

4 февраля 2023 года, 01:01

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

Пример 1 — антикоррупционный митинг:

Пример 2 — где учиться, на физтехе или физфаке:

Пример 3 — сворачивание кешбека в Бинбанке:

Систему рекомендаций в таком виде сделал Илья Бирман в Эгее — своем движке блогов. В его случае рекомендации к постам формируются на основе тегов. Тогда у меня появилась идея, как можно подбирать рекомендации на основе анализа текстов, без необходимости расставлять теги. Но одно дело — идея, и совсем другое — работающий продукт.

Подбор рекомендаций

Чтобы воплотить идею в жизнь, мне пришлось проделать много подготовительной работы.

Я подключил к моему движку сайтов S2 поисковый движок Rose. Вообще-то Rose сам по себе — это выделенный из S2 и доработанный поисковый алгоритм. Раньше он назывался просто Search (соответственно, пакет в репозитории композера — s2/search). Но Илья убедил меня, что библиотеке для поиска, как и любому продукту, нужно нормальное имя, и даже предложил несколько вариантов. Название «Ropsen», содержащее первые буквы из Roman Parpalak Search Engine, как-то не прижилось. Когда часть букв выкинули, и осталось Rose, я согласился.

Вместе с именем в Розе многое поменялось внутри. Я привел в порядок код, чтобы он следовал правилам хорошего тона для библиотек на PHP: с интерфейсами, инверсией зависимостей и прочими вещами, скрытыми за аббревиатурой SOLID. Кроме того, я сделал реализацию хранилища поискового индекса в MySQL (предыдущая реализация была просто в файле).

Поиск в S2 продолжал работать на старой кодовой базе. Мне казалось, что потребуется много времени, чтобы удалить из легаси-кода S2 старый алгоритм поиска и подключить Розу. На практике оказалось, что я сделал это меньше чем за день. Такая скорость вдохновила меня, и я решил замахнуться на рекомендации.

Почему я вообще в заметке о рекомендациях пишу уже четвертый абзац не о рекомендациях, а о поиске? Потому что рекомендации к некоторой заметке на основе ее текста — это, грубо говоря, результаты поиска по словам из этой заметки. Мне удалось написать один-единственный SQL-запрос, который возвращает список рекомендуемых заметок напрямую из поискового индекса. В следующий раз я опишу подробнее, как работает такой метод и с каким качеством.

Оформление рекомендаций

Чтобы выводить рекомендуемые заметки не просто списком, мне пришлось доработать поисковый движок Rose, чтобы в нем сохранялись предложения из проиндексированных заметок и информация о картинках.

Мне очень понравилось, как выглядят рекомендации у Ильи, и я решил сделать так же. Кроме того, он в своем докладе об автоматическом дизайне рассказал, каким образом работает автоматическая верстка рекомендаций в Эгее. Он подготовил список хорошо сверстанных вариантов и перевел их в некоторый декларативный конфиг с описанием критериев соответствия для каждого элемента верстки (вроде размера и пропорций картинок, длины заголовка и прочего). Дальше для набора рекомендуемых заметок подбирается наиболее подходящий вариант верстки. Обязательно посмотрите видео по ссылке о том, как это всё придумано и работает.

После повторного просмотра видео доклада, которое можно считать подробным и понятным техзаданием, я понял, что не смогу придумать какой-либо другой способ автоматической верстки. Мне казалось, что список хорошо сверстанных вариантов — это ключевой элемент, без которого сделать систему нельзя. И не очень хотелось брать какие-либо варианты из скриншотов Ильи, чтобы не было прямого заимствования.

Тем не менее, я сделал свой декларативный язык. Описал несколько очевидных вариантов верстки, например для пяти рекомендаций: одна крупная рекомендация слева и четыре поменьше справа.

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

В результате таким способом, никуда не подглядывая, я накопипастил 113 вариантов верстки. Ближе к концу я стал понимать, что это какой-то перебор, но отступать было поздно :) Я объясняю такое количество вариантов тем, что у меня в заметках не очень много картинок. Чтобы в рекомендации попадали хоть какие-то картинки, нужно выводить побольше рекомендаций и доставать картинки, скажем, с седьмого или восьмого места.

В процессе мне пришлось изобрести некоторые неочевидные приемы. Например, после определения варианта верстки я сортирую рекомендации по убыванию высоты картинок, а если высота одинакова или картинок вообще нет — по убыванию длины текста. Это нужно, чтобы дыры в верстке появлялись ближе к правому краю, который и так рваный, а не к левому. С дырами получалось забавно: я начал было подбирать длины текста так, чтобы он максимально заполнял дыры. Но рекомендации в некоторых пределах резиновые, а при изменении ширины страницы такие объекты как картинка и текст ведут себя по-разному. При уменьшении ширины высота картинок тоже пропорционально уменьшается, а высота текста вместе с количеством строк растет. Пришлось смириться с неплотным заполнением этажа рекомендаций.

Еще одно изобретение — отрицательная максимальная длина текста. Она появилась при попытке собрать из картинки и текста блок примерно одинаковой высоты. Скажем, на какое-то место в верстке идеально подходит картинка с относительной высотой (ratio) 0,6 от ширины. Если высота картинки меньше, скажем, 0,2 от ширины, то кроме нее нужно вывести короткое описание для заполнения блока.

Чтобы не плодить разные варианты одной и той же верстки с таким отличием, я придумал характеризовать текст не только минимальной и максимальной длиной, но и дополнительным коэффициентом, на который умножается «нехватка» высоты картинки для определения дополнительной длины текста. А если картинка с высотой 1,0 тоже подходит, то у картинки с высотой 0,6 за счет добавки обязательно появится текст.

Сначала я хотел сделать дополнительный параметр для отсчета нехватки высоты от 0,6, а не от 1,0. Но потом понял, что того же можно добиться отрицательной длиной текста.

Ограничения рекомендаций и планы на будущее

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

Сейчас, если в блоке рекомендаций надо вывести текст, я беру одно-два первых предложения. Я попробую выводить не первые предложения, а те предложения, которые содержат общие слова и которые, по идее, должны полнее отражать суть рекомендации.

    1 комментарий

Зарисовка для объяснения понятия формата

24 января 2023 года, 16:55

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


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

Чтобы пояснить, что я подразумеваю под форматом, рассмотрим для примера газеты и книги. И то и другое — печатная продукция. На газетах распространяется небольшая порция ежедневной информации. Их после прочтения выбрасывают. Поэтому газеты печатают на дешевой тонкой бумаге. Книги объемнее, используются и хранятся долго. Их печатают на хорошей плотной бумаге и переплетают.

Можно представить, что будет с романом, напечатанным в формате газеты, а не формате книги. А вот более яркий пример. Представьте, что вы работаете в типографии и к вам приходит клиент.
— Я хочу напечатать свою поэму.
— Какой у вас тираж?
— Штук сто, где-то, буду раздавать друзьям и знакомым.
— Большая поэма? Сколько страниц?
— 100 страниц в Ворде. Но я не хочу, чтобы это была обычная книга. Я хочу напечатать на рулоне бумаги.
— Что? — вырывается у вас с плохо скрываемым удивлением.
— Ну на таком рулоне, типа как обои, только поменьше, — размахивает руками клиент.
— А чем вас книга не устраивает?
— Книга? Это вчерашний день. Рулон удобнее.
— Чем же он удобнее?
— Как чем? Страницы в книге нужно перелистывать. Это неудобно. На время перелистывания отрываешься от чтения. Ну и если книга закроется, трудно найти место, где читал, если не запомнить страницу.
— И что? А причем тут рулон?
— Ну как причем? Если напечатать на рулоне, то в процессе чтения его легко перематывать. Не нужно перескакивать с одной страницы на другую. И рулон можно спокойно отложить, он не закроется.
— Я вообще первый раз такое слышу. Все печатают и читают книги. Их удобно носить и держать на полке. И недостатки не такие уж и большие. Есть же закладки, оглавление. И перевернуть страницу ничего не стоит.
— Если все что-то делают, это не значит, что это правильно. Книги — вчерашний день. Я не хочу, чтобы читатель отрывался от моей поэмы, пока он перелистывает страницы.
— Извините, у нас нет такого оборудования, — мягко посылаете клиента.

Разговор в таком стиле вряд ли возможен о привычных предметах окружающего мира, но постоянно встречается при обсуждении компьютерных интерфейсов. Я задумался, почему так происходит, когда прочитал на хабрахабре статью о критике современных веб-технологий (HTML, CSS и JS) в сравнении с древним API для рисования окон. Хотя автор сравнивает инструменты для решения разных задач, и в комментариях разгорелась объемная дискуссия, статья набрала больше сотни плюсов. Я понял, что люди не имеют представления о формате. Это видно из вопроса автора:

Как нормально сверстать хотя бы такой блокнотик с закладками на CSS/HTML без извращений и большого количества JS кода вовсе не ради динамики, а прямо для самой отрисовки и позиционирования?

И из моего ответа:

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

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

Проблема нехватки места в вебе прекрасно решается прокруткой. Поэтому, чтобы решить ту же задачу, нужно поместить друг под другом содержимое каждой вкладки, предваренное крупными заголовками. У такого метода целая куча достоинств. К прокрутке привыкли все. На больших экранах пользователь увидит сразу всю форму. Таким образом, на HTML/CSS блокнотика нет, но задача, которую он выполнял, решается.

При проектировании интерфейсов не нужно забывать о формате.


Еще в черновике было написано graceful degradation и progressive enhancement. Скорее всего, я хотел сказать, что начинать разработку надо с чистого HTML, а затем добавлять оформление в CSS и поведение в JS (progressive enhancement). А не делать наоборот — сначала разработать веб-приложение, а потом думать, как его заставить работать в простых браузерах. И хотел привести пример с веб-интерфейсом твитера. Лет 10 назад они переходили от клиентского рендеринга HTML к серверному, чтобы хоть что-то показывать в браузерах без JS и чтобы интерфейс не так тормозил.

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

    Оставить комментарий

Мастер костылей, или сущности в DOMDocument

21 января 2023 года, 01:19

Я тут решил потратить некоторое время на собственные проекты. Сейчас перерабатываю движок для поиска Rose. На нем работает поиск на этом сайте, также его использует Илья Бирман в Эгее.

Для извлечения текста из html-исходников решил взять DOM API, как советует народ. Вообще, конечно, регулярки — наше всё :) Но HTML нормально распарсить регулярками нельзя. А мне надо распарсить, чтобы удалить из исходника стили, скрипты и комментарии (попробуйте на досуге написать регулярку, которая будет определять, находится ли разделитель в контексте комментария или нет). Кроме того, парсинг поможет точно разбить текст на абзацы, чтобы их дальше разбить на предложения. Поэтому я взял волю в кулак, посмотрел, как использовать DOM API, и рекурсивно обошел dom-дерево на PHP.

Как оказалось, DOM API не поддерживает HTML5. Это выливается в практическую проблему с непоследовательной обработкой сущностей HTML. Например, если в исходнике написать &amp; &bigstar;, при получении текстового содержимого из dom-узла получим & &bigstar;. Видим, что первая сущность из HTML предыдущих версий распознается и раскодируется, а вторая из HTML5 — нет. Такие ошибки приведут к искажениям при выводе сниппетов.

Моя первая идея — прогнать текстовое содержимое через html_entity_decode($a, ENT_HTML5);. Действительно, что не доделал встроенный парсер, доделает эта функция. Но проблема в том, что раскодирование неидемпотентно. Если на вход подать &amp;bigstar;, то после DOM API мы получим &bigstar;. И на этапе повторного раскодирования мы не будем знать, нужно ли раскодировать &bigstar; еще раз, или нет.

Поиск проблемы в гугле разумного решения не выявил. В классе DOMDocument есть какое-то свойство substituteEntities, но в моем случае оно ни на что не влияло. Я уже было подумывал о возврате к регуляркам. Но всё-таки смог придумать обходное решение.

Для начала нужно все вхождения амперсанда заменить на его сущность:

$text = str_replace('&', '&amp;', $text);

После этого DOM API раскодирует копии только одой сущности — этого самого амперсанда. При рекурсивном обходе в текстовом содержимом узлов dom-дерева тегов (то есть символов < и >) не будет, а все сущности будут закодированными. Если их нужно раскодировать, можно вызвать html_entity_decode($a, ENT_HTML5);.

Уже который раз убеждаюсь, что искусство программиста во многом заключается в умении подобрать нужный костыль. Так и живем.

    5 комментариев

Отладка запросов к FastCGI из консоли

13 января 2023 года, 15:25

Обычно протокол FastCGI применяется для общения между веб-сервером и бэкендом. Например, связка nginx и PHP-FPM работает по этому протоколу. Для таких случаев есть типовые конфигурации, всё начинает работать из коробки, и особая отладка не требуется.

Однако как быть, если у вас есть собственный сервис, работающий по протоколу FastCGI (скажем, простая асинхронная очередь через PHP-FPM), и вам нужно его отладить? Как понять, где ошибка, если что-то не работает?

Можно было бы настроить в nginx отдельный location, подключить к нему отлаживаемый сервис и отправлять http-запросы через веб-сервер. Однако есть способ проще, который не требует изменения конфигурации сервисов.

Предположим, у вас есть скрипт, который забирает входные данные из $_POST['formula'] и $_POST['extension']. Тогда вызвать этот скрипт с данными formula=12345 и extension=svg можно вот так:

user@tau:~$ echo "formula=12345&extension=svg" | sudo -uwww-data \
> CONTENT_TYPE='application/x-www-form-urlencoded' CONTENT_LENGTH=28 \
> SCRIPT_FILENAME=/var/www/.../.../cache_processor.php \
> REQUEST_METHOD=POST cgi-fcgi -bind -connect /var/run/php_fpm.sock

В консоли мы увидим ответ, например, такой:

PHP message: PHP Warning: file_get_contents(...): failed to open stream: No such file or directory in ... on line 88Content-type: text/html; charset=UTF-8

В этом методе используется утилита cgi-fcgi, которая проксирует запрос из консоли к FastCGI.

    Оставить комментарий

Про войну

9 января 2023 года, 17:52

Скоро уже будет год, как идет война в Украине (я называю вещи своими именами, как и Путин). Я хотел написать о войне раньше. Текст мне не понравился, я не стал публиковать его. Но и писать о чем-то другом, сделав вид, что ничего не происходит, считаю неправильным. Поэтому я всё-таки публикую отредактированный черновик.


Решил сформулировать и записать свои мысли и ощущения. Не потому, что они представляют для кого-то большую ценность, и не потому что могут что-либо изменить (если бы могли, написал бы сразу). Хочу зафиксировать результат рефлексии и сохранить его на будущее.

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

Немного помогали проявления солидарности с Украиной в повседневной жизни. Вот, например, 26 февраля украинский флаг на гирлянде с управляемой подсветкой:

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

Из рациональных мыслей в начале войны у меня была одна, которой можно поделиться. Нападение Путина на Украину можно сравнить с нападением Сталина на Польшу в 1939 году. Он тоже проводил «военную операцию» в Польше после подписания пакта Молотова — Риббентропа и секретного протокола к нему (подлинность этих документов подтверждена МИДом в 2019 году). Но Сталин смог оказаться среди победителей во второй мировой войне и участвовать в ялтинском разделе мира только из-за безумного диктатора, Гитлера. В первые дни на основе этой аналогии можно было предположить, что Путин договорился о каком-то разделе сфер влияния с правителями Китая, который тоже вот-вот оккупирует какую-нибудь страну. Но сейчас мы видим, что в мире второго безумного диктатора не оказалось. И нормальных выходов у Путина из этой ситуации нет.

Выходить на протесты я не стал. Сейчас любой протест автоматом означает задержание. И польза от протестов может быть только в расколе элит, что возможно при численности митингов от миллионов или хотя бы нескольких сот тысяч людей. Сегодня это непредставимые числа. 10 лет назад была совершенно другая атмосфера. Как очевидцу событий на Болотной мне было ясно, что Путин самостоятельно из Кремля не уйдет. А уже 8 лет назад, когда происходили события в Крыму, «очнувшимся» людям с вопросом «а что можно сделать?» оставался только один ответ — «валить». Хотя тогда всё еще можно было выйти на марш против войны, но его численность была на порядок меньше необходимой.

В новостях сообщали о задержаниях людей даже с пустым листом бумаги. Известный анекдот советских времен про мужика, разбрасывающего пустые листовки, стал реальностью. Кроме того, стали реальностью даже лозунги из оруэлловской антиутопии «1984»: «война — это мир, свобода — это рабство, незнание — сила».


Кстати, Марш мира и Марш правды — это ответ на вопрос о том, где я был 8 лет назад. И у меня для вас есть встречный вопрос — а где вы были 11 лет назад, когда Путин в нарушение конституции возвращался в кресло президента, чтобы оставить более заметный след в учебнике истории? Если бы он не пошел на выборы в 2012 году, его бы все вспоминали как хорошего президента. А как его будут вспоминать теперь?

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

    1 комментарий

Прокси-сервер через ssh

27 февраля 2022 года, 20:50

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

ssh -D 1337 -q -C -N example.com

Разумеется, вместо example.com нужно подставить ваш хост. После запуска вы можете использовать localhost и порт 1337 как параметры SOCKS5 прокси-сервера в браузере и других программах. При этом данные будут идти через соединение по ssh с вашим сервером.

Если у вас windows, можете взять консоль WSL, установить MinGW или поискать аналогичную функциональность в PuTTY на вкладке Connection/SSH/Tunnels.

    Оставить комментарий

Как додуматься до решения олимпиадной задачи — 2

6 февраля 2022 года, 18:56

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

Условие задачи

Есть 3 различных натуральных числа $$x$$, $$y$$, $$z$$. Эти числа оказались подобраны так, что выражение

$$A={xy+yz+zx\over x+y+x}$$

тоже натуральное. Каким числом оно может быть? Иными словами, каково пересечение множества значений этой функции трех натуральных переменных и множества натуральных чисел?

Поиск решения

Идея №1: вынести в числителе за скобки $$xyz$$. Получается

$$A={\left({1\over x}+{1\over y}+{1\over z}\right)xyz\over x+y+x}.$$

Из этого я заметил, что при замене величин $$x$$, $$y$$ и $$z$$ на обратные $$1/x$$, $$1/y$$ и $$1/z$$ выражение «переворачивается», то есть $$A$$ меняется на $$1/A$$. Дальше у идеи не было очевидного развития, я решил попробовать другие идеи.

Идея №2: масштабирование. Видно, что если выполнить замену $$x$$, $$y$$ и $$z$$ на $$kx$$, $$ky$$ и $$kz$$, то числитель $$A$$ вырастет в $$k^2$$ раз, а знаменатель в $$k$$ раз, то есть $$A$$ меняется на $$kA$$. Как это можно применить? Пусть мы выбрали натуральные числа равными 1, 2 и 3. Тогда

$$A={2+6+3\over 1+2+3}={11\over 6}.$$

Чтобы из этого набора получить целое $$A=11$$, можно взять не 1, 2 и 3, а 6, 12 и 18.

Однако я не стал развивать дальше эту идею из-за ошибки. Мне показалось, что $$A$$ меняется не на $$kA$$, а на $$k^2A$$, и я пропустил условие, что числа могут быть различными. Так что мне показалось, что, подставив $$x=y=z=1$$, можно получить квадраты натуральных чисел 1, 4, 9,… Я понимал, что задача не такая простая, поэтому хотел проанализировать случай различных $$x$$, $$y$$ и $$z$$ (хотя по условию только их и надо анализировать), и перешел к дальнейшему рассмотрению.

Идея №3: перебор вариантов.

Чтобы прочувствовать задачу, часто бывает полезно рассмотреть некоторые частные случаи. В задачах вроде этой подобрать $$x$$, $$y$$ и $$z$$, чтобы выражение действительно было целым. В геометрических задачах бывает полезно нарисовать на черновике хороший чертеж, чтобы заметить закономерности вроде расположения точек на одной прямой или окружности.

Для перебора будем фиксировать значения $$x$$, $$y$$ и изменять $$z$$. Пусть $$x=y=1$$ (я проделал эту лишнюю работу, потому что невнимательно прочитал условие).

$$A={1+z+z\over 1+1+z}={1+2z\over 2+z}={4+2z-3\over 2+z}=2-{3\over 2+z}.$$

Ясно, что $$A=1$$ при $$z=1$$, а значение $$A=2$$ ни при каком $$z$$ не будет достигнуто.

Пусть $$x=1, y=2$$. Тогда

$$A={2+2z+z\over 1+2+z}={2+3z\over 3+z}.$$

Если $$z$$ нечетное, то числитель нечетный, знаменатель четный, $$A$$ не будет целым. Если $$z$$ четное, то числитель четный, знаменатель нечетный. Здесь я сделал еще одну ошибку, подумав, что четное число не может делиться на нечетное, и вообще исключил из рассмотрения варианты с $$x$$ и $$y$$ разной четности.

Пусть $$x=1, y=3$$. Тогда

$$A={3+3z+z\over 1+3+z}={3+4z\over 4+z}.$$

Здесь исключаем случай четного $$z$$, так как нечетный знаменатель не будет делиться на четный числитель. Попробуем подставить разные нечетные $$z$$. Получим:

$$ z=1\implies A={7/5}\\ z=3\implies A={12/7}\\ z=5\implies A={23/9}\\ z=7\implies A={31/11}\\ z=9\implies A={39/13}=3\\ z=11\implies A={47/15}\\ $$

Далее, сколько бы мы ни увеличивали $$z$$, до значения 4 мы не дойдем, так как 4 достигается только в пределе $$z\to\infty$$. Таким образом, при $$x=1, y=3$$ единственное целое $$A$$ дает $$z=9$$.

Пусть $$x=1, y=5$$. Тогда

$$A={5+5z+z\over 1+5+z}={5+6z\over 6+z}.$$

Перебор $$z$$ слишком долгий, и мы понимаем, что возможных значений $$A$$ не так уж много. Поэтому решим уравнение относительно $$z$$:

$$5+6z=A(6+z)\iff(6-A)z=6A-5\implies z={6A-5\over 6-A}.$$

Отсюда видно, что $$A$$ не может быть четным. 1 и 3 не подходят, $$A=5$$ дает $$z=25$$, других значений для проверки нет.

Мы видим, что значения переменных (1, 3, 9) и (1, 5, 25) дают целые значения $$A$$. Кажется, это и есть нужная закономерность.

Решение

Подставим значения $$x=1, y=n, z=n^2$$. Тогда

$$A={n+n^3+n^2\over 1+n+n^2}=n\,{1+n^2+n\over 1+n+n^2}=n.$$

Таким образом, мы можем в качестве значения выражения получить любое натуральное число, не равное 1. То, что 1 получить нельзя, посмотрите у Савватеева или докажите самостоятельно.

Обсуждение ошибок

После подстановки $$x=1, y=n, z=n^2$$ моя ошибка с четностью стала очевидной. Сначала мне вообще не хотелось писать об ошибках. Признаваться в них не очень приятно. Но с другой стороны, благодаря ошибкам на этапе поиска решения я довольно быстро нашел правильное решение. Могло бы оказаться так, что я углубился в разработку какой-нибудь другой тупиковой идеи и не довел бы решение до конца. Особенно важно такое чутье на самой олимпиаде в условиях ограниченного времени.

Чтобы минимизировать ошибки на олимпиадах, важно не переписывать решение с черновика на чистовик, а заново решить задачу на чистовике, обращаясь к черновику только для сравнения вычислений. Об этом и других советах я уже писал в руководстве олимпиадника.

    Оставить комментарий

Необычный сон и шутка про банки

3 февраля 2022 года, 20:51

Сегодня мне приснился странный сон.

В моем сне Иван Голунов ведет новости или какой-то другой прямой эфир. К этому эфиру по видеосвязи подключен Владимир Рыжков. Голунов цитирует некоторую новость, в которой использовано слово «функционал». Они обсуждают, что функционал — это не то, о чем думает большинство. Рыжков замечает, что употребление слова неправильное, но заменить его нечем (на самом деле правильный термин — «функциональность»).

Дальше Голунов говорит Рыжкову: «А помните, как та шутка — „банк — он же не on-shell“». На этом я проснулся и не поленился записать саму фразу. Давайте разбираться, какой глубокий смысл в ней скрыт, и какой могла быть эта шутка (в реальности я ничего похожего не припомню).

Термины on-shell и off-shell — это жаргон из квантовой теории поля. Дословно они означают «на (массовой) поверхности» и «вне (массовой) поверхности». Под массовой поверхностью имеется в виду многомерный график соотношения между энергией и импульсом E2p2=m2. О настоящих частицах, для которых как раз выполняется закон сохранения энергии, говорят, что они находятся на массовой поверхности. А вот для виртуальных частиц, которые возникают в квантовой теории поля как вспомогательные объекты, закон сохранения энергии не выполняется, поэтому говорят, что они вне массовой поверхности: E2p2m2.

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

Таким образом, фраза «банк — он же не on-shell» по смыслу эквивалента «для банка не выполняется закон сохранения». Какой закон сохранения неприменим к банкам? Очевидно, закон сохранения количества денег. Во-первых, банки не держат у себя все деньги вкладчиков. Эти деньги выдаются в виде кредитов, что увеличивает количество денег в экономике. Во-вторых, кредитные деньги ничем не отличаются от других денег. Они из оборота могут вновь попасть на депозиты, вновь могут быть выданы в виде кредитов и т. д. В экономике даже есть понятие банковского мультипликатора, которое описывает эту бесконечную историю.

Итак, шутка из сна могла быть про диалог двух физиков:
— Не могу понять, как банк может выдать кредитов больше, чем у него есть денег вкладчиков.
— Банк — он же не on-shell!
Можете воспользоваться, если окажетесь в компании физиков, обсуждающих экономику :)

Самая большая загадка: каким образом это всё оказалось в моем сне? Я иногда про себя отмечаю ошибку с употреблением слова «функционал» вместо «функциональность» в речи других, поэтому эта часть как раз не удивительна. Почему во сне были Голунов и Рыжков — не понимаю, я давно о них ничего не слышал. Термины on-shell и off-shell я не использовал много лет. И как вообще мозг во сне смог построить такую цепочку ассоциаций?

    Оставить комментарий

← сюда туда →

Поделиться
Записи