reddit-harvest 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/LICENSE +23 -0
- package/README.md +272 -0
- package/env.example +15 -0
- package/package.json +68 -0
- package/src/cli.js +277 -0
- package/src/dedupe.js +84 -0
- package/src/env.js +47 -0
- package/src/explorer.js +481 -0
- package/src/formatters.js +17 -0
- package/src/index.js +45 -0
- package/src/logger.js +35 -0
- package/src/openaiAnalyze.js +485 -0
- package/src/redditClient.js +66 -0
- package/src/redditHarvest.js +353 -0
- package/src/schemas.js +83 -0
- package/src/utils.js +49 -0
package/src/dedupe.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const INDEX_FILENAME = ".harvest-index.json";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Load the dedupe index from disk.
|
|
8
|
+
* Returns a Set of post IDs.
|
|
9
|
+
*/
|
|
10
|
+
export async function loadDedupeIndex(outDir) {
|
|
11
|
+
const indexPath = path.join(outDir, INDEX_FILENAME);
|
|
12
|
+
try {
|
|
13
|
+
const content = await fs.readFile(indexPath, "utf8");
|
|
14
|
+
const data = JSON.parse(content);
|
|
15
|
+
return new Set(Object.keys(data.posts || {}));
|
|
16
|
+
} catch {
|
|
17
|
+
return new Set();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Save the dedupe index to disk.
|
|
23
|
+
*/
|
|
24
|
+
export async function saveDedupeIndex(outDir, postIds, metadata = {}) {
|
|
25
|
+
const indexPath = path.join(outDir, INDEX_FILENAME);
|
|
26
|
+
const posts = {};
|
|
27
|
+
for (const id of postIds) {
|
|
28
|
+
posts[id] = { harvestedAt: new Date().toISOString(), ...metadata };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Merge with existing
|
|
32
|
+
let existing = {};
|
|
33
|
+
try {
|
|
34
|
+
const content = await fs.readFile(indexPath, "utf8");
|
|
35
|
+
existing = JSON.parse(content).posts || {};
|
|
36
|
+
} catch {
|
|
37
|
+
// No existing index
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const merged = { ...existing, ...posts };
|
|
41
|
+
await fs.writeFile(indexPath, JSON.stringify({ posts: merged }, null, 2), "utf8");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reset (clear) the dedupe index.
|
|
46
|
+
*/
|
|
47
|
+
export async function resetDedupeIndex(outDir) {
|
|
48
|
+
const indexPath = path.join(outDir, INDEX_FILENAME);
|
|
49
|
+
try {
|
|
50
|
+
await fs.unlink(indexPath);
|
|
51
|
+
} catch {
|
|
52
|
+
// File didn't exist, that's fine
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Create a trackable dedupe index that can be used during harvesting.
|
|
58
|
+
* Returns an object with Set-like interface plus save method.
|
|
59
|
+
*/
|
|
60
|
+
export async function createDedupeTracker(outDir) {
|
|
61
|
+
const existingIds = await loadDedupeIndex(outDir);
|
|
62
|
+
const newIds = new Set();
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
has(id) {
|
|
66
|
+
return existingIds.has(id);
|
|
67
|
+
},
|
|
68
|
+
add(id) {
|
|
69
|
+
newIds.add(id);
|
|
70
|
+
},
|
|
71
|
+
async save() {
|
|
72
|
+
if (newIds.size > 0) {
|
|
73
|
+
await saveDedupeIndex(outDir, newIds);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
get newCount() {
|
|
77
|
+
return newIds.size;
|
|
78
|
+
},
|
|
79
|
+
get existingCount() {
|
|
80
|
+
return existingIds.size;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
package/src/env.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import dotenv from "dotenv";
|
|
4
|
+
|
|
5
|
+
function getArgValue(argv, names) {
|
|
6
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
7
|
+
const a = argv[i];
|
|
8
|
+
for (const name of names) {
|
|
9
|
+
if (a === name) return argv[i + 1];
|
|
10
|
+
if (a.startsWith(`${name}=`)) return a.slice(name.length + 1);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Loads environment variables from a file into process.env.
|
|
18
|
+
*
|
|
19
|
+
* Priority:
|
|
20
|
+
* - explicit `envFile` argument
|
|
21
|
+
* - CLI arg `--env <path>` / `--envFile <path>` / `--env-file <path>`
|
|
22
|
+
* - process.env.ENV_FILE
|
|
23
|
+
* - default `.env` in CWD
|
|
24
|
+
*/
|
|
25
|
+
export function loadEnv({ envFile, argv = process.argv.slice(2) } = {}) {
|
|
26
|
+
const requested =
|
|
27
|
+
envFile ||
|
|
28
|
+
getArgValue(argv, ["--env", "--envFile", "--env-file"]) ||
|
|
29
|
+
process.env.ENV_FILE ||
|
|
30
|
+
".env";
|
|
31
|
+
|
|
32
|
+
const envPath = path.isAbsolute(requested) ? requested : path.resolve(process.cwd(), requested);
|
|
33
|
+
const mustExist = Boolean(envFile || getArgValue(argv, ["--env", "--envFile", "--env-file"]) || process.env.ENV_FILE);
|
|
34
|
+
|
|
35
|
+
if (!fs.existsSync(envPath)) {
|
|
36
|
+
if (mustExist) {
|
|
37
|
+
throw new Error(`Env file not found: ${envPath}`);
|
|
38
|
+
}
|
|
39
|
+
return { envPath, loaded: false };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const result = dotenv.config({ path: envPath });
|
|
43
|
+
if (result.error) throw result.error;
|
|
44
|
+
return { envPath, loaded: true, parsed: result.parsed };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
package/src/explorer.js
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { select } from "@inquirer/prompts";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Find analysis files in a directory.
|
|
8
|
+
* Returns array of { timestamp, analysisPath, opportunitiesPath, tagsPath }
|
|
9
|
+
*/
|
|
10
|
+
export async function findAnalysisFiles(dir) {
|
|
11
|
+
const files = await fs.readdir(dir);
|
|
12
|
+
|
|
13
|
+
// Find all opportunities.json files and pair with analysis.md
|
|
14
|
+
const analyses = [];
|
|
15
|
+
const opportunityFiles = files.filter(f => f.endsWith("-opportunities.json"));
|
|
16
|
+
|
|
17
|
+
for (const oppFile of opportunityFiles) {
|
|
18
|
+
const timestamp = oppFile.replace("-opportunities.json", "");
|
|
19
|
+
const analysisFile = `${timestamp}-analysis.md`;
|
|
20
|
+
|
|
21
|
+
if (files.includes(analysisFile)) {
|
|
22
|
+
analyses.push({
|
|
23
|
+
timestamp,
|
|
24
|
+
analysisPath: path.join(dir, analysisFile),
|
|
25
|
+
opportunitiesPath: path.join(dir, oppFile)
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Sort by timestamp descending (newest first)
|
|
31
|
+
analyses.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
32
|
+
|
|
33
|
+
return analyses;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Load analysis data from files.
|
|
38
|
+
*/
|
|
39
|
+
export async function loadAnalysis(analysisInfo) {
|
|
40
|
+
const [analysisContent, opportunitiesContent] = await Promise.all([
|
|
41
|
+
fs.readFile(analysisInfo.analysisPath, "utf8"),
|
|
42
|
+
fs.readFile(analysisInfo.opportunitiesPath, "utf8")
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const opportunities = JSON.parse(opportunitiesContent);
|
|
46
|
+
|
|
47
|
+
// Extract tags from the analysis markdown (they're in a JSON code block)
|
|
48
|
+
let tags = null;
|
|
49
|
+
const tagsMatch = analysisContent.match(/# Extracted Tags\s*\n\s*```json\n([\s\S]*?)\n```/);
|
|
50
|
+
if (tagsMatch) {
|
|
51
|
+
try {
|
|
52
|
+
tags = JSON.parse(tagsMatch[1]);
|
|
53
|
+
} catch {
|
|
54
|
+
// Tags parsing failed, continue without them
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
...analysisInfo,
|
|
60
|
+
opportunities,
|
|
61
|
+
tags,
|
|
62
|
+
rawAnalysis: analysisContent
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Format an opportunity for display.
|
|
68
|
+
*/
|
|
69
|
+
function formatOpportunity(opp) {
|
|
70
|
+
const lines = [
|
|
71
|
+
"",
|
|
72
|
+
chalk.bold.cyan(`āāā ${opp.title} āāā`),
|
|
73
|
+
"",
|
|
74
|
+
chalk.dim(`ID: ${opp.id}`),
|
|
75
|
+
"",
|
|
76
|
+
chalk.yellow("Target User:"),
|
|
77
|
+
` ${opp.targetUser}`,
|
|
78
|
+
"",
|
|
79
|
+
chalk.yellow("Problem:"),
|
|
80
|
+
` ${opp.problem}`,
|
|
81
|
+
"",
|
|
82
|
+
chalk.yellow("Current Workaround:"),
|
|
83
|
+
` ${opp.currentWorkaround}`,
|
|
84
|
+
"",
|
|
85
|
+
chalk.yellow("Proposed Solution:"),
|
|
86
|
+
` ${opp.proposedSolution}`,
|
|
87
|
+
"",
|
|
88
|
+
chalk.yellow("Confidence:"),
|
|
89
|
+
` ${formatConfidence(opp.confidence)} - ${opp.confidenceReason}`,
|
|
90
|
+
""
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
if (opp.supportingQuotes?.length > 0) {
|
|
94
|
+
lines.push(chalk.yellow("Supporting Quotes:"));
|
|
95
|
+
for (const q of opp.supportingQuotes) {
|
|
96
|
+
lines.push(chalk.dim(` > "${q.text}"`));
|
|
97
|
+
if (q.permalink) {
|
|
98
|
+
lines.push(chalk.blue(` ${q.permalink}`));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
lines.push("");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (opp.risks?.length > 0) {
|
|
105
|
+
lines.push(chalk.yellow("Risks:"));
|
|
106
|
+
for (const r of opp.risks) {
|
|
107
|
+
lines.push(chalk.red(` ⢠${r}`));
|
|
108
|
+
}
|
|
109
|
+
lines.push("");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (opp.mvpExperiment) {
|
|
113
|
+
lines.push(chalk.yellow("MVP Experiment:"));
|
|
114
|
+
lines.push(chalk.green(` ${opp.mvpExperiment}`));
|
|
115
|
+
lines.push("");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return lines.join("\n");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Format a pain point for display.
|
|
123
|
+
*/
|
|
124
|
+
function formatPainPoint(pp) {
|
|
125
|
+
const lines = [
|
|
126
|
+
"",
|
|
127
|
+
chalk.bold.red(`āāā ${pp.category} āāā`),
|
|
128
|
+
"",
|
|
129
|
+
chalk.yellow("Description:"),
|
|
130
|
+
` ${pp.description}`,
|
|
131
|
+
"",
|
|
132
|
+
chalk.yellow("Frequency:"),
|
|
133
|
+
` ${formatFrequency(pp.frequency)}`,
|
|
134
|
+
""
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
if (pp.quote) {
|
|
138
|
+
lines.push(chalk.yellow("Quote:"));
|
|
139
|
+
lines.push(chalk.dim(` > "${pp.quote}"`));
|
|
140
|
+
if (pp.permalink) {
|
|
141
|
+
lines.push(chalk.blue(` ${pp.permalink}`));
|
|
142
|
+
}
|
|
143
|
+
lines.push("");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return lines.join("\n");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Format a persona for display.
|
|
151
|
+
*/
|
|
152
|
+
function formatPersona(persona) {
|
|
153
|
+
const lines = [
|
|
154
|
+
"",
|
|
155
|
+
chalk.bold.magenta(`āāā ${persona.role} āāā`),
|
|
156
|
+
"",
|
|
157
|
+
chalk.yellow("Description:"),
|
|
158
|
+
` ${persona.description}`,
|
|
159
|
+
""
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
if (persona.painPoints?.length > 0) {
|
|
163
|
+
lines.push(chalk.yellow("Associated Pain Points:"));
|
|
164
|
+
for (const pp of persona.painPoints) {
|
|
165
|
+
lines.push(` ⢠${pp}`);
|
|
166
|
+
}
|
|
167
|
+
lines.push("");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return lines.join("\n");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Format a competitor for display.
|
|
175
|
+
*/
|
|
176
|
+
function formatCompetitor(comp) {
|
|
177
|
+
const sentimentColor = {
|
|
178
|
+
positive: chalk.green,
|
|
179
|
+
neutral: chalk.yellow,
|
|
180
|
+
negative: chalk.red
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const colorFn = sentimentColor[comp.sentiment] || chalk.white;
|
|
184
|
+
|
|
185
|
+
const lines = [
|
|
186
|
+
"",
|
|
187
|
+
chalk.bold.blue(`āāā ${comp.name} āāā`),
|
|
188
|
+
"",
|
|
189
|
+
chalk.yellow("Sentiment:"),
|
|
190
|
+
` ${colorFn(comp.sentiment)}`,
|
|
191
|
+
"",
|
|
192
|
+
chalk.yellow("Mentions:"),
|
|
193
|
+
` ${comp.mentions}`,
|
|
194
|
+
""
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
return lines.join("\n");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Format confidence level with color.
|
|
202
|
+
*/
|
|
203
|
+
function formatConfidence(level) {
|
|
204
|
+
const colors = {
|
|
205
|
+
high: chalk.green,
|
|
206
|
+
medium: chalk.yellow,
|
|
207
|
+
low: chalk.red
|
|
208
|
+
};
|
|
209
|
+
return (colors[level] || chalk.white)(level.toUpperCase());
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Format frequency with color.
|
|
214
|
+
*/
|
|
215
|
+
function formatFrequency(freq) {
|
|
216
|
+
const colors = {
|
|
217
|
+
common: chalk.red,
|
|
218
|
+
occasional: chalk.yellow,
|
|
219
|
+
rare: chalk.green
|
|
220
|
+
};
|
|
221
|
+
return (colors[freq] || chalk.white)(freq);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Show detail view and wait for user to go back.
|
|
226
|
+
*/
|
|
227
|
+
async function showDetail(content) {
|
|
228
|
+
console.clear();
|
|
229
|
+
console.log(content);
|
|
230
|
+
|
|
231
|
+
await select({
|
|
232
|
+
message: "",
|
|
233
|
+
choices: [{ name: "ā Back", value: "back" }]
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Browse opportunities menu.
|
|
239
|
+
*/
|
|
240
|
+
async function browseOpportunities(opportunities) {
|
|
241
|
+
while (true) {
|
|
242
|
+
console.clear();
|
|
243
|
+
|
|
244
|
+
if (opportunities.length === 0) {
|
|
245
|
+
console.log(chalk.dim("\nNo opportunities found.\n"));
|
|
246
|
+
await select({
|
|
247
|
+
message: "",
|
|
248
|
+
choices: [{ name: "ā Back", value: "back" }]
|
|
249
|
+
});
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const choices = opportunities.map(opp => ({
|
|
254
|
+
name: `${chalk.cyan(`[${opp.id}]`)} ${opp.title} ${chalk.dim(`(${opp.confidence})`)}`,
|
|
255
|
+
value: opp.id
|
|
256
|
+
}));
|
|
257
|
+
|
|
258
|
+
choices.push({ name: chalk.dim("ā Back to main menu"), value: "back" });
|
|
259
|
+
|
|
260
|
+
const selected = await select({
|
|
261
|
+
message: chalk.bold(`\nš Opportunities (${opportunities.length})\n`),
|
|
262
|
+
choices,
|
|
263
|
+
pageSize: 15
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
if (selected === "back") return;
|
|
267
|
+
|
|
268
|
+
const opp = opportunities.find(o => o.id === selected);
|
|
269
|
+
if (opp) {
|
|
270
|
+
await showDetail(formatOpportunity(opp));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Browse pain points menu.
|
|
277
|
+
*/
|
|
278
|
+
async function browsePainPoints(painPoints) {
|
|
279
|
+
while (true) {
|
|
280
|
+
console.clear();
|
|
281
|
+
|
|
282
|
+
if (!painPoints || painPoints.length === 0) {
|
|
283
|
+
console.log(chalk.dim("\nNo pain points found.\n"));
|
|
284
|
+
await select({
|
|
285
|
+
message: "",
|
|
286
|
+
choices: [{ name: "ā Back", value: "back" }]
|
|
287
|
+
});
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const choices = painPoints.map((pp, i) => ({
|
|
292
|
+
name: `${formatFrequency(pp.frequency)} ${pp.category} ${chalk.dim(`- "${(pp.description || "").slice(0, 40)}..."`)}`,
|
|
293
|
+
value: i
|
|
294
|
+
}));
|
|
295
|
+
|
|
296
|
+
choices.push({ name: chalk.dim("ā Back to main menu"), value: "back" });
|
|
297
|
+
|
|
298
|
+
const selected = await select({
|
|
299
|
+
message: chalk.bold(`\nš„ Pain Points (${painPoints.length})\n`),
|
|
300
|
+
choices,
|
|
301
|
+
pageSize: 15
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
if (selected === "back") return;
|
|
305
|
+
|
|
306
|
+
const pp = painPoints[selected];
|
|
307
|
+
if (pp) {
|
|
308
|
+
await showDetail(formatPainPoint(pp));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Browse personas menu.
|
|
315
|
+
*/
|
|
316
|
+
async function browsePersonas(personas) {
|
|
317
|
+
while (true) {
|
|
318
|
+
console.clear();
|
|
319
|
+
|
|
320
|
+
if (!personas || personas.length === 0) {
|
|
321
|
+
console.log(chalk.dim("\nNo personas found.\n"));
|
|
322
|
+
await select({
|
|
323
|
+
message: "",
|
|
324
|
+
choices: [{ name: "ā Back", value: "back" }]
|
|
325
|
+
});
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const choices = personas.map((p, i) => ({
|
|
330
|
+
name: `${chalk.magenta(p.role)} ${chalk.dim(`- ${(p.description || "").slice(0, 50)}...`)}`,
|
|
331
|
+
value: i
|
|
332
|
+
}));
|
|
333
|
+
|
|
334
|
+
choices.push({ name: chalk.dim("ā Back to main menu"), value: "back" });
|
|
335
|
+
|
|
336
|
+
const selected = await select({
|
|
337
|
+
message: chalk.bold(`\nš¤ Personas (${personas.length})\n`),
|
|
338
|
+
choices,
|
|
339
|
+
pageSize: 15
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
if (selected === "back") return;
|
|
343
|
+
|
|
344
|
+
const persona = personas[selected];
|
|
345
|
+
if (persona) {
|
|
346
|
+
await showDetail(formatPersona(persona));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Browse competitors menu.
|
|
353
|
+
*/
|
|
354
|
+
async function browseCompetitors(competitors) {
|
|
355
|
+
while (true) {
|
|
356
|
+
console.clear();
|
|
357
|
+
|
|
358
|
+
if (!competitors || competitors.length === 0) {
|
|
359
|
+
console.log(chalk.dim("\nNo competitors found.\n"));
|
|
360
|
+
await select({
|
|
361
|
+
message: "",
|
|
362
|
+
choices: [{ name: "ā Back", value: "back" }]
|
|
363
|
+
});
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const sentimentIcon = {
|
|
368
|
+
positive: "š",
|
|
369
|
+
neutral: "š",
|
|
370
|
+
negative: "š"
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const choices = competitors.map((c, i) => ({
|
|
374
|
+
name: `${sentimentIcon[c.sentiment] || "ā"} ${chalk.blue(c.name)} ${chalk.dim(`(${c.mentions} mentions)`)}`,
|
|
375
|
+
value: i
|
|
376
|
+
}));
|
|
377
|
+
|
|
378
|
+
choices.push({ name: chalk.dim("ā Back to main menu"), value: "back" });
|
|
379
|
+
|
|
380
|
+
const selected = await select({
|
|
381
|
+
message: chalk.bold(`\nš¢ Competitors (${competitors.length})\n`),
|
|
382
|
+
choices,
|
|
383
|
+
pageSize: 15
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
if (selected === "back") return;
|
|
387
|
+
|
|
388
|
+
const comp = competitors[selected];
|
|
389
|
+
if (comp) {
|
|
390
|
+
await showDetail(formatCompetitor(comp));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Main explorer loop.
|
|
397
|
+
*/
|
|
398
|
+
export async function explore(analysis) {
|
|
399
|
+
while (true) {
|
|
400
|
+
console.clear();
|
|
401
|
+
|
|
402
|
+
const oppCount = analysis.opportunities?.length || 0;
|
|
403
|
+
const painCount = analysis.tags?.painPoints?.length || 0;
|
|
404
|
+
const personaCount = analysis.tags?.personas?.length || 0;
|
|
405
|
+
const compCount = analysis.tags?.competitors?.length || 0;
|
|
406
|
+
|
|
407
|
+
console.log(chalk.bold.green("\nš Reddit Analysis Explorer\n"));
|
|
408
|
+
console.log(chalk.dim(`Analysis: ${analysis.timestamp}\n`));
|
|
409
|
+
|
|
410
|
+
const choice = await select({
|
|
411
|
+
message: "What would you like to explore?",
|
|
412
|
+
choices: [
|
|
413
|
+
{ name: `š Opportunities (${oppCount})`, value: "opportunities" },
|
|
414
|
+
{ name: `š„ Pain Points (${painCount})`, value: "painpoints" },
|
|
415
|
+
{ name: `š¤ Personas (${personaCount})`, value: "personas" },
|
|
416
|
+
{ name: `š¢ Competitors (${compCount})`, value: "competitors" },
|
|
417
|
+
{ name: chalk.dim("Exit"), value: "exit" }
|
|
418
|
+
]
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
switch (choice) {
|
|
422
|
+
case "opportunities":
|
|
423
|
+
await browseOpportunities(analysis.opportunities || []);
|
|
424
|
+
break;
|
|
425
|
+
case "painpoints":
|
|
426
|
+
await browsePainPoints(analysis.tags?.painPoints || []);
|
|
427
|
+
break;
|
|
428
|
+
case "personas":
|
|
429
|
+
await browsePersonas(analysis.tags?.personas || []);
|
|
430
|
+
break;
|
|
431
|
+
case "competitors":
|
|
432
|
+
await browseCompetitors(analysis.tags?.competitors || []);
|
|
433
|
+
break;
|
|
434
|
+
case "exit":
|
|
435
|
+
console.clear();
|
|
436
|
+
console.log(chalk.green("\nš Goodbye!\n"));
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Run the explorer from a directory.
|
|
444
|
+
* If latest is true, auto-selects the most recent analysis.
|
|
445
|
+
*/
|
|
446
|
+
export async function runExplorer({ dir = "outputs", latest = false } = {}) {
|
|
447
|
+
const analyses = await findAnalysisFiles(dir);
|
|
448
|
+
|
|
449
|
+
if (analyses.length === 0) {
|
|
450
|
+
console.log(chalk.red(`\nNo analysis files found in ${dir}\n`));
|
|
451
|
+
console.log(chalk.dim("Run 'reddit-harvest harvest --analyze' first to generate analysis.\n"));
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
let selectedAnalysis;
|
|
456
|
+
|
|
457
|
+
if (latest || analyses.length === 1) {
|
|
458
|
+
selectedAnalysis = analyses[0];
|
|
459
|
+
} else {
|
|
460
|
+
console.clear();
|
|
461
|
+
const choice = await select({
|
|
462
|
+
message: chalk.bold("\nš Select an analysis to explore:\n"),
|
|
463
|
+
choices: analyses.map(a => ({
|
|
464
|
+
name: `${a.timestamp}`,
|
|
465
|
+
value: a.timestamp
|
|
466
|
+
})),
|
|
467
|
+
pageSize: 10
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
selectedAnalysis = analyses.find(a => a.timestamp === choice);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (!selectedAnalysis) {
|
|
474
|
+
console.log(chalk.red("\nAnalysis not found.\n"));
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const analysis = await loadAnalysis(selectedAnalysis);
|
|
479
|
+
await explore(analysis);
|
|
480
|
+
}
|
|
481
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format posts array to JSONL (one JSON object per line).
|
|
3
|
+
*/
|
|
4
|
+
export function formatPostsToJSONL(posts) {
|
|
5
|
+
return posts.map((p) => JSON.stringify(p)).join("\n") + "\n";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse JSONL content back to posts array.
|
|
10
|
+
*/
|
|
11
|
+
export function parseJSONL(content) {
|
|
12
|
+
return content
|
|
13
|
+
.split("\n")
|
|
14
|
+
.filter((line) => line.trim())
|
|
15
|
+
.map((line) => JSON.parse(line));
|
|
16
|
+
}
|
|
17
|
+
|
package/src/index.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export { loadEnv } from "./env.js";
|
|
2
|
+
export { createRedditClient, withRetry } from "./redditClient.js";
|
|
3
|
+
export {
|
|
4
|
+
harvestSubreddit,
|
|
5
|
+
harvestSubredditToText,
|
|
6
|
+
harvestSubredditsToFiles,
|
|
7
|
+
formatPostsToText
|
|
8
|
+
} from "./redditHarvest.js";
|
|
9
|
+
export {
|
|
10
|
+
createOpenAIClient,
|
|
11
|
+
analyzeCorpus,
|
|
12
|
+
analyzeCorpusTextToMarkdown,
|
|
13
|
+
analyzeFileToMarkdown
|
|
14
|
+
} from "./openaiAnalyze.js";
|
|
15
|
+
export { formatPostsToJSONL, parseJSONL } from "./formatters.js";
|
|
16
|
+
export {
|
|
17
|
+
loadDedupeIndex,
|
|
18
|
+
saveDedupeIndex,
|
|
19
|
+
resetDedupeIndex,
|
|
20
|
+
createDedupeTracker
|
|
21
|
+
} from "./dedupe.js";
|
|
22
|
+
export {
|
|
23
|
+
nowTimestampForFiles,
|
|
24
|
+
ensureDir,
|
|
25
|
+
sanitizeForFilename,
|
|
26
|
+
chunkStringBySize,
|
|
27
|
+
normalizeSubredditsArg,
|
|
28
|
+
writeTextFile
|
|
29
|
+
} from "./utils.js";
|
|
30
|
+
export {
|
|
31
|
+
TagsSchema,
|
|
32
|
+
OpportunitiesSchema,
|
|
33
|
+
OpportunitySchema,
|
|
34
|
+
PainPointSchema,
|
|
35
|
+
PersonaSchema,
|
|
36
|
+
CompetitorSchema,
|
|
37
|
+
WillingnessToPaySchema,
|
|
38
|
+
SupportingQuoteSchema
|
|
39
|
+
} from "./schemas.js";
|
|
40
|
+
export {
|
|
41
|
+
findAnalysisFiles,
|
|
42
|
+
loadAnalysis,
|
|
43
|
+
explore,
|
|
44
|
+
runExplorer
|
|
45
|
+
} from "./explorer.js";
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
|
|
4
|
+
function ts() {
|
|
5
|
+
return new Date().toISOString().slice(11, 19); // HH:MM:SS
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createLogger({ verbose = false } = {}) {
|
|
9
|
+
const prefix = chalk.dim(`[${ts()}]`);
|
|
10
|
+
|
|
11
|
+
function log(line) {
|
|
12
|
+
// eslint-disable-next-line no-console
|
|
13
|
+
console.log(line);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
verbose,
|
|
18
|
+
info: (msg) => log(`${prefix} ${chalk.cyan("info")} ${msg}`),
|
|
19
|
+
warn: (msg) => log(`${prefix} ${chalk.yellow("warn")} ${msg}`),
|
|
20
|
+
success: (msg) => log(`${prefix} ${chalk.green("done")} ${msg}`),
|
|
21
|
+
error: (msg) => log(`${prefix} ${chalk.red("err")} ${msg}`),
|
|
22
|
+
debug: (msg) => {
|
|
23
|
+
if (!verbose) return;
|
|
24
|
+
log(`${prefix} ${chalk.magenta("debug")} ${chalk.dim(msg)}`);
|
|
25
|
+
},
|
|
26
|
+
spinner: (text) =>
|
|
27
|
+
ora({
|
|
28
|
+
text,
|
|
29
|
+
spinner: "dots",
|
|
30
|
+
color: "cyan"
|
|
31
|
+
})
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|