@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.
@@ -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
- estimate: listEstimate,
45
- /** priority 1 is normal (the documented default when absent), 2 is high; anything else is unreadable. */
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
- if (!operation.optionKeys?.includes("priority"))
48
- return {};
49
- const first = Array.isArray(request.body) ? request.body[0] : null;
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 priority = first.priority ?? 1;
53
- return priority === 1 ? { priority: "normal" } : priority === 2 ? { priority: "high" } : {};
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
+ }
@@ -0,0 +1,2 @@
1
+ import type { ProviderOperation } from "@usagekit/core";
2
+ export declare const requestPricing: Readonly<Record<string, NonNullable<ProviderOperation["requestPricing"]>>>;