@veyralabs/skills 0.4.1 → 0.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.
@@ -0,0 +1,228 @@
1
+ """
2
+ Validation experiment generator.
3
+ Based on Lean Startup + Mom Test methodology.
4
+ Outputs concrete, actionable experiments — not analysis.
5
+ """
6
+
7
+ EXPERIMENTS = {
8
+ "cold_outreach": {
9
+ "name": "Cold Outreach Test",
10
+ "type": "discovery",
11
+ "cost": "0€",
12
+ "duration": "5-7 days",
13
+ "effort": "medium",
14
+ "goal": "Validate problem exists and find early adopters",
15
+ "process": [
16
+ "Identify 50 potential customers on LinkedIn, Reddit, or niche communities",
17
+ "Send short message: 'I'm researching [problem area] — would you spare 15 min?'",
18
+ "Do NOT pitch. Ask about their current process and pain.",
19
+ "Apply Mom Test: ask about past behavior, not future intentions",
20
+ ],
21
+ "metric": "Response rate + calls booked",
22
+ "success_criteria": "≥10% response rate, ≥3 discovery calls completed",
23
+ "red_flags": [
24
+ "Everyone says 'great idea' but nobody wants a call",
25
+ "People say they'd pay but won't commit to a free beta",
26
+ "You have to explain the problem before they understand it",
27
+ ],
28
+ "mom_test_questions": [
29
+ "Tell me how you currently handle [problem]. Walk me through your last time.",
30
+ "What's the most frustrating part of that process?",
31
+ "Have you tried other solutions? What happened?",
32
+ "How much time/money does this cost you per month right now?",
33
+ "What would you do if this problem disappeared tomorrow?",
34
+ ],
35
+ "bad_questions_to_avoid": [
36
+ "Would you use a tool that does X?",
37
+ "Do you think this is a good idea?",
38
+ "Would you pay $X for this?",
39
+ "How much would you pay for this?",
40
+ ],
41
+ },
42
+
43
+ "fake_door": {
44
+ "name": "Fake Door Test",
45
+ "type": "demand_signal",
46
+ "cost": "0-30€",
47
+ "duration": "7-14 days",
48
+ "effort": "low",
49
+ "goal": "Measure real demand before building anything",
50
+ "process": [
51
+ "Build a landing page (Carrd.co free, 30 min)",
52
+ "Describe the value proposition clearly — no features, only outcome",
53
+ "Add a CTA button: 'Join Waitlist' or 'Get Early Access'",
54
+ "When clicked: show 'Thanks, we'll notify you' — collect email",
55
+ "Drive traffic: 2-3 Reddit posts in relevant communities + LinkedIn",
56
+ "Track: page visits, CTA clicks, emails collected",
57
+ ],
58
+ "metric": "CTA click-through rate (CTR)",
59
+ "success_criteria": "≥5% CTR from cold traffic, ≥50 emails in 14 days",
60
+ "tools": [
61
+ "Carrd.co — free landing pages",
62
+ "Tally.so — free forms with email collection",
63
+ "Google Analytics (free) — traffic tracking",
64
+ "Plausible.io — privacy-friendly alternative",
65
+ ],
66
+ "headline_formula": "[Outcome they want] without [current pain]",
67
+ "subheadline_formula": "[Product name] helps [target customer] [achieve outcome] by [key mechanism]",
68
+ "red_flags": [
69
+ "CTR below 2% consistently",
70
+ "High visits but nobody clicks CTA",
71
+ "Traffic only from your own network — zero cold traffic",
72
+ ],
73
+ },
74
+
75
+ "waitlist": {
76
+ "name": "Waitlist Campaign",
77
+ "type": "demand_signal",
78
+ "cost": "0€",
79
+ "duration": "14-30 days",
80
+ "effort": "medium",
81
+ "goal": "Build an audience before building the product",
82
+ "process": [
83
+ "Build landing with value proposition + email signup",
84
+ "Write 3-5 posts for relevant communities (Reddit, LinkedIn, Twitter)",
85
+ "Frame as 'I'm building X because Y — who's interested?'",
86
+ "Respond to every comment — this is customer research",
87
+ "Track: emails per day, most common questions, what resonates",
88
+ ],
89
+ "metric": "Email signups per week",
90
+ "success_criteria": "100 organic signups in 30 days without paid ads",
91
+ "tools": [
92
+ "Beehiiv (free for <2500 subs) — email list",
93
+ "Carrd.co — landing",
94
+ "Reddit (organic, zero cost)",
95
+ "LinkedIn (organic, zero cost)",
96
+ ],
97
+ "red_flags": [
98
+ "Only your friends/connections sign up",
99
+ "High unsubscribe rate on first email",
100
+ "Nobody shares it organically",
101
+ ],
102
+ },
103
+
104
+ "concierge_mvp": {
105
+ "name": "Concierge MVP",
106
+ "type": "value_validation",
107
+ "cost": "0€",
108
+ "duration": "2-4 weeks",
109
+ "effort": "high",
110
+ "goal": "Deliver the core value manually to 3-5 real users",
111
+ "process": [
112
+ "Find 3-5 people with the problem (from outreach or waitlist)",
113
+ "Offer to do the service manually for free",
114
+ "Do it by hand: spreadsheets, email, calls, manual research",
115
+ "Observe where they get value, where they get frustrated",
116
+ "At the end: 'Would you pay X/month for this if it was automated?'",
117
+ ],
118
+ "metric": "Willingness to pay (real commitment, not hypothetical)",
119
+ "success_criteria": "≥3/5 users say yes to paying target price, 1+ pre-orders",
120
+ "key_question": "If this service disappeared tomorrow, what would you do?",
121
+ "what_to_track": [
122
+ "Which parts of the manual process they value most",
123
+ "Which parts they don't care about",
124
+ "How often they'd use it",
125
+ "What would make them stop using it",
126
+ "Who else they'd recommend it to",
127
+ ],
128
+ "red_flags": [
129
+ "'I'd use it but can't commit right now'",
130
+ "They use it but never refer anyone",
131
+ "The manual process takes so long it's not viable to automate",
132
+ ],
133
+ },
134
+
135
+ "smoke_test_ads": {
136
+ "name": "Paid Smoke Test",
137
+ "type": "demand_signal",
138
+ "cost": "30-100€",
139
+ "duration": "7 days",
140
+ "effort": "medium",
141
+ "goal": "Measure demand with cold paid traffic — no bias from network",
142
+ "process": [
143
+ "Set up landing page with single CTA",
144
+ "Run €30-50 in Meta or Google Ads to target audience",
145
+ "B2C: Facebook/Instagram interest targeting",
146
+ "B2B: LinkedIn job title targeting (more expensive, ~€5-10/click)",
147
+ "Measure CPL (cost per lead) — this is your true demand signal",
148
+ ],
149
+ "metric": "Cost Per Lead (CPL)",
150
+ "success_criteria": "B2C CPL < 5€, B2B CPL < 40€",
151
+ "tools": [
152
+ "Meta Ads Manager (€30 minimum budget)",
153
+ "Google Ads (€10/day minimum)",
154
+ "LinkedIn Ads (most expensive — B2B only, €15-50/click)",
155
+ ],
156
+ "red_flags": [
157
+ "CPL 5x higher than benchmark — market doesn't respond to messaging",
158
+ "Clicks but no conversions — landing page or offer problem",
159
+ "Can't target audience precisely — unclear ICP",
160
+ ],
161
+ },
162
+ }
163
+
164
+
165
+ def generate_experiments(
166
+ idea: str,
167
+ target_customer: str,
168
+ market_type: str = "b2c",
169
+ competition_level: str = "medium",
170
+ budget: str = "zero",
171
+ ) -> list[dict]:
172
+ """
173
+ Generate 3 prioritized validation experiments.
174
+ Always starts with the cheapest, highest-signal experiments first.
175
+ """
176
+ selected = []
177
+
178
+ # Always: Cold outreach first (zero cost, real signal)
179
+ exp = EXPERIMENTS["cold_outreach"].copy()
180
+ exp["priority"] = 1
181
+ exp["customized_for"] = idea
182
+ exp["target"] = target_customer
183
+ selected.append(exp)
184
+
185
+ # Always: Fake door (low cost, measurable)
186
+ exp = EXPERIMENTS["fake_door"].copy()
187
+ exp["priority"] = 2
188
+ exp["customized_for"] = idea
189
+ selected.append(exp)
190
+
191
+ # B2B with budget: concierge MVP
192
+ # B2C with budget: waitlist
193
+ if market_type.lower() == "b2b" or competition_level == "high":
194
+ exp = EXPERIMENTS["concierge_mvp"].copy()
195
+ exp["priority"] = 3
196
+ exp["customized_for"] = idea
197
+ exp["target"] = target_customer
198
+ selected.append(exp)
199
+ else:
200
+ exp = EXPERIMENTS["waitlist"].copy()
201
+ exp["priority"] = 3
202
+ exp["customized_for"] = idea
203
+ selected.append(exp)
204
+
205
+ return selected
206
+
207
+
208
+ def format_experiment_output(experiments: list[dict], idea: str) -> str:
209
+ """Format experiments as markdown output."""
210
+ lines = [f"# Validation Experiments — {idea}\n"]
211
+ lines.append("Start with Experiment 1. Only move to the next if the previous fails.\n")
212
+
213
+ for exp in experiments:
214
+ lines.append(f"## Experiment {exp['priority']}: {exp['name']}")
215
+ lines.append(f"**Cost:** {exp['cost']} | **Duration:** {exp['duration']} | **Effort:** {exp['effort']}")
216
+ lines.append(f"\n**Goal:** {exp['goal']}\n")
217
+ lines.append("**Process:**")
218
+ for step in exp.get("process", []):
219
+ lines.append(f"- {step}")
220
+ lines.append(f"\n**Metric:** {exp['metric']}")
221
+ lines.append(f"**Success:** {exp['success_criteria']}")
222
+ if exp.get("red_flags"):
223
+ lines.append("\n**Red flags to watch:**")
224
+ for flag in exp["red_flags"]:
225
+ lines.append(f"- {flag}")
226
+ lines.append("")
227
+
228
+ return "\n".join(lines)
@@ -0,0 +1,194 @@
1
+ """
2
+ Scrapling wrapper for venture-analyst.
3
+ Handles competitor pages, pricing pages, and review sites.
4
+ Falls back gracefully on blocked sites.
5
+ """
6
+ import re
7
+ from typing import Optional
8
+
9
+
10
+ def scrape_competitor(url: str) -> Optional[dict]:
11
+ """
12
+ Scrape a competitor website.
13
+ Tries basic Fetcher first, falls back to StealthyFetcher for Cloudflare.
14
+ """
15
+ try:
16
+ from scrapling import Fetcher
17
+ page = Fetcher(auto_match=False).get(url, timeout=15, stealthy_headers=True)
18
+ if _is_empty(page):
19
+ return _scrape_stealthy(url)
20
+ return _parse_competitor_page(url, page)
21
+ except Exception:
22
+ return _scrape_stealthy(url)
23
+
24
+
25
+ def _scrape_stealthy(url: str) -> Optional[dict]:
26
+ """StealthyFetcher for JS-rendered or Cloudflare-protected sites."""
27
+ try:
28
+ from scrapling import StealthyFetcher
29
+ page = StealthyFetcher(auto_match=False).get(url, timeout=20, network_idle=True)
30
+ if _is_empty(page):
31
+ return None
32
+ return _parse_competitor_page(url, page)
33
+ except Exception:
34
+ return None
35
+
36
+
37
+ def _is_empty(page) -> bool:
38
+ if page is None:
39
+ return True
40
+ try:
41
+ return len(page.get_all_text()) < 100
42
+ except Exception:
43
+ return True
44
+
45
+
46
+ def _parse_competitor_page(url: str, page) -> dict:
47
+ return {
48
+ "url": url,
49
+ "title": _get_title(page),
50
+ "tagline": _get_tagline(page),
51
+ "description": _get_meta_description(page),
52
+ "pricing": _get_pricing(page),
53
+ "features": _get_features(page),
54
+ "tech_stack": _get_tech_signals(page),
55
+ }
56
+
57
+
58
+ def _get_title(page) -> str:
59
+ try:
60
+ return page.css("title").first.text.strip()[:120]
61
+ except Exception:
62
+ return ""
63
+
64
+
65
+ def _get_tagline(page) -> str:
66
+ """Extract the main hero headline."""
67
+ selectors = ["h1", "[class*='hero'] h1", "header h1", "[class*='headline']", "[class*='tagline']"]
68
+ for sel in selectors:
69
+ try:
70
+ el = page.css(sel).first
71
+ if el:
72
+ text = el.text.strip()
73
+ if 5 < len(text) < 200:
74
+ return text
75
+ except Exception:
76
+ pass
77
+ return ""
78
+
79
+
80
+ def _get_meta_description(page) -> str:
81
+ try:
82
+ el = page.css('meta[name="description"]').first
83
+ return (el.attrs.get("content") or "")[:300]
84
+ except Exception:
85
+ return ""
86
+
87
+
88
+ def _get_pricing(page) -> dict:
89
+ """Extract pricing signals from page text."""
90
+ try:
91
+ text = page.get_all_text()
92
+ except Exception:
93
+ return {}
94
+
95
+ prices = re.findall(
96
+ r'[\$€£]\s*(\d+(?:[.,]\d+)?)\s*(?:/\s*(?:mo|month|year|yr|user|seat))?',
97
+ text,
98
+ re.IGNORECASE,
99
+ )
100
+ model_keywords = [
101
+ "free", "freemium", "free trial", "per user", "per month", "per year",
102
+ "enterprise", "custom pricing", "contact us", "flat rate",
103
+ ]
104
+ detected_model = [kw for kw in model_keywords if kw.lower() in text.lower()]
105
+
106
+ return {
107
+ "prices": prices[:6],
108
+ "model_signals": detected_model[:4],
109
+ "has_free_tier": any(kw in ["free", "freemium", "free trial"] for kw in detected_model),
110
+ }
111
+
112
+
113
+ def _get_features(page) -> list[str]:
114
+ """Extract feature descriptions from bullets and feature sections."""
115
+ features = []
116
+ try:
117
+ for el in page.css("li, [class*='feature'], [class*='benefit']")[:30]:
118
+ try:
119
+ text = el.text.strip()
120
+ if 10 < len(text) < 150 and not text.startswith(("©", "Terms", "Privacy")):
121
+ features.append(text)
122
+ except Exception:
123
+ pass
124
+ except Exception:
125
+ pass
126
+ return features[:12]
127
+
128
+
129
+ def _get_tech_signals(page) -> list[str]:
130
+ """Detect tech stack from script URLs and meta tags."""
131
+ signals = []
132
+ TECH_PATTERNS = {
133
+ "React": r"react",
134
+ "Vue": r"vue\.js|vuejs",
135
+ "Angular": r"angular",
136
+ "Next.js": r"_next/",
137
+ "Stripe": r"js\.stripe\.com",
138
+ "Intercom": r"intercom",
139
+ "Segment": r"segment\.com",
140
+ "HubSpot": r"hubspot",
141
+ "Webflow": r"webflow",
142
+ "Shopify": r"shopify",
143
+ }
144
+ try:
145
+ html = str(page)
146
+ for tech, pattern in TECH_PATTERNS.items():
147
+ if re.search(pattern, html, re.IGNORECASE):
148
+ signals.append(tech)
149
+ except Exception:
150
+ pass
151
+ return signals
152
+
153
+
154
+ def scrape_g2_reviews(product_url: str, max_pages: int = 2) -> list[dict]:
155
+ """
156
+ Scrape G2 reviews. Requires StealthyFetcher + Playwright.
157
+ G2 is Cloudflare-protected — basic Fetcher will fail.
158
+ """
159
+ reviews = []
160
+ try:
161
+ from scrapling import StealthyFetcher
162
+ for page_num in range(1, max_pages + 1):
163
+ url = f"{product_url}?page={page_num}"
164
+ page = StealthyFetcher(auto_match=False).get(url, timeout=25, network_idle=True)
165
+ if _is_empty(page):
166
+ break
167
+ for review in page.css("[itemprop='review'], .paper--white, [class*='review-card']"):
168
+ try:
169
+ reviews.append({
170
+ "source": "g2",
171
+ "pros": _safe_text(review, "[class*='pros']"),
172
+ "cons": _safe_text(review, "[class*='cons']"),
173
+ "rating": _safe_attr(review, "[itemprop='ratingValue']", "content"),
174
+ "title": _safe_text(review, "h3, [class*='title']"),
175
+ })
176
+ except Exception:
177
+ pass
178
+ except Exception:
179
+ pass
180
+ return reviews
181
+
182
+
183
+ def _safe_text(parent, selector: str) -> str:
184
+ try:
185
+ return parent.css(selector).first.text.strip()[:300]
186
+ except Exception:
187
+ return ""
188
+
189
+
190
+ def _safe_attr(parent, selector: str, attr: str) -> str:
191
+ try:
192
+ return parent.css(selector).first.attrs.get(attr, "")
193
+ except Exception:
194
+ return ""