Аналіз дружніх зв'язків VK з допомогою Python. Продовження

У попередній статті ми на основі спільних друзів ВКонтакте будували граф, а сьогодні поговоримо про те, як отримати список друзів, друзів друзів і так далі. Передбачається, що ви вже прочитали попередню статті, і я не буду описувати все заново. Під хабракатом великі картинки і багато тексту.

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

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

Як будемо реалізовувати:
  • Задаємо необхідну нам глибину
  • Відправляємо вихідні дані або ті id, які треба досліджувати на даній глибині
  • Отримуємо відповідь
Що будемо використовувати:
  • Python 3.4
  • Збережені процедури в ВКонтакте

Задаємо необхідну нам глибину

Що нам потрібно в початку — це вказати глибину (deep), з якою ми хочемо працювати. Зробити це можна відразу у settings.py:

deep = 2 # така строчка там вже є

deep рівне 1 — це наші друзі, 2 — це друзі наших друзів і так далі. В результаті ми отримаємо словник, ключами якого будуть id користувачів, а значеннями — їх список друзів.

Не поспішайте виставляти великі значення глибини. При 14 моїх вихідних друзів і глибині дорівнює 2, кількість ключів в словнику склало 2427, а при глибині, що дорівнює 3, у мене не вистачило терпіння дочекатися завершення роботи скрипта, на той момент словник налічував 223 908 ключів. З цієї причини ми не будемо візуалізувати такий величезний граф, адже вершинами будуть ключі, а ребрами — значення.

Відправлення даних

Добитися потрібного нам результату допоможе вже відомий метод friends.get, який буде розташований в збереженій процедурі, яка має наступний вигляд:

var targets = Args.targets;
var all_friends = {};
var req;
var parametr = "";
var start = 0;
// з рядка з цілями виймаємо кожну мету
while(start<=targets.length){
if (targets.substr(start, 1) != "," && start != targets.length){
parametr = parametr + targets.substr(start, 1);
}
else {
// робимо запити, як тільки витягнули id
req = API.friends.get({"user_id":parametr});
if (req) {
all_friends = all_friends + [req];
}
else {
all_friends = all_friends + [0];
}
parametr = "";
}
start = start + 1;
}
return all_friends;

Нагадую, що збережену процедуру можна створити в настройках програми, вона пишеться VkScript, як і execute, документацію можна прочитати тут і тут.

Тепер про те, як вона працює. Ми приймаємо рядок з 25 id, розділених комами, виймаємо по одному id, робимо запит до friends.get, а потрібна нам інформація буде приходити в словнику, де ключі — це id, а значення — список друзів даного id.

При першому запуску ми відправимо збереженій процедурі список друзів поточного користувача, id якого вказаний в налаштуваннях. Список буде розбитий на кілька частин (N/25 — це число запитів), це пов'язано з обмеженням кількості звернень до API ВКонтакте.

Отримання відповіді

Всю отриману інформацію ми зберігаємо в словнику, наприклад:

{1:(0, 2, 3, 4), 2: (0, 1, 3, 4), 3: (0, 1, 2)}

Ключі 1, 2 і 3 були отримані при глибині, що дорівнює 1. Припустимо, що це і були всі друзі зазначеного користувача (0).

Якщо глибина більше 1, то далі скористаємося різницею множин, перше з яких — значення словника, а друге — його ключі. Таким чином, ми отримаємо ті id (в даному випадку 0 і 4), яких немає в ключах, розіб'ємо їх знову на 25 частин і відправимо збереженої процедурою.

Тоді в нашому словнику з'являться 2 нових ключа:

{1:(0, 2, 3, 4), 2: (0, 1, 3, 4), 3: (0, 1, 2), 0: (1, 2, 3), 4:(1, 2, ....)}

Сам же метод deep_friends() виглядає наступним чином:

def deep_friends(self, deep):
result = {}

def fill_result(friends):
for i in VkFriends.parts(friends):
r = requests.get(self.request_url('execute.deepFriends', 'targets=%s' % VkFriends.make_targets(i))).json()['response']
for x, id in enumerate(i):
result[id] = tuple(r[x]["items"]) if r[x] else None

