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.
@@ -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
+ });