flaresolverr 0.0.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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright © 2025 Kiko Beats <josefrancisco.verdu@gmail.com> (kikobeats.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # flaresolverr
2
+
3
+ <p align="center">
4
+ <br>
5
+ <img src="https://i.imgur.com/Mh13XWB.gif" alt="flaresolverr">
6
+ <br>
7
+ </p>
8
+
9
+ ![Last version](https://img.shields.io/github/tag/kikobeats/flaresolverr.svg?style=flat-square)
10
+ [![Coverage Status](https://img.shields.io/coveralls/kikobeats/flaresolverr.svg?style=flat-square)](https://coveralls.io/github/kikobeats/flaresolverr)
11
+ [![NPM Status](https://img.shields.io/npm/dm/flaresolverr.svg?style=flat-square)](https://www.npmjs.org/package/flaresolverr)
12
+
13
+ **NOTE:** more badges availables in [shields.io](https://shields.io/)
14
+
15
+ > A Node.js port of FlareSolverr
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ $ npm install flaresolverr --global
21
+ ```
22
+
23
+ ## CLI
24
+
25
+ ```bash
26
+ $ flaresolverr --help
27
+
28
+ Generates regular expressions that match a set of strings.
29
+
30
+ Usage
31
+ $ flaresolverr [-gimuy] string1 string2 string3...
32
+
33
+ Examples
34
+ $ flaresolverr foobar foobaz foozap fooza
35
+ $ jq '.keywords' package.json | flaresolverr
36
+ ```
37
+
38
+ ## License
39
+
40
+ **flaresolverr** © [Kiko Beats](https://kikobeats.com), released under the [MIT](https://github.com/kikobeats/flaresolverr/blob/master/LICENSE.md) License.<br>
41
+ Authored and maintained by [Kiko Beats](https://kikobeats.com) with help from [contributors](https://github.com/kikobeats/flaresolverr/contributors).
42
+
43
+ > [kikobeats.com](https://kikobeats.com) · GitHub [Kiko Beats](https://github.com/kikobeats) · Twitter [@kikobeats](https://twitter.com/kikobeats)
package/bin/help.txt ADDED
@@ -0,0 +1,13 @@
1
+ Usage
2
+ $ flaresolverr <command>[options]
3
+
4
+ Commands
5
+ --file Read the file
6
+
7
+ Options
8
+ --wait Wait for the app to exit
9
+
10
+ Examples
11
+ $ npm-url # Open the current package if you are over package.json path.
12
+ $ npm-url json-future
13
+ $ npm-url json-future -- 'google chrome' --incognito
package/bin/index.js ADDED
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ const pkg = require('../package.json')
5
+ const mri = require('mri')
6
+
7
+ require('update-notifier')({ pkg }).notify()
8
+
9
+ const { _, ...flags } = mri(process.argv.slice(2), {
10
+ /* https://github.com/lukeed/mri#usage< */
11
+ default: {
12
+ token: process.env.GH_TOKEN || process.env.GITHUB_TOKEN
13
+ }
14
+ })
15
+
16
+ if (flags.help) {
17
+ console.log(require('fs').readFileSync('./help.txt', 'utf8'))
18
+ process.exit(0)
19
+ }
20
+
21
+ Promise.resolve(
22
+ require('flaresolverr')({
23
+ ...flags
24
+ })
25
+ )
26
+ .then(() => {
27
+ process.exit(0)
28
+ })
29
+ .catch(error => {
30
+ console.error(error)
31
+ process.exit(1)
32
+ })
package/package.json ADDED
@@ -0,0 +1,102 @@
1
+ {
2
+ "name": "flaresolverr",
3
+ "description": "A Node.js port of FlareSolverr",
4
+ "homepage": "https://github.com/kikobeats/flaresolverr",
5
+ "version": "0.0.0",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./cli/index.js"
9
+ },
10
+ "bin": {
11
+ "flaresolverr": "bin/index.js"
12
+ },
13
+ "author": {
14
+ "email": "josefrancisco.verdu@gmail.com",
15
+ "name": "Kiko Beats",
16
+ "url": "https://kikobeats.com"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/kikobeats/flaresolverr.git"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/kikobeats/flaresolverr/issues"
24
+ },
25
+ "keywords": [
26
+ "flaresolverr"
27
+ ],
28
+ "dependencies": {
29
+ "mri": "~1.2.0"
30
+ },
31
+ "devDependencies": {
32
+ "@browserless/test": "latest",
33
+ "@commitlint/cli": "latest",
34
+ "@commitlint/config-conventional": "latest",
35
+ "@ksmithut/prettier-standard": "latest",
36
+ "async-listen": "latest",
37
+ "ava": "latest",
38
+ "browserless": "latest",
39
+ "c8": "latest",
40
+ "finepack": "latest",
41
+ "git-authors-cli": "latest",
42
+ "github-generate-release": "latest",
43
+ "nano-staged": "latest",
44
+ "simple-git-hooks": "latest",
45
+ "standard": "latest",
46
+ "standard-markdown": "latest",
47
+ "standard-version": "latest"
48
+ },
49
+ "engines": {
50
+ "node": ">= 20"
51
+ },
52
+ "files": [
53
+ "bin",
54
+ "src"
55
+ ],
56
+ "preferGlobal": true,
57
+ "license": "MIT",
58
+ "ava": {
59
+ "files": [
60
+ "test/**/*.js",
61
+ "!test/util.js"
62
+ ]
63
+ },
64
+ "commitlint": {
65
+ "extends": [
66
+ "@commitlint/config-conventional"
67
+ ],
68
+ "rules": {
69
+ "body-max-line-length": [
70
+ 0
71
+ ]
72
+ }
73
+ },
74
+ "nano-staged": {
75
+ "*.js": [
76
+ "prettier-standard",
77
+ "standard --fix"
78
+ ],
79
+ "*.md": [
80
+ "standard-markdown"
81
+ ],
82
+ "package.json": [
83
+ "finepack"
84
+ ]
85
+ },
86
+ "simple-git-hooks": {
87
+ "commit-msg": "npx commitlint --edit",
88
+ "pre-commit": "npx nano-staged"
89
+ },
90
+ "scripts": {
91
+ "clean": "rm -rf node_modules",
92
+ "contributors": "(npx git-authors-cli && npx finepack && git add package.json && git commit -m 'build: contributors' --no-verify) || true",
93
+ "coverage": "c8 report --reporter=text-lcov > coverage/lcov.info",
94
+ "lint": "standard-markdown README.md && standard",
95
+ "postrelease": "npm run release:tags && npm run release:github && npm publish",
96
+ "pretest": "npm run lint",
97
+ "release": "standard-version -a",
98
+ "release:github": "github-generate-release",
99
+ "release:tags": "git push --follow-tags origin HEAD:master",
100
+ "test": "c8 ava"
101
+ }
102
+ }
@@ -0,0 +1,87 @@
1
+ 'use strict'
2
+
3
+ module.exports = {
4
+ /**
5
+ * CSS selectors for DOM elements that indicate a blocked request.
6
+ * These elements contain error codes and messages shown on block pages.
7
+ */
8
+ ACCESS_DENIED_SELECTORS: [
9
+ 'div.cf-error-title span.cf-code-label span',
10
+ '#cf-error-details div.cf-error-overview h1'
11
+ ],
12
+
13
+ /**
14
+ * Page titles that indicate the request has been blocked entirely.
15
+ * Unlike challenges, blocks cannot be bypassed and require different handling.
16
+ */
17
+ ACCESS_DENIED_TITLES: ['Access denied', 'Attention Required! | Cloudflare'],
18
+
19
+ /**
20
+ * Cookie names that indicate a challenge bypass has been granted.
21
+ * - cf_clearance: Cloudflare's main bypass cookie
22
+ * - __ddg*: DDoS-Guard cookies
23
+ * - fl_pass*: FlareSolverr-specific cookies
24
+ */
25
+ CHALLENGE_COOKIE_PREFIXES: ['cf_clearance', '__ddg', 'fl_pass'],
26
+
27
+ /**
28
+ * CSS selectors for DOM elements that indicate an active challenge.
29
+ * These elements are present during the challenge verification process.
30
+ *
31
+ * - #cf-challenge-running: Main Cloudflare challenge container
32
+ * - .ray_id: Cloudflare Ray ID display (shown during challenges)
33
+ * - .attack-box: DDoS protection warning box
34
+ * - #cf-please-wait: "Please wait" message during verification
35
+ * - #challenge-spinner: Loading spinner during challenge processing
36
+ * - #trk_jschal_js: JavaScript challenge tracking element
37
+ * - #turnstile-wrapper: Cloudflare Turnstile CAPTCHA container
38
+ * - .lds-ring: Loading ring animation (common in challenge pages)
39
+ */
40
+ CHALLENGE_SELECTORS: [
41
+ '#cf-challenge-running',
42
+ '.ray_id',
43
+ '.attack-box',
44
+ '#cf-please-wait',
45
+ '#challenge-spinner',
46
+ '#trk_jschal_js',
47
+ '#turnstile-wrapper',
48
+ '.lds-ring'
49
+ ],
50
+
51
+ /**
52
+ * Page titles that indicate an active challenge is being presented.
53
+ * - "Just a moment..." - Cloudflare's standard challenge page title
54
+ * - "DDoS-Guard" - Alternative protection service
55
+ */
56
+ CHALLENGE_TITLES: ['Just a moment...', 'DDoS-Guard'],
57
+
58
+ /**
59
+ * Time to wait after navigation to ensure cookies are persisted.
60
+ * Cloudflare sets cookies asynchronously, so we need a small buffer.
61
+ */
62
+ COOKIE_PERSISTENCE_DELAY_MS: 500,
63
+
64
+ /**
65
+ * Default timeout for waiting on challenge resolution.
66
+ * If the challenge doesn't resolve within this time, we attempt manual verification.
67
+ */
68
+ DEFAULT_CHALLENGE_TIMEOUT_MS: 5000,
69
+
70
+ /**
71
+ * Default maximum number of attempts to solve a challenge.
72
+ * Each attempt includes waiting for auto-resolution and manual verification.
73
+ */
74
+ DEFAULT_MAX_ATTEMPTS: 10,
75
+
76
+ /**
77
+ * Short delay between keyboard actions during manual verification.
78
+ * Simulates human-like interaction timing.
79
+ */
80
+ KEYBOARD_ACTION_DELAY_MS: 100,
81
+
82
+ /**
83
+ * Time to wait after clicking the verify button.
84
+ * Allows the challenge to process the interaction.
85
+ */
86
+ POST_VERIFY_CLICK_DELAY_MS: 2000
87
+ }
package/src/index.js ADDED
@@ -0,0 +1,308 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Port of FlareSolverr to browserless.
5
+ *
6
+ * FlareSolverr is a proxy server to bypass Cloudflare and DDoS-Guard protection.
7
+ * This module implements the core challenge-solving logic using Puppeteer.
8
+ *
9
+ * @see https://github.com/FlareSolverr/FlareSolverr
10
+ */
11
+
12
+ const { setTimeout } = require('node:timers/promises')
13
+ const debug = require('debug-logfmt')('flaresolverr')
14
+
15
+ const { generatePostFormHtml, isChallengeCookie, parsePostData } = require('./util')
16
+
17
+ const {
18
+ ACCESS_DENIED_SELECTORS,
19
+ ACCESS_DENIED_TITLES,
20
+ CHALLENGE_SELECTORS,
21
+ CHALLENGE_TITLES,
22
+ COOKIE_PERSISTENCE_DELAY_MS,
23
+ DEFAULT_CHALLENGE_TIMEOUT_MS,
24
+ DEFAULT_MAX_ATTEMPTS,
25
+ KEYBOARD_ACTION_DELAY_MS,
26
+ POST_VERIFY_CLICK_DELAY_MS
27
+ } = require('./constants')
28
+
29
+ /* =============================================================================
30
+ * DETECTION FUNCTIONS
31
+ * ============================================================================= */
32
+
33
+ /**
34
+ * Checks if the current page is presenting a challenge page (e.g., Cloudflare or DDoS-Guard).
35
+ *
36
+ * Detection is performed in two phases:
37
+ * 1. Title matching: Fast check against known challenge page titles
38
+ * 2. Selector matching: DOM inspection for challenge-specific elements
39
+ *
40
+ * @param {import('puppeteer').Page} page - The Puppeteer page instance to check for challenges.
41
+ * @returns {Promise<boolean>} - Resolves to true if a challenge page is detected, otherwise false.
42
+ */
43
+ const isChallenge = async page => {
44
+ // Phase 1: Check page title for known challenge indicators
45
+ const title = await page.title()
46
+ const matchedTitle = CHALLENGE_TITLES.find(challengeTitle => title.includes(challengeTitle))
47
+ if (matchedTitle) {
48
+ debug('isChallenge:title', { title, matchedTitle })
49
+ return true
50
+ }
51
+
52
+ // Phase 2: Check DOM for challenge-specific elements
53
+ for (const selector of CHALLENGE_SELECTORS) {
54
+ const element = await page.$(selector)
55
+ if (element) {
56
+ debug('isChallenge:selector', { selector })
57
+ return true
58
+ }
59
+ }
60
+
61
+ return false
62
+ }
63
+
64
+ /**
65
+ * Checks if the current page is blocked by an access denied or Cloudflare block page.
66
+ *
67
+ * Block pages differ from challenges in that they cannot be bypassed.
68
+ * When detected, the calling code should fail fast rather than retry.
69
+ *
70
+ * @param {import('puppeteer').Page} page - The Puppeteer page instance to check for blocks.
71
+ * @returns {Promise<boolean>} - True if a block page is detected, otherwise false.
72
+ */
73
+ const isBlock = async page => {
74
+ // Phase 1: Check page title for known block indicators
75
+ const title = await page.title()
76
+ const matchedTitle = ACCESS_DENIED_TITLES.find(blockTitle => title.includes(blockTitle))
77
+ if (matchedTitle) {
78
+ debug('isBlock:title', { title, matchedTitle })
79
+ return true
80
+ }
81
+
82
+ // Phase 2: Check DOM for block-specific elements
83
+ for (const selector of ACCESS_DENIED_SELECTORS) {
84
+ const element = await page.$(selector)
85
+ if (element) {
86
+ debug('isBlock:selector', { selector })
87
+ return true
88
+ }
89
+ }
90
+
91
+ return false
92
+ }
93
+
94
+ /**
95
+ * Attempts to bypass Cloudflare and DDoS-Guard protection for a given URL.
96
+ *
97
+ * The bypass process works as follows:
98
+ * 1. Navigate to the target URL
99
+ * 2. Detect if a challenge is present
100
+ * 3. Wait for automatic challenge resolution (JavaScript-based)
101
+ * 4. If auto-resolution fails, attempt manual verification via keyboard simulation
102
+ * 5. Repeat until challenge is solved or max attempts reached
103
+ *
104
+ * @param {string} url - The URL to access.
105
+ * @param {Object} options - Configuration options.
106
+ * @param {number} [options.attempt=10] - Maximum number of challenge-solving attempts.
107
+ * @param {string} [options.method='GET'] - HTTP method (GET or POST).
108
+ * @param {Function} options.getBrowserless - Function that returns a browserless instance.
109
+ * @param {string} [options.postData] - URL-encoded POST data (required if method is POST).
110
+ * @param {number} [options.timeout=5000] - Timeout for challenge resolution in milliseconds.
111
+ * @param {boolean} [options.returnOnlyCookies] - If true, only return cookies without page content.
112
+ * @param {Array} [options.cookies] - Cookies to set before navigation.
113
+ * @returns {Promise<Object>} - The result object containing status, message, and solution.
114
+ */
115
+ const flaresolverr = async (
116
+ url,
117
+ {
118
+ attempt: maxAttempts = DEFAULT_MAX_ATTEMPTS,
119
+ method = 'GET',
120
+ getBrowserless,
121
+ postData,
122
+ timeout: challengeTimeout = DEFAULT_CHALLENGE_TIMEOUT_MS,
123
+ ...opts
124
+ } = {}
125
+ ) => {
126
+ const browserless = await getBrowserless()
127
+
128
+ const result = await browserless.withPage(
129
+ (page, goto) =>
130
+ async (url, { method, postData, cookies }) => {
131
+ let challengeDetected = false
132
+
133
+ /**
134
+ * Navigates to the target URL, handling both GET and POST requests.
135
+ * POST requests are handled by injecting a form and submitting it,
136
+ * which allows proper challenge handling on POST endpoints.
137
+ */
138
+ const navigate = async () => {
139
+ debug('navigate', { url, method, postData })
140
+
141
+ // Set cookies before navigation if provided
142
+ if (cookies && cookies.length > 0) {
143
+ const cookiesWithDomain = cookies.map(cookie => ({
144
+ ...cookie,
145
+ // If no URL or domain is specified, use the target URL
146
+ url: cookie.url || cookie.domain ? undefined : url
147
+ }))
148
+ debug('navigate:cookies', { count: cookiesWithDomain.length })
149
+ await page.setCookie(...cookiesWithDomain)
150
+ }
151
+
152
+ if (method === 'POST') {
153
+ debug('navigate:post')
154
+ const formFields = parsePostData(postData)
155
+ const formHtml = generatePostFormHtml(url, formFields)
156
+ await page.setContent(formHtml)
157
+ } else {
158
+ const timeout = opts.timeout || challengeTimeout
159
+ debug('navigate:get', { timeout, ...opts })
160
+ const { response, error } = await goto(page, {
161
+ url,
162
+ ...opts,
163
+ timeout
164
+ })
165
+
166
+ if (error) {
167
+ debug('navigate:get:error', { message: error.message, code: error.code })
168
+ throw error
169
+ }
170
+
171
+ debug('navigate:get:response', {
172
+ status: response ? response.status() : 'no response',
173
+ title: await page.title()
174
+ })
175
+ }
176
+
177
+ debug('navigate:finished')
178
+ }
179
+
180
+ await navigate()
181
+
182
+ // Wait for cookies to be persisted after navigation
183
+ await setTimeout(COOKIE_PERSISTENCE_DELAY_MS)
184
+
185
+ // Check for challenge cookies that indicate a challenge occurred
186
+ const initialCookies = await page.cookies()
187
+ const hasChallengeCookie = initialCookies.some(isChallengeCookie)
188
+
189
+ if (hasChallengeCookie) {
190
+ debug('challenge:detected:cookie')
191
+ challengeDetected = true
192
+ }
193
+
194
+ /**
195
+ * Attempts to manually verify the challenge using keyboard simulation.
196
+ * This mimics a user pressing Tab to focus the verify button, then Space to click it.
197
+ * Used when automatic JavaScript-based resolution fails.
198
+ */
199
+ const attemptManualVerification = async () => {
200
+ debug('manualVerification:attempt')
201
+
202
+ // Wait for the challenge UI to be ready
203
+ await setTimeout(challengeTimeout)
204
+
205
+ // Tab to focus the verify button
206
+ await page.keyboard.press('Tab')
207
+ await setTimeout(KEYBOARD_ACTION_DELAY_MS)
208
+
209
+ // Press Space to activate the button
210
+ await page.keyboard.press('Space')
211
+
212
+ // Wait for the verification to process
213
+ await setTimeout(POST_VERIFY_CLICK_DELAY_MS)
214
+
215
+ debug('manualVerification:finished')
216
+ }
217
+
218
+ // Fail fast if the page is blocked (not just challenged)
219
+ if (await isBlock(page)) {
220
+ debug('block:detected')
221
+ throw new Error('Cloudflare has blocked this request.')
222
+ }
223
+
224
+ // Start attempt counter based on whether we've already detected a challenge
225
+ let attemptCount = challengeDetected ? 1 : 0
226
+
227
+ // Challenge resolution loop
228
+ while ((await isChallenge(page)) && attemptCount < maxAttempts) {
229
+ attemptCount++
230
+ debug('challenge:attempt', { attempt: attemptCount, maxAttempts })
231
+
232
+ try {
233
+ debug('challenge:wait')
234
+
235
+ /**
236
+ * Wait for the challenge to resolve automatically.
237
+ * The challenge is considered resolved when:
238
+ * 1. The page title no longer matches any challenge titles
239
+ * 2. No challenge-specific DOM elements are present
240
+ */
241
+ await page.waitForFunction(
242
+ (titles, selectors) => {
243
+ const title = document.title
244
+ // Check if title still indicates a challenge
245
+ if (titles.some(challengeTitle => title.includes(challengeTitle))) {
246
+ return false
247
+ }
248
+ // Check if any challenge elements are still present
249
+ for (const selector of selectors) {
250
+ if (document.querySelector(selector)) {
251
+ return false
252
+ }
253
+ }
254
+ return true
255
+ },
256
+ { timeout: challengeTimeout },
257
+ CHALLENGE_TITLES,
258
+ CHALLENGE_SELECTORS
259
+ )
260
+
261
+ debug('challenge:solved:auto')
262
+ break
263
+ } catch (err) {
264
+ // Auto-resolution timed out, attempt manual verification
265
+ debug('challenge:retry', { message: err.message })
266
+ await attemptManualVerification()
267
+ }
268
+ }
269
+
270
+ if (attemptCount === 0) {
271
+ debug('challenge:not_detected')
272
+ } else if (attemptCount >= maxAttempts) {
273
+ debug('challenge:max_attempts_reached')
274
+ }
275
+
276
+ // Gather final page state
277
+ const [resolvedCookies, userAgent, content] = await Promise.all([
278
+ page.cookies(),
279
+ page.evaluate(() => navigator.userAgent),
280
+ page.content()
281
+ ])
282
+
283
+ debug('result:cookies', { names: resolvedCookies.map(c => c.name) })
284
+
285
+ const challengeWasSolved = attemptCount > 0
286
+
287
+ return {
288
+ status: 'ok',
289
+ message: challengeWasSolved ? 'Challenge solved!' : 'Challenge not detected!',
290
+ solution: {
291
+ url: page.url(),
292
+ status: 200,
293
+ cookies: resolvedCookies,
294
+ userAgent,
295
+ response: opts.returnOnlyCookies ? null : content,
296
+ headers: opts.returnOnlyCookies ? null : {}
297
+ }
298
+ }
299
+ }
300
+ )(url, { method, postData, ...opts })
301
+
302
+ return result
303
+ }
304
+
305
+ flaresolverr.isChallenge = isChallenge
306
+ flaresolverr.isBlock = isBlock
307
+
308
+ module.exports = flaresolverr
package/src/util.js ADDED
@@ -0,0 +1,67 @@
1
+ 'use strict'
2
+
3
+ const { CHALLENGE_COOKIE_PREFIXES } = require('./constants')
4
+
5
+ /**
6
+ * Checks if a cookie indicates a challenge bypass has been granted.
7
+ *
8
+ * @param {Object} cookie - The cookie object to check.
9
+ * @param {string} cookie.name - The name of the cookie.
10
+ * @returns {boolean} - True if the cookie indicates a challenge bypass.
11
+ */
12
+ const isChallengeCookie = cookie =>
13
+ CHALLENGE_COOKIE_PREFIXES.some(prefix => cookie.name === prefix || cookie.name.startsWith(prefix))
14
+
15
+ /**
16
+ * Parses POST data string into an array of name-value pairs.
17
+ * Handles URL-encoded form data format.
18
+ *
19
+ * @param {string} postData - The POST data string (e.g., "key1=value1&key2=value2").
20
+ * @returns {Array<{name: string, value: string}>} - Array of decoded name-value pairs.
21
+ */
22
+ const parsePostData = postData => {
23
+ const query = postData.startsWith('?') ? postData.substring(1) : postData
24
+ return query.split('&').map(pair => {
25
+ const [name, value] = pair.split('=')
26
+ return {
27
+ name: decodeURIComponent(name),
28
+ value: decodeURIComponent(value || '')
29
+ }
30
+ })
31
+ }
32
+
33
+ /**
34
+ * Generates an HTML form for POST request submission.
35
+ * This is used to submit POST requests through the browser context,
36
+ * which is necessary to properly handle challenges on POST endpoints.
37
+ *
38
+ * @param {string} url - The form action URL.
39
+ * @param {Array<{name: string, value: string}>} formFields - The form fields.
40
+ * @returns {string} - The complete HTML document string.
41
+ */
42
+ const generatePostFormHtml = (url, formFields) => {
43
+ const hiddenInputs = formFields
44
+ .map(({ name, value }) => {
45
+ const escapedValue = value.replace(/"/g, '&quot;')
46
+ return `<input type="hidden" name="${name}" value="${escapedValue}">`
47
+ })
48
+ .join('')
49
+
50
+ return `
51
+ <!DOCTYPE html>
52
+ <html>
53
+ <body>
54
+ <form id="hackForm" action="${url}" method="POST">
55
+ ${hiddenInputs}
56
+ </form>
57
+ <script>document.getElementById('hackForm').submit();</script>
58
+ </body>
59
+ </html>
60
+ `
61
+ }
62
+
63
+ module.exports = {
64
+ generatePostFormHtml,
65
+ isChallengeCookie,
66
+ parsePostData
67
+ }