pabal-resource-mcp 1.4.8
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 +74 -0
- package/dist/bin/mcp-server.d.ts +1 -0
- package/dist/bin/mcp-server.js +3994 -0
- package/dist/browser.d.ts +15 -0
- package/dist/browser.js +60 -0
- package/dist/chunk-4G7O7LKL.js +1045 -0
- package/dist/chunk-A7KGUQPB.js +1058 -0
- package/dist/chunk-AM6RGDD4.js +1001 -0
- package/dist/chunk-BOWRBVVV.js +716 -0
- package/dist/chunk-DLCIXAUB.js +6 -0
- package/dist/chunk-FDI7WF45.js +1058 -0
- package/dist/chunk-FXCHLO7O.js +351 -0
- package/dist/chunk-MWXNTV3M.js +1001 -0
- package/dist/chunk-OCOFNMN2.js +1058 -0
- package/dist/chunk-W62HB2ZL.js +355 -0
- package/dist/chunk-WNIH5KWA.js +1028 -0
- package/dist/chunk-YJWGBO7W.js +952 -0
- package/dist/chunk-YPDLNPLX.js +1058 -0
- package/dist/chunk-ZDL4PKBE.js +1058 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.js +76 -0
- package/dist/locale-converter-B_NCFuS8.d.ts +798 -0
- package/package.json +76 -0
|
@@ -0,0 +1,3994 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_APP_SLUG
|
|
4
|
+
} from "../chunk-DLCIXAUB.js";
|
|
5
|
+
import {
|
|
6
|
+
getKeywordResearchDir,
|
|
7
|
+
getProductsDir,
|
|
8
|
+
getPublicDir,
|
|
9
|
+
getPullDataDir,
|
|
10
|
+
getPushDataDir,
|
|
11
|
+
loadAsoFromConfig,
|
|
12
|
+
saveAsoToAsoDir
|
|
13
|
+
} from "../chunk-W62HB2ZL.js";
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_LOCALE,
|
|
16
|
+
appStoreToUnified,
|
|
17
|
+
googlePlayToUnified,
|
|
18
|
+
isAppStoreLocale,
|
|
19
|
+
isAppStoreMultilingual,
|
|
20
|
+
isGooglePlayLocale,
|
|
21
|
+
isGooglePlayMultilingual,
|
|
22
|
+
unifiedToAppStore,
|
|
23
|
+
unifiedToGooglePlay
|
|
24
|
+
} from "../chunk-BOWRBVVV.js";
|
|
25
|
+
|
|
26
|
+
// src/bin/mcp-server.ts
|
|
27
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
28
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
29
|
+
import {
|
|
30
|
+
CallToolRequestSchema,
|
|
31
|
+
ListToolsRequestSchema
|
|
32
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
33
|
+
|
|
34
|
+
// src/tools/aso/pull.ts
|
|
35
|
+
import { z } from "zod";
|
|
36
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
37
|
+
|
|
38
|
+
// src/tools/aso/utils/pull/load-pull-data.util.ts
|
|
39
|
+
import fs from "fs";
|
|
40
|
+
import path from "path";
|
|
41
|
+
function loadPullData(slug) {
|
|
42
|
+
const asoData = {};
|
|
43
|
+
const pullDataDir = getPullDataDir();
|
|
44
|
+
const googlePlayPath = path.join(
|
|
45
|
+
pullDataDir,
|
|
46
|
+
"products",
|
|
47
|
+
slug,
|
|
48
|
+
"store",
|
|
49
|
+
"google-play",
|
|
50
|
+
"aso-data.json"
|
|
51
|
+
);
|
|
52
|
+
if (fs.existsSync(googlePlayPath)) {
|
|
53
|
+
try {
|
|
54
|
+
const content = fs.readFileSync(googlePlayPath, "utf-8");
|
|
55
|
+
const data = JSON.parse(content);
|
|
56
|
+
if (data.googlePlay) {
|
|
57
|
+
asoData.googlePlay = data.googlePlay;
|
|
58
|
+
}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw new Error(`Failed to read Google Play data: ${error}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const appStorePath = path.join(
|
|
64
|
+
pullDataDir,
|
|
65
|
+
"products",
|
|
66
|
+
slug,
|
|
67
|
+
"store",
|
|
68
|
+
"app-store",
|
|
69
|
+
"aso-data.json"
|
|
70
|
+
);
|
|
71
|
+
if (fs.existsSync(appStorePath)) {
|
|
72
|
+
try {
|
|
73
|
+
const content = fs.readFileSync(appStorePath, "utf-8");
|
|
74
|
+
const data = JSON.parse(content);
|
|
75
|
+
if (data.appStore) {
|
|
76
|
+
asoData.appStore = data.appStore;
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw new Error(`Failed to read App Store data: ${error}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return asoData;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/tools/aso/utils/pull/generate-conversion-prompt.util.ts
|
|
86
|
+
function generateConversionPrompt(title, shortDescription, fullDescription, locale, keywords, promotionalText, googlePlayData, appStoreData, screenshotPaths) {
|
|
87
|
+
const inputData = {
|
|
88
|
+
locale
|
|
89
|
+
};
|
|
90
|
+
if (googlePlayData && appStoreData) {
|
|
91
|
+
inputData.mergedFrom = ["Google Play", "App Store"];
|
|
92
|
+
inputData.googlePlay = {
|
|
93
|
+
title: googlePlayData.title,
|
|
94
|
+
shortDescription: googlePlayData.shortDescription || null,
|
|
95
|
+
fullDescription: googlePlayData.fullDescription
|
|
96
|
+
};
|
|
97
|
+
inputData.appStore = {
|
|
98
|
+
name: appStoreData.name,
|
|
99
|
+
subtitle: appStoreData.subtitle || null,
|
|
100
|
+
description: appStoreData.description,
|
|
101
|
+
keywords: appStoreData.keywords || null,
|
|
102
|
+
promotionalText: appStoreData.promotionalText || null
|
|
103
|
+
};
|
|
104
|
+
} else if (googlePlayData) {
|
|
105
|
+
inputData.source = "Google Play";
|
|
106
|
+
inputData.title = googlePlayData.title;
|
|
107
|
+
inputData.shortDescription = googlePlayData.shortDescription || null;
|
|
108
|
+
inputData.fullDescription = googlePlayData.fullDescription;
|
|
109
|
+
} else if (appStoreData) {
|
|
110
|
+
inputData.source = "App Store";
|
|
111
|
+
inputData.title = appStoreData.name;
|
|
112
|
+
inputData.subtitle = appStoreData.subtitle || null;
|
|
113
|
+
inputData.fullDescription = appStoreData.description;
|
|
114
|
+
if (appStoreData.keywords) inputData.keywords = appStoreData.keywords;
|
|
115
|
+
if (appStoreData.promotionalText) inputData.promotionalText = appStoreData.promotionalText;
|
|
116
|
+
} else {
|
|
117
|
+
inputData.title = title;
|
|
118
|
+
inputData.shortDescription = shortDescription || null;
|
|
119
|
+
inputData.fullDescription = fullDescription;
|
|
120
|
+
if (keywords) inputData.keywords = keywords;
|
|
121
|
+
if (promotionalText) inputData.promotionalText = promotionalText;
|
|
122
|
+
}
|
|
123
|
+
const inputJson = JSON.stringify(inputData, null, 2);
|
|
124
|
+
const outputExample = {
|
|
125
|
+
aso: {
|
|
126
|
+
title: "App title",
|
|
127
|
+
subtitle: "Subtitle (App Store only, optional)",
|
|
128
|
+
shortDescription: "Short description (Google Play only, optional)",
|
|
129
|
+
keywords: ["keyword1", "keyword2"],
|
|
130
|
+
template: {
|
|
131
|
+
intro: "Brief app introduction paragraph (2-3 sentences)",
|
|
132
|
+
outro: "Closing paragraph and CTA (1-2 sentences)"
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
landing: {
|
|
136
|
+
screenshots: {
|
|
137
|
+
images: [
|
|
138
|
+
{ title: "Caption title", description: "Caption body" }
|
|
139
|
+
]
|
|
140
|
+
},
|
|
141
|
+
features: {
|
|
142
|
+
items: [{ title: "Feature title", body: "Feature description" }]
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const outputFormatJson = JSON.stringify(outputExample, null, 2);
|
|
147
|
+
const hasBothPlatforms = googlePlayData && appStoreData;
|
|
148
|
+
return `Please convert the following store ASO data into config.json format.
|
|
149
|
+
|
|
150
|
+
**Input Data:**
|
|
151
|
+
\`\`\`json
|
|
152
|
+
${inputJson}
|
|
153
|
+
\`\`\`
|
|
154
|
+
|
|
155
|
+
**Output Format:**
|
|
156
|
+
Convert according to the following JSON schema:
|
|
157
|
+
|
|
158
|
+
\`\`\`json
|
|
159
|
+
${outputFormatJson}
|
|
160
|
+
\`\`\`
|
|
161
|
+
|
|
162
|
+
**Conversion Rules:**
|
|
163
|
+
${hasBothPlatforms ? `
|
|
164
|
+
**IMPORTANT - Data Merge Strategy:**
|
|
165
|
+
This locale has data from BOTH Google Play and App Store. Use the following merge strategy:
|
|
166
|
+
- **Title/Name**: Prefer App Store name if available, otherwise use Google Play title
|
|
167
|
+
- **Subtitle/Short Description**: Use App Store subtitle if available, otherwise use Google Play shortDescription
|
|
168
|
+
- **Full Description**: Merge both descriptions intelligently:
|
|
169
|
+
- If descriptions are similar, use the more detailed version
|
|
170
|
+
- If descriptions are different, combine unique information from both
|
|
171
|
+
- Prioritize App Store description for intro/outro (usually more polished)
|
|
172
|
+
- Incorporate unique features from Google Play description
|
|
173
|
+
- **Keywords**: Use App Store keywords (Google Play doesn't have explicit keywords field)
|
|
174
|
+
- **Promotional Text**: Use App Store promotionalText if available
|
|
175
|
+
|
|
176
|
+
**Content Extraction:**
|
|
177
|
+
` : ""}1. Use the first paragraph(s) of fullDescription as template.intro (2-3 sentences).
|
|
178
|
+
2. Use the final paragraph as template.outro (1-2 sentences, keep CTA tone).
|
|
179
|
+
3. Extract the strongest bullets/sections from the body into landing.features.items (3-5 items). Each item should have title + 1-2 sentence body.
|
|
180
|
+
4. Extract any explicit bullet lists or section highlights into landing.screenshots.images captions (keep order; 3-5 entries). Each needs title + short description.
|
|
181
|
+
5. Convert comma-separated keywords string to array.
|
|
182
|
+
6. Include Contact & Support info only if present in the original text; otherwise omit.
|
|
183
|
+
|
|
184
|
+
**Translation Guidelines (if converting to non-English locales):**
|
|
185
|
+
- Keep proper nouns and technical terms in English when translating
|
|
186
|
+
- Keep terms like "Always-On Display", "E-Ink", "Android", brand names (e.g., "Boox", "Meebook"), and other technical/proper nouns in English
|
|
187
|
+
- Only translate descriptive text, not technical terminology or proper nouns
|
|
188
|
+
- Example: "Always-On Display" should remain "Always-On Display" in Korean, not translated to "\uD56D\uC0C1 \uCF1C\uC9C4 \uD654\uBA74"
|
|
189
|
+
|
|
190
|
+
${screenshotPaths ? `
|
|
191
|
+
**Screenshot Paths:**
|
|
192
|
+
Screenshots for this locale can be found at:
|
|
193
|
+
${screenshotPaths.googlePlay ? `- Google Play: ${screenshotPaths.googlePlay}` : ""}
|
|
194
|
+
${screenshotPaths.appStore ? `- App Store: ${screenshotPaths.appStore}` : ""}
|
|
195
|
+
|
|
196
|
+
Copy screenshots from these directories to public/products/[slug]/screenshots/ as needed.
|
|
197
|
+
` : ""}
|
|
198
|
+
**Important:** Return only valid JSON, without any additional explanation.`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/tools/aso/pull.ts
|
|
202
|
+
var toJsonSchema = zodToJsonSchema;
|
|
203
|
+
var asoToPublicInputSchema = z.object({
|
|
204
|
+
slug: z.string().describe("Product slug")
|
|
205
|
+
});
|
|
206
|
+
var jsonSchema = toJsonSchema(asoToPublicInputSchema, {
|
|
207
|
+
name: "AsoToPublicInput",
|
|
208
|
+
$refStrategy: "none"
|
|
209
|
+
});
|
|
210
|
+
var inputSchema = jsonSchema.definitions?.AsoToPublicInput || jsonSchema;
|
|
211
|
+
var asoToPublicTool = {
|
|
212
|
+
name: "aso-to-public",
|
|
213
|
+
description: `Converts ASO data from pullData to public/products/[slug]/ structure.
|
|
214
|
+
|
|
215
|
+
**IMPORTANT:** Always use 'search-app' tool first to resolve the exact slug before calling this tool. The user may provide an approximate name, bundleId, or packageName - search-app will find and return the correct slug. Never pass user input directly as slug.
|
|
216
|
+
|
|
217
|
+
This tool:
|
|
218
|
+
1. Loads ASO data from .aso/pullData/products/[slug]/store/ (path from ~/.config/pabal-mcp/config.json dataDir)
|
|
219
|
+
2. Generates per-locale conversion prompts to map fullDescription into structured locale JSON (template intro/outro + landing features/screenshots captions)
|
|
220
|
+
3. Next steps (manual): paste converted JSON into public/products/[slug]/locales/[locale].json and copy screenshots from .aso/pullData if needed
|
|
221
|
+
|
|
222
|
+
The conversion from unstructured to structured format is performed by Claude based on the conversion prompt.`,
|
|
223
|
+
inputSchema
|
|
224
|
+
};
|
|
225
|
+
async function handleAsoToPublic(input) {
|
|
226
|
+
const { slug } = input;
|
|
227
|
+
const asoData = loadPullData(slug);
|
|
228
|
+
if (!asoData.googlePlay && !asoData.appStore) {
|
|
229
|
+
throw new Error(`No ASO data found in pullData for ${slug}`);
|
|
230
|
+
}
|
|
231
|
+
const mergedDataByLocale = /* @__PURE__ */ new Map();
|
|
232
|
+
if (asoData.googlePlay) {
|
|
233
|
+
const googlePlayData = asoData.googlePlay;
|
|
234
|
+
const locales = isGooglePlayMultilingual(googlePlayData) ? googlePlayData.locales : {
|
|
235
|
+
[googlePlayData.defaultLanguage || DEFAULT_LOCALE]: googlePlayData
|
|
236
|
+
};
|
|
237
|
+
for (const [platformLocale, localeData] of Object.entries(locales)) {
|
|
238
|
+
const unifiedLocale = googlePlayToUnified(platformLocale);
|
|
239
|
+
if (!mergedDataByLocale.has(unifiedLocale)) {
|
|
240
|
+
mergedDataByLocale.set(unifiedLocale, { unifiedLocale });
|
|
241
|
+
}
|
|
242
|
+
const merged = mergedDataByLocale.get(unifiedLocale);
|
|
243
|
+
merged.googlePlay = {
|
|
244
|
+
title: localeData.title,
|
|
245
|
+
shortDescription: localeData.shortDescription,
|
|
246
|
+
fullDescription: localeData.fullDescription
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (asoData.appStore) {
|
|
251
|
+
const appStoreData = asoData.appStore;
|
|
252
|
+
const locales = isAppStoreMultilingual(appStoreData) ? appStoreData.locales : { [appStoreData.locale || DEFAULT_LOCALE]: appStoreData };
|
|
253
|
+
for (const [platformLocale, localeData] of Object.entries(locales)) {
|
|
254
|
+
const unifiedLocale = appStoreToUnified(platformLocale);
|
|
255
|
+
if (!mergedDataByLocale.has(unifiedLocale)) {
|
|
256
|
+
mergedDataByLocale.set(unifiedLocale, { unifiedLocale });
|
|
257
|
+
}
|
|
258
|
+
const merged = mergedDataByLocale.get(unifiedLocale);
|
|
259
|
+
merged.appStore = {
|
|
260
|
+
name: localeData.name,
|
|
261
|
+
subtitle: localeData.subtitle,
|
|
262
|
+
description: localeData.description,
|
|
263
|
+
keywords: localeData.keywords,
|
|
264
|
+
promotionalText: localeData.promotionalText
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const conversionPrompts = [];
|
|
269
|
+
const conversionTasks = [];
|
|
270
|
+
for (const [unifiedLocale, mergedData] of mergedDataByLocale.entries()) {
|
|
271
|
+
const sources = [];
|
|
272
|
+
if (mergedData.googlePlay) sources.push("Google Play");
|
|
273
|
+
if (mergedData.appStore) sources.push("App Store");
|
|
274
|
+
let screenshotPaths;
|
|
275
|
+
if (mergedData.googlePlay) {
|
|
276
|
+
const googlePlayLocale = Object.keys(
|
|
277
|
+
isGooglePlayMultilingual(asoData.googlePlay) ? asoData.googlePlay.locales : { [asoData.googlePlay.defaultLanguage || DEFAULT_LOCALE]: {} }
|
|
278
|
+
).find((loc) => googlePlayToUnified(loc) === unifiedLocale);
|
|
279
|
+
if (googlePlayLocale) {
|
|
280
|
+
if (!screenshotPaths) screenshotPaths = {};
|
|
281
|
+
const pullDataDir2 = getPullDataDir();
|
|
282
|
+
screenshotPaths.googlePlay = `${pullDataDir2}/products/${slug}/store/google-play/screenshots/${googlePlayLocale}/`;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (mergedData.appStore) {
|
|
286
|
+
const appStoreLocale = Object.keys(
|
|
287
|
+
isAppStoreMultilingual(asoData.appStore) ? asoData.appStore.locales : { [asoData.appStore.locale || DEFAULT_LOCALE]: {} }
|
|
288
|
+
).find((loc) => appStoreToUnified(loc) === unifiedLocale);
|
|
289
|
+
if (appStoreLocale) {
|
|
290
|
+
if (!screenshotPaths) screenshotPaths = {};
|
|
291
|
+
const pullDataDir2 = getPullDataDir();
|
|
292
|
+
screenshotPaths.appStore = `${pullDataDir2}/products/${slug}/store/app-store/screenshots/${appStoreLocale}/`;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const prompt = generateConversionPrompt(
|
|
296
|
+
mergedData.googlePlay?.title || mergedData.appStore?.name || "",
|
|
297
|
+
mergedData.googlePlay?.shortDescription || mergedData.appStore?.subtitle,
|
|
298
|
+
mergedData.googlePlay?.fullDescription || mergedData.appStore?.description || "",
|
|
299
|
+
unifiedLocale,
|
|
300
|
+
mergedData.appStore?.keywords,
|
|
301
|
+
mergedData.appStore?.promotionalText,
|
|
302
|
+
mergedData.googlePlay,
|
|
303
|
+
mergedData.appStore,
|
|
304
|
+
screenshotPaths
|
|
305
|
+
);
|
|
306
|
+
conversionTasks.push({
|
|
307
|
+
locale: unifiedLocale,
|
|
308
|
+
sources,
|
|
309
|
+
prompt
|
|
310
|
+
});
|
|
311
|
+
const sourcesText = sources.join(" + ");
|
|
312
|
+
conversionPrompts.push(
|
|
313
|
+
`
|
|
314
|
+
--- ${unifiedLocale} (${sourcesText}) ---
|
|
315
|
+
${prompt}`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
const pullDataDir = getPullDataDir();
|
|
319
|
+
let responseText = `Converting ASO data from pullData to public/products/${slug}/ structure.
|
|
320
|
+
|
|
321
|
+
`;
|
|
322
|
+
responseText += `Found ${conversionTasks.length} unified locale(s) to convert.
|
|
323
|
+
`;
|
|
324
|
+
responseText += `Data sources: Google Play (${asoData.googlePlay ? "\u2713" : "\u2717"}), App Store (${asoData.appStore ? "\u2713" : "\u2717"})
|
|
325
|
+
|
|
326
|
+
`;
|
|
327
|
+
responseText += "Please convert each locale's ASO data using the prompts below.\n";
|
|
328
|
+
responseText += "When both platforms have data for the same locale, they are merged into a single conversion.\n\n";
|
|
329
|
+
responseText += conversionPrompts.join("\n\n");
|
|
330
|
+
responseText += `
|
|
331
|
+
|
|
332
|
+
Next steps (manual):
|
|
333
|
+
`;
|
|
334
|
+
responseText += `1. Save converted JSON to public/products/${slug}/locales/[locale].json
|
|
335
|
+
`;
|
|
336
|
+
responseText += ` Example: public/products/${slug}/locales/ar.json (not ar-SA.json)
|
|
337
|
+
`;
|
|
338
|
+
responseText += `2. Copy screenshots from ${pullDataDir}/products/${slug}/store/ to public/products/${slug}/screenshots/
|
|
339
|
+
`;
|
|
340
|
+
return {
|
|
341
|
+
content: [
|
|
342
|
+
{
|
|
343
|
+
type: "text",
|
|
344
|
+
text: responseText
|
|
345
|
+
}
|
|
346
|
+
]
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// src/tools/aso/push.ts
|
|
351
|
+
import { z as z2 } from "zod";
|
|
352
|
+
import { zodToJsonSchema as zodToJsonSchema2 } from "zod-to-json-schema";
|
|
353
|
+
import path4 from "path";
|
|
354
|
+
|
|
355
|
+
// src/utils/aso-validation.util.ts
|
|
356
|
+
var FIELD_LIMITS_DOC_PATH = "docs/aso/ASO_FIELD_LIMITS.md";
|
|
357
|
+
var APP_STORE_LIMITS = {
|
|
358
|
+
name: 30,
|
|
359
|
+
subtitle: 30,
|
|
360
|
+
keywords: 100,
|
|
361
|
+
promotionalText: 170,
|
|
362
|
+
description: 4e3,
|
|
363
|
+
whatsNew: 4e3
|
|
364
|
+
};
|
|
365
|
+
var GOOGLE_PLAY_LIMITS = {
|
|
366
|
+
title: 50,
|
|
367
|
+
shortDescription: 80,
|
|
368
|
+
fullDescription: 4e3,
|
|
369
|
+
releaseNotes: 500
|
|
370
|
+
};
|
|
371
|
+
var INVALID_CHAR_REGEX = /[\u0000-\u0008\u000B-\u000C\u000E-\u001F\u007F-\u009F\uFEFF\u200B-\u200F\u202A-\u202E\u2060\uFE00-\uFE0F]/g;
|
|
372
|
+
function sanitizeText(value, fieldPath, warnings) {
|
|
373
|
+
if (typeof value !== "string") return value;
|
|
374
|
+
const cleaned = value.replace(INVALID_CHAR_REGEX, "");
|
|
375
|
+
if (cleaned !== value) {
|
|
376
|
+
warnings.push(`Removed invalid characters from ${fieldPath}`);
|
|
377
|
+
}
|
|
378
|
+
return cleaned;
|
|
379
|
+
}
|
|
380
|
+
function sanitizeAsoData(configData) {
|
|
381
|
+
const sanitizedData = JSON.parse(JSON.stringify(configData));
|
|
382
|
+
const warnings = [];
|
|
383
|
+
if (sanitizedData.appStore) {
|
|
384
|
+
const appStoreData = sanitizedData.appStore;
|
|
385
|
+
const locales = isAppStoreMultilingual(appStoreData) ? appStoreData.locales : { [appStoreData.locale || DEFAULT_LOCALE]: appStoreData };
|
|
386
|
+
for (const [locale, data] of Object.entries(locales)) {
|
|
387
|
+
data.name = sanitizeText(
|
|
388
|
+
data.name,
|
|
389
|
+
`App Store [${locale}].name`,
|
|
390
|
+
warnings
|
|
391
|
+
);
|
|
392
|
+
data.subtitle = sanitizeText(
|
|
393
|
+
data.subtitle,
|
|
394
|
+
`App Store [${locale}].subtitle`,
|
|
395
|
+
warnings
|
|
396
|
+
);
|
|
397
|
+
data.keywords = sanitizeText(
|
|
398
|
+
data.keywords,
|
|
399
|
+
`App Store [${locale}].keywords`,
|
|
400
|
+
warnings
|
|
401
|
+
);
|
|
402
|
+
data.promotionalText = sanitizeText(
|
|
403
|
+
data.promotionalText,
|
|
404
|
+
`App Store [${locale}].promotionalText`,
|
|
405
|
+
warnings
|
|
406
|
+
);
|
|
407
|
+
data.description = sanitizeText(
|
|
408
|
+
data.description,
|
|
409
|
+
`App Store [${locale}].description`,
|
|
410
|
+
warnings
|
|
411
|
+
);
|
|
412
|
+
data.whatsNew = sanitizeText(
|
|
413
|
+
data.whatsNew,
|
|
414
|
+
`App Store [${locale}].whatsNew`,
|
|
415
|
+
warnings
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (sanitizedData.googlePlay) {
|
|
420
|
+
const googlePlayData = sanitizedData.googlePlay;
|
|
421
|
+
const locales = isGooglePlayMultilingual(googlePlayData) ? googlePlayData.locales : {
|
|
422
|
+
[googlePlayData.defaultLanguage || DEFAULT_LOCALE]: googlePlayData
|
|
423
|
+
};
|
|
424
|
+
for (const [locale, data] of Object.entries(locales)) {
|
|
425
|
+
data.title = sanitizeText(
|
|
426
|
+
data.title,
|
|
427
|
+
`Google Play [${locale}].title`,
|
|
428
|
+
warnings
|
|
429
|
+
);
|
|
430
|
+
data.shortDescription = sanitizeText(
|
|
431
|
+
data.shortDescription,
|
|
432
|
+
`Google Play [${locale}].shortDescription`,
|
|
433
|
+
warnings
|
|
434
|
+
);
|
|
435
|
+
data.fullDescription = sanitizeText(
|
|
436
|
+
data.fullDescription,
|
|
437
|
+
`Google Play [${locale}].fullDescription`,
|
|
438
|
+
warnings
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return { sanitizedData, warnings };
|
|
443
|
+
}
|
|
444
|
+
function validateFieldLimits(configData) {
|
|
445
|
+
const issues = [];
|
|
446
|
+
if (configData.appStore) {
|
|
447
|
+
const appStoreData = configData.appStore;
|
|
448
|
+
const locales = isAppStoreMultilingual(appStoreData) ? appStoreData.locales : { [appStoreData.locale || DEFAULT_LOCALE]: appStoreData };
|
|
449
|
+
for (const [locale, data] of Object.entries(locales)) {
|
|
450
|
+
const checkField = (field, value) => {
|
|
451
|
+
if (typeof value === "string" && value.length > APP_STORE_LIMITS[field]) {
|
|
452
|
+
issues.push({
|
|
453
|
+
locale,
|
|
454
|
+
store: "appStore",
|
|
455
|
+
field,
|
|
456
|
+
currentLength: value.length,
|
|
457
|
+
limit: APP_STORE_LIMITS[field],
|
|
458
|
+
severity: "error"
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
checkField("name", data.name);
|
|
463
|
+
checkField("subtitle", data.subtitle);
|
|
464
|
+
checkField("keywords", data.keywords);
|
|
465
|
+
checkField("promotionalText", data.promotionalText);
|
|
466
|
+
checkField("description", data.description);
|
|
467
|
+
checkField("whatsNew", data.whatsNew);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (configData.googlePlay) {
|
|
471
|
+
const googlePlayData = configData.googlePlay;
|
|
472
|
+
const locales = isGooglePlayMultilingual(googlePlayData) ? googlePlayData.locales : {
|
|
473
|
+
[googlePlayData.defaultLanguage || DEFAULT_LOCALE]: googlePlayData
|
|
474
|
+
};
|
|
475
|
+
for (const [locale, data] of Object.entries(locales)) {
|
|
476
|
+
const checkField = (field, value) => {
|
|
477
|
+
if (typeof value === "string" && value.length > GOOGLE_PLAY_LIMITS[field]) {
|
|
478
|
+
issues.push({
|
|
479
|
+
locale,
|
|
480
|
+
store: "googlePlay",
|
|
481
|
+
field,
|
|
482
|
+
currentLength: value.length,
|
|
483
|
+
limit: GOOGLE_PLAY_LIMITS[field],
|
|
484
|
+
severity: "error"
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
checkField("title", data.title);
|
|
489
|
+
checkField("shortDescription", data.shortDescription);
|
|
490
|
+
checkField("fullDescription", data.fullDescription);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return issues;
|
|
494
|
+
}
|
|
495
|
+
function formatValidationIssues(issues) {
|
|
496
|
+
if (issues.length === 0) {
|
|
497
|
+
return `\u2705 All fields within limits (checked against ${FIELD_LIMITS_DOC_PATH})`;
|
|
498
|
+
}
|
|
499
|
+
const grouped = {};
|
|
500
|
+
for (const issue of issues) {
|
|
501
|
+
const key = `${issue.store} [${issue.locale}]`;
|
|
502
|
+
if (!grouped[key]) grouped[key] = [];
|
|
503
|
+
grouped[key].push(issue);
|
|
504
|
+
}
|
|
505
|
+
const lines = [
|
|
506
|
+
`\u26A0\uFE0F Field limit violations (see ${FIELD_LIMITS_DOC_PATH}):`
|
|
507
|
+
];
|
|
508
|
+
for (const [key, localeIssues] of Object.entries(grouped)) {
|
|
509
|
+
lines.push(`
|
|
510
|
+
${key}:`);
|
|
511
|
+
for (const issue of localeIssues) {
|
|
512
|
+
const over = issue.currentLength - issue.limit;
|
|
513
|
+
lines.push(
|
|
514
|
+
` - ${issue.field}: ${issue.currentLength}/${issue.limit} (${over} over)`
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return lines.join("\n");
|
|
519
|
+
}
|
|
520
|
+
function checkKeywordDuplicates(keywords) {
|
|
521
|
+
const keywordList = keywords.split(",").map((k) => k.trim().toLowerCase()).filter(Boolean);
|
|
522
|
+
const seen = /* @__PURE__ */ new Set();
|
|
523
|
+
const duplicates = [];
|
|
524
|
+
const uniqueKeywords = [];
|
|
525
|
+
for (const keyword of keywordList) {
|
|
526
|
+
if (seen.has(keyword)) {
|
|
527
|
+
if (!duplicates.includes(keyword)) {
|
|
528
|
+
duplicates.push(keyword);
|
|
529
|
+
}
|
|
530
|
+
} else {
|
|
531
|
+
seen.add(keyword);
|
|
532
|
+
uniqueKeywords.push(keyword);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return {
|
|
536
|
+
hasDuplicates: duplicates.length > 0,
|
|
537
|
+
duplicates,
|
|
538
|
+
uniqueKeywords
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
function validateKeywords(configData) {
|
|
542
|
+
const issues = [];
|
|
543
|
+
if (configData.appStore) {
|
|
544
|
+
const appStoreData = configData.appStore;
|
|
545
|
+
const locales = isAppStoreMultilingual(appStoreData) ? appStoreData.locales : { [appStoreData.locale || DEFAULT_LOCALE]: appStoreData };
|
|
546
|
+
for (const [locale, data] of Object.entries(locales)) {
|
|
547
|
+
if (data.keywords) {
|
|
548
|
+
const result = checkKeywordDuplicates(data.keywords);
|
|
549
|
+
if (result.hasDuplicates) {
|
|
550
|
+
issues.push({ locale, duplicates: result.duplicates });
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return issues;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// src/tools/aso/utils/push/prepare-aso-data-for-push.util.ts
|
|
559
|
+
function prepareAsoDataForPush(slug, configData) {
|
|
560
|
+
const storeData = {};
|
|
561
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://labs.quartz.best";
|
|
562
|
+
const detailPageUrl = `${baseUrl}/${slug}`;
|
|
563
|
+
if (configData.googlePlay) {
|
|
564
|
+
const googlePlayData = configData.googlePlay;
|
|
565
|
+
const locales = isGooglePlayMultilingual(googlePlayData) ? googlePlayData.locales : {
|
|
566
|
+
[googlePlayData.defaultLanguage || DEFAULT_LOCALE]: googlePlayData
|
|
567
|
+
};
|
|
568
|
+
const cleanedLocales = {};
|
|
569
|
+
for (const [locale, localeData] of Object.entries(locales)) {
|
|
570
|
+
const { screenshots, ...rest } = localeData;
|
|
571
|
+
cleanedLocales[locale] = {
|
|
572
|
+
...rest,
|
|
573
|
+
featureGraphic: void 0,
|
|
574
|
+
// Images excluded
|
|
575
|
+
contactWebsite: detailPageUrl
|
|
576
|
+
// Set detail page URL
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
const convertedLocales = {};
|
|
580
|
+
for (const [unifiedLocale, localeData] of Object.entries(cleanedLocales)) {
|
|
581
|
+
const googlePlayLocale = unifiedToGooglePlay(
|
|
582
|
+
unifiedLocale
|
|
583
|
+
);
|
|
584
|
+
if (googlePlayLocale !== null) {
|
|
585
|
+
convertedLocales[googlePlayLocale] = {
|
|
586
|
+
...localeData,
|
|
587
|
+
defaultLanguage: googlePlayLocale
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const googleDefaultLocale = isGooglePlayMultilingual(googlePlayData) ? googlePlayData.defaultLocale || DEFAULT_LOCALE : googlePlayData.defaultLanguage || DEFAULT_LOCALE;
|
|
592
|
+
const convertedDefaultLocale = unifiedToGooglePlay(googleDefaultLocale) || googleDefaultLocale;
|
|
593
|
+
storeData.googlePlay = {
|
|
594
|
+
locales: convertedLocales,
|
|
595
|
+
defaultLocale: convertedDefaultLocale
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
if (configData.appStore) {
|
|
599
|
+
const appStoreData = configData.appStore;
|
|
600
|
+
const locales = isAppStoreMultilingual(appStoreData) ? appStoreData.locales : { [appStoreData.locale || DEFAULT_LOCALE]: appStoreData };
|
|
601
|
+
const cleanedLocales = {};
|
|
602
|
+
for (const [locale, localeData] of Object.entries(locales)) {
|
|
603
|
+
const { screenshots, ...rest } = localeData;
|
|
604
|
+
cleanedLocales[locale] = {
|
|
605
|
+
...rest,
|
|
606
|
+
marketingUrl: detailPageUrl
|
|
607
|
+
// Set detail page URL
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
const convertedLocales = {};
|
|
611
|
+
for (const [unifiedLocale, localeData] of Object.entries(cleanedLocales)) {
|
|
612
|
+
const appStoreLocale = unifiedToAppStore(unifiedLocale);
|
|
613
|
+
if (appStoreLocale !== null) {
|
|
614
|
+
convertedLocales[appStoreLocale] = {
|
|
615
|
+
...localeData,
|
|
616
|
+
locale: appStoreLocale
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
const appStoreDefaultLocale = isAppStoreMultilingual(appStoreData) ? appStoreData.defaultLocale || DEFAULT_LOCALE : appStoreData.locale || DEFAULT_LOCALE;
|
|
621
|
+
const convertedDefaultLocale = unifiedToAppStore(appStoreDefaultLocale) || appStoreDefaultLocale;
|
|
622
|
+
storeData.appStore = {
|
|
623
|
+
locales: convertedLocales,
|
|
624
|
+
defaultLocale: convertedDefaultLocale
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
return storeData;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// src/tools/aso/utils/push/save-raw-aso-data.util.ts
|
|
631
|
+
function saveRawAsoData(slug, asoData) {
|
|
632
|
+
const rootDir = getPushDataDir();
|
|
633
|
+
saveAsoToAsoDir(slug, asoData);
|
|
634
|
+
const localeCounts = {};
|
|
635
|
+
if (asoData.googlePlay) {
|
|
636
|
+
const googlePlayData = asoData.googlePlay;
|
|
637
|
+
const locales = isGooglePlayMultilingual(googlePlayData) ? googlePlayData.locales : { [googlePlayData.defaultLanguage || DEFAULT_LOCALE]: googlePlayData };
|
|
638
|
+
localeCounts.googlePlay = Object.keys(locales).length;
|
|
639
|
+
}
|
|
640
|
+
if (asoData.appStore) {
|
|
641
|
+
const appStoreData = asoData.appStore;
|
|
642
|
+
const locales = isAppStoreMultilingual(appStoreData) ? appStoreData.locales : { [appStoreData.locale || DEFAULT_LOCALE]: appStoreData };
|
|
643
|
+
localeCounts.appStore = Object.keys(locales).length;
|
|
644
|
+
}
|
|
645
|
+
if (localeCounts.googlePlay) {
|
|
646
|
+
console.log(
|
|
647
|
+
`\u{1F4BE} Google Play raw data saved to ${rootDir} (${localeCounts.googlePlay} locale${localeCounts.googlePlay > 1 ? "s" : ""})`
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
if (localeCounts.appStore) {
|
|
651
|
+
console.log(
|
|
652
|
+
`\u{1F4BE} App Store raw data saved to ${rootDir} (${localeCounts.appStore} locale${localeCounts.appStore > 1 ? "s" : ""})`
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// src/tools/aso/utils/push/download-image.util.ts
|
|
658
|
+
import fs2 from "fs";
|
|
659
|
+
import path2 from "path";
|
|
660
|
+
async function downloadImage(url, outputPath) {
|
|
661
|
+
try {
|
|
662
|
+
const response = await fetch(url);
|
|
663
|
+
if (!response.ok) {
|
|
664
|
+
throw new Error(
|
|
665
|
+
`Image download failed: ${response.status} ${response.statusText}`
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
669
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
670
|
+
const dir = path2.dirname(outputPath);
|
|
671
|
+
if (!fs2.existsSync(dir)) {
|
|
672
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
673
|
+
}
|
|
674
|
+
fs2.writeFileSync(outputPath, buffer);
|
|
675
|
+
} catch (error) {
|
|
676
|
+
console.error(`\u274C Image download failed (${url}):`, error);
|
|
677
|
+
throw error;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// src/tools/aso/utils/push/is-local-asset-path.util.ts
|
|
682
|
+
function isLocalAssetPath(assetPath) {
|
|
683
|
+
if (!assetPath) {
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
const trimmedPath = assetPath.trim();
|
|
687
|
+
return !/^([a-z]+:)?\/\//i.test(trimmedPath);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// src/tools/aso/utils/push/copy-local-asset-to-aso-dir.util.ts
|
|
691
|
+
import fs3 from "fs";
|
|
692
|
+
import path3 from "path";
|
|
693
|
+
function copyLocalAssetToAsoDir(assetPath, outputPath) {
|
|
694
|
+
const publicDir = getPublicDir();
|
|
695
|
+
const trimmedPath = assetPath.replace(/^\.\//, "").replace(/^public\//, "").replace(/^\/+/, "");
|
|
696
|
+
const sourcePath = path3.join(publicDir, trimmedPath);
|
|
697
|
+
if (!fs3.existsSync(sourcePath)) {
|
|
698
|
+
console.warn(`\u26A0\uFE0F Local image not found: ${sourcePath}`);
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
const dir = path3.dirname(outputPath);
|
|
702
|
+
if (!fs3.existsSync(dir)) {
|
|
703
|
+
fs3.mkdirSync(dir, { recursive: true });
|
|
704
|
+
}
|
|
705
|
+
fs3.copyFileSync(sourcePath, outputPath);
|
|
706
|
+
return true;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// src/tools/utils/shared/convert-to-multilingual.util.ts
|
|
710
|
+
function convertToMultilingual(data, locale) {
|
|
711
|
+
const detectedLocale = locale || data.locale || data.defaultLanguage || DEFAULT_LOCALE;
|
|
712
|
+
return {
|
|
713
|
+
locales: {
|
|
714
|
+
[detectedLocale]: data
|
|
715
|
+
},
|
|
716
|
+
defaultLocale: detectedLocale
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// src/tools/aso/push.ts
|
|
721
|
+
import fs4 from "fs";
|
|
722
|
+
var toJsonSchema2 = zodToJsonSchema2;
|
|
723
|
+
var publicToAsoInputSchema = z2.object({
|
|
724
|
+
slug: z2.string().describe("Product slug"),
|
|
725
|
+
dryRun: z2.boolean().optional().default(false).describe("Preview mode (no changes)")
|
|
726
|
+
});
|
|
727
|
+
var jsonSchema2 = toJsonSchema2(publicToAsoInputSchema, {
|
|
728
|
+
name: "PublicToAsoInput",
|
|
729
|
+
$refStrategy: "none"
|
|
730
|
+
});
|
|
731
|
+
var inputSchema2 = jsonSchema2.definitions?.PublicToAsoInput || jsonSchema2;
|
|
732
|
+
async function downloadScreenshotsToAsoDir(slug, asoData) {
|
|
733
|
+
const rootDir = getPushDataDir();
|
|
734
|
+
const productStoreRoot = path4.join(rootDir, "products", slug, "store");
|
|
735
|
+
if (asoData.googlePlay) {
|
|
736
|
+
let googlePlayData = asoData.googlePlay;
|
|
737
|
+
if (!isGooglePlayMultilingual(googlePlayData)) {
|
|
738
|
+
googlePlayData = convertToMultilingual(
|
|
739
|
+
googlePlayData,
|
|
740
|
+
googlePlayData.defaultLanguage
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
for (const unifiedLocale of Object.keys(googlePlayData.locales)) {
|
|
744
|
+
const googlePlayLocale = unifiedToGooglePlay(
|
|
745
|
+
unifiedLocale
|
|
746
|
+
);
|
|
747
|
+
if (!googlePlayLocale) {
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
const localeData = googlePlayData.locales[unifiedLocale];
|
|
751
|
+
const asoDir = path4.join(
|
|
752
|
+
productStoreRoot,
|
|
753
|
+
"google-play",
|
|
754
|
+
"screenshots",
|
|
755
|
+
googlePlayLocale
|
|
756
|
+
);
|
|
757
|
+
const phoneScreenshots = localeData.screenshots?.phone;
|
|
758
|
+
if (phoneScreenshots && phoneScreenshots.length > 0) {
|
|
759
|
+
for (let i = 0; i < phoneScreenshots.length; i++) {
|
|
760
|
+
const url = phoneScreenshots[i];
|
|
761
|
+
const filename = `phone-${i + 1}.png`;
|
|
762
|
+
const outputPath = path4.join(asoDir, filename);
|
|
763
|
+
if (isLocalAssetPath(url)) {
|
|
764
|
+
copyLocalAssetToAsoDir(url, outputPath);
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
await downloadImage(url, outputPath);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
const tablet7Screenshots = localeData.screenshots?.tablet7;
|
|
771
|
+
if (tablet7Screenshots && tablet7Screenshots.length > 0) {
|
|
772
|
+
for (let i = 0; i < tablet7Screenshots.length; i++) {
|
|
773
|
+
const url = tablet7Screenshots[i];
|
|
774
|
+
const filename = `tablet7-${i + 1}.png`;
|
|
775
|
+
const outputPath = path4.join(asoDir, filename);
|
|
776
|
+
if (isLocalAssetPath(url)) {
|
|
777
|
+
copyLocalAssetToAsoDir(url, outputPath);
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
await downloadImage(url, outputPath);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
const tablet10Screenshots = localeData.screenshots?.tablet10;
|
|
784
|
+
if (tablet10Screenshots && tablet10Screenshots.length > 0) {
|
|
785
|
+
for (let i = 0; i < tablet10Screenshots.length; i++) {
|
|
786
|
+
const url = tablet10Screenshots[i];
|
|
787
|
+
const filename = `tablet10-${i + 1}.png`;
|
|
788
|
+
const outputPath = path4.join(asoDir, filename);
|
|
789
|
+
if (isLocalAssetPath(url)) {
|
|
790
|
+
copyLocalAssetToAsoDir(url, outputPath);
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
await downloadImage(url, outputPath);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
const tabletScreenshots = localeData.screenshots?.tablet;
|
|
797
|
+
if (tabletScreenshots && tabletScreenshots.length > 0) {
|
|
798
|
+
for (let i = 0; i < tabletScreenshots.length; i++) {
|
|
799
|
+
const url = tabletScreenshots[i];
|
|
800
|
+
const filename = `tablet-${i + 1}.png`;
|
|
801
|
+
const outputPath = path4.join(asoDir, filename);
|
|
802
|
+
if (isLocalAssetPath(url)) {
|
|
803
|
+
copyLocalAssetToAsoDir(url, outputPath);
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
await downloadImage(url, outputPath);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
if (localeData.featureGraphic) {
|
|
810
|
+
const featureGraphicUrl = localeData.featureGraphic;
|
|
811
|
+
const outputPath = path4.join(asoDir, "feature-graphic.png");
|
|
812
|
+
if (isLocalAssetPath(featureGraphicUrl)) {
|
|
813
|
+
copyLocalAssetToAsoDir(featureGraphicUrl, outputPath);
|
|
814
|
+
} else {
|
|
815
|
+
await downloadImage(featureGraphicUrl, outputPath);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
if (asoData.appStore) {
|
|
821
|
+
let appStoreData = asoData.appStore;
|
|
822
|
+
if (!isAppStoreMultilingual(appStoreData)) {
|
|
823
|
+
appStoreData = convertToMultilingual(appStoreData, appStoreData.locale);
|
|
824
|
+
}
|
|
825
|
+
for (const unifiedLocale of Object.keys(appStoreData.locales)) {
|
|
826
|
+
const appStoreLocale = unifiedToAppStore(unifiedLocale);
|
|
827
|
+
if (!appStoreLocale) {
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
const localeData = appStoreData.locales[unifiedLocale];
|
|
831
|
+
const asoDir = path4.join(
|
|
832
|
+
productStoreRoot,
|
|
833
|
+
"app-store",
|
|
834
|
+
"screenshots",
|
|
835
|
+
appStoreLocale
|
|
836
|
+
);
|
|
837
|
+
const iphone65Screenshots = localeData.screenshots?.iphone65;
|
|
838
|
+
if (iphone65Screenshots && iphone65Screenshots.length > 0) {
|
|
839
|
+
for (let i = 0; i < iphone65Screenshots.length; i++) {
|
|
840
|
+
const url = iphone65Screenshots[i];
|
|
841
|
+
const filename = `iphone65-${i + 1}.png`;
|
|
842
|
+
const outputPath = path4.join(asoDir, filename);
|
|
843
|
+
if (isLocalAssetPath(url)) {
|
|
844
|
+
copyLocalAssetToAsoDir(url, outputPath);
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
await downloadImage(url, outputPath);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
const ipadPro129Screenshots = localeData.screenshots?.ipadPro129;
|
|
851
|
+
if (ipadPro129Screenshots && ipadPro129Screenshots.length > 0) {
|
|
852
|
+
for (let i = 0; i < ipadPro129Screenshots.length; i++) {
|
|
853
|
+
const url = ipadPro129Screenshots[i];
|
|
854
|
+
const filename = `ipadPro129-${i + 1}.png`;
|
|
855
|
+
const outputPath = path4.join(asoDir, filename);
|
|
856
|
+
if (isLocalAssetPath(url)) {
|
|
857
|
+
copyLocalAssetToAsoDir(url, outputPath);
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
await downloadImage(url, outputPath);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
var publicToAsoTool = {
|
|
867
|
+
name: "public-to-aso",
|
|
868
|
+
description: `Prepares ASO data from public/products/[slug]/ to pushData format.
|
|
869
|
+
|
|
870
|
+
**IMPORTANT:** Always use 'search-app' tool first to resolve the exact slug before calling this tool. The user may provide an approximate name, bundleId, or packageName - search-app will find and return the correct slug. Never pass user input directly as slug.
|
|
871
|
+
|
|
872
|
+
This tool:
|
|
873
|
+
1. Loads ASO data from public/products/[slug]/config.json + locales/
|
|
874
|
+
2. Converts to store-compatible format (removes screenshots from metadata, sets contactWebsite/marketingUrl)
|
|
875
|
+
3. Saves metadata to .aso/pushData/products/[slug]/store/ (path from ~/.config/pabal-mcp/config.json dataDir)
|
|
876
|
+
4. Copies/downloads screenshots to .aso/pushData/products/[slug]/store/screenshots/
|
|
877
|
+
5. Validates text field lengths against ${FIELD_LIMITS_DOC_PATH} (fails if over limits)
|
|
878
|
+
|
|
879
|
+
Before running, review ${FIELD_LIMITS_DOC_PATH} for per-store limits. This prepares data for pushing to stores without actually uploading.`,
|
|
880
|
+
inputSchema: inputSchema2
|
|
881
|
+
};
|
|
882
|
+
async function handlePublicToAso(input) {
|
|
883
|
+
const { slug, dryRun } = input;
|
|
884
|
+
const configData = loadAsoFromConfig(slug);
|
|
885
|
+
const { sanitizedData, warnings: sanitizeWarnings } = sanitizeAsoData(configData);
|
|
886
|
+
if (!sanitizedData.googlePlay && !sanitizedData.appStore) {
|
|
887
|
+
const productsDir = getProductsDir();
|
|
888
|
+
const configPath = path4.join(productsDir, slug, "config.json");
|
|
889
|
+
const localesDir = path4.join(productsDir, slug, "locales");
|
|
890
|
+
const errors = [];
|
|
891
|
+
if (!fs4.existsSync(configPath)) {
|
|
892
|
+
errors.push(`- config.json not found at ${configPath}`);
|
|
893
|
+
} else {
|
|
894
|
+
try {
|
|
895
|
+
const config = JSON.parse(fs4.readFileSync(configPath, "utf-8"));
|
|
896
|
+
if (!config.packageName && !config.bundleId) {
|
|
897
|
+
errors.push(
|
|
898
|
+
`- config.json exists but missing both packageName and bundleId`
|
|
899
|
+
);
|
|
900
|
+
} else {
|
|
901
|
+
if (config.packageName) {
|
|
902
|
+
errors.push(`- packageName found: ${config.packageName}`);
|
|
903
|
+
}
|
|
904
|
+
if (config.bundleId) {
|
|
905
|
+
errors.push(`- bundleId found: ${config.bundleId}`);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
} catch (e) {
|
|
909
|
+
errors.push(
|
|
910
|
+
`- Failed to parse config.json: ${e instanceof Error ? e.message : String(e)}`
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
if (!fs4.existsSync(localesDir)) {
|
|
915
|
+
errors.push(`- locales directory not found at ${localesDir}`);
|
|
916
|
+
} else {
|
|
917
|
+
try {
|
|
918
|
+
const localeFiles = fs4.readdirSync(localesDir).filter((f) => f.endsWith(".json"));
|
|
919
|
+
if (localeFiles.length === 0) {
|
|
920
|
+
errors.push(`- locales directory exists but no .json files found`);
|
|
921
|
+
} else {
|
|
922
|
+
errors.push(
|
|
923
|
+
`- Found ${localeFiles.length} locale file(s): ${localeFiles.join(
|
|
924
|
+
", "
|
|
925
|
+
)}`
|
|
926
|
+
);
|
|
927
|
+
const validLocales = [];
|
|
928
|
+
const invalidLocales = [];
|
|
929
|
+
for (const file of localeFiles) {
|
|
930
|
+
const localeCode = file.replace(".json", "");
|
|
931
|
+
if (isGooglePlayLocale(localeCode) || isAppStoreLocale(localeCode)) {
|
|
932
|
+
validLocales.push(localeCode);
|
|
933
|
+
} else {
|
|
934
|
+
invalidLocales.push(localeCode);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (validLocales.length > 0) {
|
|
938
|
+
errors.push(`- Valid locales: ${validLocales.join(", ")}`);
|
|
939
|
+
}
|
|
940
|
+
if (invalidLocales.length > 0) {
|
|
941
|
+
errors.push(
|
|
942
|
+
`- Invalid locales (not supported by Google Play or App Store): ${invalidLocales.join(
|
|
943
|
+
", "
|
|
944
|
+
)}`
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
} catch (e) {
|
|
949
|
+
errors.push(
|
|
950
|
+
`- Failed to read locales directory: ${e instanceof Error ? e.message : String(e)}`
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
throw new Error(
|
|
955
|
+
`No ASO data found in config.json + locales/ for ${slug}
|
|
956
|
+
|
|
957
|
+
Diagnostics:
|
|
958
|
+
${errors.join("\n")}
|
|
959
|
+
|
|
960
|
+
Possible causes:
|
|
961
|
+
1. config.json is missing packageName (for Google Play) or bundleId (for App Store)
|
|
962
|
+
2. locales/ directory is missing or empty
|
|
963
|
+
3. Locale files exist but don't match supported Google Play/App Store locales
|
|
964
|
+
4. Locale files don't contain valid ASO data`
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
const storeData = prepareAsoDataForPush(slug, sanitizedData);
|
|
968
|
+
const validationIssues = validateFieldLimits(sanitizedData);
|
|
969
|
+
const validationMessage = formatValidationIssues(validationIssues);
|
|
970
|
+
const pushDataRoot = getPushDataDir();
|
|
971
|
+
if (dryRun) {
|
|
972
|
+
return {
|
|
973
|
+
content: [
|
|
974
|
+
{
|
|
975
|
+
type: "text",
|
|
976
|
+
text: `Preview mode - Data that would be saved to ${pushDataRoot}:
|
|
977
|
+
|
|
978
|
+
${JSON.stringify(
|
|
979
|
+
storeData,
|
|
980
|
+
null,
|
|
981
|
+
2
|
|
982
|
+
)}
|
|
983
|
+
|
|
984
|
+
${validationMessage}${sanitizeWarnings.length ? `
|
|
985
|
+
Sanitized invalid characters:
|
|
986
|
+
- ${sanitizeWarnings.join(
|
|
987
|
+
"\n- "
|
|
988
|
+
)}` : ""}`
|
|
989
|
+
}
|
|
990
|
+
]
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
if (validationIssues.length > 0) {
|
|
994
|
+
throw new Error(
|
|
995
|
+
`Field limit violations detected. Fix before pushing.
|
|
996
|
+
${validationMessage}`
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
saveRawAsoData(slug, storeData);
|
|
1000
|
+
await downloadScreenshotsToAsoDir(slug, configData);
|
|
1001
|
+
const localeCounts = {};
|
|
1002
|
+
if (storeData.googlePlay) {
|
|
1003
|
+
const googlePlayData = storeData.googlePlay;
|
|
1004
|
+
const locales = isGooglePlayMultilingual(googlePlayData) ? googlePlayData.locales : {
|
|
1005
|
+
[googlePlayData.defaultLanguage || DEFAULT_LOCALE]: googlePlayData
|
|
1006
|
+
};
|
|
1007
|
+
localeCounts.googlePlay = Object.keys(locales).length;
|
|
1008
|
+
}
|
|
1009
|
+
if (storeData.appStore) {
|
|
1010
|
+
const appStoreData = storeData.appStore;
|
|
1011
|
+
const locales = isAppStoreMultilingual(appStoreData) ? appStoreData.locales : { [appStoreData.locale || DEFAULT_LOCALE]: appStoreData };
|
|
1012
|
+
localeCounts.appStore = Object.keys(locales).length;
|
|
1013
|
+
}
|
|
1014
|
+
let responseText = `\u2705 ${slug} pushData files prepared from config.json + locales/ (images + metadata synced)
|
|
1015
|
+
|
|
1016
|
+
`;
|
|
1017
|
+
if (localeCounts.googlePlay) {
|
|
1018
|
+
responseText += `Google Play: ${localeCounts.googlePlay} locale(s)
|
|
1019
|
+
`;
|
|
1020
|
+
}
|
|
1021
|
+
if (localeCounts.appStore) {
|
|
1022
|
+
responseText += `App Store: ${localeCounts.appStore} locale(s)
|
|
1023
|
+
`;
|
|
1024
|
+
}
|
|
1025
|
+
responseText += `
|
|
1026
|
+
Next step: Push to stores using pabal-mcp's aso-push tool`;
|
|
1027
|
+
responseText += `
|
|
1028
|
+
Reference: ${FIELD_LIMITS_DOC_PATH}`;
|
|
1029
|
+
if (sanitizeWarnings.length > 0) {
|
|
1030
|
+
responseText += `
|
|
1031
|
+
Sanitized invalid characters:
|
|
1032
|
+
- ${sanitizeWarnings.join(
|
|
1033
|
+
"\n- "
|
|
1034
|
+
)}`;
|
|
1035
|
+
}
|
|
1036
|
+
return {
|
|
1037
|
+
content: [
|
|
1038
|
+
{
|
|
1039
|
+
type: "text",
|
|
1040
|
+
text: responseText
|
|
1041
|
+
}
|
|
1042
|
+
]
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// src/tools/aso/improve.ts
|
|
1047
|
+
import { z as z3 } from "zod";
|
|
1048
|
+
import { zodToJsonSchema as zodToJsonSchema3 } from "zod-to-json-schema";
|
|
1049
|
+
|
|
1050
|
+
// src/tools/aso/utils/improve/load-product-locales.util.ts
|
|
1051
|
+
import fs5 from "fs";
|
|
1052
|
+
import path5 from "path";
|
|
1053
|
+
function loadProductLocales(slug) {
|
|
1054
|
+
const productsDir = getProductsDir();
|
|
1055
|
+
const productDir = path5.join(productsDir, slug);
|
|
1056
|
+
const configPath = path5.join(productDir, "config.json");
|
|
1057
|
+
const localesDir = path5.join(productDir, "locales");
|
|
1058
|
+
let config = null;
|
|
1059
|
+
if (fs5.existsSync(configPath)) {
|
|
1060
|
+
const raw = fs5.readFileSync(configPath, "utf-8");
|
|
1061
|
+
config = JSON.parse(raw);
|
|
1062
|
+
}
|
|
1063
|
+
if (!fs5.existsSync(localesDir)) {
|
|
1064
|
+
throw new Error(`No locales directory found for ${slug}`);
|
|
1065
|
+
}
|
|
1066
|
+
const locales = {};
|
|
1067
|
+
const localeFiles = fs5.readdirSync(localesDir).filter((file) => file.endsWith(".json"));
|
|
1068
|
+
if (localeFiles.length === 0) {
|
|
1069
|
+
throw new Error(`No locale files found for ${slug}`);
|
|
1070
|
+
}
|
|
1071
|
+
for (const file of localeFiles) {
|
|
1072
|
+
const localeCode = file.replace(".json", "");
|
|
1073
|
+
const localePath = path5.join(localesDir, file);
|
|
1074
|
+
const content = fs5.readFileSync(localePath, "utf-8");
|
|
1075
|
+
locales[localeCode] = JSON.parse(content);
|
|
1076
|
+
}
|
|
1077
|
+
return { config, locales };
|
|
1078
|
+
}
|
|
1079
|
+
function resolvePrimaryLocale(config, locales) {
|
|
1080
|
+
const localeKeys = Object.keys(locales);
|
|
1081
|
+
if (localeKeys.length === 0) {
|
|
1082
|
+
return DEFAULT_LOCALE;
|
|
1083
|
+
}
|
|
1084
|
+
const configuredDefault = config?.content?.defaultLocale;
|
|
1085
|
+
if (configuredDefault && locales[configuredDefault]) {
|
|
1086
|
+
return configuredDefault;
|
|
1087
|
+
}
|
|
1088
|
+
if (locales[DEFAULT_LOCALE]) {
|
|
1089
|
+
return DEFAULT_LOCALE;
|
|
1090
|
+
}
|
|
1091
|
+
return localeKeys[0];
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// src/tools/aso/utils/improve/get-full-description.util.ts
|
|
1095
|
+
function getFullDescriptionForLocale(asoData, locale) {
|
|
1096
|
+
if (asoData.googlePlay) {
|
|
1097
|
+
const googlePlayData = asoData.googlePlay;
|
|
1098
|
+
if (isGooglePlayMultilingual(googlePlayData)) {
|
|
1099
|
+
const localeData = googlePlayData.locales[locale];
|
|
1100
|
+
if (localeData?.fullDescription) {
|
|
1101
|
+
return localeData.fullDescription;
|
|
1102
|
+
}
|
|
1103
|
+
} else if (googlePlayData.defaultLanguage === locale) {
|
|
1104
|
+
return googlePlayData.fullDescription;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
if (asoData.appStore) {
|
|
1108
|
+
const appStoreData = asoData.appStore;
|
|
1109
|
+
if (isAppStoreMultilingual(appStoreData)) {
|
|
1110
|
+
const localeData = appStoreData.locales[locale];
|
|
1111
|
+
if (localeData?.description) {
|
|
1112
|
+
return localeData.description;
|
|
1113
|
+
}
|
|
1114
|
+
} else if (appStoreData.locale === locale) {
|
|
1115
|
+
return appStoreData.description;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
return void 0;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// src/tools/aso/utils/improve/keyword-analysis.util.ts
|
|
1122
|
+
function analyzeKeywords(content) {
|
|
1123
|
+
const words = content.toLowerCase().replace(/[^\w\s]/g, " ").split(/\s+/).filter((word) => word.length > 2);
|
|
1124
|
+
const frequency = {};
|
|
1125
|
+
for (const word of words) {
|
|
1126
|
+
frequency[word] = (frequency[word] || 0) + 1;
|
|
1127
|
+
}
|
|
1128
|
+
const totalWords = words.length;
|
|
1129
|
+
const density = {};
|
|
1130
|
+
for (const [word, count] of Object.entries(frequency)) {
|
|
1131
|
+
density[word] = count / totalWords * 100;
|
|
1132
|
+
}
|
|
1133
|
+
return { keywordFrequency: frequency, keywordDensity: density, totalWords };
|
|
1134
|
+
}
|
|
1135
|
+
function extractKeywordsFromContent(localeData) {
|
|
1136
|
+
const keywords = /* @__PURE__ */ new Set();
|
|
1137
|
+
const aso = localeData.aso || {};
|
|
1138
|
+
const landing = localeData.landing || {};
|
|
1139
|
+
if (Array.isArray(aso.keywords)) {
|
|
1140
|
+
aso.keywords.forEach((kw) => keywords.add(kw.toLowerCase()));
|
|
1141
|
+
} else if (typeof aso.keywords === "string") {
|
|
1142
|
+
aso.keywords.split(",").map((kw) => kw.trim().toLowerCase()).forEach((kw) => keywords.add(kw));
|
|
1143
|
+
}
|
|
1144
|
+
[aso.title, aso.subtitle, aso.shortDescription].forEach((text) => {
|
|
1145
|
+
if (text) {
|
|
1146
|
+
text.toLowerCase().split(/\s+/).filter((word) => word.length > 2).forEach((word) => keywords.add(word));
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
if (aso.template?.intro) {
|
|
1150
|
+
aso.template.intro.toLowerCase().replace(/[^\w\s]/g, " ").split(/\s+/).filter((word) => word.length > 3).forEach((word) => keywords.add(word));
|
|
1151
|
+
}
|
|
1152
|
+
if (aso.template?.outro) {
|
|
1153
|
+
aso.template.outro.toLowerCase().replace(/[^\w\s]/g, " ").split(/\s+/).filter((word) => word.length > 3).forEach((word) => keywords.add(word));
|
|
1154
|
+
}
|
|
1155
|
+
const hero = landing.hero || {};
|
|
1156
|
+
[hero.title, hero.description].forEach((text) => {
|
|
1157
|
+
if (text) {
|
|
1158
|
+
text.toLowerCase().replace(/[^\w\s]/g, " ").split(/\s+/).filter((word) => word.length > 3).forEach((word) => keywords.add(word));
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
const features = landing.features?.items || [];
|
|
1162
|
+
features.forEach((feature) => {
|
|
1163
|
+
if (feature.title) {
|
|
1164
|
+
feature.title.toLowerCase().replace(/[^\w\s]/g, " ").split(/\s+/).filter((word) => word.length > 3).forEach((word) => keywords.add(word));
|
|
1165
|
+
}
|
|
1166
|
+
if (feature.body) {
|
|
1167
|
+
feature.body.toLowerCase().replace(/[^\w\s]/g, " ").split(/\s+/).filter((word) => word.length > 3).forEach((word) => keywords.add(word));
|
|
1168
|
+
}
|
|
1169
|
+
});
|
|
1170
|
+
const screenshots = landing.screenshots?.images || [];
|
|
1171
|
+
screenshots.forEach((screenshot) => {
|
|
1172
|
+
if (screenshot.title) {
|
|
1173
|
+
screenshot.title.toLowerCase().replace(/[^\w\s]/g, " ").split(/\s+/).filter((word) => word.length > 3).forEach((word) => keywords.add(word));
|
|
1174
|
+
}
|
|
1175
|
+
if (screenshot.description) {
|
|
1176
|
+
screenshot.description.toLowerCase().replace(/[^\w\s]/g, " ").split(/\s+/).filter((word) => word.length > 3).forEach((word) => keywords.add(word));
|
|
1177
|
+
}
|
|
1178
|
+
});
|
|
1179
|
+
return Array.from(keywords);
|
|
1180
|
+
}
|
|
1181
|
+
function generateKeywordResearchQueries(args) {
|
|
1182
|
+
const { currentKeywords, category, title, features = [], screenshots = [] } = args;
|
|
1183
|
+
const queries = [];
|
|
1184
|
+
if (category) {
|
|
1185
|
+
const categoryName = category.toLowerCase().replace(/_/g, " ");
|
|
1186
|
+
queries.push(`ASO keywords ${categoryName} app store optimization`);
|
|
1187
|
+
queries.push(`best keywords for ${categoryName} apps`);
|
|
1188
|
+
queries.push(`top ${categoryName} apps keywords`);
|
|
1189
|
+
}
|
|
1190
|
+
if (title) {
|
|
1191
|
+
const titleWords = title.toLowerCase().split(/\s+/).filter((w) => w.length > 3);
|
|
1192
|
+
titleWords.forEach((word) => {
|
|
1193
|
+
queries.push(`${word} app keywords ASO`);
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
const featureTerms = /* @__PURE__ */ new Set();
|
|
1197
|
+
features.forEach((feature) => {
|
|
1198
|
+
if (feature.title) {
|
|
1199
|
+
const words = feature.title.toLowerCase().replace(/[^\w\s]/g, " ").split(/\s+/).filter((w) => w.length > 4);
|
|
1200
|
+
words.forEach((word) => featureTerms.add(word));
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
Array.from(featureTerms).slice(0, 3).forEach((term) => {
|
|
1204
|
+
queries.push(`${term} app store keywords`);
|
|
1205
|
+
});
|
|
1206
|
+
const screenshotTerms = /* @__PURE__ */ new Set();
|
|
1207
|
+
screenshots.forEach((screenshot) => {
|
|
1208
|
+
if (screenshot.title) {
|
|
1209
|
+
const words = screenshot.title.toLowerCase().replace(/[^\w\s]/g, " ").split(/\s+/).filter((w) => w.length > 4);
|
|
1210
|
+
words.forEach((word) => screenshotTerms.add(word));
|
|
1211
|
+
}
|
|
1212
|
+
});
|
|
1213
|
+
Array.from(screenshotTerms).slice(0, 3).forEach((term) => {
|
|
1214
|
+
queries.push(`${term} app keywords`);
|
|
1215
|
+
});
|
|
1216
|
+
currentKeywords.slice(0, 5).forEach((keyword) => {
|
|
1217
|
+
if (keyword.length > 3) {
|
|
1218
|
+
queries.push(`${keyword} app store keywords`);
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
return queries;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// src/tools/aso/utils/improve/format-locale-section.util.ts
|
|
1225
|
+
function generateKeywordSuggestions(args) {
|
|
1226
|
+
const { currentKeywords, category, title, description, locale, features = [], screenshots = [] } = args;
|
|
1227
|
+
const analysis = analyzeKeywords(description);
|
|
1228
|
+
const topKeywords = Object.entries(analysis.keywordDensity).sort(([, a], [, b]) => b - a).slice(0, 10).map(([word]) => word);
|
|
1229
|
+
const researchQueries = generateKeywordResearchQueries({
|
|
1230
|
+
currentKeywords,
|
|
1231
|
+
category,
|
|
1232
|
+
title,
|
|
1233
|
+
locale,
|
|
1234
|
+
features,
|
|
1235
|
+
screenshots
|
|
1236
|
+
});
|
|
1237
|
+
let suggestions = `## Keyword Analysis & Research (${locale})
|
|
1238
|
+
|
|
1239
|
+
`;
|
|
1240
|
+
suggestions += `### Current Keywords:
|
|
1241
|
+
`;
|
|
1242
|
+
suggestions += `- ${currentKeywords.join(", ") || "(none)"}
|
|
1243
|
+
|
|
1244
|
+
`;
|
|
1245
|
+
suggestions += `### Top Keywords Found in Content (by frequency):
|
|
1246
|
+
`;
|
|
1247
|
+
topKeywords.slice(0, 10).forEach((keyword, idx) => {
|
|
1248
|
+
const density = analysis.keywordDensity[keyword]?.toFixed(2) || "0";
|
|
1249
|
+
suggestions += `${idx + 1}. "${keyword}" (density: ${density}%)
|
|
1250
|
+
`;
|
|
1251
|
+
});
|
|
1252
|
+
suggestions += `
|
|
1253
|
+
`;
|
|
1254
|
+
suggestions += `### Keyword Research Queries (MULTIPLE SEARCH STRATEGIES REQUIRED):
|
|
1255
|
+
`;
|
|
1256
|
+
suggestions += `**IMPORTANT**: Use ALL 5 search strategies below. Don't rely on just one approach.
|
|
1257
|
+
|
|
1258
|
+
`;
|
|
1259
|
+
suggestions += `#### Strategy 1: Direct Store Search
|
|
1260
|
+
`;
|
|
1261
|
+
suggestions += `- Search App Store/Play Store: "${category ? category.toLowerCase().replace(/_/g, " ") : "category"} apps"
|
|
1262
|
+
`;
|
|
1263
|
+
suggestions += `- Visit top 5-10 apps, extract keywords from their names, descriptions, screenshots
|
|
1264
|
+
|
|
1265
|
+
`;
|
|
1266
|
+
suggestions += `#### Strategy 2: ASO Guides & Tools
|
|
1267
|
+
`;
|
|
1268
|
+
researchQueries.slice(0, 4).forEach((query, idx) => {
|
|
1269
|
+
suggestions += `${idx + 1}. "${query}"
|
|
1270
|
+
`;
|
|
1271
|
+
});
|
|
1272
|
+
suggestions += `
|
|
1273
|
+
`;
|
|
1274
|
+
suggestions += `#### Strategy 3: User Intent Searches
|
|
1275
|
+
`;
|
|
1276
|
+
if (title) {
|
|
1277
|
+
const titleWords = title.toLowerCase().split(/\s+/).filter((w) => w.length > 3);
|
|
1278
|
+
titleWords.slice(0, 3).forEach((word, idx) => {
|
|
1279
|
+
suggestions += `${idx + 1}. "${word} app"
|
|
1280
|
+
`;
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
suggestions += `- Search: "[primary feature] app", "[problem solved] app"
|
|
1284
|
+
|
|
1285
|
+
`;
|
|
1286
|
+
suggestions += `#### Strategy 4: Competitor Analysis
|
|
1287
|
+
`;
|
|
1288
|
+
suggestions += `- Search: "top ${category ? category.toLowerCase().replace(/_/g, " ") : "category"} apps"
|
|
1289
|
+
`;
|
|
1290
|
+
suggestions += `- "best ${category ? category.toLowerCase().replace(/_/g, " ") : "category"} apps 2024"
|
|
1291
|
+
|
|
1292
|
+
`;
|
|
1293
|
+
suggestions += `#### Strategy 5: Long-tail Keywords
|
|
1294
|
+
`;
|
|
1295
|
+
currentKeywords.slice(0, 3).forEach((keyword, idx) => {
|
|
1296
|
+
if (keyword.length > 3) {
|
|
1297
|
+
suggestions += `${idx + 1}. "${keyword} app store keywords"
|
|
1298
|
+
`;
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
suggestions += `
|
|
1302
|
+
`;
|
|
1303
|
+
suggestions += `**What to Extract**:
|
|
1304
|
+
`;
|
|
1305
|
+
suggestions += `- Exact keyword phrases (2-4 words) from top apps
|
|
1306
|
+
`;
|
|
1307
|
+
suggestions += `- Feature terms, benefit terms, category jargon
|
|
1308
|
+
`;
|
|
1309
|
+
suggestions += `- Action verbs and technical terms
|
|
1310
|
+
`;
|
|
1311
|
+
suggestions += `- Minimum 15-20 keywords, prioritize those used by 3+ top apps
|
|
1312
|
+
|
|
1313
|
+
`;
|
|
1314
|
+
suggestions += `### Keyword Optimization Guidelines:
|
|
1315
|
+
`;
|
|
1316
|
+
suggestions += `1. **Keywords Array (CRITICAL)**: Update aso.keywords array with researched keywords. This directly impacts App Store search visibility
|
|
1317
|
+
`;
|
|
1318
|
+
suggestions += `2. **Keyword Density**: Maintain 2.5-3% density for primary keywords, distributed naturally
|
|
1319
|
+
`;
|
|
1320
|
+
suggestions += `3. **Title/Subtitle**: Include 1-2 core keywords (30 char limit)
|
|
1321
|
+
`;
|
|
1322
|
+
suggestions += `4. **Short Description**: Include searchable keywords (80 char limit)
|
|
1323
|
+
`;
|
|
1324
|
+
suggestions += `5. **Full Description**: Place primary keywords in first 2-3 lines, distribute naturally throughout
|
|
1325
|
+
`;
|
|
1326
|
+
suggestions += `6. **template.intro**: Use up to 300 chars to naturally incorporate more keywords and provide richer context
|
|
1327
|
+
`;
|
|
1328
|
+
suggestions += `7. **App Store Keywords**: 100 char limit, comma-separated, avoid duplicates from name/subtitle
|
|
1329
|
+
|
|
1330
|
+
`;
|
|
1331
|
+
if (category) {
|
|
1332
|
+
suggestions += `### Category-Based Keyword Strategy:
|
|
1333
|
+
`;
|
|
1334
|
+
suggestions += `Category: ${category}
|
|
1335
|
+
`;
|
|
1336
|
+
suggestions += `- Research top-ranking apps in "${category}" category
|
|
1337
|
+
`;
|
|
1338
|
+
suggestions += `- Identify common keywords used by successful competitors
|
|
1339
|
+
`;
|
|
1340
|
+
suggestions += `- Focus on user search intent and specific terms users actually search for
|
|
1341
|
+
`;
|
|
1342
|
+
suggestions += `- Avoid brand names and competitor names
|
|
1343
|
+
|
|
1344
|
+
`;
|
|
1345
|
+
}
|
|
1346
|
+
return suggestions;
|
|
1347
|
+
}
|
|
1348
|
+
function formatLocaleSection(args) {
|
|
1349
|
+
const {
|
|
1350
|
+
slug,
|
|
1351
|
+
locale,
|
|
1352
|
+
localeData,
|
|
1353
|
+
fullDescription,
|
|
1354
|
+
primaryLocale,
|
|
1355
|
+
category
|
|
1356
|
+
} = args;
|
|
1357
|
+
const aso = localeData.aso || {};
|
|
1358
|
+
const template = aso.template;
|
|
1359
|
+
const landing = localeData.landing || {};
|
|
1360
|
+
const hero = landing.hero || {};
|
|
1361
|
+
const screenshots = landing.screenshots?.images || [];
|
|
1362
|
+
const features = landing.features?.items || [];
|
|
1363
|
+
const lengthOf = (value) => value ? value.length : 0;
|
|
1364
|
+
const keywordsLength = Array.isArray(aso.keywords) ? aso.keywords.join(", ").length : lengthOf(typeof aso.keywords === "string" ? aso.keywords : void 0);
|
|
1365
|
+
const header = `--- ${locale}${locale === primaryLocale ? " (primary)" : ""} ---`;
|
|
1366
|
+
const stats = [
|
|
1367
|
+
`Path: public/products/${slug}/locales/${locale}.json`,
|
|
1368
|
+
`- aso.title: ${lengthOf(aso.title)} chars`,
|
|
1369
|
+
`- aso.subtitle: ${lengthOf(aso.subtitle)} chars`,
|
|
1370
|
+
`- aso.shortDescription: ${lengthOf(aso.shortDescription)} chars`,
|
|
1371
|
+
`- aso.keywords: ${keywordsLength} chars total`,
|
|
1372
|
+
`- template.intro: ${lengthOf(template?.intro)} chars (limit: 300)`,
|
|
1373
|
+
`- template.outro: ${lengthOf(template?.outro)} chars (limit: 200)`,
|
|
1374
|
+
`- landing.hero.title: ${lengthOf(hero.title)} chars`,
|
|
1375
|
+
`- landing.hero.description: ${lengthOf(hero.description)} chars`,
|
|
1376
|
+
`- features: ${features.length} items`,
|
|
1377
|
+
`- screenshots: ${screenshots.length} captions`,
|
|
1378
|
+
`- fullDescription (derived): ${fullDescription?.length ?? 0} chars`
|
|
1379
|
+
].join("\n");
|
|
1380
|
+
const currentKeywords = extractKeywordsFromContent(localeData);
|
|
1381
|
+
const landingText = [
|
|
1382
|
+
hero.title,
|
|
1383
|
+
hero.description,
|
|
1384
|
+
...screenshots.map((img) => `${img.title} ${img.description || ""}`),
|
|
1385
|
+
...features.map((item) => `${item.title} ${item.body || ""}`),
|
|
1386
|
+
landing.reviews?.title,
|
|
1387
|
+
landing.reviews?.description,
|
|
1388
|
+
landing.cta?.headline,
|
|
1389
|
+
landing.cta?.description
|
|
1390
|
+
].filter(Boolean).join(" ");
|
|
1391
|
+
const fullText = [
|
|
1392
|
+
aso.title,
|
|
1393
|
+
aso.subtitle,
|
|
1394
|
+
aso.shortDescription,
|
|
1395
|
+
template?.intro,
|
|
1396
|
+
template?.outro,
|
|
1397
|
+
fullDescription,
|
|
1398
|
+
landingText
|
|
1399
|
+
].filter(Boolean).join(" ");
|
|
1400
|
+
const keywordAnalysis = generateKeywordSuggestions({
|
|
1401
|
+
currentKeywords,
|
|
1402
|
+
category,
|
|
1403
|
+
title: aso.title,
|
|
1404
|
+
description: fullText,
|
|
1405
|
+
locale,
|
|
1406
|
+
features,
|
|
1407
|
+
screenshots
|
|
1408
|
+
});
|
|
1409
|
+
const json = JSON.stringify(localeData, null, 2);
|
|
1410
|
+
return `${header}
|
|
1411
|
+
${stats}
|
|
1412
|
+
|
|
1413
|
+
${keywordAnalysis}
|
|
1414
|
+
\`\`\`json
|
|
1415
|
+
${json}
|
|
1416
|
+
\`\`\`
|
|
1417
|
+
`;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// src/tools/aso/utils/improve/generate-aso-prompt.util.ts
|
|
1421
|
+
var FIELD_LIMITS_DOC_PATH2 = "docs/aso/ASO_FIELD_LIMITS.md";
|
|
1422
|
+
function generatePrimaryOptimizationPrompt(args) {
|
|
1423
|
+
const {
|
|
1424
|
+
slug,
|
|
1425
|
+
category,
|
|
1426
|
+
primaryLocale,
|
|
1427
|
+
localeSections,
|
|
1428
|
+
keywordResearchByLocale,
|
|
1429
|
+
keywordResearchDirByLocale
|
|
1430
|
+
} = args;
|
|
1431
|
+
let prompt = `# ASO Optimization - Stage 1: Primary Locale
|
|
1432
|
+
|
|
1433
|
+
`;
|
|
1434
|
+
prompt += `Product: ${slug} | Category: ${category || "N/A"} | Primary: ${primaryLocale}
|
|
1435
|
+
|
|
1436
|
+
`;
|
|
1437
|
+
prompt += `## Task
|
|
1438
|
+
|
|
1439
|
+
`;
|
|
1440
|
+
prompt += `Optimize the PRIMARY locale (${primaryLocale}) using **saved keyword research** + full ASO field optimization.
|
|
1441
|
+
|
|
1442
|
+
`;
|
|
1443
|
+
prompt += `## Step 1: Use Saved Keyword Research (${primaryLocale})
|
|
1444
|
+
|
|
1445
|
+
`;
|
|
1446
|
+
const researchSections = keywordResearchByLocale[primaryLocale] || [];
|
|
1447
|
+
const researchDir = keywordResearchDirByLocale[primaryLocale];
|
|
1448
|
+
if (researchSections.length > 0) {
|
|
1449
|
+
prompt += `**CRITICAL: Use ONLY the saved keyword research below. Do NOT invent or research new keywords.**
|
|
1450
|
+
|
|
1451
|
+
`;
|
|
1452
|
+
prompt += `The research data includes:
|
|
1453
|
+
`;
|
|
1454
|
+
prompt += `- **Tier 1 (Core):** Use these in title and subtitle - highest traffic, best opportunity
|
|
1455
|
+
`;
|
|
1456
|
+
prompt += `- **Tier 2 (Feature):** Use these in keywords field and descriptions
|
|
1457
|
+
`;
|
|
1458
|
+
prompt += `- **Tier 3 (Longtail):** Use these in intro, outro, and feature descriptions
|
|
1459
|
+
`;
|
|
1460
|
+
prompt += `- **Keyword Details:** Each keyword has traffic/difficulty scores and rationale - use this to prioritize
|
|
1461
|
+
`;
|
|
1462
|
+
prompt += `- **Strategy:** Overall optimization strategy based on competitor analysis
|
|
1463
|
+
`;
|
|
1464
|
+
prompt += `- **Keyword Gaps:** Opportunities where competitors are weak
|
|
1465
|
+
`;
|
|
1466
|
+
prompt += `- **User Language Patterns:** Phrases real users use in reviews - incorporate naturally
|
|
1467
|
+
|
|
1468
|
+
`;
|
|
1469
|
+
prompt += `Saved research:
|
|
1470
|
+
${researchSections.join("\n")}
|
|
1471
|
+
|
|
1472
|
+
`;
|
|
1473
|
+
} else {
|
|
1474
|
+
prompt += `No saved keyword research found at ${researchDir}.
|
|
1475
|
+
`;
|
|
1476
|
+
prompt += `**Stop and request action**: Run the 'keyword-research' tool with slug='${slug}', locale='${primaryLocale}', and the appropriate platform/country, then rerun improve-public stage 1.
|
|
1477
|
+
|
|
1478
|
+
`;
|
|
1479
|
+
}
|
|
1480
|
+
prompt += `**Priority:** When both iOS and Android research exist, keep iOS keywords first and only add Android keywords if there is room after meeting character limits.
|
|
1481
|
+
|
|
1482
|
+
`;
|
|
1483
|
+
prompt += `## Step 2: Optimize All Fields (${primaryLocale})
|
|
1484
|
+
|
|
1485
|
+
`;
|
|
1486
|
+
prompt += `**Apply keywords strategically based on tier priority:**
|
|
1487
|
+
|
|
1488
|
+
`;
|
|
1489
|
+
prompt += `### Tier 1 Keywords (Core) \u2192 Title & Subtitle
|
|
1490
|
+
`;
|
|
1491
|
+
prompt += `- \`aso.title\` (\u226430): **"App Name: [Tier1 Keyword]"** format
|
|
1492
|
+
`;
|
|
1493
|
+
prompt += ` - App name in English, keyword in target language, uppercase after colon
|
|
1494
|
+
`;
|
|
1495
|
+
prompt += ` - **Do NOT translate/rename the app name**
|
|
1496
|
+
`;
|
|
1497
|
+
prompt += `- \`aso.subtitle\` (\u226430): Use remaining Tier 1 keywords
|
|
1498
|
+
`;
|
|
1499
|
+
prompt += `- \`aso.shortDescription\` (\u226480): Tier 1 + Tier 2 keywords (no emojis/CAPS)
|
|
1500
|
+
|
|
1501
|
+
`;
|
|
1502
|
+
prompt += `### Tier 2 Keywords (Feature) \u2192 Keywords Field & Descriptions
|
|
1503
|
+
`;
|
|
1504
|
+
prompt += `- \`aso.keywords\` (\u2264100): ALL tiers, comma-separated (Tier 1 first, then Tier 2, then Tier 3)
|
|
1505
|
+
`;
|
|
1506
|
+
prompt += `- \`landing.hero.title\`: Tier 1 + Tier 2 keywords
|
|
1507
|
+
`;
|
|
1508
|
+
prompt += `- \`landing.hero.description\`: Tier 2 keywords naturally integrated
|
|
1509
|
+
`;
|
|
1510
|
+
prompt += `- \`landing.screenshots.images[].title\`: Tier 2 keywords
|
|
1511
|
+
`;
|
|
1512
|
+
prompt += `- \`landing.screenshots.images[].description\`: Tier 2 + Tier 3 keywords
|
|
1513
|
+
|
|
1514
|
+
`;
|
|
1515
|
+
prompt += `### Tier 3 Keywords (Longtail) \u2192 Content Sections
|
|
1516
|
+
`;
|
|
1517
|
+
prompt += `- \`aso.template.intro\` (\u2264300): Tier 2 + Tier 3 keywords, keyword-rich, use full length
|
|
1518
|
+
`;
|
|
1519
|
+
prompt += `- \`aso.template.outro\` (\u2264200): Tier 3 keywords, natural integration
|
|
1520
|
+
`;
|
|
1521
|
+
prompt += `- \`landing.features.items[].title\`: Tier 2 keywords
|
|
1522
|
+
`;
|
|
1523
|
+
prompt += `- \`landing.features.items[].body\`: Tier 3 keywords with user language patterns
|
|
1524
|
+
`;
|
|
1525
|
+
prompt += `- \`landing.reviews.title/description\`: Keywords if applicable
|
|
1526
|
+
`;
|
|
1527
|
+
prompt += `- \`landing.cta.headline/description\`: Keywords if applicable
|
|
1528
|
+
|
|
1529
|
+
`;
|
|
1530
|
+
prompt += `### User Language Integration
|
|
1531
|
+
`;
|
|
1532
|
+
prompt += `- Use **User Language Patterns** from research in intro/outro/features
|
|
1533
|
+
`;
|
|
1534
|
+
prompt += `- These are actual phrases users search for - incorporate naturally
|
|
1535
|
+
|
|
1536
|
+
`;
|
|
1537
|
+
prompt += `**Guidelines**: 2.5-3% keyword density, natural flow, cultural appropriateness
|
|
1538
|
+
`;
|
|
1539
|
+
prompt += `**CRITICAL**: You MUST include the complete \`landing\` object in your optimized JSON output.
|
|
1540
|
+
|
|
1541
|
+
`;
|
|
1542
|
+
prompt += `## Step 3: Validate (after applying all keywords)
|
|
1543
|
+
|
|
1544
|
+
`;
|
|
1545
|
+
prompt += `Check all limits using ${FIELD_LIMITS_DOC_PATH2}: title \u226430, subtitle \u226430, shortDescription \u226480, keywords \u2264100, intro \u2264300, outro \u2264200
|
|
1546
|
+
`;
|
|
1547
|
+
prompt += `- Remove keyword duplicates (unique list; avoid repeating title/subtitle terms verbatim)
|
|
1548
|
+
`;
|
|
1549
|
+
prompt += `- Ensure App Store/Play Store rules from ${FIELD_LIMITS_DOC_PATH2} are satisfied (no disallowed characters/formatting)
|
|
1550
|
+
|
|
1551
|
+
`;
|
|
1552
|
+
prompt += `## Current Data
|
|
1553
|
+
|
|
1554
|
+
`;
|
|
1555
|
+
prompt += `${localeSections.find((s) => s.includes(`[${primaryLocale}]`)) || localeSections[0]}
|
|
1556
|
+
|
|
1557
|
+
`;
|
|
1558
|
+
prompt += `## Output Format
|
|
1559
|
+
|
|
1560
|
+
`;
|
|
1561
|
+
prompt += `**1. Keyword Research (from saved data)**
|
|
1562
|
+
`;
|
|
1563
|
+
prompt += ` - Cite file(s) used and list the selected top 10 keywords (no new research)
|
|
1564
|
+
`;
|
|
1565
|
+
prompt += ` - Rationale: why these 10 were chosen from saved research
|
|
1566
|
+
|
|
1567
|
+
`;
|
|
1568
|
+
prompt += `**2. Optimized JSON** (complete ${primaryLocale} locale structure)
|
|
1569
|
+
`;
|
|
1570
|
+
prompt += ` - MUST include complete \`aso\` object with all fields
|
|
1571
|
+
`;
|
|
1572
|
+
prompt += ` - MUST include complete \`landing\` object with:
|
|
1573
|
+
`;
|
|
1574
|
+
prompt += ` * \`landing.hero\` (title, description, titleHighlight)
|
|
1575
|
+
`;
|
|
1576
|
+
prompt += ` * \`landing.screenshots.images[]\` (all items with title and description)
|
|
1577
|
+
`;
|
|
1578
|
+
prompt += ` * \`landing.features.items[]\` (all items with title and body)
|
|
1579
|
+
`;
|
|
1580
|
+
prompt += ` * \`landing.reviews\` (title, description, icons, rating, testimonials)
|
|
1581
|
+
`;
|
|
1582
|
+
prompt += ` * \`landing.cta\` (headline, icons, rating, description)
|
|
1583
|
+
|
|
1584
|
+
`;
|
|
1585
|
+
prompt += `**3. Validation**
|
|
1586
|
+
`;
|
|
1587
|
+
prompt += ` - title: X/30 \u2713/\u2717
|
|
1588
|
+
`;
|
|
1589
|
+
prompt += ` - subtitle: X/30 \u2713/\u2717
|
|
1590
|
+
`;
|
|
1591
|
+
prompt += ` - shortDescription: X/80 \u2713/\u2717
|
|
1592
|
+
`;
|
|
1593
|
+
prompt += ` - keywords: X/100 \u2713/\u2717 (deduped \u2713/\u2717)
|
|
1594
|
+
`;
|
|
1595
|
+
prompt += ` - intro: X/300 \u2713/\u2717
|
|
1596
|
+
`;
|
|
1597
|
+
prompt += ` - outro: X/200 \u2713/\u2717
|
|
1598
|
+
`;
|
|
1599
|
+
prompt += ` - Store rules (${FIELD_LIMITS_DOC_PATH2}): \u2713/\u2717
|
|
1600
|
+
`;
|
|
1601
|
+
prompt += ` - Density: X% (2.5-3%) \u2713/\u2717
|
|
1602
|
+
|
|
1603
|
+
`;
|
|
1604
|
+
prompt += `**Reference**: ${FIELD_LIMITS_DOC_PATH2}
|
|
1605
|
+
|
|
1606
|
+
`;
|
|
1607
|
+
prompt += `---
|
|
1608
|
+
|
|
1609
|
+
`;
|
|
1610
|
+
prompt += `## Next Step
|
|
1611
|
+
|
|
1612
|
+
`;
|
|
1613
|
+
prompt += `After saving the optimized JSON, proceed to **Stage 2** to optimize other locales:
|
|
1614
|
+
`;
|
|
1615
|
+
prompt += `\`\`\`
|
|
1616
|
+
improve-public(slug="${slug}", stage="2", optimizedPrimary=<the JSON you just created>)
|
|
1617
|
+
\`\`\`
|
|
1618
|
+
`;
|
|
1619
|
+
return prompt;
|
|
1620
|
+
}
|
|
1621
|
+
function generateKeywordLocalizationPrompt(args) {
|
|
1622
|
+
const {
|
|
1623
|
+
slug,
|
|
1624
|
+
primaryLocale,
|
|
1625
|
+
targetLocales,
|
|
1626
|
+
localeSections,
|
|
1627
|
+
optimizedPrimary,
|
|
1628
|
+
keywordResearchByLocale,
|
|
1629
|
+
keywordResearchDirByLocale,
|
|
1630
|
+
batchLocales,
|
|
1631
|
+
batchIndex,
|
|
1632
|
+
totalBatches,
|
|
1633
|
+
batchLocaleSections
|
|
1634
|
+
} = args;
|
|
1635
|
+
const nonPrimaryLocales = batchLocales || targetLocales.filter((l) => l !== primaryLocale);
|
|
1636
|
+
const sectionsToUse = batchLocaleSections || localeSections;
|
|
1637
|
+
let prompt = `# ASO Optimization - Stage 2: Keyword Localization`;
|
|
1638
|
+
if (batchIndex !== void 0 && totalBatches !== void 0) {
|
|
1639
|
+
prompt += ` (Batch ${batchIndex + 1}/${totalBatches})`;
|
|
1640
|
+
}
|
|
1641
|
+
prompt += `
|
|
1642
|
+
|
|
1643
|
+
`;
|
|
1644
|
+
prompt += `Product: ${slug} | Primary: ${primaryLocale} | Batch Locales: ${nonPrimaryLocales.join(
|
|
1645
|
+
", "
|
|
1646
|
+
)}
|
|
1647
|
+
|
|
1648
|
+
`;
|
|
1649
|
+
if (batchIndex !== void 0 && totalBatches !== void 0) {
|
|
1650
|
+
prompt += `**\u26A0\uFE0F BATCH PROCESSING MODE**
|
|
1651
|
+
|
|
1652
|
+
`;
|
|
1653
|
+
prompt += `This is batch ${batchIndex + 1} of ${totalBatches}.
|
|
1654
|
+
`;
|
|
1655
|
+
prompt += `Process ONLY the locales in this batch: ${nonPrimaryLocales.join(
|
|
1656
|
+
", "
|
|
1657
|
+
)}
|
|
1658
|
+
`;
|
|
1659
|
+
prompt += `After completing this batch, save the files and proceed to the next batch.
|
|
1660
|
+
|
|
1661
|
+
`;
|
|
1662
|
+
}
|
|
1663
|
+
prompt += `## Task
|
|
1664
|
+
|
|
1665
|
+
`;
|
|
1666
|
+
prompt += `**CRITICAL: Only process locales that already exist in public/products/${slug}/locales/.**
|
|
1667
|
+
`;
|
|
1668
|
+
prompt += `**Do NOT create new locale files - only improve existing ones.**
|
|
1669
|
+
|
|
1670
|
+
`;
|
|
1671
|
+
prompt += `For EACH target locale in this batch:
|
|
1672
|
+
`;
|
|
1673
|
+
prompt += `1. Use SAVED keyword research (see per-locale data below). Do NOT invent keywords.
|
|
1674
|
+
`;
|
|
1675
|
+
prompt += `2. **Replace ONLY keywords with optimized keywords** - keep ALL existing content, structure, tone, and context unchanged. Only swap keywords for better ASO keywords.
|
|
1676
|
+
`;
|
|
1677
|
+
prompt += `3. After all keywords are applied, validate character limits + store rules (${FIELD_LIMITS_DOC_PATH2}) + keyword duplication
|
|
1678
|
+
`;
|
|
1679
|
+
prompt += `4. **SAVE the updated JSON to file** using the save-locale-file tool (only if file exists)
|
|
1680
|
+
|
|
1681
|
+
`;
|
|
1682
|
+
prompt += `## Optimized Primary (Reference)
|
|
1683
|
+
|
|
1684
|
+
`;
|
|
1685
|
+
prompt += `Use this as the base structure/messaging:
|
|
1686
|
+
\`\`\`json
|
|
1687
|
+
${optimizedPrimary}
|
|
1688
|
+
\`\`\`
|
|
1689
|
+
|
|
1690
|
+
`;
|
|
1691
|
+
const { keywordResearchFallbackByLocale } = args;
|
|
1692
|
+
const localesNeedingFallback = nonPrimaryLocales.filter((loc) => {
|
|
1693
|
+
const fallbackInfo = keywordResearchFallbackByLocale?.[loc];
|
|
1694
|
+
const researchSections = keywordResearchByLocale[loc] || [];
|
|
1695
|
+
return researchSections.length === 0 || fallbackInfo?.isFallback;
|
|
1696
|
+
});
|
|
1697
|
+
const primaryResearchSections = keywordResearchByLocale[primaryLocale] || [];
|
|
1698
|
+
const hasPrimaryResearch = primaryResearchSections.length > 0;
|
|
1699
|
+
prompt += `## Keyword Research (Per Locale)
|
|
1700
|
+
|
|
1701
|
+
`;
|
|
1702
|
+
prompt += `**Priority:** Use each locale's own keyword research. English fallback is ONLY used when locale-specific research is missing.
|
|
1703
|
+
`;
|
|
1704
|
+
prompt += `When both iOS and Android research exist for a locale, treat iOS keywords as primary; use Android keywords only if space remains after fitting iOS keywords within character limits.
|
|
1705
|
+
|
|
1706
|
+
`;
|
|
1707
|
+
if (hasPrimaryResearch && localesNeedingFallback.length > 0) {
|
|
1708
|
+
prompt += `---
|
|
1709
|
+
`;
|
|
1710
|
+
prompt += `**\u{1F4DA} ENGLISH FALLBACK (${primaryLocale})** - Only for locales without their own research: ${localesNeedingFallback.join(
|
|
1711
|
+
", "
|
|
1712
|
+
)}
|
|
1713
|
+
`;
|
|
1714
|
+
prompt += `${primaryResearchSections.join("\n")}
|
|
1715
|
+
|
|
1716
|
+
`;
|
|
1717
|
+
prompt += `---
|
|
1718
|
+
|
|
1719
|
+
`;
|
|
1720
|
+
}
|
|
1721
|
+
nonPrimaryLocales.forEach((loc) => {
|
|
1722
|
+
const researchSections = keywordResearchByLocale[loc] || [];
|
|
1723
|
+
const researchDir = keywordResearchDirByLocale[loc];
|
|
1724
|
+
const fallbackInfo = keywordResearchFallbackByLocale?.[loc];
|
|
1725
|
+
if (researchSections.length > 0 && !fallbackInfo?.isFallback) {
|
|
1726
|
+
prompt += `### Locale ${loc}: \u2705 Using locale-specific keyword research
|
|
1727
|
+
`;
|
|
1728
|
+
prompt += researchSections.join("\n");
|
|
1729
|
+
prompt += `
|
|
1730
|
+
**Use these ${loc} keywords directly** - they are already in the target language.
|
|
1731
|
+
|
|
1732
|
+
`;
|
|
1733
|
+
} else if (researchSections.length > 0 && fallbackInfo?.isFallback) {
|
|
1734
|
+
prompt += `### Locale ${loc}: \u{1F504} No ${loc} research found - Using ${fallbackInfo.fallbackLocale} as fallback
|
|
1735
|
+
`;
|
|
1736
|
+
prompt += researchSections.join("\n");
|
|
1737
|
+
prompt += `
|
|
1738
|
+
**MUST TRANSLATE:** The keywords above are in ${fallbackInfo.fallbackLocale}. You MUST:
|
|
1739
|
+
`;
|
|
1740
|
+
prompt += `1. **TRANSLATE each keyword into ${loc}** - use natural, native expressions
|
|
1741
|
+
`;
|
|
1742
|
+
prompt += `2. Ensure translated keywords are what ${loc} users would actually search for
|
|
1743
|
+
`;
|
|
1744
|
+
prompt += `3. **DO NOT use ${fallbackInfo.fallbackLocale} keywords directly** - all keywords must be in ${loc} language
|
|
1745
|
+
|
|
1746
|
+
`;
|
|
1747
|
+
} else if (hasPrimaryResearch) {
|
|
1748
|
+
prompt += `### Locale ${loc}: \u26A0\uFE0F No research found - TRANSLATE from English fallback above
|
|
1749
|
+
`;
|
|
1750
|
+
prompt += `No keyword research found at ${researchDir}.
|
|
1751
|
+
`;
|
|
1752
|
+
prompt += `**Use the ENGLISH FALLBACK section above** and TRANSLATE all keywords to ${loc}.
|
|
1753
|
+
|
|
1754
|
+
`;
|
|
1755
|
+
} else {
|
|
1756
|
+
prompt += `### Locale ${loc}: \u274C No research available
|
|
1757
|
+
`;
|
|
1758
|
+
prompt += `No keyword research found. Extract keywords from \`aso.keywords\` in optimizedPrimary and **TRANSLATE them to ${loc}**.
|
|
1759
|
+
|
|
1760
|
+
`;
|
|
1761
|
+
}
|
|
1762
|
+
});
|
|
1763
|
+
prompt += `## Keyword Replacement Strategy
|
|
1764
|
+
|
|
1765
|
+
`;
|
|
1766
|
+
prompt += `**CRITICAL: Keep ALL existing content unchanged. Only replace keywords with optimized keywords.**
|
|
1767
|
+
|
|
1768
|
+
`;
|
|
1769
|
+
prompt += `For EACH locale:
|
|
1770
|
+
`;
|
|
1771
|
+
prompt += `- Priority: Keep iOS-sourced keywords first; add Android keywords only if there is remaining space after iOS keywords fit within field limits.
|
|
1772
|
+
`;
|
|
1773
|
+
prompt += `1. Take the EXISTING translated content (below) - **DO NOT change the content itself**
|
|
1774
|
+
`;
|
|
1775
|
+
prompt += `2. Replace \`aso.keywords\` array with optimized keywords (keep same count/structure)
|
|
1776
|
+
`;
|
|
1777
|
+
prompt += `3. **TITLE FORMAT**: \`aso.title\` must follow **"App Name: Primary Keyword"** format:
|
|
1778
|
+
`;
|
|
1779
|
+
prompt += ` - App name: **ALWAYS in English** (e.g., "Aurora EOS", "Timeline", "Recaply)
|
|
1780
|
+
`;
|
|
1781
|
+
prompt += ` - Primary keyword: **In target language** (e.g., "\uC624\uB85C\uB77C \uC608\uBCF4" for Korean, "\u30AA\u30FC\u30ED\u30E9\u4E88\u5831" for Japanese)
|
|
1782
|
+
`;
|
|
1783
|
+
prompt += ` - Example: "Aurora EOS: \uC624\uB85C\uB77C \uC608\uBCF4" (Korean), "Aurora EOS: \u30AA\u30FC\u30ED\u30E9\u4E88\u5831" (Japanese)
|
|
1784
|
+
`;
|
|
1785
|
+
prompt += ` - The keyword after the colon must start with an uppercase letter
|
|
1786
|
+
`;
|
|
1787
|
+
prompt += ` - **Do NOT translate/rename the app name**; keep the original English app name across all locales.
|
|
1788
|
+
`;
|
|
1789
|
+
prompt += ` - **Only replace the keyword part** - keep the app name and format structure unchanged
|
|
1790
|
+
`;
|
|
1791
|
+
prompt += `4. Deduplicate keywords: final \`aso.keywords\` must be unique and should not repeat title/subtitle terms verbatim
|
|
1792
|
+
`;
|
|
1793
|
+
prompt += `5. **Replace keywords in existing sentences** - swap ONLY the keywords, keep everything else:
|
|
1794
|
+
`;
|
|
1795
|
+
prompt += ` - **Keep original sentence structure exactly as is**
|
|
1796
|
+
`;
|
|
1797
|
+
prompt += ` - **Keep original tone and messaging unchanged**
|
|
1798
|
+
`;
|
|
1799
|
+
prompt += ` - **Keep original context and flow unchanged**
|
|
1800
|
+
`;
|
|
1801
|
+
prompt += ` - **Only swap individual keywords** for better ASO keywords
|
|
1802
|
+
`;
|
|
1803
|
+
prompt += ` - Maintain character limits
|
|
1804
|
+
|
|
1805
|
+
`;
|
|
1806
|
+
prompt += `6. **CRITICAL**: Update keywords in ALL \`landing\` sections (replace keywords only, keep content structure):
|
|
1807
|
+
`;
|
|
1808
|
+
prompt += ` - \`landing.hero.title\` and \`landing.hero.description\`: Replace keywords only, keep existing text structure
|
|
1809
|
+
`;
|
|
1810
|
+
prompt += ` - \`landing.screenshots.images[].title\`: Replace keywords in existing titles, keep structure
|
|
1811
|
+
`;
|
|
1812
|
+
prompt += ` - \`landing.screenshots.images[].description\`: Replace keywords in existing descriptions, keep structure
|
|
1813
|
+
`;
|
|
1814
|
+
prompt += ` - \`landing.features.items[].title\`: Replace keywords in existing titles, keep structure
|
|
1815
|
+
`;
|
|
1816
|
+
prompt += ` - \`landing.features.items[].body\`: Replace keywords in existing descriptions, keep structure
|
|
1817
|
+
`;
|
|
1818
|
+
prompt += ` - \`landing.reviews.title\` and \`landing.reviews.description\`: Replace keywords if applicable, keep structure
|
|
1819
|
+
`;
|
|
1820
|
+
prompt += ` - \`landing.cta.headline\` and \`landing.cta.description\`: Replace keywords if applicable, keep structure
|
|
1821
|
+
`;
|
|
1822
|
+
prompt += ` - **Maintain ALL original context, meaning, and structure**
|
|
1823
|
+
`;
|
|
1824
|
+
prompt += ` - Use optimized keywords that users actually search for
|
|
1825
|
+
`;
|
|
1826
|
+
prompt += ` - **DO NOT rewrite or restructure content** - only replace keywords
|
|
1827
|
+
|
|
1828
|
+
`;
|
|
1829
|
+
prompt += `**Example** (keyword replacement only, content unchanged):
|
|
1830
|
+
`;
|
|
1831
|
+
prompt += `- Original: "Track aurora with real-time forecasts"
|
|
1832
|
+
`;
|
|
1833
|
+
prompt += `- Optimized keywords: \uC624\uB85C\uB77C, \uC608\uBCF4, \uC2E4\uC2DC\uAC04
|
|
1834
|
+
`;
|
|
1835
|
+
prompt += `- Result: "Track \uC624\uB85C\uB77C with \uC2E4\uC2DC\uAC04 \uC608\uBCF4" (keywords replaced, structure kept)
|
|
1836
|
+
`;
|
|
1837
|
+
prompt += ` OR: "\uC2E4\uC2DC\uAC04 \uC608\uBCF4\uB85C \uC624\uB85C\uB77C \uCD94\uC801" (if natural keyword placement requires minor word order, but keep meaning identical)
|
|
1838
|
+
|
|
1839
|
+
`;
|
|
1840
|
+
prompt += `## Current Translated Locales (This Batch)
|
|
1841
|
+
|
|
1842
|
+
`;
|
|
1843
|
+
nonPrimaryLocales.forEach((loc) => {
|
|
1844
|
+
const section = sectionsToUse.find((s) => s.includes(`[${loc}]`));
|
|
1845
|
+
if (section) {
|
|
1846
|
+
prompt += `${section}
|
|
1847
|
+
|
|
1848
|
+
`;
|
|
1849
|
+
}
|
|
1850
|
+
});
|
|
1851
|
+
prompt += `## Workflow
|
|
1852
|
+
|
|
1853
|
+
`;
|
|
1854
|
+
prompt += `Process EACH locale in this batch sequentially:
|
|
1855
|
+
`;
|
|
1856
|
+
prompt += `1. Use saved keyword research (in target language) OR **TRANSLATE English keywords from primary locale** if missing (see fallback strategy above - MUST translate, not use English directly)
|
|
1857
|
+
`;
|
|
1858
|
+
prompt += `2. **Replace keywords ONLY** in ALL fields (keep existing content structure unchanged):
|
|
1859
|
+
`;
|
|
1860
|
+
prompt += ` - \`aso.keywords\` array
|
|
1861
|
+
`;
|
|
1862
|
+
prompt += ` - \`aso.title\`, \`aso.subtitle\`, \`aso.shortDescription\`
|
|
1863
|
+
`;
|
|
1864
|
+
prompt += ` - \`aso.template.intro\`, \`aso.template.outro\`
|
|
1865
|
+
`;
|
|
1866
|
+
prompt += ` - \`landing.hero.title\` and \`landing.hero.description\`
|
|
1867
|
+
`;
|
|
1868
|
+
prompt += ` - \`landing.screenshots.images[].title\` and \`description\` (ALL items)
|
|
1869
|
+
`;
|
|
1870
|
+
prompt += ` - \`landing.features.items[].title\` and \`body\` (ALL items)
|
|
1871
|
+
`;
|
|
1872
|
+
prompt += ` - \`landing.reviews.title\` and \`landing.reviews.description\`
|
|
1873
|
+
`;
|
|
1874
|
+
prompt += ` - **For each field: Replace keywords only, keep existing content structure and meaning unchanged**
|
|
1875
|
+
`;
|
|
1876
|
+
prompt += `3. **CRITICAL**: Ensure ALL landing fields are translated (not English)
|
|
1877
|
+
`;
|
|
1878
|
+
prompt += `4. After swapping keywords, validate limits + store rules (${FIELD_LIMITS_DOC_PATH2}) + keyword duplication (unique list; avoid repeating title/subtitle terms verbatim)
|
|
1879
|
+
`;
|
|
1880
|
+
prompt += `5. **SAVE the updated JSON to file** using save-locale-file tool
|
|
1881
|
+
`;
|
|
1882
|
+
prompt += `6. Move to next locale in batch
|
|
1883
|
+
|
|
1884
|
+
`;
|
|
1885
|
+
if (batchIndex !== void 0 && totalBatches !== void 0) {
|
|
1886
|
+
prompt += `## After Completing This Batch
|
|
1887
|
+
|
|
1888
|
+
`;
|
|
1889
|
+
prompt += `1. Verify all locales in this batch have been saved to files
|
|
1890
|
+
`;
|
|
1891
|
+
if (batchIndex + 1 < totalBatches) {
|
|
1892
|
+
prompt += `2. Proceed to next batch (batch ${batchIndex + 2}/${totalBatches})
|
|
1893
|
+
`;
|
|
1894
|
+
prompt += `3. Use the same optimizedPrimary JSON as reference
|
|
1895
|
+
|
|
1896
|
+
`;
|
|
1897
|
+
} else {
|
|
1898
|
+
prompt += `2. All batches completed! \u2705
|
|
1899
|
+
`;
|
|
1900
|
+
prompt += `3. **Run validate-aso** to verify all locales:
|
|
1901
|
+
`;
|
|
1902
|
+
prompt += ` \`\`\`
|
|
1903
|
+
validate-aso(slug="${slug}")
|
|
1904
|
+
\`\`\`
|
|
1905
|
+
|
|
1906
|
+
`;
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
prompt += `## Output Format (Per Locale)
|
|
1910
|
+
|
|
1911
|
+
`;
|
|
1912
|
+
prompt += `For EACH locale, provide:
|
|
1913
|
+
|
|
1914
|
+
`;
|
|
1915
|
+
prompt += `### Locale [locale-code]:
|
|
1916
|
+
|
|
1917
|
+
`;
|
|
1918
|
+
prompt += `**1. Keyword Source**
|
|
1919
|
+
`;
|
|
1920
|
+
prompt += ` - If saved research exists: Cite file(s) used; list selected top 10 keywords (in target language)
|
|
1921
|
+
`;
|
|
1922
|
+
prompt += ` - If using fallback: List **TRANSLATED** keywords from primary locale (English \u2192 target language) with translation rationale
|
|
1923
|
+
`;
|
|
1924
|
+
prompt += ` - Show final 10 keywords **IN TARGET LANGUAGE** with tier assignments - DO NOT show English keywords
|
|
1925
|
+
|
|
1926
|
+
`;
|
|
1927
|
+
prompt += `**2. Updated JSON** (complete locale structure with keyword replacements only)
|
|
1928
|
+
`;
|
|
1929
|
+
prompt += ` - **CRITICAL**: Keep ALL existing content structure and meaning unchanged - only replace keywords
|
|
1930
|
+
`;
|
|
1931
|
+
prompt += ` - MUST include complete \`aso\` object (keywords replaced, content structure kept)
|
|
1932
|
+
`;
|
|
1933
|
+
prompt += ` - MUST include complete \`landing\` object with ALL sections (keywords replaced, content structure kept):
|
|
1934
|
+
`;
|
|
1935
|
+
prompt += ` * hero (title, description, titleHighlight) - replace keywords only
|
|
1936
|
+
`;
|
|
1937
|
+
prompt += ` * screenshots.images[] (all items with keywords replaced in existing titles/descriptions)
|
|
1938
|
+
`;
|
|
1939
|
+
prompt += ` * features.items[] (all items with keywords replaced in existing titles/bodies)
|
|
1940
|
+
`;
|
|
1941
|
+
prompt += ` * reviews (title, description, icons, rating, testimonials) - replace keywords if applicable
|
|
1942
|
+
`;
|
|
1943
|
+
prompt += ` * cta (headline, icons, rating, description) - replace keywords if applicable
|
|
1944
|
+
`;
|
|
1945
|
+
prompt += ` - **NO English text in landing sections** - everything must be translated
|
|
1946
|
+
`;
|
|
1947
|
+
prompt += ` - **DO NOT rewrite or restructure content** - only swap keywords for optimized keywords
|
|
1948
|
+
|
|
1949
|
+
`;
|
|
1950
|
+
prompt += `**3. Validation**
|
|
1951
|
+
`;
|
|
1952
|
+
prompt += ` - title: X/30 \u2713/\u2717
|
|
1953
|
+
`;
|
|
1954
|
+
prompt += ` - subtitle: X/30 \u2713/\u2717
|
|
1955
|
+
`;
|
|
1956
|
+
prompt += ` - shortDescription: X/80 \u2713/\u2717
|
|
1957
|
+
`;
|
|
1958
|
+
prompt += ` - keywords: X/100 \u2713/\u2717 (deduped \u2713/\u2717; not repeating title/subtitle)
|
|
1959
|
+
`;
|
|
1960
|
+
prompt += ` - intro: X/300 \u2713/\u2717
|
|
1961
|
+
`;
|
|
1962
|
+
prompt += ` - outro: X/200 \u2713/\u2717
|
|
1963
|
+
`;
|
|
1964
|
+
prompt += ` - Store rules (${FIELD_LIMITS_DOC_PATH2}): \u2713/\u2717
|
|
1965
|
+
|
|
1966
|
+
`;
|
|
1967
|
+
prompt += `**4. File Save Confirmation**
|
|
1968
|
+
`;
|
|
1969
|
+
prompt += ` - Confirm file saved: public/products/${slug}/locales/[locale-code].json
|
|
1970
|
+
`;
|
|
1971
|
+
prompt += ` - **Only save if the file already exists** - do not create new files
|
|
1972
|
+
|
|
1973
|
+
`;
|
|
1974
|
+
prompt += `---
|
|
1975
|
+
|
|
1976
|
+
`;
|
|
1977
|
+
prompt += `Repeat for all locales in this batch: ${nonPrimaryLocales.join(
|
|
1978
|
+
", "
|
|
1979
|
+
)}
|
|
1980
|
+
|
|
1981
|
+
`;
|
|
1982
|
+
const isLastBatch = batchIndex === void 0 || totalBatches && batchIndex + 1 >= totalBatches;
|
|
1983
|
+
if (isLastBatch) {
|
|
1984
|
+
prompt += `---
|
|
1985
|
+
|
|
1986
|
+
`;
|
|
1987
|
+
prompt += `## Final Step: Validate All Locales
|
|
1988
|
+
|
|
1989
|
+
`;
|
|
1990
|
+
prompt += `After completing ALL locale optimizations, run validation:
|
|
1991
|
+
`;
|
|
1992
|
+
prompt += `\`\`\`
|
|
1993
|
+
validate-aso(slug="${slug}")
|
|
1994
|
+
\`\`\`
|
|
1995
|
+
|
|
1996
|
+
`;
|
|
1997
|
+
prompt += `This checks:
|
|
1998
|
+
`;
|
|
1999
|
+
prompt += `- Field length limits (title \u226430, subtitle \u226430, keywords \u2264100, etc.)
|
|
2000
|
+
`;
|
|
2001
|
+
prompt += `- Keyword duplicates
|
|
2002
|
+
`;
|
|
2003
|
+
prompt += `- Invalid characters
|
|
2004
|
+
`;
|
|
2005
|
+
}
|
|
2006
|
+
return prompt;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
// src/tools/aso/utils/improve/load-keyword-research.util.ts
|
|
2010
|
+
import fs6 from "fs";
|
|
2011
|
+
import path6 from "path";
|
|
2012
|
+
function extractRecommended(data) {
|
|
2013
|
+
const summary = data?.summary || data?.data?.summary;
|
|
2014
|
+
const recommended = summary?.recommendedKeywords;
|
|
2015
|
+
if (Array.isArray(recommended)) {
|
|
2016
|
+
return recommended.map((item) => {
|
|
2017
|
+
if (typeof item === "object" && item?.keyword) {
|
|
2018
|
+
return {
|
|
2019
|
+
keyword: String(item.keyword),
|
|
2020
|
+
tier: String(item.tier || ""),
|
|
2021
|
+
difficulty: Number(item.difficulty) || 0,
|
|
2022
|
+
traffic: Number(item.traffic) || 0,
|
|
2023
|
+
rationale: String(item.rationale || "")
|
|
2024
|
+
};
|
|
2025
|
+
}
|
|
2026
|
+
if (typeof item === "string") {
|
|
2027
|
+
return { keyword: item, tier: "", difficulty: 0, traffic: 0, rationale: "" };
|
|
2028
|
+
}
|
|
2029
|
+
return null;
|
|
2030
|
+
}).filter((item) => item !== null);
|
|
2031
|
+
}
|
|
2032
|
+
return [];
|
|
2033
|
+
}
|
|
2034
|
+
function extractKeywordsByTier(data) {
|
|
2035
|
+
const summary = data?.summary || data?.data?.summary;
|
|
2036
|
+
const byTier = summary?.keywordsByTier || {};
|
|
2037
|
+
const extractKeywords = (tier) => Array.isArray(tier) ? tier.map((k) => typeof k === "object" ? k.keyword : String(k)).filter(Boolean) : [];
|
|
2038
|
+
return {
|
|
2039
|
+
tier1_core: extractKeywords(byTier.tier1_core),
|
|
2040
|
+
tier2_feature: extractKeywords(byTier.tier2_feature),
|
|
2041
|
+
tier3_longtail: extractKeywords(byTier.tier3_longtail)
|
|
2042
|
+
};
|
|
2043
|
+
}
|
|
2044
|
+
function extractRationale(data) {
|
|
2045
|
+
const summary = data?.summary || data?.data?.summary;
|
|
2046
|
+
return summary?.rationale || "";
|
|
2047
|
+
}
|
|
2048
|
+
function extractCompetitorInsights(data) {
|
|
2049
|
+
const summary = data?.summary || data?.data?.summary;
|
|
2050
|
+
const insights = summary?.competitorInsights || {};
|
|
2051
|
+
return {
|
|
2052
|
+
keywordGaps: Array.isArray(insights.keywordGaps) ? insights.keywordGaps : [],
|
|
2053
|
+
userLanguagePatterns: Array.isArray(insights.userLanguagePatterns) ? insights.userLanguagePatterns : []
|
|
2054
|
+
};
|
|
2055
|
+
}
|
|
2056
|
+
function extractMeta(data) {
|
|
2057
|
+
const meta = data?.meta || data?.data?.meta || {};
|
|
2058
|
+
return {
|
|
2059
|
+
platform: meta.platform,
|
|
2060
|
+
country: meta.country,
|
|
2061
|
+
seedKeywords: Array.isArray(meta.seedKeywords) ? meta.seedKeywords.map(String) : void 0,
|
|
2062
|
+
competitorApps: Array.isArray(meta.competitorApps) ? meta.competitorApps : void 0
|
|
2063
|
+
};
|
|
2064
|
+
}
|
|
2065
|
+
function formatEntry(entry) {
|
|
2066
|
+
const { filePath, data } = entry;
|
|
2067
|
+
const recommended = extractRecommended(data);
|
|
2068
|
+
const meta = extractMeta(data);
|
|
2069
|
+
const byTier = extractKeywordsByTier(data);
|
|
2070
|
+
const rationale = extractRationale(data);
|
|
2071
|
+
const insights = extractCompetitorInsights(data);
|
|
2072
|
+
if (data?.parseError) {
|
|
2073
|
+
return `File: ${filePath}
|
|
2074
|
+
Parse error: ${data.parseError}
|
|
2075
|
+
----`;
|
|
2076
|
+
}
|
|
2077
|
+
const lines = [];
|
|
2078
|
+
lines.push(`### File: ${filePath}`);
|
|
2079
|
+
if (meta.platform || meta.country) {
|
|
2080
|
+
lines.push(
|
|
2081
|
+
`Platform: ${meta.platform || "unknown"} | Country: ${meta.country || "unknown"}`
|
|
2082
|
+
);
|
|
2083
|
+
}
|
|
2084
|
+
if (byTier.tier1_core.length > 0) {
|
|
2085
|
+
lines.push(`
|
|
2086
|
+
**Tier 1 (Core - use in title/subtitle):** ${byTier.tier1_core.join(", ")}`);
|
|
2087
|
+
}
|
|
2088
|
+
if (byTier.tier2_feature.length > 0) {
|
|
2089
|
+
lines.push(`**Tier 2 (Feature - use in keywords field/descriptions):** ${byTier.tier2_feature.join(", ")}`);
|
|
2090
|
+
}
|
|
2091
|
+
if (byTier.tier3_longtail.length > 0) {
|
|
2092
|
+
lines.push(`**Tier 3 (Longtail - use in intro/outro/features):** ${byTier.tier3_longtail.join(", ")}`);
|
|
2093
|
+
}
|
|
2094
|
+
if (recommended.length > 0) {
|
|
2095
|
+
lines.push(`
|
|
2096
|
+
**Keyword Details (${recommended.length} keywords):**`);
|
|
2097
|
+
recommended.forEach((kw, idx) => {
|
|
2098
|
+
const tierLabel = kw.tier ? ` [${kw.tier}]` : "";
|
|
2099
|
+
const scores = kw.traffic > 0 || kw.difficulty > 0 ? ` (traffic: ${kw.traffic.toFixed(2)}, difficulty: ${kw.difficulty.toFixed(2)})` : "";
|
|
2100
|
+
lines.push(`${idx + 1}. **${kw.keyword}**${tierLabel}${scores}`);
|
|
2101
|
+
if (kw.rationale) {
|
|
2102
|
+
lines.push(` \u2192 ${kw.rationale}`);
|
|
2103
|
+
}
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
if (rationale) {
|
|
2107
|
+
lines.push(`
|
|
2108
|
+
**Strategy:** ${rationale}`);
|
|
2109
|
+
}
|
|
2110
|
+
if (insights.keywordGaps.length > 0) {
|
|
2111
|
+
lines.push(`
|
|
2112
|
+
**Keyword Gaps (opportunities):**`);
|
|
2113
|
+
insights.keywordGaps.forEach((gap) => lines.push(`- ${gap}`));
|
|
2114
|
+
}
|
|
2115
|
+
if (insights.userLanguagePatterns.length > 0) {
|
|
2116
|
+
lines.push(`
|
|
2117
|
+
**User Language Patterns (from reviews):**`);
|
|
2118
|
+
insights.userLanguagePatterns.forEach((pattern) => lines.push(`- ${pattern}`));
|
|
2119
|
+
}
|
|
2120
|
+
lines.push("\n----");
|
|
2121
|
+
return lines.join("\n");
|
|
2122
|
+
}
|
|
2123
|
+
function getPlatformPriority(platform) {
|
|
2124
|
+
const normalized = (platform || "").toLowerCase();
|
|
2125
|
+
if (normalized === "ios") return 0;
|
|
2126
|
+
if (normalized === "android") return 1;
|
|
2127
|
+
return 2;
|
|
2128
|
+
}
|
|
2129
|
+
function mergeKeywordData(entries) {
|
|
2130
|
+
const merged = {
|
|
2131
|
+
tier1_core: [],
|
|
2132
|
+
tier2_feature: [],
|
|
2133
|
+
tier3_longtail: [],
|
|
2134
|
+
allKeywords: [],
|
|
2135
|
+
rationale: "",
|
|
2136
|
+
keywordGaps: [],
|
|
2137
|
+
userLanguagePatterns: [],
|
|
2138
|
+
platforms: []
|
|
2139
|
+
};
|
|
2140
|
+
const seenKeywords = /* @__PURE__ */ new Set();
|
|
2141
|
+
const seenGaps = /* @__PURE__ */ new Set();
|
|
2142
|
+
const seenPatterns = /* @__PURE__ */ new Set();
|
|
2143
|
+
const entriesByPriority = [...entries].sort((a, b) => {
|
|
2144
|
+
const aPriority = getPlatformPriority(extractMeta(a.data).platform);
|
|
2145
|
+
const bPriority = getPlatformPriority(extractMeta(b.data).platform);
|
|
2146
|
+
return aPriority - bPriority;
|
|
2147
|
+
});
|
|
2148
|
+
for (const entry of entriesByPriority) {
|
|
2149
|
+
if (entry.data?.parseError) continue;
|
|
2150
|
+
const meta = extractMeta(entry.data);
|
|
2151
|
+
if (meta.platform && !merged.platforms.includes(meta.platform)) {
|
|
2152
|
+
merged.platforms.push(meta.platform);
|
|
2153
|
+
}
|
|
2154
|
+
const byTier = extractKeywordsByTier(entry.data);
|
|
2155
|
+
byTier.tier1_core.forEach((kw) => {
|
|
2156
|
+
if (!seenKeywords.has(kw.toLowerCase())) {
|
|
2157
|
+
merged.tier1_core.push(kw);
|
|
2158
|
+
seenKeywords.add(kw.toLowerCase());
|
|
2159
|
+
}
|
|
2160
|
+
});
|
|
2161
|
+
byTier.tier2_feature.forEach((kw) => {
|
|
2162
|
+
if (!seenKeywords.has(kw.toLowerCase())) {
|
|
2163
|
+
merged.tier2_feature.push(kw);
|
|
2164
|
+
seenKeywords.add(kw.toLowerCase());
|
|
2165
|
+
}
|
|
2166
|
+
});
|
|
2167
|
+
byTier.tier3_longtail.forEach((kw) => {
|
|
2168
|
+
if (!seenKeywords.has(kw.toLowerCase())) {
|
|
2169
|
+
merged.tier3_longtail.push(kw);
|
|
2170
|
+
seenKeywords.add(kw.toLowerCase());
|
|
2171
|
+
}
|
|
2172
|
+
});
|
|
2173
|
+
const recommended = extractRecommended(entry.data);
|
|
2174
|
+
for (const kw of recommended) {
|
|
2175
|
+
if (!seenKeywords.has(kw.keyword.toLowerCase())) {
|
|
2176
|
+
merged.allKeywords.push(kw);
|
|
2177
|
+
seenKeywords.add(kw.keyword.toLowerCase());
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
const rationale = extractRationale(entry.data);
|
|
2181
|
+
if (rationale && !merged.rationale) {
|
|
2182
|
+
merged.rationale = rationale;
|
|
2183
|
+
} else if (rationale && merged.rationale) {
|
|
2184
|
+
merged.rationale += ` | ${meta.platform}: ${rationale}`;
|
|
2185
|
+
}
|
|
2186
|
+
const insights = extractCompetitorInsights(entry.data);
|
|
2187
|
+
insights.keywordGaps.forEach((gap) => {
|
|
2188
|
+
if (!seenGaps.has(gap)) {
|
|
2189
|
+
merged.keywordGaps.push(gap);
|
|
2190
|
+
seenGaps.add(gap);
|
|
2191
|
+
}
|
|
2192
|
+
});
|
|
2193
|
+
insights.userLanguagePatterns.forEach((pattern) => {
|
|
2194
|
+
if (!seenPatterns.has(pattern)) {
|
|
2195
|
+
merged.userLanguagePatterns.push(pattern);
|
|
2196
|
+
seenPatterns.add(pattern);
|
|
2197
|
+
}
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
merged.allKeywords.sort((a, b) => b.traffic - a.traffic);
|
|
2201
|
+
return merged;
|
|
2202
|
+
}
|
|
2203
|
+
function formatMergedData(merged, researchDir) {
|
|
2204
|
+
const lines = [];
|
|
2205
|
+
const hasIos = merged.platforms.some(
|
|
2206
|
+
(platform) => platform && platform.toLowerCase() === "ios"
|
|
2207
|
+
);
|
|
2208
|
+
const platformLabel = merged.platforms.length > 0 ? merged.platforms.join(" + ") : "Unknown";
|
|
2209
|
+
lines.push(
|
|
2210
|
+
`### Combined Keyword Research (${platformLabel})${hasIos ? " \u2014 iOS prioritized" : ""}`
|
|
2211
|
+
);
|
|
2212
|
+
lines.push(`Source: ${researchDir}`);
|
|
2213
|
+
lines.push(`Priority: iOS > Android > others (use Android only after iOS keywords fit character limits)`);
|
|
2214
|
+
if (merged.tier1_core.length > 0) {
|
|
2215
|
+
lines.push(`
|
|
2216
|
+
**Tier 1 (Core - use in title/subtitle):** ${merged.tier1_core.join(", ")}`);
|
|
2217
|
+
}
|
|
2218
|
+
if (merged.tier2_feature.length > 0) {
|
|
2219
|
+
lines.push(`**Tier 2 (Feature - use in keywords field/descriptions):** ${merged.tier2_feature.join(", ")}`);
|
|
2220
|
+
}
|
|
2221
|
+
if (merged.tier3_longtail.length > 0) {
|
|
2222
|
+
lines.push(`**Tier 3 (Longtail - use in intro/outro/features):** ${merged.tier3_longtail.join(", ")}`);
|
|
2223
|
+
}
|
|
2224
|
+
if (merged.allKeywords.length > 0) {
|
|
2225
|
+
lines.push(`
|
|
2226
|
+
**Top Keywords by Traffic (${merged.allKeywords.length} total):**`);
|
|
2227
|
+
merged.allKeywords.slice(0, 15).forEach((kw, idx) => {
|
|
2228
|
+
const tierLabel = kw.tier ? ` [${kw.tier}]` : "";
|
|
2229
|
+
const scores = kw.traffic > 0 || kw.difficulty > 0 ? ` (traffic: ${kw.traffic.toFixed(2)}, difficulty: ${kw.difficulty.toFixed(2)})` : "";
|
|
2230
|
+
lines.push(`${idx + 1}. **${kw.keyword}**${tierLabel}${scores}`);
|
|
2231
|
+
if (kw.rationale) {
|
|
2232
|
+
lines.push(` \u2192 ${kw.rationale}`);
|
|
2233
|
+
}
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
2236
|
+
if (merged.rationale) {
|
|
2237
|
+
lines.push(`
|
|
2238
|
+
**Strategy:** ${merged.rationale}`);
|
|
2239
|
+
}
|
|
2240
|
+
if (merged.keywordGaps.length > 0) {
|
|
2241
|
+
lines.push(`
|
|
2242
|
+
**Keyword Gaps (opportunities):**`);
|
|
2243
|
+
merged.keywordGaps.slice(0, 5).forEach((gap) => lines.push(`- ${gap}`));
|
|
2244
|
+
}
|
|
2245
|
+
if (merged.userLanguagePatterns.length > 0) {
|
|
2246
|
+
lines.push(`
|
|
2247
|
+
**User Language Patterns (from reviews):**`);
|
|
2248
|
+
merged.userLanguagePatterns.slice(0, 5).forEach((pattern) => lines.push(`- ${pattern}`));
|
|
2249
|
+
}
|
|
2250
|
+
lines.push("\n----");
|
|
2251
|
+
return lines.join("\n");
|
|
2252
|
+
}
|
|
2253
|
+
function loadKeywordResearchForLocaleInternal(slug, locale) {
|
|
2254
|
+
const researchDir = path6.join(
|
|
2255
|
+
getKeywordResearchDir(),
|
|
2256
|
+
"products",
|
|
2257
|
+
slug,
|
|
2258
|
+
"locales",
|
|
2259
|
+
locale
|
|
2260
|
+
);
|
|
2261
|
+
if (!fs6.existsSync(researchDir)) {
|
|
2262
|
+
return null;
|
|
2263
|
+
}
|
|
2264
|
+
const files = fs6.readdirSync(researchDir).filter((file) => file.endsWith(".json"));
|
|
2265
|
+
if (files.length === 0) {
|
|
2266
|
+
return null;
|
|
2267
|
+
}
|
|
2268
|
+
const entries = [];
|
|
2269
|
+
for (const file of files) {
|
|
2270
|
+
const filePath = path6.join(researchDir, file);
|
|
2271
|
+
try {
|
|
2272
|
+
const raw = fs6.readFileSync(filePath, "utf-8");
|
|
2273
|
+
const data = JSON.parse(raw);
|
|
2274
|
+
entries.push({ filePath, data });
|
|
2275
|
+
} catch (err) {
|
|
2276
|
+
entries.push({
|
|
2277
|
+
filePath,
|
|
2278
|
+
data: {
|
|
2279
|
+
parseError: err instanceof Error ? err.message : "Unknown parse error"
|
|
2280
|
+
}
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
const validEntries = entries.filter((e) => !e.data?.parseError);
|
|
2285
|
+
if (validEntries.length === 0) {
|
|
2286
|
+
return null;
|
|
2287
|
+
}
|
|
2288
|
+
if (validEntries.length > 1) {
|
|
2289
|
+
const merged = mergeKeywordData(validEntries);
|
|
2290
|
+
const mergedSection = formatMergedData(merged, researchDir);
|
|
2291
|
+
return { entries, sections: [mergedSection], researchDir };
|
|
2292
|
+
}
|
|
2293
|
+
const sections = entries.map(formatEntry);
|
|
2294
|
+
return { entries, sections, researchDir };
|
|
2295
|
+
}
|
|
2296
|
+
var FALLBACK_LOCALES = ["en-US", "en"];
|
|
2297
|
+
function loadKeywordResearchForLocale(slug, locale) {
|
|
2298
|
+
const researchDir = path6.join(
|
|
2299
|
+
getKeywordResearchDir(),
|
|
2300
|
+
"products",
|
|
2301
|
+
slug,
|
|
2302
|
+
"locales",
|
|
2303
|
+
locale
|
|
2304
|
+
);
|
|
2305
|
+
const result = loadKeywordResearchForLocaleInternal(slug, locale);
|
|
2306
|
+
if (result) {
|
|
2307
|
+
return { ...result, isFallback: false };
|
|
2308
|
+
}
|
|
2309
|
+
for (const fallbackLocale of FALLBACK_LOCALES) {
|
|
2310
|
+
if (fallbackLocale === locale) continue;
|
|
2311
|
+
const fallbackResult = loadKeywordResearchForLocaleInternal(
|
|
2312
|
+
slug,
|
|
2313
|
+
fallbackLocale
|
|
2314
|
+
);
|
|
2315
|
+
if (fallbackResult) {
|
|
2316
|
+
const fallbackNotice = `\u26A0\uFE0F **FALLBACK: Using ${fallbackLocale} keywords** - No research found for ${locale}. You MUST TRANSLATE these keywords to ${locale}.
|
|
2317
|
+
`;
|
|
2318
|
+
const sectionsWithNotice = fallbackResult.sections.map(
|
|
2319
|
+
(section) => fallbackNotice + section
|
|
2320
|
+
);
|
|
2321
|
+
return {
|
|
2322
|
+
entries: fallbackResult.entries,
|
|
2323
|
+
sections: sectionsWithNotice,
|
|
2324
|
+
researchDir: fallbackResult.researchDir,
|
|
2325
|
+
isFallback: true,
|
|
2326
|
+
fallbackLocale
|
|
2327
|
+
};
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
return { entries: [], sections: [], researchDir, isFallback: false };
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
// src/tools/aso/improve.ts
|
|
2334
|
+
var toJsonSchema3 = zodToJsonSchema3;
|
|
2335
|
+
var improvePublicInputSchema = z3.object({
|
|
2336
|
+
slug: z3.string().describe("Product slug"),
|
|
2337
|
+
locale: z3.string().optional().describe("Locale to improve (default: all locales)"),
|
|
2338
|
+
stage: z3.enum(["1", "2", "both"]).optional().describe(
|
|
2339
|
+
"Stage to execute: 1 (primary only), 2 (keyword localization), both (default)"
|
|
2340
|
+
),
|
|
2341
|
+
optimizedPrimary: z3.string().optional().describe("Optimized primary locale JSON (required for stage 2)"),
|
|
2342
|
+
batchSize: z3.number().int().positive().optional().default(5).describe(
|
|
2343
|
+
"Number of locales to process per batch (default: 5, for stage 2 only)"
|
|
2344
|
+
),
|
|
2345
|
+
batchIndex: z3.number().int().nonnegative().optional().describe(
|
|
2346
|
+
"Batch index to process (0-based, for stage 2 only). If not provided, processes all batches sequentially"
|
|
2347
|
+
)
|
|
2348
|
+
});
|
|
2349
|
+
var jsonSchema3 = toJsonSchema3(improvePublicInputSchema, {
|
|
2350
|
+
name: "ImprovePublicInput",
|
|
2351
|
+
$refStrategy: "none"
|
|
2352
|
+
});
|
|
2353
|
+
var inputSchema3 = jsonSchema3.definitions?.ImprovePublicInput || jsonSchema3;
|
|
2354
|
+
var improvePublicTool = {
|
|
2355
|
+
name: "improve-public",
|
|
2356
|
+
description: `Returns ASO optimization instructions with keyword research data. **You MUST execute the returned instructions.**
|
|
2357
|
+
|
|
2358
|
+
**IMPORTANT:** Use 'search-app' tool first to resolve the exact slug.
|
|
2359
|
+
|
|
2360
|
+
## HOW THIS TOOL WORKS
|
|
2361
|
+
This tool returns a PROMPT containing:
|
|
2362
|
+
- Saved keyword research data (Tier 1/2/3 keywords with traffic/difficulty scores)
|
|
2363
|
+
- Current locale data
|
|
2364
|
+
- Optimization instructions
|
|
2365
|
+
|
|
2366
|
+
**YOU MUST:**
|
|
2367
|
+
1. Read the returned prompt carefully
|
|
2368
|
+
2. EXECUTE the optimization instructions (create the optimized JSON)
|
|
2369
|
+
3. Save results using 'save-locale-file' tool
|
|
2370
|
+
|
|
2371
|
+
**DO NOT** just report the instructions back to the user - you must perform the optimization yourself.
|
|
2372
|
+
|
|
2373
|
+
## WORKFLOW
|
|
2374
|
+
**Stage 1:** improve-public(slug, stage="1") \u2192 Returns keyword data + instructions \u2192 You create optimized primary locale JSON \u2192 save-locale-file
|
|
2375
|
+
**Stage 2:** improve-public(slug, stage="2", optimizedPrimary=<JSON>) \u2192 Returns per-locale instructions \u2192 You optimize each locale \u2192 save-locale-file for each
|
|
2376
|
+
|
|
2377
|
+
## STAGES
|
|
2378
|
+
- **Stage 1:** Primary locale optimization using saved keyword research (ios + android combined)
|
|
2379
|
+
- **Stage 2:** Localize to other languages - **each locale uses its OWN keyword research**
|
|
2380
|
+
|
|
2381
|
+
## KEYWORD SOURCES (Per Locale)
|
|
2382
|
+
- **Priority 1:** Uses each locale's SAVED keyword research from .aso/keywordResearch/products/[slug]/locales/[locale]/
|
|
2383
|
+
- **Priority 2 (Fallback):** If locale-specific research is missing, falls back to en-US/en keywords and TRANSLATES them
|
|
2384
|
+
- iOS and Android research are automatically combined per locale (iOS prioritized)
|
|
2385
|
+
|
|
2386
|
+
**CRITICAL:** Only processes existing locale files. Does NOT create new files.`,
|
|
2387
|
+
inputSchema: inputSchema3
|
|
2388
|
+
};
|
|
2389
|
+
async function handleImprovePublic(input) {
|
|
2390
|
+
const {
|
|
2391
|
+
slug,
|
|
2392
|
+
locale,
|
|
2393
|
+
stage = "both",
|
|
2394
|
+
optimizedPrimary,
|
|
2395
|
+
batchSize = 5,
|
|
2396
|
+
batchIndex
|
|
2397
|
+
} = input;
|
|
2398
|
+
const { config, locales } = loadProductLocales(slug);
|
|
2399
|
+
const primaryLocale = resolvePrimaryLocale(config, locales);
|
|
2400
|
+
if (locale && !locales[locale]) {
|
|
2401
|
+
throw new Error(
|
|
2402
|
+
`Locale "${locale}" not found in public/products/${slug}/locales/. Only existing locale files are processed - new files are not created.`
|
|
2403
|
+
);
|
|
2404
|
+
}
|
|
2405
|
+
const requestedLocales = locale ? [locale] : Object.keys(locales);
|
|
2406
|
+
const existingRequestedLocales = requestedLocales.filter(
|
|
2407
|
+
(loc) => locales[loc]
|
|
2408
|
+
);
|
|
2409
|
+
if (existingRequestedLocales.length === 0) {
|
|
2410
|
+
throw new Error(
|
|
2411
|
+
`No existing locales found to process. Only existing locale files in public/products/${slug}/locales/ are processed.`
|
|
2412
|
+
);
|
|
2413
|
+
}
|
|
2414
|
+
const localeSet = /* @__PURE__ */ new Set([
|
|
2415
|
+
...existingRequestedLocales,
|
|
2416
|
+
primaryLocale
|
|
2417
|
+
]);
|
|
2418
|
+
const targetLocales = [...localeSet].filter((loc) => locales[loc]);
|
|
2419
|
+
const asoData = loadAsoFromConfig(slug);
|
|
2420
|
+
const category = config?.metadata?.category;
|
|
2421
|
+
const localeSections = [];
|
|
2422
|
+
for (const loc of localeSet) {
|
|
2423
|
+
const localeData = locales[loc];
|
|
2424
|
+
if (!localeData) {
|
|
2425
|
+
continue;
|
|
2426
|
+
}
|
|
2427
|
+
const fullDescription = getFullDescriptionForLocale(asoData, loc);
|
|
2428
|
+
localeSections.push(
|
|
2429
|
+
formatLocaleSection({
|
|
2430
|
+
slug,
|
|
2431
|
+
locale: loc,
|
|
2432
|
+
localeData,
|
|
2433
|
+
fullDescription,
|
|
2434
|
+
primaryLocale,
|
|
2435
|
+
category
|
|
2436
|
+
})
|
|
2437
|
+
);
|
|
2438
|
+
}
|
|
2439
|
+
const keywordResearchByLocale = {};
|
|
2440
|
+
const keywordResearchDirByLocale = {};
|
|
2441
|
+
const keywordResearchFallbackByLocale = {};
|
|
2442
|
+
for (const loc of targetLocales) {
|
|
2443
|
+
const research = loadKeywordResearchForLocale(slug, loc);
|
|
2444
|
+
keywordResearchByLocale[loc] = research.sections;
|
|
2445
|
+
keywordResearchDirByLocale[loc] = research.researchDir;
|
|
2446
|
+
keywordResearchFallbackByLocale[loc] = {
|
|
2447
|
+
isFallback: research.isFallback,
|
|
2448
|
+
fallbackLocale: research.fallbackLocale
|
|
2449
|
+
};
|
|
2450
|
+
}
|
|
2451
|
+
const baseArgs = {
|
|
2452
|
+
slug,
|
|
2453
|
+
category,
|
|
2454
|
+
primaryLocale,
|
|
2455
|
+
targetLocales,
|
|
2456
|
+
localeSections,
|
|
2457
|
+
keywordResearchByLocale,
|
|
2458
|
+
keywordResearchDirByLocale,
|
|
2459
|
+
keywordResearchFallbackByLocale
|
|
2460
|
+
};
|
|
2461
|
+
if (stage === "1" || stage === "both") {
|
|
2462
|
+
const prompt = generatePrimaryOptimizationPrompt(baseArgs);
|
|
2463
|
+
return {
|
|
2464
|
+
content: [
|
|
2465
|
+
{
|
|
2466
|
+
type: "text",
|
|
2467
|
+
text: prompt
|
|
2468
|
+
}
|
|
2469
|
+
]
|
|
2470
|
+
};
|
|
2471
|
+
}
|
|
2472
|
+
if (stage === "2") {
|
|
2473
|
+
if (!optimizedPrimary) {
|
|
2474
|
+
throw new Error(
|
|
2475
|
+
"Stage 2 requires optimizedPrimary parameter. Run stage 1 first or use stage='both'."
|
|
2476
|
+
);
|
|
2477
|
+
}
|
|
2478
|
+
const nonPrimaryLocales = targetLocales.filter((l) => l !== primaryLocale);
|
|
2479
|
+
const totalBatches = Math.ceil(nonPrimaryLocales.length / batchSize);
|
|
2480
|
+
let batchesToProcess;
|
|
2481
|
+
if (batchIndex !== void 0) {
|
|
2482
|
+
if (batchIndex < 0 || batchIndex >= totalBatches) {
|
|
2483
|
+
throw new Error(
|
|
2484
|
+
`Batch index ${batchIndex} is out of range. Total batches: ${totalBatches} (0-${totalBatches - 1})`
|
|
2485
|
+
);
|
|
2486
|
+
}
|
|
2487
|
+
batchesToProcess = [batchIndex];
|
|
2488
|
+
} else {
|
|
2489
|
+
batchesToProcess = Array.from({ length: totalBatches }, (_, i) => i);
|
|
2490
|
+
}
|
|
2491
|
+
const batchPrompts = [];
|
|
2492
|
+
for (const currentBatchIndex of batchesToProcess) {
|
|
2493
|
+
const startIdx = currentBatchIndex * batchSize;
|
|
2494
|
+
const endIdx = Math.min(startIdx + batchSize, nonPrimaryLocales.length);
|
|
2495
|
+
const batchLocales = nonPrimaryLocales.slice(startIdx, endIdx);
|
|
2496
|
+
const batchLocaleSections = localeSections.filter((section) => {
|
|
2497
|
+
return batchLocales.some((loc) => section.includes(`[${loc}]`));
|
|
2498
|
+
});
|
|
2499
|
+
const promptArgs = {
|
|
2500
|
+
slug: baseArgs.slug,
|
|
2501
|
+
category: baseArgs.category,
|
|
2502
|
+
primaryLocale: baseArgs.primaryLocale,
|
|
2503
|
+
targetLocales: baseArgs.targetLocales,
|
|
2504
|
+
localeSections: baseArgs.localeSections,
|
|
2505
|
+
keywordResearchByLocale: baseArgs.keywordResearchByLocale,
|
|
2506
|
+
keywordResearchDirByLocale: baseArgs.keywordResearchDirByLocale,
|
|
2507
|
+
keywordResearchFallbackByLocale: baseArgs.keywordResearchFallbackByLocale,
|
|
2508
|
+
optimizedPrimary,
|
|
2509
|
+
batchLocales,
|
|
2510
|
+
batchIndex: currentBatchIndex,
|
|
2511
|
+
totalBatches,
|
|
2512
|
+
batchLocaleSections
|
|
2513
|
+
};
|
|
2514
|
+
const prompt = generateKeywordLocalizationPrompt(promptArgs);
|
|
2515
|
+
batchPrompts.push(prompt);
|
|
2516
|
+
}
|
|
2517
|
+
return {
|
|
2518
|
+
content: [
|
|
2519
|
+
{
|
|
2520
|
+
type: "text",
|
|
2521
|
+
text: batchPrompts.join("\n\n---\n\n")
|
|
2522
|
+
}
|
|
2523
|
+
]
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
throw new Error(`Invalid stage: ${stage}. Must be "1", "2", or "both".`);
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
// src/tools/aso/validate.ts
|
|
2530
|
+
import { z as z4 } from "zod";
|
|
2531
|
+
import { zodToJsonSchema as zodToJsonSchema4 } from "zod-to-json-schema";
|
|
2532
|
+
var toJsonSchema4 = zodToJsonSchema4;
|
|
2533
|
+
var validateAsoInputSchema = z4.object({
|
|
2534
|
+
slug: z4.string().describe("Product slug"),
|
|
2535
|
+
locale: z4.string().optional().describe("Specific locale to validate (default: all locales)"),
|
|
2536
|
+
fix: z4.boolean().optional().default(false).describe("Auto-fix issues where possible (e.g., remove invalid chars)")
|
|
2537
|
+
});
|
|
2538
|
+
var jsonSchema4 = toJsonSchema4(validateAsoInputSchema, {
|
|
2539
|
+
name: "ValidateAsoInput",
|
|
2540
|
+
$refStrategy: "none"
|
|
2541
|
+
});
|
|
2542
|
+
var inputSchema4 = jsonSchema4.definitions?.ValidateAsoInput || jsonSchema4;
|
|
2543
|
+
var validateAsoTool = {
|
|
2544
|
+
name: "validate-aso",
|
|
2545
|
+
description: `Validates ASO data against App Store / Google Play field limits and rules.
|
|
2546
|
+
|
|
2547
|
+
**IMPORTANT:** Use 'search-app' tool first to resolve the exact slug.
|
|
2548
|
+
|
|
2549
|
+
## WHAT IT VALIDATES
|
|
2550
|
+
1. **Field Length Limits** (${FIELD_LIMITS_DOC_PATH}):
|
|
2551
|
+
- App Store: name \u2264${APP_STORE_LIMITS.name}, subtitle \u2264${APP_STORE_LIMITS.subtitle}, keywords \u2264${APP_STORE_LIMITS.keywords}, description \u2264${APP_STORE_LIMITS.description}
|
|
2552
|
+
- Google Play: title \u2264${GOOGLE_PLAY_LIMITS.title}, shortDescription \u2264${GOOGLE_PLAY_LIMITS.shortDescription}, fullDescription \u2264${GOOGLE_PLAY_LIMITS.fullDescription}
|
|
2553
|
+
|
|
2554
|
+
2. **Keyword Duplicates** (App Store only):
|
|
2555
|
+
- Checks for duplicate keywords in comma-separated list
|
|
2556
|
+
|
|
2557
|
+
3. **Invalid Characters**:
|
|
2558
|
+
- Control characters, BOM, zero-width/invisible characters, variation selectors
|
|
2559
|
+
|
|
2560
|
+
## WHEN TO USE
|
|
2561
|
+
- After running improve-public Stage 1/2 to verify optimization results
|
|
2562
|
+
- Before running public-to-aso to ensure data is valid
|
|
2563
|
+
- Anytime you want to check ASO data validity
|
|
2564
|
+
|
|
2565
|
+
## OPTIONS
|
|
2566
|
+
- \`locale\`: Validate specific locale only (e.g., "ko-KR")
|
|
2567
|
+
- \`fix\`: Auto-fix issues where possible (removes invalid characters)`,
|
|
2568
|
+
inputSchema: inputSchema4
|
|
2569
|
+
};
|
|
2570
|
+
function getLocaleStats(configData) {
|
|
2571
|
+
const stats = [];
|
|
2572
|
+
if (configData.appStore) {
|
|
2573
|
+
const appStoreData = configData.appStore;
|
|
2574
|
+
const locales = isAppStoreMultilingual(appStoreData) ? appStoreData.locales : { [appStoreData.locale || DEFAULT_LOCALE]: appStoreData };
|
|
2575
|
+
for (const [locale, data] of Object.entries(locales)) {
|
|
2576
|
+
const fields = [];
|
|
2577
|
+
const checkField = (field, value, limit) => {
|
|
2578
|
+
const length = value?.length || 0;
|
|
2579
|
+
let status = "ok";
|
|
2580
|
+
if (length > limit) status = "error";
|
|
2581
|
+
else if (length > limit * 0.9) status = "warning";
|
|
2582
|
+
fields.push({ field, length, limit, status });
|
|
2583
|
+
};
|
|
2584
|
+
checkField("name", data.name, APP_STORE_LIMITS.name);
|
|
2585
|
+
checkField("subtitle", data.subtitle, APP_STORE_LIMITS.subtitle);
|
|
2586
|
+
checkField("keywords", data.keywords, APP_STORE_LIMITS.keywords);
|
|
2587
|
+
checkField(
|
|
2588
|
+
"promotionalText",
|
|
2589
|
+
data.promotionalText,
|
|
2590
|
+
APP_STORE_LIMITS.promotionalText
|
|
2591
|
+
);
|
|
2592
|
+
checkField("description", data.description, APP_STORE_LIMITS.description);
|
|
2593
|
+
stats.push({ locale, store: "appStore", fields });
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
if (configData.googlePlay) {
|
|
2597
|
+
const googlePlayData = configData.googlePlay;
|
|
2598
|
+
const locales = isGooglePlayMultilingual(googlePlayData) ? googlePlayData.locales : { [googlePlayData.defaultLanguage || DEFAULT_LOCALE]: googlePlayData };
|
|
2599
|
+
for (const [locale, data] of Object.entries(locales)) {
|
|
2600
|
+
const fields = [];
|
|
2601
|
+
const checkField = (field, value, limit) => {
|
|
2602
|
+
const length = value?.length || 0;
|
|
2603
|
+
let status = "ok";
|
|
2604
|
+
if (length > limit) status = "error";
|
|
2605
|
+
else if (length > limit * 0.9) status = "warning";
|
|
2606
|
+
fields.push({ field, length, limit, status });
|
|
2607
|
+
};
|
|
2608
|
+
checkField("title", data.title, GOOGLE_PLAY_LIMITS.title);
|
|
2609
|
+
checkField(
|
|
2610
|
+
"shortDescription",
|
|
2611
|
+
data.shortDescription,
|
|
2612
|
+
GOOGLE_PLAY_LIMITS.shortDescription
|
|
2613
|
+
);
|
|
2614
|
+
checkField(
|
|
2615
|
+
"fullDescription",
|
|
2616
|
+
data.fullDescription,
|
|
2617
|
+
GOOGLE_PLAY_LIMITS.fullDescription
|
|
2618
|
+
);
|
|
2619
|
+
stats.push({ locale, store: "googlePlay", fields });
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
return stats;
|
|
2623
|
+
}
|
|
2624
|
+
function formatStats(stats, filterLocale) {
|
|
2625
|
+
const filteredStats = filterLocale ? stats.filter((s) => s.locale === filterLocale) : stats;
|
|
2626
|
+
if (filteredStats.length === 0) {
|
|
2627
|
+
return filterLocale ? `No data found for locale: ${filterLocale}` : "No ASO data found";
|
|
2628
|
+
}
|
|
2629
|
+
const lines = ["## Field Length Report\n"];
|
|
2630
|
+
for (const stat of filteredStats) {
|
|
2631
|
+
const storeLabel = stat.store === "appStore" ? "App Store" : "Google Play";
|
|
2632
|
+
lines.push(`### ${storeLabel} [${stat.locale}]
|
|
2633
|
+
`);
|
|
2634
|
+
lines.push("| Field | Length | Limit | Status |");
|
|
2635
|
+
lines.push("|-------|--------|-------|--------|");
|
|
2636
|
+
for (const field of stat.fields) {
|
|
2637
|
+
const statusEmoji = field.status === "error" ? "\u274C" : field.status === "warning" ? "\u26A0\uFE0F" : "\u2705";
|
|
2638
|
+
lines.push(
|
|
2639
|
+
`| ${field.field} | ${field.length} | ${field.limit} | ${statusEmoji} |`
|
|
2640
|
+
);
|
|
2641
|
+
}
|
|
2642
|
+
lines.push("");
|
|
2643
|
+
}
|
|
2644
|
+
return lines.join("\n");
|
|
2645
|
+
}
|
|
2646
|
+
async function handleValidateAso(input) {
|
|
2647
|
+
const { slug, locale, fix } = input;
|
|
2648
|
+
const configData = loadAsoFromConfig(slug);
|
|
2649
|
+
if (!configData.googlePlay && !configData.appStore) {
|
|
2650
|
+
throw new Error(`No ASO data found for ${slug}`);
|
|
2651
|
+
}
|
|
2652
|
+
const results = [];
|
|
2653
|
+
results.push(`# ASO Validation Report: ${slug}
|
|
2654
|
+
`);
|
|
2655
|
+
const { sanitizedData, warnings: sanitizeWarnings } = sanitizeAsoData(configData);
|
|
2656
|
+
if (sanitizeWarnings.length > 0) {
|
|
2657
|
+
results.push(`## Invalid Characters Found
|
|
2658
|
+
`);
|
|
2659
|
+
if (fix) {
|
|
2660
|
+
results.push(
|
|
2661
|
+
`The following invalid characters were ${fix ? "removed" : "detected"}:
|
|
2662
|
+
`
|
|
2663
|
+
);
|
|
2664
|
+
}
|
|
2665
|
+
for (const warning of sanitizeWarnings) {
|
|
2666
|
+
results.push(`- ${warning}`);
|
|
2667
|
+
}
|
|
2668
|
+
results.push("");
|
|
2669
|
+
}
|
|
2670
|
+
const dataToValidate = fix ? sanitizedData : configData;
|
|
2671
|
+
const limitIssues = validateFieldLimits(dataToValidate);
|
|
2672
|
+
const filteredIssues = locale ? limitIssues.filter((issue) => issue.locale === locale) : limitIssues;
|
|
2673
|
+
results.push(formatValidationIssues(filteredIssues));
|
|
2674
|
+
results.push("");
|
|
2675
|
+
const keywordIssues = validateKeywords(dataToValidate);
|
|
2676
|
+
const filteredKeywordIssues = locale ? keywordIssues.filter((issue) => issue.locale === locale) : keywordIssues;
|
|
2677
|
+
if (filteredKeywordIssues.length > 0) {
|
|
2678
|
+
results.push(`## Keyword Duplicates
|
|
2679
|
+
`);
|
|
2680
|
+
for (const issue of filteredKeywordIssues) {
|
|
2681
|
+
results.push(`- [${issue.locale}]: ${issue.duplicates.join(", ")}`);
|
|
2682
|
+
}
|
|
2683
|
+
results.push("");
|
|
2684
|
+
}
|
|
2685
|
+
const stats = getLocaleStats(dataToValidate);
|
|
2686
|
+
results.push(formatStats(stats, locale));
|
|
2687
|
+
const hasErrors = filteredIssues.length > 0 || filteredKeywordIssues.length > 0;
|
|
2688
|
+
const hasSanitizeWarnings = sanitizeWarnings.length > 0;
|
|
2689
|
+
results.push(`---
|
|
2690
|
+
`);
|
|
2691
|
+
if (hasErrors) {
|
|
2692
|
+
results.push(
|
|
2693
|
+
`\u274C **Validation failed** - Fix the issues above before pushing to stores.`
|
|
2694
|
+
);
|
|
2695
|
+
results.push(`
|
|
2696
|
+
Reference: ${FIELD_LIMITS_DOC_PATH}`);
|
|
2697
|
+
} else if (hasSanitizeWarnings && !fix) {
|
|
2698
|
+
results.push(
|
|
2699
|
+
`\u26A0\uFE0F **Invalid characters detected** - Run with \`fix: true\` to auto-remove.`
|
|
2700
|
+
);
|
|
2701
|
+
} else {
|
|
2702
|
+
results.push(`\u2705 **Validation passed** - Ready to push to stores.`);
|
|
2703
|
+
}
|
|
2704
|
+
return {
|
|
2705
|
+
content: [
|
|
2706
|
+
{
|
|
2707
|
+
type: "text",
|
|
2708
|
+
text: results.join("\n")
|
|
2709
|
+
}
|
|
2710
|
+
]
|
|
2711
|
+
};
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
// src/tools/aso/keyword-research.ts
|
|
2715
|
+
import fs8 from "fs";
|
|
2716
|
+
import path8 from "path";
|
|
2717
|
+
import { z as z6 } from "zod";
|
|
2718
|
+
import { zodToJsonSchema as zodToJsonSchema6 } from "zod-to-json-schema";
|
|
2719
|
+
|
|
2720
|
+
// src/utils/registered-apps.util.ts
|
|
2721
|
+
import fs7 from "fs";
|
|
2722
|
+
import os from "os";
|
|
2723
|
+
import path7 from "path";
|
|
2724
|
+
var DEFAULT_REGISTERED_APPS_PATH = path7.join(
|
|
2725
|
+
os.homedir(),
|
|
2726
|
+
".config",
|
|
2727
|
+
"pabal-mcp",
|
|
2728
|
+
"registered-apps.json"
|
|
2729
|
+
);
|
|
2730
|
+
function safeReadJson(filePath) {
|
|
2731
|
+
if (!fs7.existsSync(filePath)) return null;
|
|
2732
|
+
try {
|
|
2733
|
+
const raw = fs7.readFileSync(filePath, "utf-8");
|
|
2734
|
+
const parsed = JSON.parse(raw);
|
|
2735
|
+
if (!parsed?.apps || !Array.isArray(parsed.apps)) {
|
|
2736
|
+
return null;
|
|
2737
|
+
}
|
|
2738
|
+
return parsed;
|
|
2739
|
+
} catch {
|
|
2740
|
+
return null;
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
function loadRegisteredApps(filePath = DEFAULT_REGISTERED_APPS_PATH) {
|
|
2744
|
+
const data = safeReadJson(filePath);
|
|
2745
|
+
return { apps: data?.apps || [], path: filePath };
|
|
2746
|
+
}
|
|
2747
|
+
function findRegisteredApp(slug, filePath) {
|
|
2748
|
+
const { apps, path: usedPath } = loadRegisteredApps(filePath);
|
|
2749
|
+
const app = apps.find((a) => a.slug === slug);
|
|
2750
|
+
return { app, path: usedPath };
|
|
2751
|
+
}
|
|
2752
|
+
function getSupportedLocalesForSlug(slug, platform, filePath) {
|
|
2753
|
+
const { app, path: usedPath } = findRegisteredApp(slug, filePath);
|
|
2754
|
+
if (!app) return { supportedLocales: [], path: usedPath };
|
|
2755
|
+
if (platform === "ios") {
|
|
2756
|
+
return {
|
|
2757
|
+
supportedLocales: app.appStore?.supportedLocales || [],
|
|
2758
|
+
path: usedPath
|
|
2759
|
+
};
|
|
2760
|
+
}
|
|
2761
|
+
return {
|
|
2762
|
+
supportedLocales: app.googlePlay?.supportedLocales || [],
|
|
2763
|
+
path: usedPath
|
|
2764
|
+
};
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
// src/tools/apps/search.ts
|
|
2768
|
+
import { z as z5 } from "zod";
|
|
2769
|
+
import { zodToJsonSchema as zodToJsonSchema5 } from "zod-to-json-schema";
|
|
2770
|
+
var TOOL_NAME = "search-app";
|
|
2771
|
+
var searchAppInputSchema = z5.object({
|
|
2772
|
+
query: z5.string().trim().optional().describe(
|
|
2773
|
+
"Search term (slug, bundleId, packageName, name). Returns all apps if empty."
|
|
2774
|
+
),
|
|
2775
|
+
store: z5.enum(["all", "appStore", "googlePlay"]).default("all").describe("Store filter (default: all)")
|
|
2776
|
+
});
|
|
2777
|
+
var jsonSchema5 = zodToJsonSchema5(searchAppInputSchema, {
|
|
2778
|
+
name: "SearchAppInput",
|
|
2779
|
+
$refStrategy: "none"
|
|
2780
|
+
});
|
|
2781
|
+
var inputSchema5 = jsonSchema5.definitions?.SearchAppInput || jsonSchema5;
|
|
2782
|
+
var searchAppTool = {
|
|
2783
|
+
name: TOOL_NAME,
|
|
2784
|
+
description: `Search registered apps from registered-apps.json.
|
|
2785
|
+
|
|
2786
|
+
- Called without query: Returns all app list
|
|
2787
|
+
- Called with query: Search by slug, bundleId, packageName, name
|
|
2788
|
+
- Use store filter to narrow results to appStore or googlePlay only`,
|
|
2789
|
+
inputSchema: inputSchema5
|
|
2790
|
+
};
|
|
2791
|
+
function matchesQuery(app, query) {
|
|
2792
|
+
const lowerQuery = query.toLowerCase();
|
|
2793
|
+
if (app.slug.toLowerCase().includes(lowerQuery)) return true;
|
|
2794
|
+
if (app.name?.toLowerCase().includes(lowerQuery)) return true;
|
|
2795
|
+
if (app.appStore?.bundleId?.toLowerCase().includes(lowerQuery)) return true;
|
|
2796
|
+
if (app.appStore?.name?.toLowerCase().includes(lowerQuery)) return true;
|
|
2797
|
+
if (app.googlePlay?.packageName?.toLowerCase().includes(lowerQuery))
|
|
2798
|
+
return true;
|
|
2799
|
+
if (app.googlePlay?.name?.toLowerCase().includes(lowerQuery)) return true;
|
|
2800
|
+
return false;
|
|
2801
|
+
}
|
|
2802
|
+
function filterByStore(apps, store) {
|
|
2803
|
+
if (store === "all") return apps;
|
|
2804
|
+
return apps.filter((app) => {
|
|
2805
|
+
if (store === "appStore") return !!app.appStore;
|
|
2806
|
+
if (store === "googlePlay") return !!app.googlePlay;
|
|
2807
|
+
return true;
|
|
2808
|
+
});
|
|
2809
|
+
}
|
|
2810
|
+
function formatAppInfo(app) {
|
|
2811
|
+
const lines = [];
|
|
2812
|
+
lines.push(`\u{1F4F1} **${app.name || app.slug}** (\`${app.slug}\`)`);
|
|
2813
|
+
if (app.appStore) {
|
|
2814
|
+
lines.push(` \u{1F34E} App Store: \`${app.appStore.bundleId || "N/A"}\``);
|
|
2815
|
+
if (app.appStore.appId) {
|
|
2816
|
+
lines.push(` App ID: ${app.appStore.appId}`);
|
|
2817
|
+
}
|
|
2818
|
+
if (app.appStore.name) {
|
|
2819
|
+
lines.push(` Name: ${app.appStore.name}`);
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
if (app.googlePlay) {
|
|
2823
|
+
lines.push(
|
|
2824
|
+
` \u{1F916} Google Play: \`${app.googlePlay.packageName || "N/A"}\``
|
|
2825
|
+
);
|
|
2826
|
+
if (app.googlePlay.name) {
|
|
2827
|
+
lines.push(` Name: ${app.googlePlay.name}`);
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
return lines.join("\n");
|
|
2831
|
+
}
|
|
2832
|
+
async function handleSearchApp(input) {
|
|
2833
|
+
const { query, store = "all" } = input;
|
|
2834
|
+
try {
|
|
2835
|
+
const { apps: allApps, path: configPath } = loadRegisteredApps();
|
|
2836
|
+
let results;
|
|
2837
|
+
if (!query) {
|
|
2838
|
+
results = allApps;
|
|
2839
|
+
} else {
|
|
2840
|
+
const { app: exactMatch } = findRegisteredApp(query);
|
|
2841
|
+
const partialMatches = allApps.filter((app) => matchesQuery(app, query));
|
|
2842
|
+
const seenSlugs = /* @__PURE__ */ new Set();
|
|
2843
|
+
results = [];
|
|
2844
|
+
if (exactMatch && !seenSlugs.has(exactMatch.slug)) {
|
|
2845
|
+
results.push(exactMatch);
|
|
2846
|
+
seenSlugs.add(exactMatch.slug);
|
|
2847
|
+
}
|
|
2848
|
+
for (const app of partialMatches) {
|
|
2849
|
+
if (!seenSlugs.has(app.slug)) {
|
|
2850
|
+
results.push(app);
|
|
2851
|
+
seenSlugs.add(app.slug);
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
2855
|
+
results = filterByStore(results, store);
|
|
2856
|
+
if (results.length === 0) {
|
|
2857
|
+
const message = query ? `No apps found matching "${query}".` : "No apps registered.";
|
|
2858
|
+
return {
|
|
2859
|
+
content: [
|
|
2860
|
+
{
|
|
2861
|
+
type: "text",
|
|
2862
|
+
text: `\u274C ${message}
|
|
2863
|
+
|
|
2864
|
+
\u{1F4A1} Register apps in ${configPath}`
|
|
2865
|
+
}
|
|
2866
|
+
],
|
|
2867
|
+
_meta: { apps: [], count: 0, configPath }
|
|
2868
|
+
};
|
|
2869
|
+
}
|
|
2870
|
+
const header = query ? `\u{1F50D} Search results for "${query}": ${results.length} app(s)` : `\u{1F4CB} Registered app list: ${results.length} app(s)`;
|
|
2871
|
+
const appList = results.map(formatAppInfo).join("\n\n");
|
|
2872
|
+
return {
|
|
2873
|
+
content: [
|
|
2874
|
+
{
|
|
2875
|
+
type: "text",
|
|
2876
|
+
text: `${header}
|
|
2877
|
+
|
|
2878
|
+
${appList}
|
|
2879
|
+
|
|
2880
|
+
---
|
|
2881
|
+
Config: ${configPath}`
|
|
2882
|
+
}
|
|
2883
|
+
],
|
|
2884
|
+
_meta: { apps: results, count: results.length, configPath }
|
|
2885
|
+
};
|
|
2886
|
+
} catch (error) {
|
|
2887
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2888
|
+
return {
|
|
2889
|
+
content: [
|
|
2890
|
+
{
|
|
2891
|
+
type: "text",
|
|
2892
|
+
text: `\u274C App search failed: ${message}`
|
|
2893
|
+
}
|
|
2894
|
+
]
|
|
2895
|
+
};
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
// src/tools/aso/keyword-research.ts
|
|
2900
|
+
var TOOL_NAME2 = "keyword-research";
|
|
2901
|
+
var keywordResearchInputSchema = z6.object({
|
|
2902
|
+
slug: z6.string().trim().describe("Product slug"),
|
|
2903
|
+
locale: z6.string().trim().describe("Locale code (e.g., en-US, ko-KR). Used for storage under .aso/keywordResearch/products/[slug]/locales/."),
|
|
2904
|
+
platform: z6.enum(["ios", "android"]).default("ios").describe("Store to target ('ios' or 'android'). Run separately per platform."),
|
|
2905
|
+
country: z6.string().length(2).optional().describe(
|
|
2906
|
+
"Two-letter store country code. If omitted, derived from locale region (e.g., ko-KR -> kr), else 'us'."
|
|
2907
|
+
),
|
|
2908
|
+
seedKeywords: z6.array(z6.string().trim()).default([]).describe("Seed keywords to start from."),
|
|
2909
|
+
competitorApps: z6.array(
|
|
2910
|
+
z6.object({
|
|
2911
|
+
appId: z6.string().trim().describe("App ID (package name or iOS ID/bundle)"),
|
|
2912
|
+
platform: z6.enum(["ios", "android"])
|
|
2913
|
+
})
|
|
2914
|
+
).default([]).describe("Known competitor apps to probe."),
|
|
2915
|
+
filename: z6.string().trim().optional().describe("Override output filename. Defaults to keyword-research-[platform]-[country].json"),
|
|
2916
|
+
writeTemplate: z6.boolean().default(false).describe("If true, write a JSON template at the output path."),
|
|
2917
|
+
researchData: z6.string().trim().optional().describe(
|
|
2918
|
+
"Optional JSON string with research results (e.g., from mcp-appstore tools). If provided, saves it to the output path."
|
|
2919
|
+
),
|
|
2920
|
+
researchDataPath: z6.string().trim().optional().describe(
|
|
2921
|
+
"Optional path to a JSON file containing research results. If set, file content is saved to the output path (preferred to avoid escape errors)."
|
|
2922
|
+
)
|
|
2923
|
+
});
|
|
2924
|
+
var jsonSchema6 = zodToJsonSchema6(keywordResearchInputSchema, {
|
|
2925
|
+
name: "KeywordResearchInput",
|
|
2926
|
+
$refStrategy: "none"
|
|
2927
|
+
});
|
|
2928
|
+
var inputSchema6 = jsonSchema6.definitions?.KeywordResearchInput || jsonSchema6;
|
|
2929
|
+
var keywordResearchTool = {
|
|
2930
|
+
name: TOOL_NAME2,
|
|
2931
|
+
description: `Prep + persist keyword research ahead of improve-public using mcp-appstore outputs.
|
|
2932
|
+
|
|
2933
|
+
**IMPORTANT:** Always use 'search-app' tool first to resolve the exact slug before calling this tool. The user may provide an approximate name, bundleId, or packageName - search-app will find and return the correct slug. Never pass user input directly as slug.
|
|
2934
|
+
|
|
2935
|
+
## CRITICAL: Multi-Locale Execution Plan
|
|
2936
|
+
|
|
2937
|
+
**MANDATORY WORKFLOW - Complete each locale fully before moving to next:**
|
|
2938
|
+
|
|
2939
|
+
For EACH locale+platform combination, execute this cycle:
|
|
2940
|
+
1. **Plan:** Call keyword-research(slug, locale, platform) with writeTemplate=false \u2192 get research plan
|
|
2941
|
+
2. **Research:** Execute COMPLETE mcp-appstore workflow (all 16 steps) for that locale
|
|
2942
|
+
3. **Save:** Call keyword-research again with researchData or researchDataPath \u2192 persist actual data
|
|
2943
|
+
4. **Next:** Move to next locale+platform and repeat steps 1-3
|
|
2944
|
+
|
|
2945
|
+
**IMPORTANT: Research \u2192 Save \u2192 Next pattern**
|
|
2946
|
+
- Complete ONE locale fully (research + save) before starting the next
|
|
2947
|
+
- This prevents data loss if the session is interrupted
|
|
2948
|
+
- Each locale's data is persisted immediately after research
|
|
2949
|
+
|
|
2950
|
+
**FORBIDDEN:**
|
|
2951
|
+
- \u274C Using writeTemplate=true as final output
|
|
2952
|
+
- \u274C Skipping secondary locales
|
|
2953
|
+
- \u274C Researching multiple locales then saving all at once at the end
|
|
2954
|
+
- \u274C Stopping before all locale+platform combinations are done
|
|
2955
|
+
|
|
2956
|
+
**REQUIRED:**
|
|
2957
|
+
- \u2705 Research locale \u2192 Save locale \u2192 Move to next (one at a time)
|
|
2958
|
+
- \u2705 Run for EVERY platform (ios AND android separately)
|
|
2959
|
+
- \u2705 Use researchData or researchDataPath to save (NOT writeTemplate)`,
|
|
2960
|
+
inputSchema: inputSchema6
|
|
2961
|
+
};
|
|
2962
|
+
function buildTemplate({
|
|
2963
|
+
slug,
|
|
2964
|
+
locale,
|
|
2965
|
+
platform,
|
|
2966
|
+
country,
|
|
2967
|
+
seedKeywords,
|
|
2968
|
+
competitorApps
|
|
2969
|
+
}) {
|
|
2970
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
2971
|
+
return {
|
|
2972
|
+
meta: {
|
|
2973
|
+
slug,
|
|
2974
|
+
locale,
|
|
2975
|
+
platform,
|
|
2976
|
+
country,
|
|
2977
|
+
seedKeywords,
|
|
2978
|
+
competitorApps,
|
|
2979
|
+
source: "mcp-appstore",
|
|
2980
|
+
updatedAt: timestamp
|
|
2981
|
+
},
|
|
2982
|
+
plan: {
|
|
2983
|
+
steps: [
|
|
2984
|
+
"1. SETUP: Start mcp-appstore server (node server.js in external-tools/mcp-appstore).",
|
|
2985
|
+
"2. APP IDENTITY: get_app_details(appId) \u2192 confirm exact app name, category, current keywords, and store listing quality.",
|
|
2986
|
+
"3. COMPETITOR DISCOVERY: search_app(term=seed, num=15) + get_similar_apps(appId=your app, num=20) \u2192 identify 5-10 direct competitors (same category, similar size) + 3-5 aspirational competitors (top performers).",
|
|
2987
|
+
"4. COMPETITOR KEYWORD MINING: For top 5 competitors, run suggest_keywords_by_apps \u2192 extract keywords they rank for but you don't.",
|
|
2988
|
+
"5. KEYWORD EXPANSION (run ALL, num=30 each): suggest_keywords_by_seeds (your app name + core features), by_category (your primary category), by_similarity (semantic variations), by_competition (gap analysis), by_search (autocomplete/trending).",
|
|
2989
|
+
"6. KEYWORD SCORING: get_keyword_scores for ALL candidates (50-100 keywords) \u2192 filter by: traffic \u226510, difficulty \u226470, relevance to your app.",
|
|
2990
|
+
"7. REVIEW INTELLIGENCE: analyze_reviews(num=200) + fetch_reviews(num=100) on top 3 competitors \u2192 extract: user pain points, feature requests, emotional language, native phrases users actually use.",
|
|
2991
|
+
"8. KEYWORD CATEGORIZATION: Group into tiers - Tier1 (3-5): high traffic (\u22651000), high relevance, moderate difficulty (\u226450); Tier2 (5-7): medium traffic (100-1000), exact feature match; Tier3 (5-8): longtail (<100 traffic), very low difficulty (\u226430), high conversion intent.",
|
|
2992
|
+
"9. LOCALIZATION CHECK: Verify keywords are natural in target locale - avoid direct translations, prefer native expressions found in reviews.",
|
|
2993
|
+
"10. GAP ANALYSIS: Compare your current keywords vs competitor keywords \u2192 identify missed opportunities and over-saturated terms to avoid.",
|
|
2994
|
+
"11. VALIDATION: For final 15-20 keywords, ensure each has: score data, clear user intent, natural locale fit, and specific rationale for inclusion.",
|
|
2995
|
+
"Keep rationale/nextActions in English by default unless you intentionally localize them."
|
|
2996
|
+
],
|
|
2997
|
+
selectionCriteria: {
|
|
2998
|
+
tier1_core: "High traffic (\u22651000), relevance score \u22650.8, difficulty \u226450, brand-safe",
|
|
2999
|
+
tier2_feature: "Medium traffic (100-1000), exact feature/benefit match, difficulty \u226460",
|
|
3000
|
+
tier3_longtail: "Low traffic (<100), very low difficulty (\u226430), high purchase/download intent phrases",
|
|
3001
|
+
avoid: "Generic terms (difficulty \u226580), irrelevant categories, competitor brand names, terms with no search volume"
|
|
3002
|
+
},
|
|
3003
|
+
qualityChecks: [
|
|
3004
|
+
"Each keyword has traffic + difficulty scores (no gaps)",
|
|
3005
|
+
"Mix of 3 tiers represented (not all longtail, not all high-competition)",
|
|
3006
|
+
"Keywords validated against actual user language from reviews",
|
|
3007
|
+
"No duplicate semantic meanings (e.g., 'photo edit' and 'edit photo')",
|
|
3008
|
+
"Locale-appropriate phrasing verified"
|
|
3009
|
+
],
|
|
3010
|
+
note: "Run per platform/country. Target 15-20 keywords per locale with clear tier distribution. Save ALL raw data for audit trail."
|
|
3011
|
+
},
|
|
3012
|
+
data: {
|
|
3013
|
+
raw: {
|
|
3014
|
+
searchApp: [],
|
|
3015
|
+
getAppDetails: [],
|
|
3016
|
+
similarApps: [],
|
|
3017
|
+
keywordSuggestions: {
|
|
3018
|
+
bySeeds: [],
|
|
3019
|
+
byCategory: [],
|
|
3020
|
+
bySimilarity: [],
|
|
3021
|
+
byCompetition: [],
|
|
3022
|
+
bySearchHints: [],
|
|
3023
|
+
byApps: []
|
|
3024
|
+
},
|
|
3025
|
+
keywordScores: [],
|
|
3026
|
+
reviewsAnalysis: [],
|
|
3027
|
+
reviewsRaw: []
|
|
3028
|
+
},
|
|
3029
|
+
summary: {
|
|
3030
|
+
recommendedKeywords: [],
|
|
3031
|
+
keywordsByTier: {
|
|
3032
|
+
tier1_core: [],
|
|
3033
|
+
tier2_feature: [],
|
|
3034
|
+
tier3_longtail: []
|
|
3035
|
+
},
|
|
3036
|
+
competitorInsights: {
|
|
3037
|
+
topCompetitors: [],
|
|
3038
|
+
keywordGaps: [],
|
|
3039
|
+
userLanguagePatterns: []
|
|
3040
|
+
},
|
|
3041
|
+
rationale: "",
|
|
3042
|
+
confidence: {
|
|
3043
|
+
dataQuality: "",
|
|
3044
|
+
localeRelevance: "",
|
|
3045
|
+
competitivePosition: ""
|
|
3046
|
+
},
|
|
3047
|
+
nextActions: [
|
|
3048
|
+
"Feed tiered keywords into improve-public Stage 1 (prioritize Tier1 for title, Tier2-3 for keyword field)",
|
|
3049
|
+
"Monitor keyword rankings post-update",
|
|
3050
|
+
"Re-run research quarterly or after major competitor changes"
|
|
3051
|
+
]
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
};
|
|
3055
|
+
}
|
|
3056
|
+
function saveJsonFile({
|
|
3057
|
+
researchDir,
|
|
3058
|
+
fileName,
|
|
3059
|
+
payload
|
|
3060
|
+
}) {
|
|
3061
|
+
fs8.mkdirSync(researchDir, { recursive: true });
|
|
3062
|
+
const outputPath = path8.join(researchDir, fileName);
|
|
3063
|
+
fs8.writeFileSync(outputPath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
|
|
3064
|
+
return outputPath;
|
|
3065
|
+
}
|
|
3066
|
+
function normalizeKeywords(raw) {
|
|
3067
|
+
if (!raw) return [];
|
|
3068
|
+
if (Array.isArray(raw)) {
|
|
3069
|
+
return raw.map((k) => k.trim()).filter((k) => k.length > 0);
|
|
3070
|
+
}
|
|
3071
|
+
return raw.split(",").map((k) => k.trim()).filter((k) => k.length > 0);
|
|
3072
|
+
}
|
|
3073
|
+
async function handleKeywordResearch(input) {
|
|
3074
|
+
const {
|
|
3075
|
+
slug,
|
|
3076
|
+
locale,
|
|
3077
|
+
platform = "ios",
|
|
3078
|
+
country,
|
|
3079
|
+
seedKeywords = [],
|
|
3080
|
+
competitorApps = [],
|
|
3081
|
+
filename,
|
|
3082
|
+
writeTemplate = false,
|
|
3083
|
+
researchData,
|
|
3084
|
+
researchDataPath
|
|
3085
|
+
} = input;
|
|
3086
|
+
const searchResult = await handleSearchApp({ query: slug, store: "all" });
|
|
3087
|
+
const registeredApps = searchResult._meta?.apps || [];
|
|
3088
|
+
const registeredApp = registeredApps.length > 0 ? registeredApps[0] : void 0;
|
|
3089
|
+
const { config, locales } = loadProductLocales(slug);
|
|
3090
|
+
const primaryLocale = resolvePrimaryLocale(config, locales);
|
|
3091
|
+
const productLocales = Object.keys(locales);
|
|
3092
|
+
const primaryLocaleData = locales[primaryLocale];
|
|
3093
|
+
const { supportedLocales, path: supportedPath } = getSupportedLocalesForSlug(slug, platform);
|
|
3094
|
+
const appStoreLocales = registeredApp?.appStore?.supportedLocales || [];
|
|
3095
|
+
const googlePlayLocales = registeredApp?.googlePlay?.supportedLocales || [];
|
|
3096
|
+
const declaredPlatforms = [
|
|
3097
|
+
registeredApp?.appStore ? "ios" : null,
|
|
3098
|
+
registeredApp?.googlePlay ? "android" : null
|
|
3099
|
+
].filter(Boolean);
|
|
3100
|
+
const autoSeeds = [];
|
|
3101
|
+
const autoCompetitors = [];
|
|
3102
|
+
if (primaryLocaleData?.aso?.title) {
|
|
3103
|
+
autoSeeds.push(primaryLocaleData.aso.title);
|
|
3104
|
+
}
|
|
3105
|
+
const parsedKeywords = normalizeKeywords(primaryLocaleData?.aso?.keywords);
|
|
3106
|
+
autoSeeds.push(...parsedKeywords.slice(0, 5));
|
|
3107
|
+
if (config?.name) autoSeeds.push(config.name);
|
|
3108
|
+
if (config?.tagline) autoSeeds.push(config.tagline);
|
|
3109
|
+
if (!config?.name && registeredApp?.name) autoSeeds.push(registeredApp.name);
|
|
3110
|
+
if (!primaryLocaleData?.aso?.title) {
|
|
3111
|
+
if (platform === "ios" && registeredApp?.appStore?.name) {
|
|
3112
|
+
autoSeeds.push(registeredApp.appStore.name);
|
|
3113
|
+
}
|
|
3114
|
+
if (platform === "android" && registeredApp?.googlePlay?.name) {
|
|
3115
|
+
autoSeeds.push(registeredApp.googlePlay.name);
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
if (platform === "ios") {
|
|
3119
|
+
if (config?.appStoreAppId) {
|
|
3120
|
+
autoCompetitors.push({ appId: String(config.appStoreAppId), platform });
|
|
3121
|
+
} else if (config?.bundleId) {
|
|
3122
|
+
autoCompetitors.push({ appId: config.bundleId, platform });
|
|
3123
|
+
} else if (registeredApp?.appStore?.appId) {
|
|
3124
|
+
autoCompetitors.push({
|
|
3125
|
+
appId: String(registeredApp.appStore.appId),
|
|
3126
|
+
platform
|
|
3127
|
+
});
|
|
3128
|
+
} else if (registeredApp?.appStore?.bundleId) {
|
|
3129
|
+
autoCompetitors.push({
|
|
3130
|
+
appId: registeredApp.appStore.bundleId,
|
|
3131
|
+
platform
|
|
3132
|
+
});
|
|
3133
|
+
}
|
|
3134
|
+
} else if (platform === "android" && config?.packageName) {
|
|
3135
|
+
autoCompetitors.push({ appId: config.packageName, platform });
|
|
3136
|
+
} else if (platform === "android" && registeredApp?.googlePlay?.packageName) {
|
|
3137
|
+
autoCompetitors.push({
|
|
3138
|
+
appId: registeredApp.googlePlay.packageName,
|
|
3139
|
+
platform
|
|
3140
|
+
});
|
|
3141
|
+
}
|
|
3142
|
+
const resolvedSeeds = seedKeywords.length > 0 ? seedKeywords : Array.from(new Set(autoSeeds));
|
|
3143
|
+
const resolvedCompetitors = competitorApps.length > 0 ? competitorApps : autoCompetitors;
|
|
3144
|
+
const resolvedCountry = country || (locale?.includes("-") ? locale.split("-")[1].toLowerCase() : "us");
|
|
3145
|
+
const researchDir = path8.join(
|
|
3146
|
+
getKeywordResearchDir(),
|
|
3147
|
+
"products",
|
|
3148
|
+
slug,
|
|
3149
|
+
"locales",
|
|
3150
|
+
locale
|
|
3151
|
+
);
|
|
3152
|
+
const defaultFileName = `keyword-research-${platform}-${resolvedCountry}.json`;
|
|
3153
|
+
const fileName = filename || defaultFileName;
|
|
3154
|
+
let outputPath = path8.join(researchDir, fileName);
|
|
3155
|
+
let fileAction;
|
|
3156
|
+
const parseJsonWithContext = (text) => {
|
|
3157
|
+
try {
|
|
3158
|
+
return JSON.parse(text);
|
|
3159
|
+
} catch (err) {
|
|
3160
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3161
|
+
const match = /position (\d+)/i.exec(message) || /column (\d+)/i.exec(message) || /char (\d+)/i.exec(message);
|
|
3162
|
+
if (match) {
|
|
3163
|
+
const pos = Number(match[1]);
|
|
3164
|
+
const start = Math.max(0, pos - 40);
|
|
3165
|
+
const end = Math.min(text.length, pos + 40);
|
|
3166
|
+
const context = text.slice(start, end);
|
|
3167
|
+
throw new Error(
|
|
3168
|
+
`Failed to parse researchData JSON: ${message}
|
|
3169
|
+
Context around ${pos}: ${context}`
|
|
3170
|
+
);
|
|
3171
|
+
}
|
|
3172
|
+
throw new Error(`Failed to parse researchData JSON: ${message}`);
|
|
3173
|
+
}
|
|
3174
|
+
};
|
|
3175
|
+
const loadResearchDataFromPath = (p) => {
|
|
3176
|
+
if (!fs8.existsSync(p)) {
|
|
3177
|
+
throw new Error(`researchDataPath not found: ${p}`);
|
|
3178
|
+
}
|
|
3179
|
+
const raw = fs8.readFileSync(p, "utf-8");
|
|
3180
|
+
return parseJsonWithContext(raw);
|
|
3181
|
+
};
|
|
3182
|
+
if (writeTemplate || researchData) {
|
|
3183
|
+
const payload = researchData ? parseJsonWithContext(researchData) : researchDataPath ? loadResearchDataFromPath(researchDataPath) : buildTemplate({
|
|
3184
|
+
slug,
|
|
3185
|
+
locale,
|
|
3186
|
+
platform,
|
|
3187
|
+
country: resolvedCountry,
|
|
3188
|
+
seedKeywords: resolvedSeeds,
|
|
3189
|
+
competitorApps: resolvedCompetitors
|
|
3190
|
+
});
|
|
3191
|
+
outputPath = saveJsonFile({ researchDir, fileName, payload });
|
|
3192
|
+
fileAction = researchData ? "Saved provided researchData" : "Wrote template";
|
|
3193
|
+
}
|
|
3194
|
+
const templatePreview = JSON.stringify(
|
|
3195
|
+
buildTemplate({
|
|
3196
|
+
slug,
|
|
3197
|
+
locale,
|
|
3198
|
+
platform,
|
|
3199
|
+
country: resolvedCountry,
|
|
3200
|
+
seedKeywords: resolvedSeeds,
|
|
3201
|
+
competitorApps: resolvedCompetitors
|
|
3202
|
+
}),
|
|
3203
|
+
null,
|
|
3204
|
+
2
|
|
3205
|
+
);
|
|
3206
|
+
const lines = [];
|
|
3207
|
+
lines.push(`# Keyword research plan (${slug})`);
|
|
3208
|
+
lines.push(`Locale: ${locale} | Platform: ${platform} | Country: ${resolvedCountry}`);
|
|
3209
|
+
lines.push(`Primary locale detected: ${primaryLocale}`);
|
|
3210
|
+
if (declaredPlatforms.length > 0) {
|
|
3211
|
+
lines.push(`Supported platforms (search-app): ${declaredPlatforms.join(", ")}`);
|
|
3212
|
+
} else {
|
|
3213
|
+
lines.push("Supported platforms (search-app): none detected\u2014update registered-apps.json");
|
|
3214
|
+
}
|
|
3215
|
+
if (appStoreLocales.length > 0) {
|
|
3216
|
+
lines.push(`Declared App Store locales: ${appStoreLocales.join(", ")}`);
|
|
3217
|
+
}
|
|
3218
|
+
if (googlePlayLocales.length > 0) {
|
|
3219
|
+
lines.push(`Declared Google Play locales: ${googlePlayLocales.join(", ")}`);
|
|
3220
|
+
}
|
|
3221
|
+
if (supportedLocales.length > 0) {
|
|
3222
|
+
lines.push(
|
|
3223
|
+
`Registered supported locales (${platform}): ${supportedLocales.join(
|
|
3224
|
+
", "
|
|
3225
|
+
)} (source: ${supportedPath})`
|
|
3226
|
+
);
|
|
3227
|
+
if (!supportedLocales.includes(locale)) {
|
|
3228
|
+
lines.push(
|
|
3229
|
+
`WARNING: locale ${locale} not in registered supported locales. Confirm this locale or update registered-apps.json.`
|
|
3230
|
+
);
|
|
3231
|
+
}
|
|
3232
|
+
} else {
|
|
3233
|
+
lines.push(
|
|
3234
|
+
`Registered supported locales not found for ${platform} (checked: ${supportedPath}).`
|
|
3235
|
+
);
|
|
3236
|
+
}
|
|
3237
|
+
const allCombinations = [];
|
|
3238
|
+
const platformsToRun = declaredPlatforms.length > 0 ? declaredPlatforms : [platform];
|
|
3239
|
+
for (const plat of platformsToRun) {
|
|
3240
|
+
for (const loc of productLocales.length > 0 ? productLocales : [locale]) {
|
|
3241
|
+
allCombinations.push({ loc, plat });
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
const currentIndex = allCombinations.findIndex(
|
|
3245
|
+
(c) => c.loc === locale && c.plat === platform
|
|
3246
|
+
);
|
|
3247
|
+
const completedCount = currentIndex >= 0 ? currentIndex : 0;
|
|
3248
|
+
const remainingCombinations = allCombinations.slice(currentIndex + 1);
|
|
3249
|
+
if (productLocales.length > 0) {
|
|
3250
|
+
lines.push(
|
|
3251
|
+
`Existing product locales (${productLocales.length}): ${productLocales.join(", ")}`
|
|
3252
|
+
);
|
|
3253
|
+
}
|
|
3254
|
+
lines.push("");
|
|
3255
|
+
lines.push("---");
|
|
3256
|
+
lines.push("## \u{1F3AF} EXECUTION PROGRESS TRACKER");
|
|
3257
|
+
lines.push("");
|
|
3258
|
+
lines.push(`**Total combinations to complete:** ${allCombinations.length} (${platformsToRun.length} platforms \xD7 ${productLocales.length || 1} locales)`);
|
|
3259
|
+
lines.push(`**Current:** ${locale} + ${platform} (${completedCount + 1}/${allCombinations.length})`);
|
|
3260
|
+
lines.push("");
|
|
3261
|
+
if (researchData || researchDataPath) {
|
|
3262
|
+
lines.push(`\u2705 SAVED: ${locale} + ${platform} - Full research data persisted`);
|
|
3263
|
+
} else if (writeTemplate) {
|
|
3264
|
+
lines.push(`\u26A0\uFE0F WARNING: ${locale} + ${platform} - Only template written! You MUST run full mcp-appstore research and save actual data.`);
|
|
3265
|
+
} else {
|
|
3266
|
+
lines.push(`\u{1F4CB} PLANNING: ${locale} + ${platform} - Research plan shown. Now execute mcp-appstore workflow.`);
|
|
3267
|
+
}
|
|
3268
|
+
if (remainingCombinations.length > 0) {
|
|
3269
|
+
lines.push("");
|
|
3270
|
+
lines.push("## \u23ED\uFE0F MANDATORY NEXT STEPS");
|
|
3271
|
+
lines.push("");
|
|
3272
|
+
lines.push("**After completing current locale+platform, you MUST continue with:**");
|
|
3273
|
+
lines.push("");
|
|
3274
|
+
remainingCombinations.forEach((combo, idx) => {
|
|
3275
|
+
lines.push(`${idx + 1}. keyword-research(slug="${slug}", locale="${combo.loc}", platform="${combo.plat}") \u2192 full mcp-appstore workflow \u2192 save results`);
|
|
3276
|
+
});
|
|
3277
|
+
lines.push("");
|
|
3278
|
+
lines.push("\u26D4 DO NOT mark this task as complete until ALL combinations above have FULL research data (not templates).");
|
|
3279
|
+
} else {
|
|
3280
|
+
lines.push("");
|
|
3281
|
+
lines.push("## \u2705 FINAL STEP");
|
|
3282
|
+
lines.push("");
|
|
3283
|
+
lines.push("This is the LAST locale+platform combination. After saving full research data for this one, the task is complete.");
|
|
3284
|
+
}
|
|
3285
|
+
lines.push("---");
|
|
3286
|
+
lines.push("");
|
|
3287
|
+
lines.push(
|
|
3288
|
+
`Seeds: ${resolvedSeeds.length > 0 ? resolvedSeeds.join(", ") : "(none set; add seedKeywords or ensure ASO keywords/title exist)"}`
|
|
3289
|
+
);
|
|
3290
|
+
lines.push(
|
|
3291
|
+
`Competitors (from config if empty): ${resolvedCompetitors.length > 0 ? resolvedCompetitors.map((c) => `${c.platform}:${c.appId}`).join(", ") : "(none set; add competitorApps or set appStoreAppId/bundleId/packageName in config.json)"}`
|
|
3292
|
+
);
|
|
3293
|
+
lines.push("");
|
|
3294
|
+
lines.push("## Research Workflow (mcp-appstore)");
|
|
3295
|
+
lines.push("");
|
|
3296
|
+
lines.push("### Phase 1: Setup & Discovery");
|
|
3297
|
+
lines.push(
|
|
3298
|
+
`1) Start mcp-appstore server: node server.js (cwd: external-tools/mcp-appstore)`
|
|
3299
|
+
);
|
|
3300
|
+
lines.push(
|
|
3301
|
+
`2) get_app_details(appId) \u2192 confirm app identity, category, current metadata`
|
|
3302
|
+
);
|
|
3303
|
+
lines.push(
|
|
3304
|
+
`3) search_app(term=seed, num=15, platform=${platform}, country=${resolvedCountry}) \u2192 find direct competitors`
|
|
3305
|
+
);
|
|
3306
|
+
lines.push(
|
|
3307
|
+
`4) get_similar_apps(appId=your app, num=20) \u2192 discover related apps in your space`
|
|
3308
|
+
);
|
|
3309
|
+
lines.push("");
|
|
3310
|
+
lines.push("### Phase 2: Keyword Mining (run ALL of these)");
|
|
3311
|
+
lines.push(
|
|
3312
|
+
`5) suggest_keywords_by_apps(apps=[top 5 competitors]) \u2192 steal competitor keywords`
|
|
3313
|
+
);
|
|
3314
|
+
lines.push(
|
|
3315
|
+
`6) suggest_keywords_by_seeds(seeds=[app name, core features], num=30)`
|
|
3316
|
+
);
|
|
3317
|
+
lines.push(
|
|
3318
|
+
`7) suggest_keywords_by_category(category=your primary category, num=30)`
|
|
3319
|
+
);
|
|
3320
|
+
lines.push(
|
|
3321
|
+
`8) suggest_keywords_by_similarity + by_competition + by_search (num=30 each)`
|
|
3322
|
+
);
|
|
3323
|
+
lines.push("");
|
|
3324
|
+
lines.push("### Phase 3: Scoring & Filtering");
|
|
3325
|
+
lines.push(
|
|
3326
|
+
`9) get_keyword_scores for ALL candidates (50-100 keywords) \u2192 get traffic & difficulty`
|
|
3327
|
+
);
|
|
3328
|
+
lines.push(
|
|
3329
|
+
`10) Filter: traffic \u226510, difficulty \u226470, relevant to your app's core value`
|
|
3330
|
+
);
|
|
3331
|
+
lines.push("");
|
|
3332
|
+
lines.push("### Phase 4: User Language Intelligence");
|
|
3333
|
+
lines.push(
|
|
3334
|
+
`11) analyze_reviews(appId=top 3 competitors, num=200) \u2192 sentiment & themes`
|
|
3335
|
+
);
|
|
3336
|
+
lines.push(
|
|
3337
|
+
`12) fetch_reviews(appId=top 3 competitors, num=100) \u2192 extract exact phrases users say`
|
|
3338
|
+
);
|
|
3339
|
+
lines.push(
|
|
3340
|
+
`13) Cross-reference keywords with review language \u2192 validate natural phrasing`
|
|
3341
|
+
);
|
|
3342
|
+
lines.push("");
|
|
3343
|
+
lines.push("### Phase 5: Final Selection");
|
|
3344
|
+
lines.push(
|
|
3345
|
+
`14) Categorize into tiers: Tier1 (3-5 high-traffic core), Tier2 (5-7 feature-match), Tier3 (5-8 longtail)`
|
|
3346
|
+
);
|
|
3347
|
+
lines.push(
|
|
3348
|
+
`15) Validate each keyword has: score, intent, locale fit, inclusion rationale`
|
|
3349
|
+
);
|
|
3350
|
+
lines.push(
|
|
3351
|
+
`16) Save to: ${outputPath}`
|
|
3352
|
+
);
|
|
3353
|
+
lines.push("");
|
|
3354
|
+
lines.push("### Quality Checklist");
|
|
3355
|
+
lines.push("- [ ] 15-20 keywords with complete score data");
|
|
3356
|
+
lines.push("- [ ] All 3 tiers represented (not just longtail)");
|
|
3357
|
+
lines.push("- [ ] Keywords validated against actual review language");
|
|
3358
|
+
lines.push("- [ ] No semantic duplicates");
|
|
3359
|
+
lines.push("- [ ] Locale-appropriate (not direct translations)");
|
|
3360
|
+
if (fileAction) {
|
|
3361
|
+
lines.push(`File: ${fileAction} at ${outputPath}`);
|
|
3362
|
+
if (writeTemplate && !researchData && !researchDataPath) {
|
|
3363
|
+
lines.push(
|
|
3364
|
+
"\u26A0\uFE0F Template is a placeholder\u2014replace with FULL mcp-appstore research results for this locale (no template-only coverage)."
|
|
3365
|
+
);
|
|
3366
|
+
}
|
|
3367
|
+
} else {
|
|
3368
|
+
lines.push(
|
|
3369
|
+
`Tip: set writeTemplate=true to create the JSON skeleton at ${outputPath} (still run full research per locale)`
|
|
3370
|
+
);
|
|
3371
|
+
}
|
|
3372
|
+
lines.push("");
|
|
3373
|
+
lines.push("Suggested JSON shape:");
|
|
3374
|
+
lines.push("```json");
|
|
3375
|
+
lines.push(templatePreview);
|
|
3376
|
+
lines.push("```");
|
|
3377
|
+
return {
|
|
3378
|
+
content: [
|
|
3379
|
+
{
|
|
3380
|
+
type: "text",
|
|
3381
|
+
text: lines.join("\n")
|
|
3382
|
+
}
|
|
3383
|
+
]
|
|
3384
|
+
};
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
// src/tools/apps/init.ts
|
|
3388
|
+
import fs9 from "fs";
|
|
3389
|
+
import path9 from "path";
|
|
3390
|
+
import { z as z7 } from "zod";
|
|
3391
|
+
import { zodToJsonSchema as zodToJsonSchema7 } from "zod-to-json-schema";
|
|
3392
|
+
var listSlugDirs = (dir) => {
|
|
3393
|
+
if (!fs9.existsSync(dir)) return [];
|
|
3394
|
+
return fs9.readdirSync(dir, { withFileTypes: true }).filter((dirent) => dirent.isDirectory()).map((dirent) => dirent.name);
|
|
3395
|
+
};
|
|
3396
|
+
var initProjectInputSchema = z7.object({
|
|
3397
|
+
slug: z7.string().trim().optional().describe(
|
|
3398
|
+
"Optional product slug to focus on. Defaults to all slugs in .aso/pullData/products/"
|
|
3399
|
+
)
|
|
3400
|
+
});
|
|
3401
|
+
var jsonSchema7 = zodToJsonSchema7(initProjectInputSchema, {
|
|
3402
|
+
name: "InitProjectInput",
|
|
3403
|
+
$refStrategy: "none"
|
|
3404
|
+
});
|
|
3405
|
+
var inputSchema7 = jsonSchema7.definitions?.InitProjectInput || jsonSchema7;
|
|
3406
|
+
var initProjectTool = {
|
|
3407
|
+
name: "init-project",
|
|
3408
|
+
description: `Guides the initialization flow: run pabal-mcp Init, then convert ASO pullData into public/products/[slug]/.
|
|
3409
|
+
|
|
3410
|
+
This tool is read-only and returns a checklist. It does not call pabal-mcp directly or write files.
|
|
3411
|
+
|
|
3412
|
+
Steps:
|
|
3413
|
+
1) Ensure pabal-mcp 'init' ran and .aso/pullData/products/[slug]/ exists (path from ~/.config/pabal-mcp/config.json dataDir)
|
|
3414
|
+
2) Convert pulled ASO data -> public/products/[slug]/ using pabal-web-mcp tools (aso-to-public, public-to-aso dry run)
|
|
3415
|
+
3) Validate outputs and next actions`,
|
|
3416
|
+
inputSchema: inputSchema7
|
|
3417
|
+
};
|
|
3418
|
+
async function handleInitProject(input) {
|
|
3419
|
+
const pullDataDir = path9.join(getPullDataDir(), "products");
|
|
3420
|
+
const publicDir = getProductsDir();
|
|
3421
|
+
const pullDataSlugs = listSlugDirs(pullDataDir);
|
|
3422
|
+
const publicSlugs = listSlugDirs(publicDir);
|
|
3423
|
+
const targetSlugs = input.slug?.length && input.slug.trim().length > 0 ? [input.slug.trim()] : pullDataSlugs.length > 0 ? pullDataSlugs : publicSlugs;
|
|
3424
|
+
const lines = [];
|
|
3425
|
+
lines.push("Init workflow (pabal-mcp -> pabal-web-mcp)");
|
|
3426
|
+
lines.push(
|
|
3427
|
+
`Target slugs: ${targetSlugs.length > 0 ? targetSlugs.join(", ") : "(none detected)"}`
|
|
3428
|
+
);
|
|
3429
|
+
lines.push(
|
|
3430
|
+
`pullData: ${pullDataSlugs.length > 0 ? "found" : "missing"} at ${pullDataDir}`
|
|
3431
|
+
);
|
|
3432
|
+
lines.push(
|
|
3433
|
+
`public/products: ${publicSlugs.length > 0 ? "found" : "missing"} at ${publicDir}`
|
|
3434
|
+
);
|
|
3435
|
+
lines.push("");
|
|
3436
|
+
if (targetSlugs.length === 0) {
|
|
3437
|
+
lines.push(
|
|
3438
|
+
"No products detected. Run pabal-mcp 'init' for your slug(s) to populate .aso/pullData/products/, then rerun this tool."
|
|
3439
|
+
);
|
|
3440
|
+
return {
|
|
3441
|
+
content: [
|
|
3442
|
+
{
|
|
3443
|
+
type: "text",
|
|
3444
|
+
text: lines.join("\n")
|
|
3445
|
+
}
|
|
3446
|
+
]
|
|
3447
|
+
};
|
|
3448
|
+
}
|
|
3449
|
+
lines.push("Step 1: Fetch raw ASO data (pabal-mcp 'init')");
|
|
3450
|
+
for (const slug of targetSlugs) {
|
|
3451
|
+
const hasPull = pullDataSlugs.includes(slug);
|
|
3452
|
+
lines.push(`- ${slug}: ${hasPull ? "pullData ready" : "pullData missing"}`);
|
|
3453
|
+
}
|
|
3454
|
+
lines.push(
|
|
3455
|
+
"Action: In pabal-mcp, run the 'init' tool for each slug above that is missing pullData."
|
|
3456
|
+
);
|
|
3457
|
+
lines.push("");
|
|
3458
|
+
lines.push(
|
|
3459
|
+
"Step 2: Convert pullData to web assets (pabal-web-mcp 'aso-to-public')"
|
|
3460
|
+
);
|
|
3461
|
+
for (const slug of targetSlugs) {
|
|
3462
|
+
const hasPull = pullDataSlugs.includes(slug);
|
|
3463
|
+
const hasPublic = publicSlugs.includes(slug);
|
|
3464
|
+
const pullStatus = hasPull ? "pullData ready" : "pullData missing";
|
|
3465
|
+
const publicStatus = hasPublic ? "public/products ready" : "public/products missing";
|
|
3466
|
+
lines.push(
|
|
3467
|
+
`- ${slug}: ${pullStatus}, ${publicStatus}${hasPull && !hasPublic ? " (ready to convert)" : ""}`
|
|
3468
|
+
);
|
|
3469
|
+
}
|
|
3470
|
+
lines.push(
|
|
3471
|
+
"Action: After pullData exists, run pabal-web-mcp 'aso-to-public' per slug to generate locale prompts. Save results to public/products/[slug]/locales/, copy screenshots, and ensure config.json/icon/og-image are in place."
|
|
3472
|
+
);
|
|
3473
|
+
lines.push("");
|
|
3474
|
+
lines.push("Step 3: Verify and prepare for push (optional)");
|
|
3475
|
+
lines.push(
|
|
3476
|
+
"Use pabal-web-mcp 'public-to-aso' with dryRun=true to validate structure and build pushData before uploading via store tooling."
|
|
3477
|
+
);
|
|
3478
|
+
lines.push("");
|
|
3479
|
+
lines.push("Notes:");
|
|
3480
|
+
lines.push(
|
|
3481
|
+
"- This tool is read-only; it does not write files or call pabal-mcp."
|
|
3482
|
+
);
|
|
3483
|
+
lines.push(
|
|
3484
|
+
"- Extend this init checklist as new processes are added (e.g., asset generation or validations)."
|
|
3485
|
+
);
|
|
3486
|
+
return {
|
|
3487
|
+
content: [
|
|
3488
|
+
{
|
|
3489
|
+
type: "text",
|
|
3490
|
+
text: lines.join("\n")
|
|
3491
|
+
}
|
|
3492
|
+
]
|
|
3493
|
+
};
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
// src/tools/content/create-blog-html.ts
|
|
3497
|
+
import fs11 from "fs";
|
|
3498
|
+
import path11 from "path";
|
|
3499
|
+
import { z as z8 } from "zod";
|
|
3500
|
+
import { zodToJsonSchema as zodToJsonSchema8 } from "zod-to-json-schema";
|
|
3501
|
+
|
|
3502
|
+
// src/utils/blog.util.ts
|
|
3503
|
+
import fs10 from "fs";
|
|
3504
|
+
import path10 from "path";
|
|
3505
|
+
var DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
|
3506
|
+
var BLOG_ROOT = "blogs";
|
|
3507
|
+
var removeDiacritics = (value) => value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
|
|
3508
|
+
var compact = (items) => (items || []).filter((item) => Boolean(item && item.trim()));
|
|
3509
|
+
function slugifyTitle(title) {
|
|
3510
|
+
const normalized = removeDiacritics(title).toLowerCase().replace(/[^a-z0-9\s-]/g, " ").replace(/[_\s]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
3511
|
+
return normalized || "post";
|
|
3512
|
+
}
|
|
3513
|
+
function normalizeDate(date) {
|
|
3514
|
+
if (date) {
|
|
3515
|
+
if (!DATE_REGEX.test(date)) {
|
|
3516
|
+
throw new Error(
|
|
3517
|
+
`Invalid date format "${date}". Use YYYY-MM-DD (e.g. 2024-09-30).`
|
|
3518
|
+
);
|
|
3519
|
+
}
|
|
3520
|
+
return date;
|
|
3521
|
+
}
|
|
3522
|
+
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
3523
|
+
}
|
|
3524
|
+
var toPublicBlogBase = (appSlug, slug) => `/${BLOG_ROOT}/${appSlug}/${slug}`;
|
|
3525
|
+
function resolveCoverImagePath(appSlug, slug, coverImage) {
|
|
3526
|
+
if (!coverImage || !coverImage.trim()) {
|
|
3527
|
+
return `/products/${appSlug}/og-image.png`;
|
|
3528
|
+
}
|
|
3529
|
+
const cleaned = coverImage.trim();
|
|
3530
|
+
const relativePath = cleaned.replace(/^\.\//, "");
|
|
3531
|
+
if (!cleaned.startsWith("/") && !/^https?:\/\//.test(cleaned)) {
|
|
3532
|
+
return `${toPublicBlogBase(appSlug, slug)}/${relativePath}`;
|
|
3533
|
+
}
|
|
3534
|
+
if (cleaned.startsWith("./")) {
|
|
3535
|
+
return `${toPublicBlogBase(appSlug, slug)}/${relativePath}`;
|
|
3536
|
+
}
|
|
3537
|
+
return cleaned;
|
|
3538
|
+
}
|
|
3539
|
+
function deriveTags(topic, appSlug) {
|
|
3540
|
+
const topicParts = topic.toLowerCase().split(/[^a-z0-9+]+/).filter(Boolean).slice(0, 6);
|
|
3541
|
+
const set = /* @__PURE__ */ new Set([...topicParts, appSlug.toLowerCase(), "blog"]);
|
|
3542
|
+
return Array.from(set);
|
|
3543
|
+
}
|
|
3544
|
+
function buildBlogMeta(options) {
|
|
3545
|
+
const publishedAt = normalizeDate(options.publishedAt);
|
|
3546
|
+
const modifiedAt = normalizeDate(options.modifiedAt || publishedAt);
|
|
3547
|
+
const coverImage = resolveCoverImagePath(
|
|
3548
|
+
options.appSlug,
|
|
3549
|
+
options.slug,
|
|
3550
|
+
options.coverImage
|
|
3551
|
+
);
|
|
3552
|
+
if (!options.description || !options.description.trim()) {
|
|
3553
|
+
throw new Error(
|
|
3554
|
+
"Description is required. The LLM must generate a meta description based on the topic and locale."
|
|
3555
|
+
);
|
|
3556
|
+
}
|
|
3557
|
+
return {
|
|
3558
|
+
title: options.title,
|
|
3559
|
+
description: options.description.trim(),
|
|
3560
|
+
appSlug: options.appSlug,
|
|
3561
|
+
slug: options.slug,
|
|
3562
|
+
locale: options.locale,
|
|
3563
|
+
publishedAt,
|
|
3564
|
+
modifiedAt,
|
|
3565
|
+
coverImage,
|
|
3566
|
+
tags: compact(options.tags)?.length ? Array.from(
|
|
3567
|
+
new Set(compact(options.tags).map((tag) => tag.toLowerCase()))
|
|
3568
|
+
) : deriveTags(options.topic, options.appSlug)
|
|
3569
|
+
};
|
|
3570
|
+
}
|
|
3571
|
+
function renderBlogMetaBlock(meta) {
|
|
3572
|
+
const serialized = JSON.stringify(meta, null, 2);
|
|
3573
|
+
return `<!--BLOG_META
|
|
3574
|
+
${serialized}
|
|
3575
|
+
-->`;
|
|
3576
|
+
}
|
|
3577
|
+
function buildBlogHtmlDocument(options) {
|
|
3578
|
+
const metaBlock = renderBlogMetaBlock(options.meta);
|
|
3579
|
+
const body = options.content.trim();
|
|
3580
|
+
return `${metaBlock}
|
|
3581
|
+
${body}`;
|
|
3582
|
+
}
|
|
3583
|
+
function resolveTargetLocales(input) {
|
|
3584
|
+
if (input.locales?.length) {
|
|
3585
|
+
const locales = input.locales.map((loc) => loc.trim()).filter(Boolean);
|
|
3586
|
+
return Array.from(new Set(locales));
|
|
3587
|
+
}
|
|
3588
|
+
const fallback = input.locale?.trim();
|
|
3589
|
+
return fallback ? [fallback] : [];
|
|
3590
|
+
}
|
|
3591
|
+
function getBlogOutputPaths(options) {
|
|
3592
|
+
const baseDir = path10.join(
|
|
3593
|
+
options.publicDir,
|
|
3594
|
+
BLOG_ROOT,
|
|
3595
|
+
options.appSlug,
|
|
3596
|
+
options.slug
|
|
3597
|
+
);
|
|
3598
|
+
const filePath = path10.join(baseDir, `${options.locale}.html`);
|
|
3599
|
+
const publicBasePath = toPublicBlogBase(options.appSlug, options.slug);
|
|
3600
|
+
return { baseDir, filePath, publicBasePath };
|
|
3601
|
+
}
|
|
3602
|
+
function parseBlogHtml(htmlContent) {
|
|
3603
|
+
const metaBlockRegex = /<!--BLOG_META\s*\n([\s\S]*?)\n-->/;
|
|
3604
|
+
const match = htmlContent.match(metaBlockRegex);
|
|
3605
|
+
if (!match) {
|
|
3606
|
+
return { meta: null, body: htmlContent.trim() };
|
|
3607
|
+
}
|
|
3608
|
+
try {
|
|
3609
|
+
const metaJson = match[1].trim();
|
|
3610
|
+
const meta = JSON.parse(metaJson);
|
|
3611
|
+
const body = htmlContent.replace(metaBlockRegex, "").trim();
|
|
3612
|
+
return { meta, body };
|
|
3613
|
+
} catch (error) {
|
|
3614
|
+
return { meta: null, body: htmlContent.trim() };
|
|
3615
|
+
}
|
|
3616
|
+
}
|
|
3617
|
+
function findExistingBlogPosts({
|
|
3618
|
+
appSlug,
|
|
3619
|
+
locale,
|
|
3620
|
+
publicDir,
|
|
3621
|
+
limit = 2
|
|
3622
|
+
}) {
|
|
3623
|
+
const blogAppDir = path10.join(publicDir, BLOG_ROOT, appSlug);
|
|
3624
|
+
if (!fs10.existsSync(blogAppDir)) {
|
|
3625
|
+
return [];
|
|
3626
|
+
}
|
|
3627
|
+
const posts = [];
|
|
3628
|
+
const subdirs = fs10.readdirSync(blogAppDir, { withFileTypes: true });
|
|
3629
|
+
for (const subdir of subdirs) {
|
|
3630
|
+
if (!subdir.isDirectory()) continue;
|
|
3631
|
+
const localeFile = path10.join(blogAppDir, subdir.name, `${locale}.html`);
|
|
3632
|
+
if (!fs10.existsSync(localeFile)) continue;
|
|
3633
|
+
try {
|
|
3634
|
+
const htmlContent = fs10.readFileSync(localeFile, "utf-8");
|
|
3635
|
+
const { meta, body } = parseBlogHtml(htmlContent);
|
|
3636
|
+
if (meta && meta.locale === locale) {
|
|
3637
|
+
posts.push({
|
|
3638
|
+
filePath: localeFile,
|
|
3639
|
+
meta,
|
|
3640
|
+
body,
|
|
3641
|
+
publishedAt: meta.publishedAt
|
|
3642
|
+
});
|
|
3643
|
+
}
|
|
3644
|
+
} catch (error) {
|
|
3645
|
+
continue;
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3648
|
+
posts.sort((a, b) => {
|
|
3649
|
+
const dateA = new Date(a.publishedAt).getTime();
|
|
3650
|
+
const dateB = new Date(b.publishedAt).getTime();
|
|
3651
|
+
return dateB - dateA;
|
|
3652
|
+
});
|
|
3653
|
+
return posts.slice(0, limit).map(({ filePath, meta, body }) => ({
|
|
3654
|
+
filePath,
|
|
3655
|
+
meta,
|
|
3656
|
+
body
|
|
3657
|
+
}));
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3660
|
+
// src/tools/content/create-blog-html.ts
|
|
3661
|
+
var toJsonSchema5 = zodToJsonSchema8;
|
|
3662
|
+
var DATE_REGEX2 = /^\d{4}-\d{2}-\d{2}$/;
|
|
3663
|
+
var createBlogHtmlInputSchema = z8.object({
|
|
3664
|
+
appSlug: z8.string().trim().min(1).default(DEFAULT_APP_SLUG).describe(
|
|
3665
|
+
`Product/app slug used for paths and CTAs. Defaults to "${DEFAULT_APP_SLUG}" when not provided.`
|
|
3666
|
+
),
|
|
3667
|
+
title: z8.string().trim().optional().describe(
|
|
3668
|
+
"English title used for slug (kebab-case). Falls back to topic when omitted."
|
|
3669
|
+
),
|
|
3670
|
+
topic: z8.string().trim().min(1, "topic is required").describe("Topic/angle to write about in the blog body"),
|
|
3671
|
+
locale: z8.string().trim().min(1, "locale is required").describe(
|
|
3672
|
+
"Primary locale (e.g., 'en-US', 'ko-KR'). Required to determine the language for blog content generation."
|
|
3673
|
+
),
|
|
3674
|
+
locales: z8.array(z8.string().trim().min(1)).optional().describe(
|
|
3675
|
+
"Optional list of locales to generate. Each locale gets its own HTML file. If provided, locale parameter is ignored."
|
|
3676
|
+
),
|
|
3677
|
+
content: z8.string().trim().min(1, "content is required").describe(
|
|
3678
|
+
"HTML content for the blog body. You (the LLM) must generate this HTML content based on the topic and locale. Structure should follow the pattern in public/en-US.html: paragraphs (<p>), headings (<h2>, <h3>), images (<img>), lists (<ul>, <li>), horizontal rules (<hr>), etc. The content should be written in the language corresponding to the locale."
|
|
3679
|
+
),
|
|
3680
|
+
description: z8.string().trim().min(1, "description is required").describe(
|
|
3681
|
+
"Meta description for the blog post. You (the LLM) must generate this based on the topic and locale. Should be a concise summary of the blog content in the language corresponding to the locale."
|
|
3682
|
+
),
|
|
3683
|
+
tags: z8.array(z8.string().trim().min(1)).optional().describe(
|
|
3684
|
+
"Optional tags for BLOG_META. Defaults to tags derived from topic."
|
|
3685
|
+
),
|
|
3686
|
+
coverImage: z8.string().trim().optional().describe(
|
|
3687
|
+
"Cover image path. Relative paths rewrite to /blogs/<app>/<slug>/..., default is /products/<appSlug>/og-image.png."
|
|
3688
|
+
),
|
|
3689
|
+
publishedAt: z8.string().trim().regex(DATE_REGEX2, "publishedAt must use YYYY-MM-DD").optional().describe("Publish date (YYYY-MM-DD). Defaults to today."),
|
|
3690
|
+
modifiedAt: z8.string().trim().regex(DATE_REGEX2, "modifiedAt must use YYYY-MM-DD").optional().describe("Last modified date (YYYY-MM-DD). Defaults to publishedAt."),
|
|
3691
|
+
overwrite: z8.boolean().optional().default(false).describe("Overwrite existing files when true (default: false).")
|
|
3692
|
+
}).describe("Generate static HTML blog posts with BLOG_META headers.");
|
|
3693
|
+
var jsonSchema8 = toJsonSchema5(createBlogHtmlInputSchema, {
|
|
3694
|
+
name: "CreateBlogHtmlInput",
|
|
3695
|
+
$refStrategy: "none"
|
|
3696
|
+
});
|
|
3697
|
+
var inputSchema8 = jsonSchema8.definitions?.CreateBlogHtmlInput || jsonSchema8;
|
|
3698
|
+
var createBlogHtmlTool = {
|
|
3699
|
+
name: "create-blog-html",
|
|
3700
|
+
description: `Generate HTML blog posts under public/blogs/<appSlug>/<slug>/<locale>.html with a BLOG_META block.
|
|
3701
|
+
|
|
3702
|
+
CRITICAL: WRITING STYLE CONSISTENCY
|
|
3703
|
+
Before generating content, you MUST:
|
|
3704
|
+
1. Read existing blog posts from public/blogs/<appSlug>/*/<locale>.html (use findExistingBlogPosts utility or read files directly)
|
|
3705
|
+
2. Analyze the writing style, tone, and format from 2 existing posts in the same locale
|
|
3706
|
+
3. Match that exact writing style when generating the new blog post content and description
|
|
3707
|
+
4. Maintain consistency in: paragraph structure, heading usage, tone, formality level, and overall format
|
|
3708
|
+
|
|
3709
|
+
IMPORTANT REQUIREMENTS:
|
|
3710
|
+
1. The 'locale' parameter is REQUIRED. If the user does not provide a locale, you MUST ask them to specify which language/locale they want to write the blog in (e.g., 'en-US', 'ko-KR', 'ja-JP', etc.).
|
|
3711
|
+
2. The 'content' parameter is REQUIRED. You (the LLM) must generate the HTML content based on the 'topic' and 'locale' provided by the user. The content should be written in the language corresponding to the locale AND match the writing style of existing blog posts for that locale.
|
|
3712
|
+
3. The 'description' parameter is REQUIRED. You (the LLM) must generate this based on the topic, locale, AND the writing style of existing blog posts.
|
|
3713
|
+
4. The 'appSlug' parameter:
|
|
3714
|
+
- If the user explicitly requests "developer category", "developer blog", "personal category", "my category", or similar, you MUST set appSlug to "developer".
|
|
3715
|
+
- If the user mentions a specific app/product, use that app's slug.
|
|
3716
|
+
- If not specified, defaults to "developer".
|
|
3717
|
+
|
|
3718
|
+
Slug rules:
|
|
3719
|
+
- slug = slugify(English title, kebab-case ASCII)
|
|
3720
|
+
- path: public/blogs/<appSlug>/<slug>/<locale>.html
|
|
3721
|
+
- appSlug: Use "developer" when user requests developer/personal category. Defaults to "developer" if not specified.
|
|
3722
|
+
- coverImage default: /products/<appSlug>/og-image.png (relative paths are rewritten under /blogs/<app>/<slug>/)
|
|
3723
|
+
- overwrite defaults to false (throws when file exists)
|
|
3724
|
+
|
|
3725
|
+
HTML Structure (follows public/en-US.html pattern):
|
|
3726
|
+
- BLOG_META block at the top with JSON metadata
|
|
3727
|
+
- HTML body content: paragraphs (<p>), headings (<h2>, <h3>), images (<img>), lists (<ul>, <li>), horizontal rules (<hr>), etc.
|
|
3728
|
+
- You must generate the HTML content based on the topic, making it relevant and engaging for the target locale's language, while maintaining consistency with existing blog posts.
|
|
3729
|
+
|
|
3730
|
+
Supports multiple locales when locales[] is provided. Each locale gets its own HTML file. For each locale, you must:
|
|
3731
|
+
1. Read existing posts in that locale to understand the writing style
|
|
3732
|
+
2. Generate appropriate content in that locale's language
|
|
3733
|
+
3. Match the writing style and format of existing posts`,
|
|
3734
|
+
inputSchema: inputSchema8
|
|
3735
|
+
};
|
|
3736
|
+
async function handleCreateBlogHtml(input) {
|
|
3737
|
+
const publicDir = getPublicDir();
|
|
3738
|
+
const {
|
|
3739
|
+
appSlug = DEFAULT_APP_SLUG,
|
|
3740
|
+
topic,
|
|
3741
|
+
title,
|
|
3742
|
+
description,
|
|
3743
|
+
tags,
|
|
3744
|
+
coverImage,
|
|
3745
|
+
publishedAt,
|
|
3746
|
+
modifiedAt,
|
|
3747
|
+
overwrite = false,
|
|
3748
|
+
content
|
|
3749
|
+
} = input;
|
|
3750
|
+
if (!content || !content.trim()) {
|
|
3751
|
+
throw new Error(
|
|
3752
|
+
"Content is required. Please provide HTML content for the blog body based on the topic and locale."
|
|
3753
|
+
);
|
|
3754
|
+
}
|
|
3755
|
+
const resolvedTitle = title && title.trim() || topic.trim();
|
|
3756
|
+
const slug = slugifyTitle(resolvedTitle);
|
|
3757
|
+
const targetLocales = resolveTargetLocales(input);
|
|
3758
|
+
if (!targetLocales.length) {
|
|
3759
|
+
throw new Error(
|
|
3760
|
+
"Locale is required. Please specify which language/locale you want to write the blog in (e.g., 'en-US', 'ko-KR', 'ja-JP')."
|
|
3761
|
+
);
|
|
3762
|
+
}
|
|
3763
|
+
const existingPostsByLocale = {};
|
|
3764
|
+
for (const locale of targetLocales) {
|
|
3765
|
+
const existingPosts = findExistingBlogPosts({
|
|
3766
|
+
appSlug,
|
|
3767
|
+
locale,
|
|
3768
|
+
publicDir,
|
|
3769
|
+
limit: 2
|
|
3770
|
+
});
|
|
3771
|
+
if (existingPosts.length > 0) {
|
|
3772
|
+
existingPostsByLocale[locale] = existingPosts;
|
|
3773
|
+
}
|
|
3774
|
+
}
|
|
3775
|
+
const output = {
|
|
3776
|
+
slug,
|
|
3777
|
+
baseDir: path11.join(publicDir, "blogs", appSlug, slug),
|
|
3778
|
+
files: [],
|
|
3779
|
+
coverImage: coverImage && coverImage.trim().length > 0 ? coverImage.trim() : `/products/${appSlug}/og-image.png`,
|
|
3780
|
+
metaByLocale: {}
|
|
3781
|
+
};
|
|
3782
|
+
const plannedFiles = targetLocales.map(
|
|
3783
|
+
(locale) => getBlogOutputPaths({
|
|
3784
|
+
appSlug,
|
|
3785
|
+
slug,
|
|
3786
|
+
locale,
|
|
3787
|
+
publicDir
|
|
3788
|
+
})
|
|
3789
|
+
);
|
|
3790
|
+
const existing = plannedFiles.filter(
|
|
3791
|
+
({ filePath }) => fs11.existsSync(filePath)
|
|
3792
|
+
);
|
|
3793
|
+
if (existing.length > 0 && !overwrite) {
|
|
3794
|
+
const existingList = existing.map((f) => f.filePath).join("\n- ");
|
|
3795
|
+
throw new Error(
|
|
3796
|
+
`Blog HTML already exists. Set overwrite=true to replace:
|
|
3797
|
+
- ${existingList}`
|
|
3798
|
+
);
|
|
3799
|
+
}
|
|
3800
|
+
fs11.mkdirSync(output.baseDir, { recursive: true });
|
|
3801
|
+
for (const locale of targetLocales) {
|
|
3802
|
+
const { filePath } = getBlogOutputPaths({
|
|
3803
|
+
appSlug,
|
|
3804
|
+
slug,
|
|
3805
|
+
locale,
|
|
3806
|
+
publicDir
|
|
3807
|
+
});
|
|
3808
|
+
const meta = buildBlogMeta({
|
|
3809
|
+
title: resolvedTitle,
|
|
3810
|
+
description,
|
|
3811
|
+
appSlug,
|
|
3812
|
+
slug,
|
|
3813
|
+
locale,
|
|
3814
|
+
topic,
|
|
3815
|
+
coverImage,
|
|
3816
|
+
tags,
|
|
3817
|
+
publishedAt,
|
|
3818
|
+
modifiedAt
|
|
3819
|
+
});
|
|
3820
|
+
output.coverImage = meta.coverImage;
|
|
3821
|
+
output.metaByLocale[locale] = meta;
|
|
3822
|
+
const html = buildBlogHtmlDocument({
|
|
3823
|
+
meta,
|
|
3824
|
+
content
|
|
3825
|
+
});
|
|
3826
|
+
fs11.writeFileSync(filePath, html, "utf-8");
|
|
3827
|
+
output.files.push({ locale, path: filePath });
|
|
3828
|
+
}
|
|
3829
|
+
const summaryLines = [
|
|
3830
|
+
`Created blog HTML for ${appSlug}`,
|
|
3831
|
+
`Slug: ${slug}`,
|
|
3832
|
+
`Locales: ${targetLocales.join(", ")}`,
|
|
3833
|
+
`Cover image: ${output.coverImage}`,
|
|
3834
|
+
"",
|
|
3835
|
+
"Files:",
|
|
3836
|
+
...output.files.map((file) => `- ${file.locale}: ${file.path}`)
|
|
3837
|
+
];
|
|
3838
|
+
const styleReferenceInfo = [];
|
|
3839
|
+
for (const [locale, posts] of Object.entries(existingPostsByLocale)) {
|
|
3840
|
+
if (posts.length > 0) {
|
|
3841
|
+
styleReferenceInfo.push(
|
|
3842
|
+
`
|
|
3843
|
+
Writing style reference for ${locale}: Found ${posts.length} existing post(s) used for style consistency.`
|
|
3844
|
+
);
|
|
3845
|
+
}
|
|
3846
|
+
}
|
|
3847
|
+
if (styleReferenceInfo.length === 0) {
|
|
3848
|
+
styleReferenceInfo.push(
|
|
3849
|
+
"\nNote: No existing blog posts found for style reference. This is the first post for this app/locale combination."
|
|
3850
|
+
);
|
|
3851
|
+
}
|
|
3852
|
+
return {
|
|
3853
|
+
content: [
|
|
3854
|
+
{
|
|
3855
|
+
type: "text",
|
|
3856
|
+
text: summaryLines.join("\n") + styleReferenceInfo.join("")
|
|
3857
|
+
}
|
|
3858
|
+
]
|
|
3859
|
+
};
|
|
3860
|
+
}
|
|
3861
|
+
|
|
3862
|
+
// src/tools/index.ts
|
|
3863
|
+
var tools = [
|
|
3864
|
+
// ASO Tools
|
|
3865
|
+
{
|
|
3866
|
+
name: asoToPublicTool.name,
|
|
3867
|
+
description: asoToPublicTool.description,
|
|
3868
|
+
inputSchema: asoToPublicTool.inputSchema,
|
|
3869
|
+
zodSchema: asoToPublicInputSchema,
|
|
3870
|
+
handler: handleAsoToPublic,
|
|
3871
|
+
category: "aso"
|
|
3872
|
+
},
|
|
3873
|
+
{
|
|
3874
|
+
name: publicToAsoTool.name,
|
|
3875
|
+
description: publicToAsoTool.description,
|
|
3876
|
+
inputSchema: publicToAsoTool.inputSchema,
|
|
3877
|
+
zodSchema: publicToAsoInputSchema,
|
|
3878
|
+
handler: handlePublicToAso,
|
|
3879
|
+
category: "aso"
|
|
3880
|
+
},
|
|
3881
|
+
{
|
|
3882
|
+
name: improvePublicTool.name,
|
|
3883
|
+
description: improvePublicTool.description,
|
|
3884
|
+
inputSchema: improvePublicTool.inputSchema,
|
|
3885
|
+
zodSchema: improvePublicInputSchema,
|
|
3886
|
+
handler: handleImprovePublic,
|
|
3887
|
+
category: "aso"
|
|
3888
|
+
},
|
|
3889
|
+
{
|
|
3890
|
+
name: validateAsoTool.name,
|
|
3891
|
+
description: validateAsoTool.description,
|
|
3892
|
+
inputSchema: validateAsoTool.inputSchema,
|
|
3893
|
+
zodSchema: validateAsoInputSchema,
|
|
3894
|
+
handler: handleValidateAso,
|
|
3895
|
+
category: "aso"
|
|
3896
|
+
},
|
|
3897
|
+
{
|
|
3898
|
+
name: keywordResearchTool.name,
|
|
3899
|
+
description: keywordResearchTool.description,
|
|
3900
|
+
inputSchema: keywordResearchTool.inputSchema,
|
|
3901
|
+
zodSchema: keywordResearchInputSchema,
|
|
3902
|
+
handler: handleKeywordResearch,
|
|
3903
|
+
category: "aso"
|
|
3904
|
+
},
|
|
3905
|
+
// Apps Tools
|
|
3906
|
+
{
|
|
3907
|
+
name: initProjectTool.name,
|
|
3908
|
+
description: initProjectTool.description,
|
|
3909
|
+
inputSchema: initProjectTool.inputSchema,
|
|
3910
|
+
zodSchema: initProjectInputSchema,
|
|
3911
|
+
handler: handleInitProject,
|
|
3912
|
+
category: "apps"
|
|
3913
|
+
},
|
|
3914
|
+
{
|
|
3915
|
+
name: searchAppTool.name,
|
|
3916
|
+
description: searchAppTool.description,
|
|
3917
|
+
inputSchema: searchAppTool.inputSchema,
|
|
3918
|
+
zodSchema: searchAppInputSchema,
|
|
3919
|
+
handler: handleSearchApp,
|
|
3920
|
+
category: "apps"
|
|
3921
|
+
},
|
|
3922
|
+
// Content Tools
|
|
3923
|
+
{
|
|
3924
|
+
name: createBlogHtmlTool.name,
|
|
3925
|
+
description: createBlogHtmlTool.description,
|
|
3926
|
+
inputSchema: createBlogHtmlTool.inputSchema,
|
|
3927
|
+
zodSchema: createBlogHtmlInputSchema,
|
|
3928
|
+
handler: handleCreateBlogHtml,
|
|
3929
|
+
category: "content"
|
|
3930
|
+
}
|
|
3931
|
+
];
|
|
3932
|
+
function getToolDefinitions() {
|
|
3933
|
+
return [
|
|
3934
|
+
asoToPublicTool,
|
|
3935
|
+
publicToAsoTool,
|
|
3936
|
+
improvePublicTool,
|
|
3937
|
+
initProjectTool,
|
|
3938
|
+
createBlogHtmlTool,
|
|
3939
|
+
keywordResearchTool,
|
|
3940
|
+
searchAppTool,
|
|
3941
|
+
validateAsoTool
|
|
3942
|
+
];
|
|
3943
|
+
}
|
|
3944
|
+
function getToolHandler(name) {
|
|
3945
|
+
const tool = tools.find((t) => t.name === name);
|
|
3946
|
+
if (!tool) {
|
|
3947
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
3948
|
+
}
|
|
3949
|
+
return tool.handler;
|
|
3950
|
+
}
|
|
3951
|
+
function getToolZodSchema(name) {
|
|
3952
|
+
const tool = tools.find((t) => t.name === name);
|
|
3953
|
+
if (!tool) {
|
|
3954
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
3955
|
+
}
|
|
3956
|
+
return tool.zodSchema;
|
|
3957
|
+
}
|
|
3958
|
+
|
|
3959
|
+
// src/bin/mcp-server.ts
|
|
3960
|
+
var server = new Server(
|
|
3961
|
+
{
|
|
3962
|
+
name: "pabal-web-mcp",
|
|
3963
|
+
version: "0.1.0"
|
|
3964
|
+
},
|
|
3965
|
+
{
|
|
3966
|
+
capabilities: {
|
|
3967
|
+
tools: {}
|
|
3968
|
+
}
|
|
3969
|
+
}
|
|
3970
|
+
);
|
|
3971
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
3972
|
+
return {
|
|
3973
|
+
tools: getToolDefinitions()
|
|
3974
|
+
};
|
|
3975
|
+
});
|
|
3976
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
3977
|
+
const { name, arguments: args } = request.params;
|
|
3978
|
+
const zodSchema = getToolZodSchema(name);
|
|
3979
|
+
const handler = getToolHandler(name);
|
|
3980
|
+
if (!zodSchema) {
|
|
3981
|
+
throw new Error(`No schema found for tool: ${name}`);
|
|
3982
|
+
}
|
|
3983
|
+
const input = zodSchema.parse(args);
|
|
3984
|
+
return await handler(input);
|
|
3985
|
+
});
|
|
3986
|
+
async function main() {
|
|
3987
|
+
const transport = new StdioServerTransport();
|
|
3988
|
+
await server.connect(transport);
|
|
3989
|
+
console.error("pabal-web-mcp server running on stdio");
|
|
3990
|
+
}
|
|
3991
|
+
main().catch((error) => {
|
|
3992
|
+
console.error("Fatal error in main():", error);
|
|
3993
|
+
process.exit(1);
|
|
3994
|
+
});
|