javascript

Три кнопки вместо клавиатуры: HID-кликер на ESP32 своими руками

  • пятница, 11 сентября 2026 г. в 00:00:14
https://habr.com/ru/companies/first/articles/1080218/

Мне надоело тянуться к клавиатуре и мыши, чтобы пролистнуть ролик, — и я собрал кликер для ПК на базе ESP32. Три кнопки, наподобие пульта для презентаций: две листают ленту стрелками вверх и вниз, третья отправляет F13. Её нажатие ловит собственное расширение браузера и ставит лайк.

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

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

Дальше — весь путь: от печати корпуса и схемы на ёмкостных сенсорах до кода прошивки и расширения под Firefox и Chrome. Со сметой на 450 ₽ и граблями, на которые я наступил.

Что потребуется

Я делал всё на недорогой китайской плате в форм-факторе Arduino Nano с микроконтроллером ESP32-S3 N8R2. Подойдут и другие микроконтроллеры, например STM32. Но ESP32-S3 из коробки поддерживает Wi-Fi и Bluetooth LE, а я планирую сделать кликер беспроводным и подключать его не только к ПК, но и к телефону. 

Плата разработки на базе ESP32 S3 N8R2 и сенсорные кнопки TTP223
Плата разработки на базе ESP32 S3 N8R2 и сенсорные кнопки TTP223

Второй плюс платы — два порта USB-C. Один нужен для пользовательского подключения. Второй — для прошивки: отдельный программатор не нужен, интерфейс встроен в сам микроконтроллер. Достаточно подключить кабель и залить прошивку. Обратная сторона — габариты: 63×28×5 мм. 

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

Пластиковый корпус печатал на 3D-принтере. Если печатать негде, услугу легко найти на сайтах объявлений. Возьмите мою готовую модель, либо ищите подходящий по размеру корпус на Printables или MakerWorld. Подогнать габариты можно прямо в слайсере, используя функцию изменения размера без сохранения пропорций. Слайсер — программа, которая готовит 3D-модель к печати и превращает её в G-код для конкретного принтера. Все необходимые действия можно сделать в бесплатном OrcaSlicer

Проектирование и сборка

Коробочку можно собрать даже в бесплатной версии SketchUp и экспортировать в STL. Если есть опыт моделирования в Blender, он тоже подойдет, хоть и заточен больше под художественные формы. Удобнее всего работать в САПР — например, в Fusion 360, который бесплатен для личного использования. У меня он просто не запустился. Благо под рукой был SolidWorks. 

Я не стал тратить силы и время на проектирование сложного продуманного устройства с имитацией кнопок. Сделал обычную коробочку, на которой визуально выделил секции сенсоров. Внутри — четыре отсека: три под сенсоры TTP223 и один большой под плату. В месте, где располагается чип, добавил вентиляционные отверстия. Добавил две прорези под порты USB-C. Для удобства монтажа сделал правую стенку съёмной. Крепится она на саморезах — отверстия под них предусмотрел сразу. 

Подготовка проекта в слайсере Flash Studio (клон OrcaSlicer от Flashforge)
Подготовка проекта в слайсере Flash Studio (клон OrcaSlicer от Flashforge)

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

Настройка Arduino IDE

Для программирования микроконтроллера, использую Arduino IDE 2.3.10, это актуальная версия доступная на официальном сайте. Можно работать и в VS Code, установив расширение для ESP32, но в Arduino IDE процесс проще. 

После установки IDE добавляем поддержку плат ESP32. Идём в меню Tools (Инструменты) → Board (Платы) → Board Manager (Менеджер плат). Если плата подключена к компьютеру, вместо Board будет указано полное название микросхемы. Например: Board Arduino Leonardo Board. 

Слева откроется панель менеджера плат. В поиске набираем ESP32 — программа найдёт два варианта, нужен esp32 by Espressif Systems. Подключаем плату к компьютеру кабелем USB-C. Если разместить плату портами вниз, провод нужно подключить к правому. Это порт USB-OTG, он нужен для программирования микроконтроллера. Второй — для проверки: через него будем подключаться, когда зальём готовый скетч. 

