pi-producthunt 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/docs/examples.md +49 -0
- package/docs/github-template.md +61 -0
- package/docs/release.md +45 -0
- package/docs/repository-settings.md +41 -0
- package/docs/template-checklist.md +98 -0
- package/docs/typescript.md +75 -0
- package/extensions/index.ts +295 -0
- package/lib/api.ts +110 -0
- package/lib/auth-store.ts +68 -0
- package/lib/client.ts +265 -0
- package/lib/config.ts +61 -0
- package/lib/format.ts +154 -0
- package/lib/identity.ts +56 -0
- package/lib/queries.ts +89 -0
- package/lib/schema.ts +8 -0
- package/lib/types.ts +79 -0
- package/package.json +61 -0
package/lib/client.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { executeProductHuntGraphQL } from "./api.ts";
|
|
2
|
+
import { dateRangeForDay, normalizePostIdentifier } from "./identity.ts";
|
|
3
|
+
import { postCommentsQuery, postDetailsQuery, postsQuery, searchPostsQuery, viewerQuery } from "./queries.ts";
|
|
4
|
+
import type {
|
|
5
|
+
CommentSummary,
|
|
6
|
+
MakerSummary,
|
|
7
|
+
PostCommentsResult,
|
|
8
|
+
PostConnectionResult,
|
|
9
|
+
PostDetails,
|
|
10
|
+
PostListItem,
|
|
11
|
+
ResearchTopicResult,
|
|
12
|
+
TopicSummary,
|
|
13
|
+
ViewerResult,
|
|
14
|
+
} from "./types.ts";
|
|
15
|
+
|
|
16
|
+
type PostsOrder = "RANKING" | "NEWEST";
|
|
17
|
+
type CommentsOrder = "RANKING" | "NEWEST";
|
|
18
|
+
|
|
19
|
+
interface GraphQLEdge<T> {
|
|
20
|
+
node: T;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface GraphQLConnection<T> {
|
|
24
|
+
edges: Array<GraphQLEdge<T>>;
|
|
25
|
+
pageInfo?: { hasNextPage: boolean; endCursor?: string | null };
|
|
26
|
+
totalCount?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface RawTopic {
|
|
30
|
+
id?: string;
|
|
31
|
+
name: string;
|
|
32
|
+
slug?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface RawMaker {
|
|
36
|
+
id?: string;
|
|
37
|
+
name: string;
|
|
38
|
+
username?: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface RawPostListItem {
|
|
42
|
+
id: string;
|
|
43
|
+
slug: string;
|
|
44
|
+
name: string;
|
|
45
|
+
tagline?: string | null;
|
|
46
|
+
url?: string | null;
|
|
47
|
+
votesCount?: number | null;
|
|
48
|
+
commentsCount?: number | null;
|
|
49
|
+
featuredAt?: string | null;
|
|
50
|
+
createdAt?: string | null;
|
|
51
|
+
topics?: GraphQLConnection<RawTopic>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface RawPostDetails extends RawPostListItem {
|
|
55
|
+
description?: string | null;
|
|
56
|
+
website?: string | null;
|
|
57
|
+
thumbnail?: { url?: string | null } | null;
|
|
58
|
+
makers?: RawMaker[];
|
|
59
|
+
user?: RawMaker | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface RawComment {
|
|
63
|
+
id: string;
|
|
64
|
+
body: string;
|
|
65
|
+
votesCount?: number | null;
|
|
66
|
+
createdAt?: string | null;
|
|
67
|
+
user?: RawMaker | null;
|
|
68
|
+
replies?: { totalCount?: number | null } | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ProductHuntClientOptions {
|
|
72
|
+
signal?: AbortSignal;
|
|
73
|
+
token?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function getViewer(options: ProductHuntClientOptions = {}): Promise<ViewerResult> {
|
|
77
|
+
const result = await executeProductHuntGraphQL<{ viewer?: { user?: { username?: string } | null } | null }>(
|
|
78
|
+
viewerQuery,
|
|
79
|
+
{},
|
|
80
|
+
options,
|
|
81
|
+
);
|
|
82
|
+
return { username: result.data.viewer?.user?.username, rateLimit: result.rateLimit };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function getPosts(
|
|
86
|
+
params: {
|
|
87
|
+
date?: string;
|
|
88
|
+
topic?: string;
|
|
89
|
+
featured?: boolean;
|
|
90
|
+
postedAfter?: string;
|
|
91
|
+
postedBefore?: string;
|
|
92
|
+
order?: PostsOrder;
|
|
93
|
+
limit?: number;
|
|
94
|
+
after?: string;
|
|
95
|
+
},
|
|
96
|
+
options: ProductHuntClientOptions = {},
|
|
97
|
+
): Promise<PostConnectionResult> {
|
|
98
|
+
const range: { postedAfter?: string; postedBefore?: string } = params.date ? dateRangeForDay(params.date) : {};
|
|
99
|
+
const first = clampLimit(params.limit, 10, 50);
|
|
100
|
+
const result = await executeProductHuntGraphQL<{ posts: GraphQLConnection<RawPostListItem> }>(
|
|
101
|
+
postsQuery,
|
|
102
|
+
{
|
|
103
|
+
featured: params.featured,
|
|
104
|
+
topic: emptyToUndefined(params.topic),
|
|
105
|
+
postedAfter: params.postedAfter ?? range.postedAfter,
|
|
106
|
+
postedBefore: params.postedBefore ?? range.postedBefore,
|
|
107
|
+
order: params.order ?? "RANKING",
|
|
108
|
+
first,
|
|
109
|
+
after: params.after,
|
|
110
|
+
},
|
|
111
|
+
options,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
posts: result.data.posts.edges.map((edge) => cleanPostListItem(edge.node)),
|
|
116
|
+
pageInfo: result.data.posts.pageInfo,
|
|
117
|
+
totalCount: result.data.posts.totalCount,
|
|
118
|
+
rateLimit: result.rateLimit,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function searchPosts(
|
|
123
|
+
params: { query: string; limit?: number; searchPool?: number; after?: string },
|
|
124
|
+
options: ProductHuntClientOptions = {},
|
|
125
|
+
): Promise<PostConnectionResult> {
|
|
126
|
+
const query = params.query.trim().toLowerCase();
|
|
127
|
+
if (!query) throw new Error("Search query is required.");
|
|
128
|
+
|
|
129
|
+
const searchPool = clampLimit(params.searchPool, 50, 100);
|
|
130
|
+
const limit = clampLimit(params.limit, 10, 50);
|
|
131
|
+
const result = await executeProductHuntGraphQL<{ posts: GraphQLConnection<RawPostListItem> }>(
|
|
132
|
+
searchPostsQuery,
|
|
133
|
+
{ first: searchPool, after: params.after },
|
|
134
|
+
options,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const filtered = result.data.posts.edges
|
|
138
|
+
.map((edge) => cleanPostListItem(edge.node))
|
|
139
|
+
.filter((post) => `${post.name} ${post.tagline ?? ""} ${post.topics.map((topic) => topic.name).join(" ")}`.toLowerCase().includes(query))
|
|
140
|
+
.slice(0, limit);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
posts: filtered,
|
|
144
|
+
pageInfo: result.data.posts.pageInfo,
|
|
145
|
+
totalCount: filtered.length,
|
|
146
|
+
rateLimit: result.rateLimit,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function getPost(ref: string, options: ProductHuntClientOptions = {}): Promise<PostDetails> {
|
|
151
|
+
const identifier = normalizePostIdentifier(ref);
|
|
152
|
+
const result = await executeProductHuntGraphQL<{ post?: RawPostDetails | null }>(
|
|
153
|
+
postDetailsQuery,
|
|
154
|
+
{ id: identifier.id, slug: identifier.slug },
|
|
155
|
+
options,
|
|
156
|
+
);
|
|
157
|
+
if (!result.data.post) throw new Error("Product Hunt post not found.");
|
|
158
|
+
return cleanPostDetails(result.data.post);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function getPostComments(
|
|
162
|
+
params: { ref: string; limit?: number; order?: CommentsOrder; after?: string },
|
|
163
|
+
options: ProductHuntClientOptions = {},
|
|
164
|
+
): Promise<PostCommentsResult> {
|
|
165
|
+
const identifier = normalizePostIdentifier(params.ref);
|
|
166
|
+
const result = await executeProductHuntGraphQL<{
|
|
167
|
+
post?: { id: string; slug: string; name: string; comments: GraphQLConnection<RawComment> } | null;
|
|
168
|
+
}>(
|
|
169
|
+
postCommentsQuery,
|
|
170
|
+
{
|
|
171
|
+
postId: identifier.id,
|
|
172
|
+
postSlug: identifier.slug,
|
|
173
|
+
order: params.order ?? "RANKING",
|
|
174
|
+
first: clampLimit(params.limit, 10, 50),
|
|
175
|
+
after: params.after,
|
|
176
|
+
},
|
|
177
|
+
options,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
if (!result.data.post) throw new Error("Product Hunt post not found.");
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
post: { id: result.data.post.id, slug: result.data.post.slug, name: result.data.post.name },
|
|
184
|
+
comments: result.data.post.comments.edges.map((edge) => cleanComment(edge.node)),
|
|
185
|
+
pageInfo: result.data.post.comments.pageInfo,
|
|
186
|
+
totalCount: result.data.post.comments.totalCount,
|
|
187
|
+
rateLimit: result.rateLimit,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function researchTopic(
|
|
192
|
+
params: { query: string; limit?: number; commentsPerPost?: number },
|
|
193
|
+
options: ProductHuntClientOptions = {},
|
|
194
|
+
): Promise<ResearchTopicResult> {
|
|
195
|
+
const posts = await searchPosts({ query: params.query, limit: params.limit ?? 5, searchPool: 75 }, options);
|
|
196
|
+
const commentsPerPost = clampLimit(params.commentsPerPost, 3, 10);
|
|
197
|
+
const enriched: ResearchTopicResult["posts"] = [];
|
|
198
|
+
|
|
199
|
+
for (const post of posts.posts) {
|
|
200
|
+
try {
|
|
201
|
+
const comments = await getPostComments({ ref: post.slug, limit: commentsPerPost }, options);
|
|
202
|
+
enriched.push({ ...post, comments: comments.comments });
|
|
203
|
+
} catch {
|
|
204
|
+
enriched.push(post);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { query: params.query, posts: enriched, rateLimit: posts.rateLimit };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function cleanPostListItem(raw: RawPostListItem): PostListItem {
|
|
212
|
+
return {
|
|
213
|
+
id: raw.id,
|
|
214
|
+
slug: raw.slug,
|
|
215
|
+
name: raw.name,
|
|
216
|
+
tagline: raw.tagline,
|
|
217
|
+
url: raw.url,
|
|
218
|
+
votesCount: raw.votesCount,
|
|
219
|
+
commentsCount: raw.commentsCount,
|
|
220
|
+
featuredAt: raw.featuredAt,
|
|
221
|
+
createdAt: raw.createdAt,
|
|
222
|
+
topics: cleanTopics(raw.topics),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function cleanPostDetails(raw: RawPostDetails): PostDetails {
|
|
227
|
+
return {
|
|
228
|
+
...cleanPostListItem(raw),
|
|
229
|
+
description: raw.description,
|
|
230
|
+
website: raw.website,
|
|
231
|
+
thumbnailUrl: raw.thumbnail?.url,
|
|
232
|
+
makers: (raw.makers ?? []).map(cleanMaker),
|
|
233
|
+
user: raw.user ? cleanMaker(raw.user) : null,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function cleanComment(raw: RawComment): CommentSummary {
|
|
238
|
+
return {
|
|
239
|
+
id: raw.id,
|
|
240
|
+
body: raw.body,
|
|
241
|
+
votesCount: raw.votesCount,
|
|
242
|
+
createdAt: raw.createdAt,
|
|
243
|
+
user: raw.user ? cleanMaker(raw.user) : null,
|
|
244
|
+
repliesCount: raw.replies?.totalCount,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function cleanTopics(connection?: GraphQLConnection<RawTopic>): TopicSummary[] {
|
|
249
|
+
return connection?.edges.map((edge) => edge.node).map((topic) => ({ id: topic.id, name: topic.name, slug: topic.slug })) ?? [];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function cleanMaker(raw: RawMaker): MakerSummary {
|
|
253
|
+
return { id: raw.id, name: raw.name, username: raw.username };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function clampLimit(value: number | undefined, fallback: number, max: number): number {
|
|
257
|
+
if (!value || !Number.isFinite(value)) return fallback;
|
|
258
|
+
return Math.max(1, Math.min(Math.floor(value), max));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function emptyToUndefined(value: string | undefined): string | undefined {
|
|
262
|
+
const trimmed = value?.trim();
|
|
263
|
+
return trimmed ? trimmed : undefined;
|
|
264
|
+
}
|
|
265
|
+
|
package/lib/config.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { clearStoredAccessToken, loadStoredAccessToken, saveStoredAccessToken } from "./auth-store.ts";
|
|
2
|
+
|
|
3
|
+
export const PRODUCTHUNT_GRAPHQL_ENDPOINT = "https://api.producthunt.com/v2/api/graphql";
|
|
4
|
+
export const PRODUCTHUNT_TOKEN_ENV = "PRODUCTHUNT_ACCESS_TOKEN";
|
|
5
|
+
|
|
6
|
+
export type AccessTokenSource = "environment" | "stored" | "missing";
|
|
7
|
+
|
|
8
|
+
export interface ResolvedAccessToken {
|
|
9
|
+
accessToken?: string;
|
|
10
|
+
source: AccessTokenSource;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveProductHuntAccessToken(env: NodeJS.ProcessEnv = process.env): ResolvedAccessToken {
|
|
14
|
+
const envValue = env[PRODUCTHUNT_TOKEN_ENV]?.trim();
|
|
15
|
+
if (envValue) {
|
|
16
|
+
return { accessToken: envValue, source: "environment" };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const storedValue = loadStoredAccessToken();
|
|
20
|
+
if (storedValue) {
|
|
21
|
+
return { accessToken: storedValue, source: "stored" };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return { source: "missing" };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getProductHuntToken(env: NodeJS.ProcessEnv = process.env): string {
|
|
28
|
+
const token = resolveProductHuntAccessToken(env).accessToken;
|
|
29
|
+
if (!token) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`${PRODUCTHUNT_TOKEN_ENV} is required. Run /producthunt:login or set the env var; do not commit Product Hunt tokens.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return token;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isProductHuntConfigured(): boolean {
|
|
38
|
+
return Boolean(resolveProductHuntAccessToken().accessToken);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function authStatusText(): string {
|
|
42
|
+
const { source } = resolveProductHuntAccessToken();
|
|
43
|
+
if (source === "environment") {
|
|
44
|
+
return `Product Hunt token: configured via ${PRODUCTHUNT_TOKEN_ENV} environment variable.`;
|
|
45
|
+
}
|
|
46
|
+
if (source === "stored") {
|
|
47
|
+
return "Product Hunt token: configured via pi-producthunt login.";
|
|
48
|
+
}
|
|
49
|
+
return `Product Hunt token missing. Run /producthunt:login or set ${PRODUCTHUNT_TOKEN_ENV}.`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function redactProductHuntToken(text: string, token?: string): string {
|
|
53
|
+
let redacted = text.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, "Bearer [REDACTED]");
|
|
54
|
+
if (token) {
|
|
55
|
+
redacted = redacted.split(token).join("[REDACTED]");
|
|
56
|
+
}
|
|
57
|
+
return redacted;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export { clearStoredAccessToken, loadStoredAccessToken, saveStoredAccessToken };
|
|
61
|
+
|
package/lib/format.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { CommentSummary, PostCommentsResult, PostConnectionResult, PostDetails, PostListItem, ResearchTopicResult, ViewerResult } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MAX_CHARS = 16_000;
|
|
4
|
+
|
|
5
|
+
export function formatStatus(result: ViewerResult): string {
|
|
6
|
+
const viewer = result.username ? `@${result.username}` : "token valid; no viewer returned for this token scope";
|
|
7
|
+
return truncateMarkdown(`Product Hunt API status: ${viewer}${formatRateLimit(result.rateLimit)}`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function formatPostList(title: string, result: PostConnectionResult): string {
|
|
11
|
+
const lines = [`# ${title}`, ""];
|
|
12
|
+
if (result.posts.length === 0) {
|
|
13
|
+
lines.push("No Product Hunt posts found.");
|
|
14
|
+
return lines.join("\n");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
result.posts.forEach((post, index) => {
|
|
18
|
+
lines.push(`${index + 1}. ${formatPostHeadline(post)}`);
|
|
19
|
+
lines.push(` - ${post.tagline ?? "No tagline"}`);
|
|
20
|
+
lines.push(` - votes: ${post.votesCount ?? "?"}, comments: ${post.commentsCount ?? "?"}`);
|
|
21
|
+
lines.push(` - slug: ${post.slug}`);
|
|
22
|
+
if (post.url) lines.push(` - url: ${post.url}`);
|
|
23
|
+
if (post.topics.length) lines.push(` - topics: ${post.topics.map((topic) => topic.name).join(", ")}`);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
lines.push(formatRateLimit(result.rateLimit));
|
|
27
|
+
return truncateMarkdown(lines.join("\n"));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function formatPostDetails(post: PostDetails): string {
|
|
31
|
+
const lines = [`# ${post.name}`, ""];
|
|
32
|
+
lines.push(post.tagline ?? "No tagline");
|
|
33
|
+
lines.push("");
|
|
34
|
+
if (post.description) lines.push(post.description, "");
|
|
35
|
+
lines.push(`- slug: ${post.slug}`);
|
|
36
|
+
lines.push(`- votes: ${post.votesCount ?? "?"}`);
|
|
37
|
+
lines.push(`- comments: ${post.commentsCount ?? "?"}`);
|
|
38
|
+
if (post.featuredAt) lines.push(`- featured: ${post.featuredAt}`);
|
|
39
|
+
if (post.url) lines.push(`- Product Hunt: ${post.url}`);
|
|
40
|
+
if (post.website) lines.push(`- website: ${post.website}`);
|
|
41
|
+
if (post.topics.length) lines.push(`- topics: ${post.topics.map((topic) => topic.name).join(", ")}`);
|
|
42
|
+
if (post.makers.length) lines.push(`- makers: ${post.makers.map(formatMaker).join(", ")}`);
|
|
43
|
+
return truncateMarkdown(lines.join("\n"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function formatComments(result: PostCommentsResult): string {
|
|
47
|
+
const lines = [`# Comments: ${result.post.name}`, ""];
|
|
48
|
+
if (!result.comments.length) {
|
|
49
|
+
lines.push("No comments found.");
|
|
50
|
+
return lines.join("\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
result.comments.forEach((comment, index) => {
|
|
54
|
+
lines.push(`## ${index + 1}. ${comment.user ? formatMaker(comment.user) : "Unknown user"}`);
|
|
55
|
+
lines.push(`votes: ${comment.votesCount ?? "?"}, replies: ${comment.repliesCount ?? 0}`);
|
|
56
|
+
lines.push("");
|
|
57
|
+
lines.push(stripHtml(comment.body));
|
|
58
|
+
lines.push("");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
lines.push(formatRateLimit(result.rateLimit));
|
|
62
|
+
return truncateMarkdown(lines.join("\n"));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function formatDigest(date: string, result: ResearchTopicResult | PostConnectionResult): string {
|
|
66
|
+
const posts = "query" in result ? result.posts : result.posts;
|
|
67
|
+
const lines = [`# Product Hunt Digest ${date}`, ""];
|
|
68
|
+
lines.push("## Top launches");
|
|
69
|
+
posts.forEach((post, index) => {
|
|
70
|
+
lines.push(`${index + 1}. ${formatPostHeadline(post)}`);
|
|
71
|
+
lines.push(` - tagline: ${post.tagline ?? ""}`);
|
|
72
|
+
lines.push(` - votes: ${post.votesCount ?? "?"}, comments: ${post.commentsCount ?? "?"}`);
|
|
73
|
+
lines.push(` - url: ${post.url ?? `https://www.producthunt.com/posts/${post.slug}`}`);
|
|
74
|
+
lines.push(" - why notable: ");
|
|
75
|
+
const comments = getInlineComments(post);
|
|
76
|
+
if (comments.length) {
|
|
77
|
+
lines.push(" - reaction samples:");
|
|
78
|
+
comments.slice(0, 3).forEach((comment) => {
|
|
79
|
+
lines.push(` - ${truncateLine(stripHtml(comment.body), 180)}`);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
lines.push("", "## Signals", "- AI:", "- DevTools:", "- Consumer:", "- Design:", "- Productivity:");
|
|
85
|
+
lines.push("", "## User reactions", "- Praise:", "- Complaints:", "- Questions:", "- Pricing concerns:");
|
|
86
|
+
lines.push("", "## Watchlist", "- Product:", " - reason:", " - follow-up query:");
|
|
87
|
+
return truncateMarkdown(lines.join("\n"));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getInlineComments(post: PostListItem | (PostListItem & { comments?: CommentSummary[] })): CommentSummary[] {
|
|
91
|
+
const maybeComments = (post as { comments?: unknown }).comments;
|
|
92
|
+
return Array.isArray(maybeComments) ? (maybeComments as CommentSummary[]) : [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function formatResearch(result: ResearchTopicResult): string {
|
|
96
|
+
const lines = [`# Product Hunt Research: ${result.query}`, ""];
|
|
97
|
+
if (!result.posts.length) {
|
|
98
|
+
lines.push("No matching posts found in the current ranking pool.");
|
|
99
|
+
return lines.join("\n");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
result.posts.forEach((post, index) => {
|
|
103
|
+
lines.push(`## ${index + 1}. ${formatPostHeadline(post)}`);
|
|
104
|
+
lines.push(post.tagline ?? "");
|
|
105
|
+
lines.push(`votes: ${post.votesCount ?? "?"}, comments: ${post.commentsCount ?? "?"}`);
|
|
106
|
+
lines.push(`url: ${post.url ?? `https://www.producthunt.com/posts/${post.slug}`}`);
|
|
107
|
+
if (post.topics.length) lines.push(`topics: ${post.topics.map((topic) => topic.name).join(", ")}`);
|
|
108
|
+
if (post.comments?.length) {
|
|
109
|
+
lines.push("", "### Comment signals");
|
|
110
|
+
post.comments.forEach((comment) => lines.push(`- ${truncateLine(stripHtml(comment.body), 220)}`));
|
|
111
|
+
}
|
|
112
|
+
lines.push("");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
lines.push("## Synthesis prompts", "- Shared positioning pattern:", "- Repeated complaint:", "- Underserved user:", "- Follow-up searches:");
|
|
116
|
+
return truncateMarkdown(lines.join("\n"));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function truncateMarkdown(markdown: string, maxChars = DEFAULT_MAX_CHARS): string {
|
|
120
|
+
if (markdown.length <= maxChars) return markdown;
|
|
121
|
+
return `${markdown.slice(0, maxChars)}\n\n[Truncated: ${markdown.length - maxChars} chars omitted]`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatPostHeadline(post: PostListItem): string {
|
|
125
|
+
return `${post.name} (${post.slug})`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function formatMaker(maker: { name: string; username?: string | null }): string {
|
|
129
|
+
return maker.username ? `${maker.name} (@${maker.username})` : maker.name;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function formatRateLimit(rateLimit: { limit?: string; remaining?: string; reset?: string } | undefined): string {
|
|
133
|
+
if (!rateLimit?.remaining) return "";
|
|
134
|
+
return `\n\nRate limit: ${rateLimit.remaining}/${rateLimit.limit ?? "?"} remaining, reset ${rateLimit.reset ?? "?"}s`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function stripHtml(text: string): string {
|
|
138
|
+
return text
|
|
139
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
140
|
+
.replace(/<[^>]+>/g, "")
|
|
141
|
+
.replace(/&/g, "&")
|
|
142
|
+
.replace(/</g, "<")
|
|
143
|
+
.replace(/>/g, ">")
|
|
144
|
+
.replace(/"/g, '"')
|
|
145
|
+
.replace(/'/g, "'")
|
|
146
|
+
.trim();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function truncateLine(text: string, maxChars: number): string {
|
|
150
|
+
const singleLine = text.replace(/\s+/g, " ").trim();
|
|
151
|
+
if (singleLine.length <= maxChars) return singleLine;
|
|
152
|
+
return `${singleLine.slice(0, maxChars - 1)}…`;
|
|
153
|
+
}
|
|
154
|
+
|
package/lib/identity.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export interface PostIdentifier {
|
|
2
|
+
id?: string;
|
|
3
|
+
slug?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function normalizePostIdentifier(input: string): PostIdentifier {
|
|
7
|
+
const raw = input.trim();
|
|
8
|
+
if (!raw) throw new Error("Product Hunt post slug, ID, or URL is required.");
|
|
9
|
+
|
|
10
|
+
const urlSlug = extractPostSlugFromUrl(raw);
|
|
11
|
+
if (urlSlug) return { slug: urlSlug };
|
|
12
|
+
|
|
13
|
+
if (/^\d+$/.test(raw)) return { id: raw };
|
|
14
|
+
|
|
15
|
+
return { slug: raw.replace(/^@?posts\//, "").replace(/^\//, "") };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function extractPostSlugFromUrl(input: string): string | undefined {
|
|
19
|
+
let url: URL;
|
|
20
|
+
try {
|
|
21
|
+
url = new URL(input);
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
27
|
+
const postIndex = segments.indexOf("posts");
|
|
28
|
+
if (postIndex === -1) return undefined;
|
|
29
|
+
|
|
30
|
+
const slug = segments[postIndex + 1];
|
|
31
|
+
return slug ? decodeURIComponent(slug) : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function dateRangeForDay(date: string): { postedAfter: string; postedBefore: string } {
|
|
35
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
36
|
+
throw new Error("Date must use YYYY-MM-DD format.");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const start = new Date(`${date}T00:00:00.000Z`);
|
|
40
|
+
if (Number.isNaN(start.getTime())) throw new Error("Invalid date.");
|
|
41
|
+
|
|
42
|
+
const end = new Date(start.getTime() + 24 * 60 * 60 * 1000);
|
|
43
|
+
return {
|
|
44
|
+
postedAfter: start.toISOString(),
|
|
45
|
+
postedBefore: end.toISOString(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function todayIsoDate(now = new Date()): string {
|
|
50
|
+
return now.toISOString().slice(0, 10);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function yesterdayIsoDate(now = new Date()): string {
|
|
54
|
+
return new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
55
|
+
}
|
|
56
|
+
|
package/lib/queries.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export const viewerQuery = `
|
|
2
|
+
query ProductHuntViewer {
|
|
3
|
+
viewer { user { username } }
|
|
4
|
+
}
|
|
5
|
+
`;
|
|
6
|
+
|
|
7
|
+
export const postsQuery = `
|
|
8
|
+
query ProductHuntPosts(
|
|
9
|
+
$featured: Boolean,
|
|
10
|
+
$topic: String,
|
|
11
|
+
$postedAfter: DateTime,
|
|
12
|
+
$postedBefore: DateTime,
|
|
13
|
+
$order: PostsOrder,
|
|
14
|
+
$first: Int,
|
|
15
|
+
$after: String
|
|
16
|
+
) {
|
|
17
|
+
posts(
|
|
18
|
+
featured: $featured,
|
|
19
|
+
topic: $topic,
|
|
20
|
+
postedAfter: $postedAfter,
|
|
21
|
+
postedBefore: $postedBefore,
|
|
22
|
+
order: $order,
|
|
23
|
+
first: $first,
|
|
24
|
+
after: $after
|
|
25
|
+
) {
|
|
26
|
+
edges {
|
|
27
|
+
node {
|
|
28
|
+
id slug name tagline url votesCount commentsCount featuredAt createdAt
|
|
29
|
+
topics { edges { node { id name slug } } }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
pageInfo { hasNextPage endCursor }
|
|
33
|
+
totalCount
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
export const searchPostsQuery = `
|
|
39
|
+
query ProductHuntSearchPosts($first: Int, $after: String) {
|
|
40
|
+
posts(order: RANKING, first: $first, after: $after) {
|
|
41
|
+
edges {
|
|
42
|
+
node {
|
|
43
|
+
id slug name tagline url votesCount commentsCount featuredAt createdAt
|
|
44
|
+
topics { edges { node { id name slug } } }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
pageInfo { hasNextPage endCursor }
|
|
48
|
+
totalCount
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
export const postDetailsQuery = `
|
|
54
|
+
query ProductHuntPost($id: ID, $slug: String) {
|
|
55
|
+
post(id: $id, slug: $slug) {
|
|
56
|
+
id slug name tagline description url votesCount commentsCount reviewsRating featuredAt createdAt website
|
|
57
|
+
thumbnail { url }
|
|
58
|
+
topics { edges { node { id name slug } } }
|
|
59
|
+
makers { id name username }
|
|
60
|
+
user { id name username }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
export const postCommentsQuery = `
|
|
66
|
+
query ProductHuntPostComments(
|
|
67
|
+
$postId: ID,
|
|
68
|
+
$postSlug: String,
|
|
69
|
+
$order: CommentsOrder,
|
|
70
|
+
$first: Int,
|
|
71
|
+
$after: String
|
|
72
|
+
) {
|
|
73
|
+
post(id: $postId, slug: $postSlug) {
|
|
74
|
+
id slug name
|
|
75
|
+
comments(order: $order, first: $first, after: $after) {
|
|
76
|
+
edges {
|
|
77
|
+
node {
|
|
78
|
+
id body votesCount createdAt
|
|
79
|
+
user { id name username }
|
|
80
|
+
replies { totalCount }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
pageInfo { hasNextPage endCursor }
|
|
84
|
+
totalCount
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
`;
|
|
89
|
+
|
package/lib/schema.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Type, type TEnum, type TSchemaOptions } from "typebox";
|
|
2
|
+
|
|
3
|
+
export function StringEnum<const Values extends [string, ...string[]]>(
|
|
4
|
+
values: readonly [...Values],
|
|
5
|
+
options?: TSchemaOptions,
|
|
6
|
+
): TEnum<Values> {
|
|
7
|
+
return Type.Enum([...values] as [string, ...string[]], options) as unknown as TEnum<Values>;
|
|
8
|
+
}
|