spectrawl 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,132 @@
1
+ const Database = require('better-sqlite3')
2
+ const path = require('path')
3
+ const fs = require('fs')
4
+
5
+ class AuthManager {
6
+ constructor(config = {}) {
7
+ const dbPath = config.cookieStore || './data/cookies.db'
8
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true })
9
+
10
+ this.db = new Database(dbPath)
11
+ this.db.pragma('journal_mode = WAL')
12
+ this.refreshInterval = parseInterval(config.refreshInterval || '4h')
13
+
14
+ this._init()
15
+ }
16
+
17
+ _init() {
18
+ this.db.exec(`
19
+ CREATE TABLE IF NOT EXISTS accounts (
20
+ id TEXT PRIMARY KEY,
21
+ platform TEXT NOT NULL,
22
+ account TEXT NOT NULL,
23
+ method TEXT NOT NULL,
24
+ cookies TEXT,
25
+ oauth_token TEXT,
26
+ oauth_refresh TEXT,
27
+ credentials TEXT,
28
+ status TEXT DEFAULT 'unknown',
29
+ last_check INTEGER,
30
+ expires_at INTEGER,
31
+ created_at INTEGER NOT NULL,
32
+ updated_at INTEGER NOT NULL,
33
+ UNIQUE(platform, account)
34
+ )
35
+ `)
36
+ }
37
+
38
+ /**
39
+ * Add a new account.
40
+ */
41
+ async add(platform, opts = {}) {
42
+ const id = `${platform}:${opts.account}`
43
+ const now = Math.floor(Date.now() / 1000)
44
+
45
+ this.db.prepare(`
46
+ INSERT OR REPLACE INTO accounts
47
+ (id, platform, account, method, cookies, oauth_token, oauth_refresh, credentials, status, created_at, updated_at)
48
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
49
+ `).run(
50
+ id, platform, opts.account, opts.method || 'cookie',
51
+ opts.cookies ? JSON.stringify(opts.cookies) : null,
52
+ opts.oauthToken || null,
53
+ opts.oauthRefresh || null,
54
+ opts.credentials ? JSON.stringify(opts.credentials) : null,
55
+ 'valid', now, now
56
+ )
57
+
58
+ return { id, platform, account: opts.account, status: 'valid' }
59
+ }
60
+
61
+ /**
62
+ * Get cookies for a platform/account.
63
+ */
64
+ async getCookies(platformOrId, account) {
65
+ let row
66
+ if (account) {
67
+ row = this.db.prepare('SELECT * FROM accounts WHERE platform = ? AND account = ?').get(platformOrId, account)
68
+ } else {
69
+ // Try as id first, then as platform (return first account)
70
+ row = this.db.prepare('SELECT * FROM accounts WHERE id = ?').get(platformOrId) ||
71
+ this.db.prepare('SELECT * FROM accounts WHERE platform = ? ORDER BY updated_at DESC LIMIT 1').get(platformOrId)
72
+ }
73
+
74
+ if (!row) return null
75
+ if (row.cookies) return JSON.parse(row.cookies)
76
+ return null
77
+ }
78
+
79
+ /**
80
+ * Update cookies for an account.
81
+ */
82
+ async updateCookies(platform, account, cookies, expiresAt) {
83
+ const now = Math.floor(Date.now() / 1000)
84
+ this.db.prepare(`
85
+ UPDATE accounts SET cookies = ?, status = 'valid', expires_at = ?, updated_at = ?, last_check = ?
86
+ WHERE platform = ? AND account = ?
87
+ `).run(JSON.stringify(cookies), expiresAt || null, now, now, platform, account)
88
+ }
89
+
90
+ /**
91
+ * Get health status of all accounts.
92
+ */
93
+ async getStatus() {
94
+ const rows = this.db.prepare('SELECT * FROM accounts ORDER BY platform, account').all()
95
+ const now = Math.floor(Date.now() / 1000)
96
+
97
+ return rows.map(row => {
98
+ let status = row.status
99
+ if (row.expires_at) {
100
+ const remaining = row.expires_at - now
101
+ if (remaining <= 0) status = 'expired'
102
+ else if (remaining < 7200) status = 'expiring'
103
+ }
104
+
105
+ return {
106
+ platform: row.platform,
107
+ account: row.account,
108
+ method: row.method,
109
+ status,
110
+ expiresAt: row.expires_at ? new Date(row.expires_at * 1000).toISOString() : null,
111
+ lastCheck: row.last_check ? new Date(row.last_check * 1000).toISOString() : null
112
+ }
113
+ })
114
+ }
115
+
116
+ /**
117
+ * Remove an account.
118
+ */
119
+ async remove(platform, account) {
120
+ this.db.prepare('DELETE FROM accounts WHERE platform = ? AND account = ?').run(platform, account)
121
+ }
122
+ }
123
+
124
+ function parseInterval(str) {
125
+ const match = str.match(/^(\d+)(h|m|s)$/)
126
+ if (!match) return 14400 // default 4h
127
+ const [, num, unit] = match
128
+ const multipliers = { h: 3600, m: 60, s: 1 }
129
+ return parseInt(num) * (multipliers[unit] || 3600)
130
+ }
131
+
132
+ module.exports = { AuthManager }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Cookie refresh cron.
3
+ * Periodically checks cookie validity and refreshes when needed.
4
+ */
5
+
6
+ class CookieRefresher {
7
+ constructor(authManager, events, config = {}) {
8
+ this.auth = authManager
9
+ this.events = events
10
+ this.interval = config.refreshInterval || 14400 // 4h default
11
+ this.warningThreshold = config.warningThreshold || 7200 // 2h before expiry
12
+ this._timer = null
13
+ }
14
+
15
+ /**
16
+ * Start the refresh cron.
17
+ */
18
+ start() {
19
+ if (this._timer) return
20
+
21
+ // Run immediately, then on interval
22
+ this._check()
23
+ this._timer = setInterval(() => this._check(), this.interval * 1000)
24
+
25
+ console.log(`Cookie refresh cron started (every ${this.interval}s)`)
26
+ }
27
+
28
+ /**
29
+ * Stop the refresh cron.
30
+ */
31
+ stop() {
32
+ if (this._timer) {
33
+ clearInterval(this._timer)
34
+ this._timer = null
35
+ }
36
+ }
37
+
38
+ async _check() {
39
+ try {
40
+ const accounts = await this.auth.getStatus()
41
+ const now = Math.floor(Date.now() / 1000)
42
+
43
+ for (const account of accounts) {
44
+ if (account.status === 'expired') {
45
+ this.events.emit('cookie_expired', {
46
+ platform: account.platform,
47
+ account: account.account,
48
+ expiredAt: account.expiresAt
49
+ })
50
+
51
+ // Attempt auto-refresh
52
+ await this._tryRefresh(account)
53
+ } else if (account.status === 'expiring') {
54
+ const remaining = account.expiresAt
55
+ ? Math.floor((new Date(account.expiresAt).getTime() / 1000) - now)
56
+ : 0
57
+
58
+ this.events.emit('cookie_expiring', {
59
+ platform: account.platform,
60
+ account: account.account,
61
+ expiresIn: remaining,
62
+ expiresAt: account.expiresAt
63
+ })
64
+ }
65
+ }
66
+ } catch (err) {
67
+ console.warn('Cookie refresh check failed:', err.message)
68
+ }
69
+ }
70
+
71
+ async _tryRefresh(account) {
72
+ // Platform-specific refresh strategies
73
+ try {
74
+ switch (account.method) {
75
+ case 'oauth':
76
+ await this._refreshOAuth(account)
77
+ break
78
+ case 'cookie':
79
+ // Can't auto-refresh cookies without browser
80
+ // Emit event so user/agent knows
81
+ this.events.emit('auth_failed', {
82
+ platform: account.platform,
83
+ account: account.account,
84
+ reason: 'Cookie expired. Manual re-login required.',
85
+ suggestion: `Run: spectrawl login ${account.platform} --account ${account.account}`
86
+ })
87
+ break
88
+ }
89
+ } catch (err) {
90
+ this.events.emit('auth_failed', {
91
+ platform: account.platform,
92
+ account: account.account,
93
+ reason: err.message,
94
+ suggestion: `Run: spectrawl login ${account.platform} --account ${account.account}`
95
+ })
96
+ }
97
+ }
98
+
99
+ async _refreshOAuth(account) {
100
+ // TODO: implement OAuth token refresh per platform
101
+ // For now, emit that refresh is needed
102
+ this.events.emit('auth_failed', {
103
+ platform: account.platform,
104
+ account: account.account,
105
+ reason: 'OAuth refresh not yet implemented',
106
+ suggestion: `Run: spectrawl login ${account.platform} --oauth`
107
+ })
108
+ }
109
+ }
110
+
111
+ module.exports = { CookieRefresher }
@@ -0,0 +1,164 @@
1
+ const http = require('http')
2
+
3
+ /**
4
+ * Camoufox client — connects to existing Camoufox HTTP service.
5
+ * Camoufox is a modified Firefox with anti-fingerprint patches.
6
+ * Runs as a persistent service, we just send commands via REST API.
7
+ *
8
+ * Default: http://localhost:9869 (existing service on Hetzner)
9
+ */
10
+ class CamoufoxClient {
11
+ constructor(config = {}) {
12
+ this.baseUrl = config.url || process.env.CAMOUFOX_URL || 'http://localhost:9869'
13
+ this.timeout = config.timeout || 30000
14
+ }
15
+
16
+ /**
17
+ * Check if Camoufox service is running.
18
+ */
19
+ async health() {
20
+ try {
21
+ const data = await this._get('/health')
22
+ return { available: true, url: data.url }
23
+ } catch (e) {
24
+ return { available: false, error: e.message }
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Navigate to a URL.
30
+ */
31
+ async navigate(url, opts = {}) {
32
+ return this._post('/navigate', {
33
+ url,
34
+ timeout: opts.timeout || this.timeout,
35
+ wait: opts.wait || 3000
36
+ })
37
+ }
38
+
39
+ /**
40
+ * Get page text content.
41
+ */
42
+ async getText() {
43
+ return this._get('/text')
44
+ }
45
+
46
+ /**
47
+ * Take a screenshot.
48
+ */
49
+ async screenshot() {
50
+ return this._get('/screenshot')
51
+ }
52
+
53
+ /**
54
+ * Click an element.
55
+ */
56
+ async click(selector, opts = {}) {
57
+ return this._post('/click', {
58
+ selector,
59
+ timeout: opts.timeout || 10000,
60
+ wait: opts.wait || 1000
61
+ })
62
+ }
63
+
64
+ /**
65
+ * Type text.
66
+ */
67
+ async type(text, opts = {}) {
68
+ return this._post('/type', {
69
+ text,
70
+ delay: opts.delay || 20
71
+ })
72
+ }
73
+
74
+ /**
75
+ * Press a key.
76
+ */
77
+ async press(key) {
78
+ return this._post('/press', { key })
79
+ }
80
+
81
+ /**
82
+ * Update cookies.
83
+ */
84
+ async setCookies(cookies) {
85
+ return this._post('/cookies', { cookies })
86
+ }
87
+
88
+ /**
89
+ * Post to Reddit (uses built-in Reddit automation).
90
+ */
91
+ async redditPost(subreddit, title, body) {
92
+ return this._post('/post', { subreddit, title, body })
93
+ }
94
+
95
+ /**
96
+ * Reply to Reddit post/comment.
97
+ */
98
+ async redditReply(url, text) {
99
+ return this._post('/reply', { url, text })
100
+ }
101
+
102
+ /**
103
+ * Delete a Reddit post.
104
+ */
105
+ async redditDelete(url) {
106
+ return this._post('/delete', { url })
107
+ }
108
+
109
+ async _get(path) {
110
+ return new Promise((resolve, reject) => {
111
+ const urlObj = new URL(this.baseUrl + path)
112
+ http.get({
113
+ hostname: urlObj.hostname,
114
+ port: urlObj.port,
115
+ path: urlObj.pathname,
116
+ timeout: this.timeout
117
+ }, res => {
118
+ let data = ''
119
+ res.on('data', c => data += c)
120
+ res.on('end', () => {
121
+ try {
122
+ const parsed = JSON.parse(data)
123
+ if (res.statusCode >= 400) reject(new Error(parsed.error || `HTTP ${res.statusCode}`))
124
+ else resolve(parsed)
125
+ } catch (e) { reject(new Error(`Invalid response from Camoufox: ${data.slice(0, 200)}`)) }
126
+ })
127
+ }).on('error', reject)
128
+ })
129
+ }
130
+
131
+ async _post(path, body) {
132
+ return new Promise((resolve, reject) => {
133
+ const urlObj = new URL(this.baseUrl + path)
134
+ const bodyStr = JSON.stringify(body)
135
+ const opts = {
136
+ hostname: urlObj.hostname,
137
+ port: urlObj.port,
138
+ path: urlObj.pathname,
139
+ method: 'POST',
140
+ headers: {
141
+ 'Content-Type': 'application/json',
142
+ 'Content-Length': Buffer.byteLength(bodyStr)
143
+ },
144
+ timeout: this.timeout
145
+ }
146
+ const req = http.request(opts, res => {
147
+ let data = ''
148
+ res.on('data', c => data += c)
149
+ res.on('end', () => {
150
+ try {
151
+ const parsed = JSON.parse(data)
152
+ if (res.statusCode >= 400) reject(new Error(parsed.error || `HTTP ${res.statusCode}`))
153
+ else resolve(parsed)
154
+ } catch (e) { reject(new Error(`Invalid response from Camoufox: ${data.slice(0, 200)}`)) }
155
+ })
156
+ })
157
+ req.on('error', reject)
158
+ req.write(bodyStr)
159
+ req.end()
160
+ })
161
+ }
162
+ }
163
+
164
+ module.exports = { CamoufoxClient }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Browse engine — three tiers of stealth.
3
+ *
4
+ * Tier 1: playwright-extra + stealth plugin (default, npm install)
5
+ * Tier 2: Camoufox binary (npx spectrawl install-stealth, engine-level anti-detect)
6
+ * Tier 3: Remote Camoufox service (set camoufox.url, for existing deployments)
7
+ *
8
+ * Auto-detects best available. No config needed for most users.
9
+ */
10
+
11
+ const os = require('os')
12
+ const path = require('path')
13
+ const { CamoufoxClient } = require('./camoufox')
14
+ const { getCamoufoxPath, isInstalled } = require('./install-stealth')
15
+
16
+ class BrowseEngine {
17
+ constructor(config = {}, cache) {
18
+ this.config = config
19
+ this.cache = cache
20
+ this.browser = null
21
+
22
+ // Remote Camoufox service (existing deployment)
23
+ this.remoteCamoufox = config.camoufox?.url ? new CamoufoxClient(config.camoufox) : null
24
+ this._remoteCamoufoxAvailable = null
25
+
26
+ // Which engine we're using
27
+ this._engine = null
28
+ }
29
+
30
+ /**
31
+ * Browse a URL and extract content.
32
+ */
33
+ async browse(url, opts = {}) {
34
+ if (!opts.noCache && !opts.screenshot) {
35
+ const cached = this.cache?.get('scrape', url)
36
+ if (cached) return { ...cached, cached: true }
37
+ }
38
+
39
+ // Force remote Camoufox if explicitly requested
40
+ if (opts.camoufox && this.remoteCamoufox) {
41
+ return this._browseRemoteCamoufox(url, opts)
42
+ }
43
+
44
+ try {
45
+ return await this._browsePlaywright(url, opts)
46
+ } catch (err) {
47
+ // If blocked and remote Camoufox available, try that
48
+ if (this._isBlocked(err) && this.remoteCamoufox) {
49
+ console.log(`Blocked on ${url}, escalating to remote Camoufox`)
50
+ return this._browseRemoteCamoufox(url, opts)
51
+ }
52
+
53
+ if (this._isBlocked(err)) {
54
+ const hint = isInstalled()
55
+ ? 'Site has strong anti-bot. Try configuring a residential proxy.'
56
+ : 'Run `npx spectrawl install-stealth` for engine-level anti-detect.'
57
+ err.message = `Blocked on ${url}: ${err.message}. ${hint}`
58
+ }
59
+ throw err
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Launch Playwright with the best available browser.
65
+ * Priority: Camoufox binary > stealth Chromium > vanilla Chromium
66
+ */
67
+ async _getBrowser() {
68
+ if (this.browser) return this.browser
69
+
70
+ // Tier 2: Local Camoufox binary (engine-level anti-detect)
71
+ const camoufoxBinary = getCamoufoxPath()
72
+ if (camoufoxBinary) {
73
+ try {
74
+ const { firefox } = require('playwright')
75
+ this.browser = await firefox.launch({
76
+ executablePath: camoufoxBinary,
77
+ headless: true,
78
+ args: ['--no-remote']
79
+ })
80
+ this._engine = 'camoufox'
81
+ console.log('Browse engine: Camoufox (engine-level anti-detect)')
82
+ return this.browser
83
+ } catch (e) {
84
+ console.log(`Camoufox binary failed: ${e.message}, falling back`)
85
+ }
86
+ }
87
+
88
+ // Tier 1: playwright-extra + stealth plugin
89
+ try {
90
+ const { chromium } = require('playwright-extra')
91
+ const stealth = require('puppeteer-extra-plugin-stealth')
92
+ chromium.use(stealth())
93
+
94
+ this.browser = await chromium.launch({
95
+ headless: true,
96
+ args: ['--no-sandbox', '--disable-setuid-sandbox']
97
+ })
98
+ this._engine = 'stealth-playwright'
99
+ console.log('Browse engine: stealth Playwright (JS-level anti-detect)')
100
+ return this.browser
101
+ } catch (e) {
102
+ // Tier 0: vanilla playwright
103
+ const { chromium } = require('playwright')
104
+ this.browser = await chromium.launch({
105
+ headless: true,
106
+ args: ['--no-sandbox', '--disable-setuid-sandbox']
107
+ })
108
+ this._engine = 'playwright'
109
+ console.log('Browse engine: vanilla Playwright (no anti-detect — install playwright-extra)')
110
+ return this.browser
111
+ }
112
+ }
113
+
114
+ async _browsePlaywright(url, opts) {
115
+ const browser = await this._getBrowser()
116
+ const context = await this._createContext(browser, opts)
117
+ const page = await context.newPage()
118
+
119
+ try {
120
+ if (opts._cookies) {
121
+ await context.addCookies(opts._cookies)
122
+ }
123
+
124
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 })
125
+
126
+ // Human-like delays
127
+ await page.waitForTimeout(800 + Math.random() * 1500)
128
+ await page.evaluate(() => {
129
+ window.scrollBy({ top: Math.floor(Math.random() * 400) + 100, behavior: 'smooth' })
130
+ })
131
+ await page.waitForTimeout(300 + Math.random() * 700)
132
+
133
+ const result = {}
134
+
135
+ if (opts.extract !== false) {
136
+ result.content = await page.evaluate(() => {
137
+ const main = document.querySelector('main, article, [role="main"]') || document.body
138
+ return main.innerText
139
+ })
140
+ }
141
+
142
+ if (opts.html) result.html = await page.content()
143
+
144
+ if (opts.screenshot) {
145
+ result.screenshot = await page.screenshot({
146
+ type: 'png', fullPage: opts.fullPage || false
147
+ })
148
+ }
149
+
150
+ if (opts.saveCookies) result.cookies = await context.cookies()
151
+
152
+ result.url = page.url()
153
+ result.title = await page.title()
154
+ result.cached = false
155
+ result.engine = this._engine
156
+
157
+ if (!opts.screenshot) {
158
+ this.cache?.set('scrape', url, { content: result.content, url: result.url, title: result.title })
159
+ }
160
+
161
+ return result
162
+ } finally {
163
+ await page.close()
164
+ await context.close()
165
+ }
166
+ }
167
+
168
+ async _browseRemoteCamoufox(url, opts) {
169
+ if (this._remoteCamoufoxAvailable === null) {
170
+ const health = await this.remoteCamoufox.health()
171
+ this._remoteCamoufoxAvailable = health.available
172
+ }
173
+
174
+ if (!this._remoteCamoufoxAvailable) {
175
+ throw new Error('Remote Camoufox configured but not running. Check camoufox.url.')
176
+ }
177
+
178
+ if (opts._cookies) await this.remoteCamoufox.setCookies(opts._cookies)
179
+ await this.remoteCamoufox.navigate(url, { wait: 3000 })
180
+
181
+ const result = { engine: 'remote-camoufox', cached: false }
182
+
183
+ if (opts.extract !== false) {
184
+ const textData = await this.remoteCamoufox.getText()
185
+ result.content = textData.text
186
+ result.title = textData.title
187
+ result.url = textData.url
188
+ }
189
+
190
+ if (opts.screenshot) {
191
+ const ssData = await this.remoteCamoufox.screenshot()
192
+ result.screenshotPath = ssData.path
193
+ }
194
+
195
+ if (!opts.screenshot) {
196
+ this.cache?.set('scrape', url, { content: result.content, url: result.url, title: result.title })
197
+ }
198
+
199
+ return result
200
+ }
201
+
202
+ _isBlocked(err) {
203
+ const msg = (err.message || '').toLowerCase()
204
+ return msg.includes('captcha') || msg.includes('blocked') || msg.includes('403') ||
205
+ msg.includes('access denied') || msg.includes('challenge') ||
206
+ msg.includes('cloudflare') || msg.includes('bot detection')
207
+ }
208
+
209
+ async _createContext(browser, opts) {
210
+ const resolutions = [
211
+ { width: 1920, height: 1080 }, { width: 1536, height: 864 },
212
+ { width: 1440, height: 900 }, { width: 1366, height: 768 },
213
+ { width: 2560, height: 1440 }
214
+ ]
215
+ const viewport = resolutions[Math.floor(Math.random() * resolutions.length)]
216
+
217
+ const userAgents = [
218
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
219
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
220
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0',
221
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15'
222
+ ]
223
+
224
+ const contextOpts = {
225
+ userAgent: userAgents[Math.floor(Math.random() * userAgents.length)],
226
+ viewport,
227
+ locale: 'en-US',
228
+ timezoneId: 'America/New_York',
229
+ colorScheme: 'light',
230
+ deviceScaleFactor: Math.random() > 0.5 ? 1 : 2
231
+ }
232
+
233
+ if (this.config.proxy) {
234
+ contextOpts.proxy = {
235
+ server: `${this.config.proxy.host}:${this.config.proxy.port}`,
236
+ username: this.config.proxy.username,
237
+ password: this.config.proxy.password
238
+ }
239
+ }
240
+
241
+ return browser.newContext(contextOpts)
242
+ }
243
+
244
+ /**
245
+ * Get a raw Playwright page for direct interaction.
246
+ * Used by platform adapters that need browser automation (e.g., IH).
247
+ * Caller is responsible for closing the page and context.
248
+ *
249
+ * @param {object} opts - { _cookies, url }
250
+ * @returns {{ page, context, engine }}
251
+ */
252
+ async getPage(opts = {}) {
253
+ const browser = await this._getBrowser()
254
+ const context = await this._createContext(browser, opts)
255
+
256
+ if (opts._cookies) {
257
+ await context.addCookies(opts._cookies)
258
+ }
259
+
260
+ const page = await context.newPage()
261
+
262
+ if (opts.url) {
263
+ await page.goto(opts.url, { waitUntil: 'domcontentloaded', timeout: 30000 })
264
+ await page.waitForTimeout(800 + Math.random() * 1500)
265
+ }
266
+
267
+ return { page, context, engine: this._engine }
268
+ }
269
+
270
+ async close() {
271
+ if (this.browser) {
272
+ await this.browser.close()
273
+ this.browser = null
274
+ }
275
+ }
276
+ }
277
+
278
+ module.exports = { BrowseEngine }