Красным выделен порт для программирования платы
Красным выделен порт для программирования платы

После установки в меню Board → esp32 нужно найти и выбрать «ESP32-S3-USB-OTG». Следующий шаг — выбор порта. В Tools находим Port, выбираем COM-порт, он должен быть один. Если к компьютеру подключены другие платы или USB-UART-переходники, вариантов будет несколько. Тогда отключаем плату и смотрим, какой порт исчез. 

Программируем HID-клавиатуру

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

Шортсы в браузерных версиях YouTube и TikTok переключаются нажатием клавиш со стрелками. Вниз — следующий ролик, вверх — предыдущий. К счастью, для ESP32 не нужно писать отдельный драйвер или программный модуль. Устройство по умолчанию определяется как HID (Human Interface Device). Если открыть диспетчер устройств и подключить плату, в списке появится новая клавиатура. Значит, устройство может отправлять нажатия клавиш, а операционная система их корректно обработает. 

Работает это так: ESP32 отправляет специальный байт-код клавиши, операционная система интерпретирует его как нажатие. Конкретные значения кодов описаны в спецификации HID Usage Tables for USB. Посмотреть полный список можно в официальном PDF-документе на сайте usb.org

В отличие от таблицы ASCII, коды привязаны не к символам, а к конкретным клавишам. Например, 0x04 — клавиша с английской буквой A. Сам код регистр не несёт: чтобы получить заглавную, нужно добавить модификатор — левый (0xE1) или правый (0xE5) Shift. При этом ESP32 ничего не знает про языки и раскладки — он просто отправляет код. Это задача операционной системы. Если в Windows выбрана английская раскладка, появится «a», если русская — «ф». 

Сначала импортирую заголовочные файлы:

#include "USB.h"
#include "USBHIDKeyboard.h"

С этими библиотеками устройство работает как полноценная USB HID-клавиатура, без лишнего кода. Кроме того, внутри есть все необходимые функции и константы для эмуляции нажатий. В том числе константы клавиш в человекочитаемом виде: вместо числового кода можно писать KEY_DOWN_ARROW. 

Объявляю объект клавиатуры — через него буду эмулировать нажатия:

USBHIDKeyboard Keyboard;

Дальше объявляю константы с номерами пинов, которые будет слушать микроконтроллер. По даташиту платы остановился на пинах 4, 5 и 6 — к ним подключаются выводы I/O сенсоров TTP223. 

const int pinUp    = 4;   // Стрелка вверх

const int pinF13   = 5;   // F13

const int pinDown  = 6;   // Стрелка вниз

При работе с микроконтроллерами, которые взаимодействуют с пользователем, всегда нужно помнить о двух проблемах: дребезге и залипании. При нажатии кнопка совершает микродвижения, из-за которых контакт нарушается: сигнал то появляется, то пропадает. ESP32-S3 работаетт на частоте до 240 МГц, поэтому опрашивает пины намного чаще, чем длится дребезг. Каждый разрыв контакта он воспримет как отдельное нажатие.

С емкостным сенсором TTP223 ситуация лучше, но если палец окажется на границе чувствительности, появится дребезг. Устройство пролистает десятки роликов или поставит и снимет лайк сотни раз — смотря в зоне действия какого датчика окажется палец. Устройство небольшое, поэтому палец может попасть в зону сразу нескольких сенсоров — и начнётся полный хаос. 

Залипание — не менее серьезная проблема, а в случае с TTP223 гораздо более частая. Хотел пролистнуть одно видео, не успел убрать палец — и вот уже десятый ролик подряд. 

Решаем проблему так: сохраняем состояние кнопки и делаем паузу после его смены. Объявляем константу с периодом задержки и структуру, в которой будем хранить всю информацию о сенсоре:

const unsigned long DEBOUNCE_MS = 30;




struct Sensor {

  int pin; 

  int keyCode;

  bool raw;

  bool stable;

  unsigned long lastChange;

  bool lastState;

};

Все кнопки поместим в массив, проинициализировав начальные состояния:

