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.
- package/package.json +1 -1
- package/src/act/adapters/alternativeto.js +64 -0
- package/src/act/adapters/betalist.js +66 -0
- package/src/act/adapters/devhunt.js +56 -0
- package/src/act/adapters/directory.js +219 -0
- package/src/act/adapters/discord.js +131 -0
- package/src/act/adapters/github.js +152 -0
- package/src/act/adapters/hackernews.js +161 -0
- package/src/act/adapters/huggingface.js +103 -0
- package/src/act/adapters/medium.js +107 -0
- package/src/act/adapters/producthunt.js +143 -0
- package/src/act/adapters/quora.js +89 -0
- package/src/act/adapters/saashub.js +47 -0
- package/src/act/adapters/youtube.js +158 -0
- package/src/act/index.js +30 -1
- package/src/cli.js +37 -0
- package/src/proxy/index.js +234 -0
|
@@ -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 }
|