Хабр! Ну ты же про технологии... ну и где твои технологии ? Что ж ты, фраер, сдал назад...

Сегодня сделаем расширение для фрилансеров очень хорошей биржи Хабр Фриланс, я там сам работал еще в 2018 году, тогда сайт назывался еще Фрилансим.

Расширение будет уведомлять о новых задачах, приглашениях и сообщениях от заказчиков проверяя каждые 15 секунд. Это весьма удобно когда дорожишь именем и репутацией, внимательно относишься к заказам и заказчикам, я был как в роли заказчика так и фрилансера и понимаю о чем говорю ужасно тяжело общаться с фрилансерами и заказчиками, которые отвечают по часу. Да и честно сказать, я уже делал это расширение в том же 2018 году, но потом я ушел с фриланса и больше там не работал, клевое было время. Но речь не об этом, а о том, почему Хабр сам не сделал такое расширение или хотя бы просто прикрутил уведомления на сайт?

Хых, ты что, слепой, вот же кнопка «Получать заказы на почту»:

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

Ну и come on, Хабр, почта в 2к21 году ? Ну такое... На протяжении этой статьи я еще не раз вернусь к тому, с чего начал эту статью, буду разоблачать Хабр по полной, а потом найду куски своего кода на сайте про говнокод. Я не так часто создаю расширения для браузера, не особо это интересное занятие, хотя расширения для браузеров имеют очень большой потенциал, но документация Google оставляет желать лучшего, чтобы все сделать правильно пришлось перерыть весь интернет разобраться со многими тонкостями расширение-строения и провести много экспериментов. Полезную часть приобретенного опыта я буду описывать по ходу разработки.

Перейдем к делу, достаточно отступлений!

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

Расширения начинаются с файла manifest.json, в нем мы описываем некий конфиг всего расширения, поэтому начнем с него.

Манифест представляет из себя объект в JSON с строками, массивами и объектами, в целом ничего сложного.

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

Дальше у нас идет background.service_worker в 2 версии он называется просто фоновая страница и работает также как и в нынешний момент. Другими словами это просто скрипт, который работает в фоне и может делать всякие штуки, которые разрешены, например отправлять уведомления или создавать новые вкладки, а также делать запросы к страницам сайтов в данном случае доступы к сайтам будет определять host_permissions (под доступами подразумевается снятие ограничений на кроссдоменные запросы) Ну и самое главное что наш background.js сможет работать в фоне в более общем смысле, то есть если даже браузер будет свернут скрипт будет также выполняться. В отличии от 2 версии persistent (заполнить) в 3 версии работать не будет и если не использовать внутренний таймер alarms для повторного отложенного вызова функций, а использовать скажем setInterval или setTimeout то service_worker в один момент станет не активный и перестанет выполнять задачи.

Если вы пользуетесь хотя бы одним расширением то наверняка знаете про action.default_popup он используется как внешняя страница расширения в отдельном окне, обычно для настройки расширения и является по сути просто html страницей с такими же обычными стилями и скриптами, общение между этой страницей и остальным расширением в нашем случае будет реализовано через localStorage или в chrome оно называется просто storage но смысл от этого не меняется, в отличии от браузерного localStorage у storage расширенный функционал, данные можно получать асинхронно и сразу объектом, а не строкой но самое главное через них мы сможем передать данные из default_popup в service_worker и обратно. Своего рода «фронтенд» и «бекенд».

Чтобы делать всякие сложные штуки нам нужны разрешения, в массиве permissions перечисляем к чему хотим получить доступы, тут все просто, также как и в host_permissions перечисляем доступы к CORS запросам, существуют еще и другие формы например https://*.*/* — или что-то вроде того, ИМХО c доступам ко всем сайтам при публикации расширения могут возникнуть вопросы у модерации. 

Ну вот мы собрали рабочую структуру манифест файла, теперь перейдем к сайту и изучим его. Нигде уж точно явно не сказано о том что у Хабра есть открытый API, наверно он есть, просто закрытый от любопытных глаз, окей. У нас есть страница https://freelance.habr.com/tasks с задачами и фильтры с права, если понажимать на фильтры то таски задачи начинают ранжироваться и обновляться, значит используется ajax запрос, что вполне очевидно. 

