jsonseo 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
jsonseo-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JSON SEO
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
jsonseo-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,511 @@
1
+ Metadata-Version: 2.4
2
+ Name: jsonseo
3
+ Version: 1.0.0
4
+ Summary: Официальный Python SDK для JSON SEO API: выдача Яндекса, Google и Bing, Вордстат, прогноз Директа и геолокация по IP
5
+ License: MIT
6
+ Project-URL: Homepage, https://jsonseo.ru
7
+ Project-URL: Documentation, https://jsonseo.ru/docs
8
+ Project-URL: Repository, https://github.com/jsonseo/python-sdk
9
+ Project-URL: Issues, https://github.com/jsonseo/python-sdk/issues
10
+ Keywords: jsonseo,seo,serp,yandex,google,bing,wordstat,api
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Internet :: WWW/HTTP
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.8
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Dynamic: license-file
28
+
29
+ # JSON SEO Python SDK
30
+
31
+ Официальный Python-клиент [JSON SEO API](https://jsonseo.ru): выдача Яндекса, Google и Bing, картинки и видео, поисковые подсказки, Яндекс Вордстат, прогноз показов Директа и геолокация по IP.
32
+
33
+ - Работает на **Python 3.8 и выше**.
34
+ - Без зависимостей: только стандартная библиотека.
35
+ - Типы в комплекте (`py.typed`), редактор подсказывает поля ответов.
36
+ - Двадцать один метод сервиса.
37
+ - Три попытки на запрос по умолчанию: если сервис затупил, SDK сходит ещё раз сам.
38
+
39
+ ## Установка
40
+
41
+ ```bash
42
+ pip install jsonseo
43
+ ```
44
+
45
+ Ключ берётся в [личном кабинете](https://jsonseo.ru).
46
+
47
+ ## Быстрый старт
48
+
49
+ ```python
50
+ from jsonseo import Client
51
+
52
+ client = Client("ВАШ_КЛЮЧ")
53
+
54
+ serp = client.yandex("купить ноутбук", region=213)
55
+
56
+ for position, result in enumerate(serp["results"], start=1):
57
+ print(position, result["domain"], "—", result["title"])
58
+ ```
59
+
60
+ Первый аргумент — основной параметр метода, остальное передаётся по имени:
61
+
62
+ ```python
63
+ client.yandex("купить ноутбук", region=213, pages=2)
64
+ client.geoip("77.88.55.242")
65
+ client.wordstat_frequency("ремонт айфона", kind="exact")
66
+ ```
67
+
68
+ ---
69
+
70
+ # Примеры запросов
71
+
72
+ ## Позиции сайта в Яндексе
73
+
74
+ `break_domain` останавливает сбор на нужном домене — платить за страницы ниже найденной позиции незачем.
75
+
76
+ ```python
77
+ serp = client.yandex(
78
+ "ремонт айфона",
79
+ region=213, # Москва
80
+ pages=10, # до 100 позиций
81
+ break_domain="example.com",
82
+ )
83
+
84
+ for position, result in enumerate(serp["results"], start=1):
85
+ if result["domain"].endswith("example.com"):
86
+ print("Позиция:", position)
87
+ break
88
+
89
+ print("Собрано страниц:", serp["pages"])
90
+ print("Нашлось всего:", serp["found_human"])
91
+ ```
92
+
93
+ В ответе:
94
+
95
+ ```python
96
+ {
97
+ "pages": 3,
98
+ "exhausted": False,
99
+ "breakDomainHit": True, # остановились на нужном домене
100
+ "query": "ремонт айфона",
101
+ "rawQuery": "ремонт айфона",
102
+ "found": 28000000,
103
+ "found_human": "нашлось 28 млн результатов",
104
+ "lr": 213,
105
+ "url": "https://yandex.ru/search/?text=...",
106
+ "results": [
107
+ {
108
+ "url": "https://example.com/remont-iphone/",
109
+ "domain": "example.com",
110
+ "title": "Ремонт айфонов в Москве",
111
+ "passage": "Починим за 30 минут...",
112
+ "breadcrumbs": "example.com › услуги",
113
+ },
114
+ ],
115
+ }
116
+ ```
117
+
118
+ ## Выдача Google по нужному городу
119
+
120
+ Регион задаётся числовым ID из справочника — сервис сам соберёт `uule` и подставит `gl`.
121
+
122
+ ```python
123
+ regions = client.google_regions("Казань")
124
+
125
+ serp = client.google(
126
+ "заказать пиццу",
127
+ region=regions["regions"][0]["id"],
128
+ hl="ru",
129
+ device="desktop",
130
+ pages=2,
131
+ )
132
+ ```
133
+
134
+ Если Google схлопнул часть результатов как «очень похожие», причина придёт в `filter_description`, а вернуть их можно параметром `filter`:
135
+
136
+ ```python
137
+ serp = client.google("заказать пиццу", filter=0)
138
+ ```
139
+
140
+ ## Выдача Bing
141
+
142
+ ```python
143
+ serp = client.bing("buy a laptop", mkt="en-US", pages=2)
144
+
145
+ print(serp["mkt"], serp["lang"]) # фактический рынок и язык
146
+ ```
147
+
148
+ ## Реклама на странице выдачи
149
+
150
+ Приходит отдельным списком, органика не меняется. Стоит +0.01 ₽ за страницу, на которой реклама нашлась.
151
+
152
+ ```python
153
+ serp = client.yandex("пластиковые окна", region=213, ads=True)
154
+
155
+ for ad in serp.get("ads", []):
156
+ print(ad["block"], "#{}".format(ad["position"]), "—", ad["domain"])
157
+ print(" ", ad.get("title"))
158
+ ```
159
+
160
+ `block` — где стоял блок: `top` до органики, `bottom` после неё, `inline` между результатами. Пустой список `ads` значит «рекламу просили, но её не было», а отсутствие ключа — «не просили».
161
+
162
+ ## Ответ нейросети над выдачей
163
+
164
+ ```python
165
+ serp = client.yandex("чем отличается osb от фанеры", ai=True)
166
+
167
+ answer = serp.get("aiAnswer")
168
+
169
+ if answer:
170
+ print(answer["markdown"])
171
+
172
+ for source in answer.get("sources", []):
173
+ print("[{}]".format(source["id"]), source["domain"])
174
+ ```
175
+
176
+ Стоит +0.01 ₽ и только когда ответ есть: если поисковик его не показал, запрос обойдётся в обычную цену. Доступен только с первой страницы.
177
+
178
+ ## Картинки
179
+
180
+ ```python
181
+ images = client.yandex_images(
182
+ "скандинавский интерьер",
183
+ orientation="horizontal",
184
+ size="large",
185
+ format="jpg",
186
+ pages=2,
187
+ )
188
+
189
+ for image in images["results"]:
190
+ print("{}×{}".format(image.get("width"), image.get("height")), image["url"])
191
+ print(" источник:", image["sourceUrl"])
192
+ ```
193
+
194
+ Те же параметры работают у `google_images()` и `bing_images()` — SDK переводит общий фильтр в родной параметр движка. Если у поисковика такого значения нет, придёт `ValidationError` с указанием, чем заменить.
195
+
196
+ ## Видео
197
+
198
+ ```python
199
+ videos = client.google_video("как заменить ремень грм", duration="long", hl="ru")
200
+
201
+ for video in videos["results"]:
202
+ print(video["title"], "—", video.get("durationText"))
203
+ print(" ", video["url"], "({})".format(video.get("provider")))
204
+ ```
205
+
206
+ Поле `duration` приходит в секундах, но не всегда: у прямых эфиров вместо длины стоит `LIVE`. Отбор вида `duration < 600` молча выбросит такие ролики — ориентируйтесь на `durationText`, он на месте всегда.
207
+
208
+ ## Поисковые подсказки
209
+
210
+ ```python
211
+ suggest = client.yandex_suggest("купить кв", region=213)
212
+
213
+ print(suggest["results"])
214
+ # ['купить квартиру в москве', 'купить квартиру в новостройке', ...]
215
+ ```
216
+
217
+ Есть у всех трёх поисковиков: `yandex_suggest()`, `google_suggest()`, `bing_suggest()`.
218
+
219
+ ## Справочник регионов
220
+
221
+ ```python
222
+ regions = client.yandex_regions("Казань")
223
+
224
+ for region in regions["regions"]:
225
+ print(region["id"], "—", region["name"], "({})".format(region["subname"]))
226
+ # 43 — Казань (Республика Татарстан)
227
+ ```
228
+
229
+ Бесплатно, но ключ нужен: по нему считается лимит запросов в минуту. У `google_regions()` в ответе дополнительно приходит готовая строка `uule`.
230
+
231
+ ## Вордстат: частота запроса
232
+
233
+ ```python
234
+ frequency = client.wordstat_frequency(
235
+ "ремонт айфона",
236
+ kind="exact", # точная частотность: "!ремонт !айфона"
237
+ region=213,
238
+ )
239
+
240
+ print(frequency["results"]["totalValue"]) # 27356
241
+ ```
242
+
243
+ Вид частотности задаётся параметром `kind`, кавычки и операторы расставит сервис — фразу передавайте как есть:
244
+
245
+ | `kind` | Что считает |
246
+ | --- | --- |
247
+ | `base` | Базовая: фраза как есть |
248
+ | `phrase` | Фразовая: `"фраза"` |
249
+ | `exact` | Точная: `"!слово !слово"` — для прогноза трафика берут её |
250
+ | `superexact` | Сверхточная: `"[!слово !слово]"` |
251
+
252
+ ## Вордстат: расширение семантики
253
+
254
+ ```python
255
+ wordstat = client.wordstat("ремонт айфона", region=[213, 2])
256
+
257
+ for phrase in wordstat["results"]["popular"]:
258
+ print(phrase["value"], phrase["text"])
259
+
260
+ for phrase in wordstat["results"]["associations"]:
261
+ print(phrase["value"], phrase["text"])
262
+ ```
263
+
264
+ `popular` — что ищут вместе с фразой, `associations` — соседняя семантика.
265
+
266
+ ## Вордстат: сезонность
267
+
268
+ ```python
269
+ graph = client.wordstat_graph("купить ёлку", graph_type="month")
270
+
271
+ for point in graph["results"]["graph"]:
272
+ print(point["text"], point["absolute"])
273
+ # июнь 2026 9042
274
+ # июль 2026 11780
275
+ ```
276
+
277
+ `month` и `week` отдают историю с 2018 года, `day` — последние 60 дней.
278
+
279
+ ## Вордстат: география спроса
280
+
281
+ ```python
282
+ geo = client.wordstat_map("купить ноутбук", map_type="regions")
283
+
284
+ for row in geo["results"]["rows"]:
285
+ print(row["text"], row["absolute"], "индекс", row["popularity"])
286
+ ```
287
+
288
+ `popularity` — affinity-индекс: 100 означает средний по стране интерес, выше — повышенный. В каждой строке приходит `region_id`, его можно сразу подставить в `region` других методов.
289
+
290
+ ## Прогноз показов Яндекс Директа
291
+
292
+ Рекламный кабинет не нужен. Список фраз передаётся списком — SDK склеит его сам.
293
+
294
+ ```python
295
+ forecast = client.direct(
296
+ ["ремонт айфона", "замена экрана iphone", '"ремонт айфона"'],
297
+ region=213,
298
+ period="month",
299
+ )
300
+
301
+ for row in forecast["results"]:
302
+ print("{}: {} показов".format(row["phrase"], row["shows"]))
303
+
304
+ for place, bid in row["positions"].items():
305
+ print(" {}: ставка {} ₽, бюджет {} ₽, кликов {}".format(
306
+ place, bid["bid"], bid["budget"], bid["clicks"]
307
+ ))
308
+ ```
309
+
310
+ Вид частотности задаётся операторами прямо во фразе: `ремонт айфона` — базовая, `"ремонт айфона"` — фразовая, `"!ремонт !айфона"` — точная.
311
+
312
+ Стоимость — 0.01 ₽ за пачку до 4000 символов, это около 150 обычных фраз. За один запрос принимается до 1000 фраз, на аккаунт — не больше 100 запросов в час.
313
+
314
+ ## Геолокация по IP
315
+
316
+ ```python
317
+ location = client.geoip("77.88.55.242")
318
+
319
+ print(location["country"]["name"], location["region"]["name"])
320
+ print(location["latitude"], location["longitude"])
321
+ ```
322
+
323
+ ID региона тот же, что у Яндекса, — его можно сразу подставить в `region` методов выдачи и Вордстата:
324
+
325
+ ```python
326
+ serp = client.yandex("доставка пиццы", region=location["region"]["id"])
327
+ ```
328
+
329
+ ## Баланс
330
+
331
+ ```python
332
+ balance = client.balance()
333
+
334
+ print(balance["balance"], balance["currency"]) # 123.45 RUB
335
+ ```
336
+
337
+ ---
338
+
339
+ # Справочник методов
340
+
341
+ | Метод | Путь API | Что делает |
342
+ | --- | --- | --- |
343
+ | `yandex()` | `/yandex` | Органическая выдача Яндекса |
344
+ | `yandex_suggest()` | `/yandex/suggest` | Поисковые подсказки |
345
+ | `yandex_regions()` | `/yandex/regions` | Справочник регионов, бесплатно |
346
+ | `yandex_images()` | `/yandex/images` | Поиск по картинкам |
347
+ | `yandex_video()` | `/yandex/video` | Поиск по видео |
348
+ | `google()` | `/google` | Органическая выдача Google |
349
+ | `google_suggest()` | `/google/suggest` | Подсказки |
350
+ | `google_regions()` | `/google/regions` | Регионы и готовый `uule`, бесплатно |
351
+ | `google_images()` | `/google/images` | Поиск по картинкам |
352
+ | `google_video()` | `/google/video` | Поиск по видео |
353
+ | `bing()` | `/bing` | Органическая выдача Bing |
354
+ | `bing_suggest()` | `/bing/suggest` | Подсказки |
355
+ | `bing_images()` | `/bing/images` | Поиск по картинкам |
356
+ | `bing_video()` | `/bing/video` | Поиск по видео |
357
+ | `wordstat()` | `/wordstat` | Популярные и похожие запросы |
358
+ | `wordstat_frequency()` | `/wordstat/frequency` | Частота запроса одним числом |
359
+ | `wordstat_graph()` | `/wordstat/graph` | Динамика по месяцам, неделям, дням |
360
+ | `wordstat_map()` | `/wordstat/map` | География показов |
361
+ | `direct()` | `/direct` | Прогноз показов Яндекс Директа |
362
+ | `geoip()` | `/geoip` | Геолокация по IPv4, бесплатно |
363
+ | `balance()` | `/balance` | Остаток на счёте, бесплатно |
364
+
365
+ Полный список параметров каждого метода — в [документации](https://jsonseo.ru/docs).
366
+
367
+ Появился метод, которого ещё нет в SDK? Его можно вызвать напрямую:
368
+
369
+ ```python
370
+ client.call("новый/метод", {"параметр": "значение"}) # разберёт JSON
371
+ client.call_raw("новый/метод", {"параметр": "значение"}) # вернёт тело как есть
372
+ ```
373
+
374
+ # Как SDK помогает с параметрами
375
+
376
+ **Списки передаются списками.** Фразы для Директа склеиваются переводом строки, остальные списки — запятой:
377
+
378
+ ```python
379
+ client.direct(["ремонт айфона", "ремонт телефона", "замена экрана"])
380
+ client.wordstat("ремонт", region=[213, 2], device=["desktop", "phone"])
381
+ ```
382
+
383
+ **Флаги принимаются флагами.** `True` и `False` уезжают как `1` и `0`:
384
+
385
+ ```python
386
+ client.yandex("купить ноутбук", ai=True, ads=True)
387
+ ```
388
+
389
+ **`None` и пустой список не отправляются.** Необязательный параметр, который вы ещё не посчитали, можно не вычищать из вызова руками.
390
+
391
+ **Родные параметры поисковиков проходят насквозь.** Вертикали принимают не только общие фильтры, но и `tbs` у Google, `isize` у Яндекса, `qft` у Bing.
392
+
393
+ # Ошибки
394
+
395
+ Всё, что бросает SDK, наследуется от `JsonSeoError`.
396
+
397
+ | Ошибка | Статус | Когда |
398
+ | --- | --- | --- |
399
+ | `ValidationError` | 422 | Параметры не приняты. `errors` — сообщения по полям, `fields` — их имена |
400
+ | `UnauthorizedError` | 403, 401 | Ключ не передан или недействителен |
401
+ | `PaymentRequiredError` | 402 | На счёте не хватает средств |
402
+ | `RateLimitError` | 429 | Превышен лимит частоты |
403
+ | `ServiceUnavailableError` | 503 | Выдачу получить не вышло. Деньги не списаны |
404
+ | `ApiError` | прочие | Любой другой отказ сервиса |
405
+
406
+ У всех ошибок сервиса есть `status`, `body`, разобранный `payload` и `retry_after` — срок, который назвал сервис, если он его назвал.
407
+
408
+ | Ошибка | Когда |
409
+ | --- | --- |
410
+ | `NetworkError` | До сервиса не достучались: сеть, DNS, TLS |
411
+ | `TimeoutError` | Ответа не дождались |
412
+ | `IncompleteResponseError` | Соединение оборвалось посреди тела |
413
+ | `ParseError` | Ответ пришёл, но не разобрался как JSON. `body` — тело как есть |
414
+ | `InvalidArgumentError` | SDK забраковал аргументы, запрос не отправлялся |
415
+
416
+ ```python
417
+ from jsonseo import PaymentRequiredError, RateLimitError, ValidationError
418
+
419
+ try:
420
+ serp = client.yandex("купить ноутбук", pages=50)
421
+ except ValidationError as error:
422
+ for field, messages in error.errors.items():
423
+ print(field, ":", ", ".join(messages))
424
+ except PaymentRequiredError:
425
+ print("Баланс кончился:", client.balance()["balance"])
426
+ except RateLimitError as error:
427
+ print("Вернуться через", error.retry_after, "с")
428
+ ```
429
+
430
+ `TimeoutError` наследует и встроенный `TimeoutError`, так что привычный `except TimeoutError` его тоже поймает.
431
+
432
+ # Повторы
433
+
434
+ **У каждого запроса три попытки по умолчанию: одна основная и две повторных.** Если сервис затупил и выдачу собрать не вышло (`503`), SDK сам сходит ещё дважды, и обычно этого хватает. `attempts=1` отключает повторы совсем.
435
+
436
+ `429`, `5xx` и обрывы связи повторяются автоматически — это ровно те отказы, за которые сервис денег не берёт. Отказы по ключу, балансу и параметрам не повторяются: сами они не изменятся.
437
+
438
+ Таймаут и оборвавшееся посреди тела соединение не повторяются, и это намеренно: работу на стороне сервиса обрыв у клиента не отменяет — выдача будет собрана и оплачена, а повтор стоил бы ещё раз. Если ответ не успевает прийти, поднимайте `timeout`, а не `attempts`.
439
+
440
+ Пауза между попытками удваивается и разбавляется случайной добавкой. Если сервис прислал `Retry-After`, SDK не вернётся раньше названного срока. Когда сервис просит ждать дольше `max_retry_delay`, SDK не ждёт вовсе, а отдаёт ошибку с `retry_after` — решение остаётся за вами.
441
+
442
+ # Настройки клиента
443
+
444
+ ```python
445
+ client = Client(
446
+ "ВАШ_КЛЮЧ",
447
+ base_url="https://jsonseo.ru/api", # адрес API
448
+ timeout=300.0, # сколько ждать ответа на попытку, секунд
449
+ attempts=3, # всего попыток, вместе с первой
450
+ retry_delay=1.0, # стартовая пауза между попытками
451
+ max_retry_delay=30.0, # потолок паузы
452
+ auth="header", # или "query" — ключ в параметре key
453
+ user_agent="мой-проект/1.0",
454
+ transport=my_transport, # свой транспорт
455
+ )
456
+ ```
457
+
458
+ Настройки — только именованные аргументы, поэтому опечатку ловит сам Python: `Client("КЛЮЧ", timeuot=5)` не запустится.
459
+
460
+ Таймаут по умолчанию намеренно большой: многостраничный запрос выдачи собирается минутами, и обрыв на стороне клиента не отменяет запрос на стороне сервиса — деньги за него уже списаны. Считается он на **каждую попытку** отдельно, а не на весь вызов.
461
+
462
+ Ключ по умолчанию едет в заголовке `Authorization: Bearer`, а не в адресе: так он не оседает в логах прокси и серверов. `auth="query"` нужен там, где заголовки до API не доходят.
463
+
464
+ # Свой транспорт
465
+
466
+ Если HTTP в проекте уже ходит через `requests`, `httpx` или что-то своё, SDK можно отдать этот клиент — достаточно объекта с одним методом:
467
+
468
+ Транспорт обязан бросать ошибки SDK: от их класса зависит, повторит клиент запрос или нет. Чужие исключения пройдут мимо политики повторов и мимо пользовательского `except JsonSeoError`.
469
+
470
+ ```python
471
+ import requests
472
+
473
+ from jsonseo import IncompleteResponseError, NetworkError, TimeoutError
474
+ from jsonseo.transport import Response
475
+
476
+ class RequestsTransport:
477
+ def __init__(self, session):
478
+ self.session = session
479
+
480
+ def send(self, method, url, headers, body, timeout):
481
+ try:
482
+ response = self.session.request(
483
+ method, url, headers=headers, data=body, timeout=timeout
484
+ )
485
+ except requests.Timeout as error:
486
+ raise TimeoutError("Ответа не дождались.") from error
487
+ except requests.ConnectionError as error:
488
+ raise NetworkError("Запрос не удался: {}.".format(error)) from error
489
+
490
+ try:
491
+ text = response.text
492
+ except requests.RequestException as error:
493
+ # Заголовки уже пришли: выдача собрана и оплачена, повторять нельзя.
494
+ raise IncompleteResponseError("Ответ пришёл не целиком.") from error
495
+
496
+ return Response(response.status_code, dict(response.headers), text)
497
+ ```
498
+
499
+ Тот же приём годится для тестов: подмените транспорт заглушкой, и запросы никуда не пойдут.
500
+
501
+ # Разработка
502
+
503
+ ```bash
504
+ python -m unittest discover -s tests -t .
505
+ ```
506
+
507
+ Тесты идут без сети: транспорт подменяется заглушкой.
508
+
509
+ # Лицензия
510
+
511
+ MIT.