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

Нативное gzip-сжатие в JS

1 июля 2025 года, 12:46

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

Давние читатели вспомнят, что я уже рассказывал об адресах картинок, и даже упоминал об этом тикете. Повторю, что для использования в вебе формулы, например, $$a^2+b^2=c^2$$, её исходник на латехе a^2+b^2=c^2 кодируется через проценты (RFC 3986) и подставляется в URL:

//i.upmath.me/svg/a%5E2%2Bb%5E2%3Dc%5E2

Кодирование через символы процента очень неэкономное, поэтому и без того длинный код изображений и диаграмм становится ещё больше. Адрес $$a^2+b^2=c^2$$ в новой схеме выглядит так:

//i.upmath.me/svgb/S4wz0k6KM7JNjjMCAA

Здесь вместо кодирования через проценты используется сжатие deflate (тот же алгоритм, что и в gzip) и кодировка, аналогичная base64. Вот рабочий пример кода, который делает такое преобразование:

function deflateRaw(text, callback) {
    if (typeof CompressionStream === 'undefined') {
        callback(null);
        return;
    }

    try {
        var stream = new Blob([text]).stream();
        var compressedStream = stream.pipeThrough(new CompressionStream('deflate-raw'));

        new Response(compressedStream).blob().then(function (compressedBlob) {
            return compressedBlob.arrayBuffer();
        }).then(function (buffer) {
            var compressedArray = new Uint8Array(buffer);
            var binary = Array.from(compressedArray).map(function (b) {
                return String.fromCharCode(b);
            }).join('');
            var base64 = btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
            callback(base64);
        }).catch(function () {
            callback(null);
        });
    } catch (e) {
        callback(null);
    }
}

function getImgPath(formula, callback) {
    var fallbackUrl = '//i.upmath.me/svg/' + encodeURIComponent(formula);

    deflateRaw(formula, function (compressed) {
        var shortUrl = compressed ? '//i.upmath.me/svgb/' + compressed : null;
        callback(shortUrl && shortUrl.length < fallbackUrl.length ? shortUrl : fallbackUrl);
    });
}

Важная особенность API браузеров по сжатию заключается в его асинхронности. Мы не можем получить результат сжатия в той же функции, в которой его инициируем. API возвращает promise, который «разрешится» позднее. Чтобы обеспечить обратную совместимость и откатываться к несжатым адресам в старых браузерах, я проверяю саму поддержку CompressionStream и перехватываю возможные исключения. Также для обратной совместимости результат возвращаю через вызов пользовательского коллбэка, а не в виде промиса.

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

var node = document.createElement('img');
getImgPath('a^2+b^2=c^2', function(path) {
    node.setAttribute('src', path);
});

Стоит отметить, что сам алгоритм сжатия deflate был давно портирован на JS, поэтому при необходимости можно было использовать сторонние библиотеки, например, pako. Кроме того, код библиотек работает синхронно, так что ни о каких коллбэках и промисах думать не нужно. В моём же случае я не хотел в мини-скрипт по конвертации формул в картинки добавлять реализацию алгоритма сжатия на десяток-другой килобайт, тем более оставался обходной путь для старых браузеров с несжатыми адресами.

Оценим результат на примере диаграммы из предыдущей заметки. Длина старого несжатого URL равна 6,3 килобайт, а сжатого — 1,3 килобайт, что почти в 5 раз короче.

    2 комментария

Разбираем конечный автомат в системе личных сообщений

26 июня 2025 года, 21:04

В прошлый раз я рассказывал о применении понятия конечного автомата в программировании. В этот раз рассмотрим практический пример.

Одним из первых моих заданий в команде форума PunBB была система обмена личными сообщениями, которую я проектировал и разрабатывал с нуля. На примере этой системы посмотрим, как требования к системе преобразуются в набор состояний и переходов между ними.

Требование №1: черновики и уведомления о прочтении

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

