stella-coder 5.1.2 → 5.2.1
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 +2 -1
- package/stella-cli/browser-control.mjs +274 -0
- package/stella-cli/git-api.mjs +407 -0
- package/stella-cli/index.mjs +687 -1
- package/stella-cli/screen-monitor.mjs +334 -0
- package/stella-cli/web-parser.mjs +229 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlinkSync, statSync } from "fs"
|
|
2
|
+
import { join } from "path"
|
|
3
|
+
import { homedir } from "os"
|
|
4
|
+
import { execSync } from "child_process"
|
|
5
|
+
|
|
6
|
+
const STELLA_DIR = join(homedir(), ".stella")
|
|
7
|
+
const MONITOR_DIR = join(STELLA_DIR, "monitor")
|
|
8
|
+
const HISTORY_FILE = join(MONITOR_DIR, "history.json")
|
|
9
|
+
const ALERTS_FILE = join(MONITOR_DIR, "alerts.json")
|
|
10
|
+
const SCREENSHOTS_DIR = join(MONITOR_DIR, "screenshots")
|
|
11
|
+
const MONITOR_STATE = join(MONITOR_DIR, "state.json")
|
|
12
|
+
|
|
13
|
+
function ensureDir() {
|
|
14
|
+
if (!existsSync(MONITOR_DIR)) mkdirSync(MONITOR_DIR, { recursive: true })
|
|
15
|
+
if (!existsSync(SCREENSHOTS_DIR)) mkdirSync(SCREENSHOTS_DIR, { recursive: true })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function loadJSON(file, fallback) {
|
|
19
|
+
try { return JSON.parse(readFileSync(file, "utf-8")) } catch { return fallback }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function saveJSON(file, data) {
|
|
23
|
+
ensureDir()
|
|
24
|
+
writeFileSync(file, JSON.stringify(data, null, 2))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function captureScreenSync() {
|
|
28
|
+
const ts = Date.now()
|
|
29
|
+
const outPath = join(SCREENSHOTS_DIR, `screen_${ts}.png`)
|
|
30
|
+
const tempPath = join(SCREENSHOTS_DIR, `_temp.png`)
|
|
31
|
+
const psScriptPath = join(SCREENSHOTS_DIR, `_capture.ps1`)
|
|
32
|
+
|
|
33
|
+
// Write PowerShell script to file (avoids escaping issues)
|
|
34
|
+
const psScript = `
|
|
35
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
36
|
+
Add-Type -AssemblyName System.Drawing
|
|
37
|
+
try {
|
|
38
|
+
$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
|
|
39
|
+
$bitmap = New-Object System.Drawing.Bitmap($bounds.Width, $bounds.Height)
|
|
40
|
+
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
|
41
|
+
$graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
|
|
42
|
+
$bitmap.Save("${tempPath.replace(/\\/g, "\\\\")}")
|
|
43
|
+
$graphics.Dispose()
|
|
44
|
+
$bitmap.Dispose()
|
|
45
|
+
Write-Output "OK"
|
|
46
|
+
} catch {
|
|
47
|
+
Write-Output "FAIL:$($_.Exception.Message)"
|
|
48
|
+
}
|
|
49
|
+
`.trim()
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
writeFileSync(psScriptPath, psScript, "utf-8")
|
|
53
|
+
const result = execSync(`powershell -NoProfile -ExecutionPolicy Bypass -File "${psScriptPath}"`, {
|
|
54
|
+
encoding: "utf-8",
|
|
55
|
+
timeout: 10000,
|
|
56
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
57
|
+
}).trim()
|
|
58
|
+
|
|
59
|
+
if (result.startsWith("OK") && existsSync(tempPath)) {
|
|
60
|
+
const data = readFileSync(tempPath)
|
|
61
|
+
writeFileSync(outPath, data)
|
|
62
|
+
try { unlinkSync(tempPath) } catch {}
|
|
63
|
+
try { unlinkSync(psScriptPath) } catch {}
|
|
64
|
+
return outPath
|
|
65
|
+
}
|
|
66
|
+
try { unlinkSync(psScriptPath) } catch {}
|
|
67
|
+
return null
|
|
68
|
+
} catch {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function compressImage(imagePath, maxWidth = 800) {
|
|
74
|
+
const compressedPath = imagePath.replace(".png", "_small.png")
|
|
75
|
+
const psScriptPath = join(SCREENSHOTS_DIR, `_compress.ps1`)
|
|
76
|
+
|
|
77
|
+
const psScript = `
|
|
78
|
+
Add-Type -AssemblyName System.Drawing
|
|
79
|
+
try {
|
|
80
|
+
$img = [System.Drawing.Image]::FromFile("${imagePath.replace(/\\/g, "\\\\")}")
|
|
81
|
+
$ratio = ${maxWidth} / $img.Width
|
|
82
|
+
if ($ratio -ge 1) { $ratio = 1 }
|
|
83
|
+
$newW = [int]($img.Width * $ratio)
|
|
84
|
+
$newH = [int]($img.Height * $ratio)
|
|
85
|
+
$bmp = New-Object System.Drawing.Bitmap($newW, $newH)
|
|
86
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
87
|
+
$g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
|
88
|
+
$g.DrawImage($img, 0, 0, $newW, $newH)
|
|
89
|
+
$bmp.Save("${compressedPath.replace(/\\/g, "\\\\")}")
|
|
90
|
+
$g.Dispose()
|
|
91
|
+
$bmp.Dispose()
|
|
92
|
+
$img.Dispose()
|
|
93
|
+
Write-Output "OK"
|
|
94
|
+
} catch {
|
|
95
|
+
Write-Output "FAIL"
|
|
96
|
+
}
|
|
97
|
+
`.trim()
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
writeFileSync(psScriptPath, psScript, "utf-8")
|
|
101
|
+
execSync(`powershell -NoProfile -ExecutionPolicy Bypass -File "${psScriptPath}"`, {
|
|
102
|
+
encoding: "utf-8",
|
|
103
|
+
timeout: 10000,
|
|
104
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
105
|
+
})
|
|
106
|
+
try { unlinkSync(psScriptPath) } catch {}
|
|
107
|
+
if (existsSync(compressedPath)) {
|
|
108
|
+
return compressedPath
|
|
109
|
+
}
|
|
110
|
+
} catch {}
|
|
111
|
+
return imagePath
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class ScreenMonitor {
|
|
115
|
+
constructor() {
|
|
116
|
+
ensureDir()
|
|
117
|
+
this.state = loadJSON(MONITOR_STATE, {
|
|
118
|
+
running: false,
|
|
119
|
+
interval: 2000,
|
|
120
|
+
startedAt: null,
|
|
121
|
+
totalCaptures: 0,
|
|
122
|
+
totalAlerts: 0,
|
|
123
|
+
pid: null,
|
|
124
|
+
})
|
|
125
|
+
this.history = loadJSON(HISTORY_FILE, [])
|
|
126
|
+
this.alerts = loadJSON(ALERTS_FILE, [])
|
|
127
|
+
this.intervalId = null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
save() {
|
|
131
|
+
saveJSON(MONITOR_STATE, this.state)
|
|
132
|
+
saveJSON(HISTORY_FILE, this.history.slice(-500))
|
|
133
|
+
saveJSON(ALERTS_FILE, this.alerts.slice(-200))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async captureAndAnalyze(visionFn) {
|
|
137
|
+
const imagePath = captureScreenSync()
|
|
138
|
+
if (!imagePath) {
|
|
139
|
+
return { success: false, error: "Failed to capture screen" }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const stats = statSync(imagePath)
|
|
143
|
+
const sizeKB = Math.round(stats.size / 1024)
|
|
144
|
+
|
|
145
|
+
// Compress for AI analysis
|
|
146
|
+
const compressedPath = compressImage(imagePath, 600)
|
|
147
|
+
let imageBase64
|
|
148
|
+
try {
|
|
149
|
+
imageBase64 = readFileSync(compressedPath).toString("base64")
|
|
150
|
+
} catch {
|
|
151
|
+
imageBase64 = readFileSync(imagePath).toString("base64")
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const ts = new Date().toISOString()
|
|
155
|
+
const capture = {
|
|
156
|
+
id: Date.now().toString(36),
|
|
157
|
+
timestamp: ts,
|
|
158
|
+
path: imagePath,
|
|
159
|
+
sizeKB,
|
|
160
|
+
analysis: null,
|
|
161
|
+
alerts: [],
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Analyze with vision AI
|
|
165
|
+
if (visionFn) {
|
|
166
|
+
try {
|
|
167
|
+
const analysis = await visionFn(imageBase64, this.getRecentContext())
|
|
168
|
+
capture.analysis = analysis
|
|
169
|
+
|
|
170
|
+
// Detect alerts
|
|
171
|
+
const alertKeywords = [
|
|
172
|
+
"error", "crash", "exception", "fatal", "alert", "warning", "failed",
|
|
173
|
+
"ошибка", "краш", "исключение", "фатальная", "предупреждение", "сбой",
|
|
174
|
+
"not responding", "has stopped", "dead", "kill", "terminate",
|
|
175
|
+
"не отвечает", "остановлен", "завершить",
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
const textLower = (analysis.text || "").toLowerCase()
|
|
179
|
+
const detected = alertKeywords.filter(k => textLower.includes(k))
|
|
180
|
+
|
|
181
|
+
if (detected.length > 0) {
|
|
182
|
+
const alert = {
|
|
183
|
+
id: capture.id,
|
|
184
|
+
timestamp: ts,
|
|
185
|
+
type: detected.join(", "),
|
|
186
|
+
severity: detected.some(k => ["fatal", "crash", "exception", "фатальная", "краш", "исключение"].includes(k)) ? "critical" : "warning",
|
|
187
|
+
description: analysis.text?.slice(0, 500),
|
|
188
|
+
screenshot: imagePath,
|
|
189
|
+
}
|
|
190
|
+
capture.alerts.push(alert)
|
|
191
|
+
this.alerts.push(alert)
|
|
192
|
+
this.state.totalAlerts++
|
|
193
|
+
}
|
|
194
|
+
} catch (err) {
|
|
195
|
+
capture.analysis = { error: err.message }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
this.history.push(capture)
|
|
200
|
+
this.state.totalCaptures++
|
|
201
|
+
this.save()
|
|
202
|
+
|
|
203
|
+
// Clean old screenshots (keep last 50)
|
|
204
|
+
this.cleanOldScreenshots(50)
|
|
205
|
+
|
|
206
|
+
return { success: true, capture }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
getRecentContext() {
|
|
210
|
+
const recent = this.history.slice(-5)
|
|
211
|
+
return recent.map(h => ({
|
|
212
|
+
time: h.timestamp,
|
|
213
|
+
analysis: h.analysis?.summary || h.analysis?.text?.slice(0, 200) || "no analysis",
|
|
214
|
+
alerts: h.alerts?.length || 0,
|
|
215
|
+
}))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
cleanOldScreenshots(keep = 50) {
|
|
219
|
+
try {
|
|
220
|
+
const files = readdirSync(SCREENSHOTS_DIR)
|
|
221
|
+
.filter(f => f.startsWith("screen_") && f.endsWith(".png"))
|
|
222
|
+
.sort()
|
|
223
|
+
|
|
224
|
+
if (files.length > keep) {
|
|
225
|
+
const toDelete = files.slice(0, files.length - keep)
|
|
226
|
+
for (const f of toDelete) {
|
|
227
|
+
try { unlinkSync(join(SCREENSHOTS_DIR, f)) } catch {}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} catch {}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
start(intervalMs, visionFn) {
|
|
234
|
+
if (this.state.running) {
|
|
235
|
+
return { success: false, error: "Monitor already running" }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
this.state.running = true
|
|
239
|
+
this.state.startedAt = new Date().toISOString()
|
|
240
|
+
this.state.interval = intervalMs || 2000
|
|
241
|
+
this.state.pid = process.pid
|
|
242
|
+
this.save()
|
|
243
|
+
|
|
244
|
+
this.intervalId = setInterval(async () => {
|
|
245
|
+
if (!this.state.running) {
|
|
246
|
+
this.stop()
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
await this.captureAndAnalyze(visionFn)
|
|
250
|
+
}, this.state.interval)
|
|
251
|
+
|
|
252
|
+
return { success: true, interval: this.state.interval }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
stop() {
|
|
256
|
+
if (this.intervalId) {
|
|
257
|
+
clearInterval(this.intervalId)
|
|
258
|
+
this.intervalId = null
|
|
259
|
+
}
|
|
260
|
+
this.state.running = false
|
|
261
|
+
this.state.pid = null
|
|
262
|
+
this.save()
|
|
263
|
+
return { success: true }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
getStatus() {
|
|
267
|
+
const recentAlerts = this.alerts.slice(-10)
|
|
268
|
+
const recentCaptures = this.history.slice(-5)
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
running: this.state.running,
|
|
272
|
+
startedAt: this.state.startedAt,
|
|
273
|
+
interval: this.state.interval,
|
|
274
|
+
totalCaptures: this.state.totalCaptures,
|
|
275
|
+
totalAlerts: this.state.totalAlerts,
|
|
276
|
+
pid: this.state.pid,
|
|
277
|
+
recentAlerts,
|
|
278
|
+
recentCaptures: recentCaptures.map(c => ({
|
|
279
|
+
time: c.timestamp,
|
|
280
|
+
size: c.sizeKB + "KB",
|
|
281
|
+
summary: c.analysis?.summary || c.analysis?.text?.slice(0, 100) || "analyzing...",
|
|
282
|
+
alertCount: c.alerts?.length || 0,
|
|
283
|
+
})),
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
getAlerts(since) {
|
|
288
|
+
if (since) {
|
|
289
|
+
return this.alerts.filter(a => new Date(a.timestamp) > new Date(since))
|
|
290
|
+
}
|
|
291
|
+
return this.alerts
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
getScreenshotPath() {
|
|
295
|
+
const files = readdirSync(SCREENSHOTS_DIR)
|
|
296
|
+
.filter(f => f.startsWith("screen_") && f.endsWith(".png"))
|
|
297
|
+
.sort()
|
|
298
|
+
return files.length > 0 ? join(SCREENSHOTS_DIR, files[files.length - 1]) : null
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
generateReport() {
|
|
302
|
+
const status = this.getStatus()
|
|
303
|
+
const alertsByType = {}
|
|
304
|
+
for (const a of this.alerts) {
|
|
305
|
+
alertsByType[a.type] = (alertsByType[a.type] || 0) + 1
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const report = `
|
|
309
|
+
═══════════════════════════════════════════
|
|
310
|
+
👁️ SCREEN MONITOR REPORT
|
|
311
|
+
═══════════════════════════════════════════
|
|
312
|
+
|
|
313
|
+
Status: ${status.running ? "🟢 RUNNING" : "🔴 STOPPED"}
|
|
314
|
+
Started: ${status.startedAt ? new Date(status.startedAt).toLocaleString() : "Never"}
|
|
315
|
+
Interval: every ${status.interval / 1000}s
|
|
316
|
+
Total captures: ${status.totalCaptures}
|
|
317
|
+
Total alerts: ${status.totalAlerts}
|
|
318
|
+
|
|
319
|
+
${Object.keys(alertsByType).length > 0 ? `
|
|
320
|
+
Alert breakdown:
|
|
321
|
+
${Object.entries(alertsByType).map(([type, count]) => ` ⚠️ ${type}: ${count}`).join("\n")}
|
|
322
|
+
` : "No alerts detected."}
|
|
323
|
+
|
|
324
|
+
Recent captures:
|
|
325
|
+
${status.recentCaptures.map(c => ` [${new Date(c.time).toLocaleTimeString()}] ${c.summary} ${c.alertCount > 0 ? `⚠️ ${c.alertCount} alerts` : "✅"}`).join("\n")}
|
|
326
|
+
|
|
327
|
+
═══════════════════════════════════════════
|
|
328
|
+
`.trim()
|
|
329
|
+
|
|
330
|
+
return report
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export default ScreenMonitor
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import http from "http"
|
|
2
|
+
import https from "https"
|
|
3
|
+
import { JSDOM } from "jsdom"
|
|
4
|
+
|
|
5
|
+
function fetchUrl(url, options = {}) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const mod = url.startsWith("https") ? https : http
|
|
8
|
+
const headers = {
|
|
9
|
+
"User-Agent": "StellaBot/5.2 (compatible; like Googlebot)",
|
|
10
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
11
|
+
"Accept-Language": "en-US,en;q=0.5",
|
|
12
|
+
...options.headers,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const req = mod.get(url, { headers, timeout: 15000 }, (res) => {
|
|
16
|
+
// Follow redirects
|
|
17
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
18
|
+
const redirectUrl = new URL(res.headers.location, url).href
|
|
19
|
+
return fetchUrl(redirectUrl, options).then(resolve).catch(reject)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let data = ""
|
|
23
|
+
res.on("data", (chunk) => data += chunk)
|
|
24
|
+
res.on("end", () => resolve({
|
|
25
|
+
status: res.statusCode,
|
|
26
|
+
headers: res.headers,
|
|
27
|
+
html: data,
|
|
28
|
+
url,
|
|
29
|
+
}))
|
|
30
|
+
})
|
|
31
|
+
req.on("error", reject)
|
|
32
|
+
req.on("timeout", () => { req.destroy(); reject(new Error("Timeout")) })
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class WebParser {
|
|
37
|
+
constructor() {
|
|
38
|
+
this.cache = new Map()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async fetchPage(url) {
|
|
42
|
+
if (this.cache.has(url)) {
|
|
43
|
+
return this.cache.get(url)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const result = await fetchUrl(url)
|
|
47
|
+
const dom = new JSDOM(result.html)
|
|
48
|
+
const doc = dom.window.document
|
|
49
|
+
|
|
50
|
+
const parsed = {
|
|
51
|
+
url,
|
|
52
|
+
status: result.status,
|
|
53
|
+
title: doc.querySelector("title")?.textContent?.trim() || "",
|
|
54
|
+
meta: {
|
|
55
|
+
description: doc.querySelector('meta[name="description"]')?.content || "",
|
|
56
|
+
keywords: doc.querySelector('meta[name="keywords"]')?.content || "",
|
|
57
|
+
ogTitle: doc.querySelector('meta[property="og:title"]')?.content || "",
|
|
58
|
+
ogDescription: doc.querySelector('meta[property="og:description"]')?.content || "",
|
|
59
|
+
ogImage: doc.querySelector('meta[property="og:image"]')?.content || "",
|
|
60
|
+
canonical: doc.querySelector('link[rel="canonical"]')?.href || "",
|
|
61
|
+
},
|
|
62
|
+
headings: Array.from(doc.querySelectorAll("h1,h2,h3,h4")).map(h => ({
|
|
63
|
+
level: parseInt(h.tagName[1]),
|
|
64
|
+
text: h.textContent.trim(),
|
|
65
|
+
})),
|
|
66
|
+
links: Array.from(doc.querySelectorAll("a[href]")).map(a => ({
|
|
67
|
+
text: a.textContent.trim().slice(0, 100),
|
|
68
|
+
href: a.href,
|
|
69
|
+
})).filter(l => l.text).slice(0, 100),
|
|
70
|
+
images: Array.from(doc.querySelectorAll("img[src]")).map(img => ({
|
|
71
|
+
src: img.src,
|
|
72
|
+
alt: img.alt || "",
|
|
73
|
+
})).slice(0, 50),
|
|
74
|
+
text: doc.body?.innerText?.trim()?.slice(0, 50000) || "",
|
|
75
|
+
html: result.html,
|
|
76
|
+
linksCount: doc.querySelectorAll("a[href]").length,
|
|
77
|
+
imagesCount: doc.querySelectorAll("img[src]").length,
|
|
78
|
+
formsCount: doc.querySelectorAll("form").length,
|
|
79
|
+
scriptsCount: doc.querySelectorAll("script").length,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.cache.set(url, parsed)
|
|
83
|
+
return parsed
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async extractText(url) {
|
|
87
|
+
const page = await this.fetchPage(url)
|
|
88
|
+
return { success: true, title: page.title, text: page.text }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async extractLinks(url) {
|
|
92
|
+
const page = await this.fetchPage(url)
|
|
93
|
+
return { success: true, links: page.links }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async extractImages(url) {
|
|
97
|
+
const page = await this.fetchPage(url)
|
|
98
|
+
return { success: true, images: page.images }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async extractForms(url) {
|
|
102
|
+
const page = await this.fetchPage(url)
|
|
103
|
+
const dom = new JSDOM(page.html)
|
|
104
|
+
const doc = dom.window.document
|
|
105
|
+
|
|
106
|
+
const forms = Array.from(doc.querySelectorAll("form")).map(f => ({
|
|
107
|
+
action: f.action,
|
|
108
|
+
method: f.method?.toUpperCase() || "GET",
|
|
109
|
+
fields: Array.from(f.querySelectorAll("input,textarea,select,button")).map(el => ({
|
|
110
|
+
tag: el.tagName.toLowerCase(),
|
|
111
|
+
type: el.type || "",
|
|
112
|
+
name: el.name || "",
|
|
113
|
+
placeholder: el.placeholder || "",
|
|
114
|
+
value: el.value || "",
|
|
115
|
+
required: el.required,
|
|
116
|
+
options: el.tagName === "SELECT"
|
|
117
|
+
? Array.from(el.querySelectorAll("option")).map(o => ({ value: o.value, text: o.textContent.trim() }))
|
|
118
|
+
: undefined,
|
|
119
|
+
})),
|
|
120
|
+
}))
|
|
121
|
+
|
|
122
|
+
return { success: true, forms }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async search(query, engine = "duckduckgo") {
|
|
126
|
+
if (engine === "duckduckgo") {
|
|
127
|
+
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`
|
|
128
|
+
const page = await this.fetchPage(url)
|
|
129
|
+
const dom = new JSDOM(page.html)
|
|
130
|
+
const doc = dom.window.document
|
|
131
|
+
|
|
132
|
+
const results = Array.from(doc.querySelectorAll(".result")).map(r => ({
|
|
133
|
+
title: r.querySelector(".result__title")?.textContent?.trim() || "",
|
|
134
|
+
url: r.querySelector(".result__url")?.textContent?.trim() || "",
|
|
135
|
+
snippet: r.querySelector(".result__snippet")?.textContent?.trim() || "",
|
|
136
|
+
})).slice(0, 10)
|
|
137
|
+
|
|
138
|
+
return { success: true, results }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (engine === "google") {
|
|
142
|
+
const url = `https://www.google.com/search?q=${encodeURIComponent(query)}`
|
|
143
|
+
const page = await this.fetchPage(url)
|
|
144
|
+
const dom = new JSDOM(page.html)
|
|
145
|
+
const doc = dom.window.document
|
|
146
|
+
|
|
147
|
+
const results = Array.from(doc.querySelectorAll("div.g,div[data-sokoban-container]")).map(r => ({
|
|
148
|
+
title: r.querySelector("h3")?.textContent?.trim() || "",
|
|
149
|
+
url: r.querySelector("a")?.href || "",
|
|
150
|
+
snippet: r.querySelector(".VwiC3b,.st")?.textContent?.trim() || "",
|
|
151
|
+
})).filter(r => r.title).slice(0, 10)
|
|
152
|
+
|
|
153
|
+
return { success: true, results }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { success: false, error: `Unknown engine: ${engine}` }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async getSEO(url) {
|
|
160
|
+
const page = await this.fetchPage(url)
|
|
161
|
+
const dom = new JSDOM(page.html)
|
|
162
|
+
const doc = dom.window.document
|
|
163
|
+
|
|
164
|
+
const seo = {
|
|
165
|
+
title: page.title,
|
|
166
|
+
titleLength: page.title.length,
|
|
167
|
+
metaDescription: page.meta.description,
|
|
168
|
+
metaDescLength: page.meta.description.length,
|
|
169
|
+
h1Count: doc.querySelectorAll("h1").length,
|
|
170
|
+
hasCanonical: !!page.meta.canonical,
|
|
171
|
+
hasOgTitle: !!page.meta.ogTitle,
|
|
172
|
+
hasOgDescription: !!page.meta.ogDescription,
|
|
173
|
+
hasOgImage: !!page.meta.ogImage,
|
|
174
|
+
imageAlts: Array.from(doc.querySelectorAll("img")).filter(img => !img.alt).length === 0,
|
|
175
|
+
linksInternal: page.links.filter(l => new URL(l.href, url).hostname === new URL(url).hostname).length,
|
|
176
|
+
linksExternal: page.links.filter(l => new URL(l.href, url).hostname !== new URL(url).hostname).length,
|
|
177
|
+
score: 0,
|
|
178
|
+
issues: [],
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Score
|
|
182
|
+
if (seo.titleLength >= 30 && seo.titleLength <= 60) seo.score += 20
|
|
183
|
+
else seo.issues.push(`Title length ${seo.titleLength} (optimal: 30-60)`)
|
|
184
|
+
|
|
185
|
+
if (seo.metaDescLength >= 120 && seo.metaDescLength <= 160) seo.score += 20
|
|
186
|
+
else seo.issues.push(`Meta description length ${seo.metaDescLength} (optimal: 120-160)`)
|
|
187
|
+
|
|
188
|
+
if (seo.h1Count === 1) seo.score += 15
|
|
189
|
+
else seo.issues.push(`H1 count: ${seo.h1Count} (should be 1)`)
|
|
190
|
+
|
|
191
|
+
if (seo.hasCanonical) seo.score += 10
|
|
192
|
+
else seo.issues.push("Missing canonical tag")
|
|
193
|
+
|
|
194
|
+
if (seo.hasOgTitle) seo.score += 10
|
|
195
|
+
else seo.issues.push("Missing og:title")
|
|
196
|
+
|
|
197
|
+
if (seo.hasOgDescription) seo.score += 10
|
|
198
|
+
else seo.issues.push("Missing og:description")
|
|
199
|
+
|
|
200
|
+
if (seo.hasOgImage) seo.score += 5
|
|
201
|
+
else seo.issues.push("Missing og:image")
|
|
202
|
+
|
|
203
|
+
if (seo.imageAlts) seo.score += 10
|
|
204
|
+
else seo.issues.push("Images without alt text")
|
|
205
|
+
|
|
206
|
+
return { success: true, seo }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async batchFetch(urls) {
|
|
210
|
+
const results = await Promise.allSettled(
|
|
211
|
+
urls.map(url => this.fetchPage(url).then(p => ({
|
|
212
|
+
url,
|
|
213
|
+
title: p.title,
|
|
214
|
+
status: p.status,
|
|
215
|
+
textLength: p.text.length,
|
|
216
|
+
})))
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
success: true,
|
|
221
|
+
results: results.map((r, i) => ({
|
|
222
|
+
url: urls[i],
|
|
223
|
+
...(r.status === "fulfilled" ? r.value : { error: r.reason?.message }),
|
|
224
|
+
})),
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export default WebParser
|