Sensor sensors[3] = {

  {pinUp,   KEY_UP_ARROW,   false, false, 0, false},

  {pinF13,  KEY_F13,        false, false, 0, false},

  {pinDown, KEY_DOWN_ARROW, false, false, 0, false}

};

Arduino IDE заботится о разработчике: предлагает простую структуру и прячет всё лишнее под капот. Пишем мы на том же C++, но структура скетча отличается от привычной программы. Для микроконтроллеров есть две обязательные функции:

  • setup — здесь задаём начальные настройки: режимы работы пинов, прерывания и прочую подготовку. 

  • loop — функция главного цикла. Всё, что в ней написано, микроконтроллер выполняет снова и снова, пока на плату подаётся питание. Именно сюда попадает основная логика устройства.

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

    // Считываем начальное состояние пина. Это нужно на случай, если при включении какая-то из кнопок уже активна.

void setup() {

for (int i = 0; i < 3; i++) {

pinMode(sensors[i].pin, INPUT);

    bool val = digitalRead(sensors[i].pin);

    sensors[i].raw = val;

    sensors[i].stable = val;

    sensors[i].lastChange = millis();

    sensors[i].lastState = val;

  }


  // Запускается USB-стек и HID-клавиатура — плата начинает представляться компьютеру как HID-Keyboard

  USB.begin();

  Keyboard.begin();

}

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

bool debounceSensor(Sensor &s) {

  // Считываем текущее значение с пина

  bool reading = digitalRead(s.pin);




  // Если сырое значение изменилось по сравнению с прошлым разом — запоминаем новое значение и время изменения (это может быть как реальное срабатывание, так и дребезг).

  if (reading != s.raw) {

    s.raw = reading;

    s.lastChange = millis();

  }




  // Если с момента последнего изменения прошло больше DEBOUNCE_MS (30 мс) — значит, сигнал стабилизировался (не дребезжит).

  if ((millis() - s.lastChange) > DEBOUNCE_MS) {

    // Проверяем, была ли смена состояний

    if (s.stable != s.raw) {

      // Обновляем состояние и разрешаем обработку

      s.stable = s.raw;

      return true;

    }

  }

  // Запрещаем обработку сигнала

  return false;

}

Теперь главный цикл — здесь реализуем основную логику. 

void loop() {

  // Как и в setup() обходим циклом все кнопки

  for (int i = 0; i < 3; i++) {

    // Проверяем, было ли подтвержденное (не дребезг) изменение состояния

    if (debounceSensor(sensors[i])) {

      // Проверяем, что сигнал нарастает (нажатие) или LOW в HIGH, а не наоборот (затухание сигнала = отпускание кнопки)

      if (sensors[i].stable == HIGH && sensors[i].lastState == LOW) {

        // Отправляем событие на компьютер. Конкретные значения KeyCode мы прописали при инициализации кнопок

        Keyboard.press(sensors[i].keyCode);

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

        delay(15);                          

        // Сразу отпускаем кнопку, чтобы избежать залипания на компьютере

        Keyboard.release(sensors[i].keyCode);

      }

      // Обновляем в массиве информацию о сенсоре

      sensors[i].lastState = sensors[i].stable;

    }

  }

}

Это весь код для контроллера. Можно выполнить проверку программы и залить на устройство. Сначала кликнем на галочку, которая расположена вверху слева. Arduino IDE скомпилирует скетч и убедится, что все скобки закрыты, а строки завершены точкой с запятой.

Успешная проверка исходного кода
Успешная проверка исходного кода

Перед прошивкой убедитесь, что плата подключена к компьютеру по USB. Если расположить микросхему чипом к себе, а портами вниз, нужен правый порт. Именно через него реализован интерфейс UART, который позволяет перепрошивать микропрограмму в ESP32. 

Если все подключено, нажимаем кнопку со стрелкой (расположена рядом с кнопкой проверки). Дожидаемся, когда Arduino IDE перезагрузит плату и сообщит об этом. Теперь открываем любой видеохостинг с вертикальной лентой и проверяем переключение. Напомню: оно работает через нажатия стрелок вверх и вниз — так устроено большинство таких лент. 

Ставим лайк на шортс YouTube