for i in range(deep):
if result:
# ті айді, яких немає в ключах + не беремо id:None
fill_result(list(set([item for sublist in result.values() if sublist for item in sublist]) - set(result.keys())))
else:
fill_result(requests.get(self.request_url('friends.get', 'user_id=%s' % self.my_id)).json()['response']["items"])

return result

Звичайно, це швидше, ніж кидати по одному id friends.get без використання збереженої процедури, але часу все одно займає порядно багато.

Якщо б friends.get був схожий на users.get, а саме міг приймати в якості параметра user_ids, тобто перераховані через кому id, для яких потрібно повернути список друзів, а не по одному id, код було б набагато простіше, та і кількість запитів було в рази менше.

Занадто повільно

Повертаючись до початку статті, можу знову повторити — дуже повільно. Збережені процедури не рятують, рішення alxpy з багатопоточністю (спасибі йому за внесок та участь хоч комусь було цікаво, крім мене) прискорювало на декілька секунд роботу програми, але хотілося більшого.

Мудрий рада отримала від igrishaev — потрібен якийсь мап-редьюс.

Справа в тому, що ВКонтакте дозволяє 25 запитів до API через execute, з цього випливає, що якщо робити запити з різних клієнтів, то ми можемо збільшити кількість допустимих запитів. 5 тачок — це вже 125 запитів в секунду. Але й це далеко не так. Забігаючи вперед, скажу, що можна і ще швидше, приблизно буде виглядати наступним чином (на кожній машині):

while True:
r = requests.get(request_url('execute.getMutual','source=%s&targets=%s' % (my_id, make_targets(lst)),access_token=True)).json()
if 'response' in r:
r = r["response"]
break
else:
time.sleep(delay)

Якщо нам приходить повідомлення про помилку, то ми робимо запит знову, через задану кількість секунд. Такий прийом працює якийсь час, але потім ВКонтакте починає надсилати у всіх відповідях None, тому після кожного запиту чесно будемо чекати 1 секунду. Поки що.

Далі нам треба вибрати нові інструменти або ж написати свої, щоб реалізувати задумане. В результаті у нас повинна вийти горизонтально масштабована система, принцип роботи бачиться мені наступним чином:

  • Головний сервер отримує від клієнта його id ВКонтакте і код операції: або витягнути всіх спільних друзів, або рекурсивно прогулятися по списку друзів із зазначеної глибиною.
  • Далі в обох випадках сервер робить запит до API ВКонтакте — нам потрібен список всіх друзів.
  • Оскільки все зроблено для того, щоб ми користувалися по-максимуму можливостями збережених процедур — тут треба буде розділити список друзів на частини, по 25 контактів у кожній. Насправді по 75. Про це трохи нижче.
  • У нас вийде багато частин, використовуючи брокер повідомлень будемо доставляти кожну частину визначеному одержувачу (producer-consumer).
  • Одержувачі будуть приймати контакти, відразу робити запити, повертати результат постачальнику. Так, ви правильно подумали про RPC.
А якщо ви бачили цю картинку, то розумієте, на який брокер повідомлень я натякаю.


Як тільки всі відповіді прийняті, об'єднуємо їх в один. Далі результат можна вивести на екран або зовсім зберегти, про це пізніше.

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

В якості брокера повідомлень будемо використовувати RabbitMQ, асинхронної розподіленої черги завдань — Celery.

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

RabbitMQЧастина 1
Частина 2
Частина 3
Частина 4
Частина 5
Частина 6

CeleryFirst steps with Celery

Не треба боятися зрозуміти, хоч і кажуть, що можна конкретно повернути свою голову, коли починаєш «думати не одним комп'ютером, а кількома», але це зовсім не так.

Якщо у вас Mac, як і у автора, то RabbitMQ шикарно ставиться через Homebrew:

brew update
brew install rabbitmq

Celery ж ставиться ще легше, використовуємо третю гілку Python:

pip3 install Celery

У мене Celery встановлений на Linux Mint, а RabbitMQ — на Mac. З віндою, як зазвичай, проблеми — важко знайти, легко втратити вона чомусь весь час не хотіла повертати response моєму Mac.

Далі створимо virtual host, користувача і дамо йому права:

