impact-compass 0.2.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/DOCUMENTATION.md +47 -0
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/SKILLS.md +52 -0
- package/assets/top-banner-readme.png +0 -0
- package/example-coffee.json +15 -0
- package/example-input.json +15 -0
- package/example-output.json +352 -0
- package/example-react.json +15 -0
- package/package.json +52 -0
- package/src/cli.ts +219 -0
- package/src/domain/evidence.test.ts +93 -0
- package/src/domain/evidence.ts +99 -0
- package/src/domain/queryBundle.test.ts +67 -0
- package/src/domain/queryBundle.ts +116 -0
- package/src/domain/scoring.test.ts +184 -0
- package/src/domain/scoring.ts +322 -0
- package/src/services/comparison.test.ts +89 -0
- package/src/services/comparison.ts +84 -0
- package/src/services/demoReport.test.ts +37 -0
- package/src/services/demoReport.ts +32 -0
- package/src/services/publicEvidenceReport.test.ts +66 -0
- package/src/services/publicEvidenceReport.ts +82 -0
- package/src/services/queryDerivedReport.test.ts +68 -0
- package/src/services/queryDerivedReport.ts +227 -0
- package/src/services/reportBuilder.test.ts +90 -0
- package/src/services/reportBuilder.ts +219 -0
- package/src/services/reportInsights.ts +23 -0
- package/src/services/reportStorage.test.ts +77 -0
- package/src/services/reportStorage.ts +113 -0
- package/src/services/reportTypes.ts +49 -0
- package/src/services/sources/extendedAdapters.ts +218 -0
- package/src/services/sources/githubSource.test.ts +48 -0
- package/src/services/sources/githubSource.ts +63 -0
- package/src/services/sources/hackerNewsSource.test.ts +80 -0
- package/src/services/sources/hackerNewsSource.ts +117 -0
- package/src/services/sources/itunesSource.ts +62 -0
- package/src/services/sources/npmSource.ts +65 -0
- package/src/services/sources/redditSource.ts +68 -0
- package/src/services/sources/sourceAdapter.ts +12 -0
- package/src/services/sources/stackExchangeSource.ts +62 -0
- package/src/services/sources/wikipediaSource.ts +62 -0
- package/src/services/therapySeed.ts +183 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import type { EvidenceItem } from "../../domain/evidence";
|
|
3
|
+
import type { QueryBundle } from "../../domain/queryBundle";
|
|
4
|
+
import type { FetchJson, SourceAdapter } from "./sourceAdapter";
|
|
5
|
+
|
|
6
|
+
async function safeFetch(fetchJson: FetchJson, url: string, parser: (data: any) => EvidenceItem[]) {
|
|
7
|
+
try {
|
|
8
|
+
const data = await fetchJson(url);
|
|
9
|
+
return parser(data);
|
|
10
|
+
} catch {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function calculateRelevance(snippet: string, bundle: QueryBundle): number {
|
|
16
|
+
if (!snippet) return 0.5; // Neutral penalty for no text
|
|
17
|
+
|
|
18
|
+
const text = snippet.toLowerCase();
|
|
19
|
+
const keywords = [
|
|
20
|
+
...bundle.problemKeywords,
|
|
21
|
+
...bundle.solutionKeywords,
|
|
22
|
+
...bundle.audienceKeywords,
|
|
23
|
+
...bundle.competitorKeywords
|
|
24
|
+
].flatMap(k => k.toLowerCase().split(" "));
|
|
25
|
+
|
|
26
|
+
if (keywords.length === 0) return 1.0;
|
|
27
|
+
|
|
28
|
+
// If at least one distinct keyword from any domain matches the snippet, it's relevant
|
|
29
|
+
const hasMatch = keywords.some(k => k.length > 2 && text.includes(k));
|
|
30
|
+
return hasMatch ? 1.0 : 0.4; // 60% penalty for irrelevance
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createAll35Adapters({ fetchJson }: { fetchJson: FetchJson }): SourceAdapter[] {
|
|
34
|
+
const adapters: SourceAdapter[] = [];
|
|
35
|
+
|
|
36
|
+
const add = (
|
|
37
|
+
id: string, label: string, pillar: EvidenceItem["metricContribution"], sourceType: string,
|
|
38
|
+
queryFn: (b: QueryBundle) => string | undefined,
|
|
39
|
+
urlFn: (q: string) => string,
|
|
40
|
+
parseFn: (i: any, q: string) => EvidenceItem
|
|
41
|
+
) => {
|
|
42
|
+
adapters.push({
|
|
43
|
+
id, label, bestFor: pillar, limitations: "Public API rate limits apply.",
|
|
44
|
+
scan: async (bundle) => {
|
|
45
|
+
const q = queryFn(bundle);
|
|
46
|
+
if (!q) return [];
|
|
47
|
+
return safeFetch(fetchJson, urlFn(q), (data) => {
|
|
48
|
+
let items = [];
|
|
49
|
+
if (data.items) items = data.items;
|
|
50
|
+
else if (data.hits) items = data.hits;
|
|
51
|
+
else if (data.data?.children) items = data.data.children;
|
|
52
|
+
else if (data.objects) items = data.objects;
|
|
53
|
+
else if (data.query?.search) items = data.query.search;
|
|
54
|
+
else if (data.results) items = data.results;
|
|
55
|
+
return items.slice(0, 5).map((i: any) => {
|
|
56
|
+
const parsed = parseFn(i, q);
|
|
57
|
+
parsed.signalStrength = parsed.signalStrength * calculateRelevance(parsed.snippet, bundle);
|
|
58
|
+
return parsed;
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const sol = (b: QueryBundle) => b.solutionKeywords[0];
|
|
66
|
+
const prob = (b: QueryBundle) => b.problemKeywords[0];
|
|
67
|
+
const aud = (b: QueryBundle) => b.audienceKeywords[0];
|
|
68
|
+
const comp = (b: QueryBundle) => b.competitorKeywords[0];
|
|
69
|
+
|
|
70
|
+
// 1. GITHUB (Activity, Pain, Competition Fit, Momentum, Channel Fit)
|
|
71
|
+
add("gh-act", "GitHub", "Activity", "repo", sol,
|
|
72
|
+
q => `https://api.github.com/search/repositories?q=${encodeURIComponent(q)}&sort=updated`,
|
|
73
|
+
(i, q) => ({ id: `gh-${i.id}`, source: "GitHub", sourceType: "repo", date: i.updated_at.slice(0,10), query: q, snippet: i.full_name, link: i.html_url, metricContribution: "Activity", included: true, reason: `${i.stargazers_count} stars`, duplicateCluster: `gh-${i.id}`, signalStrength: Math.min(100, Math.log10((i.stargazers_count||0) + 1) * 20) }));
|
|
74
|
+
|
|
75
|
+
add("gh-pain", "GitHub", "Pain", "issue", prob,
|
|
76
|
+
q => `https://api.github.com/search/issues?q=${encodeURIComponent(q)}+type:issue`,
|
|
77
|
+
(i, q) => ({ id: `ghi-${i.id}`, source: "GitHub", sourceType: "issue", date: i.created_at.slice(0,10), query: q, snippet: i.title, link: i.html_url, metricContribution: "Pain", included: true, reason: `${i.comments} comments`, duplicateCluster: `ghi-${i.id}`, signalStrength: Math.min(100, Math.log10((i.comments||0) + 1) * 30) }));
|
|
78
|
+
|
|
79
|
+
add("gh-comp", "GitHub", "Competition Fit", "repo", comp,
|
|
80
|
+
q => `https://api.github.com/search/repositories?q=${encodeURIComponent(q)}`,
|
|
81
|
+
(i, q) => ({ id: `ghc-${i.id}`, source: "GitHub", sourceType: "repo", date: i.updated_at.slice(0,10), query: q, snippet: i.full_name, link: i.html_url, metricContribution: "Competition Fit", included: true, reason: `${i.forks_count} forks`, duplicateCluster: `ghc-${i.id}`, signalStrength: Math.min(100, Math.log10((i.forks_count||0) + 1) * 25) }));
|
|
82
|
+
|
|
83
|
+
add("gh-mom", "GitHub", "Momentum", "commit", sol,
|
|
84
|
+
q => `https://api.github.com/search/commits?q=${encodeURIComponent(q)}`,
|
|
85
|
+
(i, q) => ({ id: `ghm-${i.sha}`, source: "GitHub", sourceType: "commit", date: i.commit.author.date.slice(0,10), query: q, snippet: i.commit.message.slice(0, 50), link: i.html_url, metricContribution: "Momentum", included: true, reason: `Recent commit`, duplicateCluster: `ghm-${i.sha}`, signalStrength: 50 }));
|
|
86
|
+
|
|
87
|
+
add("gh-chan", "GitHub", "Channel Fit", "user", aud,
|
|
88
|
+
q => `https://api.github.com/search/users?q=${encodeURIComponent(q)}`,
|
|
89
|
+
(i, q) => ({ id: `ghu-${i.id}`, source: "GitHub", sourceType: "user", date: new Date().toISOString().slice(0,10), query: q, snippet: i.login, link: i.html_url, metricContribution: "Channel Fit", included: true, reason: `Target audience found`, duplicateCluster: `ghu-${i.id}`, signalStrength: 50 }));
|
|
90
|
+
|
|
91
|
+
// 2. HACKER NEWS (Demand, Pain, Channel Fit, Momentum, Competition Fit)
|
|
92
|
+
add("hn-dem", "HackerNews", "Demand", "story", sol,
|
|
93
|
+
q => `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(q)}&tags=story`,
|
|
94
|
+
(i, q) => ({ id: `hn-${i.objectID}`, source: "Hacker News", sourceType: "story", date: i.created_at.slice(0,10), query: q, snippet: i.title, link: `https://news.ycombinator.com/item?id=${i.objectID}`, metricContribution: "Demand", included: true, reason: `${i.points} points`, duplicateCluster: `hn-${i.objectID}`, signalStrength: Math.min(100, Math.log10((i.points||0) + 1) * 30) }));
|
|
95
|
+
|
|
96
|
+
add("hn-pain", "HackerNews", "Pain", "comment", prob,
|
|
97
|
+
q => `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(q)}&tags=comment`,
|
|
98
|
+
(i, q) => ({ id: `hnc-${i.objectID}`, source: "Hacker News", sourceType: "comment", date: i.created_at.slice(0,10), query: q, snippet: (i.comment_text||"").slice(0, 50), link: `https://news.ycombinator.com/item?id=${i.objectID}`, metricContribution: "Pain", included: true, reason: `Discussion on problem`, duplicateCluster: `hnc-${i.objectID}`, signalStrength: 60 }));
|
|
99
|
+
|
|
100
|
+
add("hn-chan", "HackerNews", "Channel Fit", "story", aud,
|
|
101
|
+
q => `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(q)}`,
|
|
102
|
+
(i, q) => ({ id: `hna-${i.objectID}`, source: "Hacker News", sourceType: "story", date: i.created_at.slice(0,10), query: q, snippet: i.title || "", link: `https://news.ycombinator.com/item?id=${i.objectID}`, metricContribution: "Channel Fit", included: true, reason: `Audience discussion`, duplicateCluster: `hna-${i.objectID}`, signalStrength: Math.min(100, Math.log10((i.points||0) + 1) * 20) }));
|
|
103
|
+
|
|
104
|
+
add("hn-mom", "HackerNews", "Momentum", "story", sol,
|
|
105
|
+
q => `https://hn.algolia.com/api/v1/search_by_date?query=${encodeURIComponent(q)}&tags=story`,
|
|
106
|
+
(i, q) => ({ id: `hnm-${i.objectID}`, source: "Hacker News", sourceType: "story", date: i.created_at.slice(0,10), query: q, snippet: i.title, link: `https://news.ycombinator.com/item?id=${i.objectID}`, metricContribution: "Momentum", included: true, reason: `Recent growth`, duplicateCluster: `hnm-${i.objectID}`, signalStrength: Math.min(100, Math.log10((i.points||0) + 1) * 30) }));
|
|
107
|
+
|
|
108
|
+
add("hn-comp", "HackerNews", "Competition Fit", "story", comp,
|
|
109
|
+
q => `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(q)}`,
|
|
110
|
+
(i, q) => ({ id: `hnp-${i.objectID}`, source: "Hacker News", sourceType: "story", date: i.created_at.slice(0,10), query: q, snippet: i.title || "", link: `https://news.ycombinator.com/item?id=${i.objectID}`, metricContribution: "Competition Fit", included: true, reason: `Competitor mentioned`, duplicateCluster: `hnp-${i.objectID}`, signalStrength: Math.min(100, Math.log10((i.points||0) + 1) * 25) }));
|
|
111
|
+
|
|
112
|
+
// 3. REDDIT (Pain, Demand, Channel Fit, Momentum, Competition Fit)
|
|
113
|
+
add("rd-pain", "Reddit", "Pain", "post", prob,
|
|
114
|
+
q => `https://www.reddit.com/search.json?q=${encodeURIComponent(q)}&limit=5`,
|
|
115
|
+
(i, q) => ({ id: `rd-${i.data.id}`, source: "Reddit", sourceType: "post", date: new Date(i.data.created_utc*1000).toISOString().slice(0,10), query: q, snippet: i.data.title, link: `https://reddit.com${i.data.permalink}`, metricContribution: "Pain", included: true, reason: `${i.data.score} score`, duplicateCluster: `rd-${i.data.id}`, signalStrength: Math.min(100, Math.log10((i.data.score||0) + 1) * 20) }));
|
|
116
|
+
|
|
117
|
+
add("rd-dem", "Reddit", "Demand", "post", sol,
|
|
118
|
+
q => `https://www.reddit.com/search.json?q=${encodeURIComponent(q)}&limit=5`,
|
|
119
|
+
(i, q) => ({ id: `rdd-${i.data.id}`, source: "Reddit", sourceType: "post", date: new Date(i.data.created_utc*1000).toISOString().slice(0,10), query: q, snippet: i.data.title, link: `https://reddit.com${i.data.permalink}`, metricContribution: "Demand", included: true, reason: `${i.data.num_comments} comments`, duplicateCluster: `rdd-${i.data.id}`, signalStrength: Math.min(100, Math.log10((i.data.num_comments||0) + 1) * 25) }));
|
|
120
|
+
|
|
121
|
+
add("rd-chan", "Reddit", "Channel Fit", "community", aud,
|
|
122
|
+
q => `https://www.reddit.com/subreddits/search.json?q=${encodeURIComponent(q)}&limit=5`,
|
|
123
|
+
(i, q) => ({ id: `rdc-${i.data.id}`, source: "Reddit", sourceType: "subreddit", date: new Date().toISOString().slice(0,10), query: q, snippet: i.data.display_name, link: `https://reddit.com${i.data.url}`, metricContribution: "Channel Fit", included: true, reason: `${i.data.subscribers} subscribers`, duplicateCluster: `rdc-${i.data.id}`, signalStrength: Math.min(100, Math.log10((i.data.subscribers||0) + 1) * 15) }));
|
|
124
|
+
|
|
125
|
+
add("rd-mom", "Reddit", "Momentum", "post", sol,
|
|
126
|
+
q => `https://www.reddit.com/search.json?q=${encodeURIComponent(q)}&sort=new&limit=5`,
|
|
127
|
+
(i, q) => ({ id: `rdm-${i.data.id}`, source: "Reddit", sourceType: "post", date: new Date(i.data.created_utc*1000).toISOString().slice(0,10), query: q, snippet: i.data.title, link: `https://reddit.com${i.data.permalink}`, metricContribution: "Momentum", included: true, reason: `Recent post`, duplicateCluster: `rdm-${i.data.id}`, signalStrength: Math.min(100, Math.log10((i.data.score||0) + 1) * 20) }));
|
|
128
|
+
|
|
129
|
+
add("rd-comp", "Reddit", "Competition Fit", "post", comp,
|
|
130
|
+
q => `https://www.reddit.com/search.json?q=${encodeURIComponent(q)}&limit=5`,
|
|
131
|
+
(i, q) => ({ id: `rdp-${i.data.id}`, source: "Reddit", sourceType: "post", date: new Date(i.data.created_utc*1000).toISOString().slice(0,10), query: q, snippet: i.data.title, link: `https://reddit.com${i.data.permalink}`, metricContribution: "Competition Fit", included: true, reason: `Competitor discussion`, duplicateCluster: `rdp-${i.data.id}`, signalStrength: Math.min(100, Math.log10((i.data.num_comments||0) + 1) * 20) }));
|
|
132
|
+
|
|
133
|
+
// 4. STACK EXCHANGE (Channel Fit, Activity, Pain, Demand, Evidence Quality)
|
|
134
|
+
add("se-chan", "StackExchange", "Channel Fit", "question", aud,
|
|
135
|
+
q => `https://api.stackexchange.com/2.3/search?order=desc&sort=relevance&intitle=${encodeURIComponent(q)}&site=stackoverflow&pagesize=5`,
|
|
136
|
+
(i, q) => ({ id: `se-${i.question_id}`, source: "StackExchange", sourceType: "question", date: new Date(i.creation_date*1000).toISOString().slice(0,10), query: q, snippet: i.title, link: i.link, metricContribution: "Channel Fit", included: true, reason: `${i.score} score`, duplicateCluster: `se-${i.question_id}`, signalStrength: Math.min(100, Math.log10((i.score||0) + 1) * 25) }));
|
|
137
|
+
|
|
138
|
+
add("se-act", "StackExchange", "Activity", "question", sol,
|
|
139
|
+
q => `https://api.stackexchange.com/2.3/search?order=desc&sort=activity&intitle=${encodeURIComponent(q)}&site=stackoverflow&pagesize=5`,
|
|
140
|
+
(i, q) => ({ id: `sea-${i.question_id}`, source: "StackExchange", sourceType: "question", date: new Date(i.creation_date*1000).toISOString().slice(0,10), query: q, snippet: i.title, link: i.link, metricContribution: "Activity", included: true, reason: `${i.answer_count} answers`, duplicateCluster: `sea-${i.question_id}`, signalStrength: Math.min(100, Math.log10((i.answer_count||0) + 1) * 30) }));
|
|
141
|
+
|
|
142
|
+
add("se-pain", "StackExchange", "Pain", "question", prob,
|
|
143
|
+
q => `https://api.stackexchange.com/2.3/search?order=desc&sort=relevance&intitle=${encodeURIComponent(q)}&site=stackoverflow&pagesize=5`,
|
|
144
|
+
(i, q) => ({ id: `sep-${i.question_id}`, source: "StackExchange", sourceType: "question", date: new Date(i.creation_date*1000).toISOString().slice(0,10), query: q, snippet: i.title, link: i.link, metricContribution: "Pain", included: true, reason: `${i.view_count} views`, duplicateCluster: `sep-${i.question_id}`, signalStrength: Math.min(100, Math.log10((i.view_count||0) + 1) * 15) }));
|
|
145
|
+
|
|
146
|
+
add("se-dem", "StackExchange", "Demand", "question", sol,
|
|
147
|
+
q => `https://api.stackexchange.com/2.3/search?order=desc&sort=votes&intitle=${encodeURIComponent(q)}&site=stackoverflow&pagesize=5`,
|
|
148
|
+
(i, q) => ({ id: `sed-${i.question_id}`, source: "StackExchange", sourceType: "question", date: new Date(i.creation_date*1000).toISOString().slice(0,10), query: q, snippet: i.title, link: i.link, metricContribution: "Demand", included: true, reason: `Highly voted`, duplicateCluster: `sed-${i.question_id}`, signalStrength: Math.min(100, Math.log10((i.score||0) + 1) * 20) }));
|
|
149
|
+
|
|
150
|
+
add("se-ev", "StackExchange", "Evidence Quality", "wiki", sol,
|
|
151
|
+
q => `https://api.stackexchange.com/2.3/tags/${encodeURIComponent(q.split(" ")[0])}/wikis?site=stackoverflow`,
|
|
152
|
+
(i, q) => ({ id: `see-${i.tag_name}`, source: "StackExchange", sourceType: "wiki", date: new Date().toISOString().slice(0,10), query: q, snippet: i.tag_name, link: `https://stackoverflow.com/tags/${i.tag_name}`, metricContribution: "Evidence Quality", included: true, reason: `Established tag`, duplicateCluster: `see-${i.tag_name}`, signalStrength: 80 }));
|
|
153
|
+
|
|
154
|
+
// 5. NPM (Momentum, Competition Fit, Activity, Demand, Channel Fit)
|
|
155
|
+
add("npm-mom", "NPM", "Momentum", "package", sol,
|
|
156
|
+
q => `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(q)}&size=5`,
|
|
157
|
+
(i, q) => ({ id: `npm-${i.package.name}`, source: "npm", sourceType: "package", date: i.package.date.slice(0,10), query: q, snippet: i.package.name, link: i.package.links.npm, metricContribution: "Momentum", included: true, reason: `Score: ${Math.round(i.score.final*100)}`, duplicateCluster: `npm-${i.package.name}`, signalStrength: Math.min(100, i.score.final * 100) }));
|
|
158
|
+
|
|
159
|
+
add("npm-comp", "NPM", "Competition Fit", "package", comp,
|
|
160
|
+
q => `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(q)}&size=5`,
|
|
161
|
+
(i, q) => ({ id: `npmc-${i.package.name}`, source: "npm", sourceType: "package", date: i.package.date.slice(0,10), query: q, snippet: i.package.name, link: i.package.links.npm, metricContribution: "Competition Fit", included: true, reason: `Competitor found`, duplicateCluster: `npmc-${i.package.name}`, signalStrength: Math.min(100, i.score.final * 100) }));
|
|
162
|
+
|
|
163
|
+
add("npm-act", "NPM", "Activity", "package", prob,
|
|
164
|
+
q => `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(q)}&size=5`,
|
|
165
|
+
(i, q) => ({ id: `npma-${i.package.name}`, source: "npm", sourceType: "package", date: i.package.date.slice(0,10), query: q, snippet: i.package.name, link: i.package.links.npm, metricContribution: "Activity", included: true, reason: `Active package`, duplicateCluster: `npma-${i.package.name}`, signalStrength: Math.min(100, i.score.final * 80) }));
|
|
166
|
+
|
|
167
|
+
add("npm-dem", "NPM", "Demand", "package", sol,
|
|
168
|
+
q => `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(q)}&quality=1.0&size=5`,
|
|
169
|
+
(i, q) => ({ id: `npmd-${i.package.name}`, source: "npm", sourceType: "package", date: i.package.date.slice(0,10), query: q, snippet: i.package.name, link: i.package.links.npm, metricContribution: "Demand", included: true, reason: `High quality demand`, duplicateCluster: `npmd-${i.package.name}`, signalStrength: Math.min(100, i.score.final * 90) }));
|
|
170
|
+
|
|
171
|
+
add("npm-chan", "NPM", "Channel Fit", "package", aud,
|
|
172
|
+
q => `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(q)}&size=5`,
|
|
173
|
+
(i, q) => ({ id: `npmch-${i.package.name}`, source: "npm", sourceType: "package", date: i.package.date.slice(0,10), query: q, snippet: i.package.name, link: i.package.links.npm, metricContribution: "Channel Fit", included: true, reason: `Audience tools`, duplicateCluster: `npmch-${i.package.name}`, signalStrength: Math.min(100, i.score.final * 70) }));
|
|
174
|
+
|
|
175
|
+
// 6. WIKIPEDIA (Evidence Quality, Momentum, Channel Fit, Competition Fit, Activity)
|
|
176
|
+
add("wk-ev", "Wikipedia", "Evidence Quality", "article", sol,
|
|
177
|
+
q => `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(q)}&format=json&srlimit=5`,
|
|
178
|
+
(i, q) => ({ id: `wk-${i.pageid}`, source: "Wikipedia", sourceType: "article", date: i.timestamp.slice(0,10), query: q, snippet: i.title, link: `https://en.wikipedia.org/?curid=${i.pageid}`, metricContribution: "Evidence Quality", included: true, reason: `Encyclopedia entry`, duplicateCluster: `wk-${i.pageid}`, signalStrength: 75 }));
|
|
179
|
+
|
|
180
|
+
add("wk-mom", "Wikipedia", "Momentum", "article", sol,
|
|
181
|
+
q => `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(q)}&format=json&srlimit=5`,
|
|
182
|
+
(i, q) => ({ id: `wkm-${i.pageid}`, source: "Wikipedia", sourceType: "article", date: i.timestamp.slice(0,10), query: q, snippet: i.title, link: `https://en.wikipedia.org/?curid=${i.pageid}`, metricContribution: "Momentum", included: true, reason: `Wiki tracking`, duplicateCluster: `wkm-${i.pageid}`, signalStrength: 60 }));
|
|
183
|
+
|
|
184
|
+
add("wk-chan", "Wikipedia", "Channel Fit", "article", aud,
|
|
185
|
+
q => `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(q)}&format=json&srlimit=5`,
|
|
186
|
+
(i, q) => ({ id: `wkc-${i.pageid}`, source: "Wikipedia", sourceType: "article", date: i.timestamp.slice(0,10), query: q, snippet: i.title, link: `https://en.wikipedia.org/?curid=${i.pageid}`, metricContribution: "Channel Fit", included: true, reason: `Audience defined`, duplicateCluster: `wkc-${i.pageid}`, signalStrength: 65 }));
|
|
187
|
+
|
|
188
|
+
add("wk-comp", "Wikipedia", "Competition Fit", "article", comp,
|
|
189
|
+
q => `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(q)}&format=json&srlimit=5`,
|
|
190
|
+
(i, q) => ({ id: `wkp-${i.pageid}`, source: "Wikipedia", sourceType: "article", date: i.timestamp.slice(0,10), query: q, snippet: i.title, link: `https://en.wikipedia.org/?curid=${i.pageid}`, metricContribution: "Competition Fit", included: true, reason: `Competitor wiki`, duplicateCluster: `wkp-${i.pageid}`, signalStrength: 70 }));
|
|
191
|
+
|
|
192
|
+
add("wk-act", "Wikipedia", "Activity", "article", prob,
|
|
193
|
+
q => `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(q)}&format=json&srlimit=5`,
|
|
194
|
+
(i, q) => ({ id: `wka-${i.pageid}`, source: "Wikipedia", sourceType: "article", date: i.timestamp.slice(0,10), query: q, snippet: i.title, link: `https://en.wikipedia.org/?curid=${i.pageid}`, metricContribution: "Activity", included: true, reason: `Problem activity`, duplicateCluster: `wka-${i.pageid}`, signalStrength: 50 }));
|
|
195
|
+
|
|
196
|
+
// 7. APP STORE (Competition Fit, Momentum, Pain, Demand, Evidence Quality)
|
|
197
|
+
add("as-comp", "App Store", "Competition Fit", "app", comp,
|
|
198
|
+
q => `https://itunes.apple.com/search?term=${encodeURIComponent(q)}&entity=software&limit=5`,
|
|
199
|
+
(i, q) => ({ id: `as-${i.trackId}`, source: "App Store", sourceType: "app", date: i.releaseDate.slice(0,10), query: q, snippet: i.trackName, link: i.trackViewUrl, metricContribution: "Competition Fit", included: true, reason: `Existing competitor`, duplicateCluster: `as-${i.trackId}`, signalStrength: Math.min(100, Math.log10((i.userRatingCount||0) + 1) * 20) }));
|
|
200
|
+
|
|
201
|
+
add("as-mom", "App Store", "Momentum", "app", sol,
|
|
202
|
+
q => `https://itunes.apple.com/search?term=${encodeURIComponent(q)}&entity=software&limit=5`,
|
|
203
|
+
(i, q) => ({ id: `asm-${i.trackId}`, source: "App Store", sourceType: "app", date: i.releaseDate.slice(0,10), query: q, snippet: i.trackName, link: i.trackViewUrl, metricContribution: "Momentum", included: true, reason: `Solution exists`, duplicateCluster: `asm-${i.trackId}`, signalStrength: Math.min(100, Math.log10((i.userRatingCount||0) + 1) * 15) }));
|
|
204
|
+
|
|
205
|
+
add("as-pain", "App Store", "Pain", "podcast", prob,
|
|
206
|
+
q => `https://itunes.apple.com/search?term=${encodeURIComponent(q)}&entity=podcast&limit=5`,
|
|
207
|
+
(i, q) => ({ id: `asp-${i.trackId}`, source: "App Store", sourceType: "podcast", date: i.releaseDate.slice(0,10), query: q, snippet: i.trackName, link: i.trackViewUrl, metricContribution: "Pain", included: true, reason: `Podcast discussion`, duplicateCluster: `asp-${i.trackId}`, signalStrength: Math.min(100, Math.log10((i.userRatingCount||0) + 1) * 25) }));
|
|
208
|
+
|
|
209
|
+
add("as-dem", "App Store", "Demand", "app", aud,
|
|
210
|
+
q => `https://itunes.apple.com/search?term=${encodeURIComponent(q)}&entity=software&limit=5`,
|
|
211
|
+
(i, q) => ({ id: `asd-${i.trackId}`, source: "App Store", sourceType: "app", date: i.releaseDate.slice(0,10), query: q, snippet: i.trackName, link: i.trackViewUrl, metricContribution: "Demand", included: true, reason: `Audience apps`, duplicateCluster: `asd-${i.trackId}`, signalStrength: Math.min(100, Math.log10((i.userRatingCount||0) + 1) * 20) }));
|
|
212
|
+
|
|
213
|
+
add("as-ev", "App Store", "Evidence Quality", "book", sol,
|
|
214
|
+
q => `https://itunes.apple.com/search?term=${encodeURIComponent(q)}&entity=ebook&limit=5`,
|
|
215
|
+
(i, q) => ({ id: `ase-${i.trackId}`, source: "App Store", sourceType: "book", date: i.releaseDate.slice(0,10), query: q, snippet: i.trackName, link: i.trackViewUrl, metricContribution: "Evidence Quality", included: true, reason: `Published book`, duplicateCluster: `ase-${i.trackId}`, signalStrength: Math.min(100, Math.log10((i.userRatingCount||0) + 1) * 30) }));
|
|
216
|
+
|
|
217
|
+
return adapters;
|
|
218
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createLockedQueryBundle } from "../../domain/queryBundle";
|
|
3
|
+
import { buildGitHubSearchUrl, createGitHubSourceAdapter } from "./githubSource";
|
|
4
|
+
|
|
5
|
+
describe("GitHub source adapter", () => {
|
|
6
|
+
it("builds a public repository search URL from a query term", () => {
|
|
7
|
+
expect(buildGitHubSearchUrl("therapy notes")).toBe(
|
|
8
|
+
"https://api.github.com/search/repositories?q=therapy%20notes&sort=updated&order=desc&per_page=5",
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("normalizes repository search results into evidence items", async () => {
|
|
13
|
+
const adapter = createGitHubSourceAdapter({
|
|
14
|
+
fetchJson: async () => ({
|
|
15
|
+
items: [
|
|
16
|
+
{
|
|
17
|
+
id: 12,
|
|
18
|
+
html_url: "https://github.com/example/notes",
|
|
19
|
+
full_name: "example/notes",
|
|
20
|
+
description: "Clinical notes automation experiments",
|
|
21
|
+
updated_at: "2026-04-03T00:00:00Z",
|
|
22
|
+
stargazers_count: 42,
|
|
23
|
+
forks_count: 5,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const evidence = await adapter.scan(
|
|
30
|
+
createLockedQueryBundle({
|
|
31
|
+
problemKeywords: "therapy notes",
|
|
32
|
+
solutionKeywords: "",
|
|
33
|
+
audienceKeywords: "therapists",
|
|
34
|
+
competitorKeywords: "",
|
|
35
|
+
exclusions: "physical therapy",
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
expect(evidence[0]).toMatchObject({
|
|
40
|
+
id: "github-repo-12",
|
|
41
|
+
source: "GitHub",
|
|
42
|
+
sourceType: "repo",
|
|
43
|
+
query: "therapy notes",
|
|
44
|
+
metricContribution: "Activity",
|
|
45
|
+
included: true,
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { EvidenceItem } from "../../domain/evidence";
|
|
2
|
+
import type { QueryBundle } from "../../domain/queryBundle";
|
|
3
|
+
import type { FetchJson, SourceAdapter } from "./sourceAdapter";
|
|
4
|
+
|
|
5
|
+
type GitHubRepo = {
|
|
6
|
+
id: number;
|
|
7
|
+
html_url: string;
|
|
8
|
+
full_name: string;
|
|
9
|
+
description: string | null;
|
|
10
|
+
updated_at: string;
|
|
11
|
+
stargazers_count: number;
|
|
12
|
+
forks_count: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type GitHubSearchResponse = {
|
|
16
|
+
items?: GitHubRepo[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function buildGitHubSearchUrl(term: string) {
|
|
20
|
+
const query = encodeURIComponent(term);
|
|
21
|
+
return `https://api.github.com/search/repositories?q=${query}&sort=updated&order=desc&per_page=5`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeRepo(repo: GitHubRepo, query: string): EvidenceItem {
|
|
25
|
+
return {
|
|
26
|
+
id: `github-repo-${repo.id}`,
|
|
27
|
+
source: "GitHub",
|
|
28
|
+
sourceType: "repo",
|
|
29
|
+
date: repo.updated_at.slice(0, 10),
|
|
30
|
+
query,
|
|
31
|
+
snippet: repo.description || repo.full_name,
|
|
32
|
+
link: repo.html_url,
|
|
33
|
+
metricContribution: "Activity",
|
|
34
|
+
included: true,
|
|
35
|
+
reason: `${repo.stargazers_count} stars and ${repo.forks_count} forks; updated recently.`,
|
|
36
|
+
duplicateCluster: `github-${repo.id}`,
|
|
37
|
+
signalStrength: Math.min(100, Math.round(repo.stargazers_count + repo.forks_count)),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createGitHubSourceAdapter({
|
|
42
|
+
fetchJson,
|
|
43
|
+
}: {
|
|
44
|
+
fetchJson: FetchJson;
|
|
45
|
+
}): SourceAdapter {
|
|
46
|
+
return {
|
|
47
|
+
id: "github",
|
|
48
|
+
label: "GitHub",
|
|
49
|
+
bestFor: "Devtools, open-source libraries, package ecosystems, and builder activity.",
|
|
50
|
+
limitations: "Repository activity is not customer demand and can overrepresent dev-facing ideas.",
|
|
51
|
+
async scan(bundle: QueryBundle) {
|
|
52
|
+
const query = bundle.problemKeywords[0] ?? bundle.solutionKeywords[0];
|
|
53
|
+
|
|
54
|
+
if (!query) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const response = (await fetchJson(buildGitHubSearchUrl(query))) as GitHubSearchResponse;
|
|
59
|
+
|
|
60
|
+
return (response.items ?? []).map((repo) => normalizeRepo(repo, query));
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildHackerNewsSearchUrl,
|
|
4
|
+
createHackerNewsSourceAdapter,
|
|
5
|
+
buildHackerNewsItemUrl,
|
|
6
|
+
normalizeHackerNewsItem,
|
|
7
|
+
} from "./hackerNewsSource";
|
|
8
|
+
import { createLockedQueryBundle } from "../../domain/queryBundle";
|
|
9
|
+
|
|
10
|
+
describe("Hacker News source adapter", () => {
|
|
11
|
+
it("builds official Firebase item URLs", () => {
|
|
12
|
+
expect(buildHackerNewsItemUrl(123)).toBe(
|
|
13
|
+
"https://hacker-news.firebaseio.com/v0/item/123.json",
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("builds public Algolia search URLs", () => {
|
|
18
|
+
expect(buildHackerNewsSearchUrl("invoice reminders")).toBe(
|
|
19
|
+
"https://hn.algolia.com/api/v1/search?query=invoice%20reminders&tags=story",
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("normalizes a story into an evidence item", () => {
|
|
24
|
+
expect(
|
|
25
|
+
normalizeHackerNewsItem(
|
|
26
|
+
{
|
|
27
|
+
id: 123,
|
|
28
|
+
title: "Ask HN: How do you reduce documentation toil?",
|
|
29
|
+
url: "https://news.ycombinator.com/item?id=123",
|
|
30
|
+
time: 1770000000,
|
|
31
|
+
score: 51,
|
|
32
|
+
descendants: 24,
|
|
33
|
+
},
|
|
34
|
+
"documentation toil",
|
|
35
|
+
),
|
|
36
|
+
).toMatchObject({
|
|
37
|
+
id: "hn-item-123",
|
|
38
|
+
source: "Hacker News",
|
|
39
|
+
sourceType: "post",
|
|
40
|
+
query: "documentation toil",
|
|
41
|
+
metricContribution: "Demand",
|
|
42
|
+
included: true,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("scans search results into demand evidence", async () => {
|
|
47
|
+
const adapter = createHackerNewsSourceAdapter({
|
|
48
|
+
fetchJson: async () => ({
|
|
49
|
+
hits: [
|
|
50
|
+
{
|
|
51
|
+
objectID: "42",
|
|
52
|
+
title: "Ask HN: How do you handle invoice reminders?",
|
|
53
|
+
url: "https://news.ycombinator.com/item?id=42",
|
|
54
|
+
created_at_i: 1770000000,
|
|
55
|
+
points: 12,
|
|
56
|
+
num_comments: 8,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const evidence = await adapter.scan(
|
|
63
|
+
createLockedQueryBundle({
|
|
64
|
+
problemKeywords: "invoice reminders",
|
|
65
|
+
solutionKeywords: "",
|
|
66
|
+
audienceKeywords: "freelancers",
|
|
67
|
+
competitorKeywords: "",
|
|
68
|
+
exclusions: "billing jobs",
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(evidence[0]).toMatchObject({
|
|
73
|
+
id: "hn-search-42",
|
|
74
|
+
source: "Hacker News",
|
|
75
|
+
query: "invoice reminders",
|
|
76
|
+
metricContribution: "Demand",
|
|
77
|
+
included: true,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { EvidenceItem } from "../../domain/evidence";
|
|
2
|
+
import type { QueryBundle } from "../../domain/queryBundle";
|
|
3
|
+
import type { FetchJson, SourceAdapter } from "./sourceAdapter";
|
|
4
|
+
|
|
5
|
+
export type HackerNewsItem = {
|
|
6
|
+
id: number;
|
|
7
|
+
title?: string;
|
|
8
|
+
text?: string;
|
|
9
|
+
url?: string;
|
|
10
|
+
time: number;
|
|
11
|
+
score?: number;
|
|
12
|
+
descendants?: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type HackerNewsSearchHit = {
|
|
16
|
+
objectID: string;
|
|
17
|
+
title?: string;
|
|
18
|
+
story_title?: string;
|
|
19
|
+
comment_text?: string;
|
|
20
|
+
url?: string;
|
|
21
|
+
story_url?: string;
|
|
22
|
+
created_at_i?: number;
|
|
23
|
+
points?: number;
|
|
24
|
+
num_comments?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type HackerNewsSearchResponse = {
|
|
28
|
+
hits?: HackerNewsSearchHit[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function buildHackerNewsItemUrl(id: number) {
|
|
32
|
+
return `https://hacker-news.firebaseio.com/v0/item/${id}.json`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildHackerNewsSearchUrl(query: string) {
|
|
36
|
+
return `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(query)}&tags=story`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function normalizeHackerNewsItem(
|
|
40
|
+
item: HackerNewsItem,
|
|
41
|
+
query: string,
|
|
42
|
+
): EvidenceItem {
|
|
43
|
+
const date = new Date(item.time * 1000).toISOString().slice(0, 10);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
id: `hn-item-${item.id}`,
|
|
47
|
+
source: "Hacker News",
|
|
48
|
+
sourceType: "post",
|
|
49
|
+
date,
|
|
50
|
+
query,
|
|
51
|
+
snippet: item.title || item.text || "Hacker News item",
|
|
52
|
+
link: item.url || `https://news.ycombinator.com/item?id=${item.id}`,
|
|
53
|
+
metricContribution: "Demand",
|
|
54
|
+
included: true,
|
|
55
|
+
reason: `${item.score ?? 0} points and ${item.descendants ?? 0} comments.`,
|
|
56
|
+
duplicateCluster: `hn-${item.id}`,
|
|
57
|
+
signalStrength: Math.min(100, Math.round((item.score ?? 0) + (item.descendants ?? 0))),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function stripHtml(value: string) {
|
|
62
|
+
return value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeHackerNewsSearchHit(
|
|
66
|
+
hit: HackerNewsSearchHit,
|
|
67
|
+
query: string,
|
|
68
|
+
): EvidenceItem {
|
|
69
|
+
const timestamp = hit.created_at_i ?? Math.floor(Date.now() / 1000);
|
|
70
|
+
const title = hit.title || hit.story_title || stripHtml(hit.comment_text ?? "");
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
id: `hn-search-${hit.objectID}`,
|
|
74
|
+
source: "Hacker News",
|
|
75
|
+
sourceType: "post",
|
|
76
|
+
date: new Date(timestamp * 1000).toISOString().slice(0, 10),
|
|
77
|
+
query,
|
|
78
|
+
snippet: title || "Hacker News search result",
|
|
79
|
+
link:
|
|
80
|
+
hit.url ||
|
|
81
|
+
hit.story_url ||
|
|
82
|
+
`https://news.ycombinator.com/item?id=${hit.objectID}`,
|
|
83
|
+
metricContribution: "Demand",
|
|
84
|
+
included: true,
|
|
85
|
+
reason: `${hit.points ?? 0} points and ${hit.num_comments ?? 0} comments from live search.`,
|
|
86
|
+
duplicateCluster: `hn-search-${hit.objectID}`,
|
|
87
|
+
signalStrength: Math.min(100, Math.round((hit.points ?? 0) + (hit.num_comments ?? 0))),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createHackerNewsSourceAdapter({
|
|
92
|
+
fetchJson,
|
|
93
|
+
}: {
|
|
94
|
+
fetchJson: FetchJson;
|
|
95
|
+
}): SourceAdapter {
|
|
96
|
+
return {
|
|
97
|
+
id: "hacker-news",
|
|
98
|
+
label: "Hacker News",
|
|
99
|
+
bestFor: "Technical demand, builder discussion, launch discussion, and pain language.",
|
|
100
|
+
limitations: "Strongly overrepresents technical audiences and builder-facing products.",
|
|
101
|
+
async scan(bundle: QueryBundle) {
|
|
102
|
+
const query = bundle.problemKeywords[0] ?? bundle.solutionKeywords[0];
|
|
103
|
+
|
|
104
|
+
if (!query) {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const response = (await fetchJson(
|
|
109
|
+
buildHackerNewsSearchUrl(query),
|
|
110
|
+
)) as HackerNewsSearchResponse;
|
|
111
|
+
|
|
112
|
+
return (response.hits ?? [])
|
|
113
|
+
.slice(0, 5)
|
|
114
|
+
.map((hit) => normalizeHackerNewsSearchHit(hit, query));
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { EvidenceItem } from "../../domain/evidence";
|
|
2
|
+
import type { QueryBundle } from "../../domain/queryBundle";
|
|
3
|
+
import type { FetchJson, SourceAdapter } from "./sourceAdapter";
|
|
4
|
+
|
|
5
|
+
type iTunesItem = {
|
|
6
|
+
trackId: number;
|
|
7
|
+
trackName: string;
|
|
8
|
+
description: string;
|
|
9
|
+
trackViewUrl: string;
|
|
10
|
+
releaseDate: string;
|
|
11
|
+
userRatingCount?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type iTunesResponse = {
|
|
15
|
+
results?: iTunesItem[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function buildItunesSearchUrl(term: string) {
|
|
19
|
+
const query = encodeURIComponent(term);
|
|
20
|
+
return `https://itunes.apple.com/search?term=${query}&entity=software&limit=5`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeApp(item: iTunesItem, query: string): EvidenceItem {
|
|
24
|
+
return {
|
|
25
|
+
id: `itunes-app-${item.trackId}`,
|
|
26
|
+
source: "App Store",
|
|
27
|
+
sourceType: "app",
|
|
28
|
+
date: item.releaseDate.slice(0, 10),
|
|
29
|
+
query,
|
|
30
|
+
snippet: item.trackName,
|
|
31
|
+
link: item.trackViewUrl,
|
|
32
|
+
metricContribution: "Competition Fit",
|
|
33
|
+
included: true,
|
|
34
|
+
reason: `${item.userRatingCount || 0} user ratings. Existing competition detected.`,
|
|
35
|
+
duplicateCluster: `app-${item.trackId}`,
|
|
36
|
+
signalStrength: Math.min(100, (item.userRatingCount || 0) / 10),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createItunesSourceAdapter({
|
|
41
|
+
fetchJson,
|
|
42
|
+
}: {
|
|
43
|
+
fetchJson: FetchJson;
|
|
44
|
+
}): SourceAdapter {
|
|
45
|
+
return {
|
|
46
|
+
id: "itunes",
|
|
47
|
+
label: "App Store",
|
|
48
|
+
bestFor: "Finding existing competitors in the consumer app space.",
|
|
49
|
+
limitations: "B2B and web SaaS products won't be represented here.",
|
|
50
|
+
async scan(bundle: QueryBundle) {
|
|
51
|
+
const query = bundle.competitorKeywords[0] ?? bundle.solutionKeywords[0] ?? bundle.problemKeywords[0];
|
|
52
|
+
|
|
53
|
+
if (!query) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const response = (await fetchJson(buildItunesSearchUrl(query))) as iTunesResponse;
|
|
58
|
+
|
|
59
|
+
return (response.results ?? []).map((item) => normalizeApp(item, query));
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { EvidenceItem } from "../../domain/evidence";
|
|
2
|
+
import type { QueryBundle } from "../../domain/queryBundle";
|
|
3
|
+
import type { FetchJson, SourceAdapter } from "./sourceAdapter";
|
|
4
|
+
|
|
5
|
+
type NpmItem = {
|
|
6
|
+
package: {
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
links: { npm: string };
|
|
10
|
+
date: string;
|
|
11
|
+
};
|
|
12
|
+
score: {
|
|
13
|
+
detail: { popularity: number };
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type NpmSearchResponse = {
|
|
18
|
+
objects?: NpmItem[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function buildNpmSearchUrl(term: string) {
|
|
22
|
+
const query = encodeURIComponent(term);
|
|
23
|
+
return `https://registry.npmjs.org/-/v1/search?text=${query}&size=5`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizePackage(item: NpmItem, query: string): EvidenceItem {
|
|
27
|
+
return {
|
|
28
|
+
id: `npm-pkg-${item.package.name}`,
|
|
29
|
+
source: "npm",
|
|
30
|
+
sourceType: "package",
|
|
31
|
+
date: item.package.date.slice(0, 10),
|
|
32
|
+
query,
|
|
33
|
+
snippet: item.package.description || item.package.name,
|
|
34
|
+
link: item.package.links.npm,
|
|
35
|
+
metricContribution: "Momentum",
|
|
36
|
+
included: true,
|
|
37
|
+
reason: `Popularity score: ${Math.round(item.score.detail.popularity * 100)}%`,
|
|
38
|
+
duplicateCluster: `npm-${item.package.name}`,
|
|
39
|
+
signalStrength: Math.min(100, Math.round(item.score.detail.popularity * 100)),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createNpmSourceAdapter({
|
|
44
|
+
fetchJson,
|
|
45
|
+
}: {
|
|
46
|
+
fetchJson: FetchJson;
|
|
47
|
+
}): SourceAdapter {
|
|
48
|
+
return {
|
|
49
|
+
id: "npm",
|
|
50
|
+
label: "npm",
|
|
51
|
+
bestFor: "Validating JS developer tools and library momentum.",
|
|
52
|
+
limitations: "Only relevant for JavaScript ecosystem tools.",
|
|
53
|
+
async scan(bundle: QueryBundle) {
|
|
54
|
+
const query = bundle.solutionKeywords[0] ?? bundle.problemKeywords[0];
|
|
55
|
+
|
|
56
|
+
if (!query) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const response = (await fetchJson(buildNpmSearchUrl(query))) as NpmSearchResponse;
|
|
61
|
+
|
|
62
|
+
return (response.objects ?? []).map((item) => normalizePackage(item, query));
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|