Чтобы устройство могло нажимать кнопки на странице, проще всего написать своё расширение. Я использую Firefox и код буду писать под него. Разница с Chrome минимальная — буквально одно свойство в манифесте.

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

{
  "manifest_version": 3,
  "name": "ESP32 Clicker",
  "version": "1.0.0",
  "description": "Расширение ставит лайки по нажатию кнопки на кликере",




  "browser_specific_settings": {
    "gecko": {
      "id": "yt-tt-like-clicker@example.local",
      "strict_min_version": "109.0"
    }
  },




  "content_scripts": [
    {
      "matches": ["https://www.youtube.com/*"],
      "js": ["content_yt.js"],
      "run_at": "document_idle",
      "all_frames": false
    }
  ]
}

Описание всегда начинается с manifest_version. Этот параметр говорит браузеру, какие свойства разбирать и с какой структурой проекта работать. Для Firefox и Chrome актуальная версия — третья. 

Свойство browser_specific_settings нужно только для Firefox: здесь указываем идентификатор расширения и минимальную поддерживаемую версию браузера. В Chrome весь блок можно удалить.

Главный блок — контентный скрипт (content_scripts). В нем мы будем перехватывать нажатие клавиш и выполнять клик по кнопке с лайком. Контентным он называется потому, что файлы из свойства js подгружаются прямо в страницу — наш код становится частью сайта. 

    {
      "matches": ["https://www.youtube.com/*"],
      "js": ["content_yt.js"],
      "run_at": "document_idle",
      "all_frames": false
    }

Свойство js — массив, скриптов может быть несколько. Это удобно в больших проектах со сложной логикой. 

В свойстве matches указываем шаблоны URL, для которых браузер подгружает скрипт. Можно указать хоть все сайты, но нам нужен только youtube.com. Работаем с основным документом, а не со встроенными фреймами, поэтому ставим all_frames в false. 

Через run_at определяем, на каком этапе подключить скрипт. Я указал document_idle и передал решение браузеру: обычно это момент события window.onload или сразу после него. Есть и другие варианты: 

  • document_start — до начала рендеринга страницы

  • document_end — после построения DOM-страницы, при этом ресурсы (изображения, скрипты) могут еще загружаться.

Создадим рядом с manifest.json файл content_yt.js. Внутри объявим немедленно вызываемую функцию — весь дальнейший код будет в ней:

(function () {
  // Здесь будет весь код. Он выполнится сразу после загрузки файла
})();

Чтобы кликнуть по какому-то элементу на странице, нам нужен однозначный селектор. Найти его можно, открыв код страницы в инспекторе. Кликаем правой кнопкой по сердечку и выбираем Исследовать.

Ищем подходящий селектор для лайка
Ищем подходящий селектор для лайка

Найти кнопку по идентификатору не получится: они генерируются динамически и меняются от загрузки к загрузке. К счастью, все управляющие элементы обернуты в кастомный тег reel-action-bar-view-model. Внутри него лежит искомый button. Добавлю тег в код как константу:

  // тег для поиска элемента, в котором находится кнопка "Лайк"
  const TARGET_TAG = "reel-action-bar-view-model";
  // Клавиша, которую будем слушать для триггера события "Лайк"
  const TRIGGER_KEY = "F13";

Сразу добавлю функцию, которая найдет нужный элемент:

function findTargetContainer() {
    // Ищем все элементы с указанным тегом
    const containers = document.querySelectorAll(TARGET_TAG);
    /*
      Если элементов нет, возвращаем null.
      Скорее всего, что-то пошло не так при загрузке страницы.
      В любом случае, продолжать нет смысла
    */
    if (containers.length === 0) return null;
    // Если элемент только один, мы нашли контейнер с кнопкой. Возвращаем его.
    if (containers.length === 1) return containers[0];


    /*
      Этот код на случай, если контейнеров найдено несколько.
      Скорее всего, только один из них показан на странице и работать надо с ним.
    */
    for (const el of containers) {
      // Кастомная функция проверки видимости элемента
      if (isElementVisible(el)) return el;
    }
    return containers[0];
  }

