quasarr 1.26.7__py3-none-any.whl → 1.28.0__py3-none-any.whl
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.
Potentially problematic release.
This version of quasarr might be problematic. Click here for more details.
- quasarr/__init__.py +44 -155
- quasarr/api/__init__.py +1 -1
- quasarr/api/config/__init__.py +308 -4
- quasarr/downloads/linkcrypters/al.py +8 -2
- quasarr/downloads/sources/al.py +7 -0
- quasarr/downloads/sources/wd.py +12 -2
- quasarr/providers/cloudflare.py +20 -1
- quasarr/providers/sessions/al.py +51 -0
- quasarr/providers/sessions/dd.py +8 -1
- quasarr/providers/sessions/dl.py +34 -23
- quasarr/providers/sessions/nx.py +8 -1
- quasarr/providers/utils.py +190 -0
- quasarr/providers/version.py +1 -1
- quasarr/search/sources/al.py +0 -2
- quasarr/storage/config.py +3 -0
- quasarr/storage/setup.py +532 -18
- {quasarr-1.26.7.dist-info → quasarr-1.28.0.dist-info}/METADATA +86 -94
- {quasarr-1.26.7.dist-info → quasarr-1.28.0.dist-info}/RECORD +22 -21
- {quasarr-1.26.7.dist-info → quasarr-1.28.0.dist-info}/WHEEL +0 -0
- {quasarr-1.26.7.dist-info → quasarr-1.28.0.dist-info}/entry_points.txt +0 -0
- {quasarr-1.26.7.dist-info → quasarr-1.28.0.dist-info}/licenses/LICENSE +0 -0
- {quasarr-1.26.7.dist-info → quasarr-1.28.0.dist-info}/top_level.txt +0 -0
quasarr/downloads/sources/wd.py
CHANGED
|
@@ -10,6 +10,7 @@ from bs4 import BeautifulSoup
|
|
|
10
10
|
|
|
11
11
|
from quasarr.providers.cloudflare import flaresolverr_get, is_cloudflare_challenge
|
|
12
12
|
from quasarr.providers.log import info, debug
|
|
13
|
+
from quasarr.providers.utils import is_flaresolverr_available
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
def resolve_wd_redirect(url, user_agent):
|
|
@@ -47,8 +48,13 @@ def get_wd_download_links(shared_state, url, mirror, title, password):
|
|
|
47
48
|
try:
|
|
48
49
|
output = requests.get(url)
|
|
49
50
|
if output.status_code == 403 or is_cloudflare_challenge(output.text):
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
if is_flaresolverr_available(shared_state):
|
|
52
|
+
info("WD is protected by Cloudflare. Using FlareSolverr to bypass protection.")
|
|
53
|
+
output = flaresolverr_get(shared_state, url)
|
|
54
|
+
else:
|
|
55
|
+
info("WD is protected by Cloudflare but FlareSolverr is not configured. "
|
|
56
|
+
"Please configure FlareSolverr in the web UI to access this site.")
|
|
57
|
+
return {"links": [], "imdb_id": None}
|
|
52
58
|
|
|
53
59
|
soup = BeautifulSoup(output.text, "html.parser")
|
|
54
60
|
|
|
@@ -76,6 +82,10 @@ def get_wd_download_links(shared_state, url, mirror, title, password):
|
|
|
76
82
|
link_tags = body.find_all(
|
|
77
83
|
"a", href=True, class_=lambda c: c and "background-" in c
|
|
78
84
|
)
|
|
85
|
+
except RuntimeError as e:
|
|
86
|
+
# Catch FlareSolverr not configured error
|
|
87
|
+
info(f"WD access failed: {e}")
|
|
88
|
+
return {"links": [], "imdb_id": None}
|
|
79
89
|
except Exception:
|
|
80
90
|
info(f"WD site has been updated. Grabbing download links for {title} not possible!")
|
|
81
91
|
return {"links": [], "imdb_id": None}
|
quasarr/providers/cloudflare.py
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import requests
|
|
6
6
|
from bs4 import BeautifulSoup
|
|
7
7
|
|
|
8
|
+
from quasarr.providers.utils import is_flaresolverr_available
|
|
9
|
+
|
|
8
10
|
|
|
9
11
|
def is_cloudflare_challenge(html: str) -> bool:
|
|
10
12
|
soup = BeautifulSoup(html, "html.parser")
|
|
@@ -39,9 +41,14 @@ def update_session_via_flaresolverr(info,
|
|
|
39
41
|
sess,
|
|
40
42
|
target_url: str,
|
|
41
43
|
timeout: int = 60):
|
|
44
|
+
# Check if FlareSolverr is available
|
|
45
|
+
if not is_flaresolverr_available(shared_state):
|
|
46
|
+
info("FlareSolverr is not configured. Cannot bypass Cloudflare protection.")
|
|
47
|
+
return False
|
|
48
|
+
|
|
42
49
|
flaresolverr_url = shared_state.values["config"]('FlareSolverr').get('url')
|
|
43
50
|
if not flaresolverr_url:
|
|
44
|
-
info("Cannot proceed without FlareSolverr. Please
|
|
51
|
+
info("Cannot proceed without FlareSolverr. Please configure it in the web UI!")
|
|
45
52
|
return False
|
|
46
53
|
|
|
47
54
|
fs_payload = {
|
|
@@ -104,6 +111,12 @@ def ensure_session_cf_bypassed(info, shared_state, session, url, headers):
|
|
|
104
111
|
|
|
105
112
|
# If page is protected, try FlareSolverr
|
|
106
113
|
if resp.status_code == 403 or is_cloudflare_challenge(resp.text):
|
|
114
|
+
# Check if FlareSolverr is available before attempting bypass
|
|
115
|
+
if not is_flaresolverr_available(shared_state):
|
|
116
|
+
info("Cloudflare protection detected but FlareSolverr is not configured. "
|
|
117
|
+
"Please configure FlareSolverr in the web UI to access this site.")
|
|
118
|
+
return None, None, None
|
|
119
|
+
|
|
107
120
|
info("Encountered Cloudflare protection. Solving challenge with FlareSolverr...")
|
|
108
121
|
flaresolverr_result = update_session_via_flaresolverr(info, shared_state, session, url)
|
|
109
122
|
if not flaresolverr_result:
|
|
@@ -156,7 +169,13 @@ def flaresolverr_get(shared_state, url, timeout=60):
|
|
|
156
169
|
"""
|
|
157
170
|
Core function for performing a GET request via FlareSolverr only.
|
|
158
171
|
Used internally by FlareSolverrSession.get()
|
|
172
|
+
|
|
173
|
+
Returns None if FlareSolverr is not available.
|
|
159
174
|
"""
|
|
175
|
+
# Check if FlareSolverr is available
|
|
176
|
+
if not is_flaresolverr_available(shared_state):
|
|
177
|
+
raise RuntimeError("FlareSolverr is not configured. Please configure it in the web UI.")
|
|
178
|
+
|
|
160
179
|
flaresolverr_url = shared_state.values["config"]('FlareSolverr').get('url')
|
|
161
180
|
if not flaresolverr_url:
|
|
162
181
|
raise RuntimeError("FlareSolverr URL not configured in shared_state.")
|
quasarr/providers/sessions/al.py
CHANGED
|
@@ -13,6 +13,18 @@ from bs4 import BeautifulSoup
|
|
|
13
13
|
from requests.exceptions import Timeout, RequestException
|
|
14
14
|
|
|
15
15
|
from quasarr.providers.log import info, debug
|
|
16
|
+
from quasarr.providers.utils import is_site_usable, is_flaresolverr_available
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SkippedSiteError(Exception):
|
|
20
|
+
"""Raised when a site is skipped due to missing credentials or login being skipped."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FlareSolverrNotAvailableError(Exception):
|
|
25
|
+
"""Raised when FlareSolverr is required but not available."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
16
28
|
|
|
17
29
|
hostname = "al"
|
|
18
30
|
|
|
@@ -20,6 +32,12 @@ SESSION_MAX_AGE_SECONDS = 24 * 60 * 60 # 24 hours
|
|
|
20
32
|
|
|
21
33
|
|
|
22
34
|
def create_and_persist_session(shared_state):
|
|
35
|
+
# AL requires FlareSolverr - check availability first
|
|
36
|
+
if not is_flaresolverr_available(shared_state):
|
|
37
|
+
info(f'"{hostname.upper()}" requires FlareSolverr which is not configured. '
|
|
38
|
+
f'Please configure FlareSolverr in the web UI to use this site.')
|
|
39
|
+
return None
|
|
40
|
+
|
|
23
41
|
cfg = shared_state.values["config"]("Hostnames")
|
|
24
42
|
host = cfg.get(hostname)
|
|
25
43
|
credentials_cfg = shared_state.values["config"](hostname.upper())
|
|
@@ -106,6 +124,14 @@ def create_and_persist_session(shared_state):
|
|
|
106
124
|
|
|
107
125
|
|
|
108
126
|
def retrieve_and_validate_session(shared_state):
|
|
127
|
+
if not is_site_usable(shared_state, hostname):
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
# AL requires FlareSolverr - check availability
|
|
131
|
+
if not is_flaresolverr_available(shared_state):
|
|
132
|
+
info(f'"{hostname.upper()}" requires FlareSolverr which is not configured')
|
|
133
|
+
return None
|
|
134
|
+
|
|
109
135
|
db = shared_state.values["database"]("sessions")
|
|
110
136
|
stored = db.retrieve(hostname)
|
|
111
137
|
if not stored:
|
|
@@ -213,9 +239,32 @@ def fetch_via_flaresolverr(shared_state,
|
|
|
213
239
|
– post_data: dict of form‐fields if method=="POST"
|
|
214
240
|
– timeout: seconds (FlareSolverr's internal maxTimeout = timeout*1000 ms)
|
|
215
241
|
"""
|
|
242
|
+
# Check if FlareSolverr is available
|
|
243
|
+
if not is_flaresolverr_available(shared_state):
|
|
244
|
+
info(f'"{hostname.upper()}" requires FlareSolverr which is not configured. '
|
|
245
|
+
f'Please configure FlareSolverr in the web UI.')
|
|
246
|
+
return {
|
|
247
|
+
"status_code": None,
|
|
248
|
+
"headers": {},
|
|
249
|
+
"json": None,
|
|
250
|
+
"text": "",
|
|
251
|
+
"cookies": [],
|
|
252
|
+
"error": "FlareSolverr is not configured"
|
|
253
|
+
}
|
|
254
|
+
|
|
216
255
|
flaresolverr_url = shared_state.values["config"]('FlareSolverr').get('url')
|
|
217
256
|
|
|
218
257
|
sess = retrieve_and_validate_session(shared_state)
|
|
258
|
+
if not sess:
|
|
259
|
+
debug(f"Skipping {hostname}: site not usable (login skipped or no credentials)")
|
|
260
|
+
return {
|
|
261
|
+
"status_code": None,
|
|
262
|
+
"headers": {},
|
|
263
|
+
"json": None,
|
|
264
|
+
"text": "",
|
|
265
|
+
"cookies": [],
|
|
266
|
+
"error": f"Site '{hostname}' is not usable (login skipped or no credentials)"
|
|
267
|
+
}
|
|
219
268
|
|
|
220
269
|
cmd = "request.get" if method.upper() == "GET" else "request.post"
|
|
221
270
|
fs_payload = {
|
|
@@ -301,6 +350,8 @@ def fetch_via_requests_session(shared_state, method: str, target_url: str, post_
|
|
|
301
350
|
– timeout: seconds
|
|
302
351
|
"""
|
|
303
352
|
sess = retrieve_and_validate_session(shared_state)
|
|
353
|
+
if not sess:
|
|
354
|
+
raise SkippedSiteError(f"{hostname}: site not usable (login skipped or no credentials)")
|
|
304
355
|
|
|
305
356
|
# Execute request
|
|
306
357
|
if method.upper() == "GET":
|
quasarr/providers/sessions/dd.py
CHANGED
|
@@ -7,7 +7,10 @@ import pickle
|
|
|
7
7
|
|
|
8
8
|
import requests
|
|
9
9
|
|
|
10
|
-
from quasarr.providers.log import info
|
|
10
|
+
from quasarr.providers.log import info, debug
|
|
11
|
+
from quasarr.providers.utils import is_site_usable
|
|
12
|
+
|
|
13
|
+
hostname = "dd"
|
|
11
14
|
|
|
12
15
|
|
|
13
16
|
def create_and_persist_session(shared_state):
|
|
@@ -62,6 +65,10 @@ def create_and_persist_session(shared_state):
|
|
|
62
65
|
|
|
63
66
|
|
|
64
67
|
def retrieve_and_validate_session(shared_state):
|
|
68
|
+
if not is_site_usable(shared_state, hostname):
|
|
69
|
+
debug(f"Skipping {hostname}: site not usable (login skipped or no credentials)")
|
|
70
|
+
return None
|
|
71
|
+
|
|
65
72
|
session_string = shared_state.values["database"]("sessions").retrieve("dd")
|
|
66
73
|
if not session_string:
|
|
67
74
|
dd_session = create_and_persist_session(shared_state)
|
quasarr/providers/sessions/dl.py
CHANGED
|
@@ -9,6 +9,13 @@ import requests
|
|
|
9
9
|
from bs4 import BeautifulSoup
|
|
10
10
|
|
|
11
11
|
from quasarr.providers.log import info, debug
|
|
12
|
+
from quasarr.providers.utils import is_site_usable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SkippedSiteError(Exception):
|
|
16
|
+
"""Raised when a site is skipped due to missing credentials or login being skipped."""
|
|
17
|
+
pass
|
|
18
|
+
|
|
12
19
|
|
|
13
20
|
hostname = "dl"
|
|
14
21
|
|
|
@@ -16,17 +23,17 @@ hostname = "dl"
|
|
|
16
23
|
def create_and_persist_session(shared_state):
|
|
17
24
|
"""
|
|
18
25
|
Create and persist a session using user and password.
|
|
19
|
-
|
|
26
|
+
|
|
20
27
|
Args:
|
|
21
28
|
shared_state: Shared state object
|
|
22
|
-
|
|
29
|
+
|
|
23
30
|
Returns:
|
|
24
31
|
requests.Session or None
|
|
25
32
|
"""
|
|
26
33
|
cfg = shared_state.values["config"]("Hostnames")
|
|
27
34
|
host = cfg.get(hostname)
|
|
28
35
|
credentials_cfg = shared_state.values["config"](hostname.upper())
|
|
29
|
-
|
|
36
|
+
|
|
30
37
|
user = credentials_cfg.get("user")
|
|
31
38
|
password = credentials_cfg.get("password")
|
|
32
39
|
|
|
@@ -35,30 +42,30 @@ def create_and_persist_session(shared_state):
|
|
|
35
42
|
return None
|
|
36
43
|
|
|
37
44
|
sess = requests.Session()
|
|
38
|
-
|
|
45
|
+
|
|
39
46
|
# Set user agent
|
|
40
47
|
ua = shared_state.values["user_agent"]
|
|
41
48
|
sess.headers.update({'User-Agent': ua})
|
|
42
|
-
|
|
49
|
+
|
|
43
50
|
try:
|
|
44
51
|
# Step 1: Get login page to retrieve CSRF token
|
|
45
52
|
login_page_url = f'https://www.{host}/login/'
|
|
46
53
|
login_page = sess.get(login_page_url, timeout=30)
|
|
47
|
-
|
|
54
|
+
|
|
48
55
|
if login_page.status_code != 200:
|
|
49
56
|
info(f'Failed to load login page for: "{hostname}" - Status {login_page.status_code}')
|
|
50
57
|
return None
|
|
51
|
-
|
|
58
|
+
|
|
52
59
|
# Extract CSRF token from login form
|
|
53
60
|
soup = BeautifulSoup(login_page.text, 'html.parser')
|
|
54
61
|
csrf_input = soup.find('input', {'name': '_xfToken'})
|
|
55
|
-
|
|
62
|
+
|
|
56
63
|
if not csrf_input or not csrf_input.get('value'):
|
|
57
64
|
info(f'Could not find CSRF token on login page for: "{hostname}"')
|
|
58
65
|
return None
|
|
59
|
-
|
|
66
|
+
|
|
60
67
|
csrf_token = csrf_input['value']
|
|
61
|
-
|
|
68
|
+
|
|
62
69
|
# Step 2: Submit login form
|
|
63
70
|
login_data = {
|
|
64
71
|
'login': user,
|
|
@@ -67,18 +74,18 @@ def create_and_persist_session(shared_state):
|
|
|
67
74
|
'remember': '1',
|
|
68
75
|
'_xfRedirect': f'https://www.{host}/'
|
|
69
76
|
}
|
|
70
|
-
|
|
77
|
+
|
|
71
78
|
login_url = f'https://www.{host}/login/login'
|
|
72
79
|
login_response = sess.post(login_url, data=login_data, timeout=30)
|
|
73
|
-
|
|
80
|
+
|
|
74
81
|
# Step 3: Verify login success
|
|
75
82
|
# Check if we're logged in by accessing the main page
|
|
76
83
|
verify_response = sess.get(f'https://www.{host}/', timeout=30)
|
|
77
|
-
|
|
84
|
+
|
|
78
85
|
if 'data-logged-in="true"' not in verify_response.text:
|
|
79
86
|
info(f'Login verification failed for: "{hostname}" - invalid credentials or login failed')
|
|
80
87
|
return None
|
|
81
|
-
|
|
88
|
+
|
|
82
89
|
info(f'Session successfully created for: "{hostname}" using user/password')
|
|
83
90
|
except Exception as e:
|
|
84
91
|
info(f'Failed to create session for: "{hostname}" - {e}')
|
|
@@ -88,20 +95,23 @@ def create_and_persist_session(shared_state):
|
|
|
88
95
|
blob = pickle.dumps(sess)
|
|
89
96
|
token = base64.b64encode(blob).decode("utf-8")
|
|
90
97
|
shared_state.values["database"]("sessions").update_store(hostname, token)
|
|
91
|
-
|
|
98
|
+
|
|
92
99
|
return sess
|
|
93
100
|
|
|
94
101
|
|
|
95
102
|
def retrieve_and_validate_session(shared_state):
|
|
96
103
|
"""
|
|
97
104
|
Retrieve session from database or create a new one.
|
|
98
|
-
|
|
105
|
+
|
|
99
106
|
Args:
|
|
100
107
|
shared_state: Shared state object
|
|
101
|
-
|
|
108
|
+
|
|
102
109
|
Returns:
|
|
103
110
|
requests.Session or None
|
|
104
111
|
"""
|
|
112
|
+
if not is_site_usable(shared_state, hostname):
|
|
113
|
+
return None
|
|
114
|
+
|
|
105
115
|
db = shared_state.values["database"]("sessions")
|
|
106
116
|
token = db.retrieve(hostname)
|
|
107
117
|
if not token:
|
|
@@ -122,7 +132,7 @@ def retrieve_and_validate_session(shared_state):
|
|
|
122
132
|
def invalidate_session(shared_state):
|
|
123
133
|
"""
|
|
124
134
|
Invalidate the current session.
|
|
125
|
-
|
|
135
|
+
|
|
126
136
|
Args:
|
|
127
137
|
shared_state: Shared state object
|
|
128
138
|
"""
|
|
@@ -134,7 +144,7 @@ def invalidate_session(shared_state):
|
|
|
134
144
|
def _persist_session_to_db(shared_state, sess):
|
|
135
145
|
"""
|
|
136
146
|
Serialize & store the given requests.Session into the database under `hostname`.
|
|
137
|
-
|
|
147
|
+
|
|
138
148
|
Args:
|
|
139
149
|
shared_state: Shared state object
|
|
140
150
|
sess: requests.Session to persist
|
|
@@ -144,10 +154,11 @@ def _persist_session_to_db(shared_state, sess):
|
|
|
144
154
|
shared_state.values["database"]("sessions").update_store(hostname, token)
|
|
145
155
|
|
|
146
156
|
|
|
147
|
-
def fetch_via_requests_session(shared_state, method: str, target_url: str, post_data: dict = None,
|
|
157
|
+
def fetch_via_requests_session(shared_state, method: str, target_url: str, post_data: dict = None,
|
|
158
|
+
get_params: dict = None, timeout: int = 30):
|
|
148
159
|
"""
|
|
149
160
|
Execute request using the session.
|
|
150
|
-
|
|
161
|
+
|
|
151
162
|
Args:
|
|
152
163
|
shared_state: Shared state object
|
|
153
164
|
method: "GET" or "POST"
|
|
@@ -155,13 +166,13 @@ def fetch_via_requests_session(shared_state, method: str, target_url: str, post_
|
|
|
155
166
|
post_data: POST data (for POST requests)
|
|
156
167
|
get_params: URL parameters (for GET requests)
|
|
157
168
|
timeout: Request timeout in seconds
|
|
158
|
-
|
|
169
|
+
|
|
159
170
|
Returns:
|
|
160
171
|
Response object
|
|
161
172
|
"""
|
|
162
173
|
sess = retrieve_and_validate_session(shared_state)
|
|
163
174
|
if not sess:
|
|
164
|
-
raise
|
|
175
|
+
raise SkippedSiteError(f"{hostname}: site not usable (login skipped or no credentials)")
|
|
165
176
|
|
|
166
177
|
# Execute request
|
|
167
178
|
if method.upper() == "GET":
|
quasarr/providers/sessions/nx.py
CHANGED
|
@@ -7,7 +7,10 @@ import pickle
|
|
|
7
7
|
|
|
8
8
|
import requests
|
|
9
9
|
|
|
10
|
-
from quasarr.providers.log import info
|
|
10
|
+
from quasarr.providers.log import info, debug
|
|
11
|
+
from quasarr.providers.utils import is_site_usable
|
|
12
|
+
|
|
13
|
+
hostname = "nx"
|
|
11
14
|
|
|
12
15
|
|
|
13
16
|
def create_and_persist_session(shared_state):
|
|
@@ -60,6 +63,10 @@ def create_and_persist_session(shared_state):
|
|
|
60
63
|
|
|
61
64
|
|
|
62
65
|
def retrieve_and_validate_session(shared_state):
|
|
66
|
+
if not is_site_usable(shared_state, hostname):
|
|
67
|
+
debug(f"Skipping {hostname}: site not usable (login skipped or no credentials)")
|
|
68
|
+
return None
|
|
69
|
+
|
|
63
70
|
session_string = shared_state.values["database"]("sessions").retrieve("nx")
|
|
64
71
|
if not session_string:
|
|
65
72
|
nx_session = create_and_persist_session(shared_state)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Quasarr
|
|
3
|
+
# Project by https://github.com/rix1337
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import socket
|
|
7
|
+
import sys
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
# Fallback user agent when FlareSolverr is not available
|
|
13
|
+
FALLBACK_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Unbuffered(object):
|
|
17
|
+
def __init__(self, stream):
|
|
18
|
+
self.stream = stream
|
|
19
|
+
|
|
20
|
+
def write(self, data):
|
|
21
|
+
self.stream.write(data)
|
|
22
|
+
self.stream.flush()
|
|
23
|
+
|
|
24
|
+
def writelines(self, datas):
|
|
25
|
+
self.stream.writelines(datas)
|
|
26
|
+
self.stream.flush()
|
|
27
|
+
|
|
28
|
+
def __getattr__(self, attr):
|
|
29
|
+
return getattr(self.stream, attr)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_valid_url(url):
|
|
33
|
+
"""Validate if a URL is properly formatted."""
|
|
34
|
+
if "/raw/eX4Mpl3" in url:
|
|
35
|
+
print("Example URL detected. Please provide a valid URL found on pastebin or any other public site!")
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
parsed = urlparse(url)
|
|
39
|
+
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def extract_allowed_keys(config, section):
|
|
43
|
+
"""
|
|
44
|
+
Extracts allowed keys from the specified section in the configuration.
|
|
45
|
+
|
|
46
|
+
:param config: The configuration dictionary.
|
|
47
|
+
:param section: The section from which to extract keys.
|
|
48
|
+
:return: A list of allowed keys.
|
|
49
|
+
"""
|
|
50
|
+
if section not in config:
|
|
51
|
+
raise ValueError(f"Section '{section}' not found in configuration.")
|
|
52
|
+
return [key for key, *_ in config[section]]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def extract_kv_pairs(input_text, allowed_keys):
|
|
56
|
+
"""
|
|
57
|
+
Extracts key-value pairs from the given text where keys match allowed_keys.
|
|
58
|
+
|
|
59
|
+
:param input_text: The input text containing key-value pairs.
|
|
60
|
+
:param allowed_keys: A list of allowed two-letter shorthand keys.
|
|
61
|
+
:return: A dictionary of extracted key-value pairs.
|
|
62
|
+
"""
|
|
63
|
+
kv_pattern = re.compile(rf"^({'|'.join(map(re.escape, allowed_keys))})\s*=\s*(.*)$")
|
|
64
|
+
kv_pairs = {}
|
|
65
|
+
|
|
66
|
+
for line in input_text.splitlines():
|
|
67
|
+
match = kv_pattern.match(line.strip())
|
|
68
|
+
if match:
|
|
69
|
+
key, value = match.groups()
|
|
70
|
+
kv_pairs[key] = value
|
|
71
|
+
elif "[Hostnames]" in line:
|
|
72
|
+
pass
|
|
73
|
+
else:
|
|
74
|
+
print(f"Skipping line because it does not contain any supported hostname: {line}")
|
|
75
|
+
|
|
76
|
+
return kv_pairs
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def check_ip():
|
|
80
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
81
|
+
try:
|
|
82
|
+
s.connect(('10.255.255.255', 0))
|
|
83
|
+
ip = s.getsockname()[0]
|
|
84
|
+
except:
|
|
85
|
+
ip = '127.0.0.1'
|
|
86
|
+
finally:
|
|
87
|
+
s.close()
|
|
88
|
+
return ip
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def check_flaresolverr(shared_state, flaresolverr_url):
|
|
92
|
+
# Ensure it ends with /v<digit+>
|
|
93
|
+
if not re.search(r"/v\d+$", flaresolverr_url):
|
|
94
|
+
print(f"FlareSolverr URL does not end with /v#: {flaresolverr_url}")
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
# Try sending a simple test request
|
|
98
|
+
headers = {"Content-Type": "application/json"}
|
|
99
|
+
data = {
|
|
100
|
+
"cmd": "request.get",
|
|
101
|
+
"url": "http://www.google.com/",
|
|
102
|
+
"maxTimeout": 10000
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
response = requests.post(flaresolverr_url, headers=headers, json=data, timeout=10)
|
|
107
|
+
response.raise_for_status()
|
|
108
|
+
json_data = response.json()
|
|
109
|
+
|
|
110
|
+
# Check if the structure looks like a valid FlareSolverr response
|
|
111
|
+
if "status" in json_data and json_data["status"] == "ok":
|
|
112
|
+
solution = json_data["solution"]
|
|
113
|
+
solution_ua = solution.get("userAgent", None)
|
|
114
|
+
if solution_ua:
|
|
115
|
+
shared_state.update("user_agent", solution_ua)
|
|
116
|
+
return True
|
|
117
|
+
else:
|
|
118
|
+
print(f"Unexpected FlareSolverr response: {json_data}")
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
except Exception as e:
|
|
122
|
+
print(f"Failed to connect to FlareSolverr: {e}")
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def validate_address(address, name):
|
|
127
|
+
if not address.startswith("http"):
|
|
128
|
+
sys.exit(f"Error: {name} '{address}' is invalid. It must start with 'http'.")
|
|
129
|
+
|
|
130
|
+
colon_count = address.count(":")
|
|
131
|
+
if colon_count < 1 or colon_count > 2:
|
|
132
|
+
sys.exit(
|
|
133
|
+
f"Error: {name} '{address}' is invalid. It must contain 1 or 2 colons, but it has {colon_count}.")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def is_flaresolverr_available(shared_state):
|
|
137
|
+
"""
|
|
138
|
+
Check if FlareSolverr is configured and available.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
bool: True if FlareSolverr URL is set and not skipped, False otherwise
|
|
142
|
+
"""
|
|
143
|
+
# Check if FlareSolverr was skipped
|
|
144
|
+
if shared_state.values["database"]("skip_flaresolverr").retrieve("skipped"):
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
# Check if FlareSolverr URL is configured
|
|
148
|
+
flaresolverr_url = shared_state.values["config"]('FlareSolverr').get('url')
|
|
149
|
+
if not flaresolverr_url:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def is_site_usable(shared_state, shorthand):
|
|
156
|
+
"""
|
|
157
|
+
Check if a site is fully configured and usable.
|
|
158
|
+
|
|
159
|
+
For sites that don't require login, just checks if hostname is set.
|
|
160
|
+
For login-required sites (al, dd, dl, nx), also checks that login wasn't skipped
|
|
161
|
+
and that credentials exist.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
shared_state: Shared state object
|
|
165
|
+
shorthand: Site shorthand (e.g., 'al', 'dd', etc.)
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
bool: True if site is usable, False otherwise
|
|
169
|
+
"""
|
|
170
|
+
shorthand = shorthand.lower()
|
|
171
|
+
|
|
172
|
+
# Check if hostname is set
|
|
173
|
+
hostname = shared_state.values["config"]('Hostnames').get(shorthand)
|
|
174
|
+
if not hostname:
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
login_required_sites = ['al', 'dd', 'dl', 'nx']
|
|
178
|
+
if shorthand not in login_required_sites:
|
|
179
|
+
return True # No login needed, hostname is enough
|
|
180
|
+
|
|
181
|
+
# Check if login was skipped
|
|
182
|
+
if shared_state.values["database"]("skip_login").retrieve(shorthand):
|
|
183
|
+
return False # Hostname set but login was skipped
|
|
184
|
+
|
|
185
|
+
# Check for credentials
|
|
186
|
+
config = shared_state.values["config"](shorthand.upper())
|
|
187
|
+
user = config.get('user')
|
|
188
|
+
password = config.get('password')
|
|
189
|
+
|
|
190
|
+
return bool(user and password)
|
quasarr/providers/version.py
CHANGED
quasarr/search/sources/al.py
CHANGED