Окей посмотрим что у нас по запросам в вкладке с сетью, хмм, выглядит странно Хабр, почему не JSON ? Уверен что уже 4 года это так работает и ничего не менялось с того времени, может быть тогда было так принято…

Собственно нам все равно, если посмотреть на тот же mfc можно очароваться еще больше.

Хорошо что хоть какое-то подобие API у нас есть. Откроем файл background.js и попробуем запросить задачи по дефолтной ссылке.

const taskChecker = () =>
  fetch(`https://freelance.habr.com/tasks?_=${new Date() - 0}`, {
    'headers': {
      'accept': 'text/javascript, application/javascript, application/ecmascript, application/x-ecmascript, */*; q=0.01',
      'x-requested-with': 'XMLHttpRequest'
    }
  })
  .then(response => response.text())
  .then(response => {
    console.log(response)
  })

;(async () => {
  await taskChecker()
})();

В результате получаем тот же JavaScript что и на сайте (извините)

Теперь напишем небольшой парсер данных, брать будем только необходимое, а именно: название задачи, цену и id задачи. Мне бы очень хотелось не использовать тонну регулярных выражений, но DOMParser не работает как window и document, эти объекты в service_worker отсутствуют. Поэтому сделаем просто все на регулярках

const tasksParser = response => {
  const taskHTML = response
    .replace(/\n/gi, '')
    .match(/var content = '';var pagination = '';content \+= ".+;pagination \+= "/gi)[0]
    .replace(/(var content = '';var pagination = '';content \+= "|;pagination \+= ")/gi, '')
    .replace(/\\/gi, '')

  const tasksTitle = taskHTML
                      .match(/class="task__title" title="[^"]+/gi)
                      .map(
                        html =>
                          html.replace(/class="task__title" title="/, '').trim()
                      )

  const tasksCount = taskHTML
                      .match(/(span class='count'|span class='negotiated_price')>[^<]+/gi)
                      .map(
                        html =>
                          html.replace(/(span class='count'|span class='negotiated_price')>/, '').trim()
                      )
                      
  const tasksId = taskHTML
                      .match(/<a href="\/tasks\/\d+/gi)
                      .map(
                        html =>
                          html.replace(/<a href="\/tasks\//, '').trim()
                      )

  return Array(tasksTitle.length).fill(false).map((_, i) => ({
    title: tasksTitle[i],
    count: tasksCount[i],
    id: tasksId[i]
  }))
}

const taskChecker = () =>
  fetch(`https://freelance.habr.com/tasks?_=${new Date() - 0}`, {
    'headers': {
      'accept': 'text/javascript, application/javascript, application/ecmascript, application/x-ecmascript, */*; q=0.01',
      'x-requested-with': 'XMLHttpRequest'
    }
  })
  .then(response => response.text())
  .then(response => {
    const tasks = tasksParser(response)

    console.log(tasks)
  })
  
;(async () => {
  await taskChecker()
})();

Теперь когда мы получаем задачи в нормальном виде, пора их выводить на экран в качестве уведомлений.

const taskChecker = () =>
  fetch(`https://freelance.habr.com/tasks?_=${new Date() - 0}`, {
    'headers': {
      'accept': 'text/javascript, application/javascript, application/ecmascript, application/x-ecmascript, */*; q=0.01',
      'x-requested-with': 'XMLHttpRequest'
    }
  })
  .then(response => response.text())
  .then(response => {
    const tasks = tasksParser(response)

    tasks.forEach(({ title, count, id }, i) => {
      const notificationId = id

      chrome.notifications.create(notificationId, {
        title: 'Заказы',
        message: `${title} — ${count}`,
        iconUrl: ‘/logo.jpg',
        type: 'basic'
      })
    })
  })

;(async () => {
  await taskChecker()
})();

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

chrome.notifications.create('id-1485’, { /* ... */ })
  
chrome.notifications.onClicked.addListener(data => {
  // в data будет приходить id уведомления например 'id-1485’
})

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

chrome.notifications.onClicked.addListener(data => {
  const [type, id] = data.split('_')

  if (type === 'task' || type === 'invite') {
    chrome.tabs.create({ url: `https://freelance.habr.com/tasks/${id}` })
  }

  if (type === 'dialog') {
    const [taskId, dialogId] = id.split('-')
    chrome.tabs.create({ url: `https://freelance.habr.com/tasks/${taskId}/conversations/${dialogId}` })
  }
})

Теперь код будет выглядеть так:

const taskChecker = () =>
  fetch(`https://freelance.habr.com/tasks?_=${new Date() - 0}`, {
    'headers': {
      'accept': 'text/javascript, application/javascript, application/ecmascript, application/x-ecmascript, */*; q=0.01',
      'x-requested-with': 'XMLHttpRequest'
    }
  })
  .then(response => response.text())
  .then(response => {
    const tasks = tasksParser(response)

    tasks.forEach(({ title, count, id }, i) => {
      const notificationId = 'task_'+id

      chrome.notifications.create(notificationId, {
        title: 'Заказы',
        message: `${title} — ${count}`,
        iconUrl: '/plug.jpg',
        type: 'basic'
      })
    })
  })

chrome.notifications.onClicked.addListener(data => {
  const [type, id] = data.split('_')

  if (type === 'task') {
    chrome.tabs.create({ url: `https://freelance.habr.com/tasks/${id}` })
  }
})

;(async () => {
  await taskChecker()
})();

Эй, отправляются старые задачи и вообще после удаления старых они появляются снова, эта проблема решается фильтрацией по id и специальным флажком, который пропустит старые уведомления, а фильтрация не даст появляться новым по несколько раз, хранить уже отправленные уведомления будем в storage, id будем заносить в storage и помечать как true, а потом просто игнорировать, да может быть кто-то скажет, а почему сразу в storage, а не в массив например, просто потому что перебирать массив может стать в один момент очень громоздким делом, проще проверять есть ли в объекте ключ с значением true. Вынесем уведомления в отдельную функцию, которая сама в себе будет проверять нужно ли отправлять уведомление. Реализуем таким образом:

let SKIP_MESSAGES = true // флажок

const delay = ms => new Promise(res => setTimeout(res, ms))

const pushNotification = (id, option) => {
  chrome.storage.local.get(state => {
    SKIP_MESSAGES || state[id] || chrome.notifications.create(id, option)

    chrome.storage.local.set({ [id]: true }, () => { /* save */ })
  })
}

const taskChecker = () => {
  // …
  pushNotification(id, options)
}

chrome.alarms.create({ periodInMinutes: 0.15 }) // интервал в 15 секунд

;(() => {
  await taskChecker()
  await delay(5000) // задержим выполнение функции чтоб пропустить все срабатывания уведомлений
  
  SKIP_MESSAGES = false /* Разрешаем отправлять уведомления после того как пропустили все старые */

  chrome.alarms.onAlarm.addListener(async () => {
    await taskChecker()
  })
})();

Теперь наши уведомления работаю полноценно, мы можем получать заказы прямо на рабочий стол и сразу открывать их в браузере, но это еще не все, ведь нам нужны приглашения, фильтры и диалоги с заказчиками. Начнем с последнего, тут все также просто как и с уведомлениями о задачах. Перейдем на страницу https://freelance.habr.com/my/responses здесь мы видим отклики и актуальные заказы, нам нужны именно они, точнее ссылки на те задачи, на которые мы откликнулись. Небольшое отступление, расширение не требует авторизации и делает запросы от имени браузера со всеми авторизационными данными, поэтому сделав запрос на эту страницу мы получим именно наши задачи.

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

const getResponses = () =>
  fetch('https://freelance.habr.com/my/responses')
    .then(response => response.text())
    .then(response => response.match(/\/tasks\/\d+/gi).map(task => task.match(/\d+/)[0]))

Теперь у нас есть массив id задач, получим ссылки на диалоги с заказчиками, если заказ выполнен или по каким-то другим причинам диалог с заказчиком не открыт возвращаем false, потом просто отфильтруем.

const getOwnerDialog = taskId =>
  fetch(`https://freelance.habr.com/tasks/${taskId}`)
    .then(response => response.text())
    .then(
      response => (
        hrefDialog =>
          hrefDialog
            ? `https://freelance.habr.com${hrefDialog}`
            : false
      )(
        response.match(/\/tasks\/\d+\/conversations\/\d+/)
      )
    )

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

const getOwnerMessages = dialogUrl =>
  fetch(dialogUrl, {
    'headers': {
      'accept': 'application/json'
    }
  })
  .then(response => response.json())
  .then(({ conversation: { hirer: { user }, messages } }) => ({
    username: user.username,
    avatar: user.avatar.src2x,
    messages: messages
                .filter(message => message.user.username === user.username)
                .map(message => ({ body: message.body, id: message.id }))
  }))

Собираем весь наш конструктор в одну функцию dialogChecker. Тут все работает практически также как с уведомлениями о заказах.

const dialogChecker = async () => {
  const responses = await getResponses()

  const responsesDialogUrls = (
    await Promise.all(responses.map(getOwnerDialog))
  )
  .filter(f => f)

  const ownerMessages = (
    await Promise.all(responsesDialogUrls.map(getOwnerMessages))
  )

  ownerMessages.forEach(({ avatar, messages, username }, i) => {
    const [taskId, dialogId] = responsesDialogUrls[i].match(/\d+/gi)

    messages.forEach(message => {
      const notificationId = 'dialog_'+taskId+'-'+dialogId+'-'+message.id

      pushNotification(notificationId, {
        title: `Сообщение от ${username}`,
        message: message.body,
        iconUrl: avatar.match(/https/) ? avatar : `https://freelance.habr.com/${avatar}`, // ну вот так хабр
        type: 'basic'
      })
    })
  })
}

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

const getInvites = () =>
  fetch('https://freelance.habr.com/my/responses/invites')
    .then(response => response.text())
    .then(response => response.match(/\/tasks\/\d+/gi).map(task => task.match(/\d+/)[0]))

И напишем функцию, которая будет выбирать название и объявленную цену.

const getTask = async taskId =>
  fetch(`https://freelance.habr.com/tasks/${taskId}`)
    .then(response => response.text())
    .then(response => {
      try {
        const title = response
                        .match(/<meta content='[^']+/gi)[0]
                        .replace("<meta content='", '')

        const count = response
                        .match(/(class='negotiated_price'>[^<]+|class='count'>[^<]+)/gi)[0]
                        .replace(/(class='negotiated_price'>|class='count'>)/, '')
                        .trim()
        return {
          id: taskId,
          title,
          count
        }
      } catch (e) {
        return false
      }
    })

Теперь все соберем в отдельный чекер и проверим.

const inviteChecker = async () => {
  const invites = await getInvites()

  const taskInvites = (
    await Promise.all(invites.map(getTask))
  )
  .filter(f => f)

  taskInvites.forEach(({ title, count, id }, i) => {
    const notificationId = 'invite_'+id

    pushNotification(notificationId, {
      title: 'Вас пригласили',
      message: `${title} — ${count}`,
      iconUrl: '/logo.jpg',
      type: 'basic'
    })
  })
}

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

const taskChecker = () =>
  chrome.storage.local.get(({
    q = '',
    categories = '',
    fields = '',
    only_urgent = '',
    safe_deal = '',
    only_with_price = '',
    only_mentioned = ''
  }) =>
    fetch(`https://freelance.habr.com/tasks?_=${
      new Date() - 0
    }${
      q ? `&q=${q}` : ''
    }${
      categories ? `&categories=${categories}` : ''
    }${
      fields ? `&fields=${fields}` : ''
    }${
      only_urgent ? `&only_urgent=${only_urgent}` : ''
    }${
      safe_deal ? `&safe_deal=${safe_deal}` : ''
    }${
      only_with_price ? `&only_with_price=${only_with_price}` : ''
    }${
      only_mentioned ? `&only_mentioned=${only_mentioned}` : ''
    }`, {
      'headers': {
        'accept': 'text/javascript, application/javascript, application/ecmascript, application/x-ecmascript, */*; q=0.01',
        'x-requested-with': 'XMLHttpRequest'
      }
    })
    .then(response => response.text())
    .then(response => {

      console.log(
        `https://freelance.habr.com/tasks?_=${
          new Date() - 0
        }${
          q ? `&q=${q}` : ''
        }${
          categories ? `&categories=${categories}` : ''
        }${
          fields ? `&fields=${fields}` : ''
        }${
          only_urgent ? `&only_urgent=${only_urgent}` : ''
        }${
          safe_deal ? `&safe_deal=${safe_deal}` : ''
        }${
          only_with_price ? `&only_with_price=${only_with_price}` : ''
        }${
          only_mentioned ? `&only_mentioned=${only_mentioned}` : ''
        }`
      )

      const tasks = tasksParser(response)

      tasks.forEach(({ title, count, id }, i) => {
        const notificationId = 'task_'+id

        pushNotification(notificationId, {
          title: 'Заказы',
          message: `${title} — ${count}`,
          iconUrl: '/logo.jpg',
          type: 'basic'
        })
      })
    })
  )

Также для теста откроем файл index.html и внесем туда простой HTML код для теста.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Habr Freelance</title>
</head>
<body>
  <input type="text" id="q" />
  <script src="index.js"></script>
</body>
</html>

А в index.js запишем короткий скрипт также для теста и посмотрим на результат в «отладочной странице» расширения:

const search = document.getElementById('q')

chrome.storage.local.get(({ q = '', categories = '' }) => {
  search.value = q
})

search.addEventListener('input', () => {
  chrome.storage.local.set({ q: search.value }, () => {})
})

Связь с «Фронтендом» есть, теперь нужно его оформить, для это воспользуемся версткой и стилями сайта, это оказывается очень просто, правда с JS придется повозиться…

Так как default_popup в отличии от страницы браузера изначально имеет ширину и высоту содержащегося в нем контента, также body имеет отступы в 8px. Сделаем простую разметку и стили, которую будем считать дефолтной. Для index.html

<!DOCTYPE html>
<html lang="ru">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <meta http-equiv="X-UA-Compatible" content="ie=edge" />
        <link rel="stylesheet" href="style.css" />
        <title>Habr Freelance</title>
    </head>
    <body>
        <div class="custom-form">
            
        </div>
        <script src="index.js"></script>
    </body>
</html>

И для style.css

body {
  margin: 0px;
  padding: 0px;
}

.custom-form {
  width: 300px;
  height: 500px;
  overflow-y: scroll;
  background: #fff;
}

Как итог мы имеем пустую белую страницу, теперь можем добавить верстку фильтров с Хабра. Самый верхний блок это Тег dl, его мы скопируем и вставим в наш тег <div class="custom-form»>…</div> после чего можно скопировать весь код и выравнять его в https://unminify.com/ так он будет лучше читаем.

Теперь мы видим в расширении просто страницу без стилей:

Но это еще не все, также нам нужно скопировать стили сайта, ничего лучше я не придумал чем открыть файл по ссылке https://freelance.habr.com/assets/application-93fe068befcaa9935821090c87283a9087b951f125a361d5125e6d5d749460e6.css (может быть не актуально на момент прочтения) и просто скопировать оттуда css.

Посмотрим на результат:

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

 Чтобы наши фильтры были точно такой как на сайте добавим блок поиска также скопировав его с страницы сайта, блок начинается с тега <div class="search-suggest"> и добавим его над dl тегом.

Сам поиск отличается от формы с фильтрами размером, всего на 10px.

Найдем его в style.css и заменим на:

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

Перейдем к программированию в index.js код получается очень объемным и слишком простым разбирать на кусочки. Поэтому вставим сразу посмотрим результат.

Много кода
// все кнопочки и блоки
const search = document.getElementById('q')
    , _categories = [...document.querySelectorAll('.category-group__folder')]
    , searchOptions = document.querySelector('.dropdown_inline')
    , options = [...searchOptions.querySelectorAll('[class="checkbox_flat"]')]
    , toggler = searchOptions.querySelector('.toggler')
    , arrow = document.querySelector('.icon-Arrow').cloneNode()
    , onlyMentioned = document.querySelector('#only_mentioned')
    , onlyWithPrice = document.querySelector('#only_with_price')
    , safeDeal = document.querySelector('#safe_deal')
    , onlyUrgent = document.querySelector('#only_urgent')
    , notifyTask = document.querySelector('#notify_task')
    , notifyMessageOwner = document.querySelector('#notify_message_owner')
    , notifyInvite = document.querySelector('#notify_invite')
    , cancel = document.querySelector('#cancel')


// устанавливаем все значения из storage
chrome.storage.local.get(({
  q = '',
  categories = '',
  fields = '',
  only_urgent = '',
  safe_deal = '',
  only_with_price = '',
  only_mentioned = '',
  notify_task = true,
  notify_message_owner = true,
  notify_invite = true
}) => {
  // поиск
  search.value = q

  // простые чекбоксы
  onlyUrgent.checked = !!only_urgent
  safeDeal.checked = !!safe_deal
  onlyWithPrice.checked = !!only_with_price
  onlyMentioned.checked = !!only_mentioned

  notifyTask.checked = notify_task
  notifyMessageOwner.checked = notify_message_owner
  notifyInvite.checked = notify_invite

  // фильтры по тегам, названиям и описанию
  const searchOptionsValue = fields.split(',')

  options.forEach((option, i) => {
    const checkbox = option.querySelector('input[type="checkbox"]')
    searchOptionsValue.includes(checkbox.getAttribute('name'))
      ? checkbox.checked = true
      : checkbox.checked = false
  })

  const checkboxs = options
                      .map(option => option.querySelector('input[type="checkbox"]'))
                      .flat()

  const labels = options
                  .map(option => option.querySelector('.checkbox__label'))
                  .flat()

  const selectLabels = checkboxs
                          .map(
                            (checkbox, i) =>
                              checkbox.checked
                                ? labels[i].innerHTML
                                : false
                          )
                          .filter(option => option)
                          .join(', ')

  toggler.innerHTML = selectLabels.length === 0
                        ? 'Искать везде'
                        : 'Искать ' + selectLabels
  toggler.appendChild(arrow)
 
  // категории
  const categoriesValue = categories.split(',')

  _categories
    .forEach(
      category =>
        [
          ...category.querySelectorAll('input[type="checkbox"]')
        ].forEach(
          checkbox =>
            categoriesValue.includes(checkbox.getAttribute('id'))
              ? (checkbox.checked = true)
              : false
        )
    )

  _categories.forEach((category, i) => {
    const checkboxs = [...category.querySelectorAll('input[type="checkbox"]')]

    const selectedCount = checkboxs.reduce((ctx, checkbox) => ctx + checkbox.checked, 0)

    if (selectedCount === 0) {
      category.classList.remove('full')
      category.classList.remove('part')
      return
    }

    if (selectedCount === checkboxs.length) {
      category.classList.add('full')
      category.classList.remove('part')
      return
    }

    if (selectedCount > 0) {
      category.classList.add('part')
      category.classList.remove('full')
      return
    }
  })
})

// сохраняем значения из поиска
search.addEventListener('input', () => {
  chrome.storage.local.set({ q: search.value }, () => {})
})

// обработчик событий из категорий
const categoryHandler = () => {
  const categoriesValue = _categories
                            .map(
                              category =>
                                [
                                  ...category.querySelectorAll('input[type="checkbox"]')
                                ].map(
                                  checkbox =>
                                    checkbox.checked
                                      ? checkbox.getAttribute('id')
                                      : false
                                )
                            )
                            .flat()
                            .filter(category => category)
                            .join(',')

  chrome.storage.local.set({ categories: categoriesValue }, () => {})
}

// обработчик событий из поисковых опций 
const searchOptionHandler = () => {
  const searchOptionsValue = options
                              .map(
                                option =>
                                  [
                                    ...option.querySelectorAll('input[type="checkbox"]')
                                  ].map(
                                    checkbox =>
                                      checkbox.checked
                                        ? checkbox.getAttribute('name')
                                        : false
                                  )
                              )
                              .flat()
                              .filter(category => category)
                              .join(',')

  chrome.storage.local.set({ fields: searchOptionsValue }, () => {})
}

// управление категориями
_categories.forEach((category, i) => {
  const mainCheckbox = category.querySelector('.checkbox_pseudo')
  const checkboxs = [...category.querySelectorAll('input[type="checkbox"]')]
  const title = category.querySelector('.link_dotted')

  title.addEventListener('click', () => {
    category.classList.contains('category-group__folder_open')
      ? category.classList.remove('category-group__folder_open')
      : category.classList.add('category-group__folder_open')
  })

  mainCheckbox.addEventListener('click', () => {
    if (category.classList.contains('full')) {
      category.classList.remove('full')
      category.classList.remove('part')
      checkboxs.forEach(checkbox => {
        checkbox.checked = false
      })
      categoryHandler()
    } else {
      category.classList.add('full')
      category.classList.remove('part')
      checkboxs.forEach(checkbox => {
        checkbox.checked = true
      })
      categoryHandler()
    }
  })

  checkboxs.forEach(checkbox => {
    checkbox.addEventListener('input', () => {
      categoryHandler()
      const selectedCount = checkboxs.reduce((ctx, checkbox) => ctx + checkbox.checked, 0)

      if (selectedCount === 0) {
        category.classList.remove('full')
        category.classList.remove('part')
        return
      }

      if (selectedCount === checkboxs.length) {
        category.classList.add('full')
        category.classList.remove('part')
        return
      }

      if (selectedCount > 0) {
        category.classList.add('part')
        category.classList.remove('full')
        return
      }
    })
  })
})

// открыть/закрыть опции поиска
searchOptions.addEventListener('click', () => {
  searchOptions.classList.contains('open')
    ? searchOptions.classList.remove('open')
    : searchOptions.classList.add('open')
})

// управление опциями поиска 
options.forEach((option, i) => {
  const checkboxs = options
                      .map(option => option.querySelector('input[type="checkbox"]'))
                      .flat()

  const labels = options
                  .map(option => option.querySelector('.checkbox__label'))
                  .flat()

  checkboxs[i].addEventListener('input', () => {
    const selectLabels = checkboxs
                          .map(
                            (checkbox, i) =>
                              checkbox.checked
                                ? labels[i].innerHTML
                                : false
                          )
                          .filter(option => option)
                          .join(', ')

    toggler.innerHTML = selectLabels.length === 0
                          ? 'Искать везде'
                          : 'Искать ' + selectLabels
    toggler.appendChild(arrow)
    searchOptionHandler()
  })
})

// обработчики простых чекбоксов
onlyMentioned.addEventListener('input', () => {
  chrome.storage.local.set({ only_mentioned: onlyMentioned.checked ? 'true' : '' }, () => {})
})

onlyWithPrice.addEventListener('input', () => {
  chrome.storage.local.set({ only_with_price: onlyWithPrice.checked ? 'true' : '' }, () => {})
})

safeDeal.addEventListener('input', () => {
  chrome.storage.local.set({ safe_deal: safeDeal.checked ? 'true' : '' }, () => {})
})

onlyUrgent.addEventListener('input', () => {
  chrome.storage.local.set({ only_urgent: onlyUrgent.checked ? 'true' : '' }, () => {})
})

notifyTask.addEventListener('input', () => {
  chrome.storage.local.set({ notify_task: notifyTask.checked }, () => {})
})

notifyMessageOwner.addEventListener('input', () => {
  chrome.storage.local.set({ notify_message_owner: notifyMessageOwner.checked }, () => {})
})

notifyInvite.addEventListener('input', () => {
  chrome.storage.local.set({ notify_invite: notifyInvite.checked }, () => {})
})

// стираем настройки и перезагружаем страницу расширения
cancel.addEventListener('click', () => {
  chrome.storage.local.set({
    q: '',
    categories: '',
    fields: '',
    only_urgent: '',
    safe_deal: '',
    only_with_price: '',
    only_mentioned: '',
  }, () => {
    location.reload()
  })
})

Остальной код вы найдете на моем GitHub. Добавим иконки и описание расширению в manifest.json.

Расширение уже работает и находится на GitHub, оттуда, скачав его, можно установить на странице расширений chrome://extensions/

В следующей статье мы опубликуем это расширение в Chrome Web Store и других магазинах расширений.

Если вы нашли ошибки в тексте, пишите в лс.

Комментарии (6)


  1. Starina_js
    02.12.2021 15:47

    Круто:) А для firefox подойдет?


    1. prohetamine Автор
      02.12.2021 15:48

      Скорее всего да, но лучше просто проверить)


      1. ris58h
        02.12.2021 16:21

        FF пока ещё не поддерживает manifest v3, насколько мне известно. Так что не должно работать.


  1. ris58h
    02.12.2021 16:29

    Меня смутило что fetch из background page отправляет запрос "со всеми авторизационными данными". Я это не проверял и думал что работает по-другому. Что-то уж слишком просто получить доступ к данным пользователя.


    1. prohetamine Автор
      02.12.2021 16:50

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


  1. SDKiller
    03.12.2021 07:15

    Просто дергал RSS с новыми заказами когда писал своего телеграм-бота (тогда еще у Фрилансим не было официального бота).