Если элементов несколько, важно кликать по видимому. Иначе в лучшем случае скрипт не сработает, а в худшем сайт сочтёт действие автоматическим: по невидимым элементам кликают только боты.

Функцию isElementVisible лучше разместить до findTargetContainer. Формально JavaScript поднимает объявления функций и порядок не важен, но читать код проще, когда вспомогательная функция объявлена раньше:

  function isElementVisible(el) {
    // Получаем размеры и позицию элемента относительно окна браузера
    const rect = el.getBoundingClientRect();
    // Если элемент имеет нулевую ширину и высоту, считаем его невидимым
    if (rect.width === 0 && rect.height === 0) return false;
    // Проверяем, находится ли элемент в пределах видимой области окна браузера
    return (
      rect.top < window.innerHeight &&
      rect.bottom > 0 &&
      rect.left < window.innerWidth &&
      rect.right > 0
    );
  }

Такая проверка работает не всегда: элемент может быть перекрыт другим или скрыт через opacity. Для более надёжного результата стоит проверять пересечение элемента с областью видимости через IntersectionObserver. 

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

  function shouldIgnoreEvent(e) {
    // В переменной e хранится описание события, нам нужно получить элемент, на котором оно произошло
    const target = e.target;
    // Если элемента нет, возвращаем false, чтобы обработать событие
    if (!target) return false;
    // Получаем тег элемента в верхнем регистре, чтобы сравнивать с запрещенными тегами
    const tag = target.tagName.toString().toUpperCase();
    // Если событие произошло на поле для ввода, возвращаем true, чтобы игнорировать событие
    if (tag === "INPUT" || tag === "TEXTAREA") return true;
    // Редактируемые элементы тоже игнорируем, чтобы не мешать пользователю вводить текст
    if (target.isContentEditable) return true;
    // Все хорошо, событие нужно обработать
    return false;
  }

Обработчик нажатия F13 вынес в отдельную функцию handleKeyPress. Код ниже пишем тоже внутрь тела функции:

 function handleKeyPress(e) {
    // Проверяем, что нажата нужная клавиша
    if (e.key !== TRIGGER_KEY) return;    
    console.log('TRIGGERED');
    // Проверяем, надо ли обрабатывать событие или это паразитное нажатие
    if (shouldIgnoreEvent(e)) return;


    // Ищем контейнер с кнопкой "Лайк"
    const container = findTargetContainer();
    // Если контейнер не найден, выводим сообщение в консоль и прекращаем выполнение функции
    if (!container) {
      console.debug(`[F13-clicker] Элемент <${TARGET_TAG}> не найден на странице.`);
      return;
    }


    // Ищем первую кнопку внутри контейнера, она и есть "Лайк"
    const button = container.querySelector("button");
    // Если кнопка не найдена, выводим сообщение в консоль и прекращаем выполнение функции
    if (!button) {
      console.debug(`[F13-clicker] Внутри <${TARGET_TAG}> не найдена кнопка <button>.`);
      return;
    }


    // Кликаем по кнопке "Лайк"
    button.click();
    // Предотвращаем дальнейшую обработку события, чтобы не мешать другим скриптам на странице
    e.preventDefault();
    e.stopPropagation();
  }

Заключительная часть — добавить обработку события нашей функцией:

    // Добавляем слушателя события
    document.addEventListener(
      // Слушаем нажатие клавиш
      "keydown",
      // Функция обработчик события
      handleKeyPress,
      // Включаем фазу перехвата, чтобы событие обрабатывалось до того, как оно достигнет других элементов
      true
    );

Расширение готово — добавляем его в браузер и проверяем. Кликаем на значок пазла в тулбаре и выбираем Управление расширениями.. Другой вариант — открыть новую вкладку и перейти на about:addons. 

Наше расширение не упаковано и не прошло проверку в магазине расширений Mozilla Firefox, поэтому есть только один способ проверить его — «Отладка дополнений». Кликаем на шестеренку вверху страницы и переходим к соответствующему пункту меню. Браузер откроет страницу отладки about:debugging#/runtime/this-firefox. Нажимаем Загрузить временное дополнение и указываем путь к manifest.json. Расширение появится вверху списка. Чтобы увидеть вывод console.log, который мы добавили для отладки, нажимаем Исследовать. Появится окно с инструментами разработчика, но для расширения. На вкладке «Консоль» привычный вывод JavaScript, включая ошибки и console.log. 

