| import json, time, re, urllib.parse, urllib.request, urllib.error, html as html_lib, threading, uuid |
| from concurrent.futures import ThreadPoolExecutor, wait, as_completed, TimeoutError as FuturesTimeoutError |
| from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler |
| import hashlib |
| import uuid |
|
|
| PORT = 7860 |
| OPENER = urllib.request.build_opener() |
| FAST_WORKERS = 3 |
| FULL_WORKERS = 48 |
| FETCH_TIMEOUT = 1.6 |
| FAST_SEARCH_TIMEOUT = 5.0 |
| FULL_SEARCH_TIMEOUT = 8.0 |
| CACHE_TTL = 21600 |
| VALIDATE_TIMEOUT = 1.6 |
| VALIDATE_RETRY_TIMEOUT = 4.0 |
| UNKNOWN_CACHE_TTL = 60 |
| UC_VALID_CACHE_TTL = 300 |
| UC_SEARCH_CACHE_TTL = 300 |
| BAIDU_VALID_CACHE_TTL = 300 |
| BAIDU_SEARCH_CACHE_TTL = 300 |
| MASTER_SYNC_VALIDATE_BUDGET = 2.5 |
| BUDGET_UNKNOWN_TYPES = {'115', '189', '139', 'other'} |
| VALIDATE_WORKERS = 96 |
| FAST_MAX_CHANNELS = 9 |
| FULL_MAX_CHANNELS = 150 |
| SEARCH_CACHE = {} |
| VALIDATE_CACHE = {} |
| VALIDATION_TASKS = {} |
| TASK_LOCK = threading.Lock() |
| RECENT_REQUESTS = [] |
| REQUEST_LOG_LOCK = threading.Lock() |
| REQUEST_LOG_LIMIT = 200 |
| TASK_TTL = 3600 |
| EXCLUDED_CHANNELS = {'yoyokuakeduanju'} |
| PRIORITY_CHANNELS = [ |
| 'Quark_Movies', 'leoziyuan', 'yunpanxunlei', 'NewQuark', 'shareAliyun', |
| 'alyp_TV', 'alyp_4K_Movies', 'ucquark', 'kuakeyun', 'tianyifc', |
| 'yunpan189', 'yunpanuc' |
| ] |
|
|
| CLOUD_DOMAINS = ( |
| 'pan.quark.cn', 'drive.uc.cn', 'pan.baidu.com', 'alipan.com', |
| 'aliyundrive.com', '115.com', '115cdn.com', 'anxia.com', |
| '123pan.com', '123pan.cn', '123684.com', '123865.com', '123912.com', |
| '123592.com', 'cloud.189.cn', 'caiyun.139.com', 'pan.xunlei.com', |
| ) |
| URL_RE = re.compile(r'https?://[^\s<>"\']+', re.I) |
| NON_VIDEO_TITLE_RE = re.compile(r'(?:有声剧|有声书|听书|广播剧|多人有声|主播[::]|音频|评书|小说朗读|朗读版)') |
|
|
|
|
| def is_cloud_url(url): |
| try: |
| host = urllib.parse.urlparse(url).netloc.lower() |
| except Exception: |
| return False |
| return any(d in host for d in CLOUD_DOMAINS) |
|
|
|
|
|
|
|
|
| def is_excluded_cloud_url(url): |
| try: |
| host = urllib.parse.urlparse(url).netloc.lower() |
| except Exception: |
| return False |
| return 'alipan.com' in host or 'aliyundrive.com' in host |
|
|
| def clean_tianyi_url(url): |
| raw = (url or '').strip().replace('&', '&') |
| decoded = urllib.parse.unquote(raw) |
| m = re.search(r'(?:[?&]code=|/web/share\?code=)([A-Za-z0-9]+)', decoded) |
| if not m: |
| return raw |
| code = m.group(1) |
| access = '' |
| q = urllib.parse.parse_qs(urllib.parse.urlparse(raw).query) |
| for key in ('accessCode', 'access_code', 'pwd', 'password'): |
| if q.get(key): |
| access = (q.get(key) or [''])[0] |
| break |
| if not access: |
| am = re.search(r'(?:访问码|提取码|密码)\s*[::]\s*([A-Za-z0-9]{3,12})', decoded) |
| if am: |
| access = am.group(1) |
| clean = 'https://cloud.189.cn/web/share?code=' + urllib.parse.quote(code) |
| if access: |
| clean += '&accessCode=' + urllib.parse.quote(access) |
| return clean |
|
|
|
|
| def clean_url(url): |
| url = (url or '').strip().replace('&', '&').lstrip('#') |
| if 'cloud.189.cn' in url: |
| url = clean_tianyi_url(url) |
| |
| |
| if '#' in url: |
| url = url.split('#', 1)[0] |
| return url.rstrip('。,,;;、))]】>&?#') |
|
|
|
|
| def is_non_video_title(title): |
| return bool(NON_VIDEO_TITLE_RE.search(title or '')) |
|
|
|
|
| def title_from_text(text, keyword): |
| lines = [x.strip() for x in text.splitlines() if x.strip()] |
| for i, line in enumerate(lines): |
| m = re.match(r'^(?:名称|片名|标题)\s*[::]\s*(.+)$', line) |
| if m and m.group(1).strip(): |
| return m.group(1).strip()[:120] |
| if re.match(r'^(?:名称|片名|标题)\s*[::]?$', line) and i + 1 < len(lines): |
| return lines[i + 1].strip()[:120] |
| for line in lines: |
| if keyword and keyword in line: |
| return line[:120] |
| return (lines[0][:120] if lines else keyword or '未知') |
|
|
|
|
| TITLE_LABEL_RE = re.compile(r'^(?:名称|片名|标题|剧名|资源名称)\s*[::]\s*(.+)$') |
| TITLE_LABEL_ONLY_RE = re.compile(r'^(?:名称|片名|标题|剧名|资源名称)\s*[::]?$') |
| DESC_LINE_RE = re.compile(r'^(?:简介|描述|介绍|剧情|资源简介|主演|导演|类型|地区|语言|年份|集数|更新|标签)\s*[::]?', re.I) |
| PLATFORM_LABEL_RE = re.compile(r'^(?:UC|UC网盘|夸克|夸克网盘|百度|百度网盘|迅雷|迅雷云盘|P?123|123网盘|115|115网盘|天翼|天翼云|移动|移动云|和彩云|阿里|阿里云盘|网盘|云盘|链接|下载|备用|在线)\s*[:::-]?.*$', re.I) |
|
|
|
|
| def is_probable_title_line(line): |
| line = (line or '').strip() |
| if not line or URL_RE.search(line) or line.startswith(('http://', 'https://')): |
| return False |
| if DESC_LINE_RE.match(line) or PLATFORM_LABEL_RE.match(line): |
| return False |
| |
| |
| if len(line) > 80: |
| return False |
| return True |
|
|
|
|
| def title_from_context(text, keyword): |
| lines = [x.strip() for x in (text or '').splitlines() if x.strip()] |
| if not lines: |
| return '' |
| for i, line in enumerate(lines): |
| m = TITLE_LABEL_RE.match(line) |
| if m and m.group(1).strip(): |
| return m.group(1).strip()[:120] |
| if TITLE_LABEL_ONLY_RE.match(line) and i + 1 < len(lines): |
| return lines[i + 1].strip()[:120] |
| |
| for line in reversed(lines): |
| if is_probable_title_line(line): |
| return line[:120] |
| return '' |
|
|
|
|
|
|
|
|
| def norm_text_for_match(text): |
| text = html_lib.unescape(text or '').lower() |
| |
| |
| text = re.sub(r'[丨||·・•\-—–_\s]+', '', text) |
| return re.sub(r'[\W_]+', '', text, flags=re.U) |
|
|
|
|
| def title_candidates_from_text(text, keyword): |
| lines = [x.strip() for x in (text or '').splitlines() if x.strip()] |
| candidates = [] |
| for i, line in enumerate(lines): |
| m = re.match(r'^(?:名称|片名|标题|剧名|资源名称)\s*[::]\s*(.+)$', line) |
| if m and m.group(1).strip(): |
| candidates.append(m.group(1).strip()) |
| if re.match(r'^(?:名称|片名|标题|剧名|资源名称)\s*[::]?$', line) and i + 1 < len(lines): |
| candidates.append(lines[i + 1].strip()) |
| |
| |
| |
| for line in lines[:6]: |
| if not URL_RE.search(line) and not line.startswith(('http://', 'https://')): |
| candidates.append(line) |
| title = title_from_text(text, keyword) |
| if title: |
| candidates.append(title) |
| return list(dict.fromkeys(candidates)) |
|
|
|
|
| def message_matches_keyword(text, keyword): |
| if not keyword: |
| return True |
| return norm_text_for_match(keyword) in norm_text_for_match(text) |
|
|
|
|
| def context_has_probable_title(text): |
| lines = [x.strip() for x in (text or '').splitlines() if x.strip()] |
| for i, line in enumerate(lines): |
| if TITLE_LABEL_RE.match(line) or TITLE_LABEL_ONLY_RE.match(line): |
| return True |
| return any(is_probable_title_line(line) for line in lines) |
|
|
|
|
| def keyword_title_line_from_context(text, keyword): |
| kw = norm_text_for_match(keyword) |
| if not kw: |
| return '' |
| lines = [x.strip() for x in (text or '').splitlines() if x.strip()] |
| |
| |
| |
| for line in reversed(lines): |
| if is_probable_title_line(line) and kw in norm_text_for_match(line): |
| return line[:120] |
| return '' |
|
|
|
|
| def context_matches_exact_keyword(text, keyword): |
| if not keyword: |
| return True |
| kw = norm_text_for_match(keyword) |
| if not kw: |
| return True |
| title = title_from_context(text, keyword) |
| return kw in norm_text_for_match(title) |
|
|
|
|
| def exact_title_matches_keyword(text, keyword): |
| if not keyword: |
| return True |
| kw = norm_text_for_match(keyword) |
| if not kw: |
| return True |
| for cand in title_candidates_from_text(text, keyword): |
| if kw in norm_text_for_match(cand): |
| return True |
| return False |
|
|
|
|
| def plain_text_from_html(block): |
| def repl_anchor(m): |
| href = html_lib.unescape(m.group(1) or '') |
| inner = m.group(2) or '' |
| inner = re.sub(r'<br\s*/?>', '\n', inner, flags=re.I) |
| inner = re.sub(r'<[^>]+>', '', inner) |
| inner = html_lib.unescape(inner).strip() |
| |
| |
| |
| |
| if href: |
| return (inner + '\n' if inner else '') + href |
| return inner |
| block = re.sub(r'<a[^>]+href=["\'](https?://[^"\']+)["\'][^>]*>(.*?)</a>', repl_anchor, block, flags=re.I | re.S) |
| plain = re.sub(r'<br\s*/?>', '\n', block, flags=re.I) |
| plain = re.sub(r'<[^>]+>', '', plain) |
| return html_lib.unescape(plain) |
|
|
|
|
| def split_plain_by_links(plain): |
| matches = list(URL_RE.finditer(plain or '')) |
| if not matches: |
| return [] |
| out = [] |
| prev_end = 0 |
| prev_context_start = 0 |
| for idx, m in enumerate(matches): |
| |
| |
| raw_link = m.group(0) |
| context = plain[prev_context_start:m.start()] |
| |
| |
| lines = [x for x in context.splitlines()] |
| if len(lines) > 8: |
| context = '\n'.join(lines[-8:]) |
| out.append((raw_link, context)) |
| prev_context_start = m.end() |
| prev_end = m.end() |
| return out |
|
|
| def fetch_tg(channel, keyword): |
| q = urllib.parse.quote(keyword) |
| url = f'https://telegram.me/s/{channel}?q={q}' |
| req = urllib.request.Request(url, headers={ |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36', |
| 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', |
| 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', |
| }) |
| with OPENER.open(req, timeout=FETCH_TIMEOUT) as resp: |
| return resp.read().decode('utf-8', 'ignore') |
|
|
|
|
| def parse_channel(channel, keyword, exact=True): |
| try: |
| text = fetch_tg(channel, keyword) |
| except Exception: |
| return '' |
|
|
| items, seen = [], set() |
| blocks = re.findall(r'<div class="tgme_widget_message_text[^>]*>(.*?)</div>', text, re.S) |
| if not blocks: |
| blocks = [text] |
| for block in blocks: |
| plain = plain_text_from_html(block) |
| if exact: |
| |
| if not exact_title_matches_keyword(plain, keyword) and not message_matches_keyword(plain, keyword): |
| continue |
| link_contexts = split_plain_by_links(plain) |
| |
| |
| if link_contexts: |
| inherited_title = '' |
| inherited_match = False |
| for raw, context in link_contexts: |
| link = clean_url(html_lib.unescape(raw)) |
| if not is_cloud_url(link) or is_excluded_cloud_url(link) or link in seen: |
| continue |
| local_title = title_from_context(context, keyword) |
| smart_title = keyword_title_line_from_context(context, keyword) |
| if smart_title and not local_title: |
| local_title = smart_title |
| local_has_title = bool(local_title) and context_has_probable_title(context) |
| local_match = bool(local_title) and (norm_text_for_match(keyword) in norm_text_for_match(local_title)) |
| if local_has_title: |
| inherited_title = local_title |
| inherited_match = local_match |
| if local_match: |
| title = local_title |
| elif inherited_match and not local_has_title: |
| |
| |
| |
| |
| title = inherited_title |
| else: |
| continue |
| seen.add(link) |
| if is_non_video_title(title): |
| continue |
| safe_title = title.replace('$$', ' ').replace('##', ' ').strip() or keyword or '未知' |
| items.append(f'{link}$${safe_title}') |
| continue |
| if not exact_title_matches_keyword(plain, keyword): |
| continue |
| elif not message_matches_keyword(plain, keyword): |
| continue |
| title = title_from_text(plain, keyword) |
| if is_non_video_title(title): |
| continue |
| candidates = re.findall(r'href="(https?://[^"]+)"', block) |
| candidates.extend(URL_RE.findall(plain)) |
| for raw in candidates: |
| link = clean_url(html_lib.unescape(raw)) |
| if not is_cloud_url(link) or is_excluded_cloud_url(link) or link in seen: |
| continue |
| seen.add(link) |
| safe_title = title.replace('$$', ' ').replace('##', ' ').strip() or keyword or '未知' |
| items.append(f'{link}$${safe_title}') |
| if not items: |
| return '' |
| return channel + '$$$' + '##'.join(items) |
|
|
|
|
| def normalize_channels(raw): |
| channels = [] |
| excluded = {x.lower() for x in EXCLUDED_CHANNELS} |
| for part in re.split(r'[,\n\r]+', raw or ''): |
| part = part.strip().lstrip('@') |
| if not part: |
| continue |
| part = re.sub(r'^https?://t\.me/(?:s/)?', '', part).split('/')[0].split('?')[0] |
| if part.lower() in excluded: |
| continue |
| if re.match(r'^[A-Za-z0-9_]{3,64}$', part): |
| channels.append(part) |
| return list(dict.fromkeys(channels)) |
|
|
|
|
| def cloud_type(url): |
| host = urllib.parse.urlparse(url).netloc.lower() |
| if 'pan.quark.cn' in host: |
| return 'quark' |
| if 'drive.uc.cn' in host: |
| return 'uc' |
| if 'pan.baidu.com' in host: |
| return 'baidu' |
| if 'alipan.com' in host or 'aliyundrive.com' in host: |
| return 'alipan' |
| if '115.com' in host or '115cdn.com' in host or 'anxia.com' in host: |
| return '115' |
| if '123pan' in host or re.search(r'123(684|865|912|592)\.com', host): |
| return '123pan' |
| if 'cloud.189.cn' in host: |
| return '189' |
| if 'caiyun.139.com' in host: |
| return '139' |
| if 'pan.xunlei.com' in host: |
| return 'xunlei' |
| return 'other' |
|
|
|
|
|
|
| XUNLEI_CLIENT_ID = "Xqp0kJBXWhwaTpB6" |
| XUNLEI_CLIENT_VERSION = "1.92.63" |
| XUNLEI_HOST = "pan.xunlei.com" |
| XUNLEI_SIGN_SALTS = ( |
| "y/Yo0rvIoedqTLS0qrHXFpCW4Df8/swwD46QhBUT/0CsU", |
| "DtzqOSvQcC7RFUMElw", |
| "v9Ot2jYbH8BKxnLKzN5Q/URMhp5vv2xmU5CgvPVPVl", |
| "UME2ypk6J12sqt0jektN6QYD", |
| "c2xXtD9YLc4WV56", |
| "T", |
| "Pp", |
| "hNCZhqhxzlA+BkGyoPFizQAtMAQWxhPX8Jmhnf0MqNrkv99curO", |
| "uRdzLQQCHmCVIwwGGF6h+FIp/gbE2yKxGlFpPEpKBYRV3E7NLP4DW", |
| ) |
| XUNLEI_INVALID_PHRASES = ( |
| "分享已失效", "分享地址已失效", "分享不存在", "链接不存在", "文件不存在", "资源不存在", |
| "分享的文件已经被取消", "分享已被取消", "分享被取消", "取消了分享", "已取消分享", |
| "来晚了", "啊哦", "已过期", "链接已过期", "已被删除", "不存在或已删除", |
| "页面不存在", "not found", "share not found", "expired", "deleted", "invalid share", |
| "好友已取消了分享", "该分享已取消", "分享文件已被删除", "文件已被删除", |
| "抱歉,该分享已被作者删除", "该分享已被作者删除", "分享已删除", |
| ) |
|
|
|
|
| def extract_xunlei_share_id(url): |
| try: |
| path = urllib.parse.urlparse(url).path or "" |
| except Exception: |
| return "" |
| m = re.search(r"/s/([A-Za-z0-9_-]+)", path) |
| return m.group(1) if m else "" |
|
|
|
|
| def phrase_hit_xunlei(text, phrases=XUNLEI_INVALID_PHRASES): |
| t = str(text or "").lower() |
| for p in phrases: |
| if str(p).lower() in t: |
| return p |
| return "" |
|
|
|
|
| def xunlei_md5(s): |
| return hashlib.md5(str(s).encode("utf-8")).hexdigest() |
|
|
|
|
| def xunlei_captcha_sign(device_id, ts): |
| h = XUNLEI_CLIENT_ID + XUNLEI_CLIENT_VERSION + XUNLEI_HOST + device_id + str(ts) |
| for salt in XUNLEI_SIGN_SALTS: |
| h = xunlei_md5(h + salt) |
| return "1." + h |
|
|
|
|
| def xunlei_captcha_token(device_id): |
| ts = str(int(time.time() * 1000)) |
| meta = { |
| "username": "", |
| "phone_number": "", |
| "email": "", |
| "package_name": XUNLEI_HOST, |
| "client_version": XUNLEI_CLIENT_VERSION, |
| "captcha_sign": xunlei_captcha_sign(device_id, ts), |
| "timestamp": ts, |
| "user_id": "0", |
| } |
| body = { |
| "client_id": XUNLEI_CLIENT_ID, |
| "action": "get:/drive/v1/share", |
| "device_id": device_id, |
| "meta": meta, |
| } |
| headers = { |
| "Content-Type": "application/json", |
| "Origin": "https://pan.xunlei.com", |
| "Referer": "https://pan.xunlei.com/", |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36", |
| } |
| try: |
| req = urllib.request.Request( |
| "https://xluser-ssl.xunlei.com/v1/shield/captcha/init", |
| data=json.dumps(body).encode("utf-8"), |
| headers=headers, |
| method="POST", |
| ) |
| with urllib.request.urlopen(req, timeout=VALIDATE_TIMEOUT) as resp: |
| j = json.loads(resp.read().decode("utf-8", "ignore") or "{}") |
| except Exception as e: |
| return "", "captcha_network_error_%s" % type(e).__name__ |
| token = j.get("captcha_token") or "" |
| if token: |
| return token, "" |
| return "", str(j.get("error_description") or j.get("error") or j)[:160] |
|
|
|
|
| def validate_xunlei_api(share_id, pass_code): |
| typ = "xunlei" |
| device_id = uuid.uuid4().hex |
| token, err = xunlei_captcha_token(device_id) |
| if not token: |
| return make_validation("unknown", None, typ, "captcha_" + err) |
| params = { |
| "share_id": share_id, |
| "pass_code": pass_code or "", |
| "limit": "20", |
| "pass_code_token": "", |
| "page_token": "", |
| "order": "DEFAULT_ORDER", |
| "thumbnail_size": "SIZE_MEDIUM", |
| } |
| api = "https://api-pan.xunlei.com/drive/v1/share?" + urllib.parse.urlencode(params) |
| headers = { |
| "Accept": "application/json", |
| "Origin": "https://pan.xunlei.com", |
| "Referer": "https://pan.xunlei.com/", |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36", |
| "x-device-id": device_id, |
| "x-client-id": XUNLEI_CLIENT_ID, |
| "x-captcha-token": token, |
| } |
| try: |
| req = urllib.request.Request(api, headers=headers, method="GET") |
| with urllib.request.urlopen(req, timeout=VALIDATE_TIMEOUT) as resp: |
| text = resp.read().decode("utf-8", "ignore") |
| status_code = resp.status |
| except urllib.error.HTTPError as e: |
| try: |
| text = e.read().decode("utf-8", "ignore") |
| except Exception: |
| text = "" |
| status_code = e.code |
| |
| if status_code in (400, 401, 403): |
| try: |
| j = json.loads(text or "{}") |
| return make_validation("unknown", None, typ, "api_http_%s_%s" % (status_code, j.get("error") or "")) |
| except Exception: |
| return make_validation("unknown", None, typ, "api_http_%s" % status_code) |
| if status_code in (404, 410, 451): |
| return make_validation("invalid", False, typ, "api_http_%s" % status_code) |
| return make_validation("unknown", None, typ, "api_http_%s" % status_code) |
| except Exception as e: |
| return make_validation("unknown", None, typ, "api_network_%s" % type(e).__name__) |
| try: |
| j = json.loads(text or "{}") |
| except Exception: |
| bad = phrase_hit_xunlei(text) |
| if bad: |
| return make_validation("invalid", False, typ, "api_phrase_" + bad) |
| return make_validation("unknown", None, typ, "api_json_error") |
| if j.get("error"): |
| bad = phrase_hit_xunlei(str(j.get("error_description") or j.get("error") or "")) |
| if bad: |
| return make_validation("invalid", False, typ, "api_" + bad) |
| return make_validation("unknown", None, typ, "api_" + str(j.get("error") or j.get("error_code"))[:80]) |
| status = str(j.get("share_status") or "").upper() |
| status_text = str(j.get("share_status_text") or "") |
| if status in ("DELETED", "EXPIRED", "NOT_FOUND", "SENSITIVE", "SENSITIVE_WORD", "SENSITIVE_RESOURCE"): |
| return make_validation("invalid", False, typ, "api_status_" + status.lower()) |
| if phrase_hit_xunlei(status_text): |
| return make_validation("invalid", False, typ, "api_" + status_text) |
| files = j.get("files") |
| try: |
| file_num = int(j.get("file_num") or 0) |
| except Exception: |
| file_num = 0 |
| if status == "OK" and ((isinstance(files, list) and len(files) > 0) or file_num > 0): |
| return make_validation("valid", True, typ, "api_ok_files") |
| if status == "OK": |
| |
| return make_validation("unknown", None, typ, "api_ok_empty") |
| return make_validation("unknown", None, typ, "api_status_" + (status.lower() or "missing")) |
|
|
|
|
| def validate_xunlei_page(url): |
| headers = { |
| "Referer": "https://www.google.com/", |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36", |
| } |
| try: |
| req = urllib.request.Request(url, headers=headers, method="GET") |
| with urllib.request.urlopen(req, timeout=VALIDATE_TIMEOUT) as resp: |
| text = resp.read(131072).decode("utf-8", "ignore") |
| code = resp.status |
| except urllib.error.HTTPError as e: |
| try: |
| text = e.read(65536).decode("utf-8", "ignore") |
| except Exception: |
| text = "" |
| code = e.code |
| if code in (404, 410, 451): |
| return make_validation("invalid", False, "xunlei", "http_%s" % code) |
| return make_validation("unknown", None, "xunlei", "http_%s" % code) |
| except Exception as e: |
| return make_validation("unknown", None, "xunlei", "network_%s" % type(e).__name__) |
| bad = phrase_hit_xunlei(text) |
| if code in (404, 410, 451): |
| return make_validation("invalid", False, "xunlei", "http_%s" % code) |
| if bad: |
| return make_validation("invalid", False, "xunlei", "phrase_" + bad) |
| |
| return make_validation("unknown", None, "xunlei", "page_http_%s" % code) |
|
|
|
|
| def validate_xunlei(url): |
| typ = "xunlei" |
| sid = extract_xunlei_share_id(url) |
| if not sid: |
| return make_validation("unknown", None, typ, "no_share_id") |
| q = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query) |
| pass_code = (q.get("pwd") or q.get("pass_code") or q.get("passcode") or q.get("password") or [""])[0] |
| api_res = validate_xunlei_api(sid, pass_code) |
| if api_res.get("status") in ("valid", "invalid"): |
| return api_res |
| page_res = validate_xunlei_page(url) |
| if page_res.get("status") == "invalid": |
| return page_res |
| return api_res if api_res.get("status") == "unknown" else page_res |
|
|
|
|
| def make_validation(status='unknown', ok=None, typ='other', reason='', http_status=None, final_url=None, content_type=''): |
| info = {'status': status, 'ok': ok, 'http_status': http_status, 'type': typ} |
| if reason: |
| info['reason'] = reason |
| if final_url: |
| info['final_url'] = final_url |
| if content_type: |
| info['content_type'] = content_type |
| return info |
|
|
|
|
| def open_json(req, timeout=None): |
| try: |
| with urllib.request.urlopen(req, timeout=timeout or VALIDATE_TIMEOUT) as resp: |
| body = resp.read(200000) |
| return resp.status, resp.geturl(), resp.headers.get('Content-Type') or '', json.loads(body.decode('utf-8', 'ignore') or '{}') |
| except urllib.error.HTTPError as e: |
| body = e.read(200000) |
| text = body.decode('utf-8', 'ignore') |
| try: |
| data = json.loads(text or '{}') |
| except Exception: |
| data = {'_raw': text[:1000]} |
| return e.code, e.geturl(), (e.headers.get('Content-Type') if e.headers else '') or '', data |
|
|
|
|
| def validate_quark(url, timeout=None): |
| typ = 'quark' |
| u = urllib.parse.urlparse(url) |
| m = re.search(r'/s/([A-Za-z0-9]+)', u.path) |
| if not m: |
| return make_validation('invalid', False, typ, 'bad_format') |
| pwd_id = m.group(1) |
| passcode = urllib.parse.parse_qs(u.query).get('pwd', [''])[0] |
| token_api = 'https://drive-h.quark.cn/1/clouddrive/share/sharepage/token' |
| data = json.dumps({'pwd_id': pwd_id, 'passcode': passcode, 'support_visit_limit_private_share': True}).encode() |
| req = urllib.request.Request(token_api, data=data, method='POST', headers={ |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36', |
| 'Accept': 'application/json, text/plain, */*', |
| 'Accept-Language': 'zh-CN,zh;q=0.9', |
| 'Content-Type': 'application/json', 'Origin': 'https://pan.quark.cn', 'Referer': 'https://pan.quark.cn/' |
| }) |
| try: |
| status, final_url, ct, res = open_json(req, timeout=timeout) |
| except Exception as e: |
| return make_validation('unknown', None, typ, type(e).__name__) |
| code = res.get('code') |
| if code == 41008: |
| return make_validation('valid' if not passcode else 'invalid', True if not passcode else False, typ, 'password_required' if not passcode else 'bad_password', status, url, ct) |
| if code in (41004, 41010, 41011): |
| return make_validation('invalid', False, typ, 'bad_password', status, url, ct) |
| if not (status == 200 and code == 0 and res.get('data', {}).get('stoken')): |
| return make_validation('invalid', False, typ, f'code={code}', status, url, ct) |
| stoken = res.get('data', {}).get('stoken') |
| |
| detail = 'https://drive-pc.quark.cn/1/clouddrive/share/sharepage/detail?' + urllib.parse.urlencode({'pwd_id': pwd_id, 'stoken': stoken, 'ver': '2', 'pr': 'ucpro'}) |
| dreq = urllib.request.Request(detail, headers={ |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36', |
| 'Accept': 'application/json, text/plain, */*', |
| 'Accept-Language': 'zh-CN,zh;q=0.9', |
| 'Origin': 'https://pan.quark.cn', 'Referer': 'https://pan.quark.cn/', |
| 'Cache-Control': 'no-cache', 'Pragma': 'no-cache' |
| }) |
| try: |
| dstatus, dfinal, dct, detail_res = open_json(dreq, timeout=timeout) |
| except Exception as e: |
| |
| return make_validation('valid', True, typ, 'token_ok_detail_' + type(e).__name__, status, url, ct) |
| data = detail_res.get('data') or {} |
| lst = data.get('list') or [] |
| share = data.get('share') or {} |
| share_status = share.get('status') |
| partial = bool(share.get('partial_violation')) |
| if not lst: |
| if isinstance(share_status, int) and share_status > 1: |
| return make_validation('invalid', False, typ, f'share_status={share_status}', dstatus, dfinal, dct) |
| return make_validation('invalid', False, typ, 'empty_file_list', dstatus, dfinal, dct) |
| if share_status == 1: |
| return make_validation('valid', True, typ, 'detail_ok_partial_violation' if partial else 'detail_ok', dstatus, dfinal, dct) |
| if share_status == 3 and not partial: |
| return make_validation('valid', True, typ, 'share_status_3_no_violation', dstatus, dfinal, dct) |
| if isinstance(share_status, int) and share_status > 1: |
| return make_validation('invalid', False, typ, f'share_status={share_status}', dstatus, dfinal, dct) |
| return make_validation('valid', True, typ, 'detail_ok', dstatus, dfinal, dct) |
|
|
| def validate_uc(url): |
| typ = 'uc' |
| m = re.search(r'/s/([A-Za-z0-9]+)', urllib.parse.urlparse(url).path) |
| if not m: |
| return make_validation('invalid', False, typ, 'bad_format') |
| pwd_id = m.group(1) |
| passcode = urllib.parse.parse_qs(urllib.parse.urlparse(url).query).get('pwd', [''])[0] |
|
|
| |
| |
| |
| token_api = 'https://pc-api.uc.cn/1/clouddrive/share/sharepage/token' |
| data = json.dumps({'pwd_id': pwd_id, 'passcode': passcode, 'support_visit_limit_private_share': True}).encode() |
| req = urllib.request.Request(token_api, data=data, method='POST', headers={ |
| 'User-Agent': 'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 Chrome/120 Mobile Safari/537.36', |
| 'Accept': 'application/json, text/plain, */*', |
| 'Accept-Language': 'zh-CN,zh;q=0.9', |
| 'Content-Type': 'application/json', |
| 'Origin': 'https://drive.uc.cn', |
| 'Referer': 'https://drive.uc.cn/s/' + pwd_id, |
| }) |
| try: |
| status, final_url, ct, res = open_json(req, timeout=VALIDATE_RETRY_TIMEOUT) |
| except Exception as e: |
| return make_validation('unknown', None, typ, 'token_' + type(e).__name__) |
| code = res.get('code') |
| msg = str(res.get('message') or '') |
| if status == 200 and code == 0 and (res.get('data') or {}).get('stoken'): |
| data_obj = res.get('data') or {} |
| stoken = data_obj.get('stoken') |
| file_num = data_obj.get('file_num') |
| if isinstance(file_num, int) and file_num <= 0: |
| return make_validation('invalid', False, typ, 'empty_file_num', status, final_url, ct) |
| |
| |
| detail_qs = urllib.parse.urlencode({ |
| 'pwd_id': pwd_id, |
| 'stoken': stoken, |
| 'pdir_fid': '0', |
| 'force': '0', |
| '_page': '1', |
| '_size': '50', |
| '_fetch_banner': '1', |
| '_fetch_share': '1', |
| '_fetch_total': '1', |
| '_sort': '', |
| }) |
| dreq = urllib.request.Request('https://pc-api.uc.cn/1/clouddrive/share/sharepage/detail?' + detail_qs, headers={ |
| 'User-Agent': 'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 Chrome/120 Mobile Safari/537.36', |
| 'Accept': 'application/json, text/plain, */*', |
| 'Accept-Language': 'zh-CN,zh;q=0.9', |
| 'Origin': 'https://drive.uc.cn', |
| 'Referer': 'https://drive.uc.cn/s/' + pwd_id, |
| }) |
| try: |
| dstatus, dfinal, dct, detail_res = open_json(dreq, timeout=VALIDATE_RETRY_TIMEOUT) |
| except Exception as e: |
| |
| return make_validation('valid', True, typ, 'token_ok_detail_' + type(e).__name__, status, final_url, ct) |
| dcode = detail_res.get('code') |
| dmsg = str(detail_res.get('message') or '') |
| if dcode in (41011, 41012, 41013, 41014) or any(k in dmsg for k in ['失效', '不存在', '取消', '删除', '过期', '违规']): |
| return make_validation('invalid', False, typ, f'detail_code={dcode}', dstatus, dfinal, dct) |
| ddata = detail_res.get('data') or {} |
| share = ddata.get('share') or {} |
| lst = ddata.get('list') or [] |
| share_status = share.get('status') |
| partial = bool(share.get('partial_violation')) |
| all_file_num = share.get('all_file_num') |
| file_only_num = share.get('file_only_num') |
| if isinstance(share_status, int) and share_status > 1 and not partial: |
| return make_validation('invalid', False, typ, f'share_status={share_status}', dstatus, dfinal, dct) |
| if not lst and ((isinstance(all_file_num, int) and all_file_num <= 0) or (isinstance(file_only_num, int) and file_only_num <= 0)): |
| return make_validation('invalid', False, typ, 'empty_detail_list', dstatus, dfinal, dct) |
| if not lst and share_status not in (1, None): |
| return make_validation('invalid', False, typ, 'empty_detail_list', dstatus, dfinal, dct) |
| return make_validation('valid', True, typ, 'detail_ok_partial_violation' if partial else 'detail_ok', dstatus, dfinal, dct) |
| if code in (41004, 41010): |
| return make_validation('invalid', False, typ, 'bad_password', status, final_url, ct) |
| if code in (41011, 41012, 41013, 41014) or any(k in msg for k in ['失效', '不存在', '取消', '删除', '过期', '违规']): |
| return make_validation('invalid', False, typ, f'code={code}', status, final_url, ct) |
| if code == 41008: |
| return make_validation('valid' if not passcode else 'invalid', True if not passcode else False, typ, 'password_required' if not passcode else 'bad_password', status, final_url, ct) |
| return make_validation('unknown', None, typ, f'code={code}', status, final_url, ct) |
|
|
|
|
| def baidu_ids(url): |
| u = urllib.parse.urlparse(url) |
| q = urllib.parse.parse_qs(u.query) |
| pwd = (q.get('pwd') or [''])[0] |
| if u.path.startswith('/s/'): |
| surl = u.path.split('/s/', 1)[1].split('/')[0] |
| short = surl[1:] if len(surl) > 1 and surl.startswith('1') else surl |
| return short, pwd |
| if u.path.startswith('/share/init') or u.path.startswith('/wap/init'): |
| return (q.get('surl') or [''])[0], pwd |
| return '', pwd |
|
|
|
|
| def validate_baidu(url): |
| typ = 'baidu' |
| short, pwd = baidu_ids(url) |
| if not short: |
| return make_validation('invalid', False, typ, 'bad_format') |
| headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36', 'Referer': url, 'Accept': 'application/json, text/plain, */*'} |
| cookie = '' |
| if pwd: |
| verify = f'https://pan.baidu.com/share/verify?surl={urllib.parse.quote(short)}&pwd={urllib.parse.quote(pwd)}' |
| body = urllib.parse.urlencode({'pwd': pwd, 'vcode': '', 'vcode_str': ''}).encode() |
| req = urllib.request.Request(verify, data=body, method='POST', headers={**headers, 'Content-Type': 'application/x-www-form-urlencoded'}) |
| try: |
| _, _, _, res = open_json(req) |
| if res.get('errno') != 0: |
| return make_validation('invalid', False, typ, f"verify_errno={res.get('errno')}") |
| if res.get('randsk'): |
| cookie = 'BDCLND=' + res.get('randsk') |
| except Exception as e: |
| return make_validation('unknown', None, typ, 'verify_' + type(e).__name__) |
| api = f'https://pan.baidu.com/share/list?web=5&app_id=250528&desc=1&showempty=0&page=1&num=20&order=time&shorturl={urllib.parse.quote(short)}&root=1&view_mode=1&channel=chunlei&web=1&clienttype=0' |
| h = dict(headers) |
| if cookie: |
| h['Cookie'] = cookie |
| req = urllib.request.Request(api, headers=h) |
| try: |
| status, final_url, ct, res = open_json(req) |
| except Exception as e: |
| return make_validation('unknown', None, typ, type(e).__name__) |
| errno = int(res.get('errno', 999999)) |
| if errno == 0: |
| return make_validation('valid', True, typ, 'errno_0', status, final_url, ct) |
| if errno == -12: |
| return make_validation('valid', True, typ, 'password_required', status, final_url, ct) |
| if errno == -62: |
| return make_validation('unknown', None, typ, 'rate_limited', status, final_url, ct) |
| return make_validation('invalid', False, typ, f'errno={errno}', status, final_url, ct) |
|
|
|
|
| def validate_alipan(url): |
| typ = 'alipan' |
| parts = [x for x in urllib.parse.urlparse(url).path.split('/') if x] |
| if not parts: |
| return make_validation('invalid', False, typ, 'bad_format') |
| share_id = parts[-1] |
| api = f'https://api.aliyundrive.com/adrive/v3/share_link/get_share_by_anonymous?share_id={urllib.parse.quote(share_id)}' |
| data = json.dumps({'share_id': share_id}).encode() |
| req = urllib.request.Request(api, data=data, method='POST', headers={ |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36', |
| 'Content-Type': 'application/json', 'Origin': 'https://www.alipan.com', 'Referer': 'https://www.alipan.com/', 'x-canary': 'client=web,app=share,version=v2.3.1' |
| }) |
| try: |
| status, final_url, ct, res = open_json(req) |
| except urllib.error.HTTPError as e: |
| return make_validation('unknown' if e.code == 429 else 'invalid', None if e.code == 429 else False, typ, f'http_{e.code}', e.code) |
| except Exception as e: |
| return make_validation('unknown', None, typ, type(e).__name__) |
| code = res.get('code') or '' |
| if 'Exceed' in code: |
| return make_validation('unknown', None, typ, 'rate_limited', status, final_url, ct) |
| if 'ShareLink' in code: |
| return make_validation('invalid', False, typ, code, status, final_url, ct) |
| if res.get('file_count', 0) > 0 or res.get('share_name') or res.get('share_title'): |
| return make_validation('valid', True, typ, 'api_ok', status, final_url, ct) |
| return make_validation('unknown', None, typ, 'empty_response', status, final_url, ct) |
|
|
|
|
| def decode_123pan_uid(prefix): |
| code62 = 'Tvd3hHA9QEkom14xpfaBJIMwgFYGPXn2sWCNORDr80KuUSl7bZcetizL5q6yVj' |
| dec = {ch: i for i, ch in enumerate(code62)} |
| if not prefix or any(ch not in dec for ch in prefix): |
| return None |
| value = 0 |
| for i, ch in enumerate(prefix): |
| value += dec[ch] * (62 ** i) |
| if value <= 0 or value > 9007199254740991: |
| return None |
| return value |
|
|
|
|
| def validate_123(url): |
| typ = '123pan' |
| parsed = urllib.parse.urlparse(url) |
| path = parsed.path |
| m = re.search(r'/(?:s|ps|123pan)/([A-Za-z0-9]+-[A-Za-z0-9]+)', path) |
| if not m: |
| return make_validation('invalid', False, typ, 'bad_format') |
| share_key = m.group(1) |
| q = urllib.parse.parse_qs(parsed.query) |
| share_pwd = (q.get('pwd') or q.get('password') or q.get('SharePwd') or [''])[0] |
| uid_prefix = share_key.split('-', 1)[0] |
| uid = decode_123pan_uid(uid_prefix) |
| if not uid: |
| return make_validation('unknown', None, typ, 'bad_share_uid') |
| base = 'https://%s.share.123pan.cn' % uid |
| headers = { |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36', |
| 'Accept': 'application/json, text/plain, */*', |
| 'Referer': '%s/123pan/%s?notoken=1' % (base, share_key), |
| 'Origin': base, |
| } |
| |
| info_api = '%s/gsb/s/%s' % (base, urllib.parse.quote(share_key)) |
| try: |
| status, final_url, ct, res = open_json(urllib.request.Request(info_api, headers=headers), timeout=VALIDATE_RETRY_TIMEOUT) |
| except Exception as e: |
| return make_validation('unknown', None, typ, type(e).__name__) |
| info = res.get('info') if isinstance(res.get('info'), dict) else res |
| code = info.get('code') |
| data = info.get('data') or {} |
| msg = str(info.get('message') or info.get('msg') or '') |
| if code in (5103, 5104, 5105, 5107, 5110, 5111) or any(x in msg for x in ['不存在', '失效', '删除', '取消', '过期']): |
| return make_validation('invalid', False, typ, f'code={code}:{msg}', status, final_url, ct) |
| if code not in (0, None): |
| return make_validation('unknown', None, typ, f'code={code}:{msg}', status, final_url, ct) |
| |
| |
| list_api = '%s/gsb/s/share-list?%s' % ( |
| base, |
| urllib.parse.urlencode({'OrderId': '', 'SharePwd': share_pwd or '', 'shareKey': share_key}), |
| ) |
| try: |
| lstatus, lfinal, lct, lres = open_json(urllib.request.Request(list_api, headers=headers), timeout=VALIDATE_RETRY_TIMEOUT) |
| except Exception as e: |
| |
| return make_validation('unknown', None, typ, 'list_' + type(e).__name__, status, final_url, ct) |
| lcode = lres.get('code') |
| lmsg = str(lres.get('message') or lres.get('msg') or '') |
| ldata = lres.get('data') or {} |
| if lcode in (5103, 5104, 5105, 5107, 5110, 5111) or any(x in lmsg for x in ['不存在', '失效', '删除', '取消', '过期']): |
| return make_validation('invalid', False, typ, f'list_code={lcode}:{lmsg}', lstatus, lfinal, lct) |
| info_list = ldata.get('InfoList') if isinstance(ldata, dict) else None |
| try: |
| list_len = int(ldata.get('Len')) if isinstance(ldata, dict) and ldata.get('Len') is not None else None |
| except Exception: |
| list_len = None |
| if isinstance(info_list, list): |
| if len(info_list) == 0 or list_len == 0: |
| return make_validation('invalid', False, typ, 'empty_file_list', lstatus, lfinal, lct) |
| return make_validation('valid', True, typ, 'list_ok', lstatus, lfinal, lct) |
| if code == 0 and (data.get('ShareName') or data.get('FileName') or data.get('ShareKey')): |
| |
| return make_validation('unknown', None, typ, 'list_shape_unknown', lstatus, lfinal, lct) |
| return make_validation('unknown', None, typ, f'code={code}:{msg}', status, final_url, ct) |
|
|
| def validate_115(url): |
| typ = '115' |
| u = urllib.parse.urlparse(url) |
| share_code = [x for x in u.path.split('/') if x] |
| share_code = share_code[-1] if share_code else '' |
| q = urllib.parse.parse_qs(u.query) |
| receive = (q.get('password') or [''])[0] |
| if not receive and u.fragment and 'password=' in u.fragment: |
| receive = (urllib.parse.parse_qs(u.fragment).get('password') or [''])[0] |
| if not share_code or not receive: |
| return make_validation('unknown', None, typ, 'missing_password') |
| api = f'https://115cdn.com/webapi/share/snap?share_code={urllib.parse.quote(share_code)}&offset=0&limit=20&receive_code={urllib.parse.quote(receive)}&cid=' |
| req = urllib.request.Request(api, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Referer': f'https://115cdn.com/s/{share_code}?password={receive}&', 'X-Requested-With': 'XMLHttpRequest'}) |
| try: |
| status, final_url, ct, res = open_json(req) |
| except Exception as e: |
| return make_validation('unknown', None, typ, type(e).__name__) |
| if res.get('state') and res.get('errno') == 0: |
| data = res.get('data') or {} |
| share_state = data.get('share_state') or (data.get('shareinfo') or {}).get('share_state') |
| if share_state == 1: |
| return make_validation('valid', True, typ, 'api_ok', status, final_url, ct) |
| return make_validation('invalid', False, typ, f'share_state={share_state}', status, final_url, ct) |
| return make_validation('invalid', False, typ, res.get('error') or 'api_error', status, final_url, ct) |
|
|
|
|
| def tianyi_ids(url): |
| u = urllib.parse.urlparse(clean_tianyi_url(url)) |
| q = urllib.parse.parse_qs(u.query) |
| return (q.get('code') or [''])[0], (q.get('accessCode') or [''])[0] |
|
|
|
|
| def validate_189(url): |
| typ = '189' |
| share_code, access_code = tianyi_ids(url) |
| if not share_code: |
| return make_validation('invalid', False, typ, 'bad_format') |
| api = 'https://cloud.189.cn/api/open/share/getShareInfoByCode.action?' + urllib.parse.urlencode({ |
| 'shareCode': share_code, |
| 'accessCode': access_code, |
| }) |
| req = urllib.request.Request(api, headers={ |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36', |
| 'Accept': 'application/json, text/plain, */*', |
| 'Origin': 'https://cloud.189.cn', |
| 'Referer': 'https://cloud.189.cn/web/share?code=' + urllib.parse.quote(share_code), |
| }) |
| try: |
| status, final_url, ct, res = open_json(req, timeout=VALIDATE_RETRY_TIMEOUT) |
| text = json.dumps(res, ensure_ascii=False) |
| except urllib.error.HTTPError as e: |
| status = e.code; final_url = e.geturl(); ct = e.headers.get('Content-Type') or '' |
| try: |
| text = e.read(4096).decode('utf-8', 'ignore') |
| except Exception: |
| text = '' |
| except Exception as e: |
| return make_validation('unknown', None, typ, type(e).__name__) |
| bad_codes = ['ShareAuditNotPass', 'ShareNotFound', 'ShareExpired', 'ShareCancelled', 'InvalidAccessCode', 'ShareNotExist'] |
| if any(code in text for code in bad_codes) or any(k in text for k in ['share audit not pass', '分享不存在', '分享已失效', '访问码错误', '提取码错误', '已取消']): |
| reason = 'api_invalid' |
| m = re.search(r'<code>([^<]+)</code>|"errorCode"\s*:\s*"([^"]+)"', text) |
| if m: |
| reason = m.group(1) or m.group(2) or reason |
| return make_validation('invalid', False, typ, reason, status, final_url, ct) |
| if status and 200 <= status < 300: |
| return make_validation('valid', True, typ, 'api_ok', status, final_url, ct) |
| return make_validation('unknown', None, typ, 'api_status=' + str(status), status, final_url, ct) |
|
|
|
|
| def validate_fallback(url, typ): |
| info = {'status': 'unknown', 'ok': None, 'http_status': None, 'type': typ} |
| headers = { |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36', |
| 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', |
| 'Range': 'bytes=0-65535', |
| } |
| bad_markers = ['分享已失效', '文件不存在', '链接不存在', '已被取消', '已过期', '来晚啦', 'share not found', '分享不存在', '资源不存在', '链接已失效', 'page not found', '404 not found', '不存在或已被删除'] |
| status = None; final_url = url; ct = ''; body = b'' |
| try: |
| req = urllib.request.Request(url, headers=headers, method='HEAD') |
| with urllib.request.urlopen(req, timeout=VALIDATE_TIMEOUT) as resp: |
| status = resp.status; final_url = resp.geturl(); ct = resp.headers.get('Content-Type') or '' |
| except urllib.error.HTTPError as e: |
| status = e.code; final_url = e.geturl(); ct = e.headers.get('Content-Type') or '' |
| try: body = e.read(65536) |
| except Exception: body = b'' |
| except Exception: |
| status = None |
| |
| if status is None or (200 <= status < 400): |
| try: |
| req = urllib.request.Request(url, headers=headers) |
| with urllib.request.urlopen(req, timeout=VALIDATE_TIMEOUT) as resp: |
| status = resp.status; final_url = resp.geturl(); ct = resp.headers.get('Content-Type') or ''; body = resp.read(65536) |
| except urllib.error.HTTPError as e: |
| status = e.code; final_url = e.geturl(); ct = e.headers.get('Content-Type') or '' |
| try: body = e.read(65536) |
| except Exception: body = b'' |
| except Exception as e: |
| if status is None: |
| info.update({'status': 'unknown', 'ok': None, 'error': type(e).__name__}) |
| return info |
| text = body.decode('utf-8', 'ignore').lower() if body else '' |
| bad = status in (404, 410) or any(m.lower() in text for m in bad_markers) |
| if bad: |
| info.update({'status': 'invalid', 'ok': False}) |
| elif status is not None and 200 <= status < 400: |
| info.update({'status': 'valid', 'ok': True}) |
| else: |
| info.update({'status': 'unknown', 'ok': None}) |
| info.update({'http_status': status, 'final_url': final_url, 'content_type': ct}) |
| return info |
|
|
| def validate_link(url, force=False, retry_timeout=None): |
| now = time.time() |
| cached = VALIDATE_CACHE.get(url) |
| if cached and not force: |
| cached_t, cached_info = cached |
| if (cached_info or {}).get('status') == 'unknown': |
| ttl = UNKNOWN_CACHE_TTL |
| elif cloud_type(url) == 'uc' and (cached_info or {}).get('status') == 'valid': |
| ttl = UC_VALID_CACHE_TTL |
| elif cloud_type(url) == 'baidu' and (cached_info or {}).get('status') == 'valid': |
| ttl = BAIDU_VALID_CACHE_TTL |
| else: |
| ttl = CACHE_TTL |
| if now - cached_t < ttl: |
| return cached_info |
| typ = cloud_type(url) |
| try: |
| if typ == 'quark': |
| info = validate_quark(url, timeout=retry_timeout) |
| elif typ == 'uc': |
| info = validate_uc(url) |
| elif typ == 'baidu': |
| info = validate_baidu(url) |
| elif typ == 'alipan': |
| info = validate_alipan(url) |
| elif typ == '123pan': |
| info = validate_123(url) |
| elif typ == '115': |
| info = validate_115(url) |
| elif typ == '189': |
| info = validate_189(url) |
| elif typ == 'xunlei': |
| info = validate_xunlei(url) |
| else: |
| info = validate_fallback(url, typ) |
| except Exception as e: |
| info = make_validation('unknown', None, typ, type(e).__name__) |
| info['type'] = typ |
| VALIDATE_CACHE[url] = (now, info) |
| return info |
|
|
|
|
| def retry_unknown_validity(validity): |
| |
| |
| |
| retry_types = {'quark', 'uc', 'baidu', '123pan'} |
| unknown_links = [link for link, info in validity.items() if (info or {}).get('status') == 'unknown' and cloud_type(link) in retry_types] |
| if not unknown_links: |
| return validity |
| with ThreadPoolExecutor(max_workers=min(4, len(unknown_links))) as pool: |
| future_map = {pool.submit(validate_link, link, True, VALIDATE_RETRY_TIMEOUT): link for link in unknown_links} |
| for fut in as_completed(future_map): |
| link = future_map[fut] |
| try: |
| info = fut.result() |
| except Exception as e: |
| info = {'status': 'unknown', 'ok': None, 'type': cloud_type(link), 'reason': type(e).__name__} |
| old = validity.get(link) or {} |
| if info.get('status') != 'unknown' or old.get('status') == 'unknown': |
| validity[link] = info |
| return validity |
|
|
|
|
|
|
| def retry_xunlei_validity(validity): |
| """Recheck every Xunlei link that is not already API-backed. |
| |
| Remote/old workers may mark deleted Xunlei SPA pages as valid (HTTP 200). |
| Only trust reasons produced by validate_xunlei()/validate_xunlei_api(). |
| """ |
| if not isinstance(validity, dict): |
| return validity |
| for link, info in list(validity.items()): |
| if cloud_type(link) != 'xunlei': |
| continue |
| reason = str((info or {}).get('reason') or '') |
| if reason.startswith('api_status_') or reason in ('api_ok_files', 'api_ok_empty'): |
| continue |
| try: |
| validity[link] = validate_xunlei(link) |
| except Exception as e: |
| validity[link] = make_validation('unknown', None, 'xunlei', 'master_recheck_%s' % type(e).__name__) |
| return validity |
|
|
| for link, info in list(validity.items()): |
| if cloud_type(link) != 'xunlei': |
| continue |
| info = info or {} |
| reason = str(info.get('reason') or '') |
| |
| if reason.startswith('api_status_') or reason in ('api_ok_files', 'api_ok_empty') or reason.startswith('api_http_') or reason.startswith('api_phrase_') or reason.startswith('api_') and info.get('status') in ('valid', 'invalid') and 'captcha' not in reason: |
| |
| if reason.startswith('api_') and info.get('status') in ('valid', 'invalid'): |
| continue |
| |
| needs = ( |
| not reason.startswith('api_') |
| or (info.get('status') == 'valid' and str(info.get('content_type') or '').startswith('text/html')) |
| or (info.get('status') == 'valid' and info.get('http_status') == 200 and not reason.startswith('api_')) |
| ) |
| if not needs and info.get('status') in ('valid', 'invalid') and reason.startswith('api_'): |
| continue |
| try: |
| validity[link] = validate_xunlei(link) |
| except Exception as e: |
| validity[link] = make_validation('unknown', None, 'xunlei', 'master_recheck_%s' % type(e).__name__) |
| return validity |
|
|
|
|
| def retry_unknown_123pan_validity(validity): |
| unknown_links = [link for link, info in (validity or {}).items() if (info or {}).get('status') == 'unknown' and cloud_type(link) == '123pan'] |
| if not unknown_links: |
| return validity |
| with ThreadPoolExecutor(max_workers=min(8, len(unknown_links))) as pool: |
| future_map = {pool.submit(validate_link, link, True, VALIDATE_RETRY_TIMEOUT): link for link in unknown_links} |
| for fut in as_completed(future_map): |
| link = future_map[fut] |
| try: |
| info = fut.result() |
| except Exception as e: |
| info = {'status': 'unknown', 'ok': None, 'type': '123pan', 'reason': type(e).__name__} |
| if info.get('status') != 'unknown': |
| validity[link] = info |
| return validity |
|
|
|
|
| def extract_links_from_results(results): |
| links = [] |
| for result in results: |
| if not result or '$$$' not in result: |
| continue |
| _, content = result.split('$$$', 1) |
| for item in content.split('##'): |
| parts = item.split('$$', 1) |
| if len(parts) >= 2: |
| links.append(parts[0].split('@', 1)[0]) |
| return list(dict.fromkeys(links)) |
|
|
|
|
| def search_cache_ttl(payload): |
| |
| |
| |
| try: |
| links = extract_links_from_results((payload or {}).get('results') or []) |
| types = {cloud_type(link) for link in links} |
| if 'uc' in types: |
| return UC_SEARCH_CACHE_TTL |
| if 'baidu' in types: |
| return BAIDU_SEARCH_CACHE_TTL |
| except Exception: |
| pass |
| return CACHE_TTL |
|
|
|
|
| def filter_results_by_validity(results, validity): |
| filtered = [] |
| for result in results: |
| if not result or '$$$' not in result: |
| filtered.append(result) |
| continue |
| ch, content = result.split('$$$', 1) |
| kept = [] |
| for item in content.split('##'): |
| parts = item.split('$$', 1) |
| if len(parts) < 2: |
| continue |
| link = parts[0].split('@', 1)[0] |
| if validity.get(link, {}).get('status') != 'invalid': |
| kept.append(item) |
| filtered.append(ch + '$$$' + '##'.join(kept) if kept else '') |
| return filtered |
|
|
|
|
| def compact_results(results): |
| return [r for r in (results or []) if r and '$$$' in r and r.split('$$$', 1)[1].strip()] |
|
|
|
|
| def validation_summary(validity, total_links): |
| return { |
| 'checked': len(validity), |
| 'total_links': total_links, |
| 'valid': sum(1 for v in validity.values() if v.get('status') == 'valid'), |
| 'invalid': sum(1 for v in validity.values() if v.get('status') == 'invalid'), |
| 'unknown': sum(1 for v in validity.values() if v.get('status') == 'unknown'), |
| 'limited': False, |
| 'complete': len(validity) == total_links, |
| } |
|
|
|
|
| def validate_links_sync(links): |
| links = list(dict.fromkeys(links or [])) |
| validity = {} |
| if links: |
| pool = ThreadPoolExecutor(max_workers=min(VALIDATE_WORKERS, len(links))) |
| future_map = {pool.submit(validate_link, link): link for link in links} |
| for fut in as_completed(future_map): |
| link = future_map[fut] |
| try: |
| validity[link] = fut.result() |
| except Exception as e: |
| validity[link] = {'status': 'unknown', 'ok': None, 'type': cloud_type(link), 'reason': type(e).__name__} |
| pool.shutdown(wait=False, cancel_futures=True) |
| retry_unknown_validity(validity) |
| return validity, validation_summary(validity, len(links)) |
|
|
|
|
| def validate_links_budgeted(links, budget_sec): |
| links = list(dict.fromkeys(links or [])) |
| validity = {} |
| if not links: |
| return validity, validation_summary(validity, 0) |
| budget_sec = max(0.2, float(budget_sec or MASTER_SYNC_VALIDATE_BUDGET)) |
| pool = ThreadPoolExecutor(max_workers=min(VALIDATE_WORKERS, len(links))) |
| future_map = {pool.submit(validate_link, link): link for link in links} |
| done, pending = wait(future_map.keys(), timeout=budget_sec) |
| for fut in done: |
| link = future_map[fut] |
| try: |
| validity[link] = fut.result() |
| except Exception as e: |
| validity[link] = {'status': 'unknown', 'ok': None, 'type': cloud_type(link), 'reason': type(e).__name__} |
|
|
| low_timeout = 0 |
| strict_pending = [] |
| for fut in pending: |
| link = future_map[fut] |
| typ = cloud_type(link) |
| if typ in BUDGET_UNKNOWN_TYPES: |
| low_timeout += 1 |
| fut.cancel() |
| validity[link] = {'status': 'unknown', 'ok': None, 'type': typ, 'reason': 'budget_timeout_low_priority'} |
| else: |
| strict_pending.append(fut) |
|
|
| |
| |
| |
| for fut in as_completed(strict_pending): |
| link = future_map[fut] |
| try: |
| validity[link] = fut.result() |
| except Exception as e: |
| validity[link] = {'status': 'unknown', 'ok': None, 'type': cloud_type(link), 'reason': type(e).__name__} |
|
|
| pool.shutdown(wait=False, cancel_futures=True) |
| retry_unknown_validity(validity) |
| summary = validation_summary(validity, len(links)) |
| summary['budgeted'] = True |
| summary['budget_sec'] = budget_sec |
| summary['timed_out'] = low_timeout |
| summary['budget_unknown_types'] = sorted(BUDGET_UNKNOWN_TYPES) |
| return validity, summary |
|
|
|
|
| def validate_results_sync(results, drop_invalid=True): |
| links = extract_links_from_results(results) |
| validity, summary = validate_links_sync(links) |
| out_results = filter_results_by_validity(results, validity) if drop_invalid else results |
| return out_results, validity, summary |
|
|
|
|
| def validate_task_worker(task_id): |
| with TASK_LOCK: |
| task = VALIDATION_TASKS.get(task_id) |
| if not task: |
| return |
| task['status'] = 'running' |
| task['started_at'] = time.time() |
| links = task.get('links') or [] |
| validity = {} |
| if links: |
| pool = ThreadPoolExecutor(max_workers=min(VALIDATE_WORKERS, len(links))) |
| future_map = {pool.submit(validate_link, link): link for link in links} |
| for fut in as_completed(future_map): |
| link = future_map[fut] |
| try: |
| info = fut.result() |
| except Exception as e: |
| info = {'status': 'unknown', 'ok': None, 'error': type(e).__name__} |
| validity[link] = info |
| with TASK_LOCK: |
| t = VALIDATION_TASKS.get(task_id) |
| if t: |
| t['validity'] = dict(validity) |
| t['validation'] = validation_summary(validity, len(links)) |
| pool.shutdown(wait=False, cancel_futures=True) |
| retry_unknown_validity(validity) |
| filtered = filter_results_by_validity(task.get('results') or [], validity) |
| with TASK_LOCK: |
| t = VALIDATION_TASKS.get(task_id) |
| if t: |
| t['validity'] = validity |
| t['validation'] = validation_summary(validity, len(links)) |
| t['results'] = filtered |
| t['non_empty_channels'] = sum(bool(x) for x in filtered) |
| t['status'] = 'complete' |
| t['completed_at'] = time.time() |
|
|
|
|
| def start_validation_task(results): |
| links = extract_links_from_results(results) |
| task_id = uuid.uuid4().hex[:16] |
| task = { |
| 'task_id': task_id, |
| 'status': 'processing', |
| 'created_at': time.time(), |
| 'results': results, |
| 'links': links, |
| 'validity': {}, |
| 'validation': validation_summary({}, len(links)), |
| 'non_empty_channels': sum(bool(x) for x in results), |
| } |
| with TASK_LOCK: |
| now = time.time() |
| for tid, old_task in list(VALIDATION_TASKS.items()): |
| if now - old_task.get('created_at', now) > TASK_TTL: |
| VALIDATION_TASKS.pop(tid, None) |
| VALIDATION_TASKS[task_id] = task |
| th = threading.Thread(target=validate_task_worker, args=(task_id,), daemon=True) |
| th.start() |
| return task_id, task |
|
|
|
|
| def task_response(task, include_validity=False): |
| validation = task.get('validation') or validation_summary({}, len(task.get('links') or [])) |
| payload = { |
| 'task_id': task.get('task_id'), |
| 'status': task.get('status'), |
| 'validation_status': 'complete' if task.get('status') == 'complete' else 'processing', |
| 'validation': validation, |
| 'non_empty_channels': task.get('non_empty_channels'), |
| 'results': compact_results(task.get('results') or []), |
| } |
| if include_validity: |
| payload['validity'] = task.get('validity') or {} |
| payload['validity_count'] = len(payload['validity']) |
| return payload |
|
|
|
|
| def handle_search_local(query): |
| keyword = (query.get('keyword') or [''])[0].strip() |
| channel_raw = (query.get('channelUsername') or query.get('channels') or [''])[0] |
| channels = normalize_channels(channel_raw) |
| requested_count = len(channels) |
| mode_param = (query.get('mode') or query.get('search_mode') or ['full'])[0].lower() |
| full_requested = mode_param not in ('fast', 'quick') |
| priority = [ch for ch in PRIORITY_CHANNELS if ch in channels] |
| rest = [ch for ch in channels if ch not in priority] |
| channels = priority + rest |
| if full_requested: |
| channels = channels[:FULL_MAX_CHANNELS] |
| max_workers = min(FULL_WORKERS, len(channels)) |
| search_timeout = FULL_SEARCH_TIMEOUT |
| mode_name = 'full' |
| else: |
| channels = channels[:FAST_MAX_CHANNELS] |
| max_workers = min(FAST_WORKERS, len(channels)) |
| search_timeout = FAST_SEARCH_TIMEOUT |
| mode_name = 'fast' |
| if not keyword or not channels: |
| return 400, {'error': "Missing 'keyword' or 'channelUsername' parameter"} |
| async_validate = first_value(query, 'async_validate', 'false').lower() in ('1', 'true', 'yes', 'on') |
| validate_enabled = first_value(query, 'validate', 'true').lower() not in ('0', 'false', 'no', 'off', 'skip') |
| exact_match = first_value(query, 'exact', 'true').lower() not in ('0', 'false', 'no', 'off', 'fuzzy') |
| validate_mode = 'no_validate' if not validate_enabled else ('async_validate' if async_validate else 'sync_validate') |
| exact_mode = 'exact' if exact_match else 'fuzzy' |
| cache_key = (keyword, ','.join(channels), mode_name, validate_mode + '_' + exact_mode + '_smartlocal_diskshort_tianyi_v23') |
| now = time.time() |
| cached = SEARCH_CACHE.get(cache_key) |
| if cached and now - cached[0] < search_cache_ttl(cached[1]): |
| return 200, cached[1] |
|
|
| results = [''] * len(channels) |
| pool = ThreadPoolExecutor(max_workers=max_workers) |
| future_map = {pool.submit(parse_channel, ch, keyword, exact_match): i for i, ch in enumerate(channels)} |
| done, pending = wait(future_map.keys(), timeout=search_timeout) |
| for fut in done: |
| idx = future_map[fut] |
| try: |
| results[idx] = fut.result() or '' |
| except Exception: |
| results[idx] = '' |
| for fut in pending: |
| fut.cancel() |
| pool.shutdown(wait=False, cancel_futures=True) |
|
|
| if not validate_enabled: |
| links = extract_links_from_results(results) |
| visible_results = compact_results(results) |
| payload = { |
| 'results': visible_results, |
| 'cached': False, |
| 'mode': mode_name, |
| 'searched_channels': len(channels), |
| 'requested_channels': requested_count, |
| 'timed_out_channels': len(pending), |
| 'non_empty_channels': len(visible_results), |
| 'validation_status': 'skipped', |
| 'validation': {**validation_summary({}, len(links)), 'skipped': True}, |
| } |
| elif async_validate: |
| task_id, task = start_validation_task(results) |
| visible_results = compact_results(results) |
| payload = { |
| 'results': visible_results, |
| 'cached': False, |
| 'mode': mode_name, |
| 'searched_channels': len(channels), |
| 'requested_channels': requested_count, |
| 'timed_out_channels': len(pending), |
| 'non_empty_channels': len(visible_results), |
| 'validation_status': 'processing', |
| 'task_id': task_id, |
| 'validation': {**task.get('validation', {}), 'task_id': task_id, 'poll_url': '/api/validation/' + task_id}, |
| } |
| else: |
| results, validity, validation = validate_results_sync(results, drop_invalid=True) |
| visible_results = compact_results(results) |
| payload = { |
| 'results': visible_results, |
| 'cached': False, |
| 'mode': mode_name, |
| 'searched_channels': len(channels), |
| 'requested_channels': requested_count, |
| 'timed_out_channels': len(pending), |
| 'non_empty_channels': len(visible_results), |
| 'validation_status': 'complete', |
| 'validation': validation, |
| 'validity': validity, |
| } |
| SEARCH_CACHE[cache_key] = (now, payload) |
| return 200, payload |
|
|
|
|
| MASTER_REMOTE_BASE = 'https://xibalami-tggssou.hf.space' |
| MASTER_WORKERS = [ |
| ('local', 'self'), |
| ('w1', 'https://xibalami-tgsou-w1.hf.space'), |
| ('w2', 'https://xibalami-tgsou-w2.hf.space'), |
| ('tggssou_alt', 'https://5say-tggssou.hf.space'), |
| ('w3_alt', 'https://5say-tgsou-w3.hf.space'), |
| ] |
|
|
| |
| |
| |
| |
| CHANNEL_LOAD_HINTS = { |
| 'yoyokuakeduanju': 491, |
| 'xiangnikanj': 34, |
| 'kduanju': 31, |
| 'QuarkFree': 29, |
| 'XiangxiuNBB': 25, |
| 'vip115hot': 22, |
| 'yunpanx': 21, |
| 'TG654TG': 18, |
| 'SharePanFilms': 17, |
| 'rjyxfx': 17, |
| 'tgsearchers7': 17, |
| 'kkdj001': 16, |
| 'kuakedongman': 16, |
| 'ucquark': 16, |
| 'zyfb123': 16, |
| 'Netdisk_Movies': 15, |
| 'PanjClub': 14, |
| 'tgbokee': 12, |
| 'leoziyuan': 11, |
| 'newproductsourcing': 10, |
| 'solidsexydoll': 10, |
| 'tianyifc': 10, |
| 'yydf_hzl': 10, |
| 'D_wusun': 9, |
| 'yunpanxunlei': 8, |
| 'bdwpzhpd': 7, |
| 'gokuapan': 7, |
| 'yunpan139': 7, |
| 'KaiPanshare': 6, |
| 'movielover8888_film3': 6, |
| 'ydypzyfx': 6, |
| 'yunpanNB': 6, |
| 'QQZYDAPP': 5, |
| 'gotopan': 5, |
| 'Baidu_netdisk': 4, |
| 'BooksRealm': 4, |
| 'douerpan': 4, |
| 'xx123pan': 4, |
| 'yunpanuc': 4, |
| 'Q66Share': 3, |
| 'Quark_Movies': 3, |
| 'yeqingjie_GJG666': 3, |
| 'Aliyun_4K_Movies': 2, |
| 'MCPH03': 2, |
| 'dianying4k': 2, |
| 'duan_ju': 2, |
| 'pxyunpanxunlei': 2, |
| 'taoxgzy': 2, |
| 'tyysypzypd': 2, |
| 'QukanMovie': 1, |
| 'baicaoZY': 1, |
| 'godupan': 1, |
| 'guoman4K': 1, |
| 'jxwpzy': 1, |
| 'ucshare': 1, |
| 'wp123zy': 1, |
| 'xxzlzn': 1, |
| 'yp123pan': 1, |
| 'ysxb48': 1, |
| 'yunpanquark': 1, |
| 'zdqxm': 1, |
| } |
| CHANNEL_LOAD_HISTORY = dict(CHANNEL_LOAD_HINTS) |
| CHANNEL_LOAD_LOCK = threading.Lock() |
| CHANNEL_LOAD_ALPHA = 0.35 |
| DEFAULT_CHANNEL_LOAD = 1.0 |
| |
| |
| MASTER_LOAD_TIE_ORDER = {'tggssou': 0, 'tggssou_alt': 0, 'w1': 1, 'w2': 2, 'w3': 3, 'w3_alt': 3, 'local': 4} |
| |
| |
| |
| WORKER_VALIDATION_CAPACITY = { |
| 'local': 1.0, |
| 'w3_alt': 1.0, |
| 'w3': 1.0, |
| 'w2': 1.0, |
| 'w1': 1.0, |
| 'tggssou_alt': 1.0, |
| 'tggssou': 1.0, |
| } |
|
|
|
|
| def first_value(query, key, default=''): |
| return (query.get(key) or [default])[0] |
|
|
|
|
| def make_worker_query(query, channels, validate=None): |
| q = {k: list(v) for k, v in query.items()} |
| q['channelUsername'] = [','.join(channels)] |
| q['worker'] = ['1'] |
| if validate is not None: |
| q['validate'] = ['1' if validate else '0'] |
| return q |
|
|
|
|
| def empty_worker_payload(): |
| return {'results': [], 'validation': validation_summary({}, 0), 'validity': {}, 'non_empty_channels': 0, 'searched_channels': 0, 'requested_channels': 0, 'timed_out_channels': 0} |
|
|
|
|
| def call_remote_worker(base, query, channels, validate=None): |
| if not channels: |
| return 200, empty_worker_payload() |
| params = {} |
| for k, v in query.items(): |
| if not v: |
| continue |
| if k in ('channelUsername', 'channels'): |
| continue |
| params[k] = v[0] |
| params['channelUsername'] = ','.join(channels) |
| params['worker'] = '1' |
| if validate is not None: |
| params['validate'] = '1' if validate else '0' |
| url = base + '/api/search?' + urllib.parse.urlencode(params) |
| req = urllib.request.Request(url, headers={'User-Agent': 'tgsou-master/1.0'}) |
| with urllib.request.urlopen(req, timeout=240) as resp: |
| raw = resp.read() |
| return resp.status, json.loads(raw.decode('utf-8', 'ignore')) |
|
|
|
|
| def empty_validate_payload(): |
| return {'validity': {}, 'validation': validation_summary({}, 0), 'checked_links': 0} |
|
|
|
|
| def call_remote_validate_worker(base, links, budget=None): |
| links = list(dict.fromkeys(links or [])) |
| if not links: |
| return 200, empty_validate_payload() |
| payload = {'links': links} |
| if budget: |
| payload['budget'] = float(budget) |
| data = json.dumps(payload, ensure_ascii=False).encode('utf-8') |
| req = urllib.request.Request(base + '/api/validate_links', data=data, method='POST', headers={ |
| 'User-Agent': 'tgsou-master/1.0', |
| 'Content-Type': 'application/json; charset=utf-8', |
| 'Accept': 'application/json', |
| }) |
| with urllib.request.urlopen(req, timeout=45 if budget else 240) as resp: |
| raw = resp.read() |
| return resp.status, json.loads(raw.decode('utf-8', 'ignore')) |
|
|
|
|
| def result_map_from_payload(payload): |
| out = {} |
| for result in payload.get('results', []) or []: |
| if result and '$$$' in result: |
| ch = result.split('$$$', 1)[0] |
| out[ch] = result |
| return out |
|
|
|
|
| def result_link_count(result): |
| if not result or '$$$' not in result: |
| return 0 |
| _, content = result.split('$$$', 1) |
| return sum(1 for item in content.split('##') if item.strip()) |
|
|
|
|
| def channel_load(ch): |
| with CHANNEL_LOAD_LOCK: |
| return float(CHANNEL_LOAD_HISTORY.get(ch, DEFAULT_CHANNEL_LOAD)) |
|
|
|
|
| def split_channels_by_historical_load(channels, workers): |
| shard_channels = {name: [] for name, _ in workers} |
| shard_loads = {name: 0.0 for name, _ in workers} |
| |
| ordered = sorted(enumerate(channels), key=lambda p: (-channel_load(p[1]), p[0])) |
| for _, ch in ordered: |
| name, _ = min(workers, key=lambda nb: (shard_loads[nb[0]], MASTER_LOAD_TIE_ORDER.get(nb[0], 99))) |
| shard_channels[name].append(ch) |
| shard_loads[name] += channel_load(ch) |
| return shard_channels, shard_loads |
|
|
|
|
| LINK_TYPE_WEIGHTS = { |
| 'quark': 2.2, |
| 'uc': 2.2, |
| 'baidu': 1.5, |
| '123pan': 1.4, |
| '115': 1.5, |
| 'xunlei': 0.7, |
| '189': 0.7, |
| '139': 0.7, |
| 'other': 1.0, |
| } |
|
|
|
|
| def link_validation_weight(link): |
| return float(LINK_TYPE_WEIGHTS.get(cloud_type(link), 1.0)) |
|
|
|
|
| def split_links_evenly(links, workers): |
| shard_links = {name: [] for name, _ in workers} |
| shard_weights = {name: 0.0 for name, _ in workers} |
| ordered = sorted(enumerate(links or []), key=lambda p: (-link_validation_weight(p[1]), p[0])) |
| for _, link in ordered: |
| def effective_load(nb): |
| name = nb[0] |
| cap = float(WORKER_VALIDATION_CAPACITY.get(name, 1.0)) or 1.0 |
| return (shard_weights[name] / cap, MASTER_LOAD_TIE_ORDER.get(name, 99)) |
| name, _ = min(workers, key=effective_load) |
| shard_links[name].append(link) |
| shard_weights[name] += link_validation_weight(link) |
| return shard_links, shard_weights |
|
|
|
|
| def update_channel_load_history(merged_results): |
| observed = {} |
| for result in merged_results: |
| if result and '$$$' in result: |
| ch = result.split('$$$', 1)[0] |
| observed[ch] = result_link_count(result) |
| if not observed: |
| return |
| with CHANNEL_LOAD_LOCK: |
| for ch, cnt in observed.items(): |
| old = float(CHANNEL_LOAD_HISTORY.get(ch, DEFAULT_CHANNEL_LOAD)) |
| CHANNEL_LOAD_HISTORY[ch] = round(old * (1.0 - CHANNEL_LOAD_ALPHA) + float(cnt) * CHANNEL_LOAD_ALPHA, 3) |
|
|
|
|
| def merge_validation(payloads): |
| validity = {} |
| for pld in payloads: |
| validity.update(pld.get('validity') or {}) |
| return validity, validation_summary(validity, len(validity)) |
|
|
|
|
| def handle_search_master(query): |
| keyword = first_value(query, 'keyword').strip() |
| raw = first_value(query, 'channelUsername') or first_value(query, 'channels') |
| channels = normalize_channels(raw) |
| requested_count = len(channels) |
| mode_param = first_value(query, 'mode', first_value(query, 'search_mode', 'full')).lower() |
| full_requested = mode_param not in ('fast', 'quick') |
| priority = [ch for ch in PRIORITY_CHANNELS if ch in channels] |
| rest = [ch for ch in channels if ch not in priority] |
| ordered = priority + rest |
| if full_requested: |
| channels = ordered[:FULL_MAX_CHANNELS] |
| mode_name = 'full' |
| else: |
| channels = ordered[:FAST_MAX_CHANNELS] |
| mode_name = 'fast' |
| if not keyword or not channels: |
| return 400, {'error': "Missing 'keyword' or 'channelUsername' parameter"} |
|
|
| async_validate = first_value(query, 'async_validate', 'false').lower() in ('1', 'true', 'yes', 'on') |
| exact_match = first_value(query, 'exact', 'true').lower() not in ('0', 'false', 'no', 'off', 'fuzzy') |
| exact_mode = 'exact' if exact_match else 'fuzzy' |
| cache_key = (keyword, ','.join(channels), mode_name, 'master_twostage_async_' + exact_mode + '_alt5_smartlocal_compact_xunleiapi_v23' if async_validate else 'master_twostage_sync_' + exact_mode + '_alt5_scheme3_budget25_diskshort_tianyi_xunleiapi_recheck_v34') |
| now = time.time() |
| cached = SEARCH_CACHE.get(cache_key) |
| if cached and now - cached[0] < search_cache_ttl(cached[1]): |
| return 200, cached[1] |
|
|
| |
| |
| active_workers = MASTER_WORKERS |
| shard_channels, planned_loads = split_channels_by_historical_load(channels, active_workers) |
| search_errors = [] |
| search_payloads = [] |
| search_infos = [] |
|
|
| def run_search_shard(name, base, part): |
| t0 = time.time() |
| try: |
| if not part: |
| st, pld = 200, empty_worker_payload() |
| elif base == 'self': |
| st, pld = handle_search_local(make_worker_query(query, part, validate=False)) |
| else: |
| st, pld = call_remote_worker(base, query, part, validate=False) |
| return name, base, st, pld, time.time() - t0, None |
| except Exception as e: |
| return name, base, 0, empty_worker_payload(), time.time() - t0, repr(e) |
|
|
| with ThreadPoolExecutor(max_workers=len(active_workers)) as pool: |
| futures = [pool.submit(run_search_shard, name, base, shard_channels.get(name, [])) for name, base in active_workers] |
| for fut in as_completed(futures): |
| name, base, st, pld, elapsed, err = fut.result() |
| info = {'shard': name, 'base': base, 'status': st, 'elapsed_sec': round(elapsed, 3), 'channels': len(shard_channels.get(name, [])), 'planned_load': round(planned_loads.get(name, 0.0), 3), 'non_empty_channels': pld.get('non_empty_channels'), 'validation': pld.get('validation')} |
| if err: |
| info['error'] = err |
| search_errors.append({'phase': 'search', 'shard': name, 'base': base, 'error': err}) |
| elif st != 200: |
| search_errors.append({'phase': 'search', 'shard': name, 'status': st, 'base': base}) |
| else: |
| search_payloads.append(pld) |
| search_infos.append(info) |
|
|
| |
| failed_search = {e.get('shard') for e in search_errors} |
| for name, base in active_workers: |
| if name in failed_search and base != 'self' and shard_channels.get(name): |
| t0 = time.time() |
| st, pld = handle_search_local(make_worker_query(query, shard_channels[name], validate=False)) |
| search_payloads.append(pld) |
| search_infos.append({'shard': name + '_fallback_local', 'base': 'self', 'status': st, 'elapsed_sec': round(time.time()-t0, 3), 'channels': len(shard_channels[name]), 'planned_load': round(planned_loads.get(name, 0.0), 3), 'non_empty_channels': pld.get('non_empty_channels'), 'validation': pld.get('validation')}) |
|
|
| maps = [result_map_from_payload(p) for p in search_payloads] |
| raw_results = [] |
| for ch in channels: |
| val = '' |
| for mp in maps: |
| if ch in mp: |
| val = mp[ch] |
| break |
| raw_results.append(val) |
|
|
| raw_links = extract_links_from_results(raw_results) |
| validation_errors = [] |
| validation_infos = [] |
| validation_planned_weights = {} |
|
|
| if async_validate: |
| |
| task_id, task = start_validation_task(raw_results) |
| merged_results = compact_results(raw_results) |
| validation = {**task.get('validation', {}), 'task_id': task_id, 'poll_url': '/api/validation/' + task_id} |
| validity = None |
| validation_status = 'processing' |
| else: |
| |
| |
| |
| link_shards, validation_planned_weights = split_links_evenly(raw_links, active_workers) |
| validation_payloads = [] |
|
|
| def run_validation_shard(name, base, part): |
| t0 = time.time() |
| try: |
| if not part: |
| st, pld = 200, empty_validate_payload() |
| elif base == 'self': |
| val, summary = validate_links_budgeted(part, MASTER_SYNC_VALIDATE_BUDGET) |
| pld = {'validity': val, 'validation': summary, 'checked_links': len(part)} |
| st = 200 |
| else: |
| st, pld = call_remote_validate_worker(base, part, budget=MASTER_SYNC_VALIDATE_BUDGET) |
| return name, base, st, pld, time.time() - t0, None |
| except Exception as e: |
| val = {link: {'status': 'unknown', 'ok': None, 'type': cloud_type(link), 'reason': 'validation_shard_timeout'} for link in (part or [])} |
| summary = validation_summary(val, len(part or [])) |
| summary['budgeted'] = True |
| summary['timed_out'] = len(part or []) |
| pld = {'validity': val, 'validation': summary, 'checked_links': len(part or []), 'warning': repr(e)} |
| return name, base, 200, pld, time.time() - t0, None |
|
|
| with ThreadPoolExecutor(max_workers=len(active_workers)) as pool: |
| futures = [pool.submit(run_validation_shard, name, base, link_shards.get(name, [])) for name, base in active_workers] |
| for fut in as_completed(futures): |
| name, base, st, pld, elapsed, err = fut.result() |
| part_count = len(link_shards.get(name, [])) |
| info = {'shard': name, 'base': base, 'status': st, 'elapsed_sec': round(elapsed, 3), 'links': part_count, 'planned_weight': round(validation_planned_weights.get(name, 0.0), 3), 'validation': pld.get('validation')} |
| if err: |
| info['error'] = err |
| validation_errors.append({'phase': 'validation', 'shard': name, 'base': base, 'links': part_count, 'error': err}) |
| elif st != 200: |
| validation_errors.append({'phase': 'validation', 'shard': name, 'status': st, 'base': base, 'links': part_count}) |
| else: |
| validation_payloads.append(pld) |
| validation_infos.append(info) |
|
|
| |
| failed_validation = {e.get('shard') for e in validation_errors} |
| for name, base in active_workers: |
| if name in failed_validation and base != 'self' and link_shards.get(name): |
| t0 = time.time() |
| val, summary = validate_links_sync(link_shards[name]) |
| pld = {'validity': val, 'validation': summary, 'checked_links': len(link_shards[name])} |
| validation_payloads.append(pld) |
| validation_infos.append({'shard': name + '_fallback_local', 'base': 'self', 'status': 200, 'elapsed_sec': round(time.time()-t0, 3), 'links': len(link_shards[name]), 'planned_weight': round(validation_planned_weights.get(name, 0.0), 3), 'validation': summary}) |
|
|
| validity = {} |
| for pld in validation_payloads: |
| validity.update(pld.get('validity') or {}) |
| |
| |
| retry_unknown_123pan_validity(validity) |
| retry_xunlei_validity(validity) |
| validation = validation_summary(validity, len(raw_links)) |
| validation['budgeted'] = True |
| validation['budget_sec'] = MASTER_SYNC_VALIDATE_BUDGET |
| validation['timed_out'] = sum(((pld.get('validation') or {}).get('timed_out') or 0) for pld in validation_payloads) |
| merged_results = compact_results(filter_results_by_validity(raw_results, validity)) |
| validation_status = 'complete' |
|
|
| update_channel_load_history(raw_results) |
| payload = { |
| 'results': merged_results, |
| 'cached': False, |
| 'mode': mode_name, |
| 'searched_channels': len(channels), |
| 'requested_channels': requested_count, |
| 'timed_out_channels': sum((p.get('timed_out_channels') or 0) for p in search_payloads), |
| 'non_empty_channels': sum(bool(x) for x in merged_results), |
| 'validation_status': validation_status, |
| 'validation': validation, |
| 'master': { |
| 'enabled': True, |
| 'strategy': 'two_stage_search_then_scheme3_budget25_diskshort_tianyi_xunleiapi_recheck_validation_%dshards_v12' % len(MASTER_WORKERS), |
| 'local_base': 'self', |
| 'workers': [{'name': n, 'base': b} for n, b in MASTER_WORKERS], |
| 'planned_loads': {name: round(planned_loads.get(name, 0.0), 3) for name, _ in MASTER_WORKERS}, |
| 'validation_planned_weights': {name: round(validation_planned_weights.get(name, 0.0), 3) for name, _ in MASTER_WORKERS}, |
| 'raw_total_links': len(raw_links), |
| 'shards': search_infos, |
| 'search_shards': search_infos, |
| 'validation_shards': validation_infos, |
| 'errors': search_errors + validation_errors, |
| } |
| } |
|
|
| if not async_validate: |
| payload['validity'] = validity |
| else: |
| payload['task_id'] = task_id |
| SEARCH_CACHE[cache_key] = (time.time(), payload) |
| return 200, payload |
|
|
|
|
| def handle_validate_links_request(payload_in): |
| raw_links = payload_in.get('links') if isinstance(payload_in, dict) else [] |
| budget = None |
| if isinstance(payload_in, dict): |
| try: |
| budget = float(payload_in.get('budget')) if payload_in.get('budget') is not None else None |
| except Exception: |
| budget = None |
| if isinstance(raw_links, str): |
| parts = re.split(r'[,\n\r\s]+', raw_links) |
| elif isinstance(raw_links, list): |
| parts = raw_links |
| else: |
| parts = [] |
| links = [] |
| for raw in parts: |
| link = clean_url(str(raw or '')) |
| if link and is_cloud_url(link) and not is_excluded_cloud_url(link): |
| links.append(link) |
| links = list(dict.fromkeys(links))[:2000] |
| if budget: |
| validity, validation = validate_links_budgeted(links, budget) |
| else: |
| validity, validation = validate_links_sync(links) |
| return 200, {'validity': validity, 'validation': validation, 'checked_links': len(links)} |
|
|
|
|
| def log_recent_request(method, parsed, query, body_keys=None): |
| try: |
| item = { |
| 'ts': int(time.time()), |
| 'method': method, |
| 'path': parsed.path, |
| 'query': {k: v[:3] for k, v in (query or {}).items()}, |
| 'body_keys': body_keys or [], |
| 'ua': '' |
| } |
| with REQUEST_LOG_LOCK: |
| RECENT_REQUESTS.append(item) |
| if len(RECENT_REQUESTS) > REQUEST_LOG_LIMIT: |
| del RECENT_REQUESTS[:-REQUEST_LOG_LIMIT] |
| except Exception: |
| pass |
|
|
|
|
| def handle_search(query): |
| worker = first_value(query, 'worker').lower() in ('1', 'true', 'yes', 'on') |
| split_param = first_value(query, 'split', 'true').lower() |
| split = split_param not in ('0', 'false', 'no', 'off') |
| aggregate = first_value(query, 'aggregate', 'true').lower() not in ('0', 'false', 'no', 'off') |
| if worker or not aggregate or not split: |
| return handle_search_local(query) |
| return handle_search_master(query) |
|
|
|
|
| class Handler(BaseHTTPRequestHandler): |
| protocol_version = 'HTTP/1.1' |
|
|
| def send_bytes(self, status, body, content_type='application/octet-stream', extra_headers=None): |
| self.send_response(status) |
| self.send_header('Content-Type', content_type) |
| self.send_header('Content-Length', str(len(body))) |
| self.send_header('Access-Control-Allow-Origin', '*') |
| self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') |
| self.send_header('Access-Control-Allow-Headers', '*') |
| if extra_headers: |
| for k, v in extra_headers.items(): |
| if k.lower() not in ('content-length', 'transfer-encoding', 'connection'): |
| self.send_header(k, v) |
| self.end_headers() |
| if self.command != 'HEAD': |
| self.wfile.write(body) |
|
|
| def do_OPTIONS(self): |
| self.send_bytes(204, b'', 'text/plain') |
|
|
| def do_HEAD(self): |
| self.do_GET() |
|
|
| def do_GET(self): |
| parsed = urllib.parse.urlsplit(self.path) |
| query = urllib.parse.parse_qs(parsed.query) |
| if parsed.path == '/__recent_requests': |
| with REQUEST_LOG_LOCK: |
| payload = {'ok': True, 'requests': list(RECENT_REQUESTS)} |
| body = json.dumps(payload, ensure_ascii=False).encode('utf-8') |
| self.send_bytes(200, body, 'application/json; charset=utf-8') |
| return |
| if parsed.path == '/__clear_recent_requests': |
| with REQUEST_LOG_LOCK: |
| RECENT_REQUESTS.clear() |
| body = json.dumps({'ok': True}, ensure_ascii=False).encode('utf-8') |
| self.send_bytes(200, body, 'application/json; charset=utf-8') |
| return |
| log_recent_request(self.command, parsed, query) |
| if parsed.path.startswith('/api/validation/'): |
| task_id = parsed.path.rsplit('/', 1)[-1] |
| with TASK_LOCK: |
| task = VALIDATION_TASKS.get(task_id) |
| if not task: |
| body = json.dumps({'error': 'task not found'}, ensure_ascii=False).encode('utf-8') |
| self.send_bytes(404, body, 'application/json; charset=utf-8') |
| return |
| payload = task_response(task, include_validity=True) |
| body = json.dumps(payload, ensure_ascii=False).encode('utf-8') |
| self.send_bytes(200, body, 'application/json; charset=utf-8') |
| return |
| if parsed.path == '/api/search' or (parsed.path == '/' and 'keyword' in query): |
| status, payload = handle_search(query) |
| body = json.dumps(payload, ensure_ascii=False).encode('utf-8') |
| self.send_bytes(status, body, 'application/json; charset=utf-8') |
| return |
| if parsed.path.startswith('/api/proxy/'): |
| body = json.dumps({'error': 'api proxy disabled'}, ensure_ascii=False).encode('utf-8') |
| self.send_bytes(403, body, 'application/json; charset=utf-8') |
| return |
| if parsed.path == '/healthz': |
| self.send_bytes(200, b'ok', 'text/plain') |
| return |
| self.forward_static(parsed) |
|
|
| def do_POST(self): |
| parsed = urllib.parse.urlsplit(self.path) |
| if parsed.path == '/api/validate_links': |
| length = int(self.headers.get('Content-Length') or '0') |
| raw = self.rfile.read(length) if length else b'' |
| payload_in = {} |
| try: |
| payload_in = json.loads(raw.decode('utf-8', 'ignore')) if raw else {} |
| except Exception: |
| payload_in = {'links': raw.decode('utf-8', 'ignore') if raw else ''} |
| status, payload = handle_validate_links_request(payload_in) |
| body = json.dumps(payload, ensure_ascii=False).encode('utf-8') |
| self.send_bytes(status, body, 'application/json; charset=utf-8') |
| return |
| if parsed.path == '/api/search' or parsed.path == '/': |
| length = int(self.headers.get('Content-Length') or '0') |
| raw = self.rfile.read(length) if length else b'' |
| payload_in = {} |
| ctype = (self.headers.get('Content-Type') or '').lower() |
| try: |
| if 'application/json' in ctype and raw: |
| payload_in = json.loads(raw.decode('utf-8', 'ignore')) |
| elif raw: |
| payload_in = {k: v[0] for k, v in urllib.parse.parse_qs(raw.decode('utf-8', 'ignore')).items()} |
| except Exception: |
| payload_in = {} |
| query = urllib.parse.parse_qs(parsed.query) |
| for k, v in payload_in.items(): |
| if v is None: |
| continue |
| query[k] = [','.join(str(x) for x in v)] if isinstance(v, list) else [str(v)] |
| log_recent_request(self.command, parsed, query, sorted(payload_in.keys())) |
| status, payload = handle_search(query) |
| body = json.dumps(payload, ensure_ascii=False).encode('utf-8') |
| self.send_bytes(status, body, 'application/json; charset=utf-8') |
| return |
| body = json.dumps({'error': 'not found'}, ensure_ascii=False).encode('utf-8') |
| self.send_bytes(404, body, 'application/json; charset=utf-8') |
|
|
| def forward_static(self, parsed): |
| if parsed.path == '/': |
| self.send_response(302) |
| self.send_header('Location', '/search') |
| self.send_header('Content-Length', '0') |
| self.end_headers() |
| return |
| if parsed.path == '/search': |
| html = b'<html><head><meta charset="utf-8"><title>tgsou API</title></head><body><h1>tgsou API</h1><p>Use /api/search?keyword=...&channelUsername=...</p><p>Validation is synchronous and included in /api/search response.</p></body></html>' |
| self.send_bytes(200, html, 'text/html; charset=utf-8') |
| return |
| body = b'404 page not found' |
| self.send_bytes(404, body, 'text/plain') |
|
|
| def log_message(self, fmt, *args): |
| return |
|
|
|
|
| if __name__ == '__main__': |
| print(f'front server listening on 0.0.0.0:{PORT}', flush=True) |
| ThreadingHTTPServer(('0.0.0.0', PORT), Handler).serve_forever() |
|
|