@witchpot/gmc 0.1.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.
@@ -0,0 +1,600 @@
1
+ import {
2
+ COMPARE_FOCUSES,
3
+ DEFAULT_BENCHMARK_LIMIT,
4
+ DEFAULT_CAMPAIGN_SIGNALS_LIMIT,
5
+ DEFAULT_COHORT_WORKFLOW_LIMIT,
6
+ DEFAULT_RESOLVE_LIMIT,
7
+ DEFAULT_REVIEW_PATTERNS_LIMIT,
8
+ DEFAULT_REVIEW_PATTERNS_MAX_TOPICS_PER_GAME,
9
+ DEFAULT_REVIEW_PATTERNS_MIN_GAMES,
10
+ DEFAULT_SHOWCASE_FIT_LIMIT,
11
+ EXIT_USAGE,
12
+ MAX_SAMPLE_PAGES,
13
+ MAX_SAMPLE_SIZE
14
+ } from "./constants.js";
15
+ import { matches } from "./args.js";
16
+ import { writeJsonError } from "./output.js";
17
+
18
+ export function buildRequest(command, options, positional) {
19
+ if (matches(command, ["status"])) {
20
+ return {
21
+ path: "/api/v1/status",
22
+ query: {}
23
+ };
24
+ }
25
+
26
+ if (matches(command, ["auth", "status"])) {
27
+ return {
28
+ path: "/api/v1/status",
29
+ query: {}
30
+ };
31
+ }
32
+
33
+ if (matches(command, ["schema"])) {
34
+ return {
35
+ path: "/api/v1/schema",
36
+ query: {}
37
+ };
38
+ }
39
+
40
+ if (matches(command, ["games", "search"])) {
41
+ assertSource(options.source);
42
+ return {
43
+ path: "/api/v1/steam/games",
44
+ query: buildGameSearchQuery(options)
45
+ };
46
+ }
47
+
48
+ if (matches(command, ["games", "resolve"])) {
49
+ assertSource(options.source);
50
+ const query = options.query ?? options.q ?? positional.join(" ");
51
+ assertResolveQuery(query);
52
+ return {
53
+ kind: "resolve",
54
+ source: "steam",
55
+ query: String(query),
56
+ searchRequest: {
57
+ path: "/api/v1/steam/games",
58
+ query: {
59
+ limit: DEFAULT_RESOLVE_LIMIT,
60
+ ...buildGameSearchQuery({
61
+ ...options,
62
+ query
63
+ })
64
+ }
65
+ }
66
+ };
67
+ }
68
+
69
+ if (matches(command, ["games", "benchmark"])) {
70
+ assertSource(options.source);
71
+ const sampling = buildSamplingOptions(options, DEFAULT_BENCHMARK_LIMIT);
72
+ const query = {
73
+ sort: "reviews_desc",
74
+ limit: sampling?.pageLimit ?? DEFAULT_BENCHMARK_LIMIT,
75
+ ...buildGameSearchQuery(options)
76
+ };
77
+ if (sampling && options.limit == null) query.limit = sampling.pageLimit;
78
+ return {
79
+ kind: "benchmark",
80
+ source: "steam",
81
+ query,
82
+ sampling,
83
+ searchRequest: {
84
+ path: "/api/v1/steam/games",
85
+ query
86
+ }
87
+ };
88
+ }
89
+
90
+ if (matches(command, ["games", "detail"])) {
91
+ assertSource(options.source);
92
+ const externalId = positional[0];
93
+ assertExternalId(externalId, "gmc games detail --source steam <externalId> --json");
94
+ return {
95
+ path: `/api/v1/steam/games/${encodeURIComponent(externalId)}`,
96
+ query: pickQuery(options, {
97
+ locale: "locale",
98
+ detail: "detail"
99
+ })
100
+ };
101
+ }
102
+
103
+ if (matches(command, ["games", "analysis"])) {
104
+ assertSource(options.source);
105
+ const externalId = positional[0];
106
+ assertExternalId(externalId, "gmc games analysis --source steam <externalId> --sections reviews,marketing --json");
107
+ return {
108
+ path: `/api/v1/steam/games/${encodeURIComponent(externalId)}/analysis`,
109
+ query: pickQuery(options, {
110
+ sections: "sections",
111
+ locale: "locale"
112
+ })
113
+ };
114
+ }
115
+
116
+ if (matches(command, ["campaigns", "signals"])) {
117
+ assertSource(options.source);
118
+ const externalId = positional[0];
119
+ assertExternalId(externalId, "gmc campaigns signals --source steam <externalId> --json");
120
+ const limit = assertPositiveIntegerOption(options.limit ?? DEFAULT_CAMPAIGN_SIGNALS_LIMIT, "--limit");
121
+ return {
122
+ kind: "campaignSignals",
123
+ source: "steam",
124
+ externalId,
125
+ limit,
126
+ detailQuery: pickQuery(options, {
127
+ locale: "locale"
128
+ })
129
+ };
130
+ }
131
+
132
+ if (matches(command, ["games", "compare"])) {
133
+ assertSource(options.source);
134
+ assertCompareExternalIds(positional);
135
+ return {
136
+ kind: "compare",
137
+ source: "steam",
138
+ externalIds: positional,
139
+ focus: assertCompareFocus(options.focus ?? "market"),
140
+ query: pickQuery(options, {
141
+ locale: "locale"
142
+ })
143
+ };
144
+ }
145
+
146
+ if (matches(command, ["games", "topics"])) {
147
+ assertSource(options.source);
148
+ const externalId = positional[0];
149
+ assertExternalId(externalId, "gmc games topics --source steam <externalId> --json");
150
+ return {
151
+ path: `/api/v1/steam/games/${encodeURIComponent(externalId)}/review-topics`,
152
+ query: pickQuery(options, {
153
+ limit: "limit",
154
+ offset: "offset",
155
+ locale: "locale"
156
+ })
157
+ };
158
+ }
159
+
160
+ if (matches(command, ["reviews", "patterns"])) {
161
+ assertSource(options.source);
162
+ const isCompare = positional[0] === "compare";
163
+ const minGames = assertPositiveIntegerOption(
164
+ options["min-games"] ?? DEFAULT_REVIEW_PATTERNS_MIN_GAMES,
165
+ "--min-games"
166
+ );
167
+ const maxTopicsPerGame = assertPositiveIntegerOption(
168
+ options["max-topics-per-game"] ?? DEFAULT_REVIEW_PATTERNS_MAX_TOPICS_PER_GAME,
169
+ "--max-topics-per-game"
170
+ );
171
+ const sentiment = options.sentiment == null ? undefined : assertReviewPatternSentiment(options.sentiment);
172
+ const sampling = buildSamplingOptions(options, DEFAULT_REVIEW_PATTERNS_LIMIT);
173
+ const query = {
174
+ sort: "reviews_desc",
175
+ limit: sampling?.pageLimit ?? DEFAULT_REVIEW_PATTERNS_LIMIT,
176
+ ...buildGameSearchQuery(options)
177
+ };
178
+ if (sampling && options.limit == null) query.limit = sampling.pageLimit;
179
+
180
+ return {
181
+ kind: "reviewPatterns",
182
+ source: "steam",
183
+ query,
184
+ sampling,
185
+ minGames,
186
+ maxTopicsPerGame,
187
+ sentiment,
188
+ topic: options.topic,
189
+ compareBy: isCompare ? String(options.segment ?? "price") : undefined,
190
+ searchRequest: {
191
+ path: "/api/v1/steam/games",
192
+ query
193
+ },
194
+ topicQuery: {
195
+ ...pickQuery(options, {
196
+ locale: "locale"
197
+ }),
198
+ limit: maxTopicsPerGame
199
+ }
200
+ };
201
+ }
202
+
203
+ if (matches(command, ["marketing", "patterns"])) {
204
+ return buildCohortEvidenceRequest({
205
+ workflow: "marketingPatterns",
206
+ evidenceType: "marketing",
207
+ sections: ["marketing", "media", "creators"],
208
+ command,
209
+ options,
210
+ positional
211
+ });
212
+ }
213
+
214
+ if (matches(command, ["marketing", "timing"])) {
215
+ return buildCohortEvidenceRequest({
216
+ workflow: "marketingTiming",
217
+ evidenceType: "marketingTiming",
218
+ sections: ["marketing"],
219
+ command,
220
+ options,
221
+ positional
222
+ });
223
+ }
224
+
225
+ if (matches(command, ["creators", "patterns"])) {
226
+ return buildCohortEvidenceRequest({
227
+ workflow: "creatorPatterns",
228
+ evidenceType: "creators",
229
+ sections: ["creators"],
230
+ command,
231
+ options,
232
+ positional
233
+ });
234
+ }
235
+
236
+ if (matches(command, ["cohort", "trends"])) {
237
+ return buildCohortEvidenceRequest({
238
+ workflow: "cohortTrends",
239
+ evidenceType: "trends",
240
+ sections: [],
241
+ command,
242
+ options,
243
+ positional
244
+ });
245
+ }
246
+
247
+ if (matches(command, ["cohort", "pricing"])) {
248
+ return buildCohortEvidenceRequest({
249
+ workflow: "cohortPricing",
250
+ evidenceType: "pricing",
251
+ sections: [],
252
+ command,
253
+ options,
254
+ positional
255
+ });
256
+ }
257
+
258
+ if (matches(command, ["cohort", "brief"])) {
259
+ return buildCohortEvidenceRequest({
260
+ workflow: "cohortBrief",
261
+ evidenceType: "brief",
262
+ sections: ["reviews", "marketing", "creators", "media", "showcases"],
263
+ command,
264
+ options,
265
+ positional
266
+ });
267
+ }
268
+
269
+ if (matches(command, ["cohort", "compare"])) {
270
+ return buildCohortEvidenceRequest({
271
+ workflow: "cohortCompare",
272
+ evidenceType: "trends",
273
+ sections: [],
274
+ command,
275
+ options,
276
+ positional
277
+ });
278
+ }
279
+
280
+ if (matches(command, ["showcases", "search"])) {
281
+ const deadlineBefore = options["deadline-before"];
282
+ if (deadlineBefore != null) assertDate(deadlineBefore, "--deadline-before");
283
+ return {
284
+ path: "/api/v1/showcases",
285
+ query: buildShowcaseSearchQuery(options),
286
+ transform: deadlineBefore
287
+ ? (payload) => filterShowcasesByDeadline(payload, String(deadlineBefore))
288
+ : undefined
289
+ };
290
+ }
291
+
292
+ if (matches(command, ["showcases", "patterns"])) {
293
+ return buildCohortEvidenceRequest({
294
+ workflow: "showcasePatterns",
295
+ evidenceType: "showcases",
296
+ sections: ["showcases"],
297
+ command,
298
+ options,
299
+ positional
300
+ });
301
+ }
302
+
303
+ if (matches(command, ["showcases", "fit"])) {
304
+ assertSource(options.source);
305
+ const externalId = positional[0];
306
+ assertExternalId(externalId, "gmc showcases fit --source steam <externalId> --json");
307
+ const deadlineBefore = options["deadline-before"];
308
+ if (deadlineBefore != null) assertDate(deadlineBefore, "--deadline-before");
309
+ return {
310
+ kind: "showcaseFit",
311
+ source: "steam",
312
+ externalId,
313
+ tagsOverride: options.tags ? parseCsvString(options.tags) : undefined,
314
+ deadlineBefore: deadlineBefore ? String(deadlineBefore) : undefined,
315
+ detailQuery: pickQuery(options, {
316
+ locale: "locale"
317
+ }),
318
+ showcaseQuery: {
319
+ status: "open",
320
+ sort: "deadline_asc",
321
+ limit: DEFAULT_SHOWCASE_FIT_LIMIT,
322
+ ...buildShowcaseSearchQuery(options)
323
+ }
324
+ };
325
+ }
326
+
327
+ return null;
328
+ }
329
+
330
+ function buildCohortEvidenceRequest({ workflow, evidenceType, sections, options }) {
331
+ assertSource(options.source);
332
+ const sampling = buildSamplingOptions(options, DEFAULT_COHORT_WORKFLOW_LIMIT);
333
+ const query = {
334
+ sort: "reviews_desc",
335
+ limit: sampling?.pageLimit ?? DEFAULT_COHORT_WORKFLOW_LIMIT,
336
+ ...buildGameSearchQuery(options)
337
+ };
338
+ if (sampling && options.limit == null) query.limit = sampling.pageLimit;
339
+ return {
340
+ kind: "cohortEvidence",
341
+ source: "steam",
342
+ workflow,
343
+ evidenceType,
344
+ sections,
345
+ query,
346
+ sampling,
347
+ searchRequest: {
348
+ path: "/api/v1/steam/games",
349
+ query
350
+ },
351
+ analysisQuery: sections.length
352
+ ? {
353
+ ...pickQuery(options, {
354
+ locale: "locale"
355
+ }),
356
+ sections: sections.join(",")
357
+ }
358
+ : undefined
359
+ };
360
+ }
361
+
362
+ export function createRequestUrl(baseUrl, request) {
363
+ const url = new URL(request.path, baseUrl);
364
+ for (const [key, value] of Object.entries(request.query)) {
365
+ if (value != null && value !== "") url.searchParams.set(key, String(value));
366
+ }
367
+ return url;
368
+ }
369
+
370
+ export function buildRequestHeaders(apiKey) {
371
+ return {
372
+ accept: "application/json",
373
+ ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {})
374
+ };
375
+ }
376
+
377
+ function pickQuery(options, mapping) {
378
+ const query = {};
379
+ for (const [optionName, queryName] of Object.entries(mapping)) {
380
+ if (options[optionName] != null) query[queryName] = options[optionName];
381
+ }
382
+ return query;
383
+ }
384
+
385
+ function buildGameSearchQuery(options) {
386
+ const query = pickQuery(options, {
387
+ query: "q",
388
+ tags: "tags",
389
+ "tag-mode": "tagMode",
390
+ "release-from": "releaseFrom",
391
+ "release-to": "releaseTo",
392
+ "reviews-min": "reviewsMin",
393
+ "reviews-max": "reviewsMax",
394
+ "rating-min": "ratingMin",
395
+ "price-min": "priceMin",
396
+ "price-max": "priceMax",
397
+ sort: "sort",
398
+ limit: "limit",
399
+ offset: "offset",
400
+ locale: "locale"
401
+ });
402
+
403
+ if (query.tags) query.tags = normalizeTagSlugCsv(query.tags);
404
+ return query;
405
+ }
406
+
407
+ function buildShowcaseSearchQuery(options) {
408
+ return pickQuery(options, {
409
+ query: "q",
410
+ tags: "tags",
411
+ "tag-mode": "tagMode",
412
+ status: "status",
413
+ format: "format",
414
+ country: "country",
415
+ sort: "sort",
416
+ limit: "limit",
417
+ offset: "offset",
418
+ locale: "locale"
419
+ });
420
+ }
421
+
422
+ function assertSource(source = "steam") {
423
+ if (source !== "steam") {
424
+ writeJsonError({
425
+ code: "CLI_USAGE_ERROR",
426
+ message: "Only --source steam is supported by the current API"
427
+ });
428
+ process.exit(EXIT_USAGE);
429
+ }
430
+ }
431
+
432
+ function assertDate(value, optionName) {
433
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value))) {
434
+ writeJsonError({
435
+ code: "CLI_USAGE_ERROR",
436
+ message: `${optionName} must use YYYY-MM-DD`
437
+ });
438
+ process.exit(EXIT_USAGE);
439
+ }
440
+ }
441
+
442
+ function assertPositiveIntegerOption(value, optionName) {
443
+ const number = Number(value);
444
+ if (!Number.isInteger(number) || number < 1) {
445
+ writeJsonError({
446
+ code: "CLI_USAGE_ERROR",
447
+ message: `${optionName} must be a positive integer`
448
+ });
449
+ process.exit(EXIT_USAGE);
450
+ }
451
+ return number;
452
+ }
453
+
454
+ function buildSamplingOptions(options, defaultLimit) {
455
+ const sampleSize = options["sample-size"];
456
+ const samplePages = options["sample-pages"];
457
+ if (sampleSize != null && samplePages != null) {
458
+ writeJsonError({
459
+ code: "CLI_USAGE_ERROR",
460
+ message: "Use either --sample-size or --sample-pages, not both"
461
+ });
462
+ process.exit(EXIT_USAGE);
463
+ }
464
+
465
+ if (sampleSize == null && samplePages == null) return undefined;
466
+
467
+ const pageLimit = assertPositiveIntegerOption(
468
+ options.limit ?? (sampleSize == null ? defaultLimit : Math.min(Number(sampleSize), 100)),
469
+ "--limit"
470
+ );
471
+ if (pageLimit > 100) {
472
+ writeJsonError({
473
+ code: "CLI_USAGE_ERROR",
474
+ message: "--limit must be 100 or less for sampled workflows"
475
+ });
476
+ process.exit(EXIT_USAGE);
477
+ }
478
+
479
+ if (sampleSize != null) {
480
+ const requestedSize = assertBoundedPositiveIntegerOption(sampleSize, "--sample-size", MAX_SAMPLE_SIZE);
481
+ return {
482
+ mode: "sample-size",
483
+ requestedSize,
484
+ pageLimit
485
+ };
486
+ }
487
+
488
+ const requestedPages = assertBoundedPositiveIntegerOption(samplePages, "--sample-pages", MAX_SAMPLE_PAGES);
489
+ return {
490
+ mode: "sample-pages",
491
+ requestedPages,
492
+ pageLimit
493
+ };
494
+ }
495
+
496
+ function assertBoundedPositiveIntegerOption(value, optionName, maxValue) {
497
+ const number = assertPositiveIntegerOption(value, optionName);
498
+ if (number > maxValue) {
499
+ writeJsonError({
500
+ code: "CLI_USAGE_ERROR",
501
+ message: `${optionName} must be ${maxValue} or less`
502
+ });
503
+ process.exit(EXIT_USAGE);
504
+ }
505
+ return number;
506
+ }
507
+
508
+ function assertReviewPatternSentiment(value) {
509
+ const sentiment = String(value);
510
+ if (!["positive", "negative", "mixed"].includes(sentiment)) {
511
+ writeJsonError({
512
+ code: "CLI_USAGE_ERROR",
513
+ message: "--sentiment must be one of: positive, negative, mixed"
514
+ });
515
+ process.exit(EXIT_USAGE);
516
+ }
517
+ return sentiment;
518
+ }
519
+
520
+ function assertExternalId(value, usage) {
521
+ if (!value) {
522
+ writeJsonError({
523
+ code: "CLI_USAGE_ERROR",
524
+ message: `Missing externalId. Usage: ${usage}`
525
+ });
526
+ process.exit(EXIT_USAGE);
527
+ }
528
+ }
529
+
530
+ function assertResolveQuery(value) {
531
+ if (value == null || String(value).trim() === "") {
532
+ writeJsonError({
533
+ code: "CLI_USAGE_ERROR",
534
+ message: "Missing query. Usage: gmc games resolve --source steam --query <title> --json"
535
+ });
536
+ process.exit(EXIT_USAGE);
537
+ }
538
+ }
539
+
540
+ function assertCompareExternalIds(values) {
541
+ if (values.length < 2) {
542
+ writeJsonError({
543
+ code: "CLI_USAGE_ERROR",
544
+ message: "Missing comparison targets. Usage: gmc games compare --source steam <externalId> <externalId...> --json"
545
+ });
546
+ process.exit(EXIT_USAGE);
547
+ }
548
+ if (values.length > 5) {
549
+ writeJsonError({
550
+ code: "CLI_USAGE_ERROR",
551
+ message: "gmc games compare accepts at most 5 external IDs"
552
+ });
553
+ process.exit(EXIT_USAGE);
554
+ }
555
+ }
556
+
557
+ function assertCompareFocus(value) {
558
+ if (!COMPARE_FOCUSES.includes(value)) {
559
+ writeJsonError({
560
+ code: "CLI_USAGE_ERROR",
561
+ message: `--focus must be one of: ${COMPARE_FOCUSES.join(", ")}`
562
+ });
563
+ process.exit(EXIT_USAGE);
564
+ }
565
+ return value;
566
+ }
567
+
568
+ function parseCsvString(value) {
569
+ return String(value)
570
+ .split(",")
571
+ .map((part) => part.trim())
572
+ .filter(Boolean);
573
+ }
574
+
575
+ function normalizeTagSlugCsv(value) {
576
+ return parseCsvString(value)
577
+ .map((tag) => tag
578
+ .toLowerCase()
579
+ .replace(/['’]/g, "")
580
+ .replace(/&/g, " and ")
581
+ .replace(/[^a-z0-9]+/g, "_")
582
+ .replace(/^_+|_+$/g, ""))
583
+ .filter(Boolean)
584
+ .join(",");
585
+ }
586
+
587
+ export function filterShowcasesByDeadline(payload, deadlineBefore) {
588
+ if (!payload || !Array.isArray(payload.data)) return payload;
589
+ return {
590
+ ...payload,
591
+ data: payload.data.filter((showcase) => {
592
+ if (!showcase || typeof showcase !== "object" || !showcase.deadline) return false;
593
+ return String(showcase.deadline) <= deadlineBefore;
594
+ }),
595
+ warnings: [
596
+ ...(payload.warnings ?? []),
597
+ "deadline-before was applied client-side by the gmc CLI"
598
+ ]
599
+ };
600
+ }