@witchpot/gmc 0.1.9 → 0.2.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/CHANGELOG.md +9 -0
- package/package.json +1 -1
- package/src/args.js +4 -0
- package/src/constants.js +4 -1
- package/src/help.js +5 -0
- package/src/requests.js +189 -7
- package/src/requests.test.js +58 -0
- package/src/workflows.js +52 -9
- package/src/workflows.test.js +56 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,15 @@ All notable changes to the Game Market Copilot CLI will be recorded here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.2.0 - 2026-07-04
|
|
8
|
+
|
|
9
|
+
- Added workspace game list commands for creating, listing, resolving, and
|
|
10
|
+
mutating saved cohorts from the CLI.
|
|
11
|
+
- Added saved game lists as a cohort source for agent workflows and evidence
|
|
12
|
+
packs, including manual-list item notes.
|
|
13
|
+
- Updated packaged schema/help output to include the new list routes and
|
|
14
|
+
commands.
|
|
15
|
+
|
|
7
16
|
## 0.1.9 - 2026-07-03
|
|
8
17
|
|
|
9
18
|
- Clarified packaged Agent Skill docs so Claude Code uses `gmc skill install`
|
package/package.json
CHANGED
package/src/args.js
CHANGED
|
@@ -56,6 +56,7 @@ function isCommandToken(command, token) {
|
|
|
56
56
|
"creators",
|
|
57
57
|
"cohort",
|
|
58
58
|
"showcases",
|
|
59
|
+
"lists",
|
|
59
60
|
"auth",
|
|
60
61
|
"login",
|
|
61
62
|
"logout",
|
|
@@ -95,6 +96,9 @@ function isCommandToken(command, token) {
|
|
|
95
96
|
if (command[0] === "showcases") {
|
|
96
97
|
return ["search", "fit", "patterns"].includes(token);
|
|
97
98
|
}
|
|
99
|
+
if (command[0] === "lists") {
|
|
100
|
+
return ["create", "show", "games", "add", "remove", "delete"].includes(token);
|
|
101
|
+
}
|
|
98
102
|
return false;
|
|
99
103
|
}
|
|
100
104
|
|
package/src/constants.js
CHANGED
|
@@ -8,7 +8,7 @@ export const EXIT_API = 4;
|
|
|
8
8
|
|
|
9
9
|
export const CLI_PACKAGE_NAME = "@witchpot/gmc";
|
|
10
10
|
export const CLI_BETA_PACKAGE_NAME = "@witchpot/gmc";
|
|
11
|
-
export const CLI_VERSION = "0.
|
|
11
|
+
export const CLI_VERSION = "0.2.0";
|
|
12
12
|
|
|
13
13
|
export const DEFAULT_API_BASE_URL = "https://gamemarketcopilot.com";
|
|
14
14
|
export const DEFAULT_CONFIG_PATH = path.join(homedir(), ".config", "gmc", "config.json");
|
|
@@ -33,6 +33,8 @@ export const SUPPORTED_BETA_COMMANDS = [
|
|
|
33
33
|
"gmc games topics --source steam <externalId> --json",
|
|
34
34
|
"gmc games compare --source steam <externalId> <externalId...> --focus market --json",
|
|
35
35
|
"gmc games benchmark --source steam --tags <tag_slug[,tag_slug...]> --reviews-min <number> --json",
|
|
36
|
+
"gmc lists --json",
|
|
37
|
+
"gmc lists games <id> --json",
|
|
36
38
|
"gmc reviews patterns --source steam --tags <tag_slug[,tag_slug...]> --reviews-min <number> --json",
|
|
37
39
|
"gmc showcases search --query <text> --json",
|
|
38
40
|
"gmc showcases fit --source steam <externalId> --deadline-before <YYYY-MM-DD> --json"
|
|
@@ -47,6 +49,7 @@ export const PROVISIONAL_BETA_COMMANDS = [
|
|
|
47
49
|
"gmc cohort pricing --source steam --tags <tag_slug[,tag_slug...]> --json",
|
|
48
50
|
"gmc cohort compare --source steam --tags <tag_slug[,tag_slug...]> --json",
|
|
49
51
|
"gmc cohort brief --source steam --tags <tag_slug[,tag_slug...]> --json",
|
|
52
|
+
"gmc cohort brief --source steam --list <id> --json",
|
|
50
53
|
"gmc showcases patterns --source steam --tags <tag_slug[,tag_slug...]> --json"
|
|
51
54
|
];
|
|
52
55
|
|
package/src/help.js
CHANGED
|
@@ -25,6 +25,10 @@ Usage:
|
|
|
25
25
|
gmc skill print --format agents-md
|
|
26
26
|
gmc skill status --json
|
|
27
27
|
gmc schema --json
|
|
28
|
+
gmc lists --json
|
|
29
|
+
gmc lists create --name <name> --kind manual --json
|
|
30
|
+
gmc lists games <id> --json
|
|
31
|
+
gmc lists add <id> --app-ids <appid[,appid...]> --json
|
|
28
32
|
gmc games search --source steam --query <text> --json
|
|
29
33
|
gmc games count --source steam --release-year <year> --reviews-gt <number> --json
|
|
30
34
|
gmc games aggregate --source steam --metric count,median_reviews --group-by release_year --json
|
|
@@ -47,6 +51,7 @@ Usage:
|
|
|
47
51
|
gmc cohort pricing --source steam --tags <tag_slug[,tag_slug...]> --json
|
|
48
52
|
gmc cohort compare --source steam --tags <tag_slug[,tag_slug...]> --json
|
|
49
53
|
gmc cohort brief --source steam --tags <tag_slug[,tag_slug...]> --json
|
|
54
|
+
gmc cohort brief --source steam --list <id> --json
|
|
50
55
|
gmc showcases search --query <text> --json
|
|
51
56
|
gmc showcases fit --source steam <externalId> --deadline-before <YYYY-MM-DD> --json
|
|
52
57
|
gmc showcases patterns --source steam --tags <tag_slug[,tag_slug...]> --json
|
package/src/requests.js
CHANGED
|
@@ -37,6 +37,80 @@ export function buildRequest(command, options, positional) {
|
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
if (matches(command, ["lists"])) {
|
|
41
|
+
return {
|
|
42
|
+
path: "/api/v1/lists",
|
|
43
|
+
query: {}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (matches(command, ["lists", "create"])) {
|
|
48
|
+
const kind = assertListKind(options.kind ?? "manual");
|
|
49
|
+
return {
|
|
50
|
+
path: "/api/v1/lists",
|
|
51
|
+
method: "POST",
|
|
52
|
+
query: {},
|
|
53
|
+
body: {
|
|
54
|
+
name: assertRequiredOption(options.name, "--name"),
|
|
55
|
+
kind,
|
|
56
|
+
...(options.description ? { description: options.description } : {}),
|
|
57
|
+
...(kind === "filter" ? { filter: parseJsonOption(options.filter, "--filter") } : {}),
|
|
58
|
+
...(options.sort ? { sort: parseJsonOption(options.sort, "--sort") } : {})
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (matches(command, ["lists", "show"])) {
|
|
64
|
+
const id = assertListId(positional[0], "gmc lists show <id> --json");
|
|
65
|
+
return {
|
|
66
|
+
path: `/api/v1/lists/${encodeURIComponent(id)}`,
|
|
67
|
+
query: {}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (matches(command, ["lists", "games"])) {
|
|
72
|
+
const id = assertListId(positional[0], "gmc lists games <id> --json");
|
|
73
|
+
return {
|
|
74
|
+
path: `/api/v1/lists/${encodeURIComponent(id)}/games`,
|
|
75
|
+
query: pickQuery(options, {
|
|
76
|
+
limit: "limit",
|
|
77
|
+
offset: "offset"
|
|
78
|
+
})
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (matches(command, ["lists", "add"])) {
|
|
83
|
+
const id = assertListId(positional[0], "gmc lists add <id> --app-ids <ids> --json");
|
|
84
|
+
return {
|
|
85
|
+
path: `/api/v1/lists/${encodeURIComponent(id)}/items`,
|
|
86
|
+
method: "POST",
|
|
87
|
+
query: {},
|
|
88
|
+
body: {
|
|
89
|
+
appids: parseAppIdsOption(options["app-ids"] ?? options.appids),
|
|
90
|
+
...(options.note ? { note: options.note } : {})
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (matches(command, ["lists", "remove"])) {
|
|
96
|
+
const id = assertListId(positional[0], "gmc lists remove <id> --app-ids <id> --json");
|
|
97
|
+
const [appid] = parseAppIdsOption(options["app-ids"] ?? options.appids);
|
|
98
|
+
return {
|
|
99
|
+
path: `/api/v1/lists/${encodeURIComponent(id)}/items/${encodeURIComponent(String(appid))}`,
|
|
100
|
+
method: "DELETE",
|
|
101
|
+
query: {}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (matches(command, ["lists", "delete"])) {
|
|
106
|
+
const id = assertListId(positional[0], "gmc lists delete <id> --json");
|
|
107
|
+
return {
|
|
108
|
+
path: `/api/v1/lists/${encodeURIComponent(id)}`,
|
|
109
|
+
method: "DELETE",
|
|
110
|
+
query: {}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
40
114
|
if (matches(command, ["games", "search"])) {
|
|
41
115
|
assertSource(options.source);
|
|
42
116
|
return {
|
|
@@ -199,11 +273,20 @@ export function buildRequest(command, options, positional) {
|
|
|
199
273
|
);
|
|
200
274
|
const sentiment = options.sentiment == null ? undefined : assertReviewPatternSentiment(options.sentiment);
|
|
201
275
|
const sampling = buildSamplingOptions(options, DEFAULT_REVIEW_PATTERNS_LIMIT);
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
276
|
+
const listId = options.list == null ? undefined : assertListId(options.list, "--list <id>");
|
|
277
|
+
const query = listId
|
|
278
|
+
? {
|
|
279
|
+
list: listId,
|
|
280
|
+
limit: sampling?.pageLimit ?? DEFAULT_REVIEW_PATTERNS_LIMIT,
|
|
281
|
+
...pickQuery(options, {
|
|
282
|
+
offset: "offset"
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
: {
|
|
286
|
+
sort: "reviews_desc",
|
|
287
|
+
limit: sampling?.pageLimit ?? DEFAULT_REVIEW_PATTERNS_LIMIT,
|
|
288
|
+
...buildGameSearchQuery(options, { includeTotal: false })
|
|
289
|
+
};
|
|
207
290
|
if (sampling && options.limit == null) query.limit = sampling.pageLimit;
|
|
208
291
|
|
|
209
292
|
return {
|
|
@@ -211,14 +294,14 @@ export function buildRequest(command, options, positional) {
|
|
|
211
294
|
source: "steam",
|
|
212
295
|
query,
|
|
213
296
|
sampling,
|
|
214
|
-
countRequest: buildOptionalGameCountRequest(options),
|
|
297
|
+
countRequest: listId ? undefined : buildOptionalGameCountRequest(options),
|
|
215
298
|
minGames,
|
|
216
299
|
maxTopicsPerGame,
|
|
217
300
|
sentiment,
|
|
218
301
|
topic: options.topic,
|
|
219
302
|
compareBy: isCompare ? String(options.segment ?? "price") : undefined,
|
|
220
303
|
searchRequest: {
|
|
221
|
-
path: "/api/v1/steam/games",
|
|
304
|
+
path: listId ? `/api/v1/lists/${encodeURIComponent(listId)}/games` : "/api/v1/steam/games",
|
|
222
305
|
query
|
|
223
306
|
},
|
|
224
307
|
topicQuery: {
|
|
@@ -360,6 +443,38 @@ export function buildRequest(command, options, positional) {
|
|
|
360
443
|
function buildCohortEvidenceRequest({ workflow, evidenceType, sections, options }) {
|
|
361
444
|
assertSource(options.source);
|
|
362
445
|
const sampling = buildSamplingOptions(options, DEFAULT_COHORT_WORKFLOW_LIMIT);
|
|
446
|
+
if (options.list != null) {
|
|
447
|
+
const listId = assertListId(options.list, "--list <id>");
|
|
448
|
+
const query = {
|
|
449
|
+
list: listId,
|
|
450
|
+
limit: sampling?.pageLimit ?? DEFAULT_COHORT_WORKFLOW_LIMIT,
|
|
451
|
+
...pickQuery(options, {
|
|
452
|
+
offset: "offset"
|
|
453
|
+
})
|
|
454
|
+
};
|
|
455
|
+
if (sampling && options.limit == null) query.limit = sampling.pageLimit;
|
|
456
|
+
return {
|
|
457
|
+
kind: "cohortEvidence",
|
|
458
|
+
source: "steam",
|
|
459
|
+
workflow,
|
|
460
|
+
evidenceType,
|
|
461
|
+
sections,
|
|
462
|
+
query,
|
|
463
|
+
sampling,
|
|
464
|
+
searchRequest: {
|
|
465
|
+
path: `/api/v1/lists/${encodeURIComponent(listId)}/games`,
|
|
466
|
+
query
|
|
467
|
+
},
|
|
468
|
+
analysisQuery: sections.length
|
|
469
|
+
? {
|
|
470
|
+
...pickQuery(options, {
|
|
471
|
+
locale: "locale"
|
|
472
|
+
}),
|
|
473
|
+
sections: sections.join(",")
|
|
474
|
+
}
|
|
475
|
+
: undefined
|
|
476
|
+
};
|
|
477
|
+
}
|
|
363
478
|
const query = {
|
|
364
479
|
sort: "reviews_desc",
|
|
365
480
|
limit: sampling?.pageLimit ?? DEFAULT_COHORT_WORKFLOW_LIMIT,
|
|
@@ -658,6 +773,73 @@ function assertResolveQuery(value) {
|
|
|
658
773
|
}
|
|
659
774
|
}
|
|
660
775
|
|
|
776
|
+
function assertRequiredOption(value, optionName) {
|
|
777
|
+
if (value == null || String(value).trim() === "") {
|
|
778
|
+
writeJsonError({
|
|
779
|
+
code: "CLI_USAGE_ERROR",
|
|
780
|
+
message: `Missing required option ${optionName}`
|
|
781
|
+
});
|
|
782
|
+
process.exit(EXIT_USAGE);
|
|
783
|
+
}
|
|
784
|
+
return String(value);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function assertListKind(value) {
|
|
788
|
+
if (value !== "manual" && value !== "filter") {
|
|
789
|
+
writeJsonError({
|
|
790
|
+
code: "CLI_USAGE_ERROR",
|
|
791
|
+
message: "--kind must be manual or filter"
|
|
792
|
+
});
|
|
793
|
+
process.exit(EXIT_USAGE);
|
|
794
|
+
}
|
|
795
|
+
return value;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function assertListId(value, usage) {
|
|
799
|
+
if (value == null || String(value).trim() === "") {
|
|
800
|
+
writeJsonError({
|
|
801
|
+
code: "CLI_USAGE_ERROR",
|
|
802
|
+
message: `Missing list id. Usage: ${usage}`
|
|
803
|
+
});
|
|
804
|
+
process.exit(EXIT_USAGE);
|
|
805
|
+
}
|
|
806
|
+
return String(value);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function parseJsonOption(value, optionName) {
|
|
810
|
+
if (value == null) {
|
|
811
|
+
writeJsonError({
|
|
812
|
+
code: "CLI_USAGE_ERROR",
|
|
813
|
+
message: `${optionName} is required`
|
|
814
|
+
});
|
|
815
|
+
process.exit(EXIT_USAGE);
|
|
816
|
+
}
|
|
817
|
+
try {
|
|
818
|
+
const parsed = JSON.parse(String(value));
|
|
819
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("expected object");
|
|
820
|
+
return parsed;
|
|
821
|
+
} catch {
|
|
822
|
+
writeJsonError({
|
|
823
|
+
code: "CLI_USAGE_ERROR",
|
|
824
|
+
message: `${optionName} must be a JSON object`
|
|
825
|
+
});
|
|
826
|
+
process.exit(EXIT_USAGE);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function parseAppIdsOption(value) {
|
|
831
|
+
const appids = parseCsvString(assertRequiredOption(value, "--app-ids"))
|
|
832
|
+
.map((item) => Number(item));
|
|
833
|
+
if (appids.length === 0 || appids.some((appid) => !Number.isSafeInteger(appid) || appid <= 0)) {
|
|
834
|
+
writeJsonError({
|
|
835
|
+
code: "CLI_USAGE_ERROR",
|
|
836
|
+
message: "--app-ids must be a comma-separated list of positive integers"
|
|
837
|
+
});
|
|
838
|
+
process.exit(EXIT_USAGE);
|
|
839
|
+
}
|
|
840
|
+
return appids;
|
|
841
|
+
}
|
|
842
|
+
|
|
661
843
|
function assertCompareExternalIds(values) {
|
|
662
844
|
if (values.length < 2) {
|
|
663
845
|
writeJsonError({
|
package/src/requests.test.js
CHANGED
|
@@ -119,6 +119,64 @@ test("reviews patterns forwards coverage into the cohort search request", () =>
|
|
|
119
119
|
assert.equal(request.searchRequest.query.includeTotal, false);
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
+
test("lists create builds a POST request body", () => {
|
|
123
|
+
const request = buildRequestFromArgv([
|
|
124
|
+
"lists",
|
|
125
|
+
"create",
|
|
126
|
+
"--name",
|
|
127
|
+
"Competitors",
|
|
128
|
+
"--kind",
|
|
129
|
+
"manual",
|
|
130
|
+
"--json"
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
assert.equal(request.path, "/api/v1/lists");
|
|
134
|
+
assert.equal(request.method, "POST");
|
|
135
|
+
assert.deepEqual(request.body, {
|
|
136
|
+
name: "Competitors",
|
|
137
|
+
kind: "manual"
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("lists add maps comma-separated app ids to item mutation body", () => {
|
|
142
|
+
const request = buildRequestFromArgv([
|
|
143
|
+
"lists",
|
|
144
|
+
"add",
|
|
145
|
+
"list-1",
|
|
146
|
+
"--app-ids",
|
|
147
|
+
"730,570",
|
|
148
|
+
"--note",
|
|
149
|
+
"genre refs",
|
|
150
|
+
"--json"
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
assert.equal(request.path, "/api/v1/lists/list-1/items");
|
|
154
|
+
assert.equal(request.method, "POST");
|
|
155
|
+
assert.deepEqual(request.body, {
|
|
156
|
+
appids: [730, 570],
|
|
157
|
+
note: "genre refs"
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("cohort brief can use a saved game list as its cohort source", () => {
|
|
162
|
+
const request = buildRequestFromArgv([
|
|
163
|
+
"cohort",
|
|
164
|
+
"brief",
|
|
165
|
+
"--source",
|
|
166
|
+
"steam",
|
|
167
|
+
"--list",
|
|
168
|
+
"list-1",
|
|
169
|
+
"--json"
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
assert.equal(request.kind, "cohortEvidence");
|
|
173
|
+
assert.equal(request.searchRequest.path, "/api/v1/lists/list-1/games");
|
|
174
|
+
assert.deepEqual(request.searchRequest.query, {
|
|
175
|
+
list: "list-1",
|
|
176
|
+
limit: "20"
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
122
180
|
test("sampled workflows keep exact totals in a separate count request", () => {
|
|
123
181
|
const request = buildRequestFromArgv([
|
|
124
182
|
"games",
|
package/src/workflows.js
CHANGED
|
@@ -25,7 +25,11 @@ export async function executeRequestPlan(baseUrl, request, { apiKey }) {
|
|
|
25
25
|
return executeShowcaseFitRequest(baseUrl, request, { apiKey });
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
const response = await fetchJson(createRequestUrl(baseUrl, request), {
|
|
28
|
+
const response = await fetchJson(createRequestUrl(baseUrl, request), {
|
|
29
|
+
apiKey,
|
|
30
|
+
method: request.method,
|
|
31
|
+
body: request.body
|
|
32
|
+
});
|
|
29
33
|
return request.transform ? request.transform(response) : response;
|
|
30
34
|
}
|
|
31
35
|
|
|
@@ -216,12 +220,12 @@ async function executeShowcaseFitRequest(baseUrl, request, { apiKey }) {
|
|
|
216
220
|
async function executeSearchPages(baseUrl, searchRequest, sampling, { apiKey }) {
|
|
217
221
|
if (!sampling) {
|
|
218
222
|
return {
|
|
219
|
-
searches: [await fetchJson(createRequestUrl(baseUrl, searchRequest), { apiKey })],
|
|
223
|
+
searches: [normalizeSearchEnvelope(await fetchJson(createRequestUrl(baseUrl, searchRequest), { apiKey }))],
|
|
220
224
|
pageErrors: []
|
|
221
225
|
};
|
|
222
226
|
}
|
|
223
227
|
|
|
224
|
-
const firstSearch = await fetchJson(createRequestUrl(baseUrl, searchRequest), { apiKey });
|
|
228
|
+
const firstSearch = normalizeSearchEnvelope(await fetchJson(createRequestUrl(baseUrl, searchRequest), { apiKey }));
|
|
225
229
|
const searches = [firstSearch];
|
|
226
230
|
const pageErrors = [];
|
|
227
231
|
let nextOffset = firstSearch?.pagination?.nextOffset;
|
|
@@ -244,8 +248,9 @@ async function executeSearchPages(baseUrl, searchRequest, sampling, { apiKey })
|
|
|
244
248
|
break;
|
|
245
249
|
}
|
|
246
250
|
|
|
247
|
-
|
|
248
|
-
|
|
251
|
+
const normalizedPage = normalizeSearchEnvelope(pageResult.payload);
|
|
252
|
+
searches.push(normalizedPage);
|
|
253
|
+
nextOffset = normalizedPage?.pagination?.nextOffset;
|
|
249
254
|
}
|
|
250
255
|
|
|
251
256
|
return {
|
|
@@ -254,6 +259,18 @@ async function executeSearchPages(baseUrl, searchRequest, sampling, { apiKey })
|
|
|
254
259
|
};
|
|
255
260
|
}
|
|
256
261
|
|
|
262
|
+
function normalizeSearchEnvelope(payload) {
|
|
263
|
+
if (Array.isArray(payload?.data?.games)) {
|
|
264
|
+
return {
|
|
265
|
+
...payload,
|
|
266
|
+
data: payload.data.games,
|
|
267
|
+
list: payload.data.list,
|
|
268
|
+
notes: payload.data.notes
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
return payload;
|
|
272
|
+
}
|
|
273
|
+
|
|
257
274
|
async function fetchOptionalCount(baseUrl, countRequest, { apiKey }) {
|
|
258
275
|
if (!countRequest) return undefined;
|
|
259
276
|
return fetchJson(createRequestUrl(baseUrl, countRequest), { apiKey });
|
|
@@ -682,7 +699,8 @@ export function composeCohortEvidencePayload(request, result, options = {}) {
|
|
|
682
699
|
const searches = Array.isArray(result.searches) ? result.searches : [result.search];
|
|
683
700
|
const searchPageErrors = Array.isArray(result.searchPageErrors) ? result.searchPageErrors : [];
|
|
684
701
|
const analysisResults = Array.isArray(result.analysisResults) ? result.analysisResults : [];
|
|
685
|
-
const
|
|
702
|
+
const listNotes = collectListNotes(searches);
|
|
703
|
+
const entries = analysisResults.map((entry) => buildCohortEvidenceGame(request, entry, listNotes));
|
|
686
704
|
const included = entries.filter((entry) => !entry.skippedReason);
|
|
687
705
|
const skipped = entries
|
|
688
706
|
.filter((entry) => entry.skippedReason)
|
|
@@ -737,6 +755,7 @@ export function composeCohortEvidencePayload(request, result, options = {}) {
|
|
|
737
755
|
releaseDate: entry.releaseDate,
|
|
738
756
|
coverage: entry.coverage,
|
|
739
757
|
tags: entry.tags,
|
|
758
|
+
...(entry.listNote ? { listNote: entry.listNote } : {}),
|
|
740
759
|
patternEvidence: entry.patternEvidence
|
|
741
760
|
}
|
|
742
761
|
})),
|
|
@@ -760,6 +779,7 @@ export function composeCohortEvidencePayload(request, result, options = {}) {
|
|
|
760
779
|
releaseDate: entry.releaseDate,
|
|
761
780
|
coverage: entry.coverage,
|
|
762
781
|
tags: entry.tags,
|
|
782
|
+
...(entry.listNote ? { listNote: entry.listNote } : {}),
|
|
763
783
|
patternEvidence: entry.patternEvidence
|
|
764
784
|
})),
|
|
765
785
|
skipped
|
|
@@ -854,11 +874,16 @@ export function composeShowcaseFitPayload(request, result, options = {}) {
|
|
|
854
874
|
};
|
|
855
875
|
}
|
|
856
876
|
|
|
857
|
-
async function fetchJson(url, { apiKey }) {
|
|
877
|
+
async function fetchJson(url, { apiKey, method = "GET", body } = {}) {
|
|
858
878
|
let response;
|
|
859
879
|
try {
|
|
860
880
|
response = await fetch(url, {
|
|
861
|
-
|
|
881
|
+
method,
|
|
882
|
+
headers: {
|
|
883
|
+
...buildRequestHeaders(apiKey),
|
|
884
|
+
...(body == null ? {} : { "content-type": "application/json" })
|
|
885
|
+
},
|
|
886
|
+
...(body == null ? {} : { body: JSON.stringify(body) })
|
|
862
887
|
});
|
|
863
888
|
} catch (error) {
|
|
864
889
|
writeJsonError({
|
|
@@ -1357,7 +1382,7 @@ function buildReviewPatternSegments(games, segment) {
|
|
|
1357
1382
|
}));
|
|
1358
1383
|
}
|
|
1359
1384
|
|
|
1360
|
-
function buildCohortEvidenceGame(request, entry) {
|
|
1385
|
+
function buildCohortEvidenceGame(request, entry, listNotes = new Map()) {
|
|
1361
1386
|
const game = entry.game ?? {};
|
|
1362
1387
|
const externalId = asString(entry.externalId ?? game?.externalId ?? game?.appid);
|
|
1363
1388
|
const sections = getAnalysisSections(entry.analysis);
|
|
@@ -1378,6 +1403,7 @@ function buildCohortEvidenceGame(request, entry) {
|
|
|
1378
1403
|
coverage: game?.coverage,
|
|
1379
1404
|
tags: getRankedTagNames(game).slice(0, 8),
|
|
1380
1405
|
genres: game?.genres,
|
|
1406
|
+
listNote: lookupListNote(listNotes, game?.appid, externalId),
|
|
1381
1407
|
analysis: entry.analysis,
|
|
1382
1408
|
sections,
|
|
1383
1409
|
patternEvidence,
|
|
@@ -1386,6 +1412,23 @@ function buildCohortEvidenceGame(request, entry) {
|
|
|
1386
1412
|
};
|
|
1387
1413
|
}
|
|
1388
1414
|
|
|
1415
|
+
function collectListNotes(searches) {
|
|
1416
|
+
const notes = new Map();
|
|
1417
|
+
for (const search of searches) {
|
|
1418
|
+
if (!search?.notes || typeof search.notes !== "object") continue;
|
|
1419
|
+
for (const [appid, note] of Object.entries(search.notes)) {
|
|
1420
|
+
if (typeof note === "string" && note.trim()) notes.set(String(appid), note);
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
return notes;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function lookupListNote(notes, appid, externalId) {
|
|
1427
|
+
if (!notes?.size) return undefined;
|
|
1428
|
+
const appidKey = appid == null ? undefined : String(appid);
|
|
1429
|
+
return (appidKey ? notes.get(appidKey) : undefined) ?? (externalId ? notes.get(String(externalId)) : undefined);
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1389
1432
|
function buildCohortEvidenceSummary(request, entries, pagination) {
|
|
1390
1433
|
return {
|
|
1391
1434
|
sampleSize: entries.length,
|
package/src/workflows.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
|
|
4
|
-
import { composeBenchmarkPayload, composeCohortEvidencePayload } from "./workflows.js";
|
|
4
|
+
import { composeBenchmarkPayload, composeCohortEvidencePayload, executeRequestPlan } from "./workflows.js";
|
|
5
5
|
|
|
6
6
|
test("cohort evidence summarizes skipped games by reason", () => {
|
|
7
7
|
const request = {
|
|
@@ -105,3 +105,58 @@ test("benchmark attaches exact total only when a count response is supplied", ()
|
|
|
105
105
|
assert.equal(payload.pagination.total, 123);
|
|
106
106
|
assert.deepEqual(payload.meta.upstreamRequests.map((entry) => entry.kind), ["search", "count"]);
|
|
107
107
|
});
|
|
108
|
+
|
|
109
|
+
test("executeRequestPlan normalizes list game resolution for cohort workflows", async () => {
|
|
110
|
+
const originalFetch = globalThis.fetch;
|
|
111
|
+
const seenUrls = [];
|
|
112
|
+
globalThis.fetch = async (url) => {
|
|
113
|
+
seenUrls.push(String(url));
|
|
114
|
+
if (String(url).includes("/analysis")) {
|
|
115
|
+
return jsonResponse({
|
|
116
|
+
data: { appid: 730, sections: {} },
|
|
117
|
+
meta: { requestId: "analysis_req" }
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return jsonResponse({
|
|
121
|
+
data: {
|
|
122
|
+
list: { id: "list-1", name: "Competitors", kind: "manual" },
|
|
123
|
+
games: [{ appid: 730, externalId: "730", title: "Example", reviews: 100 }],
|
|
124
|
+
notes: { 730: "Positioning reference" }
|
|
125
|
+
},
|
|
126
|
+
pagination: { limit: 20, offset: 0, total: 1, nextOffset: null },
|
|
127
|
+
meta: { requestId: "list_req", source: "static-fixture" }
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const payload = await executeRequestPlan("https://gmc.test", {
|
|
133
|
+
kind: "cohortEvidence",
|
|
134
|
+
source: "steam",
|
|
135
|
+
workflow: "cohortTrends",
|
|
136
|
+
evidenceType: "trends",
|
|
137
|
+
sections: [],
|
|
138
|
+
query: { list: "list-1", limit: "20" },
|
|
139
|
+
searchRequest: {
|
|
140
|
+
path: "/api/v1/lists/list-1/games",
|
|
141
|
+
query: { list: "list-1", limit: "20" }
|
|
142
|
+
}
|
|
143
|
+
}, { apiKey: "gmck_test" });
|
|
144
|
+
|
|
145
|
+
assert.equal(payload.data.cohort.returnedGames, 1);
|
|
146
|
+
assert.equal(payload.data.cohort.included[0].externalId, "730");
|
|
147
|
+
assert.equal(payload.data.cohort.included[0].evidence.listNote, "Positioning reference");
|
|
148
|
+
assert.equal(payload.data.games[0].listNote, "Positioning reference");
|
|
149
|
+
assert.ok(seenUrls.some((url) => url.includes("/api/v1/lists/list-1/games")));
|
|
150
|
+
} finally {
|
|
151
|
+
globalThis.fetch = originalFetch;
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
function jsonResponse(payload, status = 200) {
|
|
156
|
+
return {
|
|
157
|
+
ok: status >= 200 && status < 300,
|
|
158
|
+
status,
|
|
159
|
+
headers: new Headers({ "content-type": "application/json" }),
|
|
160
|
+
json: async () => payload
|
|
161
|
+
};
|
|
162
|
+
}
|