nova-data-mcp 0.5.1__py3-none-any.whl

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.
core/__init__.py ADDED
File without changes
@@ -0,0 +1 @@
1
+ """Nova Strategic Analyzers."""
@@ -0,0 +1,170 @@
1
+ """
2
+ asset_analyzer.py - анализ наших ресурсов.
3
+
4
+ Оценивает: железо (CPU, RAM, disk), код (traces, skills, MCP), данные.
5
+ Возвращает dict со score 0-10 по каждому направлению.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sqlite3
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import psutil
16
+
17
+
18
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
19
+ DB_PATH = PROJECT_ROOT / "data" / "nova.db"
20
+
21
+
22
+ def analyze_hardware() -> dict:
23
+ """Оценка железа."""
24
+ cpu_count = psutil.cpu_count(logical=True)
25
+ cpu_freq = psutil.cpu_freq()
26
+ ram_gb = psutil.virtual_memory().total / (1024 ** 3)
27
+ disk = psutil.disk_usage(str(PROJECT_ROOT))
28
+ disk_free_gb = disk.free / (1024 ** 3)
29
+
30
+ # Score: 0-10
31
+ # CPU: 8+ cores = 5 баллов, 16+ = 8, 24+ = 10
32
+ cpu_score = min(10, max(1, cpu_count // 3))
33
+
34
+ # RAM: 8GB = 3, 16GB = 6, 32GB = 8, 64GB = 10
35
+ ram_score = min(10, max(1, int(ram_gb / 6)))
36
+
37
+ # Disk free: 100GB = 3, 500GB = 7, 1TB = 10
38
+ disk_score = min(10, max(1, int(disk_free_gb / 100)))
39
+
40
+ total = (cpu_score * 0.4 + ram_score * 0.4 + disk_score * 0.2)
41
+
42
+ return {
43
+ "cpu_count": cpu_count,
44
+ "cpu_freq_mhz": int(cpu_freq.max) if cpu_freq else 0,
45
+ "ram_gb": round(ram_gb, 1),
46
+ "disk_free_gb": round(disk_free_gb, 1),
47
+ "scores": {
48
+ "cpu": cpu_score,
49
+ "ram": ram_score,
50
+ "disk": disk_score,
51
+ },
52
+ "total_score": round(total, 1),
53
+ }
54
+
55
+
56
+ def analyze_code() -> dict:
57
+ """Оценка кода проекта."""
58
+ if not DB_PATH.exists():
59
+ return {"error": "nova.db not found", "total_score": 0}
60
+
61
+ conn = sqlite3.connect(str(DB_PATH))
62
+ try:
63
+ traces = conn.execute("SELECT COUNT(*) FROM traces").fetchone()[0]
64
+ skills = conn.execute("SELECT COUNT(*) FROM skills").fetchone()[0]
65
+ orders = conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0]
66
+ except sqlite3.OperationalError:
67
+ traces = skills = orders = 0
68
+ finally:
69
+ conn.close()
70
+
71
+ # MCP-сервер
72
+ mcp_server = (PROJECT_ROOT / "mcp_server" / "server.py").exists()
73
+
74
+ # Оценка
75
+ # Traces: 50 = 3, 150 = 6, 500 = 9
76
+ traces_score = min(10, max(1, traces // 20))
77
+
78
+ # Skills: 1 = 3, 3 = 6, 10 = 9
79
+ skills_score = min(10, max(1, skills * 2))
80
+
81
+ # MCP: есть = 8, нет = 0
82
+ mcp_score = 8 if mcp_server else 0
83
+
84
+ # Orders: 10 = 3, 50 = 6, 200 = 9
85
+ orders_score = min(10, max(1, orders // 5))
86
+
87
+ total = traces_score * 0.3 + skills_score * 0.3 + mcp_score * 0.2 + orders_score * 0.2
88
+
89
+ return {
90
+ "traces": traces,
91
+ "skills": skills,
92
+ "orders": orders,
93
+ "mcp_server": mcp_server,
94
+ "scores": {
95
+ "traces": traces_score,
96
+ "skills": skills_score,
97
+ "mcp": mcp_score,
98
+ "orders": orders_score,
99
+ },
100
+ "total_score": round(total, 1),
101
+ }
102
+
103
+
104
+ def analyze_data() -> dict:
105
+ """Оценка данных (fl.ru)."""
106
+ if not DB_PATH.exists():
107
+ return {"error": "nova.db not found", "total_score": 0}
108
+
109
+ conn = sqlite3.connect(str(DB_PATH))
110
+ try:
111
+ orders = conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0]
112
+ budgets = conn.execute(
113
+ "SELECT COUNT(*), AVG(budget) FROM orders WHERE budget > 0"
114
+ ).fetchone()
115
+ budgets_count = budgets[0] or 0
116
+ avg_budget = budgets[1] or 0
117
+ except sqlite3.OperationalError:
118
+ orders = budgets_count = 0
119
+ avg_budget = 0
120
+ finally:
121
+ conn.close()
122
+
123
+ # Уникальность: fl.ru данные больше никто не продаёт
124
+ uniqueness_score = 7
125
+
126
+ # Объём: 10 заказов = 3, 50 = 6, 200 = 9
127
+ volume_score = min(10, max(1, orders // 5))
128
+
129
+ # Бюджеты: % с бюджетом
130
+ budget_pct = (budgets_count / orders * 100) if orders else 0
131
+ budget_score = min(10, max(1, int(budget_pct / 10)))
132
+
133
+ total = uniqueness_score * 0.5 + volume_score * 0.3 + budget_score * 0.2
134
+
135
+ return {
136
+ "orders": orders,
137
+ "with_budget": budgets_count,
138
+ "avg_budget_rub": round(avg_budget, 0),
139
+ "scores": {
140
+ "uniqueness": uniqueness_score,
141
+ "volume": volume_score,
142
+ "budget_quality": budget_score,
143
+ },
144
+ "total_score": round(total, 1),
145
+ }
146
+
147
+
148
+ def analyze_all() -> dict:
149
+ """Полный анализ всех ресурсов."""
150
+ hw = analyze_hardware()
151
+ code = analyze_code()
152
+ data = analyze_data()
153
+
154
+ total = (
155
+ hw.get("total_score", 0) * 0.3
156
+ + code.get("total_score", 0) * 0.4
157
+ + data.get("total_score", 0) * 0.3
158
+ )
159
+
160
+ return {
161
+ "hardware": hw,
162
+ "code": code,
163
+ "data": data,
164
+ "overall_score": round(total, 1),
165
+ }
166
+
167
+
168
+ if __name__ == "__main__":
169
+ import json
170
+ print(json.dumps(analyze_all(), indent=2, ensure_ascii=False))
@@ -0,0 +1,211 @@
1
+ """
2
+ market_parser.py - парсер рынка.
3
+
4
+ Собирает данные о рынке из публичных источников:
5
+ - MCP Registry (количество серверов)
6
+ - fl.ru (данные нашего crawler)
7
+ - x402 stats (если публичный)
8
+ - Boosty (рынок цифровых товаров РФ)
9
+
10
+ Возвращает dict с оценками: размер рынка, конкуренция, тренд.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import sqlite3
17
+ import urllib.request
18
+ import urllib.error
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+
23
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
24
+ DB_PATH = PROJECT_ROOT / "data" / "nova.db"
25
+
26
+ USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
27
+
28
+
29
+ def _http_get_json(url: str, timeout: int = 15) -> dict | list | None:
30
+ """Простой GET с JSON-парсингом."""
31
+ try:
32
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json"})
33
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
34
+ if resp.status == 200:
35
+ return json.loads(resp.read().decode("utf-8", errors="replace"))
36
+ except (urllib.error.URLError, urllib.error.HTTPError, OSError, json.JSONDecodeError):
37
+ return None
38
+ return None
39
+
40
+
41
+ def parse_mcp_registry() -> dict:
42
+ """MCP Registry — количество серверов."""
43
+ # Популярные публичные MCP-каталоги
44
+ sources = [
45
+ "https://registry.modelcontextprotocol.io/servers",
46
+ "https://mcp.so/api/servers",
47
+ ]
48
+
49
+ total_servers = 0
50
+ found_source = None
51
+
52
+ for url in sources:
53
+ data = _http_get_json(url)
54
+ if data:
55
+ if isinstance(data, dict):
56
+ total_servers = data.get("total", data.get("count", 0)) or len(data.get("servers", []))
57
+ elif isinstance(data, list):
58
+ total_servers = len(data)
59
+ if total_servers > 0:
60
+ found_source = url
61
+ break
62
+
63
+ # Если API не доступны — используем известные данные (12,770+ серверов, <5% монетизированы)
64
+ if total_servers == 0:
65
+ total_servers = 12770 # известное число из исследований 2026
66
+ found_source = "research_data_2026"
67
+
68
+ # Оценка: чем больше серверов, тем выше конкуренция
69
+ # < 1000 = низкая, 5000 = средняя, 20000+ = высокая
70
+ if total_servers < 1000:
71
+ competition_score = 3
72
+ elif total_servers < 5000:
73
+ competition_score = 5
74
+ elif total_servers < 15000:
75
+ competition_score = 7
76
+ else:
77
+ competition_score = 9
78
+
79
+ # Монетизация: <5% зарабатывают = низкая
80
+ monetization_rate = 0.05
81
+
82
+ return {
83
+ "total_servers": total_servers,
84
+ "source": found_source,
85
+ "monetization_rate": monetization_rate,
86
+ "competition_score": competition_score,
87
+ "notes": f"Менее {int(monetization_rate*100)}% MCP-серверов зарабатывают хоть $1 (данные 2026)",
88
+ }
89
+
90
+
91
+ def parse_fl_ru() -> dict:
92
+ """Данные fl.ru из нашей БД."""
93
+ if not DB_PATH.exists():
94
+ return {"error": "nova.db not found", "score": 0}
95
+
96
+ conn = sqlite3.connect(str(DB_PATH))
97
+ try:
98
+ orders = conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0]
99
+ budgets = conn.execute(
100
+ "SELECT AVG(budget), MAX(budget) FROM orders WHERE budget > 0"
101
+ ).fetchone()
102
+ avg_budget = budgets[0] or 0
103
+ max_budget = budgets[1] or 0
104
+
105
+ # Последние 24 часа
106
+ from datetime import datetime, timedelta
107
+ cutoff = (datetime.now() - timedelta(days=1)).isoformat()
108
+ recent = conn.execute(
109
+ "SELECT COUNT(*) FROM orders WHERE found_at > ?", (cutoff,)
110
+ ).fetchone()[0]
111
+ except sqlite3.OperationalError:
112
+ orders = recent = 0
113
+ avg_budget = max_budget = 0
114
+ finally:
115
+ conn.close()
116
+
117
+ # Оценка активности рынка
118
+ # 10 заказов/день = 3, 50 = 6, 200 = 9
119
+ activity_score = min(10, max(1, recent // 5))
120
+
121
+ return {
122
+ "total_orders_in_db": orders,
123
+ "orders_last_24h": recent,
124
+ "avg_budget_rub": round(avg_budget, 0),
125
+ "max_budget_rub": round(max_budget, 0),
126
+ "activity_score": activity_score,
127
+ "notes": f"Медиана IT-заказа на FL.ru: 150,000 ₽ (по данным 2026)",
128
+ }
129
+
130
+
131
+ def parse_x402_stats() -> dict:
132
+ """x402 stats — объём транзакций."""
133
+ # Публичный endpoint x402.org для статистики
134
+ sources = [
135
+ "https://x402.org/api/stats",
136
+ "https://api.x402.org/stats",
137
+ ]
138
+
139
+ data = None
140
+ for url in sources:
141
+ data = _http_get_json(url)
142
+ if data:
143
+ break
144
+
145
+ # Если endpoint недоступен — используем известные данные
146
+ if not data:
147
+ # Данные из исследований 2026:
148
+ # - 169M транзакций, $41.6-52.7M объём
149
+ # - Реальный экономический объём: $5-11K/мес
150
+ # - Пик: декабрь 2025 (731K транзакций/день)
151
+ # - Падение: март 2026 (57K транзакций/день, -92%)
152
+ return {
153
+ "total_transactions": 169_000_000,
154
+ "total_volume_usd": 41_600_000,
155
+ "real_monthly_volume_usd": 8_000,
156
+ "daily_transactions_peak": 731_000,
157
+ "daily_transactions_now": 57_000,
158
+ "decline_pct": 92,
159
+ "source": "research_data_2026",
160
+ "trend_score": 3, # падение
161
+ "notes": "Реальный экономический объём $5-11K/мес. 92% падение с пика.",
162
+ }
163
+
164
+ return {
165
+ "data": data,
166
+ "source": "live_api",
167
+ "trend_score": 5,
168
+ }
169
+
170
+
171
+ def parse_boosty_market() -> dict:
172
+ """Рынок цифровых товаров РФ (Boosty/Gumroad)."""
173
+ # Boosty не даёт публичного API для аналитики.
174
+ # Используем известные данные из исследований 2026:
175
+ # - 35% онлайн-покупок — цифровые товары
176
+ # - Средний чек вырос на 40%
177
+ # - Количество чеков выросло в 2.2 раза
178
+ # - Топовые авторы Boosty: 5K подписчиков = 25-30 тыс ₽/мес
179
+
180
+ return {
181
+ "digital_share_of_purchases_pct": 35,
182
+ "avg_check_growth_pct": 40,
183
+ "check_count_growth_x": 2.2,
184
+ "top_boosty_monthly_rub": 30_000,
185
+ "market_trend": "growing",
186
+ "trend_score": 8,
187
+ "notes": "Цифровые товары в РФ растут. Boosty — рабочая площадка.",
188
+ }
189
+
190
+
191
+ def analyze_all() -> dict:
192
+ """Собирает данные со всех источников."""
193
+ mcp = parse_mcp_registry()
194
+ flru = parse_fl_ru()
195
+ x402 = parse_x402_stats()
196
+ boosty = parse_boosty_market()
197
+
198
+ return {
199
+ "mcp_registry": mcp,
200
+ "fl_ru": flru,
201
+ "x402": x402,
202
+ "boosty": boosty,
203
+ "market_summary": {
204
+ "best_opportunity": "boosty",
205
+ "reason": "Рынок цифровых товаров РФ растёт (+40% чек, 2.2x чеки). MCP перегрет (12770+ серверов, <5% монетизированы). x402 в падении (-92%).",
206
+ },
207
+ }
208
+
209
+
210
+ if __name__ == "__main__":
211
+ print(json.dumps(analyze_all(), indent=2, ensure_ascii=False))
@@ -0,0 +1,207 @@
1
+ """
2
+ path_comparator.py - сравнивает возможные пути развития.
3
+
4
+ Берёт данные из asset_analyzer (наши ресурсы) и market_parser (рынок).
5
+ Оценивает пути по 4 критериям:
6
+ - potential (потенциал дохода)
7
+ - complexity (сложность реализации)
8
+ - time_to_income (время до первого дохода)
9
+ - risk (вероятность провала)
10
+
11
+ Возвращает топ-1 + топ-2 с обоснованием.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ # Добавляем корень проекта в sys.path
21
+ _PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
22
+ if str(_PROJECT_ROOT) not in sys.path:
23
+ sys.path.insert(0, str(_PROJECT_ROOT))
24
+
25
+ from core.analyzers.asset_analyzer import analyze_all as analyze_assets
26
+ from core.analyzers.market_parser import analyze_all as analyze_market
27
+
28
+
29
+ # Веса критериев (сумма = 1.0)
30
+ WEIGHTS = {
31
+ "potential": 0.40,
32
+ "complexity": 0.20, # инвертируется: меньше сложность = больше балл
33
+ "time_to_income": 0.20, # инвертируется: меньше время = больше балл
34
+ "risk": 0.20, # инвертируется: меньше риск = больше балл
35
+ }
36
+
37
+
38
+ # Описание путей
39
+ PATHS = {
40
+ "boosty": {
41
+ "name": "Boosty (цифровые товары РФ)",
42
+ "description": "Ежемесячный отчёт по рынку фриланса РФ. Подписка 990 ₽/мес.",
43
+ "potential_score": 7, # 30-100 тыс ₽/мес при 30-100 подписчиках
44
+ "complexity_score": 4, # нужен контент, но не код
45
+ "time_to_income": 5, # 14 дней до первой подписки
46
+ "risk_score": 3, # низкий риск
47
+ "requirements": ["Контент (PDF/JSON)", "Boosty-страница", "Трафик (Habr/Telegram)"],
48
+ },
49
+ "consulting": {
50
+ "name": "MCP-консалтинг (B2B)",
51
+ "description": "Разработка кастомных MCP-серверов для бизнеса. $1.5-3K за проект.",
52
+ "potential_score": 9, # 100-300 тыс ₽/мес
53
+ "complexity_score": 8, # нужны клиенты, переговоры, разработка
54
+ "time_to_income": 8, # 30 дней до первого клиента
55
+ "risk_score": 6, # средний риск
56
+ "requirements": ["Портфолио", "Профиль на Upwork/fl.ru", "Навыки продаж"],
57
+ },
58
+ "fl_ru_orders": {
59
+ "name": "fl.ru заказы (активные деньги)",
60
+ "description": "Ручное выполнение заказов на fl.ru. Nova как ускоритель.",
61
+ "potential_score": 8, # 150 тыс ₽ за заказ (медиана IT)
62
+ "complexity_score": 6, # нужно выполнять руками
63
+ "time_to_income": 2, # 3-7 дней до первого заказа
64
+ "risk_score": 4, # низкий риск
65
+ "requirements": ["Активный профиль на fl.ru", "Навыки выполнения", "Время"],
66
+ },
67
+ "public_mcp": {
68
+ "name": "Публичный MCP + x402",
69
+ "description": "Публичный MCP-сервер с оплатой через x402. $0.001-0.01 за вызов.",
70
+ "potential_score": 3, # $50-500/мес при удаче
71
+ "complexity_score": 6, # нужен named tunnel, домен, продвижение
72
+ "time_to_income": 8, # 30+ дней до первых вызовов
73
+ "risk_score": 8, # высокий риск (x402 в падении)
74
+ "requirements": ["Публичный endpoint", "MCP Registry", "Трафик агентов"],
75
+ },
76
+ }
77
+
78
+
79
+ def _invert_score(score: int) -> int:
80
+ """Инвертирует score: 10 → 1, 1 → 10. Для complexity/time/risk."""
81
+ return 11 - score
82
+
83
+
84
+ def compute_path_score(path_key: str) -> dict:
85
+ """Считает общий score для пути."""
86
+ path = PATHS[path_key]
87
+
88
+ potential = path["potential_score"]
89
+ complexity = _invert_score(path["complexity_score"])
90
+ time_to_income = _invert_score(path["time_to_income"])
91
+ risk = _invert_score(path["risk_score"])
92
+
93
+ total = (
94
+ potential * WEIGHTS["potential"]
95
+ + complexity * WEIGHTS["complexity"]
96
+ + time_to_income * WEIGHTS["time_to_income"]
97
+ + risk * WEIGHTS["risk"]
98
+ )
99
+
100
+ return {
101
+ "key": path_key,
102
+ "name": path["name"],
103
+ "description": path["description"],
104
+ "scores": {
105
+ "potential": potential,
106
+ "complexity_inverted": complexity,
107
+ "time_inverted": time_to_income,
108
+ "risk_inverted": risk,
109
+ },
110
+ "raw": {
111
+ "complexity": path["complexity_score"],
112
+ "time_to_income_days": path["time_to_income"],
113
+ "risk": path["risk_score"],
114
+ },
115
+ "total_score": round(total, 2),
116
+ "requirements": path["requirements"],
117
+ }
118
+
119
+
120
+ def analyze_all() -> dict:
121
+ """Сравнивает все пути и даёт рекомендацию."""
122
+ assets = analyze_assets()
123
+ market = analyze_market()
124
+
125
+ results = []
126
+ for key in PATHS:
127
+ result = compute_path_score(key)
128
+ results.append(result)
129
+
130
+ # Сортировка по total_score (убывание)
131
+ results.sort(key=lambda x: x["total_score"], reverse=True)
132
+
133
+ # Корректировка с учётом наших активов и рынка
134
+ asset_score = assets.get("overall_score", 5)
135
+ data_unique = assets.get("data", {}).get("scores", {}).get("uniqueness", 5)
136
+ boosty_trend = market.get("boosty", {}).get("trend_score", 5)
137
+ x402_trend = market.get("x402", {}).get("trend_score", 5)
138
+
139
+ # Бонус к Boosty: если у нас уникальные данные и рынок растёт
140
+ if data_unique >= 6 and boosty_trend >= 7:
141
+ for r in results:
142
+ if r["key"] == "boosty":
143
+ r["total_score"] = round(r["total_score"] + 0.5, 2)
144
+
145
+ # Штраф к public_mcp: если x402 падает
146
+ if x402_trend <= 4:
147
+ for r in results:
148
+ if r["key"] == "public_mcp":
149
+ r["total_score"] = round(r["total_score"] - 0.5, 2)
150
+
151
+ # Пересортировка
152
+ results.sort(key=lambda x: x["total_score"], reverse=True)
153
+
154
+ primary = results[0]
155
+ secondary = results[1]
156
+
157
+ recommendation = {
158
+ "primary": primary["key"],
159
+ "primary_name": primary["name"],
160
+ "primary_score": primary["total_score"],
161
+ "primary_reason": _build_reason(primary, market, assets),
162
+ "secondary": secondary["key"],
163
+ "secondary_name": secondary["name"],
164
+ "secondary_score": secondary["total_score"],
165
+ "secondary_reason": _build_reason(secondary, market, assets),
166
+ }
167
+
168
+ return {
169
+ "our_assets_score": asset_score,
170
+ "market_summary": market.get("market_summary", {}),
171
+ "paths": results,
172
+ "recommendation": recommendation,
173
+ }
174
+
175
+
176
+ def _build_reason(path: dict, market: dict, assets: dict) -> str:
177
+ """Строит текстовое обоснование для пути."""
178
+ parts = []
179
+
180
+ if path["key"] == "boosty":
181
+ boosty = market.get("boosty", {})
182
+ parts.append(f"Рынок цифровых товаров РФ растёт: чек +{boosty.get('avg_check_growth_pct', 0)}%, чеки x{boosty.get('check_count_growth_x', 0)}.")
183
+ parts.append(f"Доля цифровых товаров в покупках: {boosty.get('digital_share_of_purchases_pct', 0)}%.")
184
+ parts.append("Наши данные fl.ru уникальны - нет аналогов.")
185
+
186
+ elif path["key"] == "consulting":
187
+ parts.append("MCP-навыки редки, спрос от бизнеса есть.")
188
+ parts.append("$1.5-3K за кастомный MCP-сервер.")
189
+ parts.append("Нужны клиенты и портфолио.")
190
+
191
+ elif path["key"] == "fl_ru_orders":
192
+ parts.append("Медиана IT-заказа на FL.ru: 150 тыс руб.")
193
+ parts.append("Nova ускоряет выполнение.")
194
+ parts.append("Реальные деньги сейчас, но не пассивно.")
195
+
196
+ elif path["key"] == "public_mcp":
197
+ x402 = market.get("x402", {})
198
+ parts.append(f"x402 в падении: -{x402.get('decline_pct', 0)}% с пика.")
199
+ parts.append(f"Реальный объём: ${x402.get('real_monthly_volume_usd', 0)}/мес.")
200
+ parts.append("Нужен публичный endpoint и трафик агентов.")
201
+
202
+ return " ".join(parts)
203
+
204
+
205
+ if __name__ == "__main__":
206
+ import json
207
+ print(json.dumps(analyze_all(), indent=2, ensure_ascii=False))