quasarr 0.1.6__py3-none-any.whl → 1.23.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 +316 -42
- quasarr/api/__init__.py +187 -0
- quasarr/api/arr/__init__.py +387 -0
- quasarr/api/captcha/__init__.py +1189 -0
- quasarr/api/config/__init__.py +23 -0
- quasarr/api/sponsors_helper/__init__.py +166 -0
- quasarr/api/statistics/__init__.py +196 -0
- quasarr/downloads/__init__.py +319 -256
- quasarr/downloads/linkcrypters/__init__.py +0 -0
- quasarr/downloads/linkcrypters/al.py +237 -0
- quasarr/downloads/linkcrypters/filecrypt.py +444 -0
- quasarr/downloads/linkcrypters/hide.py +123 -0
- quasarr/downloads/packages/__init__.py +476 -0
- quasarr/downloads/sources/al.py +697 -0
- quasarr/downloads/sources/by.py +106 -0
- quasarr/downloads/sources/dd.py +76 -0
- quasarr/downloads/sources/dj.py +7 -0
- quasarr/downloads/sources/dl.py +199 -0
- quasarr/downloads/sources/dt.py +66 -0
- quasarr/downloads/sources/dw.py +14 -7
- quasarr/downloads/sources/he.py +112 -0
- quasarr/downloads/sources/mb.py +47 -0
- quasarr/downloads/sources/nk.py +54 -0
- quasarr/downloads/sources/nx.py +42 -83
- quasarr/downloads/sources/sf.py +159 -0
- quasarr/downloads/sources/sj.py +7 -0
- quasarr/downloads/sources/sl.py +90 -0
- quasarr/downloads/sources/wd.py +110 -0
- quasarr/downloads/sources/wx.py +127 -0
- quasarr/providers/cloudflare.py +204 -0
- quasarr/providers/html_images.py +22 -0
- quasarr/providers/html_templates.py +211 -104
- quasarr/providers/imdb_metadata.py +108 -3
- quasarr/providers/log.py +19 -0
- quasarr/providers/myjd_api.py +201 -40
- quasarr/providers/notifications.py +99 -11
- quasarr/providers/obfuscated.py +65 -0
- quasarr/providers/sessions/__init__.py +0 -0
- quasarr/providers/sessions/al.py +286 -0
- quasarr/providers/sessions/dd.py +78 -0
- quasarr/providers/sessions/dl.py +175 -0
- quasarr/providers/sessions/nx.py +76 -0
- quasarr/providers/shared_state.py +656 -79
- quasarr/providers/statistics.py +154 -0
- quasarr/providers/version.py +60 -1
- quasarr/providers/web_server.py +1 -1
- quasarr/search/__init__.py +144 -15
- quasarr/search/sources/al.py +448 -0
- quasarr/search/sources/by.py +204 -0
- quasarr/search/sources/dd.py +135 -0
- quasarr/search/sources/dj.py +213 -0
- quasarr/search/sources/dl.py +354 -0
- quasarr/search/sources/dt.py +265 -0
- quasarr/search/sources/dw.py +94 -67
- quasarr/search/sources/fx.py +89 -33
- quasarr/search/sources/he.py +196 -0
- quasarr/search/sources/mb.py +195 -0
- quasarr/search/sources/nk.py +188 -0
- quasarr/search/sources/nx.py +75 -21
- quasarr/search/sources/sf.py +374 -0
- quasarr/search/sources/sj.py +213 -0
- quasarr/search/sources/sl.py +246 -0
- quasarr/search/sources/wd.py +208 -0
- quasarr/search/sources/wx.py +337 -0
- quasarr/storage/config.py +39 -10
- quasarr/storage/setup.py +269 -97
- quasarr/storage/sqlite_database.py +6 -1
- quasarr-1.23.0.dist-info/METADATA +306 -0
- quasarr-1.23.0.dist-info/RECORD +77 -0
- {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/WHEEL +1 -1
- quasarr/arr/__init__.py +0 -423
- quasarr/captcha_solver/__init__.py +0 -284
- quasarr-0.1.6.dist-info/METADATA +0 -81
- quasarr-0.1.6.dist-info/RECORD +0 -31
- {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/entry_points.txt +0 -0
- {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info/licenses}/LICENSE +0 -0
- {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Quasarr
|
|
3
|
+
# Project by https://github.com/rix1337
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from bs4 import BeautifulSoup
|
|
7
|
+
|
|
8
|
+
from quasarr.providers.log import info
|
|
9
|
+
|
|
10
|
+
hostname = "nk"
|
|
11
|
+
supported_mirrors = ["rapidgator", "ddownload"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_nk_download_links(shared_state, url, mirror, title):
|
|
15
|
+
host = shared_state.values["config"]("Hostnames").get(hostname)
|
|
16
|
+
headers = {
|
|
17
|
+
'User-Agent': shared_state.values["user_agent"],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
session = requests.Session()
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
resp = session.get(url, headers=headers, timeout=20)
|
|
24
|
+
soup = BeautifulSoup(resp.text, 'html.parser')
|
|
25
|
+
except Exception as e:
|
|
26
|
+
info(f"{hostname}: could not fetch release page for {title}: {e}")
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
anchors = soup.select('a.btn-orange')
|
|
30
|
+
candidates = []
|
|
31
|
+
for a in anchors:
|
|
32
|
+
mirror = a.text.strip().lower()
|
|
33
|
+
if mirror == 'ddl.to':
|
|
34
|
+
mirror = 'ddownload'
|
|
35
|
+
|
|
36
|
+
if mirror not in supported_mirrors:
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
href = a.get('href', '').strip()
|
|
40
|
+
if not href.lower().startswith(('http://', 'https://')):
|
|
41
|
+
href = 'https://' + host + href
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
href = requests.head(href, headers=headers, allow_redirects=True, timeout=20).url
|
|
45
|
+
except Exception as e:
|
|
46
|
+
info(f"{hostname}: could not resolve download link for {title}: {e}")
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
candidates.append([href, mirror])
|
|
50
|
+
|
|
51
|
+
if not candidates:
|
|
52
|
+
info(f"No external download links found on {hostname} page for {title}")
|
|
53
|
+
|
|
54
|
+
return candidates
|
quasarr/downloads/sources/nx.py
CHANGED
|
@@ -2,109 +2,61 @@
|
|
|
2
2
|
# Quasarr
|
|
3
3
|
# Project by https://github.com/rix1337
|
|
4
4
|
|
|
5
|
-
import base64
|
|
6
|
-
import pickle
|
|
7
5
|
import re
|
|
8
6
|
|
|
9
7
|
import requests
|
|
10
8
|
from bs4 import BeautifulSoup
|
|
11
9
|
|
|
10
|
+
from quasarr.providers.log import info
|
|
11
|
+
from quasarr.providers.sessions.nx import retrieve_and_validate_session
|
|
12
12
|
|
|
13
|
-
def create_and_persist_session(shared_state):
|
|
14
|
-
nx = shared_state.values["config"]("Hostnames").get("nx")
|
|
15
13
|
|
|
16
|
-
|
|
14
|
+
def get_filer_folder_links_via_api(shared_state, url):
|
|
15
|
+
try:
|
|
16
|
+
headers = {
|
|
17
|
+
'User-Agent': shared_state.values["user_agent"],
|
|
18
|
+
'Referer': url
|
|
19
|
+
}
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
21
|
+
m = re.search(r"/folder/([A-Za-z0-9]+)", url)
|
|
22
|
+
if not m:
|
|
23
|
+
return url # not a folder URL
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
'password': shared_state.values["config"]("NX").get("password")
|
|
26
|
-
}
|
|
25
|
+
folder_hash = m.group(1)
|
|
26
|
+
api_url = f"https://filer.net/api/folder/{folder_hash}"
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
response = requests.get(api_url, headers=headers, timeout=10)
|
|
29
|
+
if not response or response.status_code != 200:
|
|
30
|
+
return url
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
response_data = nx_response.json()
|
|
34
|
-
if response_data.get('err', {}).get('status') == 403:
|
|
35
|
-
print("Invalid NX credentials provided.")
|
|
36
|
-
error = True
|
|
37
|
-
elif response_data.get('user').get('username') != shared_state.values["config"]("NX").get("user"):
|
|
38
|
-
print("Invalid NX response on login.")
|
|
39
|
-
error = True
|
|
40
|
-
else:
|
|
41
|
-
sessiontoken = response_data.get('user').get('sessiontoken')
|
|
42
|
-
nx_session.cookies.set('sessiontoken', sessiontoken, domain=nx)
|
|
43
|
-
except ValueError:
|
|
44
|
-
print("Could not parse response.")
|
|
45
|
-
error = True
|
|
46
|
-
|
|
47
|
-
if error:
|
|
48
|
-
shared_state.values["config"]("NX").save("user", "")
|
|
49
|
-
shared_state.values["config"]("NX").save("password", "")
|
|
50
|
-
return None
|
|
51
|
-
|
|
52
|
-
serialized_session = pickle.dumps(nx_session)
|
|
53
|
-
session_string = base64.b64encode(serialized_session).decode('utf-8')
|
|
54
|
-
shared_state.values["database"]("sessions").update_store("nx", session_string)
|
|
55
|
-
return nx_session
|
|
56
|
-
else:
|
|
57
|
-
print("Could not create NX session")
|
|
58
|
-
return None
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def retrieve_and_validate_session(shared_state):
|
|
62
|
-
session_string = shared_state.values["database"]("sessions").retrieve("nx")
|
|
63
|
-
if not session_string:
|
|
64
|
-
nx_session = create_and_persist_session(shared_state)
|
|
65
|
-
else:
|
|
66
|
-
try:
|
|
67
|
-
serialized_session = base64.b64decode(session_string.encode('utf-8'))
|
|
68
|
-
nx_session = pickle.loads(serialized_session)
|
|
69
|
-
if not isinstance(nx_session, requests.Session):
|
|
70
|
-
raise ValueError("Retrieved object is not a valid requests.Session instance.")
|
|
71
|
-
except Exception as e:
|
|
72
|
-
print(f"Session retrieval failed: {e}")
|
|
73
|
-
nx_session = create_and_persist_session(shared_state)
|
|
32
|
+
data = response.json()
|
|
33
|
+
files = data.get("files", [])
|
|
34
|
+
links = []
|
|
74
35
|
|
|
75
|
-
|
|
36
|
+
# Build download URLs from their file hashes
|
|
37
|
+
for f in files:
|
|
38
|
+
file_hash = f.get("hash")
|
|
39
|
+
if not file_hash:
|
|
40
|
+
continue
|
|
41
|
+
dl_url = f"https://filer.net/get/{file_hash}"
|
|
42
|
+
links.append(dl_url)
|
|
76
43
|
|
|
44
|
+
# Return extracted links or fallback
|
|
45
|
+
return links if links else url
|
|
77
46
|
|
|
78
|
-
def get_filer_folder_links(shared_state, url):
|
|
79
|
-
try:
|
|
80
|
-
headers = {
|
|
81
|
-
'User-Agent': shared_state.values["user_agent"],
|
|
82
|
-
'Referer': url
|
|
83
|
-
}
|
|
84
|
-
response = requests.get(url, headers=headers)
|
|
85
|
-
links = []
|
|
86
|
-
if response:
|
|
87
|
-
soup = BeautifulSoup(response.content, 'html.parser')
|
|
88
|
-
folder_links = soup.find_all('a', href=re.compile("/get/"))
|
|
89
|
-
for link in folder_links:
|
|
90
|
-
link = "https://filer.net" + link.get('href')
|
|
91
|
-
if link not in links:
|
|
92
|
-
links.append(link)
|
|
93
|
-
return links
|
|
94
47
|
except:
|
|
95
|
-
|
|
96
|
-
return url
|
|
48
|
+
return url
|
|
97
49
|
|
|
98
50
|
|
|
99
|
-
def get_nx_download_links(shared_state, url, title):
|
|
51
|
+
def get_nx_download_links(shared_state, url, mirror, title): # signature must align with other download link functions!
|
|
100
52
|
nx = shared_state.values["config"]("Hostnames").get("nx")
|
|
101
53
|
|
|
102
54
|
if f"{nx}/release/" not in url:
|
|
103
|
-
|
|
55
|
+
info("Link is not a Release link, could not proceed:" + url)
|
|
104
56
|
|
|
105
57
|
nx_session = retrieve_and_validate_session(shared_state)
|
|
106
58
|
if not nx_session:
|
|
107
|
-
|
|
59
|
+
info(f"Could not retrieve valid session for {nx}")
|
|
108
60
|
return []
|
|
109
61
|
|
|
110
62
|
headers = {
|
|
@@ -120,27 +72,34 @@ def get_nx_download_links(shared_state, url, title):
|
|
|
120
72
|
payload = nx_session.post(payload_url,
|
|
121
73
|
headers=headers,
|
|
122
74
|
json=json_data,
|
|
75
|
+
timeout=10
|
|
123
76
|
)
|
|
124
77
|
|
|
125
78
|
if payload.status_code == 200:
|
|
126
79
|
try:
|
|
127
80
|
payload = payload.json()
|
|
128
81
|
except:
|
|
129
|
-
|
|
82
|
+
info("Invalid response decrypting " + str(title) + " URL: " + str(url))
|
|
130
83
|
shared_state.values["database"]("sessions").delete("nx")
|
|
131
84
|
return []
|
|
132
85
|
|
|
86
|
+
if payload and any(key in payload for key in ("err", "error")):
|
|
87
|
+
error_msg = payload.get("err") or payload.get("error")
|
|
88
|
+
info(f"Error decrypting {title!r} URL: {url!r} - {error_msg}")
|
|
89
|
+
shared_state.values["database"]("sessions").delete("nx")
|
|
90
|
+
return []
|
|
91
|
+
|
|
133
92
|
try:
|
|
134
93
|
decrypted_url = payload['link'][0]['url']
|
|
135
94
|
if decrypted_url:
|
|
136
95
|
if "filer.net/folder/" in decrypted_url:
|
|
137
|
-
urls =
|
|
96
|
+
urls = get_filer_folder_links_via_api(shared_state, decrypted_url)
|
|
138
97
|
else:
|
|
139
98
|
urls = [decrypted_url]
|
|
140
99
|
return urls
|
|
141
100
|
except:
|
|
142
101
|
pass
|
|
143
102
|
|
|
144
|
-
|
|
103
|
+
info("Something went wrong decrypting " + str(title) + " URL: " + str(url))
|
|
145
104
|
shared_state.values["database"]("sessions").delete("nx")
|
|
146
105
|
return []
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Quasarr
|
|
3
|
+
# Project by https://github.com/rix1337
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
from bs4 import BeautifulSoup
|
|
10
|
+
|
|
11
|
+
from quasarr.providers.log import info, debug
|
|
12
|
+
from quasarr.search.sources.sf import parse_mirrors
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_last_section_integer(url):
|
|
16
|
+
last_section = url.rstrip('/').split('/')[-1]
|
|
17
|
+
if last_section.isdigit() and len(last_section) <= 3:
|
|
18
|
+
return int(last_section)
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_sf_download_links(shared_state, url, mirror, title): # signature must align with other download link functions!
|
|
23
|
+
release_pattern = re.compile(
|
|
24
|
+
r'''
|
|
25
|
+
^ # start of string
|
|
26
|
+
(?P<name>.+?)\. # show name (dots in name) up to the dot before “S”
|
|
27
|
+
S(?P<season>\d+) # “S” + season number
|
|
28
|
+
(?:E\d+(?:-E\d+)?)? # optional “E##” or “E##-E##”
|
|
29
|
+
\. # literal dot
|
|
30
|
+
.*?\. # anything (e.g. language/codec) up to next dot
|
|
31
|
+
(?P<resolution>\d+p) # resolution “720p”, “1080p”, etc.
|
|
32
|
+
\..+? # dot + more junk (e.g. “.WEB.h264”)
|
|
33
|
+
-(?P<group>\w+) # dash + release group at end
|
|
34
|
+
$ # end of string
|
|
35
|
+
''',
|
|
36
|
+
re.IGNORECASE | re.VERBOSE
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
release_match = release_pattern.match(title)
|
|
40
|
+
|
|
41
|
+
if not release_match:
|
|
42
|
+
return {
|
|
43
|
+
"real_url": None,
|
|
44
|
+
"imdb_id": None,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
release_parts = release_match.groupdict()
|
|
48
|
+
|
|
49
|
+
season = is_last_section_integer(url)
|
|
50
|
+
try:
|
|
51
|
+
if not season:
|
|
52
|
+
season = "ALL"
|
|
53
|
+
|
|
54
|
+
sf = shared_state.values["config"]("Hostnames").get("sf")
|
|
55
|
+
headers = {
|
|
56
|
+
'User-Agent': shared_state.values["user_agent"],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
series_page = requests.get(url, headers=headers, timeout=10).text
|
|
60
|
+
|
|
61
|
+
soup = BeautifulSoup(series_page, "html.parser")
|
|
62
|
+
# extract IMDb id if present
|
|
63
|
+
imdb_id = None
|
|
64
|
+
a_imdb = soup.find("a", href=re.compile(r"imdb\.com/title/tt\d+"))
|
|
65
|
+
if a_imdb:
|
|
66
|
+
m = re.search(r"(tt\d+)", a_imdb["href"])
|
|
67
|
+
if m:
|
|
68
|
+
imdb_id = m.group(1)
|
|
69
|
+
debug(f"Found IMDb id: {imdb_id}")
|
|
70
|
+
|
|
71
|
+
season_id = re.findall(r"initSeason\('(.+?)\',", series_page)[0]
|
|
72
|
+
epoch = str(datetime.now().timestamp()).replace('.', '')[:-3]
|
|
73
|
+
api_url = 'https://' + sf + '/api/v1/' + season_id + f'/season/{season}?lang=ALL&_=' + epoch
|
|
74
|
+
|
|
75
|
+
response = requests.get(api_url, headers=headers, timeout=10)
|
|
76
|
+
try:
|
|
77
|
+
data = response.json()["html"]
|
|
78
|
+
except ValueError:
|
|
79
|
+
epoch = str(datetime.now().timestamp()).replace('.', '')[:-3]
|
|
80
|
+
api_url = 'https://' + sf + '/api/v1/' + season_id + f'/season/ALL?lang=ALL&_=' + epoch
|
|
81
|
+
response = requests.get(api_url, headers=headers, timeout=10)
|
|
82
|
+
data = response.json()["html"]
|
|
83
|
+
|
|
84
|
+
content = BeautifulSoup(data, "html.parser")
|
|
85
|
+
|
|
86
|
+
items = content.find_all("h3")
|
|
87
|
+
|
|
88
|
+
for item in items:
|
|
89
|
+
try:
|
|
90
|
+
details = item.parent.parent.parent
|
|
91
|
+
name = details.find("small").text.strip()
|
|
92
|
+
|
|
93
|
+
result_pattern = re.compile(
|
|
94
|
+
r'^(?P<name>.+?)\.S(?P<season>\d+)(?:E\d+)?\..*?(?P<resolution>\d+p)\..+?-(?P<group>[\w/-]+)$',
|
|
95
|
+
re.IGNORECASE
|
|
96
|
+
)
|
|
97
|
+
result_match = result_pattern.match(name)
|
|
98
|
+
|
|
99
|
+
if not result_match:
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
result_parts = result_match.groupdict()
|
|
103
|
+
|
|
104
|
+
# Normalize all relevant fields for case-insensitive comparison
|
|
105
|
+
name_match = release_parts['name'].lower() == result_parts['name'].lower()
|
|
106
|
+
season_match = release_parts['season'] == result_parts['season'] # Numbers are case-insensitive
|
|
107
|
+
resolution_match = release_parts['resolution'].lower() == result_parts['resolution'].lower()
|
|
108
|
+
|
|
109
|
+
# Handle multiple groups and case-insensitive matching
|
|
110
|
+
result_groups = {g.lower() for g in result_parts['group'].split('/')}
|
|
111
|
+
release_groups = {g.lower() for g in release_parts['group'].split('/')}
|
|
112
|
+
group_match = not result_groups.isdisjoint(release_groups) # Checks if any group matches
|
|
113
|
+
|
|
114
|
+
if name_match and season_match and resolution_match and group_match:
|
|
115
|
+
info(f'Release "{name}" found on SF at: {url}')
|
|
116
|
+
|
|
117
|
+
mirrors = parse_mirrors(f"https://{sf}", details)
|
|
118
|
+
|
|
119
|
+
if mirror:
|
|
120
|
+
if mirror not in mirrors["season"]:
|
|
121
|
+
continue
|
|
122
|
+
release_url = mirrors["season"][mirror]
|
|
123
|
+
if not release_url:
|
|
124
|
+
info(f"Could not find mirror '{mirror}' for '{title}'")
|
|
125
|
+
else:
|
|
126
|
+
release_url = next(iter(mirrors["season"].values()))
|
|
127
|
+
|
|
128
|
+
real_url = resolve_sf_redirect(release_url, shared_state.values["user_agent"])
|
|
129
|
+
return {
|
|
130
|
+
"real_url": real_url,
|
|
131
|
+
"imdb_id": imdb_id,
|
|
132
|
+
}
|
|
133
|
+
except:
|
|
134
|
+
continue
|
|
135
|
+
except:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
"real_url": None,
|
|
140
|
+
"imdb_id": None,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def resolve_sf_redirect(url, user_agent):
|
|
145
|
+
try:
|
|
146
|
+
response = requests.get(url, allow_redirects=True, timeout=10,
|
|
147
|
+
headers={'User-Agent': user_agent})
|
|
148
|
+
if response.history:
|
|
149
|
+
for resp in response.history:
|
|
150
|
+
debug(f"Redirected from {resp.url} to {response.url}")
|
|
151
|
+
if "/404.html" in response.url:
|
|
152
|
+
info(f"SF link redirected to 404 page: {response.url}")
|
|
153
|
+
return None
|
|
154
|
+
return response.url
|
|
155
|
+
else:
|
|
156
|
+
info(f"SF blocked attempt to resolve {url}. Your IP may be banned. Try again later.")
|
|
157
|
+
except Exception as e:
|
|
158
|
+
info(f"Error fetching redirected URL for {url}: {e}")
|
|
159
|
+
return None
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Quasarr
|
|
3
|
+
# Project by https://github.com/rix1337
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
from bs4 import BeautifulSoup
|
|
10
|
+
|
|
11
|
+
from quasarr.providers.log import info, debug
|
|
12
|
+
|
|
13
|
+
supported_mirrors = ["nitroflare", "ddownload"] # ignoring captcha-protected multiup/mirrorace for now
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_sl_download_links(shared_state, url, mirror, title): # signature must align with other download link functions!
|
|
17
|
+
headers = {"User-Agent": shared_state.values["user_agent"]}
|
|
18
|
+
session = requests.Session()
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
resp = session.get(url, headers=headers, timeout=10)
|
|
22
|
+
soup = BeautifulSoup(resp.text, "html.parser")
|
|
23
|
+
|
|
24
|
+
entry = soup.find("div", class_="entry")
|
|
25
|
+
if not entry:
|
|
26
|
+
info(f"Could not find main content section for {title}")
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
# extract IMDb id if present
|
|
30
|
+
imdb_id = None
|
|
31
|
+
a_imdb = soup.find("a", href=re.compile(r"imdb\.com/title/tt\d+"))
|
|
32
|
+
if a_imdb:
|
|
33
|
+
m = re.search(r"(tt\d+)", a_imdb["href"])
|
|
34
|
+
if m:
|
|
35
|
+
imdb_id = m.group(1)
|
|
36
|
+
debug(f"Found IMDb id: {imdb_id}")
|
|
37
|
+
|
|
38
|
+
download_h2 = entry.find(
|
|
39
|
+
lambda t: t.name == "h2" and "download" in t.get_text(strip=True).lower()
|
|
40
|
+
)
|
|
41
|
+
if download_h2:
|
|
42
|
+
anchors = []
|
|
43
|
+
for sib in download_h2.next_siblings:
|
|
44
|
+
if getattr(sib, "name", None) == "h2":
|
|
45
|
+
break
|
|
46
|
+
if hasattr(sib, "find_all"):
|
|
47
|
+
anchors += sib.find_all("a", href=True)
|
|
48
|
+
else:
|
|
49
|
+
anchors = entry.find_all("a", href=True)
|
|
50
|
+
|
|
51
|
+
except Exception as e:
|
|
52
|
+
info(f"SL site has been updated. Grabbing download links for {title} not possible! ({e})")
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
filtered = []
|
|
56
|
+
for a in anchors:
|
|
57
|
+
href = a["href"].strip()
|
|
58
|
+
if not href.lower().startswith(("http://", "https://")):
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
host = (urlparse(href).hostname or "").lower()
|
|
62
|
+
# require host to start with one of supported_mirrors + "."
|
|
63
|
+
if not any(host.startswith(m + ".") for m in supported_mirrors):
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
if not mirror or mirror in href:
|
|
67
|
+
filtered.append(href)
|
|
68
|
+
|
|
69
|
+
# regex‐fallback if still empty
|
|
70
|
+
if not filtered:
|
|
71
|
+
text = "".join(str(x) for x in anchors)
|
|
72
|
+
urls = re.findall(r"https?://[^\s<>'\"]+", text)
|
|
73
|
+
seen = set()
|
|
74
|
+
for u in urls:
|
|
75
|
+
u = u.strip()
|
|
76
|
+
if u in seen:
|
|
77
|
+
continue
|
|
78
|
+
seen.add(u)
|
|
79
|
+
|
|
80
|
+
host = (urlparse(u).hostname or "").lower()
|
|
81
|
+
if not any(host.startswith(m + ".") for m in supported_mirrors):
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
if not mirror or mirror in u:
|
|
85
|
+
filtered.append(u)
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
"links": filtered,
|
|
89
|
+
"imdb_id": imdb_id,
|
|
90
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Quasarr
|
|
3
|
+
# Project by https://github.com/rix1337
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from urllib.parse import urljoin
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
from bs4 import BeautifulSoup
|
|
10
|
+
|
|
11
|
+
from quasarr.providers.cloudflare import flaresolverr_get, is_cloudflare_challenge
|
|
12
|
+
from quasarr.providers.log import info, debug
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resolve_wd_redirect(url, user_agent):
|
|
16
|
+
"""
|
|
17
|
+
Follow redirects for a WD mirror URL and return the final destination.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
response = requests.get(
|
|
21
|
+
url,
|
|
22
|
+
allow_redirects=True,
|
|
23
|
+
timeout=10,
|
|
24
|
+
headers={"User-Agent": user_agent},
|
|
25
|
+
)
|
|
26
|
+
if response.history:
|
|
27
|
+
for resp in response.history:
|
|
28
|
+
debug(f"Redirected from {resp.url} to {response.url}")
|
|
29
|
+
return response.url
|
|
30
|
+
else:
|
|
31
|
+
info(f"WD blocked attempt to resolve {url}. Your IP may be banned. Try again later.")
|
|
32
|
+
except Exception as e:
|
|
33
|
+
info(f"Error fetching redirected URL for {url}: {e}")
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_wd_download_links(shared_state, url, mirror, title): # signature must align with other download link functions!
|
|
38
|
+
wd = shared_state.values["config"]("Hostnames").get("wd")
|
|
39
|
+
user_agent = shared_state.values["user_agent"]
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
output = requests.get(url)
|
|
43
|
+
if output.status_code == 403 or is_cloudflare_challenge(output.text):
|
|
44
|
+
info("WD is protected by Cloudflare. Using FlareSolverr to bypass protection.")
|
|
45
|
+
output = flaresolverr_get(shared_state, url)
|
|
46
|
+
|
|
47
|
+
soup = BeautifulSoup(output.text, "html.parser")
|
|
48
|
+
|
|
49
|
+
# extract IMDb id if present
|
|
50
|
+
imdb_id = None
|
|
51
|
+
a_imdb = soup.find("a", href=re.compile(r"imdb\.com/title/tt\d+"))
|
|
52
|
+
if a_imdb:
|
|
53
|
+
m = re.search(r"(tt\d+)", a_imdb["href"])
|
|
54
|
+
if m:
|
|
55
|
+
imdb_id = m.group(1)
|
|
56
|
+
debug(f"Found IMDb id: {imdb_id}")
|
|
57
|
+
|
|
58
|
+
# find Downloads card
|
|
59
|
+
header = soup.find(
|
|
60
|
+
"div",
|
|
61
|
+
class_="card-header",
|
|
62
|
+
string=re.compile(r"^\s*Downloads\s*$", re.IGNORECASE),
|
|
63
|
+
)
|
|
64
|
+
if not header:
|
|
65
|
+
info(f"WD Downloads section not found. Grabbing download links for {title} not possible!")
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
card = header.find_parent("div", class_="card")
|
|
69
|
+
body = card.find("div", class_="card-body")
|
|
70
|
+
link_tags = body.find_all(
|
|
71
|
+
"a", href=True, class_=lambda c: c and "background-" in c
|
|
72
|
+
)
|
|
73
|
+
except Exception:
|
|
74
|
+
info(f"WD site has been updated. Grabbing download links for {title} not possible!")
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
results = []
|
|
78
|
+
try:
|
|
79
|
+
for a in link_tags:
|
|
80
|
+
raw_href = a["href"]
|
|
81
|
+
full_link = urljoin(f"https://{wd}", raw_href)
|
|
82
|
+
|
|
83
|
+
# resolve any redirects
|
|
84
|
+
resolved = resolve_wd_redirect(full_link, user_agent)
|
|
85
|
+
|
|
86
|
+
if resolved:
|
|
87
|
+
if resolved.endswith("/404.html"):
|
|
88
|
+
info(f"Link {resolved} is dead!")
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# determine hoster
|
|
92
|
+
hoster = a.get_text(strip=True) or None
|
|
93
|
+
if not hoster:
|
|
94
|
+
for cls in a.get("class", []):
|
|
95
|
+
if cls.startswith("background-"):
|
|
96
|
+
hoster = cls.split("-", 1)[1]
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
if mirror and mirror.lower() not in hoster.lower():
|
|
100
|
+
debug(f'Skipping link from "{hoster}" (not the desired mirror "{mirror}")!')
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
results.append([resolved, hoster])
|
|
104
|
+
except Exception:
|
|
105
|
+
info(f"WD site has been updated. Parsing download links for {title} not possible!")
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"links": results,
|
|
109
|
+
"imdb_id": imdb_id,
|
|
110
|
+
}
|