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.
- package/LICENSE +21 -0
- package/README.md +179 -0
- package/index.d.ts +90 -0
- package/package.json +53 -0
- package/src/act/adapters/devto.js +103 -0
- package/src/act/adapters/hashnode.js +89 -0
- package/src/act/adapters/ih.js +251 -0
- package/src/act/adapters/linkedin.js +106 -0
- package/src/act/adapters/reddit.js +160 -0
- package/src/act/adapters/x.js +202 -0
- package/src/act/form-filler.js +94 -0
- package/src/act/index.js +159 -0
- package/src/act/rate-limiter.js +143 -0
- package/src/auth/index.js +132 -0
- package/src/auth/refresh.js +111 -0
- package/src/browse/camoufox.js +164 -0
- package/src/browse/index.js +278 -0
- package/src/browse/install-stealth.js +188 -0
- package/src/cache.js +82 -0
- package/src/cli.js +160 -0
- package/src/config.js +65 -0
- package/src/events.js +57 -0
- package/src/index.js +108 -0
- package/src/mcp.js +195 -0
- package/src/search/engines/brave.js +62 -0
- package/src/search/engines/ddg.js +192 -0
- package/src/search/engines/google-cse.js +50 -0
- package/src/search/engines/jina.js +76 -0
- package/src/search/engines/searxng.js +69 -0
- package/src/search/engines/serper.js +64 -0
- package/src/search/index.js +104 -0
- package/src/search/scraper.js +170 -0
- package/src/search/summarizer.js +156 -0
- package/src/server.js +111 -0
|
@@ -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 }
|