@tydung26/product-kit 1.4.0 → 1.5.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.
Files changed (30) hide show
  1. package/dist/scripts/market-intel/search-app-store.d.ts +7 -0
  2. package/dist/scripts/market-intel/search-app-store.d.ts.map +1 -0
  3. package/dist/scripts/market-intel/search-app-store.js +91 -0
  4. package/dist/scripts/market-intel/search-app-store.js.map +1 -0
  5. package/dist/scripts/market-intel/search-google-play.d.ts +7 -0
  6. package/dist/scripts/market-intel/search-google-play.d.ts.map +1 -0
  7. package/dist/scripts/market-intel/search-google-play.js +195 -0
  8. package/dist/scripts/market-intel/search-google-play.js.map +1 -0
  9. package/dist/scripts/market-intel/search-product-hunt.d.ts +7 -0
  10. package/dist/scripts/market-intel/search-product-hunt.d.ts.map +1 -0
  11. package/dist/scripts/market-intel/search-product-hunt.js +236 -0
  12. package/dist/scripts/market-intel/search-product-hunt.js.map +1 -0
  13. package/dist/scripts/market-intel/search-yc-launch.d.ts +7 -0
  14. package/dist/scripts/market-intel/search-yc-launch.d.ts.map +1 -0
  15. package/dist/scripts/market-intel/search-yc-launch.js +229 -0
  16. package/dist/scripts/market-intel/search-yc-launch.js.map +1 -0
  17. package/dist/scripts/market-intel/shared-types.d.ts +44 -0
  18. package/dist/scripts/market-intel/shared-types.d.ts.map +1 -0
  19. package/dist/scripts/market-intel/shared-types.js +63 -0
  20. package/dist/scripts/market-intel/shared-types.js.map +1 -0
  21. package/package.json +8 -9
  22. package/skills/market-intel/SKILL.md +184 -61
  23. package/skills/market-intel/scripts/search-app-store.py +117 -0
  24. package/skills/market-intel/scripts/search-google-play.py +179 -0
  25. package/skills/market-intel/scripts/search-product-hunt.py +194 -0
  26. package/skills/market-intel/scripts/search-yc-launch.py +160 -0
  27. package/dist/commands/config/index.d.ts +0 -3
  28. package/dist/commands/config/index.d.ts.map +0 -1
  29. package/dist/commands/config/index.js +0 -34
  30. package/dist/commands/config/index.js.map +0 -1
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env python3
2
+ """Product Hunt crawler via HTML scraping + __NEXT_DATA__ extraction. Zero deps.
3
+ Usage: python3 search-product-hunt.py "<keywords>" [limit]
4
+ Output: JSON CrawlResult to stdout
5
+ """
6
+
7
+ import json
8
+ import re
9
+ import sys
10
+ import urllib.request
11
+ import urllib.parse
12
+ from datetime import datetime, timezone
13
+
14
+
15
+ def safe_fetch(url, timeout=10):
16
+ """Fetch URL with timeout and user-agent header."""
17
+ req = urllib.request.Request(url, headers={
18
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
19
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
20
+ "Chrome/120.0.0.0 Safari/537.36",
21
+ "Accept-Language": "en-US,en;q=0.9",
22
+ })
23
+ return urllib.request.urlopen(req, timeout=timeout).read().decode("utf-8", errors="replace")
24
+
25
+
26
+ def truncate(text, max_len=500):
27
+ return text[:max_len] + "..." if len(text) > max_len else text
28
+
29
+
30
+ def extract_next_data(html):
31
+ """Extract __NEXT_DATA__ JSON from a Next.js page."""
32
+ match = re.search(r'<script\s+id="__NEXT_DATA__"[^>]*>(.*?)</script>', html, re.DOTALL)
33
+ if not match:
34
+ return None
35
+ try:
36
+ return json.loads(match.group(1))
37
+ except (json.JSONDecodeError, ValueError):
38
+ return None
39
+
40
+
41
+ def deep_find_posts(obj, depth=0, max_depth=8):
42
+ """Recursively find post-like objects in nested data."""
43
+ if depth > max_depth or not isinstance(obj, (dict, list)):
44
+ return []
45
+
46
+ posts = []
47
+ if isinstance(obj, list):
48
+ for item in obj:
49
+ posts.extend(deep_find_posts(item, depth + 1, max_depth))
50
+ return posts
51
+
52
+ # Check if this dict looks like a post
53
+ if "slug" in obj and "name" in obj and isinstance(obj.get("name"), str):
54
+ posts.append(obj)
55
+
56
+ for value in obj.values():
57
+ if isinstance(value, (dict, list)):
58
+ posts.extend(deep_find_posts(value, depth + 1, max_depth))
59
+
60
+ return posts
61
+
62
+
63
+ def extract_post_urls(html):
64
+ """Extract product post URLs from search results."""
65
+ urls = []
66
+ seen = set()
67
+ for match in re.finditer(r'href="(/posts/[^"?#]+)"', html):
68
+ path = match.group(1)
69
+ if path not in seen:
70
+ seen.add(path)
71
+ urls.append(f"https://www.producthunt.com{path}")
72
+ return urls
73
+
74
+
75
+ def extract_post_from_page(html, url):
76
+ """Extract product details from a Product Hunt post page."""
77
+ next_data = extract_next_data(html)
78
+
79
+ if next_data:
80
+ # Search for post object in __NEXT_DATA__
81
+ posts = deep_find_posts(next_data)
82
+ for post in posts:
83
+ name = post.get("name", "")
84
+ if not name:
85
+ continue
86
+
87
+ tagline = post.get("tagline", "")
88
+ description = post.get("description", "") or tagline
89
+ votes = post.get("votesCount", 0)
90
+ rating = post.get("reviewsRating")
91
+ review_count = post.get("reviewsCount") or votes
92
+ topics = [t.get("name", "") for t in post.get("topics", []) if isinstance(t, dict)]
93
+
94
+ return {
95
+ "name": name,
96
+ "url": url,
97
+ "description": truncate(description),
98
+ "tagline": tagline or None,
99
+ "rating": round(float(rating), 1) if rating else None,
100
+ "reviewCount": review_count or None,
101
+ "pricing": {"free": True, "other": post.get("pricing")},
102
+ "features": [t for t in topics[:5] if t],
103
+ "reviews": [],
104
+ }
105
+
106
+ # Fallback: meta tags
107
+ name_match = re.search(r'<meta\s+property="og:title"\s+content="([^"]*)"', html)
108
+ desc_match = re.search(r'<meta\s+property="og:description"\s+content="([^"]*)"', html)
109
+ name = name_match.group(1).replace(" | Product Hunt", "") if name_match else ""
110
+ description = desc_match.group(1) if desc_match else ""
111
+
112
+ if not name:
113
+ return None
114
+
115
+ return {
116
+ "name": name,
117
+ "url": url,
118
+ "description": truncate(description),
119
+ "tagline": None,
120
+ "pricing": {"free": True},
121
+ "features": [],
122
+ "reviews": [],
123
+ }
124
+
125
+
126
+ def main():
127
+ if len(sys.argv) < 2:
128
+ print(json.dumps({"error": "Usage: python3 search-product-hunt.py <keywords> [limit]"}))
129
+ sys.exit(1)
130
+
131
+ query = sys.argv[1]
132
+ limit = 5
133
+ if len(sys.argv) >= 3:
134
+ try:
135
+ limit = max(1, min(int(sys.argv[2]), 10))
136
+ except ValueError:
137
+ limit = 5
138
+
139
+ errors = []
140
+ search_url = f"https://www.producthunt.com/search?q={urllib.parse.quote(query)}"
141
+
142
+ try:
143
+ search_html = safe_fetch(search_url)
144
+ except Exception as e:
145
+ print(json.dumps({
146
+ "platform": "product_hunt", "query": query,
147
+ "timestamp": datetime.now(timezone.utc).isoformat(),
148
+ "results": [], "errors": [f"Product Hunt search error: {e}"],
149
+ }, indent=2))
150
+ return
151
+
152
+ # Try to get post URLs from search page
153
+ post_urls = extract_post_urls(search_html)[:limit]
154
+
155
+ # Fallback: extract from __NEXT_DATA__ on search page
156
+ if not post_urls:
157
+ next_data = extract_next_data(search_html)
158
+ if next_data:
159
+ posts = deep_find_posts(next_data)
160
+ post_urls = [
161
+ f"https://www.producthunt.com/posts/{p['slug']}"
162
+ for p in posts[:limit] if p.get("slug")
163
+ ]
164
+
165
+ if not post_urls:
166
+ errors.append("No post URLs found — page may require JS rendering")
167
+ print(json.dumps({
168
+ "platform": "product_hunt", "query": query,
169
+ "timestamp": datetime.now(timezone.utc).isoformat(),
170
+ "results": [], "errors": errors,
171
+ }, indent=2))
172
+ return
173
+
174
+ results = []
175
+ for url in post_urls:
176
+ try:
177
+ page_html = safe_fetch(url)
178
+ entry = extract_post_from_page(page_html, url)
179
+ if entry:
180
+ results.append(entry)
181
+ except Exception as e:
182
+ errors.append(f"Error fetching {url}: {e}")
183
+
184
+ print(json.dumps({
185
+ "platform": "product_hunt",
186
+ "query": query,
187
+ "timestamp": datetime.now(timezone.utc).isoformat(),
188
+ "results": results,
189
+ "errors": errors,
190
+ }, indent=2))
191
+
192
+
193
+ if __name__ == "__main__":
194
+ main()
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env python3
2
+ """YC Launch (Y Combinator) crawler via HTML scraping. Zero external dependencies.
3
+ Usage: python3 search-yc-launch.py "<keywords>" [limit]
4
+ Output: JSON CrawlResult to stdout
5
+ """
6
+
7
+ import json
8
+ import re
9
+ import sys
10
+ import urllib.request
11
+ import urllib.parse
12
+ from datetime import datetime, timezone
13
+
14
+
15
+ def safe_fetch(url, timeout=10):
16
+ """Fetch URL with timeout and user-agent header."""
17
+ req = urllib.request.Request(url, headers={
18
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
19
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
20
+ "Chrome/120.0.0.0 Safari/537.36",
21
+ "Accept-Language": "en-US,en;q=0.9",
22
+ })
23
+ return urllib.request.urlopen(req, timeout=timeout).read().decode("utf-8", errors="replace")
24
+
25
+
26
+ def truncate(text, max_len=500):
27
+ return text[:max_len] + "..." if len(text) > max_len else text
28
+
29
+
30
+ def extract_launch_urls(html):
31
+ """Extract launch URLs from YC launches page."""
32
+ urls = []
33
+ seen = set()
34
+ for match in re.finditer(r'href="(/launches/[^"?#]+)"', html):
35
+ path = match.group(1)
36
+ if path not in seen:
37
+ seen.add(path)
38
+ urls.append(f"https://www.ycombinator.com{path}")
39
+ return urls
40
+
41
+
42
+ def extract_launch_from_page(html, url):
43
+ """Extract launch details from a YC launch page."""
44
+ # Try JSON-LD
45
+ ld_match = re.search(
46
+ r'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>',
47
+ html, re.DOTALL
48
+ )
49
+ name = ""
50
+ description = ""
51
+
52
+ if ld_match:
53
+ try:
54
+ data = json.loads(ld_match.group(1))
55
+ name = data.get("name", "")
56
+ description = data.get("description", "")
57
+ except (json.JSONDecodeError, ValueError):
58
+ pass
59
+
60
+ # Fallback: meta tags
61
+ if not name:
62
+ m = re.search(r'<meta\s+property="og:title"\s+content="([^"]*)"', html)
63
+ if m:
64
+ name = m.group(1).replace(" | Y Combinator", "").replace("Launch YC: ", "")
65
+
66
+ if not description:
67
+ m = re.search(r'<meta\s+property="og:description"\s+content="([^"]*)"', html)
68
+ if not m:
69
+ m = re.search(r'<meta\s+name="description"\s+content="([^"]*)"', html)
70
+ if m:
71
+ description = m.group(1)
72
+
73
+ if not name:
74
+ # Try h1 tag
75
+ m = re.search(r'<h1[^>]*>([^<]+)</h1>', html)
76
+ if m:
77
+ name = m.group(1).strip()
78
+
79
+ if not name:
80
+ return None
81
+
82
+ # Try to extract longer pitch from page body
83
+ pitch = ""
84
+ # Look for main content paragraphs
85
+ for m in re.finditer(r'<p[^>]*>([^<]{50,})</p>', html):
86
+ candidate = m.group(1).strip()
87
+ if len(candidate) > len(pitch):
88
+ pitch = candidate
89
+
90
+ return {
91
+ "name": name,
92
+ "url": url,
93
+ "description": truncate(pitch or description),
94
+ "tagline": description[:150] if len(description) < 150 else None,
95
+ "rating": None,
96
+ "reviewCount": None,
97
+ "pricing": {"free": True, "other": None},
98
+ "features": [],
99
+ "reviews": [],
100
+ }
101
+
102
+
103
+ def main():
104
+ if len(sys.argv) < 2:
105
+ print(json.dumps({"error": "Usage: python3 search-yc-launch.py <keywords> [limit]"}))
106
+ sys.exit(1)
107
+
108
+ query = sys.argv[1]
109
+ limit = 5
110
+ if len(sys.argv) >= 3:
111
+ try:
112
+ limit = max(1, min(int(sys.argv[2]), 10))
113
+ except ValueError:
114
+ limit = 5
115
+
116
+ errors = []
117
+ search_url = f"https://www.ycombinator.com/launches?q={urllib.parse.quote(query)}"
118
+
119
+ try:
120
+ search_html = safe_fetch(search_url)
121
+ except Exception as e:
122
+ print(json.dumps({
123
+ "platform": "yc_launch", "query": query,
124
+ "timestamp": datetime.now(timezone.utc).isoformat(),
125
+ "results": [], "errors": [f"YC launches error: {e}"],
126
+ }, indent=2))
127
+ return
128
+
129
+ launch_urls = extract_launch_urls(search_html)[:limit]
130
+
131
+ if not launch_urls:
132
+ errors.append("No launch URLs found — page may require JS rendering")
133
+ print(json.dumps({
134
+ "platform": "yc_launch", "query": query,
135
+ "timestamp": datetime.now(timezone.utc).isoformat(),
136
+ "results": [], "errors": errors,
137
+ }, indent=2))
138
+ return
139
+
140
+ results = []
141
+ for url in launch_urls:
142
+ try:
143
+ page_html = safe_fetch(url)
144
+ entry = extract_launch_from_page(page_html, url)
145
+ if entry:
146
+ results.append(entry)
147
+ except Exception as e:
148
+ errors.append(f"Error fetching {url}: {e}")
149
+
150
+ print(json.dumps({
151
+ "platform": "yc_launch",
152
+ "query": query,
153
+ "timestamp": datetime.now(timezone.utc).isoformat(),
154
+ "results": results,
155
+ "errors": errors,
156
+ }, indent=2))
157
+
158
+
159
+ if __name__ == "__main__":
160
+ main()
@@ -1,3 +0,0 @@
1
- import type { CAC } from 'cac';
2
- export declare function registerConfig(cli: CAC): void;
3
- //# sourceMappingURL=index.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/commands/config/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,KAAK,CAAC;AAI/B,wBAAgB,cAAc,CAAC,GAAG,EAAE,GAAG,QA2BtC"}
@@ -1,34 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.registerConfig = registerConfig;
4
- const config_1 = require("../../domains/config");
5
- const logger_1 = require("../../shared/logger");
6
- function registerConfig(cli) {
7
- // pkit config → show current config
8
- cli
9
- .command('config', 'View or set pkit configuration')
10
- .action(() => {
11
- const config = (0, config_1.getConfig)();
12
- logger_1.log.plain('\npkit configuration\n');
13
- logger_1.log.plain(` defaultScope ${config.defaultScope}`);
14
- logger_1.log.plain(` toolPaths.claude ${config.toolPaths.claude}`);
15
- logger_1.log.plain(` toolPaths.antigravity ${config.toolPaths.antigravity}`);
16
- logger_1.log.plain(` toolPaths.opencode ${config.toolPaths.opencode}`);
17
- logger_1.log.plain('\nChange with: pkit config set <key> <value>');
18
- logger_1.log.plain('Keys: defaultScope, toolPaths.claude, toolPaths.antigravity, toolPaths.opencode');
19
- });
20
- // pkit config set <key> <value>
21
- cli
22
- .command('config set <key> <value>', 'Set a configuration value')
23
- .action((key, value) => {
24
- try {
25
- (0, config_1.setConfigValue)(key, value);
26
- logger_1.log.success(`Set ${key} = ${value}`);
27
- }
28
- catch (err) {
29
- logger_1.log.error(err instanceof Error ? err.message : String(err));
30
- process.exit(1);
31
- }
32
- });
33
- }
34
- //# sourceMappingURL=index.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/commands/config/index.ts"],"names":[],"mappings":";;AAIA,wCA2BC;AA9BD,iDAAiE;AACjE,gDAA0C;AAE1C,SAAgB,cAAc,CAAC,GAAQ;IACrC,oCAAoC;IACpC,GAAG;SACA,OAAO,CAAC,QAAQ,EAAE,gCAAgC,CAAC;SACnD,MAAM,CAAC,GAAG,EAAE;QACX,MAAM,MAAM,GAAG,IAAA,kBAAS,GAAE,CAAC;QAC3B,YAAG,CAAC,KAAK,CAAC,wBAAwB,CAAC,CAAC;QACpC,YAAG,CAAC,KAAK,CAAC,0BAA0B,MAAM,CAAC,YAAY,EAAE,CAAC,CAAC;QAC3D,YAAG,CAAC,KAAK,CAAC,0BAA0B,MAAM,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC,CAAC;QAC/D,YAAG,CAAC,KAAK,CAAC,4BAA4B,MAAM,CAAC,SAAS,CAAC,WAAW,EAAE,CAAC,CAAC;QACtE,YAAG,CAAC,KAAK,CAAC,0BAA0B,MAAM,CAAC,SAAS,CAAC,QAAQ,EAAE,CAAC,CAAC;QACjE,YAAG,CAAC,KAAK,CAAC,8CAA8C,CAAC,CAAC;QAC1D,YAAG,CAAC,KAAK,CAAC,iFAAiF,CAAC,CAAC;IAC/F,CAAC,CAAC,CAAC;IAEL,gCAAgC;IAChC,GAAG;SACA,OAAO,CAAC,0BAA0B,EAAE,2BAA2B,CAAC;SAChE,MAAM,CAAC,CAAC,GAAW,EAAE,KAAa,EAAE,EAAE;QACrC,IAAI,CAAC;YACH,IAAA,uBAAc,EAAC,GAAG,EAAE,KAAK,CAAC,CAAC;YAC3B,YAAG,CAAC,OAAO,CAAC,OAAO,GAAG,MAAM,KAAK,EAAE,CAAC,CAAC;QACvC,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,YAAG,CAAC,KAAK,CAAC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;YAC5D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,CAAC,CAAC;AACP,CAAC"}