rabbitmqctl add_vhost vk_friends
rabbitmqctl add_user user password
rabbitmqctl set_permissions-p vk_friends user ".*" ".*" ".*"

У конфігурації RabbitMQ треба вказати ip хоста, на якому він встановлений:

vim /usr/local/etc/rabbitmq/rabbitmq-env.conf
NODE_IP_ADDRESS=192.168.1.14 // конкретно мій варіант

Ось кілька можливих конфігурацій, яку вибрати — вирішувати вам.



Якщо у вас роутер або ще щось, корисно буде знати, що RabbitMQ використовує 5672 порт, ну і зробити переадресацію в налаштуваннях вашого пристрою. Швидше за все, якщо будете тестувати, то воркеров раскидаете по різних машин, а їм потрібно буде використовувати брокер, і, якщо не правильно налаштували мережа, Celery RabbitMQ так і не достукається.

Дуже гарною новиною є те, що ВКонтакте дозволяє робити по 3 запиту в секунду з одного id. Помножимо ці запити на кількість можливих звернень до API ВКонтакте (25), отримаємо максимальне число оброблюваних контактів в секунду (75).


Якщо у нас буде багато воркеров, то настане момент, коли почнемо переходити за дозволений ліміт. Тому мінливаtoken (settings.py) тепер буде кортежем, що містить у собі кілька токенів з різних id. Скрипт буде при кожному запиті до ВКонтакте API вибирати один з них рандомным чином:

def request_url(method_name, parameters, access_token=False):
"""read https://vk.com/dev/api_requests"""

req_url = 'https://api.vk.com/method/{method_name}?{parameters}&v={api_v}'.format(
method_name=method_name, api_v=api_v, parameters=parameters)

if access_token:
req_url = '{}&access_token={token}'.format(req_url, token=random.choice(token))

return req_url 

У цьому плані у вас не повинно виникнути труднощів, якщо є кілька акаунтів у ВКонтакте (можна напружити друзів, рідних), у мене не було проблем з 4 токенами і 3 воркерами.

Ні, вам ніхто не заважає використовувати time.sleep(), або приклад вище з while, але тоді готуйтеся отримувати повідомлення про помилки (можливі взагалі порожні відповіді — id:None), або довше чекати.

Найцікавіше з файлу call.py:

def getMutual():
all_friends = friends(my_id)
c_friends = group(mutual_friends.s(i) for i in parts(list(all_friends[0].keys()), 75))().get()
result = {k: v for d in c_friends for k, v in d.items()}
return cleaner(result)

def getDeep():
result = {}
for i in range(deep):
if result:
# ті айді, яких немає в ключах + не беремо id:None
lst = list(set([item for sublist in result.values() if sublist for item in sublist]) - set(result.keys()))
d_friends = group(deep_friends.s(i) for i in parts(list(lst), 75))().get()
result = {k: v for d in d_friends for k, v in d.items()}
result.update(result)
else:
all_friends = friends(my_id)
d_friends = group(deep_friends.s(i) for i in parts(list(all_friends[0].keys()), 75) )().get()
result = {k: v for d in d_friends for k, v in d.items()}
result.update(result)

return cleaner(result)

Як бачите, в 2 функціях ми використовуємо groups(), який паралельно запускає кілька завдань, після чого ми «склеюємо» відповідь. Пам'ятаєте, як deep_friends() виглядав спочатку (там вже дуже старий приклад — навіть без багатопоточності)? Зміст залишився тим же — ми використовуємо різниця множин.

І, нарешті, tasks.py. Коли-небудь ці чудові функції об'єднаються в одну:

@app.task
def mutual_friends(lst):
"""
read https://vk.com/dev/friends.getMutual and read https://vk.com/dev/execute
"""
result = {}
for i in list(parts(lst, 25)):
r = requests.get(request_url('execute.getMutual', 'source=%s&targets=%s' % (my_id, make_targets(i)), access_token=True)).json()['response'] 
for x, vk_id in enumerate(i):
result[vk_id] = tuple(i for i in r[x]) if r[x] else None
return result

