spectrawl 0.1.2 → 0.2.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/README.md CHANGED
@@ -19,7 +19,14 @@ npm install spectrawl
19
19
 
20
20
  **Auth** — Persistent cookie storage (SQLite), multi-account management, automatic cookie refresh, expiry alerts.
21
21
 
22
- **Act** — Post to X, Reddit, Dev.to, Hashnode, LinkedIn, IndieHackers. Rate limiting, content dedup, dead letter queue for retries.
22
+ **Act** — 24 platform adapters covering 30+ sites:
23
+ - **Content platforms:** X, Reddit, LinkedIn, Dev.to, Hashnode, IndieHackers, Medium, Hacker News, Quora
24
+ - **Developer:** GitHub (repos, issues, releases), HuggingFace (models, datasets), Discord (bot + webhooks)
25
+ - **Launch/SEO:** Product Hunt, BetaList, AlternativeTo, SaaSHub, DevHunt, AppSumo
26
+ - **Directories:** Generic adapter for MicroLaunch, Uneed, Peerlist, Fazier, BetaPage, LaunchingNext, StartupStash, SideProjectors, TAIFT, Futurepedia, Crunchbase, G2, StackShare, YouTube
27
+ - Rate limiting, content dedup, dead letter queue for retries.
28
+
29
+ **Proxy** — Rotating proxy server. One endpoint (`localhost:8080`) for all your tools. Round-robin, random, or least-used strategies. Health checking with auto-failover.
23
30
 
24
31
  ## Quick Start
25
32
 
@@ -123,6 +130,19 @@ Configure the cascade in `spectrawl.json`:
123
130
  | Hashnode | GraphQL API | post |
124
131
  | LinkedIn | Cookie API (Voyager) | post |
125
132
  | IndieHackers | Browser automation | post, comment, upvote |
133
+ | Medium | REST API | post (markdown) |
134
+ | GitHub | REST v3 | repo, file, issue, release |
135
+ | Discord | Bot API + webhooks | send, thread |
136
+ | Product Hunt | GraphQL v2 | launch, comment, upvote |
137
+ | Hacker News | Cookie/form POST | submit, comment, upvote |
138
+ | YouTube | Data API v3 | comment, playlist, update |
139
+ | Quora | Browser automation | answer, question |
140
+ | HuggingFace | Hub API | repo, model card, upload |
141
+ | BetaList | REST API | submit |
142
+ | AlternativeTo | Browser automation | submit |
143
+ | SaaSHub | Browser automation | submit |
144
+ | DevHunt | Browser automation | submit |
145
+ | **30+ Directories** | Generic adapter | submit (MicroLaunch, Uneed, TAIFT, Futurepedia, Crunchbase, G2, etc.) |
126
146
 
127
147
  ## Configuration
128
148
 
@@ -141,10 +161,11 @@ Configure the cascade in `spectrawl.json`:
141
161
  "scrapeTtl": 24
142
162
  },
143
163
  "proxy": {
144
- "host": "proxy.example.com",
145
- "port": "8080",
146
- "username": "user",
147
- "password": "pass"
164
+ "localPort": 8080,
165
+ "strategy": "round-robin",
166
+ "upstreams": [
167
+ { "url": "http://user:pass@proxy1.example.com:8080" }
168
+ ]
148
169
  },