Осталось открыть шортсы и протестировать работоспособность устройства. Если все сделали, как описано, кликер будет переключать ролики и ставить/убирать лайки. 

Добавляем лайки в TikTok

Сначала внесем правки в блок контентных скриптов. Блок content_scripts — массив, в который можно добавлять файлы для разных доменов. Используем эту возможность:

  "content_scripts": [
    {
      "matches": ["https://www.youtube.com/*"],
      "js": ["content_yt.js"],
      "run_at": "document_idle",
      "all_frames": false
    },
   
    {
      "matches": ["https://www.tiktok.com/*"],
      "js": ["content_tiktok.js"],
      "run_at": "document_idle",
      "all_frames": false
    }
  ]

Дальше нужно найти подходящие селекторы, чтобы добраться до лайка. Здесь есть нюанс: в отличие от YouTube, у TikTok два режима отображения — классический и полноэкранный. Значит, нужно реализовывать два механизма поиска кнопки. 

В обычном режиме сначала нужно найти первый доступный тег article: 

let ttPage = document.querySelector('article')

Внутри него — второй section, и только там появляется кнопка лайка.

let ttButton = Array.from(ttPage.querySelectorAll('section'))[1]

Лайк — вторая кнопка:

let ttLike = Array.from(ttButton .querySelectorAll('button'))[1]

Когда приложение открыто на весь экран, ситуация немного сложнее. Разобрав HTML-код, пришёл к выводу: искать нужно этот div:

<div class="tiktok-1l9iosz-7937d88b--DivCinemaModeContent ezh7xdt1">

Скорее всего, статическая часть — DivCinemaModeContent, поэтому селектор будет таким:

let ttArea = document.querySelector('div[class*="DivCinemaModeContent"]')
let ttLike = Array.from(ttArea.querySelectorAll('button'))[2]

Чтобы определить, в каком режиме работает TikTok, проверяем результат поиска DivCinemaModeContent. Если в ответ приходит null, значит, сайт работает в обычном режиме. В противном случае, полноэкранный просмотр ролика. 

Можно создать файл content_tiktok.js рядом с манифестом и скриптом под YouTube.