@app.task
def deep_friends(friends):
result = {}
for i in list(parts(friends, 25)):
r = requests.get(request_url('execute.deepFriends', 'targets=%s' % make_targets(i), access_token=True)).json()["response"]

for x, vk_id in enumerate(i):
result[vk_id] = tuple(r[x]["items"]) if r[x] else None

return result

Коли все налаштовано, запускаємо RabbitMQ командою:

rabbitmq-server

Потім переходимо в папку проекту і активуємо воркера:

celery-A tasks worker --loglevel=info

Тепер, щоб отримати і зберегти список загальних або «глибинних» друзів, досить скомандувати в консолі:

python3 call.py

Про результати вимірювань

Нагадаю, що у автора статті, яка надихнула мене на першу частину, 343 одного (запит на спільних друзів) «оброблялися» за 119 секунд.

Мій варіант з попередній статті робив те ж саме за 9 секунд.

Зараз у того автора інше число друзів — 308. Що ж, доведеться зробити один зайвий запит для останніх восьми id витратити на нього дорогоцінну секунду, хоча за ту ж секунду можна обробити 75 id.

З одним воркером робота скрипта зайняла 4.2 секунди, з двома воркерами — 2.2 секунди.

Якщо 119 округлимо до 120, а 2.2 до 2, то мій варіант працює в 60 разів швидше.

Що стосується «глибинних друзів» (друзі моїх друзів і так далі + тестуємо на іншому id, щоб чекати менше) — при глибині, рівній 2, кількість ключів в словнику склало 1 251.

Час виконання коду, наведеного в самому початку статті, — 17.9 секунди.

Одним воркером час виконання скрипта — 15.1 секунди, з двома воркерами — 8.2 секунди.

Таким чином, deep_friends() став швидше приблизно в 2.18 рази.

Так, не завжди результат такий райдужний, іноді відповіді на запит у ВКонтакте доводиться чекати і по 10, і по 20 секунд (хоча часте час виконання одного завдання 1.2 — 1.6 секунд), швидше за все, це пов'язано з навантаженням на сервіс, адже ми не одні у всесвіті.


В результаті, чим більше воркеров зробити, тим швидше буде оброблятися результат. Не забудьте про потужність свого заліза, додаткові токени мережу (наприклад, у автора різко зростала час виконання скрипта, коли він юзал свій айфон в якості точки доступу) і інші фактори.

Збереження результату

Так, є багато графо-орієнтованих БД. Якщо надалі (а воно так і буде), ми захочемо аналізувати отримані результати, то все одно їх треба буде десь зберігати до самого аналізу, потім ці ж результати вивантажувати в пам'ять і проводити з ними які-небудь дії. Не бачу сенсу використовувати яку-небудь субд, якщо проект був би комерційним і нас цікавило, наприклад, що відбувається з графом конкретного користувача протягом часу — то так, тут обов'язкова графо-орієнтована бд, але, оскільки займатися аналізом будемо в «домашніх умовах», pickle нам цілком вистачить.

Перед збереженням словників логічно буде видаляти з них ключі, значення яких None. Це заблоковані або видалені акаунти. Простіше кажучи, ці id будуть присутні в графі, тому що вони є у кого-то в друзів, ну а ми зекономимо на кількості ключів у словнику:

def cleaner(dct):
return {k:v for k, v in dct.items() if v != None}

def save_or_load(myfile, sv, smth=None):
if sv and smth:
pickle.dump(smth, open(myfile, "wb"))
else:
return pickle.load(open(myfile, "rb"))

Як видно, якщо ми десь зберегли результат, то де-то і повинні його завантажити, щоб заново не збирати id.

Аналіз графа

Щоб не писати свій велосипед, скористаємося досить відомим networkx, який зробить за нас всю брудну роботу. Більше дізнатися про networkx ви можете з цієї статті.

pip3 install networkx

Перш ніж почнемо аналізувати граф, намалюємо його. networkx для цього знадобиться matplotlib:

pip3 install matplotlib

Далі нам треба створити сам граф. Взагалі, є 2 шляхи.

Перший зжере багато оперативної пам'яті і вашого часу:

def adder(self, node):
if node not in self.graph.nodes():
self.graph.add_node(node)

