arendi-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1459 -0
- package/package.json +55 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1459 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import "dotenv/config";
|
|
5
|
+
|
|
6
|
+
// src/cli.ts
|
|
7
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
8
|
+
import { dirname, resolve } from "path";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
import { Command } from "commander";
|
|
11
|
+
|
|
12
|
+
// ../../packages/arendi/dist/types.js
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
var CATEGORIES = [
|
|
15
|
+
"books",
|
|
16
|
+
"business",
|
|
17
|
+
"education",
|
|
18
|
+
"entertainment",
|
|
19
|
+
"fashion",
|
|
20
|
+
"finance",
|
|
21
|
+
"fitness",
|
|
22
|
+
"food",
|
|
23
|
+
"gaming",
|
|
24
|
+
"health",
|
|
25
|
+
"lifestyle",
|
|
26
|
+
"marketing",
|
|
27
|
+
"music",
|
|
28
|
+
"pets",
|
|
29
|
+
"real-estate",
|
|
30
|
+
"saas",
|
|
31
|
+
"social-media",
|
|
32
|
+
"sports",
|
|
33
|
+
"technology",
|
|
34
|
+
"travel"
|
|
35
|
+
];
|
|
36
|
+
var PROVIDERS = ["openai", "anthropic", "google"];
|
|
37
|
+
var OUTPUT_FORMATS = ["markdown", "json", "csv", "text"];
|
|
38
|
+
var optionsSchema = z.object({
|
|
39
|
+
country: z.string().optional(),
|
|
40
|
+
city: z.string().optional(),
|
|
41
|
+
categories: z.array(z.string()).optional(),
|
|
42
|
+
limit: z.number().min(1).max(50).default(5),
|
|
43
|
+
keywords: z.array(z.string()).optional(),
|
|
44
|
+
provider: z.enum(PROVIDERS).default("openai"),
|
|
45
|
+
model: z.string().optional(),
|
|
46
|
+
apiKey: z.string().min(1, "LLM provider API key is required"),
|
|
47
|
+
tavilyApiKey: z.string().min(1, "Tavily API key is required"),
|
|
48
|
+
/** Optional ISO 3166-1 alpha-2 code for Google Trends (e.g. "US", "ID"). Auto-detected from `country` if omitted. */
|
|
49
|
+
geo: z.string().length(2).optional()
|
|
50
|
+
});
|
|
51
|
+
var tavilyResultSchema = z.object({
|
|
52
|
+
title: z.string(),
|
|
53
|
+
url: z.string(),
|
|
54
|
+
content: z.string(),
|
|
55
|
+
score: z.number()
|
|
56
|
+
});
|
|
57
|
+
var tavilyResponseSchema = z.object({
|
|
58
|
+
results: z.array(tavilyResultSchema)
|
|
59
|
+
});
|
|
60
|
+
var businessIdeaSchema = z.object({
|
|
61
|
+
title: z.string().describe("A concise, compelling title for the business idea"),
|
|
62
|
+
description: z.string().describe("A 2-3 sentence description of the business idea"),
|
|
63
|
+
category: z.string().describe("The primary category this idea falls under"),
|
|
64
|
+
targetAudience: z.string().describe("Who this business targets (demographics, psychographics)"),
|
|
65
|
+
painPoint: z.string().describe("The specific pain point or unmet need this addresses"),
|
|
66
|
+
opportunityType: z.enum(["trending", "pain-point", "gap", "trend-transfer"]).describe("How this opportunity was identified: trending topic, pain point, market gap, or trend transferred from another market"),
|
|
67
|
+
estimatedDifficulty: z.enum(["low", "medium", "high"]).describe("How difficult it is to start this business"),
|
|
68
|
+
potentialRevenue: z.enum(["low", "medium", "high"]).describe("Estimated revenue potential"),
|
|
69
|
+
reasoning: z.string().describe("Why this is a good opportunity right now, grounded in the research data"),
|
|
70
|
+
actionableSteps: z.array(z.string()).describe("3-5 concrete first steps to pursue this idea"),
|
|
71
|
+
references: z.array(z.string()).describe("URLs or sources that support this idea")
|
|
72
|
+
});
|
|
73
|
+
var arendiResultSchema = z.object({
|
|
74
|
+
ideas: z.array(businessIdeaSchema)
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ../../packages/arendi/dist/format.js
|
|
78
|
+
function toMarkdown(ideas) {
|
|
79
|
+
const lines = [`# Business Ideas & Opportunities`, ""];
|
|
80
|
+
for (let i = 0; i < ideas.length; i++) {
|
|
81
|
+
const idea = ideas[i];
|
|
82
|
+
const num = i + 1;
|
|
83
|
+
lines.push(`## ${num}. ${idea.title}`);
|
|
84
|
+
lines.push("");
|
|
85
|
+
lines.push(idea.description);
|
|
86
|
+
lines.push("");
|
|
87
|
+
lines.push(`| Field | Value |`);
|
|
88
|
+
lines.push(`| --- | --- |`);
|
|
89
|
+
lines.push(`| **Category** | ${idea.category} |`);
|
|
90
|
+
lines.push(`| **Opportunity type** | ${idea.opportunityType} |`);
|
|
91
|
+
lines.push(`| **Target audience** | ${idea.targetAudience} |`);
|
|
92
|
+
lines.push(`| **Pain point** | ${idea.painPoint} |`);
|
|
93
|
+
lines.push(`| **Difficulty** | ${idea.estimatedDifficulty} |`);
|
|
94
|
+
lines.push(`| **Revenue potential** | ${idea.potentialRevenue} |`);
|
|
95
|
+
lines.push("");
|
|
96
|
+
lines.push(`### Why now?`);
|
|
97
|
+
lines.push("");
|
|
98
|
+
lines.push(idea.reasoning);
|
|
99
|
+
lines.push("");
|
|
100
|
+
lines.push(`### Actionable steps`);
|
|
101
|
+
lines.push("");
|
|
102
|
+
for (const step of idea.actionableSteps) {
|
|
103
|
+
lines.push(`1. ${step}`);
|
|
104
|
+
}
|
|
105
|
+
lines.push("");
|
|
106
|
+
if (idea.references.length > 0) {
|
|
107
|
+
lines.push(`### References`);
|
|
108
|
+
lines.push("");
|
|
109
|
+
for (const ref of idea.references) {
|
|
110
|
+
lines.push(`- ${ref}`);
|
|
111
|
+
}
|
|
112
|
+
lines.push("");
|
|
113
|
+
}
|
|
114
|
+
lines.push("---");
|
|
115
|
+
lines.push("");
|
|
116
|
+
}
|
|
117
|
+
return lines.join("\n");
|
|
118
|
+
}
|
|
119
|
+
function toJson(ideas) {
|
|
120
|
+
return JSON.stringify(ideas, null, 2);
|
|
121
|
+
}
|
|
122
|
+
function escapeCsv(value) {
|
|
123
|
+
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
|
|
124
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
125
|
+
}
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
function toCsv(ideas) {
|
|
129
|
+
const headers = [
|
|
130
|
+
"title",
|
|
131
|
+
"description",
|
|
132
|
+
"category",
|
|
133
|
+
"targetAudience",
|
|
134
|
+
"painPoint",
|
|
135
|
+
"opportunityType",
|
|
136
|
+
"estimatedDifficulty",
|
|
137
|
+
"potentialRevenue",
|
|
138
|
+
"reasoning",
|
|
139
|
+
"actionableSteps",
|
|
140
|
+
"references"
|
|
141
|
+
];
|
|
142
|
+
const rows = ideas.map((idea) => [
|
|
143
|
+
escapeCsv(idea.title),
|
|
144
|
+
escapeCsv(idea.description),
|
|
145
|
+
escapeCsv(idea.category),
|
|
146
|
+
escapeCsv(idea.targetAudience),
|
|
147
|
+
escapeCsv(idea.painPoint),
|
|
148
|
+
escapeCsv(idea.opportunityType),
|
|
149
|
+
escapeCsv(idea.estimatedDifficulty),
|
|
150
|
+
escapeCsv(idea.potentialRevenue),
|
|
151
|
+
escapeCsv(idea.reasoning),
|
|
152
|
+
escapeCsv(idea.actionableSteps.join("; ")),
|
|
153
|
+
escapeCsv(idea.references.join("; "))
|
|
154
|
+
].join(","));
|
|
155
|
+
return [headers.join(","), ...rows].join("\n");
|
|
156
|
+
}
|
|
157
|
+
function toText(ideas) {
|
|
158
|
+
const lines = [
|
|
159
|
+
"BUSINESS IDEAS & OPPORTUNITIES",
|
|
160
|
+
"=".repeat(40),
|
|
161
|
+
""
|
|
162
|
+
];
|
|
163
|
+
for (let i = 0; i < ideas.length; i++) {
|
|
164
|
+
const idea = ideas[i];
|
|
165
|
+
const num = i + 1;
|
|
166
|
+
lines.push(`${num}. ${idea.title.toUpperCase()}`);
|
|
167
|
+
lines.push("-".repeat(40));
|
|
168
|
+
lines.push(idea.description);
|
|
169
|
+
lines.push("");
|
|
170
|
+
lines.push(` Category: ${idea.category}`);
|
|
171
|
+
lines.push(` Opportunity type: ${idea.opportunityType}`);
|
|
172
|
+
lines.push(` Target audience: ${idea.targetAudience}`);
|
|
173
|
+
lines.push(` Pain point: ${idea.painPoint}`);
|
|
174
|
+
lines.push(` Difficulty: ${idea.estimatedDifficulty}`);
|
|
175
|
+
lines.push(` Revenue potential: ${idea.potentialRevenue}`);
|
|
176
|
+
lines.push("");
|
|
177
|
+
lines.push(` Why now?`);
|
|
178
|
+
lines.push(` ${idea.reasoning}`);
|
|
179
|
+
lines.push("");
|
|
180
|
+
lines.push(` Steps:`);
|
|
181
|
+
for (const step of idea.actionableSteps) {
|
|
182
|
+
lines.push(` - ${step}`);
|
|
183
|
+
}
|
|
184
|
+
lines.push("");
|
|
185
|
+
if (idea.references.length > 0) {
|
|
186
|
+
lines.push(` References:`);
|
|
187
|
+
for (const ref of idea.references) {
|
|
188
|
+
lines.push(` - ${ref}`);
|
|
189
|
+
}
|
|
190
|
+
lines.push("");
|
|
191
|
+
}
|
|
192
|
+
lines.push("");
|
|
193
|
+
}
|
|
194
|
+
return lines.join("\n");
|
|
195
|
+
}
|
|
196
|
+
function formatIdeas(ideas, format) {
|
|
197
|
+
switch (format) {
|
|
198
|
+
case "markdown":
|
|
199
|
+
return toMarkdown(ideas);
|
|
200
|
+
case "json":
|
|
201
|
+
return toJson(ideas);
|
|
202
|
+
case "csv":
|
|
203
|
+
return toCsv(ideas);
|
|
204
|
+
case "text":
|
|
205
|
+
return toText(ideas);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ../../packages/arendi/dist/search.js
|
|
210
|
+
import { createHash } from "crypto";
|
|
211
|
+
|
|
212
|
+
// ../../packages/arendi/dist/trends.js
|
|
213
|
+
import googleTrends from "google-trends-api";
|
|
214
|
+
var COUNTRY_GEO_MAP = {
|
|
215
|
+
afghanistan: "AF",
|
|
216
|
+
albania: "AL",
|
|
217
|
+
algeria: "DZ",
|
|
218
|
+
argentina: "AR",
|
|
219
|
+
australia: "AU",
|
|
220
|
+
austria: "AT",
|
|
221
|
+
bangladesh: "BD",
|
|
222
|
+
belgium: "BE",
|
|
223
|
+
bolivia: "BO",
|
|
224
|
+
brazil: "BR",
|
|
225
|
+
cambodia: "KH",
|
|
226
|
+
cameroon: "CM",
|
|
227
|
+
canada: "CA",
|
|
228
|
+
chile: "CL",
|
|
229
|
+
china: "CN",
|
|
230
|
+
colombia: "CO",
|
|
231
|
+
"costa rica": "CR",
|
|
232
|
+
croatia: "HR",
|
|
233
|
+
"czech republic": "CZ",
|
|
234
|
+
czechia: "CZ",
|
|
235
|
+
denmark: "DK",
|
|
236
|
+
"dominican republic": "DO",
|
|
237
|
+
ecuador: "EC",
|
|
238
|
+
egypt: "EG",
|
|
239
|
+
"el salvador": "SV",
|
|
240
|
+
ethiopia: "ET",
|
|
241
|
+
finland: "FI",
|
|
242
|
+
france: "FR",
|
|
243
|
+
germany: "DE",
|
|
244
|
+
ghana: "GH",
|
|
245
|
+
greece: "GR",
|
|
246
|
+
guatemala: "GT",
|
|
247
|
+
honduras: "HN",
|
|
248
|
+
"hong kong": "HK",
|
|
249
|
+
hungary: "HU",
|
|
250
|
+
india: "IN",
|
|
251
|
+
indonesia: "ID",
|
|
252
|
+
iran: "IR",
|
|
253
|
+
iraq: "IQ",
|
|
254
|
+
ireland: "IE",
|
|
255
|
+
israel: "IL",
|
|
256
|
+
italy: "IT",
|
|
257
|
+
jamaica: "JM",
|
|
258
|
+
japan: "JP",
|
|
259
|
+
jordan: "JO",
|
|
260
|
+
kazakhstan: "KZ",
|
|
261
|
+
kenya: "KE",
|
|
262
|
+
"south korea": "KR",
|
|
263
|
+
korea: "KR",
|
|
264
|
+
kuwait: "KW",
|
|
265
|
+
laos: "LA",
|
|
266
|
+
lebanon: "LB",
|
|
267
|
+
libya: "LY",
|
|
268
|
+
malaysia: "MY",
|
|
269
|
+
mexico: "MX",
|
|
270
|
+
morocco: "MA",
|
|
271
|
+
myanmar: "MM",
|
|
272
|
+
nepal: "NP",
|
|
273
|
+
netherlands: "NL",
|
|
274
|
+
"new zealand": "NZ",
|
|
275
|
+
nicaragua: "NI",
|
|
276
|
+
nigeria: "NG",
|
|
277
|
+
norway: "NO",
|
|
278
|
+
oman: "OM",
|
|
279
|
+
pakistan: "PK",
|
|
280
|
+
panama: "PA",
|
|
281
|
+
paraguay: "PY",
|
|
282
|
+
peru: "PE",
|
|
283
|
+
philippines: "PH",
|
|
284
|
+
poland: "PL",
|
|
285
|
+
portugal: "PT",
|
|
286
|
+
qatar: "QA",
|
|
287
|
+
romania: "RO",
|
|
288
|
+
russia: "RU",
|
|
289
|
+
"saudi arabia": "SA",
|
|
290
|
+
senegal: "SN",
|
|
291
|
+
serbia: "RS",
|
|
292
|
+
singapore: "SG",
|
|
293
|
+
slovakia: "SK",
|
|
294
|
+
"south africa": "ZA",
|
|
295
|
+
spain: "ES",
|
|
296
|
+
"sri lanka": "LK",
|
|
297
|
+
sweden: "SE",
|
|
298
|
+
switzerland: "CH",
|
|
299
|
+
taiwan: "TW",
|
|
300
|
+
tanzania: "TZ",
|
|
301
|
+
thailand: "TH",
|
|
302
|
+
tunisia: "TN",
|
|
303
|
+
turkey: "TR",
|
|
304
|
+
turkiye: "TR",
|
|
305
|
+
uganda: "UG",
|
|
306
|
+
ukraine: "UA",
|
|
307
|
+
"united arab emirates": "AE",
|
|
308
|
+
uae: "AE",
|
|
309
|
+
"united kingdom": "GB",
|
|
310
|
+
uk: "GB",
|
|
311
|
+
"united states": "US",
|
|
312
|
+
usa: "US",
|
|
313
|
+
us: "US",
|
|
314
|
+
uruguay: "UY",
|
|
315
|
+
uzbekistan: "UZ",
|
|
316
|
+
venezuela: "VE",
|
|
317
|
+
vietnam: "VN",
|
|
318
|
+
zambia: "ZM",
|
|
319
|
+
zimbabwe: "ZW"
|
|
320
|
+
};
|
|
321
|
+
function countryToGeo(country) {
|
|
322
|
+
const trimmed = country.trim();
|
|
323
|
+
if (/^[A-Za-z]{2}$/.test(trimmed)) {
|
|
324
|
+
return trimmed.toUpperCase();
|
|
325
|
+
}
|
|
326
|
+
return COUNTRY_GEO_MAP[trimmed.toLowerCase()] ?? null;
|
|
327
|
+
}
|
|
328
|
+
async function fetchDailyTrends(geo) {
|
|
329
|
+
try {
|
|
330
|
+
const raw = await googleTrends.dailyTrends({ geo });
|
|
331
|
+
const parsed = JSON.parse(raw);
|
|
332
|
+
const days = parsed?.["default"];
|
|
333
|
+
const trendingDays = days?.["trendingSearchesDays"] ?? [];
|
|
334
|
+
const results = [];
|
|
335
|
+
for (const day of trendingDays) {
|
|
336
|
+
const searches = day["trendingSearches"] ?? [];
|
|
337
|
+
for (const search of searches) {
|
|
338
|
+
const title = search["title"]?.["query"] ?? "";
|
|
339
|
+
const traffic = search["formattedTraffic"] ?? "";
|
|
340
|
+
const relatedQueriesRaw = search["relatedQueries"] ?? [];
|
|
341
|
+
const relatedQueries = relatedQueriesRaw.map((q) => q["query"] ?? "").filter(Boolean);
|
|
342
|
+
if (title) {
|
|
343
|
+
results.push({ title, traffic, relatedQueries });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return results;
|
|
348
|
+
} catch (error) {
|
|
349
|
+
console.warn(`Warning: Failed to fetch daily trends for geo "${geo}":`, error instanceof Error ? error.message : String(error));
|
|
350
|
+
return [];
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
async function fetchRelatedQueries(keywords, geo) {
|
|
354
|
+
const ninetyDaysAgo = /* @__PURE__ */ new Date();
|
|
355
|
+
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
|
|
356
|
+
const results = await Promise.all(keywords.map(async (keyword) => {
|
|
357
|
+
try {
|
|
358
|
+
const raw = await googleTrends.relatedQueries({
|
|
359
|
+
keyword,
|
|
360
|
+
startTime: ninetyDaysAgo,
|
|
361
|
+
geo: geo ?? ""
|
|
362
|
+
});
|
|
363
|
+
const parsed = JSON.parse(raw);
|
|
364
|
+
const rankedList = parsed?.["default"]?.["rankedList"];
|
|
365
|
+
if (!rankedList || rankedList.length === 0) {
|
|
366
|
+
return { keyword, rising: [], top: [] };
|
|
367
|
+
}
|
|
368
|
+
const topList = rankedList[0]?.["rankedKeyword"] ?? [];
|
|
369
|
+
const risingList = rankedList[1]?.["rankedKeyword"] ?? [];
|
|
370
|
+
const top = topList.map((item) => ({
|
|
371
|
+
query: item["query"] ?? "",
|
|
372
|
+
value: item["value"] ?? 0
|
|
373
|
+
})).filter((item) => item.query);
|
|
374
|
+
const rising = risingList.map((item) => ({
|
|
375
|
+
query: item["query"] ?? "",
|
|
376
|
+
value: item["formattedValue"] ?? item["value"] ?? 0
|
|
377
|
+
})).filter((item) => item.query);
|
|
378
|
+
return { keyword, rising, top };
|
|
379
|
+
} catch (error) {
|
|
380
|
+
console.warn(`Warning: Failed to fetch related queries for "${keyword}":`, error instanceof Error ? error.message : String(error));
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
}));
|
|
384
|
+
return results.filter((r) => r !== null);
|
|
385
|
+
}
|
|
386
|
+
async function fetchRelatedTopics(keywords, geo) {
|
|
387
|
+
const ninetyDaysAgo = /* @__PURE__ */ new Date();
|
|
388
|
+
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
|
|
389
|
+
const results = await Promise.all(keywords.map(async (keyword) => {
|
|
390
|
+
try {
|
|
391
|
+
const raw = await googleTrends.relatedTopics({
|
|
392
|
+
keyword,
|
|
393
|
+
startTime: ninetyDaysAgo,
|
|
394
|
+
geo: geo ?? ""
|
|
395
|
+
});
|
|
396
|
+
const parsed = JSON.parse(raw);
|
|
397
|
+
const rankedList = parsed?.["default"]?.["rankedList"];
|
|
398
|
+
if (!rankedList || rankedList.length === 0) {
|
|
399
|
+
return { keyword, rising: [], top: [] };
|
|
400
|
+
}
|
|
401
|
+
const topList = rankedList[0]?.["rankedKeyword"] ?? [];
|
|
402
|
+
const risingList = rankedList[1]?.["rankedKeyword"] ?? [];
|
|
403
|
+
const top = topList.map((item) => {
|
|
404
|
+
const topic = item["topic"];
|
|
405
|
+
return {
|
|
406
|
+
title: topic?.["title"] ?? "",
|
|
407
|
+
type: topic?.["type"] ?? "",
|
|
408
|
+
value: item["value"] ?? 0
|
|
409
|
+
};
|
|
410
|
+
}).filter((item) => item.title);
|
|
411
|
+
const rising = risingList.map((item) => {
|
|
412
|
+
const topic = item["topic"];
|
|
413
|
+
return {
|
|
414
|
+
title: topic?.["title"] ?? "",
|
|
415
|
+
type: topic?.["type"] ?? "",
|
|
416
|
+
value: item["formattedValue"] ?? item["value"] ?? 0
|
|
417
|
+
};
|
|
418
|
+
}).filter((item) => item.title);
|
|
419
|
+
return { keyword, rising, top };
|
|
420
|
+
} catch (error) {
|
|
421
|
+
console.warn(`Warning: Failed to fetch related topics for "${keyword}":`, error instanceof Error ? error.message : String(error));
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
}));
|
|
425
|
+
return results.filter((r) => r !== null);
|
|
426
|
+
}
|
|
427
|
+
async function trendsResearch(options) {
|
|
428
|
+
const geo = options.geo ? options.geo.toUpperCase() : options.country ? countryToGeo(options.country) : null;
|
|
429
|
+
const keywords = options.keywords?.length ? [...options.keywords] : [];
|
|
430
|
+
if (options.categories?.length && keywords.length === 0) {
|
|
431
|
+
keywords.push(...options.categories);
|
|
432
|
+
}
|
|
433
|
+
const [dailyTrends, relatedQueries, relatedTopics] = await Promise.all([
|
|
434
|
+
geo ? fetchDailyTrends(geo) : Promise.resolve([]),
|
|
435
|
+
keywords.length > 0 ? fetchRelatedQueries(keywords, geo ?? void 0) : Promise.resolve([]),
|
|
436
|
+
keywords.length > 0 ? fetchRelatedTopics(keywords, geo ?? void 0) : Promise.resolve([])
|
|
437
|
+
]);
|
|
438
|
+
return { dailyTrends, relatedQueries, relatedTopics };
|
|
439
|
+
}
|
|
440
|
+
function summarizeTrends(ctx) {
|
|
441
|
+
const sections = [];
|
|
442
|
+
if (ctx.dailyTrends.length > 0) {
|
|
443
|
+
const items = ctx.dailyTrends.slice(0, 20).map((t) => {
|
|
444
|
+
let line = `- **${t.title}**`;
|
|
445
|
+
if (t.traffic)
|
|
446
|
+
line += ` (${t.traffic} searches)`;
|
|
447
|
+
if (t.relatedQueries.length > 0) {
|
|
448
|
+
line += ` \u2014 related: ${t.relatedQueries.join(", ")}`;
|
|
449
|
+
}
|
|
450
|
+
return line;
|
|
451
|
+
}).join("\n");
|
|
452
|
+
sections.push(`### Daily Trending Searches
|
|
453
|
+
|
|
454
|
+
These are the top trending search terms in the target market right now.
|
|
455
|
+
|
|
456
|
+
${items}`);
|
|
457
|
+
}
|
|
458
|
+
const risingQueries = ctx.relatedQueries.filter((r) => r.rising.length > 0);
|
|
459
|
+
if (risingQueries.length > 0) {
|
|
460
|
+
const items = risingQueries.map((r) => {
|
|
461
|
+
const queries = r.rising.slice(0, 10).map((q) => {
|
|
462
|
+
const label = typeof q.value === "string" ? q.value : `+${q.value}%`;
|
|
463
|
+
return ` - "${q.query}" (${label})`;
|
|
464
|
+
}).join("\n");
|
|
465
|
+
return `- **${r.keyword}**:
|
|
466
|
+
${queries}`;
|
|
467
|
+
}).join("\n");
|
|
468
|
+
sections.push(`### Rising Search Queries
|
|
469
|
+
|
|
470
|
+
Queries with the biggest recent increase in search frequency. "Breakout" means massive growth from near-zero.
|
|
471
|
+
|
|
472
|
+
${items}`);
|
|
473
|
+
}
|
|
474
|
+
const risingTopics = ctx.relatedTopics.filter((r) => r.rising.length > 0);
|
|
475
|
+
if (risingTopics.length > 0) {
|
|
476
|
+
const items = risingTopics.map((r) => {
|
|
477
|
+
const topics = r.rising.slice(0, 10).map((t) => {
|
|
478
|
+
const label = typeof t.value === "string" ? t.value : `+${t.value}%`;
|
|
479
|
+
return ` - ${t.title} (${t.type}) \u2014 ${label}`;
|
|
480
|
+
}).join("\n");
|
|
481
|
+
return `- **${r.keyword}**:
|
|
482
|
+
${topics}`;
|
|
483
|
+
}).join("\n");
|
|
484
|
+
sections.push(`### Rising Related Topics
|
|
485
|
+
|
|
486
|
+
Topics with the biggest recent increase in search interest.
|
|
487
|
+
|
|
488
|
+
${items}`);
|
|
489
|
+
}
|
|
490
|
+
if (sections.length === 0)
|
|
491
|
+
return "";
|
|
492
|
+
return `## Google Trends Data
|
|
493
|
+
|
|
494
|
+
Real-time search demand signals from Google Trends showing what people are actively searching for.
|
|
495
|
+
|
|
496
|
+
${sections.join("\n\n")}`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ../../packages/arendi/dist/search.js
|
|
500
|
+
var TAVILY_API_URL = "https://api.tavily.com/search";
|
|
501
|
+
var TAVILY_CACHE_TTL_MS = 864e5;
|
|
502
|
+
function buildCacheKey(query, includeDomains) {
|
|
503
|
+
const parts = [query];
|
|
504
|
+
if (includeDomains?.length) {
|
|
505
|
+
parts.push(...[...includeDomains].sort());
|
|
506
|
+
}
|
|
507
|
+
const hash = createHash("sha256").update(parts.join("|")).digest("hex");
|
|
508
|
+
return `tavily:${hash}`;
|
|
509
|
+
}
|
|
510
|
+
var GLOBAL_DOMAINS = /* @__PURE__ */ new Set([
|
|
511
|
+
"wikipedia.org",
|
|
512
|
+
"youtube.com",
|
|
513
|
+
"reddit.com",
|
|
514
|
+
"medium.com",
|
|
515
|
+
"facebook.com",
|
|
516
|
+
"twitter.com",
|
|
517
|
+
"x.com",
|
|
518
|
+
"linkedin.com",
|
|
519
|
+
"instagram.com",
|
|
520
|
+
"tiktok.com",
|
|
521
|
+
"quora.com",
|
|
522
|
+
"forbes.com",
|
|
523
|
+
"bloomberg.com",
|
|
524
|
+
"cnbc.com",
|
|
525
|
+
"bbc.com",
|
|
526
|
+
"cnn.com",
|
|
527
|
+
"reuters.com"
|
|
528
|
+
]);
|
|
529
|
+
async function tavilySearch(query, apiKey, options) {
|
|
530
|
+
const { cache, cacheStats } = options ?? {};
|
|
531
|
+
if (cache) {
|
|
532
|
+
const key = buildCacheKey(query, options?.includeDomains);
|
|
533
|
+
try {
|
|
534
|
+
const cached = await cache.get(key);
|
|
535
|
+
if (cached !== null) {
|
|
536
|
+
if (cacheStats)
|
|
537
|
+
cacheStats.hits++;
|
|
538
|
+
return JSON.parse(cached);
|
|
539
|
+
}
|
|
540
|
+
} catch {
|
|
541
|
+
}
|
|
542
|
+
if (cacheStats)
|
|
543
|
+
cacheStats.misses++;
|
|
544
|
+
}
|
|
545
|
+
const body = {
|
|
546
|
+
api_key: apiKey,
|
|
547
|
+
query,
|
|
548
|
+
search_depth: "advanced",
|
|
549
|
+
max_results: 5,
|
|
550
|
+
include_answer: false
|
|
551
|
+
};
|
|
552
|
+
if (options?.includeDomains?.length) {
|
|
553
|
+
body["include_domains"] = options.includeDomains;
|
|
554
|
+
}
|
|
555
|
+
const response = await fetch(TAVILY_API_URL, {
|
|
556
|
+
method: "POST",
|
|
557
|
+
headers: {
|
|
558
|
+
"Content-Type": "application/json"
|
|
559
|
+
},
|
|
560
|
+
body: JSON.stringify(body)
|
|
561
|
+
});
|
|
562
|
+
if (!response.ok) {
|
|
563
|
+
const text = await response.text();
|
|
564
|
+
throw new Error(`Tavily search failed (${response.status}): ${text}`);
|
|
565
|
+
}
|
|
566
|
+
const data = await response.json();
|
|
567
|
+
const parsed = tavilyResponseSchema.safeParse(data);
|
|
568
|
+
if (!parsed.success) {
|
|
569
|
+
console.warn(`Warning: Could not parse Tavily response for "${query}"`);
|
|
570
|
+
return [];
|
|
571
|
+
}
|
|
572
|
+
const results = parsed.data.results;
|
|
573
|
+
if (cache) {
|
|
574
|
+
const key = buildCacheKey(query, options?.includeDomains);
|
|
575
|
+
try {
|
|
576
|
+
await cache.set(key, JSON.stringify(results), TAVILY_CACHE_TTL_MS);
|
|
577
|
+
} catch {
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return results;
|
|
581
|
+
}
|
|
582
|
+
async function discoverLocalSources(options, cacheStats) {
|
|
583
|
+
const locationParts = [];
|
|
584
|
+
if (options.city)
|
|
585
|
+
locationParts.push(options.city);
|
|
586
|
+
if (options.country)
|
|
587
|
+
locationParts.push(options.country);
|
|
588
|
+
const location = locationParts.join(", ");
|
|
589
|
+
if (!location)
|
|
590
|
+
return [];
|
|
591
|
+
const discoveryQueries = [
|
|
592
|
+
`top local business news websites in ${location}`,
|
|
593
|
+
`popular local media sources ${location} economy startups`,
|
|
594
|
+
`${location} local online newspapers business publications`
|
|
595
|
+
];
|
|
596
|
+
const allResults = await Promise.all(discoveryQueries.map((q) => tavilySearch(q, options.tavilyApiKey, {
|
|
597
|
+
cache: options.cache,
|
|
598
|
+
cacheStats
|
|
599
|
+
})));
|
|
600
|
+
const domains = /* @__PURE__ */ new Set();
|
|
601
|
+
for (const results of allResults) {
|
|
602
|
+
for (const result of results) {
|
|
603
|
+
try {
|
|
604
|
+
const url = new URL(result.url);
|
|
605
|
+
const hostname = url.hostname.replace(/^www\./, "");
|
|
606
|
+
if (!GLOBAL_DOMAINS.has(hostname)) {
|
|
607
|
+
domains.add(hostname);
|
|
608
|
+
}
|
|
609
|
+
} catch {
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return Array.from(domains);
|
|
614
|
+
}
|
|
615
|
+
function buildLocalSearchQueries(options) {
|
|
616
|
+
const locationParts = [];
|
|
617
|
+
if (options.city)
|
|
618
|
+
locationParts.push(options.city);
|
|
619
|
+
if (options.country)
|
|
620
|
+
locationParts.push(options.country);
|
|
621
|
+
const location = locationParts.join(" ");
|
|
622
|
+
if (!location)
|
|
623
|
+
return [];
|
|
624
|
+
const cats = options.categories?.join(", ") ?? "";
|
|
625
|
+
const kw = options.keywords?.join(", ") ?? "";
|
|
626
|
+
const year = (/* @__PURE__ */ new Date()).getFullYear();
|
|
627
|
+
const queries = [];
|
|
628
|
+
queries.push([`business opportunities trends`, cats, location, year].filter(Boolean).join(" "));
|
|
629
|
+
queries.push([`consumer needs problems`, cats, location, year].filter(Boolean).join(" "));
|
|
630
|
+
queries.push([`startup ecosystem market growth`, cats, location, year].filter(Boolean).join(" "));
|
|
631
|
+
if (kw) {
|
|
632
|
+
queries.push([kw, `market demand`, location, year].filter(Boolean).join(" "));
|
|
633
|
+
}
|
|
634
|
+
return queries;
|
|
635
|
+
}
|
|
636
|
+
function buildGlobalSearchQueries(options) {
|
|
637
|
+
const parts = [];
|
|
638
|
+
if (options.country)
|
|
639
|
+
parts.push(options.country);
|
|
640
|
+
if (options.city)
|
|
641
|
+
parts.push(options.city);
|
|
642
|
+
const location = parts.join(" ");
|
|
643
|
+
const cats = options.categories?.join(", ") ?? "";
|
|
644
|
+
const kw = options.keywords?.join(", ") ?? "";
|
|
645
|
+
const queries = [];
|
|
646
|
+
queries.push([
|
|
647
|
+
"trending business opportunities",
|
|
648
|
+
cats,
|
|
649
|
+
location,
|
|
650
|
+
(/* @__PURE__ */ new Date()).getFullYear()
|
|
651
|
+
].filter(Boolean).join(" "));
|
|
652
|
+
queries.push(["consumer pain points", cats, location, (/* @__PURE__ */ new Date()).getFullYear()].filter(Boolean).join(" "));
|
|
653
|
+
queries.push(["market gaps underserved needs", cats, location].filter(Boolean).join(" "));
|
|
654
|
+
if (kw) {
|
|
655
|
+
queries.push(["emerging trends", kw, cats, "business", (/* @__PURE__ */ new Date()).getFullYear()].filter(Boolean).join(" "));
|
|
656
|
+
}
|
|
657
|
+
queries.push([
|
|
658
|
+
"successful business models trending globally",
|
|
659
|
+
cats,
|
|
660
|
+
(/* @__PURE__ */ new Date()).getFullYear()
|
|
661
|
+
].filter(Boolean).join(" "));
|
|
662
|
+
return queries;
|
|
663
|
+
}
|
|
664
|
+
async function localResearch(options, localDomains, cacheStats) {
|
|
665
|
+
const queries = buildLocalSearchQueries(options);
|
|
666
|
+
if (queries.length === 0)
|
|
667
|
+
return [];
|
|
668
|
+
const searchOpts = {
|
|
669
|
+
includeDomains: localDomains.length > 0 ? localDomains : void 0,
|
|
670
|
+
cache: options.cache,
|
|
671
|
+
cacheStats
|
|
672
|
+
};
|
|
673
|
+
const results = await Promise.all(queries.map(async (query) => {
|
|
674
|
+
const res = await tavilySearch(query, options.tavilyApiKey, searchOpts);
|
|
675
|
+
return { query, results: res };
|
|
676
|
+
}));
|
|
677
|
+
return results;
|
|
678
|
+
}
|
|
679
|
+
async function globalResearch(options, cacheStats) {
|
|
680
|
+
const queries = buildGlobalSearchQueries(options);
|
|
681
|
+
const results = await Promise.all(queries.map(async (query) => {
|
|
682
|
+
const res = await tavilySearch(query, options.tavilyApiKey, {
|
|
683
|
+
cache: options.cache,
|
|
684
|
+
cacheStats
|
|
685
|
+
});
|
|
686
|
+
return { query, results: res };
|
|
687
|
+
}));
|
|
688
|
+
return results;
|
|
689
|
+
}
|
|
690
|
+
function summarizeContexts(contexts) {
|
|
691
|
+
return contexts.map((ctx) => {
|
|
692
|
+
const snippets = ctx.results.map((r) => `- [${r.title}](${r.url}): ${r.content}`).join("\n");
|
|
693
|
+
return `### Search: "${ctx.query}"
|
|
694
|
+
${snippets || "No results found."}`;
|
|
695
|
+
}).join("\n\n");
|
|
696
|
+
}
|
|
697
|
+
function summarizeAllResearch(localContexts, globalContexts, trendsContext) {
|
|
698
|
+
const sections = [];
|
|
699
|
+
if (localContexts.length > 0) {
|
|
700
|
+
const localHasResults = localContexts.some((c) => c.results.length > 0);
|
|
701
|
+
if (localHasResults) {
|
|
702
|
+
sections.push(`## Local Sources
|
|
703
|
+
|
|
704
|
+
These results come from local and regional media in the target market.
|
|
705
|
+
|
|
706
|
+
${summarizeContexts(localContexts)}`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
if (globalContexts.length > 0) {
|
|
710
|
+
sections.push(`## Global Sources
|
|
711
|
+
|
|
712
|
+
These results come from international and global publications.
|
|
713
|
+
|
|
714
|
+
${summarizeContexts(globalContexts)}`);
|
|
715
|
+
}
|
|
716
|
+
if (trendsContext) {
|
|
717
|
+
const trendsSummary = summarizeTrends(trendsContext);
|
|
718
|
+
if (trendsSummary) {
|
|
719
|
+
sections.push(trendsSummary);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return sections.join("\n\n---\n\n");
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// ../../packages/arendi/dist/generate.js
|
|
726
|
+
import { generateObject } from "ai";
|
|
727
|
+
|
|
728
|
+
// ../../packages/arendi/dist/provider.js
|
|
729
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
730
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
731
|
+
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
|
732
|
+
var DEFAULT_MODELS = {
|
|
733
|
+
openai: "gpt-4o",
|
|
734
|
+
anthropic: "claude-sonnet-4-20250514",
|
|
735
|
+
google: "gemini-2.0-flash"
|
|
736
|
+
};
|
|
737
|
+
function createModel(provider, apiKey, model) {
|
|
738
|
+
const modelId = model ?? DEFAULT_MODELS[provider];
|
|
739
|
+
switch (provider) {
|
|
740
|
+
case "openai": {
|
|
741
|
+
const openai = createOpenAI({ apiKey });
|
|
742
|
+
return openai(modelId);
|
|
743
|
+
}
|
|
744
|
+
case "anthropic": {
|
|
745
|
+
const anthropic = createAnthropic({ apiKey });
|
|
746
|
+
return anthropic(modelId);
|
|
747
|
+
}
|
|
748
|
+
case "google": {
|
|
749
|
+
const google = createGoogleGenerativeAI({ apiKey });
|
|
750
|
+
return google(modelId);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// ../../packages/arendi/dist/generate.js
|
|
756
|
+
function buildSystemPrompt() {
|
|
757
|
+
return `You are a world-class business strategist and market analyst. Your job is to
|
|
758
|
+
analyze real-time research data and generate actionable, creative, and well-reasoned
|
|
759
|
+
business ideas and opportunities.
|
|
760
|
+
|
|
761
|
+
You will receive THREE types of research data:
|
|
762
|
+
|
|
763
|
+
1. **Local Sources** \u2013 Information from local and regional media, news outlets, and
|
|
764
|
+
publications in the target market. These provide on-the-ground insights about what
|
|
765
|
+
is actually happening in the specific country, city, or region.
|
|
766
|
+
2. **Global Sources** \u2013 Information from international publications and global trend
|
|
767
|
+
reports. These provide a broader view of worldwide trends, successful models, and
|
|
768
|
+
emerging opportunities.
|
|
769
|
+
3. **Google Trends Data** \u2013 Real-time search demand signals from Google Trends showing
|
|
770
|
+
what people are actively searching for right now, which queries are spiking
|
|
771
|
+
("Breakout" = massive growth from near-zero), and which topics are rising in
|
|
772
|
+
popularity. This data reveals actual consumer intent and demand.
|
|
773
|
+
|
|
774
|
+
**Local sources should be your PRIMARY foundation.** Use them to understand the real
|
|
775
|
+
conditions, needs, pain points, and cultural context of the target market. Then use
|
|
776
|
+
global sources to identify transferable models, worldwide trends that could be
|
|
777
|
+
localized, and broader market sizing data. Use Google Trends data to validate demand
|
|
778
|
+
signals \u2014 rising search queries indicate real consumer interest and unmet needs.
|
|
779
|
+
|
|
780
|
+
When analyzing the research data you MUST consider:
|
|
781
|
+
|
|
782
|
+
1. **Local context first** \u2013 What do local sources say about the market? What problems
|
|
783
|
+
are people actually talking about on the ground?
|
|
784
|
+
2. **Trending topics** \u2013 What is gaining popularity and momentum right now? Cross-
|
|
785
|
+
reference Google Trends rising queries with news articles for strongest signals.
|
|
786
|
+
3. **Pain points** \u2013 What are consumers or businesses struggling with? What frustrations
|
|
787
|
+
exist that are not being adequately addressed?
|
|
788
|
+
4. **Market gaps** \u2013 Where is there underserved demand? What products or services are
|
|
789
|
+
missing or poorly executed? Rising search queries with no clear market leader are
|
|
790
|
+
strong gap indicators.
|
|
791
|
+
5. **Trend transfers** \u2013 What successful business models or trends in other countries,
|
|
792
|
+
cities, or industries could be adapted to the target market? Use local sources to
|
|
793
|
+
validate whether a transfer makes sense culturally and economically.
|
|
794
|
+
|
|
795
|
+
For each idea you produce:
|
|
796
|
+
- Ground it in the research data provided. Prefer citing local sources when available.
|
|
797
|
+
- Be specific about the target audience (demographics, psychographics).
|
|
798
|
+
- Explain WHY this is a good opportunity RIGHT NOW (timing, market conditions).
|
|
799
|
+
Reference Google Trends data where it supports the timing argument.
|
|
800
|
+
- Provide realistic, concrete first steps someone could take this week.
|
|
801
|
+
- Assess difficulty and revenue potential honestly.
|
|
802
|
+
- Include source URLs from the research \u2014 prioritize local source URLs but include
|
|
803
|
+
global ones where they add supporting evidence.
|
|
804
|
+
|
|
805
|
+
Aim for diversity across opportunity types. Do not produce generic ideas like
|
|
806
|
+
"start a blog" or "become a freelancer" \u2014 be specific and creative.`;
|
|
807
|
+
}
|
|
808
|
+
function buildUserPrompt(options, researchSummary) {
|
|
809
|
+
const contextParts = [];
|
|
810
|
+
if (options.country)
|
|
811
|
+
contextParts.push(`Country: ${options.country}`);
|
|
812
|
+
if (options.city)
|
|
813
|
+
contextParts.push(`City: ${options.city}`);
|
|
814
|
+
if (options.categories?.length)
|
|
815
|
+
contextParts.push(`Categories: ${options.categories.join(", ")}`);
|
|
816
|
+
if (options.keywords?.length)
|
|
817
|
+
contextParts.push(`Keywords: ${options.keywords.join(", ")}`);
|
|
818
|
+
const context = contextParts.length > 0 ? `## Target Market
|
|
819
|
+
${contextParts.join("\n")}` : "## Target Market\nNo specific market constraints provided \u2014 generate globally relevant ideas.";
|
|
820
|
+
return `${context}
|
|
821
|
+
|
|
822
|
+
## Research Data
|
|
823
|
+
|
|
824
|
+
The following research was gathered from the web just now. It is divided into
|
|
825
|
+
**Local Sources** (from the target market's own media), **Global Sources**
|
|
826
|
+
(from international publications), and **Google Trends Data** (real-time search
|
|
827
|
+
demand signals). Prioritize local insights when forming ideas, use global sources
|
|
828
|
+
for supporting evidence, and use Google Trends data to validate demand and timing.
|
|
829
|
+
|
|
830
|
+
${researchSummary}
|
|
831
|
+
|
|
832
|
+
## Task
|
|
833
|
+
|
|
834
|
+
Generate exactly ${options.limit} business ideas or opportunities based on the research data
|
|
835
|
+
and target market above. Make sure each idea is distinct and covers a different angle
|
|
836
|
+
or opportunity type.
|
|
837
|
+
|
|
838
|
+
When citing references, prefer local source URLs where applicable. Each idea should
|
|
839
|
+
ideally reference at least one local source if the research data provides relevant ones.
|
|
840
|
+
Where Google Trends data shows rising demand for a topic, mention it in the reasoning.`;
|
|
841
|
+
}
|
|
842
|
+
async function generate(options, researchSummary) {
|
|
843
|
+
const model = createModel(options.provider, options.apiKey, options.model);
|
|
844
|
+
const { object } = await generateObject({
|
|
845
|
+
model,
|
|
846
|
+
schema: arendiResultSchema,
|
|
847
|
+
system: buildSystemPrompt(),
|
|
848
|
+
prompt: buildUserPrompt(options, researchSummary)
|
|
849
|
+
});
|
|
850
|
+
return object.ideas;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// ../../packages/arendi/dist/index.js
|
|
854
|
+
import { randomUUID } from "crypto";
|
|
855
|
+
|
|
856
|
+
// ../../packages/arendi/dist/cache.js
|
|
857
|
+
function createCacheStats() {
|
|
858
|
+
return { hits: 0, misses: 0 };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ../../packages/arendi/dist/index.js
|
|
862
|
+
async function generateIdeas(rawOptions, callbacks) {
|
|
863
|
+
const runId = randomUUID();
|
|
864
|
+
const runStart = performance.now();
|
|
865
|
+
const { cache, store, ...serializableOptions } = rawOptions;
|
|
866
|
+
const options = {
|
|
867
|
+
...optionsSchema.parse(serializableOptions),
|
|
868
|
+
cache,
|
|
869
|
+
store
|
|
870
|
+
};
|
|
871
|
+
const cacheStats = createCacheStats();
|
|
872
|
+
callbacks?.onSourceDiscoveryStart?.();
|
|
873
|
+
const sourceDiscoveryStart = performance.now();
|
|
874
|
+
const localDomains = await discoverLocalSources(options, cacheStats);
|
|
875
|
+
const sourceDiscoveryDuration = performance.now() - sourceDiscoveryStart;
|
|
876
|
+
callbacks?.onSourceDiscoveryComplete?.(localDomains);
|
|
877
|
+
callbacks?.onLocalResearchStart?.();
|
|
878
|
+
callbacks?.onGlobalResearchStart?.();
|
|
879
|
+
callbacks?.onTrendsResearchStart?.();
|
|
880
|
+
const parallelStart = performance.now();
|
|
881
|
+
const [localContexts, globalContexts, trendsContext] = await Promise.all([
|
|
882
|
+
localResearch(options, localDomains, cacheStats),
|
|
883
|
+
globalResearch(options, cacheStats),
|
|
884
|
+
trendsResearch(options)
|
|
885
|
+
]);
|
|
886
|
+
const parallelDuration = performance.now() - parallelStart;
|
|
887
|
+
const localResultCount = localContexts.reduce((sum, c) => sum + c.results.length, 0);
|
|
888
|
+
callbacks?.onLocalResearchComplete?.(localContexts.length, localResultCount);
|
|
889
|
+
const globalResultCount = globalContexts.reduce((sum, c) => sum + c.results.length, 0);
|
|
890
|
+
callbacks?.onGlobalResearchComplete?.(globalContexts.length, globalResultCount);
|
|
891
|
+
callbacks?.onTrendsResearchComplete?.(trendsContext);
|
|
892
|
+
const researchSummary = summarizeAllResearch(localContexts, globalContexts, trendsContext);
|
|
893
|
+
callbacks?.onGenerateStart?.();
|
|
894
|
+
const generateStart = performance.now();
|
|
895
|
+
const ideas = await generate(options, researchSummary);
|
|
896
|
+
const generateDuration = performance.now() - generateStart;
|
|
897
|
+
callbacks?.onGenerateComplete?.(ideas.length);
|
|
898
|
+
const totalDurationMs = Math.round(performance.now() - runStart);
|
|
899
|
+
if (store) {
|
|
900
|
+
const runData = {
|
|
901
|
+
id: runId,
|
|
902
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
903
|
+
options: {
|
|
904
|
+
country: options.country,
|
|
905
|
+
city: options.city,
|
|
906
|
+
categories: options.categories,
|
|
907
|
+
keywords: options.keywords,
|
|
908
|
+
limit: options.limit,
|
|
909
|
+
provider: options.provider,
|
|
910
|
+
model: options.model,
|
|
911
|
+
geo: options.geo
|
|
912
|
+
},
|
|
913
|
+
sourceDiscovery: {
|
|
914
|
+
domains: localDomains,
|
|
915
|
+
durationMs: Math.round(sourceDiscoveryDuration)
|
|
916
|
+
},
|
|
917
|
+
localResearch: {
|
|
918
|
+
contexts: localContexts,
|
|
919
|
+
durationMs: Math.round(parallelDuration)
|
|
920
|
+
},
|
|
921
|
+
globalResearch: {
|
|
922
|
+
contexts: globalContexts,
|
|
923
|
+
durationMs: Math.round(parallelDuration)
|
|
924
|
+
},
|
|
925
|
+
trendsResearch: {
|
|
926
|
+
context: trendsContext,
|
|
927
|
+
durationMs: Math.round(parallelDuration)
|
|
928
|
+
},
|
|
929
|
+
generation: {
|
|
930
|
+
ideas,
|
|
931
|
+
durationMs: Math.round(generateDuration)
|
|
932
|
+
},
|
|
933
|
+
researchSummary,
|
|
934
|
+
totalDurationMs,
|
|
935
|
+
cacheStats: { ...cacheStats }
|
|
936
|
+
};
|
|
937
|
+
store.save(runData).catch((err) => {
|
|
938
|
+
console.warn("Warning: Failed to save run data:", err instanceof Error ? err.message : String(err));
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
return { ideas, runId };
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// ../../packages/sqlite/dist/index.js
|
|
945
|
+
import Database from "better-sqlite3";
|
|
946
|
+
|
|
947
|
+
// ../../packages/sqlite/dist/schema.js
|
|
948
|
+
function initSchema(db) {
|
|
949
|
+
db.exec(`
|
|
950
|
+
-- Tavily search result cache
|
|
951
|
+
CREATE TABLE IF NOT EXISTS cache (
|
|
952
|
+
key TEXT PRIMARY KEY,
|
|
953
|
+
value TEXT NOT NULL,
|
|
954
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch('now') * 1000),
|
|
955
|
+
expires_at INTEGER
|
|
956
|
+
);
|
|
957
|
+
|
|
958
|
+
-- Run metadata
|
|
959
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
960
|
+
id TEXT PRIMARY KEY,
|
|
961
|
+
created_at INTEGER NOT NULL,
|
|
962
|
+
options TEXT NOT NULL,
|
|
963
|
+
research_summary TEXT,
|
|
964
|
+
total_duration_ms INTEGER,
|
|
965
|
+
cache_hits INTEGER DEFAULT 0,
|
|
966
|
+
cache_misses INTEGER DEFAULT 0
|
|
967
|
+
);
|
|
968
|
+
|
|
969
|
+
-- Discovered local source domains
|
|
970
|
+
CREATE TABLE IF NOT EXISTS run_sources (
|
|
971
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
972
|
+
run_id TEXT NOT NULL REFERENCES runs(id),
|
|
973
|
+
domain TEXT NOT NULL
|
|
974
|
+
);
|
|
975
|
+
|
|
976
|
+
-- Individual Tavily searches executed during a run
|
|
977
|
+
CREATE TABLE IF NOT EXISTS run_searches (
|
|
978
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
979
|
+
run_id TEXT NOT NULL REFERENCES runs(id),
|
|
980
|
+
phase TEXT NOT NULL,
|
|
981
|
+
query TEXT NOT NULL,
|
|
982
|
+
include_domains TEXT,
|
|
983
|
+
duration_ms INTEGER,
|
|
984
|
+
cache_hit INTEGER DEFAULT 0
|
|
985
|
+
);
|
|
986
|
+
|
|
987
|
+
-- Results returned by each Tavily search
|
|
988
|
+
CREATE TABLE IF NOT EXISTS run_search_results (
|
|
989
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
990
|
+
search_id INTEGER NOT NULL REFERENCES run_searches(id),
|
|
991
|
+
title TEXT NOT NULL,
|
|
992
|
+
url TEXT NOT NULL,
|
|
993
|
+
content TEXT NOT NULL,
|
|
994
|
+
score REAL
|
|
995
|
+
);
|
|
996
|
+
|
|
997
|
+
-- Google Trends data snapshots
|
|
998
|
+
CREATE TABLE IF NOT EXISTS run_trends (
|
|
999
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1000
|
+
run_id TEXT NOT NULL REFERENCES runs(id),
|
|
1001
|
+
type TEXT NOT NULL,
|
|
1002
|
+
data TEXT NOT NULL
|
|
1003
|
+
);
|
|
1004
|
+
|
|
1005
|
+
-- Generated business ideas
|
|
1006
|
+
CREATE TABLE IF NOT EXISTS run_ideas (
|
|
1007
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1008
|
+
run_id TEXT NOT NULL REFERENCES runs(id),
|
|
1009
|
+
title TEXT NOT NULL,
|
|
1010
|
+
description TEXT NOT NULL,
|
|
1011
|
+
category TEXT,
|
|
1012
|
+
target_audience TEXT,
|
|
1013
|
+
pain_point TEXT,
|
|
1014
|
+
opportunity_type TEXT,
|
|
1015
|
+
estimated_difficulty TEXT,
|
|
1016
|
+
potential_revenue TEXT,
|
|
1017
|
+
reasoning TEXT,
|
|
1018
|
+
actionable_steps TEXT,
|
|
1019
|
+
refs TEXT
|
|
1020
|
+
);
|
|
1021
|
+
|
|
1022
|
+
-- Indexes for common queries
|
|
1023
|
+
CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
|
|
1024
|
+
CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(created_at);
|
|
1025
|
+
CREATE INDEX IF NOT EXISTS idx_run_sources_run ON run_sources(run_id);
|
|
1026
|
+
CREATE INDEX IF NOT EXISTS idx_run_searches_run ON run_searches(run_id);
|
|
1027
|
+
CREATE INDEX IF NOT EXISTS idx_run_search_results_search ON run_search_results(search_id);
|
|
1028
|
+
CREATE INDEX IF NOT EXISTS idx_run_trends_run ON run_trends(run_id);
|
|
1029
|
+
CREATE INDEX IF NOT EXISTS idx_run_ideas_run ON run_ideas(run_id);
|
|
1030
|
+
`);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// ../../packages/sqlite/dist/cache.js
|
|
1034
|
+
var SqliteCacheProvider = class {
|
|
1035
|
+
db;
|
|
1036
|
+
stmtGet;
|
|
1037
|
+
stmtSet;
|
|
1038
|
+
stmtDelete;
|
|
1039
|
+
constructor(db) {
|
|
1040
|
+
this.db = db;
|
|
1041
|
+
this.stmtGet = this.db.prepare("SELECT value, expires_at FROM cache WHERE key = ?");
|
|
1042
|
+
this.stmtSet = this.db.prepare("INSERT OR REPLACE INTO cache (key, value, created_at, expires_at) VALUES (?, ?, ?, ?)");
|
|
1043
|
+
this.stmtDelete = this.db.prepare("DELETE FROM cache WHERE key = ?");
|
|
1044
|
+
}
|
|
1045
|
+
async get(key) {
|
|
1046
|
+
const row = this.stmtGet.get(key);
|
|
1047
|
+
if (!row)
|
|
1048
|
+
return null;
|
|
1049
|
+
if (row.expires_at !== null && Date.now() > row.expires_at) {
|
|
1050
|
+
this.stmtDelete.run(key);
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
return row.value;
|
|
1054
|
+
}
|
|
1055
|
+
async set(key, value, ttlMs) {
|
|
1056
|
+
const now = Date.now();
|
|
1057
|
+
const expiresAt = ttlMs != null ? now + ttlMs : null;
|
|
1058
|
+
this.stmtSet.run(key, value, now, expiresAt);
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Remove all expired entries. Call periodically if the database grows large.
|
|
1062
|
+
*/
|
|
1063
|
+
prune() {
|
|
1064
|
+
const result = this.db.prepare("DELETE FROM cache WHERE expires_at IS NOT NULL AND expires_at < ?").run(Date.now());
|
|
1065
|
+
return result.changes;
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
|
|
1069
|
+
// ../../packages/sqlite/dist/store.js
|
|
1070
|
+
var SqliteRunStore = class {
|
|
1071
|
+
db;
|
|
1072
|
+
constructor(db) {
|
|
1073
|
+
this.db = db;
|
|
1074
|
+
}
|
|
1075
|
+
// -------------------------------------------------------------------------
|
|
1076
|
+
// save – persists a full RunData in a single transaction
|
|
1077
|
+
// -------------------------------------------------------------------------
|
|
1078
|
+
async save(run) {
|
|
1079
|
+
const tx = this.db.transaction(() => {
|
|
1080
|
+
this.db.prepare(`INSERT INTO runs (id, created_at, options, research_summary, total_duration_ms, cache_hits, cache_misses)
|
|
1081
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(run.id, run.createdAt.getTime(), JSON.stringify(run.options), run.researchSummary, run.totalDurationMs, run.cacheStats.hits, run.cacheStats.misses);
|
|
1082
|
+
const insertSource = this.db.prepare("INSERT INTO run_sources (run_id, domain) VALUES (?, ?)");
|
|
1083
|
+
for (const domain of run.sourceDiscovery.domains) {
|
|
1084
|
+
insertSource.run(run.id, domain);
|
|
1085
|
+
}
|
|
1086
|
+
const insertSearch = this.db.prepare(`INSERT INTO run_searches (run_id, phase, query, include_domains, duration_ms)
|
|
1087
|
+
VALUES (?, ?, ?, ?, ?)`);
|
|
1088
|
+
const insertResult = this.db.prepare(`INSERT INTO run_search_results (search_id, title, url, content, score)
|
|
1089
|
+
VALUES (?, ?, ?, ?, ?)`);
|
|
1090
|
+
const writeContexts = (contexts, phase, durationMs) => {
|
|
1091
|
+
for (const ctx of contexts) {
|
|
1092
|
+
const info = insertSearch.run(run.id, phase, ctx.query, null, durationMs);
|
|
1093
|
+
const searchId = info.lastInsertRowid;
|
|
1094
|
+
for (const r of ctx.results) {
|
|
1095
|
+
insertResult.run(searchId, r.title, r.url, r.content, r.score);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
};
|
|
1099
|
+
writeContexts(run.localResearch.contexts, "local", run.localResearch.durationMs);
|
|
1100
|
+
writeContexts(run.globalResearch.contexts, "global", run.globalResearch.durationMs);
|
|
1101
|
+
const insertTrend = this.db.prepare("INSERT INTO run_trends (run_id, type, data) VALUES (?, ?, ?)");
|
|
1102
|
+
const trends = run.trendsResearch.context;
|
|
1103
|
+
if (trends.dailyTrends.length > 0) {
|
|
1104
|
+
insertTrend.run(run.id, "daily", JSON.stringify(trends.dailyTrends));
|
|
1105
|
+
}
|
|
1106
|
+
if (trends.relatedQueries.length > 0) {
|
|
1107
|
+
insertTrend.run(run.id, "related_queries", JSON.stringify(trends.relatedQueries));
|
|
1108
|
+
}
|
|
1109
|
+
if (trends.relatedTopics.length > 0) {
|
|
1110
|
+
insertTrend.run(run.id, "related_topics", JSON.stringify(trends.relatedTopics));
|
|
1111
|
+
}
|
|
1112
|
+
const insertIdea = this.db.prepare(`INSERT INTO run_ideas
|
|
1113
|
+
(run_id, title, description, category, target_audience, pain_point,
|
|
1114
|
+
opportunity_type, estimated_difficulty, potential_revenue, reasoning,
|
|
1115
|
+
actionable_steps, refs)
|
|
1116
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
1117
|
+
for (const idea of run.generation.ideas) {
|
|
1118
|
+
insertIdea.run(run.id, idea.title, idea.description, idea.category, idea.targetAudience, idea.painPoint, idea.opportunityType, idea.estimatedDifficulty, idea.potentialRevenue, idea.reasoning, JSON.stringify(idea.actionableSteps), JSON.stringify(idea.references));
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
tx();
|
|
1122
|
+
}
|
|
1123
|
+
// -------------------------------------------------------------------------
|
|
1124
|
+
// get – reconstructs a full RunData from the database
|
|
1125
|
+
// -------------------------------------------------------------------------
|
|
1126
|
+
async get(id) {
|
|
1127
|
+
const row = this.db.prepare("SELECT * FROM runs WHERE id = ?").get(id);
|
|
1128
|
+
if (!row)
|
|
1129
|
+
return null;
|
|
1130
|
+
return this.hydrateRun(row);
|
|
1131
|
+
}
|
|
1132
|
+
// -------------------------------------------------------------------------
|
|
1133
|
+
// list – returns runs in reverse-chronological order
|
|
1134
|
+
// -------------------------------------------------------------------------
|
|
1135
|
+
async list(options) {
|
|
1136
|
+
const limit = options?.limit ?? 20;
|
|
1137
|
+
const offset = options?.offset ?? 0;
|
|
1138
|
+
const rows = this.db.prepare("SELECT * FROM runs ORDER BY created_at DESC LIMIT ? OFFSET ?").all(limit, offset);
|
|
1139
|
+
return rows.map((row) => this.hydrateRun(row));
|
|
1140
|
+
}
|
|
1141
|
+
// -------------------------------------------------------------------------
|
|
1142
|
+
// Private helpers
|
|
1143
|
+
// -------------------------------------------------------------------------
|
|
1144
|
+
hydrateRun(row) {
|
|
1145
|
+
const id = row.id;
|
|
1146
|
+
const sources = this.db.prepare("SELECT domain FROM run_sources WHERE run_id = ?").all(id);
|
|
1147
|
+
const searches = this.db.prepare("SELECT * FROM run_searches WHERE run_id = ? ORDER BY id").all(id);
|
|
1148
|
+
const localContexts = [];
|
|
1149
|
+
const globalContexts = [];
|
|
1150
|
+
let localDurationMs = 0;
|
|
1151
|
+
let globalDurationMs = 0;
|
|
1152
|
+
for (const s of searches) {
|
|
1153
|
+
const results = this.db.prepare("SELECT title, url, content, score FROM run_search_results WHERE search_id = ? ORDER BY id").all(s.id);
|
|
1154
|
+
const ctx = { query: s.query, results };
|
|
1155
|
+
if (s.phase === "local") {
|
|
1156
|
+
localContexts.push(ctx);
|
|
1157
|
+
localDurationMs = s.duration_ms ?? 0;
|
|
1158
|
+
} else if (s.phase === "global") {
|
|
1159
|
+
globalContexts.push(ctx);
|
|
1160
|
+
globalDurationMs = s.duration_ms ?? 0;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
const trendRows = this.db.prepare("SELECT type, data FROM run_trends WHERE run_id = ?").all(id);
|
|
1164
|
+
const trendsContext = {
|
|
1165
|
+
dailyTrends: [],
|
|
1166
|
+
relatedQueries: [],
|
|
1167
|
+
relatedTopics: []
|
|
1168
|
+
};
|
|
1169
|
+
for (const t of trendRows) {
|
|
1170
|
+
if (t.type === "daily") {
|
|
1171
|
+
trendsContext.dailyTrends = JSON.parse(t.data);
|
|
1172
|
+
} else if (t.type === "related_queries") {
|
|
1173
|
+
trendsContext.relatedQueries = JSON.parse(t.data);
|
|
1174
|
+
} else if (t.type === "related_topics") {
|
|
1175
|
+
trendsContext.relatedTopics = JSON.parse(t.data);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
const ideaRows = this.db.prepare("SELECT * FROM run_ideas WHERE run_id = ? ORDER BY id").all(id);
|
|
1179
|
+
const ideas = ideaRows.map((i) => ({
|
|
1180
|
+
title: i.title,
|
|
1181
|
+
description: i.description,
|
|
1182
|
+
category: i.category ?? "",
|
|
1183
|
+
targetAudience: i.target_audience ?? "",
|
|
1184
|
+
painPoint: i.pain_point ?? "",
|
|
1185
|
+
opportunityType: i.opportunity_type ?? "trending",
|
|
1186
|
+
estimatedDifficulty: i.estimated_difficulty ?? "medium",
|
|
1187
|
+
potentialRevenue: i.potential_revenue ?? "medium",
|
|
1188
|
+
reasoning: i.reasoning ?? "",
|
|
1189
|
+
actionableSteps: i.actionable_steps ? JSON.parse(i.actionable_steps) : [],
|
|
1190
|
+
references: i.refs ? JSON.parse(i.refs) : []
|
|
1191
|
+
}));
|
|
1192
|
+
return {
|
|
1193
|
+
id: row.id,
|
|
1194
|
+
createdAt: new Date(row.created_at),
|
|
1195
|
+
options: JSON.parse(row.options),
|
|
1196
|
+
sourceDiscovery: {
|
|
1197
|
+
domains: sources.map((s) => s.domain),
|
|
1198
|
+
durationMs: 0
|
|
1199
|
+
// Not stored per-phase for discovery — captured in total
|
|
1200
|
+
},
|
|
1201
|
+
localResearch: {
|
|
1202
|
+
contexts: localContexts,
|
|
1203
|
+
durationMs: localDurationMs
|
|
1204
|
+
},
|
|
1205
|
+
globalResearch: {
|
|
1206
|
+
contexts: globalContexts,
|
|
1207
|
+
durationMs: globalDurationMs
|
|
1208
|
+
},
|
|
1209
|
+
trendsResearch: {
|
|
1210
|
+
context: trendsContext,
|
|
1211
|
+
durationMs: 0
|
|
1212
|
+
},
|
|
1213
|
+
generation: {
|
|
1214
|
+
ideas,
|
|
1215
|
+
durationMs: 0
|
|
1216
|
+
},
|
|
1217
|
+
researchSummary: row.research_summary ?? "",
|
|
1218
|
+
totalDurationMs: row.total_duration_ms ?? 0,
|
|
1219
|
+
cacheStats: {
|
|
1220
|
+
hits: row.cache_hits ?? 0,
|
|
1221
|
+
misses: row.cache_misses ?? 0
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
};
|
|
1226
|
+
|
|
1227
|
+
// ../../packages/sqlite/dist/index.js
|
|
1228
|
+
function createSqliteProviders(dbPath) {
|
|
1229
|
+
const db = new Database(dbPath);
|
|
1230
|
+
db.pragma("journal_mode = WAL");
|
|
1231
|
+
db.pragma("foreign_keys = ON");
|
|
1232
|
+
initSchema(db);
|
|
1233
|
+
const cache = new SqliteCacheProvider(db);
|
|
1234
|
+
const store = new SqliteRunStore(db);
|
|
1235
|
+
return {
|
|
1236
|
+
cache,
|
|
1237
|
+
store,
|
|
1238
|
+
close: () => db.close()
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// src/cli.ts
|
|
1243
|
+
var program = new Command();
|
|
1244
|
+
program.name("arendi").description(
|
|
1245
|
+
"AI-powered business idea generator. Researches trending topics, pain points, and market gaps, then generates actionable business ideas."
|
|
1246
|
+
).version("0.1.0").option("--country <country>", "Target country (e.g., US, Indonesia)").option("--city <city>", "Target city (e.g., Jakarta, New York)").option(
|
|
1247
|
+
"--categories <categories>",
|
|
1248
|
+
`Comma-separated categories: ${CATEGORIES.join(", ")}`
|
|
1249
|
+
).option("--limit <number>", "Number of ideas to generate", "10").option("--keywords <keywords>", "Comma-separated keywords to focus on").option(
|
|
1250
|
+
"--format <format>",
|
|
1251
|
+
`Output format: ${OUTPUT_FORMATS.join(", ")}`,
|
|
1252
|
+
"markdown"
|
|
1253
|
+
).option("--output <file>", "Write output to file instead of stdout").option(
|
|
1254
|
+
"--provider <provider>",
|
|
1255
|
+
`AI provider: ${PROVIDERS.join(", ")}`,
|
|
1256
|
+
"openai"
|
|
1257
|
+
).option(
|
|
1258
|
+
"--model <model>",
|
|
1259
|
+
"Model name override (e.g., gpt-4o, claude-sonnet-4-20250514)"
|
|
1260
|
+
).option(
|
|
1261
|
+
"--geo <code>",
|
|
1262
|
+
"ISO 3166-1 alpha-2 country code for Google Trends (e.g., US, ID). Auto-detected from --country if omitted."
|
|
1263
|
+
).option(
|
|
1264
|
+
"--db <path>",
|
|
1265
|
+
"Path to the SQLite database for caching and run history",
|
|
1266
|
+
resolve(homedir(), ".arendi", "arendi.db")
|
|
1267
|
+
).option("--no-cache", "Disable Tavily search result caching").option("--no-store", "Disable run history persistence");
|
|
1268
|
+
function getApiKey(provider) {
|
|
1269
|
+
const envMap = {
|
|
1270
|
+
openai: "OPENAI_API_KEY",
|
|
1271
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
1272
|
+
google: "GOOGLE_GENERATIVE_AI_API_KEY"
|
|
1273
|
+
};
|
|
1274
|
+
const envVar = envMap[provider];
|
|
1275
|
+
const key = process.env[envVar];
|
|
1276
|
+
if (!key) {
|
|
1277
|
+
console.error(
|
|
1278
|
+
`Error: Missing environment variable ${envVar} for provider "${provider}".`
|
|
1279
|
+
);
|
|
1280
|
+
console.error(`Set it via: export ${envVar}=your-key-here`);
|
|
1281
|
+
process.exit(1);
|
|
1282
|
+
}
|
|
1283
|
+
return key;
|
|
1284
|
+
}
|
|
1285
|
+
function getTavilyApiKey() {
|
|
1286
|
+
const key = process.env["TAVILY_API_KEY"];
|
|
1287
|
+
if (!key) {
|
|
1288
|
+
console.error("Error: Missing environment variable TAVILY_API_KEY.");
|
|
1289
|
+
console.error("Get a free key at https://tavily.com and set it via:");
|
|
1290
|
+
console.error(" export TAVILY_API_KEY=your-key-here");
|
|
1291
|
+
process.exit(1);
|
|
1292
|
+
}
|
|
1293
|
+
return key;
|
|
1294
|
+
}
|
|
1295
|
+
program.action(async (opts) => {
|
|
1296
|
+
const provider = opts["provider"] ?? "openai";
|
|
1297
|
+
const format = opts["format"] ?? "markdown";
|
|
1298
|
+
const limit = parseInt(String(opts["limit"] ?? "5"), 10);
|
|
1299
|
+
if (!PROVIDERS.includes(provider)) {
|
|
1300
|
+
console.error(
|
|
1301
|
+
`Error: Invalid provider "${provider}". Choose from: ${PROVIDERS.join(", ")}`
|
|
1302
|
+
);
|
|
1303
|
+
process.exit(1);
|
|
1304
|
+
}
|
|
1305
|
+
if (!OUTPUT_FORMATS.includes(format)) {
|
|
1306
|
+
console.error(
|
|
1307
|
+
`Error: Invalid format "${format}". Choose from: ${OUTPUT_FORMATS.join(", ")}`
|
|
1308
|
+
);
|
|
1309
|
+
process.exit(1);
|
|
1310
|
+
}
|
|
1311
|
+
if (isNaN(limit) || limit < 1 || limit > 50) {
|
|
1312
|
+
console.error("Error: --limit must be a number between 1 and 50.");
|
|
1313
|
+
process.exit(1);
|
|
1314
|
+
}
|
|
1315
|
+
const apiKey = getApiKey(provider);
|
|
1316
|
+
const tavilyApiKey = getTavilyApiKey();
|
|
1317
|
+
const categories = opts["categories"]?.split(",").map((c) => c.trim()).filter(Boolean);
|
|
1318
|
+
const keywords = opts["keywords"]?.split(",").map((k) => k.trim()).filter(Boolean);
|
|
1319
|
+
const dbPath = opts["db"];
|
|
1320
|
+
const useCache = opts["cache"] !== false;
|
|
1321
|
+
const useStore = opts["store"] !== false;
|
|
1322
|
+
let cache;
|
|
1323
|
+
let store;
|
|
1324
|
+
let closeDb;
|
|
1325
|
+
if (useCache || useStore) {
|
|
1326
|
+
try {
|
|
1327
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
1328
|
+
const providers = createSqliteProviders(dbPath);
|
|
1329
|
+
if (useCache) cache = providers.cache;
|
|
1330
|
+
if (useStore) store = providers.store;
|
|
1331
|
+
closeDb = providers.close;
|
|
1332
|
+
console.error(`Database: ${dbPath}`);
|
|
1333
|
+
} catch (err) {
|
|
1334
|
+
console.warn(
|
|
1335
|
+
"Warning: Could not open SQLite database, proceeding without cache/store:",
|
|
1336
|
+
err instanceof Error ? err.message : String(err)
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
try {
|
|
1341
|
+
const hasLocation = opts["country"] || opts["city"];
|
|
1342
|
+
const { ideas, runId } = await generateIdeas(
|
|
1343
|
+
{
|
|
1344
|
+
country: opts["country"],
|
|
1345
|
+
city: opts["city"],
|
|
1346
|
+
categories,
|
|
1347
|
+
limit,
|
|
1348
|
+
keywords,
|
|
1349
|
+
provider,
|
|
1350
|
+
model: opts["model"],
|
|
1351
|
+
apiKey,
|
|
1352
|
+
tavilyApiKey,
|
|
1353
|
+
geo: opts["geo"],
|
|
1354
|
+
cache,
|
|
1355
|
+
store
|
|
1356
|
+
},
|
|
1357
|
+
{
|
|
1358
|
+
onSourceDiscoveryStart() {
|
|
1359
|
+
if (hasLocation) {
|
|
1360
|
+
console.error("Discovering local sources for the target market...");
|
|
1361
|
+
}
|
|
1362
|
+
},
|
|
1363
|
+
onSourceDiscoveryComplete(sources) {
|
|
1364
|
+
if (sources.length > 0) {
|
|
1365
|
+
console.error(
|
|
1366
|
+
`Found ${sources.length} local sources: ${sources.join(", ")}`
|
|
1367
|
+
);
|
|
1368
|
+
} else if (hasLocation) {
|
|
1369
|
+
console.error(
|
|
1370
|
+
"No local sources discovered \u2014 will rely on global research."
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
1373
|
+
},
|
|
1374
|
+
onLocalResearchStart() {
|
|
1375
|
+
if (hasLocation) {
|
|
1376
|
+
console.error("Researching local trends and opportunities...");
|
|
1377
|
+
}
|
|
1378
|
+
},
|
|
1379
|
+
onLocalResearchComplete(queryCount, resultCount) {
|
|
1380
|
+
if (hasLocation) {
|
|
1381
|
+
console.error(
|
|
1382
|
+
`Local research complete: ${queryCount} queries, ${resultCount} results.`
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
},
|
|
1386
|
+
onGlobalResearchStart() {
|
|
1387
|
+
console.error(
|
|
1388
|
+
"Researching global trends, pain points, and market gaps..."
|
|
1389
|
+
);
|
|
1390
|
+
},
|
|
1391
|
+
onGlobalResearchComplete(queryCount, resultCount) {
|
|
1392
|
+
console.error(
|
|
1393
|
+
`Global research complete: ${queryCount} queries, ${resultCount} results.`
|
|
1394
|
+
);
|
|
1395
|
+
},
|
|
1396
|
+
onTrendsResearchStart() {
|
|
1397
|
+
console.error(
|
|
1398
|
+
"Fetching Google Trends data (daily trends, rising queries)..."
|
|
1399
|
+
);
|
|
1400
|
+
},
|
|
1401
|
+
onTrendsResearchComplete(trendsContext) {
|
|
1402
|
+
const parts = [];
|
|
1403
|
+
if (trendsContext.dailyTrends.length > 0) {
|
|
1404
|
+
parts.push(
|
|
1405
|
+
`${trendsContext.dailyTrends.length} daily trending searches`
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
if (trendsContext.relatedQueries.length > 0) {
|
|
1409
|
+
const risingCount = trendsContext.relatedQueries.reduce(
|
|
1410
|
+
(sum, r) => sum + r.rising.length,
|
|
1411
|
+
0
|
|
1412
|
+
);
|
|
1413
|
+
parts.push(`${risingCount} rising queries`);
|
|
1414
|
+
}
|
|
1415
|
+
if (trendsContext.relatedTopics.length > 0) {
|
|
1416
|
+
const risingCount = trendsContext.relatedTopics.reduce(
|
|
1417
|
+
(sum, r) => sum + r.rising.length,
|
|
1418
|
+
0
|
|
1419
|
+
);
|
|
1420
|
+
parts.push(`${risingCount} rising topics`);
|
|
1421
|
+
}
|
|
1422
|
+
if (parts.length > 0) {
|
|
1423
|
+
console.error(`Google Trends complete: ${parts.join(", ")}.`);
|
|
1424
|
+
} else {
|
|
1425
|
+
console.error(
|
|
1426
|
+
"Google Trends: no data available (geo may not be supported)."
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1429
|
+
},
|
|
1430
|
+
onGenerateStart() {
|
|
1431
|
+
console.error(`Generating ${limit} business ideas...`);
|
|
1432
|
+
},
|
|
1433
|
+
onGenerateComplete(ideaCount) {
|
|
1434
|
+
console.error(`Generated ${ideaCount} ideas.`);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
);
|
|
1438
|
+
if (store) {
|
|
1439
|
+
console.error(`Run saved: ${runId}`);
|
|
1440
|
+
}
|
|
1441
|
+
const output = formatIdeas(ideas, format);
|
|
1442
|
+
if (opts["output"]) {
|
|
1443
|
+
const filePath = resolve(opts["output"]);
|
|
1444
|
+
writeFileSync(filePath, output, "utf-8");
|
|
1445
|
+
console.error(`Output written to ${filePath}`);
|
|
1446
|
+
} else {
|
|
1447
|
+
console.log(output);
|
|
1448
|
+
}
|
|
1449
|
+
} catch (error) {
|
|
1450
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1451
|
+
console.error(`Error: ${message}`);
|
|
1452
|
+
process.exit(1);
|
|
1453
|
+
} finally {
|
|
1454
|
+
closeDb?.();
|
|
1455
|
+
}
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
// src/index.ts
|
|
1459
|
+
program.parse();
|