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)
# Cloud share URLs do not need URL fragments. Keeping fragments can break
# the legacy result format because channel items are separated by "##".
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
# Long prose/synopsis is not a resource title. Exact mode should not keep a
# link just because its description says e.g. "遮天三部曲...完美世界".
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]
# Pick the nearest probable title line before the link, not synopsis lines.
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()
# Remove spaces/punctuation/decorative separators so variants like
# "大丨主宰", "大|主宰", "灵魂摆渡·十年" still match the plain keyword.
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())
# Most Telegram resource posts put the real title in the first few text
# lines. Keep these as candidates, but do not scan arbitrary long body text
# in exact mode, otherwise unrelated posts mentioning the keyword are kept.
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()]
# Use only short title-like lines near the link. This is intentionally not
# a full-message fuzzy match, so synopsis lines such as "遮天三部曲...完美世界"
# will not pull unrelated resources in.
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'
', '\n', inner, flags=re.I)
inner = re.sub(r'<[^>]+>', '', inner)
inner = html_lib.unescape(inner).strip()
# Telegram often stores cloud URLs in href attributes while the visible
# text is just "查看链接"/domain. Inject the href into plain text so the
# per-link context filter can evaluate this link instead of falling back
# to the whole message.
if href:
return (inner + '\n' if inner else '') + href
return inner
block = re.sub(r']+href=["\'](https?://[^"\']+)["\'][^>]*>(.*?)', repl_anchor, block, flags=re.I | re.S)
plain = re.sub(r'
', '\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):
# Context for a link starts after the previous link. This ties each link
# to its closest local title/description instead of the whole message.
raw_link = m.group(0)
context = plain[prev_context_start:m.start()]
# If there are many lines since the previous link, keep only the nearby
# block to avoid an earlier matched title leaking into this link.
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'
([^<]+)|"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
# For fallback platforms, do a small GET to catch visible invalid pages.
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):
# Unknown is kept to avoid false deletion. Retry only high-value platforms
# where a transient timeout often hides a clear invalid code. Skip slower
# low-value fallback platforms (189/139/115/other) to keep cold search fast.
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 '')
# Already API-backed definitive result.
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:
# still recheck false-valid page-style even if status valid without api reason
if reason.startswith('api_') and info.get('status') in ('valid', 'invalid'):
continue
# Page/fallback style: has content_type html or bare http_status without api reason
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):
# UC/Baidu links can flip from valid to invalid quickly, and each Space has
# its own in-memory validation cache. Keep final search responses containing
# those links short-lived so stale valid shares are rechecked soon.
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)
# Scheme 3 + 2.5s: low-priority fallback platforms may remain unknown,
# but mainstream platforms keep waiting so they are not unknown only because
# of the global budget.
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'),
]
# Historical per-channel load hints. The cost is roughly “links that need
# validation” observed from previous 108-channel searches. It is intentionally
# mutable: after every master request we blend in the newly observed channel
# result size, so future splits become less round-robin and more load-balanced.
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
# Assign the heaviest first channel to a remote worker; local also coordinates
# the request, so keep it as the last tie-breaker for cold starts.
MASTER_LOAD_TIE_ORDER = {'tggssou': 0, 'tggssou_alt': 0, 'w1': 1, 'w2': 2, 'w3': 3, 'w3_alt': 3, 'local': 4}
# Relative validation capacity observed from cold runs. Slower Spaces get less
# planned validation weight; faster local/w3 can take more. This is applied on
# top of per-link type weights.
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}
# Stable order: heavy known channels first, then original order for ties.
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]
# Stage 1: split channel search. Workers search only, without validating;
# validation is done in Stage 2 after the master knows the real unique links.
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)
# If any remote search shard failed, run its channels locally to preserve coverage.
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:
# Async mode keeps the old background validation behavior on the master.
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:
# Stage 2: split the actual unique links by estimated validation cost
# rather than raw count, because Quark/UC/Baidu are much slower than
# Xunlei/139/189.
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)
# If remote validation failed, retry its link shard locally.
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 {})
# Remote validation workers may still run older 123pan logic. Recheck
# unknown 123pan links on the master so cancelled shares are filtered.
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'Use /api/search?keyword=...&channelUsername=...
Validation is synchronous and included in /api/search response.
' 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()