Исходный код:
(function () {
  const TARGET_DIV_CINEMA  = "DivCinemaModeContent"
  // Клавиша, которую будем слушать для триггера события "Лайк"
  const TRIGGER_KEY = "F13";


  function findCinemaContainer() {
    // Ищем контейнер полноэкранного режима (Cinema Mode)
    return document.querySelector(`div[class*="${TARGET_DIV_CINEMA}"]`);
  }


  function findRegularContainer() {
    // В обычном режиме кнопка лайка лежит внутри тега TARGET_TAG
    return document.querySelector('article');
  }


  function getLikeButton() {
    // Сначала проверяем, не находимся ли мы в полноэкранном режиме
    const cinemaArea = findCinemaContainer();
    if (cinemaArea) {
      // В полноэкранном режиме кнопка лайка - пятая по счету кнопка в контейнере
      const buttons = Array.from(cinemaArea.querySelectorAll("button"));
      if (buttons.length < 5) return null;
      return buttons[2];
    }


    // Если полноэкранного контейнера нет, работаем в обычном режиме
    const ttPage = findRegularContainer();
    if (!ttPage) {
      console.debug(`[F13-clicker] Элемент <${TARGET_TAG}> не найден на странице.`);
      return null;
    }


    const sections = Array.from(ttPage.querySelectorAll("section"));
    // Кнопки лайка/комментов/шеринга лежат во второй секции
    if (sections.length < 2) return null;
    const ttButton = sections[1];


    const buttons = Array.from(ttButton.querySelectorAll("button"));
    // Внутри секции кнопка лайка - вторая по счету
    if (buttons.length < 2) return null;
    return buttons[1];
  }


  function shouldIgnoreEvent(e) {
    // В переменной e хранится описание события, нам нужно получить элемент, на котором оно произошло
    const target = e.target;
    // Если элемента нет, возвращаем false, чтобы обработать событие
    if (!target) return false;
    // Получаем тег элемента в верхнем регистре, чтобы сравнивать с запрещенными тегами
    const tag = target.tagName.toString().toUpperCase();
    // Если событие произошло на поле для ввода, возвращаем true, чтобы игнорировать событие
    if (tag === "INPUT" || tag === "TEXTAREA") return true;
    // Редактируемые элементы тоже игнорируем, чтобы не мешать пользователю вводить текст
    if (target.isContentEditable) return true;
    // Все хорошо, событие нужно обработать
    return false;
  }


  function handleKeyPress(e) {
    // Проверяем, что нажата нужная клавиша
    if (e.key !== TRIGGER_KEY) return;
    console.log('TRIGGERED');
    // Проверяем, надо ли обрабатывать событие или это паразитное нажатие
    if (shouldIgnoreEvent(e)) return;


    // Ищем кнопку "Лайк" с учетом текущего режима (обычный/полноэкранный)
    const button = getLikeButton();
    // Если кнопка не найдена, выводим сообщение в консоль и прекращаем выполнение функции
    if (!button) {
      console.debug("[F13-clicker] Кнопка лайка не найдена на странице TikTok.");
      return;
    }


    // Кликаем по кнопке "Лайк"
    button.click();
    // Предотвращаем дальнейшую обработку события, чтобы не мешать другим скриптам на странице
    e.preventDefault();
    e.stopPropagation();
  }


  // Добавляем слушателя события
  document.addEventListener(
    // Слушаем нажатие клавиш
    "keydown",
    // Функция обработчик события
    handleKeyPress,
    // Включаем фазу перехвата, чтобы событие обрабатывалось до того, как оно достигнет других элементов
    true
  );


})();

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

Стоимость изделия

Паяльные принадлежности не считаю: они покупаются не под один проект. Только сами детали:

  1. Плата разработки — 370 ₽ на маркетплейсах. Есть варианты интереснее и по цене, и по размеру, но там выше риск брака: в дешёвых платах часто стоят перемаркированные чипы, снятые с бывшего в употреблении оборудования. 

  2. Сенсор TTP223 — 210 ₽ за 10 шт. Для проекта нужны три штуки — 63 ₽. Если взять обычную тактовую кнопку, выйдет ещё дешевле. Например, KAN0611-0901B 6×6×9 мм выходит по 9 ₽ за штуку.

Самостоятельная печать корпуса — меньше 50 ₽. Я печатал дешёвым пластиком местного производства: 570 ₽ за килограмм, то есть 0,57 ₽ за грамм. На корпус ушло меньше 25 г. Даже на дорогом пластике корпус не выйдет дороже 50 ₽. 

Электричество можно не учитывать: 3D-принтер потребляет копейки в прямом смысле слова. Пиковая мощность принтера — 350 Вт, печать заняла 55 минут: это примерно 0,3 кВт·ч. 

Итоговая стоимость устройства — от 450 до 1000 ₽. Недорого, особенно с учётом того, что кликер можно перепрограммировать под другие задачи.  

Заключение 

Кликер обошёлся в 450 ₽ и один день работы, причём большая часть времени ушла на моделирование корпуса — код занял куда меньше.

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

Устройство собрано ради развлечения и опыта, но потенциал у него шире. Сенсоров можно добавить сколько угодно, каждый обойдётся в 20 ₽, а прошивка переписывается под любые горячие клавиши. По сути получается программируемый макропад за цену чашки кофе.

Следующий шаг — избавиться от провода. ESP32-S3 умеет в BLE HID, так что кликер должен заработать не только с ПК, но и с телефоном. Если получится, напишу вторую часть.

Исходники прошивки, оба скрипта расширения и STL-модель корпуса выложил на GitHub.

Автор текста — Евгений Хорошилов


НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ нового VDS — HABRFIRSTVDS.

Положение об акции