spectrawl 0.1.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,251 @@
1
+ const { smartFill } = require('../form-filler')
2
+
3
+ /**
4
+ * IndieHackers platform adapter.
5
+ * Browser-only — no API available. Uses Playwright page automation.
6
+ *
7
+ * IH is an Ember.js app. Key patterns:
8
+ * - IDs are dynamic (ember48, ember49, etc.)
9
+ * - Must use class/placeholder selectors instead
10
+ * - New post page: /new-post (requires auth)
11
+ * - Groups: selected via dropdown before posting
12
+ * - Editor: contentEditable div (Ember component)
13
+ */
14
+ class IHAdapter {
15
+ constructor() {
16
+ this.baseUrl = 'https://www.indiehackers.com'
17
+ // Timeouts for various operations
18
+ this.navTimeout = 30000
19
+ this.actionTimeout = 10000
20
+ }
21
+
22
+ async execute(action, params, ctx) {
23
+ switch (action) {
24
+ case 'post':
25
+ return this._post(params, ctx)
26
+ case 'comment':
27
+ return this._comment(params, ctx)
28
+ case 'upvote':
29
+ return this._upvote(params, ctx)
30
+ default:
31
+ throw new Error(`Unsupported IH action: ${action}`)
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Create a new post on IndieHackers.
37
+ * @param {object} params - { title, body, group?, account, _cookies }
38
+ */
39
+ async _post(params, ctx) {
40
+ const { title, body, group, account, _cookies } = params
41
+
42
+ if (!_cookies) {
43
+ throw new Error(`No auth for IH/${account}. Run: spectrawl login ih --account ${account}`)
44
+ }
45
+ if (!title) throw new Error('IH post requires a title')
46
+ if (!body) throw new Error('IH post requires a body')
47
+
48
+ // Get a raw page from the browse engine
49
+ const { page, context } = await ctx.browse.getPage({
50
+ _cookies,
51
+ url: `${this.baseUrl}/new-post`
52
+ })
53
+
54
+ try {
55
+ // Check if we got redirected to sign-in (cookies expired)
56
+ if (page.url().includes('/sign-in')) {
57
+ throw new Error(`IH cookies expired for ${account}. Re-authenticate with: spectrawl login ih --account ${account}`)
58
+ }
59
+
60
+ // Wait for the post form to load
61
+ await page.waitForTimeout(2000)
62
+
63
+ // Select group if specified
64
+ if (group) {
65
+ await this._selectGroup(page, group)
66
+ }
67
+
68
+ // Fill title — IH uses an input with specific class
69
+ const titleSelector = 'input[placeholder*="title" i], input[placeholder*="Title" i], .post-form__title input, input.ember-text-field[type="text"]'
70
+ await page.waitForSelector(titleSelector, { timeout: this.actionTimeout })
71
+ await smartFill(page, titleSelector, title)
72
+ await page.waitForTimeout(300 + Math.random() * 500)
73
+
74
+ // Fill body — IH uses contentEditable div for the editor
75
+ const bodySelector = '[contenteditable="true"], .post-form__body [contenteditable], .ember-view[contenteditable], .ProseMirror, .ql-editor, textarea.ember-text-area'
76
+ await page.waitForSelector(bodySelector, { timeout: this.actionTimeout })
77
+ await smartFill(page, bodySelector, body)
78
+ await page.waitForTimeout(500 + Math.random() * 1000)
79
+
80
+ // Click submit/publish button
81
+ const submitSelector = 'button:has-text("Publish"), button:has-text("Post"), button:has-text("Submit"), button[type="submit"]'
82
+ await page.waitForSelector(submitSelector, { timeout: this.actionTimeout })
83
+
84
+ // Human-like pause before clicking submit
85
+ await page.waitForTimeout(1000 + Math.random() * 2000)
86
+ await page.click(submitSelector)
87
+
88
+ // Wait for navigation (post created → redirects to post page)
89
+ await page.waitForTimeout(3000)
90
+
91
+ // Verify we're on a post page
92
+ const finalUrl = page.url()
93
+ const postCreated = finalUrl.includes('/post/') || finalUrl.includes('/product/')
94
+
95
+ if (!postCreated) {
96
+ // Check for error messages
97
+ const errorText = await page.evaluate(() => {
98
+ const err = document.querySelector('.error, .alert, .flash-message, [class*="error"]')
99
+ return err ? err.innerText : null
100
+ })
101
+
102
+ if (errorText) {
103
+ throw new Error(`IH post failed: ${errorText}`)
104
+ }
105
+
106
+ // Might still be processing
107
+ await page.waitForTimeout(3000)
108
+ const retryUrl = page.url()
109
+ if (!retryUrl.includes('/post/') && !retryUrl.includes('/product/')) {
110
+ throw new Error(`IH post may have failed. Final URL: ${retryUrl}`)
111
+ }
112
+ }
113
+
114
+ return {
115
+ url: page.url(),
116
+ title: await page.title(),
117
+ platform: 'ih'
118
+ }
119
+ } finally {
120
+ await page.close()
121
+ await context.close()
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Comment on an IH post.
127
+ * @param {object} params - { postUrl, text, account, _cookies }
128
+ */
129
+ async _comment(params, ctx) {
130
+ const { postUrl, text, account, _cookies } = params
131
+
132
+ if (!_cookies) {
133
+ throw new Error(`No auth for IH/${account}. Run: spectrawl login ih --account ${account}`)
134
+ }
135
+ if (!postUrl) throw new Error('IH comment requires postUrl')
136
+ if (!text) throw new Error('IH comment requires text')
137
+
138
+ const { page, context } = await ctx.browse.getPage({
139
+ _cookies,
140
+ url: postUrl
141
+ })
142
+
143
+ try {
144
+ if (page.url().includes('/sign-in')) {
145
+ throw new Error(`IH cookies expired for ${account}. Re-authenticate.`)
146
+ }
147
+
148
+ await page.waitForTimeout(2000)
149
+
150
+ // Find and click the comment input area
151
+ const commentSelector = '[contenteditable="true"], textarea[placeholder*="comment" i], textarea[placeholder*="reply" i], .comment-form [contenteditable], .ProseMirror, .ql-editor'
152
+
153
+ // Scroll to comment area first
154
+ await page.evaluate(() => {
155
+ const commentArea = document.querySelector('[contenteditable="true"], textarea[placeholder*="comment" i]')
156
+ if (commentArea) commentArea.scrollIntoView({ behavior: 'smooth', block: 'center' })
157
+ })
158
+ await page.waitForTimeout(1000)
159
+
160
+ await page.waitForSelector(commentSelector, { timeout: this.actionTimeout })
161
+ await smartFill(page, commentSelector, text)
162
+ await page.waitForTimeout(500 + Math.random() * 1000)
163
+
164
+ // Submit comment
165
+ const submitSelector = 'button:has-text("Reply"), button:has-text("Comment"), button:has-text("Submit"), button:has-text("Post")'
166
+ await page.click(submitSelector)
167
+ await page.waitForTimeout(3000)
168
+
169
+ return {
170
+ url: postUrl,
171
+ commented: true,
172
+ platform: 'ih'
173
+ }
174
+ } finally {
175
+ await page.close()
176
+ await context.close()
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Upvote an IH post.
182
+ */
183
+ async _upvote(params, ctx) {
184
+ const { postUrl, account, _cookies } = params
185
+
186
+ if (!_cookies) {
187
+ throw new Error(`No auth for IH/${account}. Run: spectrawl login ih --account ${account}`)
188
+ }
189
+
190
+ const { page, context } = await ctx.browse.getPage({
191
+ _cookies,
192
+ url: postUrl
193
+ })
194
+
195
+ try {
196
+ if (page.url().includes('/sign-in')) {
197
+ throw new Error(`IH cookies expired for ${account}. Re-authenticate.`)
198
+ }
199
+
200
+ await page.waitForTimeout(2000)
201
+
202
+ // Find upvote button
203
+ const upvoteSelector = 'button[class*="upvote"], .upvote-button, [data-test*="upvote"], button:has-text("upvote")'
204
+ await page.waitForSelector(upvoteSelector, { timeout: this.actionTimeout })
205
+ await page.click(upvoteSelector)
206
+ await page.waitForTimeout(1000)
207
+
208
+ return {
209
+ url: postUrl,
210
+ upvoted: true,
211
+ platform: 'ih'
212
+ }
213
+ } finally {
214
+ await page.close()
215
+ await context.close()
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Select a group/community from the dropdown.
221
+ */
222
+ async _selectGroup(page, group) {
223
+ // IH group selector varies — try common patterns
224
+ const groupSelector = 'select[class*="group"], .group-selector, [data-test*="group"], button:has-text("Select a group")'
225
+
226
+ try {
227
+ await page.waitForSelector(groupSelector, { timeout: 5000 })
228
+
229
+ // If it's a select element
230
+ const isSelect = await page.evaluate((sel) => {
231
+ const el = document.querySelector(sel)
232
+ return el?.tagName === 'SELECT'
233
+ }, groupSelector)
234
+
235
+ if (isSelect) {
236
+ await page.selectOption(groupSelector, { label: group })
237
+ } else {
238
+ // Click to open dropdown, then find the option
239
+ await page.click(groupSelector)
240
+ await page.waitForTimeout(500)
241
+ await page.click(`text="${group}"`)
242
+ }
243
+
244
+ await page.waitForTimeout(500)
245
+ } catch (e) {
246
+ console.log(`Could not select group "${group}": ${e.message}. Posting without group.`)
247
+ }
248
+ }
249
+ }
250
+
251
+ module.exports = { IHAdapter }
@@ -0,0 +1,106 @@
1
+ const https = require('https')
2
+
3
+ /**
4
+ * LinkedIn platform adapter.
5
+ * Uses Cookie API — LinkedIn has aggressive bot detection.
6
+ * OAuth available but requires LinkedIn app approval (hard to get).
7
+ */
8
+ class LinkedInAdapter {
9
+ async execute(action, params, ctx) {
10
+ switch (action) {
11
+ case 'post':
12
+ return this._post(params, ctx)
13
+ default:
14
+ throw new Error(`Unsupported LinkedIn action: ${action}`)
15
+ }
16
+ }
17
+
18
+ async _post(params, ctx) {
19
+ const { text, account, _cookies } = params
20
+
21
+ if (!_cookies) {
22
+ throw new Error(`No auth for LinkedIn/${account}. Run: spectrawl login linkedin --account ${account}`)
23
+ }
24
+
25
+ const csrfToken = _cookies.find(c => c.name === 'JSESSIONID')?.value?.replace(/"/g, '')
26
+ if (!csrfToken) throw new Error('Missing JSESSIONID in LinkedIn cookies')
27
+
28
+ const cookieStr = _cookies.map(c => `${c.name}=${c.value}`).join('; ')
29
+
30
+ // Get member URN first
31
+ const me = await fetchJson('https://www.linkedin.com/voyager/api/me', {
32
+ 'Cookie': cookieStr,
33
+ 'Csrf-Token': csrfToken
34
+ })
35
+
36
+ const memberUrn = me.miniProfile?.entityUrn || me.entityUrn
37
+ if (!memberUrn) throw new Error('Could not get LinkedIn member URN')
38
+
39
+ // Create post via Voyager API
40
+ const body = JSON.stringify({
41
+ visibleToConnectionsOnly: false,
42
+ externalAudienceProviders: [],
43
+ commentaryV2: { text, attributes: [] },
44
+ origin: 'FEED',
45
+ allowedCommentersScope: 'ALL',
46
+ media: []
47
+ })
48
+
49
+ const data = await postJson(
50
+ 'https://www.linkedin.com/voyager/api/contentcreation/normalizedContent',
51
+ body,
52
+ {
53
+ 'Cookie': cookieStr,
54
+ 'Csrf-Token': csrfToken,
55
+ 'Content-Type': 'application/json',
56
+ 'X-Li-Lang': 'en_US',
57
+ 'X-Restli-Protocol-Version': '2.0.0'
58
+ }
59
+ )
60
+
61
+ return { postId: data.urn || data.value?.urn, url: null }
62
+ }
63
+ }
64
+
65
+ function fetchJson(url, headers) {
66
+ return new Promise((resolve, reject) => {
67
+ const urlObj = new URL(url)
68
+ https.get({
69
+ hostname: urlObj.hostname,
70
+ path: urlObj.pathname + urlObj.search,
71
+ headers: { ...headers, 'User-Agent': 'Mozilla/5.0' }
72
+ }, res => {
73
+ let data = ''
74
+ res.on('data', c => data += c)
75
+ res.on('end', () => {
76
+ try { resolve(JSON.parse(data)) }
77
+ catch (e) { reject(new Error('Invalid LinkedIn response')) }
78
+ })
79
+ }).on('error', reject)
80
+ })
81
+ }
82
+
83
+ function postJson(url, body, headers) {
84
+ return new Promise((resolve, reject) => {
85
+ const urlObj = new URL(url)
86
+ const opts = {
87
+ hostname: urlObj.hostname,
88
+ path: urlObj.pathname,
89
+ method: 'POST',
90
+ headers: { ...headers, 'Content-Length': Buffer.byteLength(body) }
91
+ }
92
+ const req = https.request(opts, res => {
93
+ let data = ''
94
+ res.on('data', c => data += c)
95
+ res.on('end', () => {
96
+ try { resolve(JSON.parse(data)) }
97
+ catch (e) { reject(new Error('Invalid LinkedIn response')) }
98
+ })
99
+ })
100
+ req.on('error', reject)
101
+ req.write(body)
102
+ req.end()
103
+ })
104
+ }
105
+
106
+ module.exports = { LinkedInAdapter }
@@ -0,0 +1,160 @@
1
+ const https = require('https')
2
+
3
+ /**
4
+ * Reddit platform adapter.
5
+ * Method: Cookie API via OAuth endpoint.
6
+ * Key insight: Reddit blocks datacenter IPs on web frontend
7
+ * but NOT on oauth.reddit.com API.
8
+ */
9
+ class RedditAdapter {
10
+ async execute(action, params, ctx) {
11
+ switch (action) {
12
+ case 'post':
13
+ return this._post(params, ctx)
14
+ case 'comment':
15
+ return this._comment(params, ctx)
16
+ case 'delete':
17
+ return this._delete(params, ctx)
18
+ default:
19
+ throw new Error(`Unsupported Reddit action: ${action}`)
20
+ }
21
+ }
22
+
23
+ async _post(params, ctx) {
24
+ const { account, subreddit, title, body: text, url: linkUrl, _cookies } = params
25
+
26
+ const token = await this._getToken(_cookies)
27
+
28
+ const formData = new URLSearchParams()
29
+ formData.append('sr', subreddit)
30
+ formData.append('title', title)
31
+ formData.append('api_type', 'json')
32
+
33
+ if (linkUrl) {
34
+ formData.append('kind', 'link')
35
+ formData.append('url', linkUrl)
36
+ } else {
37
+ formData.append('kind', 'self')
38
+ formData.append('text', text || '')
39
+ }
40
+
41
+ const data = await postOAuth('https://oauth.reddit.com/api/submit', formData.toString(), token)
42
+
43
+ if (data.json?.errors?.length > 0) {
44
+ throw new Error(`Reddit error: ${JSON.stringify(data.json.errors)}`)
45
+ }
46
+
47
+ const postUrl = data.json?.data?.url
48
+ const postId = data.json?.data?.name
49
+ return { postId, url: postUrl }
50
+ }
51
+
52
+ async _comment(params, ctx) {
53
+ const { postId, text, _cookies } = params
54
+
55
+ const token = await this._getToken(_cookies)
56
+
57
+ const formData = new URLSearchParams()
58
+ formData.append('thing_id', postId)
59
+ formData.append('text', text)
60
+ formData.append('api_type', 'json')
61
+
62
+ const data = await postOAuth('https://oauth.reddit.com/api/comment', formData.toString(), token)
63
+
64
+ if (data.json?.errors?.length > 0) {
65
+ throw new Error(`Reddit error: ${JSON.stringify(data.json.errors)}`)
66
+ }
67
+
68
+ const commentId = data.json?.data?.things?.[0]?.data?.name
69
+ return { commentId }
70
+ }
71
+
72
+ async _delete(params, ctx) {
73
+ const { thingId, _cookies } = params
74
+
75
+ const token = await this._getToken(_cookies)
76
+
77
+ const formData = new URLSearchParams()
78
+ formData.append('id', thingId)
79
+
80
+ await postOAuth('https://oauth.reddit.com/api/del', formData.toString(), token)
81
+ return { deleted: thingId }
82
+ }
83
+
84
+ /**
85
+ * Extract Bearer token from Reddit cookies.
86
+ * Flow: cookies → hit reddit.com → get token_v2 JWT → use as Bearer
87
+ */
88
+ async _getToken(cookies) {
89
+ if (!cookies) throw new Error('No Reddit cookies available')
90
+
91
+ // Look for token_v2 in cookies
92
+ const tokenCookie = cookies.find(c => c.name === 'token_v2')
93
+ if (tokenCookie) return tokenCookie.value
94
+
95
+ // Look for reddit_session
96
+ const sessionCookie = cookies.find(c => c.name === 'reddit_session')
97
+ if (sessionCookie) {
98
+ // Need to exchange session cookie for token
99
+ // Hit reddit.com with cookies to get fresh token_v2
100
+ const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; ')
101
+ const html = await fetchWithCookies('https://www.reddit.com/', cookieStr)
102
+
103
+ // Extract access token from page
104
+ const tokenMatch = html.match(/"accessToken":"([^"]+)"/)
105
+ if (tokenMatch) return tokenMatch[1]
106
+ }
107
+
108
+ throw new Error('Could not extract Reddit auth token from cookies')
109
+ }
110
+ }
111
+
112
+ function postOAuth(url, body, token) {
113
+ return new Promise((resolve, reject) => {
114
+ const urlObj = new URL(url)
115
+ const opts = {
116
+ hostname: urlObj.hostname,
117
+ path: urlObj.pathname,
118
+ method: 'POST',
119
+ headers: {
120
+ 'Authorization': `Bearer ${token}`,
121
+ 'Content-Type': 'application/x-www-form-urlencoded',
122
+ 'User-Agent': 'Spectrawl/0.1.0',
123
+ 'Content-Length': Buffer.byteLength(body)
124
+ }
125
+ }
126
+
127
+ const req = https.request(opts, res => {
128
+ let data = ''
129
+ res.on('data', c => data += c)
130
+ res.on('end', () => {
131
+ try { resolve(JSON.parse(data)) }
132
+ catch (e) { reject(new Error(`Invalid Reddit response: ${data.slice(0, 200)}`)) }
133
+ })
134
+ })
135
+ req.on('error', reject)
136
+ req.setTimeout(15000, () => { req.destroy(); reject(new Error('Reddit API timeout')) })
137
+ req.write(body)
138
+ req.end()
139
+ })
140
+ }
141
+
142
+ function fetchWithCookies(url, cookieStr) {
143
+ return new Promise((resolve, reject) => {
144
+ const urlObj = new URL(url)
145
+ https.get({
146
+ hostname: urlObj.hostname,
147
+ path: urlObj.pathname,
148
+ headers: {
149
+ 'Cookie': cookieStr,
150
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
151
+ }
152
+ }, res => {
153
+ let data = ''
154
+ res.on('data', c => data += c)
155
+ res.on('end', () => resolve(data))
156
+ }).on('error', reject)
157
+ })
158
+ }
159
+
160
+ module.exports = { RedditAdapter }