$$\usetikzlibrary{arrows} \begin{tikzpicture}[node distance=4cm,font=\sffamily] \tikzset{ mynode/.style={rectangle,rounded corners,draw=black,thick, inner sep=0.7em, text width=7em,text centered}, myarrow/.style={->, >=latex', shorten >=1pt, shorten <=2pt,thick,font=\small\sffamily} } \node[mynode,fill=gray!10] (Draft) {Черновик\\status=draft}; \node[mynode, right of=Draft,fill=cyan!10] (Sent) {Отправлено\\status=sent}; \node[mynode, right of=Sent,fill=green!10] (Read) {Прочитано\\status=read}; \draw[myarrow] (Draft) to[in=130,out=50] node[above,align=center] {Отправитель нажал\\«Отправить»} (Sent); \draw[myarrow] (Sent) to[in=130,out=50] node[above,align=center] {Получатель открыл\\ сообщение} (Read); \end{tikzpicture}$$

Требование №2: отзыв отправленных сообщений

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

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

$$\usetikzlibrary{arrows} \begin{tikzpicture}[node distance=4cm,font=\sffamily] \tikzset{ mynode/.style={rectangle,rounded corners,draw=black,thick, inner sep=0.7em, text width=7em,text centered}, myarrow/.style={->, >=latex', shorten >=1pt, shorten <=2pt,thick,font=\small\sffamily} } \node[mynode,fill=gray!10] (Draft) {Черновик\\status=draft}; \node[mynode, right of=Draft,fill=cyan!10] (Sent) {Отправлено\\status=sent}; \node[mynode, right of=Sent,fill=green!10] (Delivered) {Доставлено\\status=delivered}; \node[mynode, right of=Delivered,fill=green!10] (Read) {Прочитано\\status=read}; \draw[myarrow] (Draft) to[in=130,out=50] node[above,align=center] {Отправитель нажал\\«Отправить»} (Sent); \draw[myarrow] (Sent) to[in=-50,out=-130] node[below,align=center] {Отправитель нажал\\«В черновики»} (Draft); \draw[myarrow] (Sent) to[in=130,out=50] node[above,align=center] {Получатель увидел\\ кол-во сообщений} (Delivered); \draw[myarrow] (Delivered) to[in=130,out=50] node[above,align=center] {Получатель открыл\\ сообщение} (Read); \end{tikzpicture}$$

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

Требование №3: удаление сообщений

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

С одной стороны флаги deleted_by_sender и deleted_by_receiver выглядят красиво и симметрично. Но с другой стороны, если вы внимательно читали предыдущий пост, то уже догадались, что набор из трех полей (status, deleted_by_sender и deleted_by_receiver) — не самое лучшее решение для кодирования состояния.

Первая проблема этих флагов и поля состояния заключается в том, что некоторые наборы значений (например, status = draft и deleted_by_receiver = 1) не соответствуют ни одному допустимому состоянию (черновик не может иметь отметку об удалении получателем, потому что получатель ничего не получал). Вторая проблема проявляется в повторении в коде одних и тех же условий. Так, условие deleted_by_receiver = 0 AND (status = 'delivered' OR status = 'read'), которое соответствует доступным получателю сообщениям, повторяется в коде три раза.

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

$$\usetikzlibrary{arrows} \begin{tikzpicture}[node distance=3.6cm,font=\sffamily] \tikzset{ mynode/.style={rectangle,rounded corners,draw=black,thick, inner sep=0.5em, text width=7em,text centered}, myarrow/.style={->, >=latex', shorten >=1pt, shorten <=2pt,thick,font=\small\sffamily} } \node[mynode,fill=gray!10] (Draft) {\shortstack{Черновик\\status=draft}}; \node[mynode, right of=Draft,fill=cyan!10] (Sent) {Отправлено\\status=sent}; \node[mynode, right of=Sent,fill=green!10] (Delivered) {\shortstack{Доставлено\\status=delivered}}; \node[mynode, right of=Delivered,fill=green!10] (Read) {Прочитано\\status=read}; \node[mynode, right of=Read,fill=red!10] (Deleted) {Удалено\\status=deleted}; \draw[myarrow] (Draft) to[in=130,out=50] node[above,align=center] {Отправитель нажал\\«Отправить»} (Sent); \draw[myarrow] (Sent) to[in=-50,out=-130] node[below,align=center] {Отправитель нажал\\«В черновики»} (Draft); \draw[myarrow] (Sent) to[in=130,out=50] node[above,align=center] {Получатель увидел\\ кол-во сообщений} (Delivered); \draw[myarrow] (Delivered) to[in=130,out=50] node[above,align=center] {Получатель открыл\\ сообщение} (Read); \draw[myarrow] (Read) to[in=130,out=50] node[above,align=center] {Получатель удалил\\ сообщение} (Deleted); \end{tikzpicture}$$

К сожалению, от флага deleted_by_sender не получится избавиться так же просто. Дело в том, что удаление сообщения отправителем может произойти в любом статусе, и у этого действия будут разные последствия:

С учетом этих требований диаграмма всех возможных состояний приобретает следующий вид:

$$\usetikzlibrary{arrows} \begin{tikzpicture}[node distance=3.6cm,font=\sffamily] \tikzset{ mynode/.style={rectangle,rounded corners,draw=black,thick, inner sep=0.5em, text width=7.0em,text centered}, myarrow/.style={->, >=latex', shorten >=1pt, shorten <=2pt,thick,orange!70!black,font=\small\sffamily}, sender/.style={blue!80!black} } \clip (-10,10) rectangle (20,-8); %костыль \node[mynode,fill=gray!10] (Draft) {Черновик\\status=draft}; \node[mynode,above of=Draft,dashed] (New) {В БД пусто}; \node[mynode, right of=Draft,fill=cyan!10] (Sent) {Отправлено\\status=sent}; \node[mynode, right of=Sent,fill=green!10] (Delivered) {Доставлено\\status=delivered}; \node[mynode, below of=Delivered,fill=yellow!10] (DeliveredAndDeleted) {\shortstack{Доставл. и удал.}\\status=delivered\\del\_by\_sendr=1}; \node[mynode, right of=Delivered,fill=green!10] (Read) {Прочитано\\status=read}; \node[mynode, below of=Read,fill=yellow!10] (ReadAndDeleted) {Прочит. и удал.\\status=read\\del\_by\_sendr=1}; \node[mynode, right of=Read,fill=yellow!10] (Deleted) {Удалено\\status=deleted}; \node[mynode, below of=Deleted,dashed] (Deleted2) {Удалено из БД}; \draw[myarrow,sender] (New) to[] node[left,align=right] {Отправитель\\нажал\\«В черновики»} (Draft); \draw[myarrow,sender] (New) to[out=0,in=90] node[right,pos=0.2,align=center] {Отправитель нажал\\«Отправить»} (Sent); \draw[myarrow,sender] (Draft) to[in=130,out=50] node[above,align=center] {Отправитель\\нажал\\«Отправить»} (Sent); \draw[myarrow,sender] (Sent) to[in=-50,out=-130] node[below,align=center] {Отправитель\\нажал\\«В черновики»} (Draft); \draw[myarrow] (Sent) to[in=130,out=50] node[above,align=center] {Получатель увидел\\ кол-во сообщений} (Delivered); \draw[myarrow] (Delivered) to[in=130,out=50] node[above,align=center] {Получатель открыл\\ сообщ.} (Read); \draw[myarrow] (Read) to[in=130,out=50] node[above,align=center] {Получатель удалил\\ сообщ.} (Deleted); \draw[myarrow,sender] (Delivered) to node[left,align=right] {Отправитель\\удалил\\сообщ.} (DeliveredAndDeleted); \draw[myarrow,sender] (Read) to node[left,align=right] {Отправитель\\удалил\\сообщ.} (ReadAndDeleted); \draw[myarrow,sender] (Deleted) to node[left,align=right] {Отправитель\\удалил\\сообщ.} (Deleted2); \draw[myarrow,sender] (Draft) to[in=-80,out=-90,looseness=1.2] node[below,pos=0.6] {Отправитель удалил сообщ.} (Deleted2); \draw[myarrow,sender] (Sent) to[in=-90,out=-90,looseness=1.3] node[below,pos=0.58] {Отправитель удалил сообщ.} (Deleted2); \draw[myarrow] (DeliveredAndDeleted) to[in=-130,out=-50] node[below,align=center] {Получатель открыл\\ сообщ.} (ReadAndDeleted); \draw[myarrow] (ReadAndDeleted) to[in=-130,out=-50] node[below,align=center,pos=0.4] {Получатель удалил\\ сообщ.} (Deleted2); \end{tikzpicture}$$

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

Выводы и анализ корректности требований

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

Проанализируем корректность и полноту диаграммы из нашего примера. На диаграмме отсутствует переход между доставленным и удаленным состоянием сообщения. Должен ли он существовать? Должен, если получатель может удалить сообщение из списка, не открывая его. Должна ли система отображать отправителю, что получатель, не прочитав, удалил сообщение? Если должна, то одного состояния «удалено» недостаточно, так как мы теряем информацию о прочтении удаленных сообщений. Возможно, в этом случае не стоило удалять флаг deleted_by_receiver и заменять его на status=deleted.

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

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

Сложение вращений и анимация в TikZ

29 мая 2025 года, 12:35

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

Эта задача встречалась в американском тесте абитуриентов 1982 года и примечательна тем, что среди предложенных вариантов ответов не было правильного. Сама задача, её история и связанные вопросы разобраны в этом видео:

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

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

$$\dvisvgm\definecolor{cyan}{RGB}{0, 200, 250} \shorthandoff{"} \usetikzlibrary {shapes.geometric} \usetikzlibrary{animations} \begin{tikzpicture} \def\a{1} \def\b{3} \useasboundingbox (-\b-2*\a-0.1,-\b-2*\a-0.1) rectangle (\b+2*\a+0.1,\b+2*\a+0.1); \draw[cyan,very thin] (-\b-2*\a,-\b-2*\a) grid (\b+2*\a,\b+2*\a); \node[star,star points=57, star point ratio=1.07,minimum size=6.2cm, draw,fill=white] at (0,0); \draw[purple,fill] (0:\b) circle (1pt) -- (0,0) circle (1pt) node [midway, sloped, above] {$\b$} ; \begin{scope}:rotate = {0s="0", (5*\b)s="360",repeats} \begin{scope} :rotate = {0s="0", (5*\a)s="360", origin={(\b+\a,0)}, repeats} \node [star,star points=19, star point ratio=1.2,minimum size=2.2cm, draw,fill=white] at (0:\b+\a) {}; \draw [purple,fill] (0:\b+\a) circle (1pt) -- (0:\b) circle (1pt) node [midway, sloped, above] {$\a$} ; \end{scope} \end{scope} \coordinate (A) at (1,1.5); \node [fill=white,inner sep=1pt,anchor=east,xshift=3pt,yshift=-1pt] at (A) {$\text{обороты: }\,\,\,.$}; \foreach \t in {3,2,...,0} { \node :opacity = { 3.75*(0) s="0", 3.75*(0+\t) s="0", 3.75*(0.001+\t) s="1", 3.75*(0.999+\t) s="1", 3.75*(1+\t)s="0", 3.75*(4) s="0", repeats } [anchor=east,inner sep=1pt] at (A) {$\t$}; } \foreach \t in {0,1,...,9} { \node :opacity = { 0.375*(0) s="0", 0.375*(0+\t) s="0", 0.375*(0.01+\t) s="1", 0.375*(0.99+\t) s="1", 0.375*(1+\t)s="0", 0.375*(10) s="0", repeats } [anchor=west,inner sep=1.5pt] at (A) {$\t$}; } \end{tikzpicture}$$

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

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

$$\dvisvgm\definecolor{cyan}{RGB}{0, 200, 250} \usetikzlibrary {shapes.geometric} \usetikzlibrary{animations} \begin{tikzpicture} \def\a{1} \def\b{3} \useasboundingbox (-\b-2*\a-0.1,-\b-2*\a-0.1) rectangle (\b+2*\a+0.1,\b+2*\a+0.1); \draw[cyan,very thin] (-\b-2*\a,-\b-2*\a) grid (\b+2*\a,\b+2*\a); \begin{scope}:rotate = {0s="0", 5s="-360", 6s="-360", 11s="0", 12s="0", repeats} \node[star,star points=57, star point ratio=1.07,minimum size=6.2cm, draw,fill=white] at (0,0); \draw[purple,fill] (0:\b) circle (1pt) -- (0,0) circle (1pt) node [midway, sloped, above] {$\b$} ; \end{scope} \begin{scope} :rotate = {0s="0", 6s="0", 11s="360", 12s="360", origin={(0,0)}, repeats} \begin{scope} :rotate = {0s="0", 5s="1080", 6s="1080", 11s="1080", 12s="1080", origin={(\b+\a,0)}, repeats} \node [star,star points=19, star point ratio=1.2,minimum size=2.2cm, draw,fill=white] at (0:\b+\a) {}; \draw [purple,fill] (0:\b+\a) circle (1pt) -- (0:\b) circle (1pt) node [midway, sloped, above] {$\a$} ; \end{scope} \end{scope} \end{tikzpicture}$$

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

Самодельная типографская раскладка

23 мая 2025 года, 18:07

В повседневном использовании мне не хватает двух символов на русской раскладке клавиатуры: решетки # и собаки @. Решетка обозначает заголовки в распространенной разметке markdown, а собака применяется в адресах электронной почты в чатах, чтобы «тегать» определенных людей (@supercoder) или всех подряд (@channel).

Для ввода решетки и собаки приходится постоянно переключаться с русского языка на английский и назад, да и сами эти символы вводить с шифтом. Ещё у Windows случается баг, когда она переключает язык не сразу, а после небольшой паузы: ты нажал Alt + Shift и уже что-то набираешь дальше, а язык не переключился. Приходится стирать, опять переключать язык на английский, выдерживать паузу, вводить символы, выдерживать паузу, переключаться на русский и опять выдерживать паузу перед продолжением набора.

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

Много лет назад я устанавливал типографскую раскладку Ильи Бирмана. Она специальным образом задействует правую клавишу Alt для ввода с клавиатуры расширенного набора символов. Например, вместо копирования откуда-то знака рубля ₽ можно просто нажать правый Alt + HР (то есть клавишу с русской буквой «эр»). Причем если установить раскладку и для русского языка, и для английского, то это сочетание клавиш будет работать и там и там.

Казалось бы, сам бог велел сделать в типографской раскладке не зависящие от языка комбинации клавиш Alt + 2 и Alt + 3 для ввода собаки и решетки. Тем более, так уже сделано и для знака доллара $ с комбинацией Alt + 4, и для квадратных скобок, которые тоже нужны в маркдауне. Но у Ильи в раскладке через Alt + 2 и Alt + 3 вводятся символы верхних индексов ² и ³. Сегодня такое решение кажется устаревшим. Я, кстати, вообще не помню, чтобы хоть раз в жизни использовал верхние индексы именно как эти отдельные символы, а не через инструменты форматирования.

Илья делал свою раскладку в программе MSKLC (Microsoft Keyboard Layout Creator). И нам никто не мешает создать собственную раскладку, отталкиваясь от своих потребностей. К счастью, MSKLC позволяет импортировать любую установленную раскладку и затем её отредактировать.

Я оставил большинство дополнительных символов как у Ильи. Единственные изменения — это прямой ввод символов `@# через комбинацию с правым Alt.

Стоит отметить, что программа MSKLC давно не обновлялась. Это заметно и по ее внешнему виду, и по требованию скачать древнюю версию .NET Framework 3.5 SP1 2008 года. К счастью, эти трудности не помешали создать свою версию раскладки.

Ещё одно интересное наблюдение заключается в том, что раньше типографская раскладка у меня не прижилась, потому что она ломает сочетание клавиш правый Alt + Enter для перехода в полноэкранный режим. Дискомфорт от сломанной привычки оказался сильнее пользы, которую приносила раскладка. Сейчас же я не испытываю особого дискомфорта. Похоже, изменения в интерфейсах и способах просмотра видео привели к тому, что жест правый Alt + Enter потерял своё значение.

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

Подключил Akismet для борьбы со спамом

27 апреля 2025 года, 20:59

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

Со временем технологии развивались, и через selenium разработчики автоматизировали действия ботов через полноценные браузеры. Метод защиты с помощью Javascript стал фильтровать только самых тупых ботов.

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

Чтобы облегчить себе жизнь по окончательному удалению спаммерских комментариев из очереди на модерацию, я задумался над тем, какова цель спаммеров? Конечная цель — разместить ссылки для манипуляции индексом цитирования и для привлечения посетителей. Если запретить оставлять ссылки, спаммерам не будет смысла оставлять комментарии без них. А если ссылку хочет разместить человек в хорошем комментарии, сайт скажет ему, чтобы он удалил http:// из ссылки. Запрет на ссылки принес свои плоды, но какие-то спаммерские комментарии всё равно пролезали даже без явных ссылок.

Сейчас я решил посмотреть, как привлечь новые технологии для фильтрации спама. Теоретически можно натренировать нейросеть на каком-то множестве спаммерских и хороших комментариев и делегировать ей задачу фильтрации. Идея реализации классификатора текстов описана в статье на хабре аж восьмилетней давности. Решил спросить у ChatGPT, что он предложит по поводу классификации. Решения с обучением нейросетей оказались не очень простыми, но кроме них он ещё предложил использовать готовый сервис Akismet.

Akismet — это система фильтрации спама в комментариях, разработанная авторами WordPress. В вордпрессе есть плагин, который обращается к API Akismet. Однако сам API открыт и может быть использован любым сайтом, для обращения нужен только лицензионный ключ. Лизензия для некоммерческого использования бесплатная.

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

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

После внедрения за две недели пришло 62 комментария. Из них 60 спаммерских комментариев были отсеяны либо как вопиющий спам (21 комментарий), либо как спам с наличием ссылок в тексте. Остальные два комментария опубликованы: один хороший комментарий и один спаммерский со ссылкой на yotube.

Из-за низкого потока комментариев набрана небольшая статистика, и масштаб проблемы с опубликованным спаммерским комментарием неясен. Для окончательных выводов нужно подождать ещё. Или же приходите в комментарии, чтобы протестировать защиту от спама :)

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

    2 комментария

Задача о шпионах за круглым столом

12 апреля 2025 года, 00:15

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

Условие

На банкете за круглым столом сидят $$n$$ шпионов. Каждый шпион независимо от остальных случайным образом выбирает одного из двух соседей — левого или правого — и подсыпает яд ему в бокал. Каково математическое ожидание числа выживших шпионов?

$$\usetikzlibrary{arrows.meta} \begin{tikzpicture}[scale=1.4] \tikzset{spy/.style={circle,draw,minimum size=6mm,inner sep=1pt}} \foreach \i [evaluate=\i as \angle using 360/8*(\i-1)] in {1,...,8} { \node[circle, minimum size=6mm, inner sep=1pt] (spy\i) at (\angle:1.5); \coordinate (below\i) at (\angle:1.1); } \foreach \a/\b in {1/2, 2/1, 3/4, 4/5, 5/4, 6/7, 7/6, 8/1} { \draw[-{Stealth[length=1.6mm]}] (spy\a) to[bend right=15] (spy\b); } \foreach \i in {3,8} { \node[spy,green!40!black,fill=green!10!white] at (spy\i) {\i}; \node[green!60!black] at (below\i) {$\checkmark$}; } \foreach \i in {1,2,4,5,7,6} { \node[spy, red!50!black,fill=red!10!white] at (spy\i) {\i}; \node[red!80!black] at (below\i) {$\dagger$}; } \end{tikzpicture} $$

Говоря простыми словами, нам надо найти среднюю долю выживших при многократном повторении эксперимента.

Решение

Пусть шпионы пронумерованы по кругу числами от 1 до $$n$$ (шпион $$n$$ считается соседом шпиона 1). Обозначим через $$X$$ количество выживших шпионов. Требуется найти математическое ожидание $$\mathbb{E}[X]$$.

Рассмотрим судьбу одного конкретного шпиона, скажем, с номером $$i$$. Он погибает в том и только в том случае, если хотя бы один из соседей выбрал его целью. Таким образом, шпион $$i$$ выживает, если и шпион $$i-1$$, и шпион $$i+1$$ выбрали не его. Поскольку выбор жертв происходит независимо, и каждый выбирает левого или правого соседа с вероятностью 1/2, вероятность того, что оба соседа шпиона $$i$$ выбрали другого соседа, равна:

$$ \mathbb{P}(\text{шпион }i\text{ выживает})=\left(\frac{1}{2}\right)^2=\frac{1}{4}. $$

Введем индикаторную случайную величину $$X_i$$, равную 1, если шпион $$i$$ выживает, и 0 в противном случае. Тогда общее число выживших:

$$X=\sum_{i=1}^n X_i.$$

Так как все шпионы находятся в равных условиях, математическое ожидание $$\mathbb{E}[X_i]$$ одинаково для всех и вычисляется по определению:

$$\mathbb{E}[X_i]=0\cdot\mathbb{P}(\text{шпион }i\text{ умирает})+1\cdot\mathbb{P}(\text{шпион } i \text{ выживает})={1\over4}.$$

Следовательно, по линейности математического ожидания:

$$\mathbb{E}[X]=\sum_{i=1}^n\mathbb{E}[X_i]=n\cdot\frac{1}{4}=\frac{n}{4}.$$

Таким образом, в среднем выживает четверть шпионов, независимо от их числа.

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

ChatGPT получил правильную формулу только для $$n>2$$. Если $$n=2$$, то ответ $$n/4=1/2$$ неправильный, так как в этом случае сосед слева и справа — один и тот же человек, и оба шпиона отравят друг друга. И для $$n=1$$ ответ тоже неприменим.

Самый неочевидный шаг в этом решении — переход от $$\mathbb{E}[X]$$ к $$\mathbb{E}[X_1]+\mathbb{E}[X_2]+\ldots+\mathbb{E}[X_n]$$. Если бы случайные величины $$X_i$$ были независимы, например, как результаты многократного подбрасывания монеты, никого бы не удивило математическое ожидание количества орлов, равное $$n/2$$. Но в нашем случае $$X_i$$ зависимы друг от друга. Так, если шпион $$i$$ выжил, то его соседи $$i\pm1$$ точно выбрали своими жертвами шпионов $$i\pm2$$, сидящих через одного от $$i$$, и они гарантированно не выжили. Не повлияют ли такие взаимосвязи на среднее количество выживших?

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

Пространство элементарных событий и математическое ожидание суммы случайных величин

В задачах теории вероятностей рассматривают так называемое пространство элементарных событий $$\Omega$$ — множество всех возможных непересекающихся исходов $$\omega_k$$. Например, в качестве элементарных событий в нашей задаче удобно взять совокупность принятых решений каждым шпионом. Чтобы закодировать элементарное событие, будем выписывать по порядку цифры 0 или 1: 0, если очередной шпион выбрал жертвой соседа слева, и 1 — если справа. Таким образом, каждая последовательность из $$n$$ нулей и единиц соответствует некоторому исходу, то есть некоторому элементарному событию, и наоборот, для каждого исхода можно указать соответствующую последовательность нулей и единиц.

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

$$\begin{align*} \omega_0&=000\ldots000,\\ \omega_1&=000\ldots001,\\ \omega_2&=000\ldots010,\\ \omega_3&=000\ldots011,\\ &\ldots\\ \omega_{2^n-2}&=111\ldots110,\\ \omega_{2^n-1}&=111\ldots111.\\ \end{align*}$$

Все они равновероятны, поэтому вероятность каждого исхода $$P(\omega_k)=1/{2^n}$$.

Нам осталось выяснить, какой смысл имеет понятие случайной величины $$X$$ с точки зрения элементарных событий. Для каждого элементарного события случайная величина $$X$$ имеет вполне определенное значение, то есть это обычная функция от $$\omega_k$$. Рассмотрим для примера случайную величину $$X_2$$ из нашей задачи, которая равна 1, если второй шпион выжил, и 0 в противном случае.

$$ \begin{tikzpicture}[scale=1.4] \tikzset{spy/.style={circle,draw,minimum size=6mm,inner sep=1pt}} \foreach \i [evaluate=\i as \angle using 360/8*(\i-1)] in {1,...,8} { \node[circle, minimum size=6mm, inner sep=1pt] (spy\i) at (\angle:1.5); \coordinate (below\i) at (\angle:1.1); } \foreach \a/\b in {3/4, 2/3, 1/8, 4/3, 8/1} { \draw[->] (spy\a) to[bend right=15] (spy\b); } \foreach \i in {2} { \node[spy,green!40!black,fill=green!10!white] at (spy\i) {\i}; \node[] at (below\i) {$a$}; } \node[] at (below1) {$0$}; \node[] at (below3) {$1$}; \node[] at (below4) {$b$}; \foreach \i in {1,3,4,8} { \node[spy, red!50!black,fill=red!10!white] at (spy\i) {\i}; } \node at(2.4,1.05) {$X_2(1a0b...)=1$}; \end{tikzpicture} $$

Так как на второго шпиона влияют только первый и третий шпионы, то значение $$X_2$$ определяется цифрами на первом и третьем месте, а именно $$X_2(0a1b...)=1$$, $$X_2(0a0b...)=X_2(1a0b...)=X_2(1a1b...)=0$$.

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

$$\begin{align*} \mathbb{E}[X]&=\sum_{k=0}^{2^n}X(\omega_k)P(\omega_k)=\sum_{k=0}^{2^n}\left[X_1(\omega_k)+X_2(\omega_k)+\ldots+X_n(\omega_k)\right]P(\omega_k)=\\ &=\sum_{k=0}^{2^n}X_1(\omega_k)P(\omega_k)+\sum_{k=0}^{2^n}X_2(\omega_k)P(\omega_k)+\ldots+\sum_{k=0}^{2^n}X_n(\omega_k)P(\omega_k)=\\ &=\mathbb{E}[X_1]+\mathbb{E}[X_2]+\ldots+\mathbb{E}[X_n]. \end{align*}$$

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

Дополнение

Выпишем ответ в явном виде для торопящихся читателей:

$$\mathbb{E}[X]=\begin{cases} 1&\text{при }n=1,\\ 0&\text{при }n=2,\\ \frac{n}{4}&\text{при }n>2. \end{cases} $$

А ещё Евгений Степанищев подтвердил теоретический ответ с помощью моделирования методом Монте-Карло на языке R.

    2 комментария

CPU steal time на виртуальном сервере, мониторинг и перцентили

17 февраля 2025 года, 00:41

Оказывается, на виртуальных серверах есть специальная метрика CPU steal time. Она показывает, сколько процессорного времени было «украдено» у вашего сервера другими виртуальными машинами на том же физическом сервере. Есть смысл проверить эту метрику, если вы сталкиваетесь с необъяснимыми подтормаживаниями. Их причина может быть не в вашей системе, а в соседях по серверу.

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

Изучая статистику после долгого перерыва, опять заметил, что проблема вернулась. Рассмотрел график из нью-релика с использованным и «украденным» процессорным временем. На нем видно, что 9 декабря появился заметный CPU steal time. Кто-то из соседей по физическому серверу стал активно нагружать процессор.

Казалось бы, величина не сильно большая: steal time не превосходит полпроцента, в то время как собственное потребление виртуалки около 5%. Но надо помнить, что это средние значения. Мгновенные значения в отдельные моменты времени могут оказаться гораздо больше. Чтобы их оценить, нужно смотреть на графики перцентилей.

На втором графике я вывел 95-ю и 99-ю перцентили времени ответа сервера при генерации страниц блога. Перцентили вычисляются из детальной статистики, а ее нью-релик хранит только последние 8 дней, так что сейчас уже никак не узнаешь, что происходило в районе 9 декабря. Когда я обнаружил проблему, среднее и медианное время генерации были около 30 миллисекунд, а 99-я перцентиль — около 190 миллисекунд (это значит, что каждый сотый запрос выполнялся сервером дольше, чем 190 миллисекунд).

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

Я сделал временный ресайз и виртуалка оказалась на другом гипервизоре. Этот момент я отметил на графике красной лииней. CPU steal time упал практически до нуля, перцентили приблизились к среднему и медиане. Среднее время генерации тоже снизилось с 30 до 10 миллисекунд, потому что на гипервизоре оказался более мощный процессор.

Влияние ресайза я обнаружил случайно в сентябре 2023 года, когда хотел проверить, поможет ли увеличение памяти победить непонятные подтормаживания. Эффект был, но не от увеличения объема оперативки, а от перемещения виртуалки на новый гипервизор. Это подтверждает упавший график steal time:

Однако проблема повторилась в декабре 2023 года в большем масштабе, когда steal time подскочил до 8% и дальше стал колебаться около 2%:

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

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

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

Нейросети для подготовки текстовой расшифровки речи

9 декабря 2024 года, 23:22

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

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

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

pip install git+https://github.com/openai/whisper.git

Распознавание запускается простой командой, на входе указывается аудиофайл и язык:

whisper путь_к_файлу.mp3 --language Russian

Работает нейросеть довольно долго, я ждал несколько часов. Это в несколько раз больше длительности самой записи. По мере распознавания команда выводит текст в консоль. Также текст записывается в файл.

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

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

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

В итоге последовательность действий получилась такой:

На всю работу я потратил столько же времени, сколько занимает подготовка обычной статьи объемом в одну-две страницы A4. Но у меня получилось целых 11 страниц, и это чистый текст без иллюстраций. Помощь ChatGPT в этом процессе весьма существенна, так как самостоятельно перевести живую речь в читаемый текст сложно и утомительно: при переписывании нужно помнить все темы и вопросы, которые обсуждались и до и после, чтобы выстроить логику обсуждения и не перескакивать между вопросами.

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

Существует ли идеальный код, или новый разработчик всегда хочет всё переписать?

17 ноября 2024 года, 13:30

Ситуация в проекте: каждый новый разработчик считает, что имеющаяся кодовая база никуда не годится, в ней сплошной техдолг, от нее надо отказаться и написать всё заново. Кому в этой ситуации доверять? И существует ли объективно идеальный код, или же представление об идеальности кода субъективно, так как всегда найдется критик?

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

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

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

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

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

Технология отбеливания пластика Retrobright

15 августа 2024 года, 10:47

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

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

Описание технологии отбеливания и примеры результатов читайте на хабре и в блоге Александра Алексеева. Я выбрал способ с гелем для обесцвечивания волос и ультрафиолетовой светодиодной лентой. Гель наносится на поверхность детали и накрывается целлофановой пленкой для предотвращения высыхания. Далее деталь выдерживается в ультрафиолетовом свете.

Я дождался 2 метра светодиодной ленты с Алиэкспресса и купил гель с концентрацией перекиси водорода 12%. Протестировать способ решил на телефонной трубке, обмотав ее светодиодной лентой.

Ближе к концу процесса гель слегка вспенивается от выделяющегося кислорода. Но как показывает практика, на результат это не влияет.

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

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

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

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

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

В целом результатом я доволен, могу рекомендовать к повторению. Я купил 60 миллилитров геля для обесцвечивания волос, для телефона этого хватило с небольшим запасом. Гель наносил старой зубной щеткой на предварительно вымытую поверхность. Ленту запитывал током в 900 миллиампер, при этом напряжение на ней было около 11,5 вольт. Работайте в перчатках и очках, так как концентрированная перекись водорода опасна.

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

туда →

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

Подписка на RSS (?)