149
170
  "camoufox": {
150
171
  "url": "http://localhost:9869"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spectrawl",
3
- "version": "0.1.2",
4
- "description": "The unified web layer for AI agents. Search, browse, authenticate, act one tool, self-hosted, free.",
3
+ "version": "0.2.1",
4
+ "description": "The unified web layer for AI agents. Search (6 engines), stealth browse (Camoufox + Playwright), auth (cookies, multi-account), act (24 adapters, 30+ platforms), proxy rotation. Self-hosted, free.",
5
5
  "main": "src/index.js",
6
6
  "types": "index.d.ts",
7
7
  "bin": {
@@ -0,0 +1,64 @@
1
+ /**
2
+ * AlternativeTo platform adapter.
3
+ * No public API — browser automation for submitting alternatives.
4
+ * High SEO value: people search "[tool] alternative" constantly.
5
+ */
6
+ class AlternativeToAdapter {
7
+ async execute(action, params, ctx) {
8
+ switch (action) {
9
+ case 'post':
10
+ case 'submit':
11
+ return this._submit(params, ctx)
12
+ default:
13
+ throw new Error(`Unsupported AlternativeTo action: ${action}. Requires browser automation.`)
14
+ }
15
+ }
16
+
17
+ async _submit(params, ctx) {
18
+ const { name, url, description, category, tags, _cookies, _browse } = params
19
+ if (!_browse) throw new Error('AlternativeTo requires browse engine')
20
+
21
+ const page = await _browse('https://alternativeto.net/add/', {
22
+ cookies: _cookies,
23
+ getPage: true
24
+ })
25
+
26
+ const pw = page._page
27
+ if (!pw) throw new Error('AlternativeTo requires getPage access')
28
+
29
+ // Fill the submission form
30
+ await pw.waitForSelector('input[name="Name"], #Name', { timeout: 10000 })
31
+
32
+ // Name
33
+ const nameInput = await pw.$('input[name="Name"], #Name')
34
+ if (nameInput) { await nameInput.fill(name) }
35
+
36
+ // URL
37
+ const urlInput = await pw.$('input[name="Url"], input[name="url"], #Url')
38
+ if (urlInput) { await urlInput.fill(url) }
39
+
40
+ // Description
41
+ const descInput = await pw.$('textarea[name="Description"], #Description')
42
+ if (descInput) { await descInput.fill(description || '') }
43
+
44
+ // Tags
45
+ if (tags?.length) {
46
+ const tagInput = await pw.$('input[name="tags"], input[placeholder*="tag"]')
47
+ if (tagInput) {
48
+ for (const tag of tags) {
49
+ await tagInput.fill(tag)
50
+ await pw.keyboard.press('Enter')
51
+ await pw.waitForTimeout(300)
52
+ }
53
+ }
54
+ }
55
+
56
+ // Submit
57
+ await pw.click('button[type="submit"], input[type="submit"]')
58
+ await pw.waitForTimeout(3000)
59
+
60
+ return { submitted: true, name, url }
61
+ }
62
+ }
63
+
64
+ module.exports = { AlternativeToAdapter }
@@ -0,0 +1,66 @@
1
+ const https = require('https')
2
+
3
+ /**
4
+ * BetaList platform adapter.
5
+ * Uses their submission API (POST to betalist.com/api/v1/startups).
6
+ * Requires API key from betalist.com/developers
7
+ */
8
+ class BetaListAdapter {
9
+ async execute(action, params, ctx) {
10
+ switch (action) {
11
+ case 'post':
12
+ case 'submit':
13
+ return this._submit(params, ctx)
14
+ default:
15
+ throw new Error(`Unsupported BetaList action: ${action}`)
16
+ }
17
+ }
18
+
19
+ async _submit(params, ctx) {
20
+ const { name, url, tagline, description, email, account } = params
21
+ const token = this._getToken(account, ctx)
22
+
23
+ const body = JSON.stringify({
24
+ startup: {
25
+ name,
26
+ url,
27
+ one_liner: tagline,
28
+ description: description || tagline,
29
+ email: email || ''
30
+ }
31
+ })
32
+
33
+ const data = await new Promise((resolve, reject) => {
34
+ const opts = {
35
+ hostname: 'betalist.com',
36
+ path: '/api/v1/startups',
37
+ method: 'POST',
38
+ headers: {
39
+ 'Authorization': `Token ${token}`,
40
+ 'Content-Type': 'application/json',
41
+ 'Content-Length': Buffer.byteLength(body)
42
+ }
43
+ }
44
+ const req = https.request(opts, res => {
45
+ let data = ''
46
+ res.on('data', c => data += c)
47
+ res.on('end', () => {
48
+ try { resolve(JSON.parse(data)) }
49
+ catch (e) { resolve({ raw: data, status: res.statusCode }) }
50
+ })
51
+ })
52
+ req.on('error', reject)
53
+ req.write(body)
54
+ req.end()
55
+ })
56
+
57
+ return { submitted: true, ...data }
58
+ }
59
+
60
+ _getToken(account, ctx) {
61
+ if (account?.apiKey) return account.apiKey
62
+ return ctx?.config?.accounts?.betalist?.apiKey || process.env.BETALIST_API_KEY || ''
63
+ }
64
+ }
65
+
66
+ module.exports = { BetaListAdapter }
@@ -0,0 +1,56 @@
1
+ const https = require('https')
2
+
3
+ /**
4
+ * DevHunt platform adapter.
5
+ * Dev-focused Product Hunt alternative.
6
+ * Uses GitHub OAuth — you log in with GitHub and submit tools.
7
+ * Browser automation for submission.
8
+ */
9
+ class DevHuntAdapter {
10
+ async execute(action, params, ctx) {
11
+ switch (action) {
12
+ case 'post':
13
+ case 'submit':
14
+ return this._submit(params, ctx)
15
+ default:
16
+ throw new Error(`Unsupported DevHunt action: ${action}`)
17
+ }
18
+ }
19
+
20
+ async _submit(params, ctx) {
21
+ const { name, url, description, tagline, githubUrl, _cookies, _browse } = params
22
+ if (!_browse) throw new Error('DevHunt requires browse engine')
23
+
24
+ const page = await _browse('https://devhunt.org/submit', {
25
+ cookies: _cookies,
26
+ getPage: true
27
+ })
28
+
29
+ const pw = page._page
30
+ if (!pw) throw new Error('DevHunt requires getPage access')
31
+
32
+ await pw.waitForSelector('input, form', { timeout: 10000 })
33
+
34
+ // Fill form fields
35
+ const fields = [
36
+ ['input[name="name"], input[placeholder*="name"]', name],
37
+ ['input[name="url"], input[placeholder*="url"], input[placeholder*="URL"]', url],
38
+ ['input[name="tagline"], input[placeholder*="tagline"]', tagline || description?.slice(0, 100)],
39
+ ['textarea[name="description"], textarea[placeholder*="description"]', description],
40
+ ['input[name="github_url"], input[placeholder*="github"]', githubUrl]
41
+ ]
42
+
43
+ for (const [selector, value] of fields) {
44
+ if (!value) continue
45
+ const el = await pw.$(selector)
46
+ if (el) await el.fill(value)
47
+ }
48
+
49
+ await pw.click('button[type="submit"], input[type="submit"]')
50
+ await pw.waitForTimeout(3000)
51
+
52
+ return { submitted: true, name, url }
53
+ }
54
+ }
55
+
56
+ module.exports = { DevHuntAdapter }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Generic directory submission adapter.
3
+ * Handles Tier 2 launch/SaaS directories that all have similar forms:
4
+ * name, url, tagline, description, email, category.
5
+ *
6
+ * Supported directories (with custom selectors):
7
+ * - MicroLaunch, Uneed, Peerlist, Fazier, BetaPage
8
+ * - LaunchingNext, StartupStash, SideProjectors
9
+ * - TAIFT, Futurepedia, Toolify, OpenTools
10
+ * - Crunchbase, G2, StackShare (profile claim)
11
+ *
12
+ * Falls back to generic form detection for unknown directories.
13
+ */
14
+
15
+ const DIRECTORY_CONFIGS = {
16
+ microlaunch: {
17
+ submitUrl: 'https://microlaunch.net/submit',
18
+ fields: {
19
+ name: 'input[name="name"], input[placeholder*="product name"]',
20
+ url: 'input[name="url"], input[type="url"]',
21
+ tagline: 'input[name="tagline"], input[placeholder*="tagline"]',
22
+ description: 'textarea[name="description"]',
23
+ }
24
+ },
25
+ uneed: {
26
+ submitUrl: 'https://www.uneed.best/submit',
27
+ fields: {
28
+ name: 'input[name="name"]',
29
+ url: 'input[name="url"], input[type="url"]',
30
+ tagline: 'input[name="tagline"], input[name="slogan"]',
31
+ description: 'textarea[name="description"]',
32
+ }
33
+ },
34
+ peerlist: {
35
+ submitUrl: 'https://peerlist.io/projects/new',
36
+ fields: {
37
+ name: 'input[name="name"], input[placeholder*="project name"]',
38
+ url: 'input[name="url"], input[type="url"]',
39
+ tagline: 'input[name="tagline"]',
40
+ description: 'textarea[name="description"]',
41
+ }
42
+ },
43
+ fazier: {
44
+ submitUrl: 'https://fazier.com/submit',
45
+ fields: {
46
+ name: 'input[name="name"]',
47
+ url: 'input[name="url"]',
48
+ tagline: 'input[name="tagline"]',
49
+ description: 'textarea[name="description"]',
50
+ }
51
+ },
52
+ betapage: {
53
+ submitUrl: 'https://betapage.co/submit-startup',
54
+ fields: {
55
+ name: 'input[name="name"], #startup_name',
56
+ url: 'input[name="url"], input[name="website"]',
57
+ tagline: 'input[name="tagline"], input[name="oneliner"]',
58
+ description: 'textarea[name="description"]',
59
+ email: 'input[name="email"], input[type="email"]',
60
+ }
61
+ },
62
+ launchingnext: {
63
+ submitUrl: 'https://www.launchingnext.com/submit/',
64
+ fields: {
65
+ name: 'input[name="name"], input[id="name"]',
66
+ url: 'input[name="url"], input[id="url"]',
67
+ tagline: 'input[name="tagline"]',
68
+ description: 'textarea[name="description"]',
69
+ email: 'input[name="email"]',
70
+ }
71
+ },
72
+ startupstash: {
73
+ submitUrl: 'https://startupstash.com/add-listing/',
74
+ fields: {
75
+ name: 'input[name="title"], input[name="name"]',
76
+ url: 'input[name="url"], input[name="website"]',
77
+ description: 'textarea[name="description"], textarea[name="content"]',
78
+ }
79
+ },
80
+ sideprojectors: {
81
+ submitUrl: 'https://www.sideprojectors.com/project/new',
82
+ fields: {
83
+ name: 'input[name="title"], input[name="name"]',
84
+ url: 'input[name="url"]',
85
+ description: 'textarea[name="description"]',
86
+ }
87
+ },
88
+ taift: {
89
+ submitUrl: 'https://theresanaiforthat.com/submit/',
90
+ fields: {
91
+ name: 'input[name="name"], input[placeholder*="name"]',
92
+ url: 'input[name="url"], input[type="url"]',
93
+ tagline: 'input[name="tagline"], input[name="short_description"]',
94
+ description: 'textarea[name="description"]',
95
+ }
96
+ },
97
+ futurepedia: {
98
+ submitUrl: 'https://www.futurepedia.io/submit-tool',
99
+ fields: {
100
+ name: 'input[name="name"]',
101
+ url: 'input[name="url"]',
102
+ tagline: 'input[name="tagline"]',
103
+ description: 'textarea[name="description"]',
104
+ }
105
+ },
106
+ crunchbase: {
107
+ submitUrl: 'https://www.crunchbase.com/add-new',
108
+ fields: {
109
+ name: 'input[name="name"], input[placeholder*="organization"]',
110
+ url: 'input[name="url"], input[name="website"]',
111
+ description: 'textarea[name="description"], textarea[name="short_description"]',
112
+ }
113
+ },
114
+ g2: {
115
+ submitUrl: 'https://www.g2.com/products/new',
116
+ fields: {
117
+ name: 'input[name="name"], input[name="product_name"]',
118
+ url: 'input[name="url"], input[name="website"]',
119
+ description: 'textarea[name="description"]',
120
+ }
121
+ },
122
+ stackshare: {
123
+ submitUrl: 'https://stackshare.io/tools/new',
124
+ fields: {
125
+ name: 'input[name="name"]',
126
+ url: 'input[name="url"]',
127
+ tagline: 'input[name="tagline"]',
128
+ description: 'textarea[name="description"]',
129
+ }
130
+ },
131
+ appsumo: {
132
+ submitUrl: 'https://sell.appsumo.com/',
133
+ fields: {
134
+ name: 'input[name="name"], input[name="product_name"]',
135
+ url: 'input[name="url"]',
136
+ description: 'textarea[name="description"]',
137
+ }
138
+ }
139
+ }
140
+
141
+ class DirectoryAdapter {
142
+ async execute(action, params, ctx) {
143
+ switch (action) {
144
+ case 'post':
145
+ case 'submit':
146
+ return this._submit(params, ctx)
147
+ case 'list-directories':
148
+ return { directories: Object.keys(DIRECTORY_CONFIGS) }
149
+ default:
150
+ throw new Error(`Unsupported Directory action: ${action}`)
151
+ }
152
+ }
153
+
154
+ async _submit(params, ctx) {
155
+ const { directory, name, url, tagline, description, email, category, _cookies, _browse } = params
156
+ if (!_browse) throw new Error('Directory submission requires browse engine')
157
+
158
+ const config = DIRECTORY_CONFIGS[directory?.toLowerCase()]
159
+ const submitUrl = config?.submitUrl || params.submitUrl
160
+ if (!submitUrl) {
161
+ throw new Error(`Unknown directory "${directory}". Known: ${Object.keys(DIRECTORY_CONFIGS).join(', ')}. Or pass submitUrl directly.`)
162
+ }
163
+
164
+ const page = await _browse(submitUrl, {
165
+ cookies: _cookies,
166
+ getPage: true
167
+ })
168
+
169
+ const pw = page._page
170
+ if (!pw) throw new Error('Directory adapter requires getPage access')
171
+
172
+ await pw.waitForSelector('input, form', { timeout: 15000 })
173
+
174
+ // Fill known fields
175
+ const fields = config?.fields || {}
176
+ const data = { name, url, tagline, description, email, category }
177
+
178
+ for (const [field, value] of Object.entries(data)) {
179
+ if (!value) continue
180
+
181
+ // Try config selector first, then generic selectors
182
+ const selectors = [
183
+ fields[field],
184
+ `input[name="${field}"]`,
185
+ `textarea[name="${field}"]`,
186
+ `input[placeholder*="${field}"]`,
187
+ `#${field}`
188
+ ].filter(Boolean)
189
+
190
+ for (const sel of selectors) {
191
+ try {
192
+ const el = await pw.$(sel)
193
+ if (el) {
194
+ const tag = await el.evaluate(e => e.tagName.toLowerCase())
195
+ if (tag === 'textarea') {
196
+ await el.fill(value)
197
+ } else {
198
+ await el.fill(value)
199
+ }
200
+ break
201
+ }
202
+ } catch (e) { /* selector didn't match, try next */ }
203
+ }
204
+ }
205
+
206
+ // Try to submit
207
+ try {
208
+ await pw.click('button[type="submit"], input[type="submit"], button:has-text("Submit"), button:has-text("Launch"), button:has-text("Add")')
209
+ await pw.waitForTimeout(3000)
210
+ } catch (e) {
211
+ // No submit button found — might need manual submission
212
+ return { filled: true, submitted: false, note: 'Form filled but submit button not found', directory }
213
+ }
214
+
215
+ return { submitted: true, directory: directory || submitUrl, name, url }
216
+ }
217
+ }
218
+
219
+ module.exports = { DirectoryAdapter, DIRECTORY_CONFIGS }
@@ -0,0 +1,131 @@
1
+ const https = require('https')
2
+
3
+ /**
4
+ * Discord platform adapter.
5
+ * Uses Discord Bot API or webhooks.
6
+ * Bot token for full access, webhooks for simple posting.
7
+ */
8
+ class DiscordAdapter {
9
+ async execute(action, params, ctx) {
10
+ switch (action) {
11
+ case 'post':
12
+ case 'send':
13
+ return this._sendMessage(params, ctx)
14
+ case 'webhook':
15
+ return this._webhook(params, ctx)
16
+ case 'create-thread':
17
+ return this._createThread(params, ctx)
18
+ default:
19
+ throw new Error(`Unsupported Discord action: ${action}`)
20
+ }
21
+ }
22
+
23
+ async _sendMessage(params, ctx) {
24
+ const { channelId, content, embeds, account } = params
25
+ const token = this._getToken(account, ctx)
26
+
27
+ const body = { content: content || '' }
28
+ if (embeds) body.embeds = embeds
29
+
30
+ const data = await discordApi('POST', `/channels/${channelId}/messages`, body, token)
31
+ return { messageId: data.id, channelId: data.channel_id }
32
+ }
33
+
34
+ async _webhook(params, ctx) {
35
+ const { webhookUrl, content, username, avatarUrl, embeds } = params
36
+ if (!webhookUrl) throw new Error('Discord webhook URL required')
37
+
38
+ const body = { content: content || '' }
39
+ if (username) body.username = username
40
+ if (avatarUrl) body.avatar_url = avatarUrl
41
+ if (embeds) body.embeds = embeds
42
+
43
+ const urlObj = new URL(webhookUrl)
44
+ const data = await new Promise((resolve, reject) => {
45
+ const json = JSON.stringify(body)
46
+ const opts = {
47
+ hostname: urlObj.hostname,
48
+ path: urlObj.pathname + urlObj.search,
49
+ method: 'POST',
50
+ headers: {
51
+ 'Content-Type': 'application/json',
52
+ 'Content-Length': Buffer.byteLength(json)
53
+ }
54
+ }
55
+ const req = https.request(opts, res => {
56
+ let data = ''
57
+ res.on('data', c => data += c)
58
+ res.on('end', () => {
59
+ if (res.statusCode === 204) return resolve({ sent: true })
60
+ try { resolve(JSON.parse(data)) }
61
+ catch (e) { resolve({ sent: res.statusCode < 300 }) }
62
+ })
63
+ })
64
+ req.on('error', reject)
65
+ req.write(json)
66
+ req.end()
67
+ })
68
+
69
+ return { sent: true, ...data }
70
+ }
71
+
72
+ async _createThread(params, ctx) {
73
+ const { channelId, name, content, account } = params
74
+ const token = this._getToken(account, ctx)
75
+
76
+ const thread = await discordApi('POST', `/channels/${channelId}/threads`, {
77
+ name,
78
+ type: 11, // PUBLIC_THREAD
79
+ auto_archive_duration: 1440
80
+ }, token)
81
+
82
+ if (content) {
83
+ await discordApi('POST', `/channels/${thread.id}/messages`, { content }, token)
84
+ }
85
+
86
+ return { threadId: thread.id, name: thread.name }
87
+ }
88
+
89
+ _getToken(account, ctx) {
90
+ if (account?.botToken) return `Bot ${account.botToken}`
91
+ const token = ctx?.config?.accounts?.discord?.botToken ||
92
+ process.env.DISCORD_BOT_TOKEN
93
+ if (!token) throw new Error('Discord bot token required')
94
+ return `Bot ${token}`
95
+ }
96
+ }
97
+
98
+ function discordApi(method, path, body, token) {
99
+ return new Promise((resolve, reject) => {
100
+ const json = JSON.stringify(body)
101
+ const opts = {
102
+ hostname: 'discord.com',
103
+ path: `/api/v10${path}`,
104
+ method,
105
+ headers: {
106
+ 'Authorization': token,
107
+ 'Content-Type': 'application/json',
108
+ 'Content-Length': Buffer.byteLength(json),
109
+ 'User-Agent': 'Spectrawl/0.1.0'
110
+ }
111
+ }
112
+ const req = https.request(opts, res => {
113
+ let data = ''
114
+ res.on('data', c => data += c)
115
+ res.on('end', () => {
116
+ try {
117
+ const parsed = data ? JSON.parse(data) : {}
118
+ if (res.statusCode >= 400) {
119
+ reject(new Error(`Discord API ${res.statusCode}: ${parsed.message || data.slice(0, 200)}`))
120
+ } else { resolve(parsed) }
121
+ } catch (e) { reject(new Error(`Invalid Discord response: ${data.slice(0, 200)}`)) }
122
+ })
123
+ })
124
+ req.on('error', reject)
125
+ req.setTimeout(15000, () => { req.destroy(); reject(new Error('Discord API timeout')) })
126
+ req.write(json)
127
+ req.end()
128
+ })
129
+ }
130
+
131
+ module.exports = { DiscordAdapter }