self.graph = nx.Graph()
for k, v in self.dct.items():
self.adder(k)
for i in v:
self.adder(i)
self.graph.add_edge(k, i)

І це не автор вижив з розуму, немає. Подібний приклад наводиться на сторінки університету Райса (Rice University), під заголовком Convert Dictionary Graph Representation into networkx Graph Representation:

def dict2nx(aDictGraph):
""" Converts the given dictionary representation of a graph, 
aDictGraph, into a networkx DiGraph (directed graph) representation. 
aDictGraph is a dictionary that maps nodes to its 
сусідів (successors): {node:[nodes]}
A DiGraph object is returned.
"""
g = nx.DiGraph()
for node, сусідів in aDictGraph.items():
g.add_node(node) # in case there are nodes with no edges
for neighbor in сусідів:
g.add_edge(node, neighbor)
return g 

Можете повірити мені на слово, граф будувався цілий вечір, коли в ньому було вже понад 300 000 вершин, моє терпіння лопнуло. Якщо ви пройшли курс на Сoursera по Python від цього університету, то розумієте, про що я. А я всім на курсі говорив, що не так людей вчать, ну да ладно.

Так, наводиться приклад для орієнтованого графа, але суть залишається тією ж — спочатку додаємо ключі, роблячи їх вершинами, потім робимо вершинами значення, і то якщо їх ще немає в графі, а потім з'єднуємо їх ребрами (в моєму варіанті).

А другий спосіб зробить все за секунди:

self.graph = nx.from_dict_of_lists(self.dct)

Код лежить у файлі graph.py, щоб намалювати граф спільних друзів досить запустити цей скрипт або ж де-небудь створити екземпляр класу VkGraph(), а потім викликати його метод draw_graph().

Це граф спільних друзів, всього 306 вершин і 2096 ребер. На жаль, я ні разу не дизайнер (практично всі налаштування стандартні), але ви завжди зможете стилізувати граф «під себе». Ось пара посилань:

А сам метод виглядає так:

def draw_graph(self):
plt.figure(figsize=(19,19), dpi=450,)
nx.draw(self.graph, node_size=100, cmap=True)
plt.savefig("%s graph.png" % datetime.now().strftime('%H:%M:%S %d-%m-%Y'))

У підсумку ви отримуєте картинку date graph.png в папці проекту. Не раджу малювати граф для глибинних друзів. При 308 друзях і глибиною дорівнює 2, в словнику виявилося більше 145 000 ключів. А є ж ще і значення кортежі з id, яких навряд чи буде мало.

Довго шукав самий малосодержащий друзів профіль ВКонтакте, хоча тут важливіше — друзі-друзів. 10 початкових друзів (1 з них заблокований і видалиться автоматично), при нашої стандартної глибині (2), у словнику налічувалося 1234 ключа і 517 174 id (значення). Приблизно 419 друзів у одного id. Так, там є і спільні друзі, коли побудуємо граф, ми це зрозуміємо:

deep_friends = VkGraph(d_friends_dct)
print('Кількість вершин:', deep_friends.graph.number_of_nodes())
print('Кількість ребер:', deep_friends.graph.number_of_edges())

Поверне:

Кількість вершин: 370341
Кількість ребер: 512949

З такими великими даними було б непогано погратися. У networkx дуже великий список алгоритмів, які можна застосовувати до графів. Розберемо деякі з них.

Зв'язний граф
Для початку визначимо, зв'язний у нас граф:

