@usagekit/providers 0.8.0 → 0.9.1
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/dist/catalog.d.ts +8 -14
- package/dist/catalog.js +102 -17
- package/dist/dataforseo/descriptor.d.ts +2 -2
- package/dist/dataforseo/descriptor.js +54 -13
- package/dist/dataforseo/extractors.js +278 -9
- package/dist/dataforseo/history-window.d.ts +5 -0
- package/dist/dataforseo/history-window.js +36 -0
- package/dist/dataforseo/request-pricing.d.ts +2 -0
- package/dist/dataforseo/request-pricing.js +401 -0
- package/dist/execute.d.ts +9 -7
- package/dist/execute.js +5 -11
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/priced-request.d.ts +49 -0
- package/dist/priced-request.js +234 -0
- package/dist/prices.d.ts +3 -1
- package/dist/prices.js +59 -2
- package/dist/serpapi/descriptor.js +16 -9
- package/dist/testing/example.js +1 -0
- package/dist/types.d.ts +4 -0
- package/package.json +2 -2
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { formatQuantity } from "@usagekit/core";
|
|
1
|
+
import { addQuantity, formatQuantity } from "@usagekit/core";
|
|
2
|
+
import { descriptor } from "./descriptor.js";
|
|
3
|
+
import { historyWindowOptions } from "./history-window.js";
|
|
2
4
|
import { listEstimate } from "../prices.js";
|
|
3
5
|
import { pointer } from "../pointer.js";
|
|
4
|
-
import { moneyFromDollars, requestsMeasured } from "../receipts.js";
|
|
6
|
+
import { decimalQuantity, moneyFromDollars, requestsMeasured } from "../receipts.js";
|
|
5
7
|
const tasksOf = (body) => {
|
|
6
8
|
const tasks = pointer(body, "/tasks");
|
|
7
9
|
return Array.isArray(tasks) ? tasks : [];
|
|
@@ -34,23 +36,287 @@ export const priceKeys = {
|
|
|
34
36
|
...Object.fromEntries(["summary", "history", "backlinks"].map((n) => [`backlinks.live.${n}`, `backlinks.${n}.live`])),
|
|
35
37
|
};
|
|
36
38
|
const withPriority = new Set(["serp.google.organic.task_post"]);
|
|
39
|
+
const itemCountFields = {
|
|
40
|
+
"dataforseo_labs.google.keyword_overview.live": "keywords",
|
|
41
|
+
"dataforseo_labs.google.keyword_ideas.live": "limit",
|
|
42
|
+
"dataforseo_labs.google.keyword_suggestions.live": "limit",
|
|
43
|
+
"dataforseo_labs.google.related_keywords.live": "limit",
|
|
44
|
+
"dataforseo_labs.google.relevant_pages.live": "limit",
|
|
45
|
+
"backlinks.backlinks.live": "limit",
|
|
46
|
+
"dataforseo_labs.google.ranked_keywords.live": "limit",
|
|
47
|
+
"dataforseo_labs.google.domain_rank_overview.live": "limit",
|
|
48
|
+
};
|
|
37
49
|
function timestamp(text) {
|
|
38
50
|
if (typeof text !== "string")
|
|
39
51
|
return undefined;
|
|
40
52
|
const m = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) \+00:00$/.exec(text);
|
|
41
53
|
return m ? new Date(`${m[1]}T${m[2]}Z`).toISOString() : undefined;
|
|
42
54
|
}
|
|
55
|
+
const nonNegativeInteger = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
56
|
+
/**
|
|
57
|
+
* The single task of a present billable body, with documented JSON types for every pricing
|
|
58
|
+
* field. Ambiguous body shapes (text, bare objects, empty or nested arrays) are InvalidInput.
|
|
59
|
+
* Unlisted keys, including alternate spellings, are separately marked unpriceable.
|
|
60
|
+
* Undefined fields count as omitted, as JSON serialization drops them. A request without a
|
|
61
|
+
* body carries no task and keeps the defaults.
|
|
62
|
+
*/
|
|
63
|
+
function billableTask(body) {
|
|
64
|
+
if (body === undefined)
|
|
65
|
+
return undefined;
|
|
66
|
+
if (Array.isArray(body) && body.length > 1)
|
|
67
|
+
throw new Error("InvalidInput: multiple DataForSEO tasks");
|
|
68
|
+
const task = Array.isArray(body) ? body[0] : undefined;
|
|
69
|
+
if (!task || typeof task !== "object" || Array.isArray(task))
|
|
70
|
+
throw new Error("InvalidInput: DataForSEO body must be an array with one task object");
|
|
71
|
+
const t = task;
|
|
72
|
+
for (const field of ["limit", "depth"])
|
|
73
|
+
if (t[field] !== undefined && !nonNegativeInteger(t[field]))
|
|
74
|
+
throw new Error(`InvalidInput: DataForSEO ${field}`);
|
|
75
|
+
if (t.priority !== undefined && t.priority !== 1 && t.priority !== 2)
|
|
76
|
+
throw new Error("InvalidInput: DataForSEO priority");
|
|
77
|
+
if (t.keywords !== undefined &&
|
|
78
|
+
(!Array.isArray(t.keywords) || t.keywords.some((keyword) => typeof keyword !== "string")))
|
|
79
|
+
throw new Error("InvalidInput: DataForSEO keywords");
|
|
80
|
+
for (const field of ["include_clickstream_data", "include_seed_keyword"])
|
|
81
|
+
if (t[field] !== undefined && typeof t[field] !== "boolean")
|
|
82
|
+
throw new Error(`InvalidInput: DataForSEO ${field}`);
|
|
83
|
+
return t;
|
|
84
|
+
}
|
|
85
|
+
const isSerp = (id) => id.startsWith("serp.google.organic.");
|
|
86
|
+
function validateSerpDepth(depth) {
|
|
87
|
+
if (depth !== undefined &&
|
|
88
|
+
(!/^\d+$/.test(String(depth)) ||
|
|
89
|
+
BigInt(String(depth)) < 1n ||
|
|
90
|
+
BigInt(String(depth)) > BigInt(Number.MAX_SAFE_INTEGER)))
|
|
91
|
+
throw new Error("InvalidInput: DataForSEO SERP depth must be a positive safe integer");
|
|
92
|
+
}
|
|
93
|
+
// "Contains" is intentional, including embedded forms such as android: (id:).
|
|
94
|
+
// Each distinct documented substring contributes a factor of five.
|
|
95
|
+
// https://docs.dataforseo.com/v3/serp/google/organic/live/advanced/
|
|
96
|
+
// https://docs.dataforseo.com/v3/serp/google/organic/task_post/
|
|
97
|
+
// https://dataforseo.com/pricing/serp/google-organic-serp-api
|
|
98
|
+
const searchOperators = [
|
|
99
|
+
"allinanchor:",
|
|
100
|
+
"allintext:",
|
|
101
|
+
"allintitle:",
|
|
102
|
+
"allinurl:",
|
|
103
|
+
"cache:",
|
|
104
|
+
"define:",
|
|
105
|
+
"definition:",
|
|
106
|
+
"filetype:",
|
|
107
|
+
"id:",
|
|
108
|
+
"inanchor:",
|
|
109
|
+
"info:",
|
|
110
|
+
"intext:",
|
|
111
|
+
"intitle:",
|
|
112
|
+
"inurl:",
|
|
113
|
+
"link:",
|
|
114
|
+
"site:",
|
|
115
|
+
];
|
|
116
|
+
function operatorCount(task) {
|
|
117
|
+
const values = [
|
|
118
|
+
...(typeof task.keyword === "string" ? [task.keyword, decodeKeyword(task.keyword)] : []),
|
|
119
|
+
...(typeof task.search_param === "string"
|
|
120
|
+
? [task.search_param, decodeSearchParam(task.search_param)]
|
|
121
|
+
: []),
|
|
122
|
+
];
|
|
123
|
+
const normalized = values
|
|
124
|
+
.flatMap((v) => [
|
|
125
|
+
v.normalize("NFKC").toLowerCase(),
|
|
126
|
+
v.normalize("NFKC").toUpperCase().toLowerCase(),
|
|
127
|
+
v
|
|
128
|
+
.normalize("NFKD")
|
|
129
|
+
.replace(/[\p{M}\p{Default_Ignorable_Code_Point}]/gu, "")
|
|
130
|
+
.toLowerCase(),
|
|
131
|
+
])
|
|
132
|
+
.map((v) => v.replaceAll("-site:", ""));
|
|
133
|
+
// -site: is explicitly free. Other operator matches are conservative upper bounds.
|
|
134
|
+
// https://dataforseo.com/help-center/rules-and-limitations-of-keyword-and-keywords-fields-in-dataforseo-apis
|
|
135
|
+
return searchOperators.filter((op) => normalized.some((value) => value.includes(op))).length;
|
|
136
|
+
}
|
|
137
|
+
/** Keywords are text, not query strings: preserve literal percent signs. Decode each
|
|
138
|
+
* valid escape run leniently; invalid UTF-8 bytes become replacement characters. */
|
|
139
|
+
function decodeKeyword(value) {
|
|
140
|
+
value = value.normalize("NFKC");
|
|
141
|
+
for (let i = 0; i < 16; i++) {
|
|
142
|
+
const decoded = value
|
|
143
|
+
.replace(/(?:%[0-9a-f]{2})+/gi, (run) => {
|
|
144
|
+
try {
|
|
145
|
+
return decodeURIComponent(run);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return new TextDecoder().decode(Uint8Array.from(run.match(/[0-9a-f]{2}/gi), (hex) => parseInt(hex, 16)));
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
.normalize("NFKC");
|
|
152
|
+
if (decoded === value)
|
|
153
|
+
return decoded;
|
|
154
|
+
value = decoded;
|
|
155
|
+
}
|
|
156
|
+
throw new Error("InvalidInput: DataForSEO keyword encoding depth");
|
|
157
|
+
}
|
|
158
|
+
function validateSerpOptions(options) {
|
|
159
|
+
validateSerpDepth(options.depth);
|
|
160
|
+
for (const [field, max] of [
|
|
161
|
+
["operatorCount", searchOperators.length],
|
|
162
|
+
["clicks", 4],
|
|
163
|
+
]) {
|
|
164
|
+
const value = options[field];
|
|
165
|
+
if (value !== undefined && (!/^\d+$/.test(value) || BigInt(value) > BigInt(max)))
|
|
166
|
+
throw new Error(`InvalidInput: DataForSEO SERP ${field}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function decodeSearchParam(value) {
|
|
170
|
+
value = value.normalize("NFKC");
|
|
171
|
+
// Decode nested query escapes before checking operators. Fail closed when malformed
|
|
172
|
+
// or still changing after the bound rather than passing an unchecked encoded operator.
|
|
173
|
+
for (let i = 0; i < 16; i++) {
|
|
174
|
+
let decoded;
|
|
175
|
+
try {
|
|
176
|
+
decoded = decodeURIComponent(value.replace(/\+/g, " ")).normalize("NFKC");
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
throw new Error("InvalidInput: DataForSEO malformed search_param escape");
|
|
180
|
+
}
|
|
181
|
+
if (decoded === value)
|
|
182
|
+
return decoded;
|
|
183
|
+
value = decoded;
|
|
184
|
+
}
|
|
185
|
+
throw new Error("InvalidInput: DataForSEO search_param encoding depth");
|
|
186
|
+
}
|
|
187
|
+
function validateSerpTask(task) {
|
|
188
|
+
validateSerpDepth(task.depth);
|
|
189
|
+
for (const field of ["calculate_rectangles", "load_async_ai_overview"])
|
|
190
|
+
if (task[field] !== undefined && typeof task[field] !== "boolean")
|
|
191
|
+
throw new Error(`InvalidInput: DataForSEO ${field}`);
|
|
192
|
+
for (const [field, max] of [
|
|
193
|
+
["people_also_ask_click_depth", 4],
|
|
194
|
+
["max_crawl_pages", 100],
|
|
195
|
+
])
|
|
196
|
+
if (task[field] !== undefined &&
|
|
197
|
+
(!nonNegativeInteger(task[field]) || Number(task[field]) < 1 || Number(task[field]) > max))
|
|
198
|
+
throw new Error(`InvalidInput: DataForSEO ${field}`);
|
|
199
|
+
if (task.stop_crawl_on_match !== undefined && !Array.isArray(task.stop_crawl_on_match))
|
|
200
|
+
throw new Error("InvalidInput: DataForSEO stop_crawl_on_match");
|
|
201
|
+
for (const field of ["keyword", "search_param"])
|
|
202
|
+
if (task[field] !== undefined && typeof task[field] !== "string")
|
|
203
|
+
throw new Error(`InvalidInput: DataForSEO ${field}`);
|
|
204
|
+
}
|
|
43
205
|
export const extractors = {
|
|
44
|
-
|
|
45
|
-
|
|
206
|
+
canEstimate: (options) => options.pricingUnknown !== "true",
|
|
207
|
+
// https://dataforseo.com/pricing/serp/google-organic-serp-api
|
|
208
|
+
// https://dataforseo.com/pricing/dataforseo-labs/dataforseo-google-api
|
|
209
|
+
requiresListPriceFloor: (options) => Number(options.operatorCount ?? "0") > 0 ||
|
|
210
|
+
options.rectangles === "true" ||
|
|
211
|
+
options.asyncOverview === "true" ||
|
|
212
|
+
Number(options.clicks ?? "0") > 0 ||
|
|
213
|
+
options.crawlPages !== undefined ||
|
|
214
|
+
options.clickstream === "true",
|
|
215
|
+
estimate: (input) => {
|
|
216
|
+
if (isSerp(input.operation.id))
|
|
217
|
+
validateSerpOptions(input.options);
|
|
218
|
+
if (!extractors.canEstimate(input.options))
|
|
219
|
+
return [];
|
|
220
|
+
const estimates = listEstimate(input);
|
|
221
|
+
if (!isSerp(input.operation.id))
|
|
222
|
+
return estimates;
|
|
223
|
+
// Bound every page and operator combination, including add-ons. Field docs give
|
|
224
|
+
// $0.002 live / $0.0006 queued; the pricing table gives one base (higher for high priority).
|
|
225
|
+
// https://docs.dataforseo.com/v3/serp/google/organic/live/advanced/
|
|
226
|
+
// https://docs.dataforseo.com/v3/serp/google/organic/task_post/
|
|
227
|
+
// https://dataforseo.com/pricing/serp/google-organic-serp-api
|
|
228
|
+
// https://dataforseo.com/help-center/serp-api-cost-explained
|
|
229
|
+
const pages = (BigInt(input.options.depth ?? "10") + 9n) / 10n;
|
|
230
|
+
const operators = BigInt(input.options.operatorCount ?? "0");
|
|
231
|
+
const factor = 5n ** operators;
|
|
232
|
+
const addOns = Number(input.options.rectangles === "true") + Number(input.options.asyncOverview === "true");
|
|
233
|
+
const extra = decimalQuantity(input.operation.id.endsWith("task_post")
|
|
234
|
+
? input.options.priority === "high"
|
|
235
|
+
? "0.12"
|
|
236
|
+
: "0.06"
|
|
237
|
+
: "0.2", "cents");
|
|
238
|
+
const clicks = decimalQuantity("0.015", "cents");
|
|
239
|
+
return estimates.map((q) => {
|
|
240
|
+
if (q.unit !== "cents")
|
|
241
|
+
return q;
|
|
242
|
+
const total = addQuantity(addQuantity(q, { ...extra, value: extra.value * BigInt(addOns) * pages }), { ...clicks, value: clicks.value * BigInt(input.options.clicks ?? "0") * pages });
|
|
243
|
+
return decimalQuantity(formatQuantity({ ...total, value: total.value * factor }), "cents");
|
|
244
|
+
});
|
|
245
|
+
},
|
|
246
|
+
/** Request dimensions and documented defaults, separate from observed response quantities. */
|
|
46
247
|
options: (operation, request) => {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
248
|
+
const first = operation.billable
|
|
249
|
+
? billableTask(request.body)
|
|
250
|
+
: Array.isArray(request.body)
|
|
251
|
+
? request.body[0]
|
|
252
|
+
: null;
|
|
50
253
|
if (!first || typeof first !== "object")
|
|
51
254
|
return {};
|
|
52
|
-
const
|
|
53
|
-
|
|
255
|
+
const task = first, options = {};
|
|
256
|
+
const keys = operation.optionKeys ?? [];
|
|
257
|
+
Object.assign(options, historyWindowOptions(operation.id, task));
|
|
258
|
+
if (operation.billable) {
|
|
259
|
+
const fields = operation.requestPricing;
|
|
260
|
+
const unlisted = !fields ||
|
|
261
|
+
Object.keys(task).some((key) => task[key] !== undefined &&
|
|
262
|
+
!Object.hasOwn(fields.priceFields, key) &&
|
|
263
|
+
!fields.neutralFields.includes(key));
|
|
264
|
+
if (unlisted ||
|
|
265
|
+
task.limit === 0 ||
|
|
266
|
+
Number(task.limit) > 1000 ||
|
|
267
|
+
Object.entries(fields?.priceFields ?? {}).some(([field, rule]) => rule.unpricedWhen === "present"
|
|
268
|
+
? task[field] !== undefined
|
|
269
|
+
: rule.unpricedWhen === "true" && task[field] === true))
|
|
270
|
+
options.pricingUnknown = "true";
|
|
271
|
+
}
|
|
272
|
+
if (operation.billable && isSerp(operation.id))
|
|
273
|
+
validateSerpTask(task);
|
|
274
|
+
if (keys.includes("priority")) {
|
|
275
|
+
const priority = task.priority ?? 1;
|
|
276
|
+
if (priority === 1)
|
|
277
|
+
options.priority = "normal";
|
|
278
|
+
else if (priority === 2)
|
|
279
|
+
options.priority = "high";
|
|
280
|
+
}
|
|
281
|
+
if (keys.includes("depth") && task.depth !== undefined)
|
|
282
|
+
options.depth = String(task.depth);
|
|
283
|
+
if (operation.billable && isSerp(operation.id)) {
|
|
284
|
+
if (task.max_crawl_pages !== undefined) {
|
|
285
|
+
options.crawlPages = String(task.max_crawl_pages);
|
|
286
|
+
options.depth = String(Math.max(Number(task.depth ?? 10), Number(task.max_crawl_pages) * 10));
|
|
287
|
+
}
|
|
288
|
+
const count = operatorCount(task);
|
|
289
|
+
if (count)
|
|
290
|
+
options.operatorCount = String(count);
|
|
291
|
+
if (task.calculate_rectangles === true)
|
|
292
|
+
options.rectangles = "true";
|
|
293
|
+
if (task.load_async_ai_overview === true)
|
|
294
|
+
options.asyncOverview = "true";
|
|
295
|
+
if (task.people_also_ask_click_depth !== undefined)
|
|
296
|
+
options.clicks = String(task.people_also_ask_click_depth);
|
|
297
|
+
}
|
|
298
|
+
if (keys.includes("itemCount")) {
|
|
299
|
+
const field = itemCountFields[operation.id];
|
|
300
|
+
const count = field === "keywords"
|
|
301
|
+
? Array.isArray(task.keywords)
|
|
302
|
+
? task.keywords.length
|
|
303
|
+
: undefined
|
|
304
|
+
: field === "limit"
|
|
305
|
+
? // Only omission uses the documented default; zero marks the request unknown.
|
|
306
|
+
task.limit === undefined
|
|
307
|
+
? operation.requestPricing?.priceFields.limit?.defaultCount
|
|
308
|
+
: task.limit
|
|
309
|
+
: // Summary returns one row; explicit history dates were bounded above.
|
|
310
|
+
// https://docs.dataforseo.com/v3/backlinks/summary/live/
|
|
311
|
+
operation.id === "backlinks.summary.live"
|
|
312
|
+
? 1
|
|
313
|
+
: undefined;
|
|
314
|
+
if (count !== undefined)
|
|
315
|
+
options.itemCount = String(count);
|
|
316
|
+
}
|
|
317
|
+
if (keys.includes("clickstream") && task.include_clickstream_data !== undefined)
|
|
318
|
+
options.clickstream = task.include_clickstream_data === false ? "false" : "true";
|
|
319
|
+
return options;
|
|
54
320
|
},
|
|
55
321
|
/**
|
|
56
322
|
* Cost from the envelope `cost` (USD, every task of the call). A task status of 40000 or
|
|
@@ -115,12 +381,15 @@ export const extractors = {
|
|
|
115
381
|
continue;
|
|
116
382
|
if (!withPriority.has(operation) && level !== "normal")
|
|
117
383
|
continue;
|
|
384
|
+
const calculation = descriptor.prices.find((r) => r.operation === operation && (!r.option || r.option.priority === level))?.calculation;
|
|
118
385
|
rows.push({
|
|
119
386
|
validFrom: checkedAt,
|
|
120
387
|
operation,
|
|
121
388
|
...(withPriority.has(operation) ? { option: { priority: level } } : {}),
|
|
122
389
|
unit: "cents",
|
|
123
390
|
perUnit: formatQuantity(centsQuantity(entry.cost)),
|
|
391
|
+
// The endpoint refreshes base prices; retain static formula metadata.
|
|
392
|
+
...(calculation ? { calculation } : {}),
|
|
124
393
|
source: priceListSource,
|
|
125
394
|
checkedAt,
|
|
126
395
|
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Both history APIs return at most one item per month. Include partial boundary months.
|
|
2
|
+
* https://docs.dataforseo.com/v3/backlinks-history-live/
|
|
3
|
+
* https://docs.dataforseo.com/v3/dataforseo_labs-google-historical_rank_overview-live/
|
|
4
|
+
*/
|
|
5
|
+
export declare function historyWindowOptions(operation: string, task: Readonly<Record<string, unknown>>): Record<string, string>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const historyOperations = new Set([
|
|
2
|
+
"backlinks.history.live",
|
|
3
|
+
"dataforseo_labs.google.historical_rank_overview.live",
|
|
4
|
+
]);
|
|
5
|
+
function validDate(value) {
|
|
6
|
+
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
7
|
+
return false;
|
|
8
|
+
const date = new Date(`${value}T00:00:00Z`);
|
|
9
|
+
return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value;
|
|
10
|
+
}
|
|
11
|
+
/** Both history APIs return at most one item per month. Include partial boundary months.
|
|
12
|
+
* https://docs.dataforseo.com/v3/backlinks-history-live/
|
|
13
|
+
* https://docs.dataforseo.com/v3/dataforseo_labs-google-historical_rank_overview-live/
|
|
14
|
+
*/
|
|
15
|
+
export function historyWindowOptions(operation, task) {
|
|
16
|
+
if (!historyOperations.has(operation))
|
|
17
|
+
return {};
|
|
18
|
+
const from = task.date_from;
|
|
19
|
+
const to = task.date_to;
|
|
20
|
+
const options = {};
|
|
21
|
+
if (validDate(from))
|
|
22
|
+
options.dateFrom = from;
|
|
23
|
+
else if (from !== undefined)
|
|
24
|
+
options.pricingUnknown = "true";
|
|
25
|
+
if (validDate(to))
|
|
26
|
+
options.dateTo = to;
|
|
27
|
+
else if (to !== undefined)
|
|
28
|
+
options.pricingUnknown = "true";
|
|
29
|
+
if (!validDate(from) || !validDate(to))
|
|
30
|
+
return options;
|
|
31
|
+
if (from > to)
|
|
32
|
+
return { ...options, pricingUnknown: "true" };
|
|
33
|
+
const monthIndex = (date) => Number(date.slice(0, 4)) * 12 + Number(date.slice(5, 7));
|
|
34
|
+
options.itemCount = String(monthIndex(to) - monthIndex(from) + 1);
|
|
35
|
+
return options;
|
|
36
|
+
}
|