mirror of
https://github.com/Yuvi9587/Kemono-Downloader.git
synced 2025-12-29 16:14:44 +00:00
191 lines
7.1 KiB
Python
191 lines
7.1 KiB
Python
import requests
|
|
import re
|
|
import os
|
|
import time
|
|
import threading
|
|
from urllib.parse import urlparse
|
|
|
|
class DeviantArtClient:
|
|
# Public Client Credentials
|
|
CLIENT_ID = "5388"
|
|
CLIENT_SECRET = "76b08c69cfb27f26d6161f9ab6d061a1"
|
|
BASE_API = "https://www.deviantart.com/api/v1/oauth2"
|
|
|
|
def __init__(self, logger_func=print):
|
|
self.session = requests.Session()
|
|
# Headers matching 1.py (Firefox)
|
|
self.session.headers.update({
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0",
|
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8",
|
|
"Accept-Language": "en-US,en;q=0.5",
|
|
"Connection": "keep-alive",
|
|
"Upgrade-Insecure-Requests": "1",
|
|
"Sec-Fetch-Dest": "document",
|
|
"Sec-Fetch-Mode": "navigate",
|
|
"Sec-Fetch-Site": "none",
|
|
"Sec-Fetch-User": "?1",
|
|
})
|
|
self.access_token = None
|
|
self.logger = logger_func
|
|
|
|
# --- DEDUPLICATION LOGIC ---
|
|
self.logged_waits = set()
|
|
self.log_lock = threading.Lock()
|
|
|
|
def authenticate(self):
|
|
"""Authenticates using client credentials flow."""
|
|
try:
|
|
url = "https://www.deviantart.com/oauth2/token"
|
|
data = {
|
|
"grant_type": "client_credentials",
|
|
"client_id": self.CLIENT_ID,
|
|
"client_secret": self.CLIENT_SECRET
|
|
}
|
|
resp = self.session.post(url, data=data, timeout=10)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
self.access_token = data.get("access_token")
|
|
return True
|
|
except Exception as e:
|
|
self.logger(f"DA Auth Error: {e}")
|
|
return False
|
|
|
|
def _api_call(self, endpoint, params=None):
|
|
if not self.access_token:
|
|
if not self.authenticate():
|
|
raise Exception("Authentication failed")
|
|
|
|
url = f"{self.BASE_API}{endpoint}"
|
|
params = params or {}
|
|
params['access_token'] = self.access_token
|
|
params['mature_content'] = 'true'
|
|
|
|
retries = 0
|
|
max_retries = 4
|
|
backoff_delay = 2
|
|
|
|
while True:
|
|
try:
|
|
resp = self.session.get(url, params=params, timeout=20)
|
|
|
|
# --- Handle Status Codes ---
|
|
|
|
# 429: Rate Limit (Retry infinitely like 1.py)
|
|
if resp.status_code == 429:
|
|
retry_after = resp.headers.get('Retry-After')
|
|
if retry_after:
|
|
sleep_time = int(retry_after) + 1
|
|
else:
|
|
sleep_time = 5 # Default sleep from 1.py
|
|
|
|
self._log_once(sleep_time, f" [DeviantArt] ⚠️ Rate limit (429). Sleeping {sleep_time}s...")
|
|
time.sleep(sleep_time)
|
|
continue
|
|
|
|
# 401: Token Expired (Refresh and Retry)
|
|
if resp.status_code == 401:
|
|
self.logger(" [DeviantArt] Token expired. Refreshing...")
|
|
if self.authenticate():
|
|
params['access_token'] = self.access_token
|
|
continue
|
|
else:
|
|
raise Exception("Failed to refresh token")
|
|
|
|
# 400, 403, 404: Client Errors (DO NOT RETRY)
|
|
# These mean the file doesn't exist or isn't downloadable via this endpoint.
|
|
if 400 <= resp.status_code < 500:
|
|
resp.raise_for_status() # This raises immediately, breaking the loop
|
|
|
|
# 5xx: Server Errors (Retry)
|
|
if 500 <= resp.status_code < 600:
|
|
resp.raise_for_status() # Will be caught by except block below for retry
|
|
|
|
resp.raise_for_status()
|
|
|
|
# Success - Clear logs
|
|
with self.log_lock:
|
|
self.logged_waits.clear()
|
|
|
|
return resp.json()
|
|
|
|
except requests.exceptions.HTTPError as e:
|
|
# If it's a 4xx error (caught above), re-raise it immediately
|
|
# so get_deviation_content can switch to fallback logic.
|
|
if e.response is not None and 400 <= e.response.status_code < 500:
|
|
raise e
|
|
|
|
# Otherwise fall through to general retry logic (for 5xx)
|
|
pass
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
# Network errors / 5xx errors -> Retry
|
|
if retries < max_retries:
|
|
self._log_once("conn_error", f" [DeviantArt] Connection error: {e}. Retrying...")
|
|
time.sleep(backoff_delay)
|
|
retries += 1
|
|
continue
|
|
raise e
|
|
|
|
def _log_once(self, key, message):
|
|
"""Helper to avoid spamming the same log message during loops."""
|
|
should_log = False
|
|
with self.log_lock:
|
|
if key not in self.logged_waits:
|
|
self.logged_waits.add(key)
|
|
should_log = True
|
|
if should_log:
|
|
self.logger(message)
|
|
|
|
def get_deviation_uuid(self, url):
|
|
"""Scrapes the deviation page to find the UUID."""
|
|
try:
|
|
resp = self.session.get(url, timeout=15)
|
|
match = re.search(r'"deviationUuid":"([^"]+)"', resp.text)
|
|
if match:
|
|
return match.group(1)
|
|
match = re.search(r'-(\d+)$', url)
|
|
if match:
|
|
return match.group(1)
|
|
except Exception as e:
|
|
self.logger(f"Error scraping UUID: {e}")
|
|
return None
|
|
|
|
def get_deviation_content(self, uuid):
|
|
"""Fetches download info."""
|
|
# 1. Try high-res download endpoint
|
|
try:
|
|
data = self._api_call(f"/deviation/download/{uuid}")
|
|
if 'src' in data:
|
|
return data
|
|
except:
|
|
# If 400/403 (Not downloadable), we fail silently here
|
|
# and proceed to step 2 (Metadata fallback)
|
|
pass
|
|
|
|
# 2. Fallback to standard content
|
|
try:
|
|
meta = self._api_call(f"/deviation/{uuid}")
|
|
if 'content' in meta:
|
|
return meta['content']
|
|
except:
|
|
pass
|
|
return None
|
|
|
|
def get_gallery_folder(self, username, offset=0, limit=24):
|
|
"""Fetches items from a user's gallery."""
|
|
return self._api_call("/gallery/all", {"username": username, "offset": offset, "limit": limit})
|
|
|
|
@staticmethod
|
|
def extract_info_from_url(url):
|
|
parsed = urlparse(url)
|
|
path = parsed.path.strip('/')
|
|
parts = path.split('/')
|
|
|
|
if len(parts) >= 3 and parts[1] == 'art':
|
|
return 'post', parts[0], parts[2]
|
|
elif len(parts) >= 2 and parts[1] == 'gallery':
|
|
return 'gallery', parts[0], None
|
|
elif len(parts) == 1:
|
|
return 'gallery', parts[0], None
|
|
|
|
return None, None, None |