spectrawl 0.1.1 → 0.2.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,161 @@
1
+ const https = require('https')
2
+ const http = require('http')
3
+
4
+ /**
5
+ * Hacker News platform adapter.
6
+ * HN has no official write API — uses Firebase API for reading
7
+ * and browser automation / cookie-based form submission for posting.
8
+ *
9
+ * Read: Firebase API (no auth needed)
10
+ * Write: Cookie-based form POST to news.ycombinator.com
11
+ */
12
+ class HackerNewsAdapter {
13
+ async execute(action, params, ctx) {
14
+ switch (action) {
15
+ case 'post':
16
+ case 'submit':
17
+ return this._submit(params, ctx)
18
+ case 'comment':
19
+ return this._comment(params, ctx)
20
+ case 'upvote':
21
+ return this._upvote(params, ctx)
22
+ default:
23
+ throw new Error(`Unsupported HN action: ${action}`)
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Submit a story to HN.
29
+ * Requires login cookie (user session).
30
+ */
31
+ async _submit(params, ctx) {
32
+ const { title, url, text, _cookies } = params
33
+ const cookie = this._getCookie(_cookies)
34
+
35
+ // Get FNID (form nonce) from submit page
36
+ const submitPage = await fetchHN('/submit', cookie)
37
+ const fnidMatch = submitPage.match(/name="fnid" value="([^"]+)"/)
38
+ if (!fnidMatch) throw new Error('Could not get HN submit form token. Check cookie validity.')
39
+ const fnid = fnidMatch[1]
40
+
41
+ // Submit the form
42
+ const form = new URLSearchParams()
43
+ form.append('fnid', fnid)
44
+ form.append('fnop', 'submit-page')
45
+ form.append('title', title)
46
+ if (url) form.append('url', url)
47
+ if (text && !url) form.append('text', text)
48
+
49
+ const result = await postHN('/r', form.toString(), cookie)
50
+
51
+ // HN redirects to /newest on success
52
+ if (result.includes('newest') || result.includes('item?id=')) {
53
+ const idMatch = result.match(/item\?id=(\d+)/)
54
+ return {
55
+ url: idMatch ? `https://news.ycombinator.com/item?id=${idMatch[1]}` : 'https://news.ycombinator.com/newest',
56
+ submitted: true
57
+ }
58
+ }
59
+
60
+ // Check for rate limit or error
61
+ if (result.includes('submitting too fast')) {
62
+ throw new Error('HN rate limit: submitting too fast')
63
+ }
64
+
65
+ return { submitted: true, url: 'https://news.ycombinator.com/newest' }
66
+ }
67
+
68
+ async _comment(params, ctx) {
69
+ const { parentId, text, _cookies } = params
70
+ const cookie = this._getCookie(_cookies)
71
+
72
+ // Get the item page to find comment form HMAC
73
+ const itemPage = await fetchHN(`/item?id=${parentId}`, cookie)
74
+ const hmacMatch = itemPage.match(/name="hmac" value="([^"]+)"/)
75
+ if (!hmacMatch) throw new Error('Could not get HN comment form token')
76
+
77
+ const form = new URLSearchParams()
78
+ form.append('parent', parentId)
79
+ form.append('goto', `item?id=${parentId}`)
80
+ form.append('hmac', hmacMatch[1])
81
+ form.append('text', text)
82
+
83
+ await postHN('/comment', form.toString(), cookie)
84
+ return { commented: true, parentId }
85
+ }
86
+
87
+ async _upvote(params, ctx) {
88
+ const { itemId, _cookies } = params
89
+ const cookie = this._getCookie(_cookies)
90
+
91
+ // Get vote link from item page
92
+ const itemPage = await fetchHN(`/item?id=${itemId}`, cookie)
93
+ const voteMatch = itemPage.match(/id="up_(\d+)"[^>]*href="([^"]+)"/)
94
+ if (!voteMatch) throw new Error('Could not find upvote link (already voted or not logged in)')
95
+
96
+ await fetchHN(voteMatch[2], cookie)
97
+ return { upvoted: true, itemId }
98
+ }
99
+
100
+ _getCookie(cookies) {
101
+ if (!cookies) throw new Error('HN cookies required for posting')
102
+ if (typeof cookies === 'string') return cookies
103
+
104
+ const userCookie = cookies.find(c => c.name === 'user')
105
+ if (!userCookie) throw new Error('HN user cookie not found')
106
+ return `user=${userCookie.value}`
107
+ }
108
+ }
109
+
110
+ function fetchHN(path, cookie) {
111
+ return new Promise((resolve, reject) => {
112
+ https.get({
113
+ hostname: 'news.ycombinator.com',
114
+ path,
115
+ headers: {
116
+ 'Cookie': cookie,
117
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
118
+ }
119
+ }, res => {
120
+ // Follow redirects
121
+ if (res.statusCode === 302 || res.statusCode === 301) {
122
+ const loc = res.headers.location
123
+ if (loc) return fetchHN(loc.startsWith('http') ? new URL(loc).pathname + new URL(loc).search : loc, cookie).then(resolve).catch(reject)
124
+ }
125
+ let data = ''
126
+ res.on('data', c => data += c)
127
+ res.on('end', () => resolve(data))
128
+ }).on('error', reject)
129
+ })
130
+ }
131
+
132
+ function postHN(path, body, cookie) {
133
+ return new Promise((resolve, reject) => {
134
+ const opts = {
135
+ hostname: 'news.ycombinator.com',
136
+ path,
137
+ method: 'POST',
138
+ headers: {
139
+ 'Cookie': cookie,
140
+ 'Content-Type': 'application/x-www-form-urlencoded',
141
+ 'Content-Length': Buffer.byteLength(body),
142
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
143
+ }
144
+ }
145
+ const req = https.request(opts, res => {
146
+ let data = ''
147
+ // Follow redirect and capture location
148
+ if (res.statusCode === 302 || res.statusCode === 301) {
149
+ data = res.headers.location || ''
150
+ }
151
+ res.on('data', c => data += c)
152
+ res.on('end', () => resolve(data))
153
+ })
154
+ req.on('error', reject)
155
+ req.setTimeout(15000, () => { req.destroy(); reject(new Error('HN timeout')) })
156
+ req.write(body)
157
+ req.end()
158
+ })
159
+ }
160
+
161
+ module.exports = { HackerNewsAdapter }
@@ -0,0 +1,103 @@
1
+ const https = require('https')
2
+
3
+ /**
4
+ * HuggingFace platform adapter.
5
+ * Uses HF Hub API for model/dataset/space management.
6
+ * Docs: https://huggingface.co/docs/hub/api
7
+ */
8
+ class HuggingFaceAdapter {
9
+ async execute(action, params, ctx) {
10
+ switch (action) {
11
+ case 'post':
12
+ case 'create-repo':
13
+ return this._createRepo(params, ctx)
14
+ case 'create-model-card':
15
+ return this._createModelCard(params, ctx)
16
+ case 'upload-file':
17
+ return this._uploadFile(params, ctx)
18
+ default:
19
+ throw new Error(`Unsupported HuggingFace action: ${action}`)
20
+ }
21
+ }
22
+
23
+ async _createRepo(params, ctx) {
24
+ const { name, type, private: isPrivate, account } = params
25
+ const token = this._getToken(account, ctx)
26
+
27
+ const data = await hfApi('POST', '/api/repos/create', {
28
+ name,
29
+ type: type || 'model', // model, dataset, space
30
+ private: isPrivate || false
31
+ }, token)
32
+
33
+ return {
34
+ url: data.url || `https://huggingface.co/${data.repoId || name}`,
35
+ repoId: data.repoId
36
+ }
37
+ }
38
+
39
+ async _createModelCard(params, ctx) {
40
+ const { repo, content, account } = params
41
+ const token = this._getToken(account, ctx)
42
+
43
+ // Upload README.md to the repo
44
+ const data = await hfApi('PUT', `/api/${repo}/upload/main/README.md`, content, token, 'text/plain')
45
+ return { updated: true, repo }
46
+ }
47
+
48
+ async _uploadFile(params, ctx) {
49
+ const { repo, path, content, branch, account } = params
50
+ const token = this._getToken(account, ctx)
51
+
52
+ const data = await hfApi('PUT', `/api/${repo}/upload/${branch || 'main'}/${path}`, content, token,
53
+ typeof content === 'string' ? 'text/plain' : 'application/octet-stream')
54
+ return { uploaded: true, path }
55
+ }
56
+
57
+ _getToken(account, ctx) {
58
+ if (account?.token) return account.token
59
+ const token = ctx?.config?.accounts?.huggingface?.token ||
60
+ process.env.HF_TOKEN ||
61
+ process.env.HUGGINGFACE_TOKEN
62
+ if (!token) throw new Error('HuggingFace token required. Get one from huggingface.co/settings/tokens')
63
+ return token
64
+ }
65
+ }
66
+
67
+ function hfApi(method, path, body, token, contentType) {
68
+ return new Promise((resolve, reject) => {
69
+ const isJson = !contentType || contentType === 'application/json'
70
+ const payload = isJson && typeof body === 'object' ? JSON.stringify(body) : (body || '')
71
+ const opts = {
72
+ hostname: 'huggingface.co',
73
+ path,
74
+ method,
75
+ headers: {
76
+ 'Authorization': `Bearer ${token}`,
77
+ 'Content-Type': contentType || 'application/json',
78
+ 'Content-Length': Buffer.byteLength(payload)
79
+ }
80
+ }
81
+ const req = https.request(opts, res => {
82
+ let data = ''
83
+ res.on('data', c => data += c)
84
+ res.on('end', () => {
85
+ try {
86
+ const parsed = data ? JSON.parse(data) : {}
87
+ if (res.statusCode >= 400) {
88
+ reject(new Error(`HF API ${res.statusCode}: ${parsed.error || data.slice(0, 200)}`))
89
+ } else { resolve(parsed) }
90
+ } catch (e) {
91
+ if (res.statusCode < 300) resolve({ raw: data })
92
+ else reject(new Error(`HF API error: ${data.slice(0, 200)}`))
93
+ }
94
+ })
95
+ })
96
+ req.on('error', reject)
97
+ req.setTimeout(30000, () => { req.destroy(); reject(new Error('HF API timeout')) })
98
+ req.write(payload)
99
+ req.end()
100
+ })
101
+ }
102
+
103
+ module.exports = { HuggingFaceAdapter }
@@ -0,0 +1,107 @@
1
+ const https = require('https')
2
+
3
+ /**
4
+ * Medium platform adapter.
5
+ * Uses Medium's REST API with integration tokens.
6
+ * Docs: https://github.com/Medium/medium-api-docs
7
+ */
8
+ class MediumAdapter {
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 Medium action: ${action}`)
15
+ }
16
+ }
17
+
18
+ async _post(params, ctx) {
19
+ const { title, body, tags, publishStatus, account } = params
20
+ const token = this._getToken(account, ctx)
21
+
22
+ // Get user ID first
23
+ const me = await apiGet('https://api.medium.com/v1/me', token)
24
+ const userId = me.data?.id
25
+ if (!userId) throw new Error('Could not get Medium user ID')
26
+
27
+ const article = {
28
+ title,
29
+ contentFormat: 'markdown',
30
+ content: body,
31
+ tags: tags || [],
32
+ publishStatus: publishStatus || 'public' // public, draft, unlisted
33
+ }
34
+
35
+ const data = await apiPost(
36
+ `https://api.medium.com/v1/users/${userId}/posts`,
37
+ JSON.stringify(article),
38
+ token
39
+ )
40
+
41
+ return {
42
+ postId: data.data?.id,
43
+ url: data.data?.url,
44
+ title: data.data?.title
45
+ }
46
+ }
47
+
48
+ _getToken(account, ctx) {
49
+ if (account?.apiKey) return account.apiKey
50
+ const token = ctx?.config?.accounts?.medium?.apiKey ||
51
+ process.env.MEDIUM_API_KEY
52
+ if (!token) throw new Error('Medium API key required. Get one from medium.com/me/settings')
53
+ return token
54
+ }
55
+ }
56
+
57
+ function apiGet(url, token) {
58
+ return new Promise((resolve, reject) => {
59
+ const urlObj = new URL(url)
60
+ https.get({
61
+ hostname: urlObj.hostname,
62
+ path: urlObj.pathname,
63
+ headers: {
64
+ 'Authorization': `Bearer ${token}`,
65
+ 'Accept': 'application/json'
66
+ }
67
+ }, res => {
68
+ let data = ''
69
+ res.on('data', c => data += c)
70
+ res.on('end', () => {
71
+ try { resolve(JSON.parse(data)) }
72
+ catch (e) { reject(new Error(`Invalid Medium response: ${data.slice(0, 200)}`)) }
73
+ })
74
+ }).on('error', reject)
75
+ })
76
+ }
77
+
78
+ function apiPost(url, body, token) {
79
+ return new Promise((resolve, reject) => {
80
+ const urlObj = new URL(url)
81
+ const opts = {
82
+ hostname: urlObj.hostname,
83
+ path: urlObj.pathname,
84
+ method: 'POST',
85
+ headers: {
86
+ 'Authorization': `Bearer ${token}`,
87
+ 'Content-Type': 'application/json',
88
+ 'Accept': 'application/json',
89
+ 'Content-Length': Buffer.byteLength(body)
90
+ }
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 Medium response: ${data.slice(0, 200)}`)) }
98
+ })
99
+ })
100
+ req.on('error', reject)
101
+ req.setTimeout(15000, () => { req.destroy(); reject(new Error('Medium API timeout')) })
102
+ req.write(body)
103
+ req.end()
104
+ })
105
+ }
106
+
107
+ module.exports = { MediumAdapter }
@@ -0,0 +1,143 @@
1
+ const https = require('https')
2
+
3
+ /**
4
+ * Product Hunt platform adapter.
5
+ * Uses GraphQL API v2.
6
+ * Docs: https://api.producthunt.com/v2/docs
7
+ */
8
+ class ProductHuntAdapter {
9
+ async execute(action, params, ctx) {
10
+ switch (action) {
11
+ case 'post':
12
+ case 'launch':
13
+ return this._launch(params, ctx)
14
+ case 'comment':
15
+ return this._comment(params, ctx)
16
+ case 'upvote':
17
+ return this._upvote(params, ctx)
18
+ default:
19
+ throw new Error(`Unsupported Product Hunt action: ${action}`)
20
+ }
21
+ }
22
+
23
+ async _launch(params, ctx) {
24
+ const { name, tagline, url, description, topics, thumbnailUrl, account } = params
25
+ const token = this._getToken(account, ctx)
26
+
27
+ // Note: Product Hunt API v2 doesn't directly support creating posts
28
+ // via API anymore — launches go through producthunt.com/posts/new
29
+ // This uses the maker tools endpoint where available
30
+ const query = `
31
+ mutation CreatePost($input: PostCreateInput!) {
32
+ postCreate(input: $input) {
33
+ post {
34
+ id
35
+ name
36
+ tagline
37
+ url
38
+ votesCount
39
+ }
40
+ }
41
+ }
42
+ `
43
+
44
+ const data = await graphql(query, {
45
+ input: {
46
+ name,
47
+ tagline,
48
+ url,
49
+ description: description || '',
50
+ topicIds: topics || [],
51
+ thumbnailUrl: thumbnailUrl || ''
52
+ }
53
+ }, token)
54
+
55
+ const post = data.data?.postCreate?.post
56
+ if (!post) {
57
+ throw new Error(`Product Hunt launch failed: ${JSON.stringify(data.errors || data)}`)
58
+ }
59
+
60
+ return { postId: post.id, url: post.url, name: post.name }
61
+ }
62
+
63
+ async _comment(params, ctx) {
64
+ const { postId, body, account } = params
65
+ const token = this._getToken(account, ctx)
66
+
67
+ const query = `
68
+ mutation CreateComment($input: CommentCreateInput!) {
69
+ commentCreate(input: $input) {
70
+ comment {
71
+ id
72
+ body
73
+ }
74
+ }
75
+ }
76
+ `
77
+
78
+ const data = await graphql(query, {
79
+ input: { postId, body }
80
+ }, token)
81
+
82
+ const comment = data.data?.commentCreate?.comment
83
+ return { commentId: comment?.id }
84
+ }
85
+
86
+ async _upvote(params, ctx) {
87
+ const { postId, account } = params
88
+ const token = this._getToken(account, ctx)
89
+
90
+ const query = `
91
+ mutation VotePost($input: PostVoteInput!) {
92
+ postVote(input: $input) {
93
+ node {
94
+ id
95
+ votesCount
96
+ }
97
+ }
98
+ }
99
+ `
100
+
101
+ const data = await graphql(query, { input: { postId } }, token)
102
+ return { votes: data.data?.postVote?.node?.votesCount }
103
+ }
104
+
105
+ _getToken(account, ctx) {
106
+ if (account?.token) return account.token
107
+ const token = ctx?.config?.accounts?.producthunt?.token ||
108
+ process.env.PRODUCTHUNT_TOKEN
109
+ if (!token) throw new Error('Product Hunt API token required. Get one from producthunt.com/v2/oauth/applications')
110
+ return token
111
+ }
112
+ }
113
+
114
+ function graphql(query, variables, token) {
115
+ return new Promise((resolve, reject) => {
116
+ const body = JSON.stringify({ query, variables })
117
+ const opts = {
118
+ hostname: 'api.producthunt.com',
119
+ path: '/v2/api/graphql',
120
+ method: 'POST',
121
+ headers: {
122
+ 'Authorization': `Bearer ${token}`,
123
+ 'Content-Type': 'application/json',
124
+ 'Accept': 'application/json',
125
+ 'Content-Length': Buffer.byteLength(body)
126
+ }
127
+ }
128
+ const req = https.request(opts, res => {
129
+ let data = ''
130
+ res.on('data', c => data += c)
131
+ res.on('end', () => {
132
+ try { resolve(JSON.parse(data)) }
133
+ catch (e) { reject(new Error(`Invalid PH response: ${data.slice(0, 200)}`)) }
134
+ })
135
+ })
136
+ req.on('error', reject)
137
+ req.setTimeout(15000, () => { req.destroy(); reject(new Error('PH API timeout')) })
138
+ req.write(body)
139
+ req.end()
140
+ })
141
+ }
142
+
143
+ module.exports = { ProductHuntAdapter }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Quora platform adapter.
3
+ * No public API — requires browser automation.
4
+ * Uses Spectrawl's browse engine for stealth interaction.
5
+ */
6
+ class QuoraAdapter {
7
+ async execute(action, params, ctx) {
8
+ switch (action) {
9
+ case 'post':
10
+ case 'answer':
11
+ return this._answer(params, ctx)
12
+ case 'question':
13
+ return this._askQuestion(params, ctx)
14
+ default:
15
+ throw new Error(`Unsupported Quora action: ${action}. Requires browser automation.`)
16
+ }
17
+ }
18
+
19
+ async _answer(params, ctx) {
20
+ const { questionUrl, text, _cookies, _browse } = params
21
+ if (!_browse) throw new Error('Quora requires browse engine. Pass _browse: spectrawl.browse')
22
+
23
+ const page = await _browse(questionUrl, {
24
+ cookies: _cookies,
25
+ getPage: true
26
+ })
27
+
28
+ const playwright = page._page
29
+ if (!playwright) throw new Error('Quora adapter requires getPage access')
30
+
31
+ // Click answer button
32
+ await playwright.click('[class*="AnswerButton"], button:has-text("Answer")')
33
+ await playwright.waitForTimeout(1000)
34
+
35
+ // Find the contenteditable answer box
36
+ const editor = await playwright.$('[class*="editor"], [contenteditable="true"], .q-box [role="textbox"]')
37
+ if (!editor) throw new Error('Could not find Quora answer editor')
38
+
39
+ // Type the answer
40
+ await editor.click()
41
+ await playwright.keyboard.type(text, { delay: 30 })
42
+ await playwright.waitForTimeout(500)
43
+
44
+ // Submit
45
+ await playwright.click('button:has-text("Submit"), button:has-text("Post")')
46
+ await playwright.waitForTimeout(2000)
47
+
48
+ return { answered: true, questionUrl }
49
+ }
50
+
51
+ async _askQuestion(params, ctx) {
52
+ const { question, details, topics, _cookies, _browse } = params
53
+ if (!_browse) throw new Error('Quora requires browse engine')
54
+
55
+ const page = await _browse('https://www.quora.com/', {
56
+ cookies: _cookies,
57
+ getPage: true
58
+ })
59
+
60
+ const playwright = page._page
61
+ if (!playwright) throw new Error('Quora adapter requires getPage access')
62
+
63
+ // Click "Add question" button
64
+ await playwright.click('button:has-text("Add question"), [class*="AddQuestion"]')
65
+ await playwright.waitForTimeout(1000)
66
+
67
+ // Type question in the modal
68
+ const input = await playwright.$('[placeholder*="question"], [class*="QuestionInput"] input, textarea')
69
+ if (!input) throw new Error('Could not find question input')
70
+ await input.type(question, { delay: 30 })
71
+
72
+ // Add details if provided
73
+ if (details) {
74
+ const detailInput = await playwright.$('[placeholder*="details"], [class*="details"] [contenteditable]')
75
+ if (detailInput) {
76
+ await detailInput.click()
77
+ await playwright.keyboard.type(details, { delay: 20 })
78
+ }
79
+ }
80
+
81
+ // Submit
82
+ await playwright.click('button:has-text("Add question"), button:has-text("Submit")')
83
+ await playwright.waitForTimeout(2000)
84
+
85
+ return { asked: true, question }
86
+ }
87
+ }
88
+
89
+ module.exports = { QuoraAdapter }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * SaaSHub platform adapter.
3
+ * Browser automation — no public API.
4
+ * Good for alternative comparisons and SEO.
5
+ */
6
+ class SaaSHubAdapter {
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 SaaSHub action: ${action}`)
14
+ }
15
+ }
16
+
17
+ async _submit(params, ctx) {
18
+ const { name, url, description, category, alternatives, _cookies, _browse } = params
19
+ if (!_browse) throw new Error('SaaSHub requires browse engine')
20
+
21
+ const page = await _browse('https://www.saashub.com/submit', {
22
+ cookies: _cookies,
23
+ getPage: true
24
+ })
25
+
26
+ const pw = page._page
27
+ if (!pw) throw new Error('SaaSHub requires getPage access')
28
+
29
+ await pw.waitForSelector('input[name="name"], #product_name', { timeout: 10000 })
30
+
31
+ const nameInput = await pw.$('input[name="name"], #product_name')
32
+ if (nameInput) await nameInput.fill(name)
33
+
34
+ const urlInput = await pw.$('input[name="url"], input[name="website"]')
35
+ if (urlInput) await urlInput.fill(url)
36
+
37
+ const descInput = await pw.$('textarea[name="description"]')
38
+ if (descInput) await descInput.fill(description || '')
39
+
40
+ await pw.click('button[type="submit"], input[type="submit"]')
41
+ await pw.waitForTimeout(3000)
42
+
43
+ return { submitted: true, name, url }
44
+ }
45
+ }
46
+
47
+ module.exports = { SaaSHubAdapter }