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
package/package.json
CHANGED
|
@@ -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 }
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
const https = require('https')
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GitHub platform adapter.
|
|
5
|
+
* Uses GitHub REST API v3 with personal access tokens.
|
|
6
|
+
* Actions: create repo, create/update file, create discussion, create issue
|
|
7
|
+
*/
|
|
8
|
+
class GitHubAdapter {
|
|
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-file':
|
|
15
|
+
case 'push':
|
|
16
|
+
return this._createFile(params, ctx)
|
|
17
|
+
case 'create-issue':
|
|
18
|
+
return this._createIssue(params, ctx)
|
|
19
|
+
case 'create-release':
|
|
20
|
+
return this._createRelease(params, ctx)
|
|
21
|
+
case 'update-readme':
|
|
22
|
+
return this._updateReadme(params, ctx)
|
|
23
|
+
default:
|
|
24
|
+
throw new Error(`Unsupported GitHub action: ${action}`)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async _createRepo(params, ctx) {
|
|
29
|
+
const { name, description, homepage, topics, isPrivate, account } = params
|
|
30
|
+
const token = this._getToken(account, ctx)
|
|
31
|
+
|
|
32
|
+
const data = await ghApi('POST', '/user/repos', {
|
|
33
|
+
name,
|
|
34
|
+
description: description || '',
|
|
35
|
+
homepage: homepage || '',
|
|
36
|
+
private: isPrivate || false,
|
|
37
|
+
auto_init: true
|
|
38
|
+
}, token)
|
|
39
|
+
|
|
40
|
+
if (topics?.length) {
|
|
41
|
+
await ghApi('PUT', `/repos/${data.full_name}/topics`, { names: topics }, token)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { repoId: data.id, url: data.html_url, fullName: data.full_name }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async _createFile(params, ctx) {
|
|
48
|
+
const { repo, path, content, message, branch, account } = params
|
|
49
|
+
const token = this._getToken(account, ctx)
|
|
50
|
+
|
|
51
|
+
// Check if file exists (for updates)
|
|
52
|
+
let sha
|
|
53
|
+
try {
|
|
54
|
+
const existing = await ghApi('GET', `/repos/${repo}/contents/${path}${branch ? `?ref=${branch}` : ''}`, null, token)
|
|
55
|
+
sha = existing.sha
|
|
56
|
+
} catch (e) { /* file doesn't exist, that's fine */ }
|
|
57
|
+
|
|
58
|
+
const body = {
|
|
59
|
+
message: message || `Update ${path}`,
|
|
60
|
+
content: Buffer.from(content).toString('base64'),
|
|
61
|
+
branch: branch || 'main'
|
|
62
|
+
}
|
|
63
|
+
if (sha) body.sha = sha
|
|
64
|
+
|
|
65
|
+
const data = await ghApi('PUT', `/repos/${repo}/contents/${path}`, body, token)
|
|
66
|
+
return { url: data.content?.html_url, sha: data.content?.sha }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async _createIssue(params, ctx) {
|
|
70
|
+
const { repo, title, body, labels, account } = params
|
|
71
|
+
const token = this._getToken(account, ctx)
|
|
72
|
+
|
|
73
|
+
const data = await ghApi('POST', `/repos/${repo}/issues`, {
|
|
74
|
+
title,
|
|
75
|
+
body: body || '',
|
|
76
|
+
labels: labels || []
|
|
77
|
+
}, token)
|
|
78
|
+
|
|
79
|
+
return { issueId: data.number, url: data.html_url }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async _createRelease(params, ctx) {
|
|
83
|
+
const { repo, tag, name, body, draft, prerelease, account } = params
|
|
84
|
+
const token = this._getToken(account, ctx)
|
|
85
|
+
|
|
86
|
+
const data = await ghApi('POST', `/repos/${repo}/releases`, {
|
|
87
|
+
tag_name: tag,
|
|
88
|
+
name: name || tag,
|
|
89
|
+
body: body || '',
|
|
90
|
+
draft: draft || false,
|
|
91
|
+
prerelease: prerelease || false
|
|
92
|
+
}, token)
|
|
93
|
+
|
|
94
|
+
return { releaseId: data.id, url: data.html_url }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async _updateReadme(params, ctx) {
|
|
98
|
+
const { repo, content, account } = params
|
|
99
|
+
return this._createFile({ repo, path: 'README.md', content, message: 'Update README', account }, ctx)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_getToken(account, ctx) {
|
|
103
|
+
if (account?.token) return account.token
|
|
104
|
+
const token = ctx?.config?.accounts?.github?.token ||
|
|
105
|
+
process.env.GITHUB_TOKEN
|
|
106
|
+
if (!token) throw new Error('GitHub token required')
|
|
107
|
+
return token
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function ghApi(method, path, body, token) {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const opts = {
|
|
114
|
+
hostname: 'api.github.com',
|
|
115
|
+
path,
|
|
116
|
+
method,
|
|
117
|
+
headers: {
|
|
118
|
+
'Authorization': `Bearer ${token}`,
|
|
119
|
+
'Accept': 'application/vnd.github+json',
|
|
120
|
+
'User-Agent': 'Spectrawl/0.1.0',
|
|
121
|
+
'X-GitHub-Api-Version': '2022-11-28'
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (body && method !== 'GET') {
|
|
126
|
+
const json = JSON.stringify(body)
|
|
127
|
+
opts.headers['Content-Type'] = 'application/json'
|
|
128
|
+
opts.headers['Content-Length'] = Buffer.byteLength(json)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const req = https.request(opts, res => {
|
|
132
|
+
let data = ''
|
|
133
|
+
res.on('data', c => data += c)
|
|
134
|
+
res.on('end', () => {
|
|
135
|
+
try {
|
|
136
|
+
const parsed = data ? JSON.parse(data) : {}
|
|
137
|
+
if (res.statusCode >= 400) {
|
|
138
|
+
reject(new Error(`GitHub API ${res.statusCode}: ${parsed.message || data.slice(0, 200)}`))
|
|
139
|
+
} else {
|
|
140
|
+
resolve(parsed)
|
|
141
|
+
}
|
|
142
|
+
} catch (e) { reject(new Error(`Invalid GitHub response: ${data.slice(0, 200)}`)) }
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
req.on('error', reject)
|
|
146
|
+
req.setTimeout(15000, () => { req.destroy(); reject(new Error('GitHub API timeout')) })
|
|
147
|
+
if (body && method !== 'GET') req.write(JSON.stringify(body))
|
|
148
|
+
req.end()
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { GitHubAdapter }
|