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 +21 -0
- package/README.md +43 -0
- package/bin/help.txt +13 -0
- package/bin/index.js +32 -0
- package/package.json +102 -0
- package/src/constants.js +87 -0
- package/src/index.js +308 -0
- package/src/util.js +67 -0
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
|
+

|
|
10
|
+
[](https://coveralls.io/github/kikobeats/flaresolverr)
|
|
11
|
+
[](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
|
+
}
|
package/src/constants.js
ADDED
|
@@ -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, '"')
|
|
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
|
+
}
|