print('Зв'язний граф?', nx.is_connected(deep_friends.graph))

Зв'язний граф — це такий граф, у якому кожна пара вершин з'єднана маршрутом.

До речі, в того ж самого id при глибині, рівній 1, ось такий гарний граф, що містить 1268 вершин і 1329 ребер:

Питання знавцям. Раз у нас виходить граф друзі-друзів, він по-любому має бути зв'язковим. Не може бути такого, щоб з нізвідки з'являлася вершина, яка не пов'язана ні з однією з існуючих. Однак ми бачимо праворуч одну вершину, яка не з'єднана з жодною. Чому? Закликаю спочатку подумати, цікавіше буде дізнатися істину.

Правильну відповідьДавайте розберемося. Спочатку береться список друзів, і виходить так, що є у нас id X. Із-за якого граф незв'язних. Далі ми робимо запит на друзів цього X (в глибину). Так от якщо у X всі друзі приховані, то буде запис у словнику:

{X:(), ...}

Коли буде будується граф, ми чесно додамо вершину X, але ніяких ребер у неї не буде. Так, чисто теоретично біля вершини X має бути ребро з вершиною, id якої зазначений у settings.py, але ви ж уважно читали — ми додаємо лише те, що знаходиться в словнику. Отже, при глибині, рівній 2, ми отримаємо в словнику id, вказаний в налаштуваннях. І тоді у вершини X з'являться ребра.

Будемо вважати, що у вас не буде такої ситуації, тобто швидше за все ви побачите True.

Діаметр, центр і радіус
Згадаймо, що діаметром графа називається максимальна відстань між двома його вершинами.

print('Діамерт графа:', nx.diameter(deep_friends.graph))

Центр графа — це будь-яка вершина, така, що відстань від неї до найбільш віддаленої вершини мінімально. Центром графа може бути одна вершина або кілька вершин. Або простіше. Центр графа — вершина, ексцентриситет (відстань від цієї вершини до самої віддаленої від неї) якої дорівнює радіусу.

print('Центр графа:', nx.center(deep_friends.graph))

Поверне список вершин, які є центром графа. Не дивуйтеся, якщо центром виявиться id, зазначений у settings.py.

Радіус графа — це найменший з усіх ексцентриситетів вершин.

print('Радіус графа:', nx.radius(deep_friends.graph))

Авторитетність або Page Rank
Цей той самий Page Rank, про яке ви подумали. Як правило, алгоритм використовується для веб-сторінок або інших документів, зв'язаних посиланнями. Чим більше посилань на сторінку, тим вона важливіше. Але ніхто не забороняє використовувати цей алгоритм для графів. Більш точне і придатне визначення запозичимо з цією статті:
Авторитетність в соціальному графі можна аналізувати різними способами. Найпростіший — відсортувати учасників кількості вхідних ребер. У кого більше — той більше авторитетний. Такий спосіб придатний для невеликих графів. В пошуку по Інтернету Google в якості одного з критеріїв для авторитетності сторінок використовує PageRank. Він обчислюється за допомогою випадкового блукання по графу, де в якості вузлів — сторінки, а ребро між вузлами — якщо одна сторінка посилається на іншу. Випадковий блуждатель рухається по графу і час від часу переміщається на випадковий вузол і починає блукання заново. PageRank дорівнює частки перебування на якомусь сайті за весь час блукання. Чим він більше, тим вузол більш авторитетний.
import operator
print('Page Rank:', sorted(nx.pagerank(deep_friends.graph).items(), key=operator.itemgetter(1), reverse=True))

Коефіцієнт кластеризації
Цитуючи Вікіпедію:
Коефіцієнт кластеризація — це ступінь імовірності того, що два різних користувача, пов'язані з конкретним індивідуумом, теж пов'язані.
print('Коефіцієнт кластеризації', nx.average_clustering(deep_friends.graph))

Як бачите, немає нічого складного, і networkx пропонує ще багато алгоритмів (автор нарахував 148), які можна застосовувати до графів. Вам залишається лише вибрати потрібний вам алгоритм і викликати відповідний метод у файлі graph.py.

Підсумок

Ми зробили розподілену систему, що дозволяє збирати і будувати граф спільних друзів у ВКонтакте, яка працює в 60 разів швидше (в залежності від кількості воркеров), ніж запропонований варіант Himura, також реалізували нову функцію — запит усіх «глибинних друзів», додали можливість аналізу побудованих графів.

Якщо ви не хочете ставити додаткове ПЗ і вам треба побудувати красивий граф спільних друзів з попередньої статті, старий реліз до ваших послуг. У ньому ви також знайдете стару версію отримання списку друзів користувача рекурсивно як завгодно глибоко.

Це зовсім не межа, триватимуть роботи по збільшенню швидкодії програми.

Як і раніше чекаю від вас смачних пулл-реквестов, особливо якщо ви займаєтеся визуализированием даних. Спасибі за увагу!

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.