@xpr-agents/openclaw 0.3.1 → 0.4.0
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.
- package/README.md +51 -10
- package/openclaw.plugin.json +15 -1
- package/package.json +7 -4
- package/skills/code-sandbox/SKILL.md +30 -0
- package/skills/code-sandbox/dist/index.js +188 -0
- package/skills/code-sandbox/skill.json +13 -0
- package/skills/code-sandbox/src/index.ts +212 -0
- package/skills/creative/SKILL.md +32 -0
- package/skills/creative/dist/index.js +667 -0
- package/skills/creative/skill.json +13 -0
- package/skills/creative/src/index.ts +679 -0
- package/skills/defi/SKILL.md +123 -0
- package/skills/defi/dist/index.js +1745 -0
- package/skills/defi/skill.json +44 -0
- package/skills/defi/src/index.ts +1788 -0
- package/skills/defi/test-read.mjs +281 -0
- package/skills/governance/SKILL.md +69 -0
- package/skills/governance/dist/index.js +632 -0
- package/skills/governance/skill.json +21 -0
- package/skills/governance/src/index.ts +656 -0
- package/skills/governance/test-read.mjs +176 -0
- package/skills/lending/SKILL.md +63 -0
- package/skills/lending/dist/index.js +1039 -0
- package/skills/lending/skill.json +29 -0
- package/skills/lending/src/index.ts +1105 -0
- package/skills/lending/test-read.mjs +156 -0
- package/skills/nft/SKILL.md +95 -0
- package/skills/nft/dist/index.js +1520 -0
- package/skills/nft/skill.json +37 -0
- package/skills/nft/src/index.ts +1539 -0
- package/skills/shellbook/SKILL.md +59 -0
- package/skills/shellbook/dist/index.js +381 -0
- package/skills/shellbook/skill.json +29 -0
- package/skills/shellbook/src/index.ts +391 -0
- package/skills/shellbook/tsconfig.json +14 -0
- package/skills/smart-contracts/SKILL.md +128 -0
- package/skills/smart-contracts/dist/index.js +1225 -0
- package/skills/smart-contracts/skill.json +25 -0
- package/skills/smart-contracts/src/index.ts +1327 -0
- package/skills/smart-contracts/tsconfig.json +14 -0
- package/skills/structured-data/SKILL.md +36 -0
- package/skills/structured-data/dist/index.js +501 -0
- package/skills/structured-data/skill.json +13 -0
- package/skills/structured-data/src/index.ts +597 -0
- package/skills/tax/SKILL.md +109 -0
- package/skills/tax/dist/index.js +1749 -0
- package/skills/tax/skill.json +20 -0
- package/skills/tax/src/index.ts +1985 -0
- package/skills/web-scraping/SKILL.md +29 -0
- package/skills/web-scraping/dist/index.js +311 -0
- package/skills/web-scraping/skill.json +13 -0
- package/skills/web-scraping/src/index.ts +371 -0
- package/skills/xmd/SKILL.md +52 -0
- package/skills/xmd/dist/index.js +596 -0
- package/skills/xmd/skill.json +22 -0
- package/skills/xmd/src/index.ts +635 -0
- package/skills/xmd/test-read.mjs +178 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: web-scraping
|
|
3
|
+
description: Web scraping tools for fetching and extracting data from web pages
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Web Scraping
|
|
7
|
+
|
|
8
|
+
You have web scraping tools for fetching and extracting data from web pages:
|
|
9
|
+
|
|
10
|
+
**Single page:**
|
|
11
|
+
- `scrape_url` — fetch a URL and get cleaned text content + metadata (title, description, link count)
|
|
12
|
+
- Use format="text" (default) for most tasks — strips all HTML
|
|
13
|
+
- Use format="markdown" to preserve headings, links, lists, bold/italic
|
|
14
|
+
- Use format="html" only when you need raw HTML
|
|
15
|
+
|
|
16
|
+
**Link discovery:**
|
|
17
|
+
- `extract_links` — fetch a page and extract all links with text and type (internal/external)
|
|
18
|
+
- Use the `pattern` parameter to filter by regex (e.g. `"\\.pdf$"` for PDF links)
|
|
19
|
+
- Links are deduplicated and resolved to absolute URLs
|
|
20
|
+
|
|
21
|
+
**Multi-page research:**
|
|
22
|
+
- `scrape_multiple` — fetch up to 10 URLs in parallel for comparison/research
|
|
23
|
+
- One failure doesn't block others (uses Promise.allSettled)
|
|
24
|
+
|
|
25
|
+
**Best practices:**
|
|
26
|
+
- Prefer "text" format for content extraction, "markdown" for preserving structure
|
|
27
|
+
- Don't scrape the same domain more than 5 times per minute
|
|
28
|
+
- Combine with `store_deliverable` to save scraped content as job evidence
|
|
29
|
+
- For very large pages, the content is limited to 5MB
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Web Scraping Skill — fetch, parse, and extract data from web pages
|
|
4
|
+
*
|
|
5
|
+
* Zero external dependencies — uses Node.js built-in fetch and regex-based HTML parsing.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.default = webScrapingSkill;
|
|
9
|
+
// ── Constants ───────────────────────────────────
|
|
10
|
+
const MAX_BODY_SIZE = 5 * 1024 * 1024; // 5MB
|
|
11
|
+
const DEFAULT_TIMEOUT = 30000;
|
|
12
|
+
const USER_AGENT = 'XPR-Agent/1.0 (web-scraping-skill)';
|
|
13
|
+
// ── Shared helpers ──────────────────────────────
|
|
14
|
+
async function fetchPage(url, timeout = DEFAULT_TIMEOUT, headers) {
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
17
|
+
try {
|
|
18
|
+
const resp = await fetch(url, {
|
|
19
|
+
signal: controller.signal,
|
|
20
|
+
redirect: 'follow',
|
|
21
|
+
headers: {
|
|
22
|
+
'User-Agent': USER_AGENT,
|
|
23
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
24
|
+
...headers,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
const contentType = resp.headers.get('content-type') || '';
|
|
28
|
+
const contentLength = parseInt(resp.headers.get('content-length') || '0');
|
|
29
|
+
if (contentLength > MAX_BODY_SIZE) {
|
|
30
|
+
throw new Error(`Response too large: ${contentLength} bytes (max ${MAX_BODY_SIZE})`);
|
|
31
|
+
}
|
|
32
|
+
const html = await resp.text();
|
|
33
|
+
if (html.length > MAX_BODY_SIZE) {
|
|
34
|
+
throw new Error(`Response body too large: ${html.length} chars (max ${MAX_BODY_SIZE})`);
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
html,
|
|
38
|
+
status: resp.status,
|
|
39
|
+
contentType: contentType.split(';')[0].trim(),
|
|
40
|
+
finalUrl: resp.url || url,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function decodeEntities(text) {
|
|
48
|
+
return text
|
|
49
|
+
.replace(/&/g, '&')
|
|
50
|
+
.replace(/</g, '<')
|
|
51
|
+
.replace(/>/g, '>')
|
|
52
|
+
.replace(/"/g, '"')
|
|
53
|
+
.replace(/'/g, "'")
|
|
54
|
+
.replace(/'/g, "'")
|
|
55
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n)))
|
|
56
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)))
|
|
57
|
+
.replace(/ /g, ' ');
|
|
58
|
+
}
|
|
59
|
+
function stripHtml(html) {
|
|
60
|
+
// Remove script, style, and noscript blocks
|
|
61
|
+
let text = html.replace(/<script[\s\S]*?<\/script>/gi, '');
|
|
62
|
+
text = text.replace(/<style[\s\S]*?<\/style>/gi, '');
|
|
63
|
+
text = text.replace(/<noscript[\s\S]*?<\/noscript>/gi, '');
|
|
64
|
+
// Remove HTML comments
|
|
65
|
+
text = text.replace(/<!--[\s\S]*?-->/g, '');
|
|
66
|
+
// Remove tags
|
|
67
|
+
text = text.replace(/<[^>]+>/g, ' ');
|
|
68
|
+
// Decode entities
|
|
69
|
+
text = decodeEntities(text);
|
|
70
|
+
// Normalize whitespace
|
|
71
|
+
text = text.replace(/[ \t]+/g, ' ');
|
|
72
|
+
text = text.replace(/\n\s*\n\s*\n/g, '\n\n');
|
|
73
|
+
return text.trim();
|
|
74
|
+
}
|
|
75
|
+
function htmlToMarkdown(html) {
|
|
76
|
+
// Remove script/style/noscript
|
|
77
|
+
let md = html.replace(/<script[\s\S]*?<\/script>/gi, '');
|
|
78
|
+
md = md.replace(/<style[\s\S]*?<\/style>/gi, '');
|
|
79
|
+
md = md.replace(/<noscript[\s\S]*?<\/noscript>/gi, '');
|
|
80
|
+
md = md.replace(/<!--[\s\S]*?-->/g, '');
|
|
81
|
+
// Headings
|
|
82
|
+
md = md.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, (_, c) => `# ${stripHtml(c)}\n\n`);
|
|
83
|
+
md = md.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, (_, c) => `## ${stripHtml(c)}\n\n`);
|
|
84
|
+
md = md.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, (_, c) => `### ${stripHtml(c)}\n\n`);
|
|
85
|
+
md = md.replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, (_, c) => `#### ${stripHtml(c)}\n\n`);
|
|
86
|
+
md = md.replace(/<h5[^>]*>([\s\S]*?)<\/h5>/gi, (_, c) => `##### ${stripHtml(c)}\n\n`);
|
|
87
|
+
md = md.replace(/<h6[^>]*>([\s\S]*?)<\/h6>/gi, (_, c) => `###### ${stripHtml(c)}\n\n`);
|
|
88
|
+
// Bold / italic
|
|
89
|
+
md = md.replace(/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi, '**$2**');
|
|
90
|
+
md = md.replace(/<(em|i)[^>]*>([\s\S]*?)<\/\1>/gi, '*$2*');
|
|
91
|
+
// Links
|
|
92
|
+
md = md.replace(/<a\s+[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, (_, href, text) => {
|
|
93
|
+
const cleanText = stripHtml(text);
|
|
94
|
+
return `[${cleanText}](${href})`;
|
|
95
|
+
});
|
|
96
|
+
// List items
|
|
97
|
+
md = md.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, (_, c) => `- ${stripHtml(c)}\n`);
|
|
98
|
+
// Paragraphs / line breaks
|
|
99
|
+
md = md.replace(/<br\s*\/?>/gi, '\n');
|
|
100
|
+
md = md.replace(/<\/p>/gi, '\n\n');
|
|
101
|
+
md = md.replace(/<p[^>]*>/gi, '');
|
|
102
|
+
// Block-level elements → newlines
|
|
103
|
+
md = md.replace(/<\/(div|section|article|header|footer|main|nav)>/gi, '\n');
|
|
104
|
+
// Remove remaining tags
|
|
105
|
+
md = md.replace(/<[^>]+>/g, '');
|
|
106
|
+
// Decode entities
|
|
107
|
+
md = decodeEntities(md);
|
|
108
|
+
// Clean up whitespace
|
|
109
|
+
md = md.replace(/[ \t]+/g, ' ');
|
|
110
|
+
md = md.replace(/\n{3,}/g, '\n\n');
|
|
111
|
+
return md.trim();
|
|
112
|
+
}
|
|
113
|
+
function extractTitle(html) {
|
|
114
|
+
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
115
|
+
return match ? decodeEntities(match[1].trim()) : '';
|
|
116
|
+
}
|
|
117
|
+
function extractMetaDescription(html) {
|
|
118
|
+
const match = html.match(/<meta\s+[^>]*name=["']description["'][^>]*content=["']([^"']*)["'][^>]*>/i)
|
|
119
|
+
|| html.match(/<meta\s+[^>]*content=["']([^"']*)["'][^>]*name=["']description["'][^>]*>/i);
|
|
120
|
+
return match ? decodeEntities(match[1].trim()) : '';
|
|
121
|
+
}
|
|
122
|
+
function resolveUrl(href, base) {
|
|
123
|
+
try {
|
|
124
|
+
return new URL(href, base).href;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return href;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function extractLinksFromHtml(html, baseUrl) {
|
|
131
|
+
const links = [];
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
const re = /<a\s+[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
|
|
134
|
+
let match;
|
|
135
|
+
let baseOrigin;
|
|
136
|
+
try {
|
|
137
|
+
baseOrigin = new URL(baseUrl).origin;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
baseOrigin = '';
|
|
141
|
+
}
|
|
142
|
+
while ((match = re.exec(html)) !== null) {
|
|
143
|
+
const rawHref = match[1].trim();
|
|
144
|
+
// Skip anchors, javascript:, mailto:, tel:
|
|
145
|
+
if (rawHref.startsWith('#') || rawHref.startsWith('javascript:') || rawHref.startsWith('mailto:') || rawHref.startsWith('tel:'))
|
|
146
|
+
continue;
|
|
147
|
+
const absoluteHref = resolveUrl(rawHref, baseUrl);
|
|
148
|
+
if (seen.has(absoluteHref))
|
|
149
|
+
continue;
|
|
150
|
+
seen.add(absoluteHref);
|
|
151
|
+
const text = stripHtml(match[2]).slice(0, 200);
|
|
152
|
+
let linkType = 'external';
|
|
153
|
+
try {
|
|
154
|
+
if (new URL(absoluteHref).origin === baseOrigin)
|
|
155
|
+
linkType = 'internal';
|
|
156
|
+
}
|
|
157
|
+
catch { /* keep external */ }
|
|
158
|
+
links.push({ href: absoluteHref, text, type: linkType });
|
|
159
|
+
}
|
|
160
|
+
return links;
|
|
161
|
+
}
|
|
162
|
+
// ── Skill entry point ───────────────────────────
|
|
163
|
+
function webScrapingSkill(api) {
|
|
164
|
+
// ── scrape_url ──
|
|
165
|
+
api.registerTool({
|
|
166
|
+
name: 'scrape_url',
|
|
167
|
+
description: [
|
|
168
|
+
'Fetch a web page and return its text content with metadata.',
|
|
169
|
+
'Strips HTML tags by default (format="text"). Use format="markdown" to preserve structure,',
|
|
170
|
+
'or format="html" to get raw HTML. Max response size: 5MB.',
|
|
171
|
+
].join(' '),
|
|
172
|
+
parameters: {
|
|
173
|
+
type: 'object',
|
|
174
|
+
required: ['url'],
|
|
175
|
+
properties: {
|
|
176
|
+
url: { type: 'string', description: 'URL to fetch' },
|
|
177
|
+
format: { type: 'string', description: '"text" (default), "markdown", or "html"' },
|
|
178
|
+
timeout: { type: 'number', description: 'Request timeout in milliseconds (default 30000)' },
|
|
179
|
+
headers: { type: 'object', description: 'Optional custom HTTP headers' },
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
handler: async ({ url, format, timeout, headers }) => {
|
|
183
|
+
if (!url || !/^https?:\/\//i.test(url)) {
|
|
184
|
+
return { error: 'Invalid URL. Must start with http:// or https://' };
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const page = await fetchPage(url, timeout || DEFAULT_TIMEOUT, headers);
|
|
188
|
+
let text;
|
|
189
|
+
const fmt = (format || 'text').toLowerCase();
|
|
190
|
+
if (fmt === 'html') {
|
|
191
|
+
text = page.html;
|
|
192
|
+
}
|
|
193
|
+
else if (fmt === 'markdown') {
|
|
194
|
+
text = htmlToMarkdown(page.html);
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
text = stripHtml(page.html);
|
|
198
|
+
}
|
|
199
|
+
const linkCount = (page.html.match(/<a\s+[^>]*href=/gi) || []).length;
|
|
200
|
+
return {
|
|
201
|
+
url: page.finalUrl,
|
|
202
|
+
title: extractTitle(page.html),
|
|
203
|
+
description: extractMetaDescription(page.html),
|
|
204
|
+
text,
|
|
205
|
+
links_count: linkCount,
|
|
206
|
+
content_length: text.length,
|
|
207
|
+
status: page.status,
|
|
208
|
+
content_type: page.contentType,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
return { error: `Failed to fetch ${url}: ${err.message}` };
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
// ── extract_links ──
|
|
217
|
+
api.registerTool({
|
|
218
|
+
name: 'extract_links',
|
|
219
|
+
description: [
|
|
220
|
+
'Fetch a web page and extract all links with context.',
|
|
221
|
+
'Returns resolved absolute URLs, link text, and internal/external classification.',
|
|
222
|
+
'Use pattern parameter to filter links by regex on href.',
|
|
223
|
+
].join(' '),
|
|
224
|
+
parameters: {
|
|
225
|
+
type: 'object',
|
|
226
|
+
required: ['url'],
|
|
227
|
+
properties: {
|
|
228
|
+
url: { type: 'string', description: 'URL to fetch and extract links from' },
|
|
229
|
+
pattern: { type: 'string', description: 'Optional regex pattern to filter link hrefs' },
|
|
230
|
+
limit: { type: 'number', description: 'Maximum links to return (default 50)' },
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
handler: async ({ url, pattern, limit }) => {
|
|
234
|
+
if (!url || !/^https?:\/\//i.test(url)) {
|
|
235
|
+
return { error: 'Invalid URL. Must start with http:// or https://' };
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
const page = await fetchPage(url);
|
|
239
|
+
let links = extractLinksFromHtml(page.html, page.finalUrl);
|
|
240
|
+
if (pattern) {
|
|
241
|
+
try {
|
|
242
|
+
const re = new RegExp(pattern, 'i');
|
|
243
|
+
links = links.filter(l => re.test(l.href));
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
return { error: `Invalid regex pattern: ${err.message}` };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const maxLinks = Math.min(limit || 50, 200);
|
|
250
|
+
const total = links.length;
|
|
251
|
+
return {
|
|
252
|
+
url: page.finalUrl,
|
|
253
|
+
links: links.slice(0, maxLinks),
|
|
254
|
+
total_found: total,
|
|
255
|
+
returned: Math.min(total, maxLinks),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
return { error: `Failed to fetch ${url}: ${err.message}` };
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
// ── scrape_multiple ──
|
|
264
|
+
api.registerTool({
|
|
265
|
+
name: 'scrape_multiple',
|
|
266
|
+
description: [
|
|
267
|
+
'Fetch multiple URLs in parallel and return their text content.',
|
|
268
|
+
'Useful for research and comparison tasks. Max 10 URLs per call.',
|
|
269
|
+
'Uses Promise.allSettled so one failure does not block others.',
|
|
270
|
+
].join(' '),
|
|
271
|
+
parameters: {
|
|
272
|
+
type: 'object',
|
|
273
|
+
required: ['urls'],
|
|
274
|
+
properties: {
|
|
275
|
+
urls: { type: 'array', description: 'Array of URLs to fetch (max 10)', items: { type: 'string' } },
|
|
276
|
+
format: { type: 'string', description: '"text" (default) or "html"' },
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
handler: async ({ urls, format }) => {
|
|
280
|
+
if (!Array.isArray(urls) || urls.length === 0) {
|
|
281
|
+
return { error: 'urls must be a non-empty array of strings' };
|
|
282
|
+
}
|
|
283
|
+
if (urls.length > 10) {
|
|
284
|
+
return { error: 'Maximum 10 URLs per call' };
|
|
285
|
+
}
|
|
286
|
+
const fmt = (format || 'text').toLowerCase();
|
|
287
|
+
const results = await Promise.allSettled(urls.map(async (url) => {
|
|
288
|
+
if (!url || !/^https?:\/\//i.test(url)) {
|
|
289
|
+
throw new Error('Invalid URL');
|
|
290
|
+
}
|
|
291
|
+
const page = await fetchPage(url);
|
|
292
|
+
const text = fmt === 'html' ? page.html : stripHtml(page.html);
|
|
293
|
+
return {
|
|
294
|
+
url: page.finalUrl,
|
|
295
|
+
title: extractTitle(page.html),
|
|
296
|
+
text,
|
|
297
|
+
status: page.status,
|
|
298
|
+
};
|
|
299
|
+
}));
|
|
300
|
+
const items = results.map((r, i) => {
|
|
301
|
+
if (r.status === 'fulfilled') {
|
|
302
|
+
return r.value;
|
|
303
|
+
}
|
|
304
|
+
return { url: urls[i], title: '', text: '', status: 0, error: r.reason?.message || 'Unknown error' };
|
|
305
|
+
});
|
|
306
|
+
const succeeded = items.filter(r => !('error' in r)).length;
|
|
307
|
+
const failed = items.length - succeeded;
|
|
308
|
+
return { results: items, succeeded, failed };
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "web-scraping",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Fetch, parse, and extract data from web pages using Node.js built-in fetch",
|
|
5
|
+
"author": "xpr-agents",
|
|
6
|
+
"category": "oracle",
|
|
7
|
+
"tags": ["web", "scraping", "fetch", "html", "extract"],
|
|
8
|
+
"capabilities": ["web-scraping", "link-extraction"],
|
|
9
|
+
"tools": ["scrape_url", "extract_links", "scrape_multiple"],
|
|
10
|
+
"requires": {
|
|
11
|
+
"env": []
|
|
12
|
+
}
|
|
13
|
+
}
|