Хакнуть Tinder
В этой статье мы поговорим о совершенно необычном применении искусственного интеллекта и покажем, как его использовать для поиска подходящей пары в Tinder.
Можно создать Auto-tinder на основе Tensorflow и Python 3, который будет автоматически лайкать профили в Tinder на основе ваших предпочтений.
Для этого нам нужно сделать последовательно вот эти шаги:
- Проанализировать веб-страницы Tinder на предмет внутренних вызовов API, воссоздать их в Postman, и анализировать их содержимое
- Создать класс-обертку для API на Python, чтобы дальше использовать Tinder API.
- Загрузить изображения пользователей, которые находятся рядом
- Создать обычный классификатор для изображений
- Разработать препроцессор, использующий Tensorflow API для распознавания объектов
- Переобучить нейронную сеть inceptionv3 на классифицированных данных
- Применить классификатор вместе с оберткой Tinder API, чтобы они свайпали за нас.
Шаг 1: Анализ API
Вначале узнаем, как приложение Tinder взаимодействует с бэкендом. Для этого переходим на сайт Tinder, открываем Chrome Devtools и изучаем сетевой протокол. У Tinder есть внутренний API, который они используют для связи между фронт-эндом и бэкэндом. Эта конечная точка API возвращает список профилей пользователей, которые находятся поблизости. Данные включают эту информацию:
{
"meta": {
"status": 200
},
"data": {
"results": [
{
"type": "user",
"user": {
"_id": "4adfwe547s8df64df",
"bio": "19y.",
"birth_date": "1997-17-06T18:21:44.654Z",
"name": "Anna",
"photos": [
{
"id": "879sdfert-lskdföj-8asdf879-987sdflkj",
"crop_info": {
"user": {
"width_pct": 1,
"x_offset_pct": 0,
"height_pct": 0.8,
"y_offset_pct": 0.08975463
},
"algo": {
"width_pct": 0.45674357,
"x_offset_pct": 0.984341657,
"height_pct": 0.234165403,
"y_offset_pct": 0.78902343
},
"processed_by_bullseye": true,
"user_customized": false
},
"url": "https://images-ssl.gotinder.com/4adfwe547s8df64df/original_879sdfert-lskdföj-8asdf879-987sdflkj.jpeg",
"processedFiles": [
{
"url": "https://images-ssl.gotinder.com/4adfwe547s8df64df/640x800_879sdfert-lskdföj-8asdf879-987sdflkj.jpg",
"height": 800,
"width": 640
},
{
"url": "https://images-ssl.gotinder.com/4adfwe547s8df64df/320x400_879sdfert-lskdföj-8asdf879-987sdflkj.jpg",
"height": 400,
"width": 320
},
{
"url": "https://images-ssl.gotinder.com/4adfwe547s8df64df/172x216_879sdfert-lskdföj-8asdf879-987sdflkj.jpg",
"height": 216,
"width": 172
},
{
"url": "https://images-ssl.gotinder.com/4adfwe547s8df64df/84x106_879sdfert-lskdföj-8asdf879-987sdflkj.jpg",
"height": 106,
"width": 84
}
],
"last_update_time": "2019-10-03T16:18:30.532Z",
"fileName": "879sdfert-lskdföj-8asdf879-987sdflkj.webp",
"extension": "jpg,webp",
"webp_qf": [
75
]
}
],
"gender": 1,
"jobs": [],
"schools": [],
"show_gender_on_profile": false
},
"facebook": {
"common_connections": [],
"connection_count": 0,
"common_interests": []
},
"spotify": {
"spotify_connected": false
},
"distance_mi": 1,
"content_hash": "slkadjfiuwejsdfuzkejhrsdbfskdzufiuerwer",
"s_number": 9876540657341,
"teaser": {
"string": ""
},
"teasers": [],
"snap": {
"snaps": []
}
}
]
}
}Все изображения являются общедоступными и их может видеть кто угодно. Оригиналы фотографий имеют высокое разрешение и хранятся на публично доступных серверах. Если вы не выберете «show_gender_on_profile», через API все равно можно увидеть ваш пол («gender»: 1, где 1 = женщина, 0 = мужчина).
Анализируя заголовки, можно быстро найти наш личный ключ к API: X-Auth-Token. Скопировав этот токен в Postman, можно увидеть, что мы действительно можем свободно общаться с API Tinder, используя только правильный URL и токен авторизации.
Шаг 2: Обертка для API
Мы будем пользоваться библиотекой Python Requests для взаимодействия с API и создания обертки. Класс Person будет принимать ответ API и позволит взаимодействовать с ним.
Давайте начнем с написания класса. Он должен получать данные от API и сохранять все нужные данные во внутренних переменных. Кроме того, он должен иметь некоторые базовые функции, такие как “like” или “dislike»”, которые делают запрос к API, чтобы мы могли лайкать интересный нам профиль, используя команду “some_person.like ()”.
import datetime
from geopy.geocoders import Nominatim
TINDER_URL = "https://api.gotinder.com"
geolocator = Nominatim(user_agent="auto-tinder")
PROF_FILE = "./images/unclassified/profiles.txt"
class Person(object):
def __init__(self, data, api):
self._api = api
self.id = data["_id"]
self.name = data.get("name", "Unknown")
self.bio = data.get("bio", "")
self.distance = data.get("distance_mi", 0) / 1.60934
self.birth_date = datetime.datetime.strptime(data["birth_date"], '%Y-%m-%dT%H:%M:%S.%fZ') if data.get(
"birth_date", False) else None
self.gender = ["Male", "Female", "Unknown"][data.get("gender", 2)]
self.images = list(map(lambda photo: photo["url"], data.get("photos", [])))
self.jobs = list(
map(lambda job: {"title": job.get("title", {}).get("name"), "company": job.get("company", {}).get("name")}, data.get("jobs", [])))
self.schools = list(map(lambda school: school["name"], data.get("schools", [])))
if data.get("pos", False):
self.location = geolocator.reverse(f'{data["pos"]["lat"]}, {data["pos"]["lon"]}')
def __repr__(self):
return f"{self.id} - {self.name} ({self.birth_date.strftime('%d.%m.%Y')})"
def like(self):
return self._api.like(self.id)
def dislike(self):
return self._api.dislike(self.id)
На самом деле наша API-обертка - это не более, чем причудливый способ вызова Tinder API с использованием нашего класса:
import requests
TINDER_URL = "https://api.gotinder.com"
class tinderAPI():
def __init__(self, token):
self._token = token
def profile(self):
data = requests.get(TINDER_URL + "/v2/profile?include=account%2Cuser", headers={"X-Auth-Token": self._token}).json()
return Profile(data["data"], self)
def matches(self, limit=10):
data = requests.get(TINDER_URL + f"/v2/matches?count={limit}", headers={"X-Auth-Token": self._token}).json()
return list(map(lambda match: Person(match["person"], self), data["data"]["matches"]))
def like(self, user_id):
data = requests.get(TINDER_URL + f"/like/{user_id}", headers={"X-Auth-Token": self._token}).json()
return {
"is_match": data["match"],
"liked_remaining": data["likes_remaining"]
}
def dislike(self, user_id):
requests.get(TINDER_URL + f"/pass/{user_id}", headers={"X-Auth-Token": self._token}).json()
return True
def nearby_persons(self):
data = requests.get(TINDER_URL + "/v2/recs/core", headers={"X-Auth-Token": self._token}).json()
return list(map(lambda user: Person(user["user"], self), data["data"]["results"]))
Сейчас мы можем использовать API, чтобы находить пользователей поблизости, смотреть их профили и оценивать их. Для этого замените YOUR-API-TOKEN на X-Auth-Token.
if __name__ == "__main__":
token = "YOUR-API-TOKEN"
api = tinderAPI(token)
while True:
persons = api.nearby_persons()
for person in persons:
print(person)
# person.like()Шаг 3: Загружаем фотографии
Далее нам нужно автоматически загрузить около 2000 фотографий пользователей поблизости, которые мы будем использовать для обучения нашего ИИ. Во-первых, расширяем наш класс Person функцией, которая позволит нам загружать изображения.
# At the top of auto_tinder.py
PROF_FILE = "./images/unclassified/profiles.txt"
# inside the Person-class
def download_images(self, folder=".", sleep_max_for=0):
with open(PROF_FILE, "r") as f:
lines = f.readlines()
if self.id in lines:
return
with open(PROF_FILE, "a") as f:
f.write(self.id+"rn")
index = -1
for image_url in self.images:
index += 1
req = requests.get(image_url, stream=True)
if req.status_code == 200:
with open(f"{folder}/{self.id}_{self.name}_{index}.jpeg", "wb") as f:
f.write(req.content)
sleep(random()*sleep_max_for)
В код добавлен sleep не случайно. Это нужно для того, чтобы не перегружать Tinder большим количеством фотографий за несколько секунд.
Записываем ID всех пользователей в файл с именем “profiles.txt”. Сначала нужно проверить, есть ли определенный человек в файле, чтобы не включать один профиль дважды. Этим мы гарантируем, что нам не придется классифицировать одних и тех же людей несколько раз. Теперь итерируем по профилям людей, находящихся неподалеку, и загружаем их изображения в папку “unclassified”.
if __name__ == "__main__":
token = "YOUR-API-TOKEN"
api = tinderAPI(token)
while True:
persons = api.nearby_persons()
for person in persons:
person.download_images(folder="./images/unclassified", sleep_max_for=random()*3)
sleep(random()*10)
sleep(random()*10)После этого запускаем скрипт и даем ему поработать несколько часов, чтобы получить нужное количество фотографий.
Шаг 4: Классификация фотографий
На этом этапе будем создавать простенький классификатор полученных фотографий. Он будет проходить циклом по всем фотографиям в нашей папке “unclassified” и открывать их в GUI. Щелкнув правой кнопкой мыши по человеку, мы будем помечать его как непонравившегося, а щелчок левой кнопкой мыши будет значить, что человек нам понравился.
Позже этот выбор будет отображаться в имени файла: 4tz3kjldfj3482.jpg будет переименован в 1_4tz3kjldfj3482.jpg, если мы лайкнем фотографию или 0_4tz3kjldfj3482.jpg в противном случае.
Используем tkinter, чтобы быстро создать этот GUI:
from os import listdir, rename
from os.path import isfile, join
import tkinter as tk
from PIL import ImageTk, Image
IMAGE_FOLDER = "./images/unclassified"
images = [f for f in listdir(IMAGE_FOLDER) if isfile(join(IMAGE_FOLDER, f))]
unclassified_images = filter(lambda image: not (image.startswith("0_") or image.startswith("1_")), images)
current = None
def next_img():
global current, unclassified_images
try:
current = next(unclassified_images)
except StopIteration:
root.quit()
print(current)
pil_img = Image.open(IMAGE_FOLDER+"/"+current)
width, height = pil_img.size
max_height = 1000
if height > max_height:
resize_factor = max_height / height
pil_img = pil_img.resize((int(width*resize_factor), int(height*resize_factor)), resample=Image.LANCZOS)
img_tk = ImageTk.PhotoImage(pil_img)
img_label.img = img_tk
img_label.config(image=img_label.img)
def positive(arg):
global current
rename(IMAGE_FOLDER+"/"+current, IMAGE_FOLDER+"/1_"+current)
next_img()
def negative(arg):
global current
rename(IMAGE_FOLDER + "/" + current, IMAGE_FOLDER + "/0_" + current)
next_img()
if __name__ == "__main__":
root = tk.Tk()
img_label = tk.Label(root)
img_label.pack()
img_label.bind("<Button-1>", positive)
img_label.bind("<Button-3>", negative)
btn = tk.Button(root, text='Next image', command=next_img)
next_img() # load first image
root.mainloop()Загружаем все неклассифицированные фотографии в список «unclassified_images», открываем tkinter, передаем ему первое изображение вызвав next_img () и меняем размер, чтобы оно поместилось на экране. Затем мы задаем два щелчка (левой и правой кнопкой мыши) и вызываем функции “positive” и “negative”, которые переименовывают изображения в зависимости от того какой кнопкой мыши был сделан щелчок, а потом показывают следующую фотографию.
Шаг 5: Создаем препроцессор
Для следующего шага нам нужно привести фотографии к виду, который позволит нам классифицировать их. Есть несколько проблем, которым надо уделить внимание:
1. Размер базы данных: наша база относительно мала. Мы имеем дело с плюс-минус двумя тысячами фотографий, что считается очень маленьким количеством для данных такой сложности.
2. Разнообразие данных: фотографии выполнены с разных ракурсов - со спины, иногда на фото есть только лицо, иногда людей на фото нет.
3. Зашумленность данных: на большинстве фотографий есть не только сам человек, но и фон, который может мешать ИИ.
Мы будем решать эти проблемы двумя способами:
1. Преобразовывать фотографии из цветных в черно-белые, чтобы уменьшить объем информации в 3 раза
2. Вырезать часть фотографии, где находится человек
Первый способ очень просто сделать с использованием Pillow, в котором мы можем открыть наше изображение и перевести его в оттенки серого.
Второй - мы реализуем с помощью Tensorflow API для распознавания объектов и сети MobileNet на базе данных COCO.
Наш скрипт для распознавания людей на фото будет состоять из четырех частей:
Часть 1: Открываем предобученную сеть
В репозитории на Github вы найдете файл .pb с предобученной сетью MobileNet.
Давайте откроем его как граф Tensorflow:
import tensorflow as tf
def open_graph():
detection_graph = tf.Graph()
with detection_graph.as_default():
od_graph_def = tf.GraphDef()
with tf.gfile.GFile('ssd_mobilenet_v1_coco_2017_11_17/frozen_inference_graph.pb', 'rb') as fid:
serialized_graph = fid.read()
od_graph_def.ParseFromString(serialized_graph)
tf.import_graph_def(od_graph_def, name='')
return detection_graphЧасть 2: Загружаем фотографии
Используем Pillow для работы с изображениями. Так как Tensorflow для работы нужны numpy-массивы, поэтому напишем функцию, которая преобразует изображения в такие массивы:
import numpy as np
def load_image_into_numpy_array(image):
(im_width, im_height) = image.size
return np.array(image.getdata()).reshape(
(im_height, im_width, 3)).astype(np.uint8)Часть 3: Вызываем API
Эта функция берет изображение и граф Tensorflow, запускает процесс распознавания, а после возвращает всю информацию о типах объектов, ограничивающие рамки для них и оценки.
import numpy as np
from object_detection.utils import ops as utils_ops
import tensorflow as tf
def run_inference_for_single_image(image, sess):
ops = tf.get_default_graph().get_operations()
all_tensor_names = {output.name for op in ops for output in op.outputs}
tensor_dict = {}
for key in [
'num_detections', 'detection_boxes', 'detection_scores',
'detection_classes', 'detection_masks'
]:
tensor_name = key + ':0'
if tensor_name in all_tensor_names:
tensor_dict[key] = tf.get_default_graph().get_tensor_by_name(
tensor_name)
if 'detection_masks' in tensor_dict:
# The following processing is only for single image
detection_boxes = tf.squeeze(tensor_dict['detection_boxes'], [0])
detection_masks = tf.squeeze(tensor_dict['detection_masks'], [0])
# Reframe is required to translate mask from box coordinates to image coordinates and fit the image size.
real_num_detection = tf.cast(tensor_dict['num_detections'][0], tf.int32)
detection_boxes = tf.slice(detection_boxes, [0, 0], [real_num_detection, -1])
detection_masks = tf.slice(detection_masks, [0, 0, 0], [real_num_detection, -1, -1])
detection_masks_reframed = utils_ops.reframe_box_masks_to_image_masks(
detection_masks, detection_boxes, image.shape[1], image.shape[2])
detection_masks_reframed = tf.cast(
tf.greater(detection_masks_reframed, 0.5), tf.uint8)
# Follow the convention by adding back the batch dimension
tensor_dict['detection_masks'] = tf.expand_dims(
detection_masks_reframed, 0)
image_tensor = tf.get_default_graph().get_tensor_by_name('image_tensor:0')
# Run inference
output_dict = sess.run(tensor_dict,
feed_dict={image_tensor: image})
# all outputs are float32 numpy arrays, so convert types as appropriate
output_dict['num_detections'] = int(output_dict['num_detections'][0])
output_dict['detection_classes'] = output_dict[
'detection_classes'][0].astype(np.int64)
output_dict['detection_boxes'] = output_dict['detection_boxes'][0]
output_dict['detection_scores'] = output_dict['detection_scores'][0]
if 'detection_masks' in output_dict:
output_dict['detection_masks'] = output_dict['detection_masks'][0]
return output_dict Часть 4: Собираем все
Осталось написать функцию, которая сделает все действия последовательно: возьмет путь к фото, откроет его с помощью Pillow, вызовет API для распознавания и обрежет изображение вокруг человека по рамке.
import numpy as np
from PIL import Image
PERSON_CLASS = 1
SCORE_THRESHOLD = 0.5
def get_person(image_path, sess):
img = Image.open(image_path)
image_np = load_image_into_numpy_array(img)
image_np_expanded = np.expand_dims(image_np, axis=0)
output_dict = run_inference_for_single_image(image_np_expanded, sess)
persons_coordinates = []
for i in range(len(output_dict["detection_boxes"])):
score = output_dict["detection_scores"][i]
classtype = output_dict["detection_classes"][i]
if score > SCORE_THRESHOLD and classtype == PERSON_CLASS:
persons_coordinates.append(output_dict["detection_boxes"][i])
w, h = img.size
for person_coordinate in persons_coordinates:
cropped_img = img.crop((
int(w * person_coordinate[1]),
int(h * person_coordinate[0]),
int(w * person_coordinate[3]),
int(h * person_coordinate[2]),
))
return cropped_img
return None
Часть 5: Размещаем фото
Последний шаг - скрипт, который будет перебирать все фото в папке “unclassified”, проверять, есть ли у них закодированная метка, а потом копировать изображение в соответствующую папку.
import os
import person_detector
import tensorflow as tf
IMAGE_FOLDER = "./images/unclassified"
POS_FOLDER = "./images/classified/positive"
NEG_FOLDER = "./images/classified/negative"
if __name__ == "__main__":
detection_graph = person_detector.open_graph()
images = [f for f in os.listdir(IMAGE_FOLDER) if os.path.isfile(os.path.join(IMAGE_FOLDER, f))]
positive_images = filter(lambda image: (image.startswith("1_")), images)
negative_images = filter(lambda image: (image.startswith("0_")), images)
with detection_graph.as_default():
with tf.Session() as sess:
for pos in positive_images:
old_filename = IMAGE_FOLDER + "/" + pos
new_filename = POS_FOLDER + "/" + pos[:-5] + ".jpg"
if not os.path.isfile(new_filename):
img = person_detector.get_person(old_filename, sess)
if not img:
continue
img = img.convert('L')
img.save(new_filename, "jpeg")
for neg in negative_images:
old_filename = IMAGE_FOLDER + "/" + neg
new_filename = NEG_FOLDER + "/" + neg[:-5] + ".jpg"
if not os.path.isfile(new_filename):
img = person_detector.get_person(old_filename, sess)
if not img:
continue
img = img.convert('L')
img.save(new_filename, "jpeg")
После запуска все выбранные изображения обрабатываются и перемещаются в соответствующие подпапки в папке “classified”.
Шаг 6: Переобучение inceptionv3
Для переобучения мы будем просто использовать скрипт Tensorflow и модель inceptionv3. Вызываем скрипт в директории вашего проекта со следующими параметрами:
python retrain.py --bottleneck_dir=tf/training_data/bottlenec ks --model_dir=tf/training_data/inception -- summaries_dir=tf/training_data/summaries/ basic -- output_graph=tf/training_output/retrained _graph.pb --output_labels=tf/training_output/retraine d_labels.txt -- image_dir=./images/classified -- how_many_training_steps=50000 -- testing_percentage=20 -- learning_rate=0.001
В результате у нас будет переобученная модель inceptionV3 в файле «tf/training_output/retrained_graph.pb». Теперь мы должны написать класс Classifier, который открывает сессию. В ней передается граф и имеется метод «classify», который получает фото и возвращает словарь с оценками вероятности того, что фото должно быть помечено “positive” или “negative”.
Класс принимает на вход путь к графу и путь к файлу с метками — оба находятся в нашей папке «tf/training_output/». Мы создаем вспомогательные функции для преобразования файла с фото в тензор, который мы сможем подать в наш граф, вспомогательную функцию для загрузки графа и меток, а также важную маленькую функцию, которая закроет граф после того, как мы закончим работу с ним.
import numpy as np
import tensorflow as tf
class Classifier():
def __init__(self, graph, labels):
self._graph = self.load_graph(graph)
self._labels = self.load_labels(labels)
self._input_operation = self._graph.get_operation_by_name("import/Placeholder")
self._output_operation = self._graph.get_operation_by_name("import/final_result")
self._session = tf.Session(graph=self._graph)
def classify(self, file_name):
t = self.read_tensor_from_image_file(file_name)
# Open up a new tensorflow session and run it on the input
results = self._session.run(self._output_operation.outputs[0], {self._input_operation.outputs[0]: t})
results = np.squeeze(results)
# Sort the output predictions by prediction accuracy
top_k = results.argsort()[-5:][::-1]
result = {}
for i in top_k:
result[self._labels[i]] = results[i]
# Return sorted result tuples
return result
def close(self):
self._session.close()
@staticmethod
def load_graph(model_file):
graph = tf.Graph()
graph_def = tf.GraphDef()
with open(model_file, "rb") as f:
graph_def.ParseFromString(f.read())
with graph.as_default():
tf.import_graph_def(graph_def)
return graph
@staticmethod
def load_labels(label_file):
label = []
proto_as_ascii_lines = tf.gfile.GFile(label_file).readlines()
for l in proto_as_ascii_lines:
label.append(l.rstrip())
return label
@staticmethod
def read_tensor_from_image_file(file_name,
input_height=299,
input_width=299,
input_mean=0,
input_std=255):
input_name = "file_reader"
file_reader = tf.read_file(file_name, input_name)
image_reader = tf.image.decode_jpeg(
file_reader, channels=3, name="jpeg_reader")
float_caster = tf.cast(image_reader, tf.float32)
dims_expander = tf.expand_dims(float_caster, 0)
resized = tf.image.resize_bilinear(dims_expander, [input_height, input_width])
normalized = tf.divide(tf.subtract(resized, [input_mean]), [input_std])
sess = tf.Session()
result = sess.run(normalized)
return result
Шаг 7: Используем скрипты
Сейчас необходимо расширить класс Person с помощью функции «predict_likeliness», которая использует наш классификатор, чтобы проверить следует ли лайкать этого пользователя или нет.
# In the Person class
def predict_likeliness(self, classifier, sess):
ratings = []
for image in self.images:
req = requests.get(image, stream=True)
tmp_filename = f"./images/tmp/run.jpg"
if req.status_code == 200:
with open(tmp_filename, "wb") as f:
f.write(req.content)
img = person_detector.get_person(tmp_filename, sess)
if img:
img = img.convert('L')
img.save(tmp_filename, "jpeg")
certainty = classifier.classify(tmp_filename)
pos = certainty["positive"]
ratings.append(pos)
ratings.sort(reverse=True)
ratings = ratings[:5]
if len(ratings) == 0:
return 0.001
return ratings[0]*0.6 + sum(ratings[1:])/len(ratings[1:])*0.4
После проверки будем собирать все в одно целое. Сперва инициализируем Tinder API. Затем мы создадим сессию для нашего классифицирующего графа Tensorflow с использованием переобученной модели и меток. Затем мы получим данные пользователей поблизости и увидим вероятность того, что тот или иной пользователь может нам понравиться.
И несколько дополнений: коэффициент 1.2 для пользователей Tinder как максимальное совпадение;
0,8 - высокая вероятность понравиться (меньше этого значения профили свайпаются вслево). Получился скрипт, который будет работать 2 часа с начала запуска:
from likeliness_classifier import Classifier
import person_detector
import tensorflow as tf
from time import time
if __name__ == "__main__":
token = "YOUR-API-TOKEN"
api = tinderAPI(token)
detection_graph = person_detector.open_graph()
with detection_graph.as_default():
with tf.Session() as sess:
classifier = Classifier(graph="./tf/training_output/retrained_graph.pb",
labels="./tf/training_output/retrained_labels.txt")
end_time = time() + 60*60*2
while time() < end_time:
try:
persons = api.nearby_persons()
pos_schools = ["Universität Zürich", "University of Zurich", "UZH"]
for person in persons:
score = person.predict_likeliness(classifier, sess)
for school in pos_schools:
if school in person.schools:
print()
score *= 1.2
print("-------------------------")
print("ID: ", person.id)
print("Name: ", person.name)
print("Schools: ", person.schools)
print("Images: ", person.images)
print(score)
if score > 0.8:
res = person.like()
print("LIKE")
else:
res = person.dislike()
print("DISLIKE")
except Exception:
pass
classifier.close()Теперь у вас есть инструкция, чтобы повторить эксперимент. Кто попробует, ждем ваши отзывы о работе.
🔥 Ставь реакцию, если понравилась статья
⚡️Остались вопросы? Пиши - @golden_hpa