feat: stabilize multi-region runtime sync and worker orchestration

This commit is contained in:
root
2026-04-27 15:48:12 +08:00
parent 7cbde2aa78
commit 215a364891
137 changed files with 31931 additions and 1943 deletions

View File

@@ -11,6 +11,7 @@
import time
from loguru import logger
from app.utils.database import Database
from app.utils.detection_results import normalize_detector_result
from app.detectors.rdap_detector import RDAPDetector
from app.detectors.wayback_detector import WaybackDetector
from app.detectors.baidu_detector import BaiduDetector
@@ -32,6 +33,10 @@ class DetectEngine:
"""
检测引擎
"""
OUTCOME_SUCCESS = "success"
OUTCOME_BLACKLISTED = "blacklisted"
OUTCOME_FAILED = "failed"
def __init__(self):
"""
@@ -49,6 +54,9 @@ class DetectEngine:
self.jucha_detector = JuchaDetector()
def detect_domain(self, domain_id):
return self._detect_domain_with_outcome(domain_id) == self.OUTCOME_SUCCESS
def _detect_domain_with_outcome(self, domain_id):
"""
检测域名
@@ -69,24 +77,30 @@ class DetectEngine:
self.db.update_domain_detect_status(domain_id, DETECT_STATUS_RUNNING)
# 1. 基础检测
if not self._basic_detect(domain_id, domain):
basic_outcome = self._basic_detect(domain_id, domain)
if basic_outcome != self.OUTCOME_SUCCESS:
logger.info(f"基础检测失败,停止后续检测: {domain}")
return False
if basic_outcome == self.OUTCOME_FAILED:
self.db.update_domain_detect_status(domain_id, DETECT_STATUS_FAILED)
return basic_outcome
# 2. 深度检测
if not self._deep_detect(domain_id, domain):
deep_outcome = self._deep_detect(domain_id, domain)
if deep_outcome != self.OUTCOME_SUCCESS:
logger.info(f"深度检测失败: {domain}")
return False
if deep_outcome == self.OUTCOME_FAILED:
self.db.update_domain_detect_status(domain_id, DETECT_STATUS_FAILED)
return deep_outcome
# 更新检测状态为正常
self.db.update_domain_detect_status(domain_id, DETECT_STATUS_COMPLETED)
logger.info(f"域名检测完成: {domain}")
return True
return self.OUTCOME_SUCCESS
except Exception as e:
logger.error(f"检测域名出错: {e}")
# 更新检测状态为检测失败
self.db.update_domain_detect_status(domain_id, DETECT_STATUS_FAILED)
return False
return self.OUTCOME_FAILED
def _basic_detect(self, domain_id, domain):
"""
@@ -94,7 +108,7 @@ class DetectEngine:
:param domain_id: 域名ID
:param domain: 域名
:return: bool - 是否检测通过
:return: str - 检测结果
"""
# 1. 检查是否为一口价域名
is_ykj = self.db.is_ykj_domain(domain_id)
@@ -108,7 +122,7 @@ class DetectEngine:
if self.db.is_blacklisted(domain):
logger.info(f"域名在黑名单中: {domain}")
self.db.update_domain_detect_status(domain_id, DETECT_STATUS_BLACKLISTED)
return False
return self.OUTCOME_BLACKLISTED
# 4. 时光机快照年份采集
snapshot_years = self.wayback_detector.get_snapshot_years(domain)
@@ -120,9 +134,9 @@ class DetectEngine:
logger.info(f"域名包含敏感词: {domain}")
self.db.update_domain_detect_status(domain_id, DETECT_STATUS_BLACKLISTED)
self.db.add_to_blacklist(domain, "快照包含敏感词")
return False
return self.OUTCOME_BLACKLISTED
return True
return self.OUTCOME_SUCCESS
def _deep_detect(self, domain_id, domain):
"""
@@ -130,38 +144,52 @@ class DetectEngine:
:param domain_id: 域名ID
:param domain: 域名
:return: bool - 是否检测通过
:return: str - 检测结果
"""
# 1. 百度历史/Site
baidu_history = self.baidu_detector.check_history(domain)
baidu_site = self.baidu_detector.check_site(domain)
# 2. 360 Site
qihu360_site = self.qihu360_detector.check_site(domain)
# 3. Google Site
google_site = self.google_detector.check_site(domain)
# 4. 站长之家
chinaz_info = self.chinaz_detector.check_domain(domain)
# 5. 爱站网
aizhan_info = self.aizhan_detector.check_domain(domain)
# 6. 桔子SEO
juziseo_info = self.juziseo_detector.check_domain(domain)
# 7. 聚查
jucha_info = self.jucha_detector.check_domain(domain)
# 检查是否有风险
if self._check_risk(domain_id, domain, baidu_history, baidu_site, qihu360_site, google_site, chinaz_info, aizhan_info, juziseo_info, jucha_info):
return False
detector_results = {}
detector_steps = [
("baidu_history", lambda: self.baidu_detector.check_history(domain)),
("baidu_site", lambda: self.baidu_detector.check_site(domain)),
("qihu360_site", lambda: self.qihu360_detector.check_site(domain)),
("google_site", lambda: self.google_detector.check_site(domain)),
("chinaz_info", lambda: self.chinaz_detector.check_domain(domain)),
("aizhan_info", lambda: self.aizhan_detector.check_domain(domain)),
("juziseo_info", lambda: self.juziseo_detector.check_domain(domain)),
("jucha_info", lambda: self.jucha_detector.check_domain(domain)),
]
for detector_name, runner in detector_steps:
detector_results[detector_name] = normalize_detector_result(detector_name, runner())
detector_error = detector_results[detector_name].get("error")
if detector_error:
logger.error(
f"深度检测存在第三方检测错误: {domain}, detector={detector_name}, error={detector_error}"
)
return self.OUTCOME_FAILED
if self._check_risk(
domain_id,
domain,
detector_results.get("baidu_history"),
detector_results.get("baidu_site"),
detector_results.get("qihu360_site"),
detector_results.get("google_site"),
detector_results.get("chinaz_info"),
detector_results.get("aizhan_info"),
detector_results.get("juziseo_info"),
detector_results.get("jucha_info"),
):
self._persist_detection_results(domain_id, detector_results)
return self.OUTCOME_BLACKLISTED
# 保存检测结果
self.db.add_detection_result(domain_id, baidu_history, baidu_site, qihu360_site, google_site, chinaz_info, aizhan_info, juziseo_info, jucha_info)
persisted = self._persist_detection_results(domain_id, detector_results)
if not persisted:
logger.error(f"保存检测结果失败: {domain}")
return self.OUTCOME_FAILED
return True
return self.OUTCOME_SUCCESS
def _check_risk(self, domain_id, domain, baidu_history, baidu_site, qihu360_site, google_site, chinaz_info, aizhan_info, juziseo_info, jucha_info):
"""
@@ -180,7 +208,9 @@ class DetectEngine:
:return: bool - 是否有风险
"""
# 检查百度历史过灰
if baidu_history and '' in str(baidu_history):
if (
isinstance(baidu_history, dict) and baidu_history.get('has_gray')
) or (baidu_history and '' in str(baidu_history)):
logger.info(f"百度历史过灰: {domain}")
self.db.update_domain_detect_status(domain_id, DETECT_STATUS_BLACKLISTED)
self.db.add_to_blacklist(domain, "百度历史过灰")
@@ -215,7 +245,7 @@ class DetectEngine:
# 检查WHOIS状态
if jucha_info and 'whois' in jucha_info:
if jucha_info['whois'].get('status') in ['clientHold', 'serverHold']:
if jucha_info['whois'].get('whois_status') in ['clientHold', 'serverHold']:
logger.info(f"WHOIS状态异常: {domain}")
self.db.update_domain_detect_status(domain_id, DETECT_STATUS_BLACKLISTED)
self.db.add_to_blacklist(domain, "WHOIS状态异常")
@@ -225,11 +255,24 @@ class DetectEngine:
if jucha_info and 'intercept' in jucha_info:
if not jucha_info['intercept'].get('normal', True):
logger.info(f"拦截检测异常: {domain}")
self.db.update_domain_detect_status(domain_id, 4) # 4 表示黑名单
self.db.update_domain_detect_status(domain_id, DETECT_STATUS_BLACKLISTED)
self.db.add_to_blacklist(domain, "拦截检测异常")
return True
return False
def _persist_detection_results(self, domain_id, detector_results):
return self.db.add_detection_result(
domain_id,
detector_results.get("baidu_history"),
detector_results.get("baidu_site"),
detector_results.get("qihu360_site"),
detector_results.get("google_site"),
detector_results.get("chinaz_info"),
detector_results.get("aizhan_info"),
detector_results.get("juziseo_info"),
detector_results.get("jucha_info"),
)
def _contains_sensitive_words(self, text):
"""
@@ -264,11 +307,12 @@ class DetectEngine:
self.db.update_task_status(task_id, 1) # 1 表示执行中
# 执行检测
success = self.detect_domain(domain_id)
outcome = self._detect_domain_with_outcome(domain_id)
# 更新任务状态
if success:
if outcome in (self.OUTCOME_SUCCESS, self.OUTCOME_BLACKLISTED):
self.db.update_task_status(task_id, 2) # 2 表示完成
return True
else:
# 增加重试次数
retry_count = task.get('retry_count', 0) + 1
@@ -277,8 +321,7 @@ class DetectEngine:
self.db.update_task_status(task_id, 0) # 0 表示待执行
else:
self.db.update_task_status(task_id, 3) # 3 表示失败
return success
return False
except Exception as e:
logger.error(f"处理任务出错: {e}")
# 更新任务状态为失败

View File

@@ -64,7 +64,7 @@ class AizhanDetector(BaseDetector):
}
else:
self._log_warning(f"爱站网查询失败: {response.status_code}")
return {'title': '', 'risk': '', 'has_sensitive': False}
return {'error': f'HTTP {response.status_code}'}
except Exception as e:
return self._handle_exception(e, domain)
@@ -127,4 +127,4 @@ class AizhanDetector(BaseDetector):
if word in title:
return True
return False
return False

View File

@@ -83,10 +83,9 @@ class BaiduDetector(BaseDetector):
}
else:
self._log_warning(f"百度site查询失败: {response.status_code}")
return {'has_收录': False, 'subdomains': []}
return {'error': f'HTTP {response.status_code}'}
except Exception as e:
self._handle_exception(e, domain)
return {'has_收录': False, 'subdomains': []}
return self._handle_exception(e, domain)
def check_history(self, domain):
"""
@@ -123,10 +122,9 @@ class BaiduDetector(BaseDetector):
}
else:
self._log_warning(f"百度历史查询失败: {response.status_code}")
return {'has_history': False, 'has_gray': False}
return {'error': f'HTTP {response.status_code}'}
except Exception as e:
self._handle_exception(e, domain)
return {'has_history': False, 'has_gray': False}
return self._handle_exception(e, domain)
def _extract_subdomains(self, content, domain):
"""
@@ -148,4 +146,4 @@ class BaiduDetector(BaseDetector):
return subdomains
except Exception as e:
self._handle_exception(e, domain)
return []
return []

View File

@@ -64,7 +64,7 @@ class ChinazDetector(BaseDetector):
}
else:
self._log_warning(f"站长之家查询失败: {response.status_code}")
return {'title': '', 'category': '', 'has_sensitive': False}
return {'error': f'HTTP {response.status_code}'}
except Exception as e:
return self._handle_exception(e, domain)
@@ -127,4 +127,4 @@ class ChinazDetector(BaseDetector):
if word in title:
return True
return False
return False

View File

@@ -74,7 +74,6 @@ class GoogleDetector(BaseDetector):
}
else:
self._log_warning(f"Google site查询失败: {response.status_code}")
return {'has_收录': False}
return {'error': f'HTTP {response.status_code}'}
except Exception as e:
self._handle_exception(e, domain)
return {'has_收录': False}
return self._handle_exception(e, domain)

View File

@@ -81,10 +81,9 @@ class JuchaDetector(BaseDetector):
return whois_info
else:
self._log_warning(f"聚查WHOIS查询失败: {response.status_code}")
return {'status': ''}
return {'error': f'HTTP {response.status_code}'}
except Exception as e:
self._handle_exception(e, domain)
return {'status': ''}
return self._handle_exception(e, domain)
def check_beian(self, domain):
"""
@@ -113,10 +112,9 @@ class JuchaDetector(BaseDetector):
return beian_info
else:
self._log_warning(f"聚查备案查询失败: {response.status_code}")
return {'has_beian': False, 'beian_year': '', 'is_enterprise': False, 'beian_match': False}
return {'error': f'HTTP {response.status_code}'}
except Exception as e:
self._handle_exception(e, domain)
return {'has_beian': False, 'beian_year': '', 'is_enterprise': False, 'beian_match': False}
return self._handle_exception(e, domain)
def check_intercept(self, domain):
"""
@@ -147,10 +145,9 @@ class JuchaDetector(BaseDetector):
}
else:
self._log_warning(f"聚查拦截查询失败: {response.status_code}")
return {'normal': False}
return {'error': f'HTTP {response.status_code}'}
except Exception as e:
self._handle_exception(e, domain)
return {'normal': False}
return self._handle_exception(e, domain)
def _extract_whois_info(self, content):
"""
@@ -225,4 +222,4 @@ class JuchaDetector(BaseDetector):
return False
except Exception as e:
self._handle_exception(e, 'check_intercept_status')
return False
return False

View File

@@ -93,10 +93,9 @@ class JuziseoDetector(BaseDetector):
}
else:
self._log_warning(f"桔子SEO历史查询失败: {response.status_code}")
return {'has_sensitive': False, 'has_baidu_history': False, 'has_subdomains': False, 'is_simplified': True}
return {'error': f'HTTP {response.status_code}'}
except Exception as e:
self._handle_exception(e, domain)
return {'has_sensitive': False, 'has_baidu_history': False, 'has_subdomains': False, 'is_simplified': True}
return self._handle_exception(e, domain)
def check_backlink(self, domain):
"""
@@ -131,10 +130,9 @@ class JuziseoDetector(BaseDetector):
}
else:
self._log_warning(f"桔子SEO外链查询失败: {response.status_code}")
return {'has_sensitive': False, 'has_subdomains': False}
return {'error': f'HTTP {response.status_code}'}
except Exception as e:
self._handle_exception(e, domain)
return {'has_sensitive': False, 'has_subdomains': False}
return self._handle_exception(e, domain)
def _extract_history_info(self, content):
"""
@@ -211,4 +209,4 @@ class JuziseoDetector(BaseDetector):
if word in content:
return True
return False
return False

View File

@@ -79,10 +79,9 @@ class Qihu360Detector(BaseDetector):
}
else:
self._log_warning(f"360 site查询失败: {response.status_code}")
return {'has_收录': False, 'subdomains': []}
return {'error': f'HTTP {response.status_code}'}
except Exception as e:
self._handle_exception(e, domain)
return {'has_收录': False, 'subdomains': []}
return self._handle_exception(e, domain)
def _extract_subdomains(self, content, domain):
"""
@@ -104,4 +103,4 @@ class Qihu360Detector(BaseDetector):
return subdomains
except Exception as e:
self._handle_exception(e, domain)
return []
return []

View File

@@ -73,7 +73,7 @@ class RDAPDetector(BaseDetector):
return 2 # 可注册
# 检查域名状态
statuses = result.get('status', [])
statuses = result.get('statuses', [])
if 'clientHold' in statuses:
return 7 # clientHold
elif 'serverHold' in statuses:
@@ -125,4 +125,4 @@ class RDAPDetector(BaseDetector):
elif event_action == 'last update':
result['last_update'] = event_date
return result
return result

View File

@@ -17,13 +17,13 @@ import zlib
from base64 import b64decode, b64encode
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
import redis
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from app.config import config
from app.detectors.base import BaseDetector
from app.utils.redis_client import get_redis_client
class WaybackDetector(BaseDetector):
@@ -51,6 +51,7 @@ class WaybackDetector(BaseDetector):
def _build_session(self):
session = requests.Session()
session.trust_env = False
retry = Retry(
total=max(0, config.WAYBACK_RETRY_COUNT),
backoff_factor=0.5,
@@ -106,15 +107,7 @@ class WaybackDetector(BaseDetector):
def _build_redis_client(self):
try:
client = redis.Redis(
host=config.REDIS_HOST,
port=config.REDIS_PORT,
password=config.REDIS_PASSWORD,
db=config.REDIS_DB,
decode_responses=True,
socket_connect_timeout=5,
socket_timeout=5,
)
client = get_redis_client(role="standard")
client.ping()
return client
except Exception:
@@ -489,6 +482,14 @@ class WaybackDetector(BaseDetector):
trimmed.append(item)
return trimmed
def _resolve_scan_record_fetch_limit(self):
max_records = max(1, int(getattr(config, "WAYBACK_MAX_RECORDS", 8) or 8))
# 扫描阶段最终只会保留最近的少量快照;如果每次都把整个 CDX 历史拉下来,
# 在快照特别多的域名上会白白浪费很多秒。这里改成“最近一小窗”,同时留出
# 重复 digest/标题的缓冲空间,避免把最新几条里重复记录全部裁没了。
recent_window = max(12, max_records * 6)
return -recent_window
def scan_snapshots(self, domain, sensitive_words=None, stop_on_first_hit=True, recent_years=None):
sensitive_words = sensitive_words or config.load_sensitive_words()
request_errors = []
@@ -512,12 +513,17 @@ class WaybackDetector(BaseDetector):
'request_errors': [f"wayback_backoff_active:{round(transient_backoff_remaining, 2)}s"],
'elapsed_seconds': 0.0,
}
transient_request_failures = 0
transient_request_failure_threshold = 2
latest_fetch = self._fetch_cdx_records_with_meta(domain, limit=-1, fast_latest=True)
latest_record = (latest_fetch.get('records') or [None])[0]
latest_fetch_transient_failure = False
if latest_fetch.get('error'):
request_errors.append(f"latest_cdx: {latest_fetch.get('error')}")
if self._is_transient_request_error(latest_fetch.get('error')):
self._trip_transient_backoff()
latest_fetch_transient_failure = True
transient_request_failures += 1
latest_timestamp = (latest_record or {}).get('timestamp')
latest_digest = (latest_record or {}).get('digest', '')
cutoff_year = self._resolve_recent_year_cutoff(recent_years)
@@ -547,26 +553,6 @@ class WaybackDetector(BaseDetector):
transient_snapshot_failures = 0
transient_snapshot_failure_threshold = max(2, domain_concurrency)
if (not latest_timestamp) and latest_fetch.get('error') and self._is_transient_request_error(latest_fetch.get('error')):
return {
'snapshot_years': [],
'has_sensitive_content': False,
'matched_word': None,
'matched_timestamp': None,
'matched_title': None,
'backlink_count': 0,
'backlink_count_gt_10': False,
'checked_snapshot_count': 0,
'fetched_snapshot_count': 0,
'failed_snapshot_count': max(1, failed_snapshot_count),
'unique_title_count': 0,
'duplicate_title_skipped': 0,
'digest_duplicate_skipped': 0,
'request_error_count': len(request_errors),
'request_errors': request_errors,
'elapsed_seconds': round(time.time() - started_at, 2),
}
if latest_timestamp:
latest_result = self._fetch_snapshot_title(domain, latest_timestamp)
checked_snapshot_count = 1
@@ -605,40 +591,55 @@ class WaybackDetector(BaseDetector):
if latest_error:
request_errors.append(f"latest_snapshot: {latest_error}")
if latest_error and self._is_transient_request_error(latest_error):
transient_snapshot_failures += 1
self._trip_transient_backoff()
return {
'snapshot_years': [],
'has_sensitive_content': False,
'matched_word': None,
'matched_timestamp': None,
'matched_title': None,
'backlink_count': 0,
'backlink_count_gt_10': False,
'checked_snapshot_count': checked_snapshot_count,
'fetched_snapshot_count': fetched_snapshot_count,
'failed_snapshot_count': failed_snapshot_count,
'unique_title_count': unique_title_count,
'duplicate_title_skipped': duplicate_title_skipped,
'digest_duplicate_skipped': digest_duplicate_skipped,
'request_error_count': len(request_errors),
'request_errors': request_errors,
'elapsed_seconds': round(time.time() - started_at, 2),
}
transient_request_failures += 1
cached_records = self._load_cached_records(domain)
if cached_records is not None:
records = cached_records
else:
records_fetch = self._fetch_cdx_records_with_meta(domain)
if records_fetch.get('error'):
request_errors.append(f"records_cdx: {records_fetch.get('error')}")
if self._is_transient_request_error(records_fetch.get('error')):
self._trip_transient_backoff()
records = records_fetch.get('records') or []
if records:
self._save_cached_records(domain, records)
self._save_cached_timestamps(domain, [item['timestamp'] for item in records])
if latest_fetch_transient_failure and not latest_timestamp:
records = []
else:
records_fetch = self._fetch_cdx_records_with_meta(
domain,
limit=self._resolve_scan_record_fetch_limit(),
)
if records_fetch.get('error'):
request_errors.append(f"records_cdx: {records_fetch.get('error')}")
if self._is_transient_request_error(records_fetch.get('error')):
transient_request_failures += 1
records = records_fetch.get('records') or []
if records:
self._save_cached_records(domain, records)
self._save_cached_timestamps(domain, [item['timestamp'] for item in records])
if (
not latest_timestamp
and not records
and (
transient_request_failures >= transient_request_failure_threshold
or latest_fetch_transient_failure
)
):
self._trip_transient_backoff()
return {
'snapshot_years': [],
'has_sensitive_content': False,
'matched_word': None,
'matched_timestamp': None,
'matched_title': None,
'backlink_count': 0,
'backlink_count_gt_10': False,
'checked_snapshot_count': checked_snapshot_count,
'fetched_snapshot_count': fetched_snapshot_count,
'failed_snapshot_count': max(1, failed_snapshot_count),
'unique_title_count': 0,
'duplicate_title_skipped': duplicate_title_skipped,
'digest_duplicate_skipped': digest_duplicate_skipped,
'request_error_count': len(request_errors),
'request_errors': request_errors,
'elapsed_seconds': round(time.time() - started_at, 2),
}
records = self._filter_records_recent_years(records, recent_years=recent_years)
records = sorted(records, key=lambda item: item.get('timestamp', ''), reverse=True)
@@ -660,12 +661,12 @@ class WaybackDetector(BaseDetector):
digest_seen.add(digest)
pending_records.append(item)
with ThreadPoolExecutor(max_workers=domain_concurrency) as executor:
pending = {}
index = 0
finished_count = 1 if latest_timestamp else 0
stop_requested = False
executor = ThreadPoolExecutor(max_workers=domain_concurrency)
pending = {}
index = 0
finished_count = 1 if latest_timestamp else 0
stop_requested = False
try:
while (index < len(pending_records) or pending) and not stop_requested:
while index < len(pending_records) and len(pending) < domain_concurrency and not stop_requested:
timestamp = pending_records[index]['timestamp']
@@ -694,8 +695,8 @@ class WaybackDetector(BaseDetector):
request_errors.append(f"snapshot:{timestamp}: {error_message}")
if error_message and self._is_transient_request_error(error_message):
transient_snapshot_failures += 1
self._trip_transient_backoff()
if transient_snapshot_failures >= transient_snapshot_failure_threshold:
self._trip_transient_backoff()
stop_requested = True
continue
@@ -722,10 +723,13 @@ class WaybackDetector(BaseDetector):
)
if config.WAYBACK_REQUEST_DELAY > 0:
time.sleep(config.WAYBACK_REQUEST_DELAY)
finally:
if stop_requested:
for future in pending:
for future in list(pending.keys()):
future.cancel()
executor.shutdown(wait=False, cancel_futures=True)
else:
executor.shutdown(wait=True)
return {
'snapshot_years': years,

View File

@@ -8,6 +8,8 @@
@explain : 域名筛选界面
'''
import json
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QPushButton, QLabel, QComboBox, QDateEdit, QCheckBox, QTableWidget, QTableWidgetItem, QHeaderView, QFileDialog, QLineEdit, QSpinBox, QInputDialog
from PySide6.QtGui import QIntValidator
from PySide6.QtCore import Qt, QDate, QThread, Signal
@@ -15,6 +17,11 @@ from loguru import logger
from app.core.export_manager import ExportManager
from app.utils.database import Database
from app.utils.detection_results import (
build_manual_detection_result,
load_detection_result,
resolve_detection_status,
)
from app.utils.status_codes import (
DETECT_STATUS_BLACKLISTED,
DETECT_STATUS_COMPLETED,
@@ -205,9 +212,9 @@ class UpdateThread(QThread):
baidu_history_value = self.update_values['baidu_history']
if baidu_history_value != '不更新':
status_value = status_mappings['百度历史收录状态'][baidu_history_value]
# 转换为JSON格式
import json
json_value = json.dumps({"status": status_value})
json_value = json.dumps(
build_manual_detection_result(status_value, legacy_key='has_history')
)
if detection_id:
cur.execute("UPDATE domain_detections SET baidu_history = %s WHERE domain_id = %s", (json_value, domain_info['id']))
else:
@@ -219,9 +226,9 @@ class UpdateThread(QThread):
baidu_site_value = self.update_values['baidu_site']
if baidu_site_value != '不更新':
status_value = status_mappings['百度site收录状态'][baidu_site_value]
# 转换为JSON格式
import json
json_value = json.dumps({"status": status_value})
json_value = json.dumps(
build_manual_detection_result(status_value, legacy_key='has_收录')
)
if detection_id:
cur.execute("UPDATE domain_detections SET baidu_site = %s WHERE domain_id = %s", (json_value, domain_info['id']))
else:
@@ -244,9 +251,9 @@ class UpdateThread(QThread):
qihu360_site_value = self.update_values['qihu360_site']
if qihu360_site_value != '不更新':
status_value = status_mappings['360 site收录状态'][qihu360_site_value]
# 转换为JSON格式
import json
json_value = json.dumps({"status": status_value})
json_value = json.dumps(
build_manual_detection_result(status_value, legacy_key='has_收录')
)
if detection_id:
cur.execute("UPDATE domain_detections SET qihu360_site = %s WHERE domain_id = %s", (json_value, domain_info['id']))
else:
@@ -258,9 +265,9 @@ class UpdateThread(QThread):
google_site_value = self.update_values['google_site']
if google_site_value != '不更新':
status_value = status_mappings['Google site收录状态'][google_site_value]
# 转换为JSON格式
import json
json_value = json.dumps({"status": status_value})
json_value = json.dumps(
build_manual_detection_result(status_value, legacy_key='has_收录')
)
if detection_id:
cur.execute("UPDATE domain_detections SET google_site = %s WHERE domain_id = %s", (json_value, domain_info['id']))
else:
@@ -1082,28 +1089,14 @@ class DomainFilterWidget(QWidget):
# 百度历史收录状态
baidu_history = domain.get('baidu_history')
if baidu_history is None:
baidu_history = {}
elif isinstance(baidu_history, str):
import json
try:
baidu_history = json.loads(baidu_history)
except:
baidu_history = {}
baidu_history_status = '' if baidu_history.get('status') else ''
baidu_history = load_detection_result(baidu_history)
baidu_history_status = '' if resolve_detection_status(baidu_history, 'has_history') else ''
self.table_widget.setItem(row, 12, QTableWidgetItem(baidu_history_status))
# 百度site收录状态
baidu_site = domain.get('baidu_site')
if baidu_site is None:
baidu_site = {}
elif isinstance(baidu_site, str):
import json
try:
baidu_site = json.loads(baidu_site)
except:
baidu_site = {}
baidu_site_status = '' if baidu_site.get('status') else ''
baidu_site = load_detection_result(baidu_site)
baidu_site_status = '' if resolve_detection_status(baidu_site, 'has_收录') else ''
self.table_widget.setItem(row, 13, QTableWidgetItem(baidu_site_status))
# title是否有中文
@@ -1113,28 +1106,14 @@ class DomainFilterWidget(QWidget):
# 360site收录
qihu360_site = domain.get('qihu360_site')
if qihu360_site is None:
qihu360_site = {}
elif isinstance(qihu360_site, str):
import json
try:
qihu360_site = json.loads(qihu360_site)
except:
qihu360_site = {}
qihu360_site_status = '' if qihu360_site.get('status') else ''
qihu360_site = load_detection_result(qihu360_site)
qihu360_site_status = '' if resolve_detection_status(qihu360_site, 'has_收录') else ''
self.table_widget.setItem(row, 15, QTableWidgetItem(qihu360_site_status))
# Google site收录状态
google_site = domain.get('google_site')
if google_site is None:
google_site = {}
elif isinstance(google_site, str):
import json
try:
google_site = json.loads(google_site)
except:
google_site = {}
google_site_status = '' if google_site.get('status') else ''
google_site = load_detection_result(google_site)
google_site_status = '' if resolve_detection_status(google_site, 'has_收录') else ''
self.table_widget.setItem(row, 16, QTableWidgetItem(google_site_status))
# 友情链接数量

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,194 @@
import json
def build_detection_result(
*,
status=None,
state=None,
message="",
error=None,
**payload,
):
result = dict(payload)
result["status"] = bool(status) if status is not None else None
result["message"] = message or ""
if error:
result["state"] = "error"
result["error"] = str(error)
if not result["message"]:
result["message"] = str(error)
else:
result["state"] = state or _default_state_for_status(status)
result.pop("error", None)
return result
def _default_state_for_status(status):
if status is True:
return "positive"
if status is False:
return "negative"
return "ok"
def load_detection_result(value):
if value is None:
return {}
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
decoded = json.loads(value)
except Exception:
return {}
return decoded if isinstance(decoded, dict) else {}
return {}
def resolve_detection_status(value, *legacy_keys):
data = load_detection_result(value)
if not data:
return False
status = data.get("status")
if status is not None:
return bool(status)
for key in legacy_keys:
if data.get(key) is not None:
return bool(data.get(key))
return False
def build_manual_detection_result(status, *, legacy_key=None, message="人工更新"):
payload = {}
if legacy_key:
payload[legacy_key] = bool(status)
return build_detection_result(
status=bool(status),
state="manual",
message=message,
**payload,
)
def normalize_detector_result(name, result):
result = load_detection_result(result)
if result.get("error"):
return build_detection_result(error=result.get("error"), **_without_meta(result))
if name == "baidu_history":
has_history = bool(result.get("has_history"))
has_gray = bool(result.get("has_gray"))
return build_detection_result(
status=has_history,
state="risk" if has_gray else None,
has_history=has_history,
has_gray=has_gray,
)
if name in {"baidu_site", "qihu360_site", "google_site"}:
has_index = bool(result.get("has_收录"))
normalized = build_detection_result(
status=has_index,
has_收录=has_index,
subdomains=list(result.get("subdomains", []) or []),
)
return normalized
if name == "chinaz_info":
return build_detection_result(
status=None,
title=result.get("title", ""),
category=result.get("category", ""),
has_sensitive=bool(result.get("has_sensitive")),
)
if name == "aizhan_info":
return build_detection_result(
status=None,
title=result.get("title", ""),
risk=result.get("risk", ""),
has_sensitive=bool(result.get("has_sensitive")),
)
if name == "juziseo_info":
history = normalize_detector_result("juziseo_history", result.get("history"))
backlink = normalize_detector_result("juziseo_backlink", result.get("backlink"))
nested_error = history.get("error") or backlink.get("error")
return build_detection_result(
status=None,
error=nested_error,
history=history,
backlink=backlink,
)
if name == "juziseo_history":
return build_detection_result(
status=None,
state="risk" if result.get("has_sensitive") or result.get("has_subdomains") else None,
has_sensitive=bool(result.get("has_sensitive")),
has_baidu_history=bool(result.get("has_baidu_history")),
has_subdomains=bool(result.get("has_subdomains")),
is_simplified=bool(result.get("is_simplified", True)),
)
if name == "juziseo_backlink":
return build_detection_result(
status=None,
state="risk" if result.get("has_sensitive") or result.get("has_subdomains") else None,
has_sensitive=bool(result.get("has_sensitive")),
has_subdomains=bool(result.get("has_subdomains")),
)
if name == "jucha_info":
whois = normalize_detector_result("jucha_whois", result.get("whois"))
beian = normalize_detector_result("jucha_beian", result.get("beian"))
intercept = normalize_detector_result("jucha_intercept", result.get("intercept"))
nested_error = whois.get("error") or beian.get("error") or intercept.get("error")
return build_detection_result(
status=None,
error=nested_error,
whois=whois,
beian=beian,
intercept=intercept,
)
if name == "jucha_whois":
whois_status = result.get("status", "")
hold = whois_status in {"clientHold", "serverHold"}
return build_detection_result(
status=None,
state="risk" if hold else None,
whois_status=whois_status,
)
if name == "jucha_beian":
has_beian = bool(result.get("has_beian"))
return build_detection_result(
status=has_beian,
has_beian=has_beian,
beian_year=result.get("beian_year", ""),
is_enterprise=bool(result.get("is_enterprise")),
beian_match=bool(result.get("beian_match")),
)
if name == "jucha_intercept":
normal = bool(result.get("normal"))
return build_detection_result(
status=normal,
state="risk" if not normal else None,
normal=normal,
)
return build_detection_result(status=None, **result)
def _without_meta(result):
return {
key: value
for key, value in result.items()
if key not in {"status", "state", "message", "error"}
}

View File

@@ -0,0 +1,129 @@
from __future__ import annotations
import os
import threading
import redis
from app.config import config
_CLIENTS: dict[tuple[str, bool], redis.Redis] = {}
_LOCK = threading.Lock()
def _safe_int(raw_value: object, default: int, minimum: int) -> int:
try:
parsed = int(raw_value)
except Exception:
parsed = default
return max(minimum, parsed)
def _safe_float(raw_value: object, default: float, minimum: float) -> float:
try:
parsed = float(raw_value)
except Exception:
parsed = default
return max(minimum, parsed)
def _pool_options(role: str, *, decode_responses: bool) -> dict:
normalized_role = str(role or "standard").strip().lower() or "standard"
node_code = str(getattr(config, "NODE_CODE", "") or "").strip() or "unknown"
if normalized_role == "pubsub":
return {
"host": config.REDIS_HOST,
"port": config.REDIS_PORT,
"password": config.REDIS_PASSWORD or None,
"db": config.REDIS_DB,
"decode_responses": decode_responses,
"socket_connect_timeout": _safe_float(
os.getenv("DOMAINCHECK_REDIS_PUBSUB_CONNECT_TIMEOUT", "30"),
30.0,
1.0,
),
"socket_timeout": _safe_float(
os.getenv("DOMAINCHECK_REDIS_PUBSUB_SOCKET_TIMEOUT", "60"),
60.0,
1.0,
),
"health_check_interval": _safe_int(
os.getenv("DOMAINCHECK_REDIS_HEALTH_CHECK_INTERVAL", "30"),
30,
0,
),
"retry_on_timeout": True,
"max_connections": _safe_int(
os.getenv("DOMAINCHECK_REDIS_PUBSUB_MAX_CONNECTIONS", "2"),
2,
1,
),
"timeout": _safe_float(
os.getenv("DOMAINCHECK_REDIS_PUBSUB_POOL_TIMEOUT", "5"),
5.0,
0.1,
),
"client_name": f"domaincheck:pubsub:{node_code}:{os.getpid()}",
}
return {
"host": config.REDIS_HOST,
"port": config.REDIS_PORT,
"password": config.REDIS_PASSWORD or None,
"db": config.REDIS_DB,
"decode_responses": decode_responses,
"socket_connect_timeout": _safe_float(
os.getenv("DOMAINCHECK_REDIS_CONNECT_TIMEOUT", "5"),
5.0,
0.5,
),
"socket_timeout": _safe_float(
os.getenv("DOMAINCHECK_REDIS_SOCKET_TIMEOUT", "10"),
10.0,
0.5,
),
"health_check_interval": _safe_int(
os.getenv("DOMAINCHECK_REDIS_HEALTH_CHECK_INTERVAL", "30"),
30,
0,
),
"retry_on_timeout": True,
"max_connections": _safe_int(
os.getenv("DOMAINCHECK_REDIS_MAX_CONNECTIONS", "12"),
12,
1,
),
"timeout": _safe_float(
os.getenv("DOMAINCHECK_REDIS_POOL_TIMEOUT", "1.5"),
1.5,
0.1,
),
"client_name": f"domaincheck:standard:{node_code}:{os.getpid()}",
}
def get_redis_client(*, role: str = "standard", decode_responses: bool = True) -> redis.Redis:
normalized_role = str(role or "standard").strip().lower() or "standard"
cache_key = (normalized_role, bool(decode_responses))
with _LOCK:
cached = _CLIENTS.get(cache_key)
if cached is not None:
return cached
pool = redis.BlockingConnectionPool(**_pool_options(normalized_role, decode_responses=decode_responses))
client = redis.Redis(connection_pool=pool)
_CLIENTS[cache_key] = client
return client
def reset_redis_clients_for_tests() -> None:
with _LOCK:
clients = list(_CLIENTS.values())
_CLIENTS.clear()
for client in clients:
try:
client.close()
except Exception:
pass

View File

@@ -12,6 +12,7 @@ import re # 正则表达式模块
import os
import threading
import time
from collections import OrderedDict
import requests # HTTP请求库
from loguru import logger # 日志记录
from requests.adapters import HTTPAdapter
@@ -23,7 +24,12 @@ SO_SEARCH_URL = 'https://www.so.com/s' # 360搜索URL
SO_REFERER_TEMPLATE = 'https://www.so.com/s?ie=utf-8&q=site%3A{domain}' # Referer模板
SEARCH_PATTERN = r'target="_blank">([^<]+)</a></h3>' # 搜索结果匹配模式
BLOCKED_CODE = 3 # 拦截状态码
_SESSION_LOCAL = threading.local()
_DIRECT_HTTP = requests.Session()
_DIRECT_HTTP.trust_env = False
_DIRECT_HTTP.mount("http://", HTTPAdapter(pool_connections=128, pool_maxsize=256, max_retries=0))
_DIRECT_HTTP.mount("https://", HTTPAdapter(pool_connections=128, pool_maxsize=256, max_retries=0))
_PROXY_SESSIONS = OrderedDict()
_PROXY_SESSION_LOCK = threading.Lock()
SO_TIMEOUT_PROXY = max(
1.0,
float(os.getenv("DOMAINCHECK_360_TIMEOUT_PROXY", "1.4") or 1.4),
@@ -34,6 +40,20 @@ SO_TIMEOUT_DIRECT = max(
)
def _proxy_session_cache_limit() -> int:
try:
return max(1, int(os.getenv("DOMAINCHECK_360_PROXY_SESSION_CACHE_SIZE", "128") or 128))
except Exception:
return 128
def _close_session_quietly(session) -> None:
try:
session.close()
except Exception:
pass
def _resolve_360_timeout(proxies: Optional[Dict] = None, budget_seconds: Optional[float] = None) -> float:
timeout = float(SO_TIMEOUT_PROXY if proxies else SO_TIMEOUT_DIRECT)
if budget_seconds not in (None, "", 0, "0"):
@@ -41,15 +61,27 @@ def _resolve_360_timeout(proxies: Optional[Dict] = None, budget_seconds: Optiona
return max(0.6, timeout)
def _get_session():
session = getattr(_SESSION_LOCAL, "session", None)
if session is not None:
def _get_session(proxies: Optional[Dict] = None):
proxy_url = ""
if proxies:
proxy_url = str(proxies.get("https") or proxies.get("http") or "").strip()
if not proxy_url:
return _DIRECT_HTTP
with _PROXY_SESSION_LOCK:
session = _PROXY_SESSIONS.get(proxy_url)
if session is not None:
_PROXY_SESSIONS.move_to_end(proxy_url)
return session
session = requests.Session()
session.trust_env = False
session.mount("http://", HTTPAdapter(pool_connections=64, pool_maxsize=128, max_retries=0))
session.mount("https://", HTTPAdapter(pool_connections=64, pool_maxsize=128, max_retries=0))
_PROXY_SESSIONS[proxy_url] = session
while len(_PROXY_SESSIONS) > _proxy_session_cache_limit():
_, stale_session = _PROXY_SESSIONS.popitem(last=False)
_close_session_quietly(stale_session)
return session
session = requests.Session()
session.mount("http://", HTTPAdapter(pool_connections=64, pool_maxsize=128, max_retries=0))
session.mount("https://", HTTPAdapter(pool_connections=64, pool_maxsize=128, max_retries=0))
_SESSION_LOCAL.session = session
return session
def check_domain(
@@ -95,7 +127,7 @@ def check_domain(
try:
session_started_at = time.perf_counter()
logger.info(f'360阶段: domain={domain} | stage=session_init_start')
session = _get_session()
session = _get_session(proxies)
logger.info(
f'360阶段: domain={domain} | stage=session_init_done '
f'| elapsed_ms={int((time.perf_counter() - session_started_at) * 1000)}'
@@ -103,20 +135,28 @@ def check_domain(
# 发送GET请求获取搜索结果
request_started_at = time.perf_counter()
logger.info(f'360阶段: domain={domain} | stage=request_start | proxy={"yes" if proxies else "no"}')
response = session.get(
SO_SEARCH_URL,
params=params,
headers=headers,
proxies=proxies,
timeout=_resolve_360_timeout(proxies, budget_seconds=budget_seconds),
)
logger.info(
f'360阶段: domain={domain} | stage=request_done | status={response.status_code} '
f'| elapsed_ms={int((time.perf_counter() - request_started_at) * 1000)}'
)
response = None
try:
response = session.get(
SO_SEARCH_URL,
params=params,
headers=headers,
proxies=proxies,
timeout=_resolve_360_timeout(proxies, budget_seconds=budget_seconds),
)
logger.info(
f'360阶段: domain={domain} | stage=request_done | status={response.status_code} '
f'| elapsed_ms={int((time.perf_counter() - request_started_at) * 1000)}'
)
# 解析响应内容
response_html = response.content.decode('utf-8', errors='ignore')
# 解析响应内容
response_html = response.content.decode('utf-8', errors='ignore')
finally:
try:
if response is not None:
response.close()
except Exception:
pass
# 使用正则表达式提取搜索结果
search_results = re.findall(SEARCH_PATTERN, response_html)

View File

@@ -15,10 +15,23 @@ import subprocess
import execjs
def _resolve_geetest_asset_timeout():
connect_timeout = max(
0.3,
float(os.getenv("DOMAINCHECK_GEETEST_ASSET_CONNECT_TIMEOUT", "2.0") or 2.0),
)
read_timeout = max(
0.5,
float(os.getenv("DOMAINCHECK_GEETEST_ASSET_READ_TIMEOUT", "4.0") or 4.0),
)
return (connect_timeout, read_timeout)
class slide():
def __init__(self):
self.headers = randomUA()
self.t = round(time.time() * 1000)
self.asset_timeout = _resolve_geetest_asset_timeout()
def __ease_out_expo(self, sep):
if sep == 1:
@@ -83,7 +96,7 @@ class slide():
for idv, p_url in vdict.items():
p_url = 'http://static.geetest.com/' + p_url
# print(p_url)
vcode = requests.get(p_url, headers=self.headers)
vcode = requests.get(p_url, headers=self.headers, timeout=self.asset_timeout)
text = vcode.content
if idv == 'bg':
text = self.tp_huanyuan(text)
@@ -131,7 +144,7 @@ class Geetest2():
"callback": f"geetest_{self.t}"
}
try:
respe = http.get(url=url, params=params, proxies=proxies).text
respe = http.get(url=url, params=params, proxies=proxies, timeout=self.timeout).text
except Exception as e:
if proxy:
if pparmas:

View File

@@ -13,7 +13,6 @@ import base64 # Base64编码解码
import hashlib # 哈希加密
import json # JSON数据处理
import os # 操作系统接口
import pickle # 序列化反序列化
import random # 随机数生成
import shutil
import subprocess # 子进程管理
@@ -24,6 +23,7 @@ import requests # HTTP请求库
from loguru import logger # 日志记录
from requests.cookies import RequestsCookieJar # Cookie管理
from detect.locked_pickle import load_pickle_locked, save_pickle_atomic
# # 移除自定义子进程类的替换,避免影响其他模块
@@ -109,12 +109,19 @@ class JC(object): # 聚查网API封装类
def __init__(self, proxies: dict = None): # 初始化JC类
self.session = requests.Session() # 创建会话对象
self.session.timeout = 10 # 设置超时时间
self.session.proxies = proxies # 设置代理
self.request_timeout = max(
0.5,
float(os.getenv("DOMAINCHECK_JUCHA_TIMEOUT", "10") or 10),
)
self.base_url = "https://www.jucha.com" # 设置基础URL
self.cookie = requests.cookies.RequestsCookieJar() # 初始化CookieJar
self.juming_cookie = requests.cookies.RequestsCookieJar() # 初始化聚名网CookieJar
def _request(self, method: str, url: str, **kwargs):
kwargs.setdefault("timeout", self.request_timeout)
return getattr(self.session, method)(url, **kwargs)
def _get_headers(self, referer: str = None) -> dict: # 获取请求头可自定义referer
headers = self.headers.copy() # 复制默认请求头
if referer: # 如果指定了referer
@@ -139,7 +146,7 @@ class JC(object): # 聚查网API封装类
return False, f'滑块验证码失败,{retry_times}次内未成功' # 返回失败
def _check_request(self, url: str, data: dict, headers: dict, cookies=None) -> dict: # 检查请求并处理验证码
res = self.session.post(url=url, data=data, headers=headers, cookies=cookies) # 发送POST请求
res = self._request("post", url=url, data=data, headers=headers, cookies=cookies) # 发送POST请求
try:
response = res.json() # 尝试解析JSON
except Exception as e: # JSON解析失败
@@ -179,7 +186,7 @@ class JC(object): # 聚查网API封装类
"seed": calculate_seed(286) # 种子值
}
url = f"{self.base_url}/captcha/init" # 初始化URL
response = self.session.post(url, headers=self.headers, data=data, cookies=self.cookie).json() # 发送请求
response = self._request("post", url, headers=self.headers, data=data, cookies=self.cookie).json() # 发送请求
if response['code'] == 1: # 初始化成功
self.captchaId = response['data']['captchaId'] # 保存验证码ID
self.encryptionPublicKey = response['data']['encryptionPublicKey'] # 保存加密公钥
@@ -234,8 +241,13 @@ class JC(object): # 聚查网API封装类
url = f"{self.base_url}/captcha/verify" # 验证URL
verify_headers = self.headers.copy() # 复制请求头
verify_headers['Content-Type'] = 'application/json' # 设置内容类型(仅用于验证请求)
verify_response = self.session.post(url, headers=verify_headers, data=verify_data,
cookies=self.cookie) # 发送验证请求
verify_response = self._request(
"post",
url,
headers=verify_headers,
data=verify_data,
cookies=self.cookie,
) # 发送验证请求
if verify_response.cookies: # 如果响应中有新的Cookie
self.cookie.update(verify_response.cookies) # 合并Cookie而不是替换
response = verify_response.json() # 解析JSON响应
@@ -254,20 +266,12 @@ class JC(object): # 聚查网API封装类
return False, "Node.js脚本未输出任何内容" # 返回失败
def save_cookies(self, filepath="jucha_cookies.pkl"): # 保存Cookie到文件
with open(filepath, "wb") as f: # 以二进制写入模式打开文件
pickle.dump(self.cookie, f) # 序列化保存Cookie
save_pickle_atomic(filepath, self.cookie)
logger.info(f"已保存Cookie到文件: {filepath}")
# 保存到Redis
try:
import redis
from app.config import config
redis_client = redis.Redis(
host=config.REDIS_HOST,
port=config.REDIS_PORT,
password=config.REDIS_PASSWORD,
db=config.REDIS_DB,
decode_responses=True
)
from app.utils.redis_client import get_redis_client
redis_client = get_redis_client(role="standard")
# 将cookie转换为字典
cookie_dict = {}
# 检查self.cookie的类型
@@ -292,25 +296,29 @@ class JC(object): # 聚查网API封装类
def load_cookies(self, filepath="jucha_cookies.pkl"): # 从文件加载Cookie
try: # 尝试加载
with open(filepath, "rb") as f: # 以二进制读取模式打开文件
loaded_cookie = pickle.load(f) # 反序列化加载Cookie
# 检查加载的cookie类型
if isinstance(loaded_cookie, dict):
# 如果是字典转换为RequestsCookieJar
cookie_jar = requests.cookies.RequestsCookieJar()
for name, value in loaded_cookie.items():
cookie_jar.set(name, value)
self.cookie = cookie_jar
else:
self.cookie = loaded_cookie
loaded_cookie = load_pickle_locked(
filepath,
default_factory=requests.cookies.RequestsCookieJar,
)
# 检查加载的cookie类型
if isinstance(loaded_cookie, dict):
# 如果是字典转换为RequestsCookieJar
cookie_jar = requests.cookies.RequestsCookieJar()
for name, value in loaded_cookie.items():
cookie_jar.set(name, value)
self.cookie = cookie_jar
else:
self.cookie = loaded_cookie
except Exception as e: # 加载失败
logger.error(f"加载Cookie失败: {e}")
self.cookie = requests.cookies.RequestsCookieJar() # 创建空的CookieJar
def load_juming_cookies(self, filepath="juming_cookies.pkl"): # 从文件加载聚名网Cookie
try: # 尝试加载
with open(filepath, "rb") as f: # 以二进制读取模式打开文件
self.juming_cookie = pickle.load(f) # 反序列化加载Cookie
self.juming_cookie = load_pickle_locked(
filepath,
default_factory=requests.cookies.RequestsCookieJar,
)
except: # 加载失败
self.juming_cookie = requests.cookies.RequestsCookieJar() # 创建空的CookieJar
@@ -318,7 +326,8 @@ class JC(object): # 聚查网API封装类
params = { # 请求参数
'platform': 'juming', # 平台标识
}
res = self.session.get( # 发送GET请求
res = self._request( # 发送GET请求
"get",
url=f'{self.base_url}/home/login/get_auth_url', # 获取授权URL
params=params, # 请求参数
headers=self.headers, # 请求头
@@ -333,7 +342,8 @@ class JC(object): # 聚查网API封装类
combined_cookies = requests.cookies.RequestsCookieJar()
combined_cookies.update(self.juming_cookie)
combined_cookies.update(self.cookie)
response = self.session.get( # 发送GET请求
response = self._request( # 发送GET请求
"get",
url=url, # 跳转URL
headers=self.headers, # 请求头
cookies=combined_cookies, # 合并后的Cookie
@@ -345,7 +355,8 @@ class JC(object): # 聚查网API封装类
self.cookie[name] = value # 直接赋值覆盖同名Cookie
if response.status_code == 302: # 重定向状态码
url = response.headers['location'] # 获取重定向URL
response = self.session.get( # 发送GET请求
response = self._request( # 发送GET请求
"get",
url=url, # 重定向URL
headers=self.headers, # 请求头
allow_redirects=False # 不自动重定向

View File

@@ -12,7 +12,6 @@ import base64 # Base64编码模块
import hashlib # 哈希算法模块
import json # JSON处理模块
import os # 操作系统接口模块
import pickle # 序列化模块
import random # 随机数生成模块
import shutil
import subprocess # 子进程管理模块
@@ -23,6 +22,7 @@ import requests # HTTP请求库
from loguru import logger # 日志记录库
from requests.cookies import RequestsCookieJar # Cookie处理
from detect.locked_pickle import load_pickle_locked, save_pickle_atomic
# 常量定义
BASE_URL = "https://www.juming.com" # 聚名网基础URL
@@ -170,11 +170,18 @@ class JM(object): # 聚名网API封装类
def __init__(self, proxies: Optional[Dict] = None): # 初始化方法
self.session = requests.Session() # 创建会话对象
self.session.timeout = REQUEST_TIMEOUT # 设置超时时间
self.session.proxies = proxies # 设置代理
self.request_timeout = max(
0.5,
float(os.getenv("DOMAINCHECK_JUMING_TIMEOUT", str(REQUEST_TIMEOUT)) or REQUEST_TIMEOUT),
)
self.base_url = BASE_URL # 设置基础URL
logger.debug(proxies)
def _request(self, method: str, url: str, **kwargs):
kwargs.setdefault("timeout", self.request_timeout)
return getattr(self.session, method)(url, **kwargs)
def captcha_init(self) -> Tuple[bool, str]: # 初始化滑块验证码
"""
初始化滑块验证码
@@ -194,7 +201,7 @@ class JM(object): # 聚名网API封装类
# self.cookie.clear()
# self.cookie.update({'acw_sc__v2': '69d948ed49117d89433d4316a79ada6f00d68f26'})
# logger.info(self.cookie)
response = self.session.post(url, headers=self.headers, data=data, cookies=self.cookie).json() # 发送请求
response = self._request("post", url, headers=self.headers, data=data, cookies=self.cookie).json() # 发送请求
logger.info(response)
if response['code'] == 1: # 初始化成功
@@ -271,7 +278,7 @@ class JM(object): # 聚名网API封装类
url = f"{self.base_url}/captcha/verify" # 验证URL
self.headers['Content-Type'] = 'application/json' # 设置内容类型
response = self.session.post(url, headers=self.headers, data=verify_data, cookies=self.cookie).json() # 发送验证请求
response = self._request("post", url, headers=self.headers, data=verify_data, cookies=self.cookie).json() # 发送验证请求
if response['code'] == 1: # 验证成功
self.token = response['data']['token'] # 保存token
self.session_id = response['data']['session_id'] # 保存会话ID
@@ -294,7 +301,7 @@ class JM(object): # 聚名网API封装类
"""
url = f"{self.base_url}/user_zh/wxdl_ewm" # 二维码URL
try: # 异常处理
response = self.session.post(url, headers=self.headers).json() # 发送请求
response = self._request("post", url, headers=self.headers).json() # 发送请求
self.token = response['data']["token"] # 保存token
return response['code'] == 1, response['msg'], response['data']['url'] if response['code'] == 1 else None # 返回结果
except Exception as e: # 异常处理
@@ -338,7 +345,8 @@ class JM(object): # 聚名网API封装类
}
login_url = f"{self.base_url}/user_zh/p_login" # 登录URL
response = self.session.post( # 发送登录请求
response = self._request( # 发送登录请求
"post",
url=login_url,
headers=self.headers,
json=data,
@@ -384,7 +392,7 @@ class JM(object): # 聚名网API封装类
url = f"{self.base_url}/ykj/get_list" # 列表URL
try: # 异常处理
self.headers['Content-Type'] = 'application/x-www-form-urlencoded' # 设置内容类型
res = self.session.post(url, headers=self.headers, data=data, cookies=self.cookie) # 发送请求
res = self._request("post", url, headers=self.headers, data=data, cookies=self.cookie) # 发送请求
response = res.json() # 解析响应
if response['code'] == -401: # 需要验证码
@@ -427,7 +435,7 @@ class JM(object): # 聚名网API封装类
"""
url = f"{self.base_url}/newcha/del_down?scsj={date}" # 下载URL
try: # 异常处理
response = self.session.get(url, headers=self.headers, cookies=self.cookie, allow_redirects=False) # 发送请求
response = self._request("get", url, headers=self.headers, cookies=self.cookie, allow_redirects=False) # 发送请求
url = response.headers['Location'] # 获取重定向URL
response = self.session.get(url, timeout=DOWNLOAD_TIMEOUT) # 下载文件
response_text = response.content.decode('utf-8') # 解码内容
@@ -443,20 +451,12 @@ class JM(object): # 聚名网API封装类
Args:
filepath: 文件路径
"""
with open(filepath, "wb") as f: # 以二进制写入模式打开文件
pickle.dump(self.cookie, f) # 序列化保存
save_pickle_atomic(filepath, self.cookie)
# 保存到Redis
try:
import redis
from app.config import config
redis_client = redis.Redis(
host=config.REDIS_HOST,
port=config.REDIS_PORT,
password=config.REDIS_PASSWORD,
db=config.REDIS_DB,
decode_responses=True
)
from app.utils.redis_client import get_redis_client
redis_client = get_redis_client(role="standard")
# 将cookie转换为字典
cookie_dict = {}
for cookie in self.cookie:
@@ -473,8 +473,10 @@ class JM(object): # 聚名网API封装类
filepath: 文件路径
"""
try: # 异常处理
with open(filepath, "rb") as f: # 以二进制读取模式打开文件
self.cookie = pickle.load(f) # 反序列化加载
self.cookie = load_pickle_locked(
filepath,
default_factory=requests.cookies.RequestsCookieJar,
)
except: # 异常处理
self.cookie = requests.cookies.RequestsCookieJar() # 创建空Cookie

View File

@@ -10,9 +10,7 @@
# 导入标准库
import os # 操作系统接口
import pickle # 序列化反序列化
import re
import time # 时间处理
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import base64
@@ -22,6 +20,7 @@ from loguru import logger # 日志记录
from requests.cookies import RequestsCookieJar # Cookie管理
from detect.geetest2 import Geetest2
from detect.locked_pickle import load_pickle_locked, save_pickle_atomic
@@ -69,30 +68,29 @@ class Juziseo:
self.gt = None
self.session = requests.Session() # 创建会话对象
self.session.proxies = proxies # 设置代理
self.session.timeout = 10 # 设置超时时间10秒
self.request_timeout = max(
0.5,
float(os.getenv("DOMAINCHECK_JUZISEO_TIMEOUT", "10") or 10),
)
self.base_url = "https://seo.juziseo.com" # 设置基础URL
def _request(self, method: str, url: str, **kwargs):
kwargs.setdefault("timeout", self.request_timeout)
return getattr(self.session, method)(url, **kwargs)
def save_cookies(self, filepath="juziseo_cookies.pkl"):
"""
保存Cookie到文件和Redis
:param filepath: 文件路径
"""
with open(filepath, "wb") as f: # 以二进制写入模式打开文件
pickle.dump(self.cookie, f) # 序列化保存Cookie
save_pickle_atomic(filepath, self.cookie)
logger.info(f"已保存桔子SEO Cookie到 {filepath}")
# 保存到Redis
try:
import redis
from app.config import config
redis_client = redis.Redis(
host=config.REDIS_HOST,
port=config.REDIS_PORT,
password=config.REDIS_PASSWORD,
db=config.REDIS_DB,
decode_responses=True
)
from app.utils.redis_client import get_redis_client
redis_client = get_redis_client(role="standard")
# 将cookie转换为字典
cookie_dict = {}
for cookie in self.cookie:
@@ -108,8 +106,10 @@ class Juziseo:
:param filepath: 文件路径
"""
try: # 尝试加载
with open(filepath, "rb") as f: # 以二进制读取模式打开文件
self.cookie = pickle.load(f) # 反序列化加载Cookie
self.cookie = load_pickle_locked(
filepath,
default_factory=requests.cookies.RequestsCookieJar,
)
# logger.info(f"已从 {filepath} 加载桔子SEO Cookie")
except: # 加载失败
self.cookie = requests.cookies.RequestsCookieJar() # 创建空的CookieJar
@@ -117,7 +117,7 @@ class Juziseo:
def start_msg_captcha_servlet(self):
url = f"{self.base_url}/class/gtcode/msg/StartMsgCaptchaServlet.php"
response = self.session.get(url, headers=self.headers, cookies=self.cookie).json()
response = self._request("get", url, headers=self.headers, cookies=self.cookie).json()
logger.info(response)
self.gt = response['gt']
self.challenge = response['challenge']
@@ -161,7 +161,7 @@ class Juziseo:
logger.debug(data)
# 发送登录请求
url = f"{self.base_url}/account/ajax/login_process/"
response = self.session.post(url, headers=self.headers, data=data, cookies=self.cookie)
response = self._request("post", url, headers=self.headers, data=data, cookies=self.cookie)
logger.info(response.text)
# 解析响应
result = response.json()
@@ -190,11 +190,11 @@ class Juziseo:
'_post_type': 'ajax',
}
url = f"{self.base_url}/snapshot/save/"
response = self.session.post(url, headers=self.headers, data=data, cookies=self.cookie).json()
response = self._request("post", url, headers=self.headers, data=data, cookies=self.cookie).json()
# logger.info(response)
if response['errno'] == 1:
url = response['rsm']['url']
response_html = self.session.get(url, headers=self.headers, cookies=self.cookie).content.decode('utf-8')
response_html = self._request("get", url, headers=self.headers, cookies=self.cookie).content.decode('utf-8')
title_sensitive_words_match = re.search(r'标题敏感词\D*(\d+)', response_html, re.S)
title_suspected_sensitive_words_match = re.search(r'标题有疑似敏感词\D*(\d+)', response_html, re.S)
content_sensitive_words_match = re.search(r'内容敏感词\D*(\d+)', response_html, re.S)
@@ -221,11 +221,11 @@ class Juziseo:
'_post_type': 'ajax',
}
url = f"{self.base_url}/domain_rank/save_domain/"
response = self.session.post(url, headers=self.headers, data=data, cookies=self.cookie).json()
response = self._request("post", url, headers=self.headers, data=data, cookies=self.cookie).json()
logger.info(response)
if response['errno'] == 1:
url = response['rsm']['url']
response_html = self.session.get(url, headers=self.headers, cookies=self.cookie).content.decode('utf-8')
response_html = self._request("get", url, headers=self.headers, cookies=self.cookie).content.decode('utf-8')
subdomain_match = re.search(r'子域名:\D*(\d+)', response_html, re.S)
if subdomain_match:
return False, f"存在子域名: {subdomain_match.group(1)}"

View File

@@ -0,0 +1,91 @@
# -*- coding: UTF-8 -*-
"""Helpers for cross-process safe pickle persistence."""
import os
import pickle
import tempfile
import time
from contextlib import contextmanager
from typing import Callable, TypeVar
T = TypeVar("T")
try: # pragma: no cover - platform specific
import fcntl
except ImportError: # pragma: no cover - platform specific
fcntl = None
try: # pragma: no cover - platform specific
import msvcrt
except ImportError: # pragma: no cover - platform specific
msvcrt = None
def _acquire_platform_lock(lock_file, *, timeout_seconds: float) -> None:
deadline = time.monotonic() + max(0.1, float(timeout_seconds or 0.0))
while True:
try:
if fcntl is not None:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
elif msvcrt is not None:
msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
return
except (BlockingIOError, OSError):
if time.monotonic() >= deadline:
raise TimeoutError(f"lock acquire timed out for {lock_file.name}")
time.sleep(0.05)
def _release_platform_lock(lock_file) -> None:
try:
if fcntl is not None:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
elif msvcrt is not None:
lock_file.seek(0)
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
except Exception:
pass
@contextmanager
def _locked_path(path: str, *, timeout_seconds: float = 10.0):
normalized_path = os.path.abspath(path)
directory = os.path.dirname(normalized_path) or "."
os.makedirs(directory, exist_ok=True)
lock_path = f"{normalized_path}.lock"
with open(lock_path, "a+b") as lock_file:
_acquire_platform_lock(lock_file, timeout_seconds=timeout_seconds)
try:
yield normalized_path
finally:
_release_platform_lock(lock_file)
def save_pickle_atomic(path: str, value, *, timeout_seconds: float = 10.0) -> None:
with _locked_path(path, timeout_seconds=timeout_seconds) as normalized_path:
directory = os.path.dirname(normalized_path) or "."
fd, temp_path = tempfile.mkstemp(
prefix=f".{os.path.basename(normalized_path)}.",
suffix=".tmp",
dir=directory,
)
try:
with os.fdopen(fd, "wb") as temp_file:
pickle.dump(value, temp_file)
temp_file.flush()
os.fsync(temp_file.fileno())
os.replace(temp_path, normalized_path)
finally:
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
def load_pickle_locked(path: str, *, default_factory: Callable[[], T], timeout_seconds: float = 10.0) -> T:
with _locked_path(path, timeout_seconds=timeout_seconds) as normalized_path:
if not os.path.exists(normalized_path):
return default_factory()
with open(normalized_path, "rb") as source:
return pickle.load(source)

View File

@@ -11,6 +11,7 @@
import json
import os
import threading
from collections import OrderedDict
from datetime import datetime, timezone, timedelta # 日期时间处理,用于时区转换
from typing import Optional
@@ -22,10 +23,24 @@ _DIRECT_HTTP = requests.Session()
_DIRECT_HTTP.trust_env = False
_DIRECT_HTTP.mount("http://", requests.adapters.HTTPAdapter(pool_connections=256, pool_maxsize=512, max_retries=0))
_DIRECT_HTTP.mount("https://", requests.adapters.HTTPAdapter(pool_connections=256, pool_maxsize=512, max_retries=0))
_PROXY_MANAGERS = {}
_PROXY_MANAGERS = OrderedDict()
_PROXY_MANAGER_LOCK = threading.Lock()
def _proxy_manager_cache_limit() -> int:
try:
return max(1, int(os.getenv("DOMAINCHECK_REGISTER_PROXY_SESSION_CACHE_SIZE", "128") or 128))
except Exception:
return 128
def _close_session_quietly(session) -> None:
try:
session.close()
except Exception:
pass
def _get_http_manager(proxies: Optional[dict] = None):
proxy_url = ""
if proxies:
@@ -34,12 +49,17 @@ def _get_http_manager(proxies: Optional[dict] = None):
return _DIRECT_HTTP
with _PROXY_MANAGER_LOCK:
manager = _PROXY_MANAGERS.get(proxy_url)
if manager is None:
manager = requests.Session()
manager.trust_env = False
manager.mount("http://", requests.adapters.HTTPAdapter(pool_connections=128, pool_maxsize=256, max_retries=0))
manager.mount("https://", requests.adapters.HTTPAdapter(pool_connections=128, pool_maxsize=256, max_retries=0))
_PROXY_MANAGERS[proxy_url] = manager
if manager is not None:
_PROXY_MANAGERS.move_to_end(proxy_url)
return manager
manager = requests.Session()
manager.trust_env = False
manager.mount("http://", requests.adapters.HTTPAdapter(pool_connections=128, pool_maxsize=256, max_retries=0))
manager.mount("https://", requests.adapters.HTTPAdapter(pool_connections=128, pool_maxsize=256, max_retries=0))
_PROXY_MANAGERS[proxy_url] = manager
while len(_PROXY_MANAGERS) > _proxy_manager_cache_limit():
_, stale_manager = _PROXY_MANAGERS.popitem(last=False)
_close_session_quietly(stale_manager)
return manager

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
{
"mainland-controller-01": 160,
"mainland-worker-01": 1
}

View File

@@ -0,0 +1,3 @@
{
"process_count": "160"
}

View File

@@ -1,5 +1,6 @@
import unittest
import sys
import unittest
from datetime import datetime, timedelta
sys.path.insert(0, "/www/wwwroot/getDomain/domainCheck")
@@ -7,6 +8,7 @@ sys.path.insert(0, "/www/wwwroot/getDomain/domainCheck")
from app.utils.database import _detect_job_item_step_priority
from app.utils.database import _ordered_step_claim_codes
from app.utils.database import _resolve_step_claim_quota
from app.utils.database import _select_preferred_claim_job_ids
class DetectJobItemClaimPriorityTestCase(unittest.TestCase):
@@ -46,9 +48,42 @@ class DetectJobItemClaimPriorityTestCase(unittest.TestCase):
_detect_job_item_step_priority("detect_register"),
)
def test_step_claim_quota_defaults_to_quarter_window_with_floor(self):
def test_step_claim_quota_defaults_to_full_window_for_large_pools(self):
self.assertEqual(64, _resolve_step_claim_quota(120))
self.assertEqual(300, _resolve_step_claim_quota(1200))
self.assertEqual(1200, _resolve_step_claim_quota(1200))
def test_select_preferred_claim_job_ids_prioritizes_running_jobs_even_if_older(self):
now = datetime(2026, 4, 24, 18, 30, 0)
selected = _select_preferred_claim_job_ids(
[
(19, "pending", now - timedelta(hours=60)),
(57, "running", now - timedelta(hours=72)),
(58, "pending", now - timedelta(hours=2)),
],
limit=3,
recent_hours=24,
now=now,
)
self.assertEqual([57, 58], selected)
def test_select_preferred_claim_job_ids_filters_out_stale_pending_jobs(self):
now = datetime(2026, 4, 24, 18, 30, 0)
selected = _select_preferred_claim_job_ids(
[
(19, "pending", now - timedelta(hours=60)),
(20, "pending", now - timedelta(hours=40)),
(58, "pending", now - timedelta(hours=2)),
(59, "pending", now - timedelta(minutes=30)),
],
limit=4,
recent_hours=24,
now=now,
)
self.assertEqual([59, 58], selected)
if __name__ == "__main__":

View File

@@ -0,0 +1,440 @@
import threading
import time
import sys
import unittest
from unittest.mock import MagicMock, patch
sys.path.insert(0, "/www/wwwroot/getDomain/domainCheck")
from psycopg2 import extensions # noqa: E402
from app.utils.database import Database, _build_claim_token # noqa: E402
class _FakeCursor:
def __init__(self):
self.closed = False
self.executed = []
self.fetchone_queue = [(1,)]
self.fetchall_queue = []
def execute(self, sql, params=None):
self.executed.append((sql, params))
def fetchone(self):
if self.fetchone_queue:
return self.fetchone_queue.pop(0)
return (1,)
def fetchall(self):
if self.fetchall_queue:
return self.fetchall_queue.pop(0)
return []
def close(self):
self.closed = True
class _HealthyConn:
def __init__(self):
self.closed = False
self.cursor_calls = 0
def get_transaction_status(self):
return extensions.TRANSACTION_STATUS_IDLE
def rollback(self):
return None
def cursor(self):
self.cursor_calls += 1
return _FakeCursor()
def close(self):
self.closed = True
class _BrokenRollbackConn:
def __init__(self):
self.closed = False
def get_transaction_status(self):
return extensions.TRANSACTION_STATUS_INTRANS
def rollback(self):
raise RuntimeError("rollback failed")
def close(self):
self.closed = True
class DatabaseConnectionPoolTests(unittest.TestCase):
def _build_db(self):
db = Database.__new__(Database)
db.connection_pool = []
db.pool_size = 2
db.pool_idle_keep_max = 1
db.pool_healthcheck_interval = 30.0
db.pool_acquire_timeout = 1.0
db.pool_lock = threading.Lock()
db.pool_condition = threading.Condition(db.pool_lock)
db._pool_initialized = True
db.total_connections = 0
db._connection_last_healthcheck = {}
db.close = Database.close.__get__(db, Database)
db.connect = Database.connect.__get__(db, Database)
db._prepare_pooled_connection = Database._prepare_pooled_connection.__get__(db, Database)
db._discard_connection = Database._discard_connection.__get__(db, Database)
return db
def test_claim_token_is_unique_even_for_same_thread_and_instant(self):
token_a = _build_claim_token("mainland-controller-01", thread_id=123)
token_b = _build_claim_token("mainland-controller-01", thread_id=123)
self.assertNotEqual(token_a, token_b)
self.assertLessEqual(len(token_a), 64)
self.assertLessEqual(len(token_b), 64)
self.assertTrue(token_a.startswith("mainland-controller-01-7b-"))
self.assertTrue(token_b.startswith("mainland-controller-01-7b-"))
def test_connect_discards_bad_pooled_connection_and_creates_new_one(self):
db = self._build_db()
bad_conn = _BrokenRollbackConn()
good_conn = _HealthyConn()
db.connection_pool = [bad_conn]
db.total_connections = 1
db._connection_last_healthcheck[id(bad_conn)] = time.monotonic()
db._create_connection = MagicMock(return_value=good_conn)
conn, cur = db.connect(thread_id=123)
self.assertIs(conn, good_conn)
self.assertIsInstance(cur, _FakeCursor)
self.assertTrue(bad_conn.closed)
self.assertEqual(1, db.total_connections)
db._create_connection.assert_called_once()
def test_close_discards_connection_when_rollback_fails(self):
db = self._build_db()
conn = _BrokenRollbackConn()
db.total_connections = 1
db._connection_last_healthcheck[id(conn)] = time.monotonic()
db.close(conn, None)
self.assertTrue(conn.closed)
self.assertEqual(0, db.total_connections)
self.assertEqual([], db.connection_pool)
def test_ensure_cluster_runtime_tables_skips_ddl_when_schema_is_ready(self):
db = Database.__new__(Database)
db._cluster_runtime_schema_ready = MagicMock(return_value=True)
db.execute = MagicMock()
db.ensure_cluster_runtime_tables = Database.ensure_cluster_runtime_tables.__get__(db, Database)
self.assertTrue(db.ensure_cluster_runtime_tables())
db.execute.assert_not_called()
def test_ensure_cluster_runtime_tables_executes_ddl_when_schema_is_missing(self):
db = Database.__new__(Database)
db._cluster_runtime_schema_ready = MagicMock(return_value=False)
db._cluster_runtime_schema_basics_present = MagicMock(return_value=False)
db.execute = MagicMock(return_value=True)
db.ensure_cluster_runtime_tables = Database.ensure_cluster_runtime_tables.__get__(db, Database)
self.assertTrue(db.ensure_cluster_runtime_tables())
db.execute.assert_called_once()
def test_ensure_cluster_runtime_tables_skips_index_repair_without_explicit_enable(self):
db = Database.__new__(Database)
db._cluster_runtime_schema_ready = MagicMock(return_value=False)
db._cluster_runtime_schema_basics_present = MagicMock(return_value=True)
db._ensure_cluster_runtime_indexes = MagicMock(return_value=True)
db.execute = MagicMock()
db.ensure_cluster_runtime_tables = Database.ensure_cluster_runtime_tables.__get__(db, Database)
db._runtime_index_repair_enabled = Database._runtime_index_repair_enabled.__get__(db, Database)
with patch.dict("os.environ", {}, clear=False):
self.assertFalse(db.ensure_cluster_runtime_tables())
db._ensure_cluster_runtime_indexes.assert_not_called()
db.execute.assert_not_called()
def test_cluster_runtime_schema_ready_requires_claim_step_indexes(self):
db = Database.__new__(Database)
db._cluster_runtime_schema_basics_present = MagicMock(return_value=True)
db._cluster_runtime_missing_indexes = MagicMock(return_value=["idx_detect_job_items_claim_step_ready"])
db._cluster_runtime_schema_ready = Database._cluster_runtime_schema_ready.__get__(db, Database)
self.assertFalse(db._cluster_runtime_schema_ready())
def test_cluster_runtime_missing_indexes_treats_invalid_indexes_as_missing(self):
db = Database.__new__(Database)
db.fetch_all = MagicMock(
return_value=[
{"index_name": "idx_detect_job_items_job_domain_step", "is_valid": True},
{"index_name": "idx_detect_job_items_claim_step_ready", "is_valid": False},
]
)
db._cluster_runtime_missing_indexes = Database._cluster_runtime_missing_indexes.__get__(db, Database)
missing = db._cluster_runtime_missing_indexes()
self.assertIn("idx_detect_job_items_claim_step_ready", missing)
self.assertIn("idx_detect_job_items_claim_job_step_ready", missing)
self.assertNotIn("idx_detect_job_items_job_domain_step", missing)
def test_ensure_cluster_runtime_indexes_skips_invalid_rebuild_by_default(self):
db = Database.__new__(Database)
conn = MagicMock()
cur = _FakeCursor()
cur.fetchone_queue = [(True,)]
db._cluster_runtime_missing_indexes = MagicMock(return_value=["idx_detect_job_items_stalled_job_activity"])
db._cluster_runtime_invalid_indexes = MagicMock(return_value=["idx_detect_job_items_stalled_job_activity"])
db._create_connection = MagicMock(return_value=conn)
conn.cursor.return_value = cur
db._ensure_cluster_runtime_indexes = Database._ensure_cluster_runtime_indexes.__get__(db, Database)
db._runtime_index_repair_enabled = Database._runtime_index_repair_enabled.__get__(db, Database)
with patch.dict("os.environ", {}, clear=False):
self.assertTrue(db._ensure_cluster_runtime_indexes())
executed_sql = [sql for sql, _ in cur.executed]
self.assertFalse(any("DROP INDEX CONCURRENTLY IF EXISTS idx_detect_job_items_stalled_job_activity" in str(sql) for sql in executed_sql))
self.assertFalse(any("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_detect_job_items_stalled_job_activity" in str(sql) for sql in executed_sql))
def test_ensure_cluster_runtime_indexes_rebuilds_invalid_index_when_enabled(self):
db = Database.__new__(Database)
conn = MagicMock()
cur = _FakeCursor()
cur.fetchone_queue = [(True,)]
db._cluster_runtime_missing_indexes = MagicMock(return_value=["idx_detect_job_items_stalled_job_activity"])
db._cluster_runtime_invalid_indexes = MagicMock(return_value=["idx_detect_job_items_stalled_job_activity"])
db._create_connection = MagicMock(return_value=conn)
conn.cursor.return_value = cur
db._ensure_cluster_runtime_indexes = Database._ensure_cluster_runtime_indexes.__get__(db, Database)
db._runtime_index_repair_enabled = Database._runtime_index_repair_enabled.__get__(db, Database)
with patch.dict("os.environ", {"DOMAINCHECK_RUNTIME_INDEX_REPAIR_ENABLED": "1"}, clear=False):
self.assertTrue(db._ensure_cluster_runtime_indexes())
executed_sql = [str(sql) for sql, _ in cur.executed]
self.assertTrue(any("DROP INDEX CONCURRENTLY IF EXISTS idx_detect_job_items_stalled_job_activity" in sql for sql in executed_sql))
self.assertTrue(any("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_detect_job_items_stalled_job_activity" in sql for sql in executed_sql))
def test_release_detect_job_items_is_enabled_by_default(self):
db = Database.__new__(Database)
conn = MagicMock()
cur = _FakeCursor()
cur.fetchone_queue = [(0, [])]
db.connect = MagicMock(return_value=(conn, cur))
db.close = MagicMock()
db.release_detect_job_items_for_node = Database.release_detect_job_items_for_node.__get__(db, Database)
self.assertEqual(0, db.release_detect_job_items_for_node("mainland-controller-01-a"))
db.connect.assert_called_once()
executed_sql = [sql for sql, _ in cur.executed]
self.assertTrue(any("FOR UPDATE SKIP LOCKED" in sql for sql in executed_sql))
def test_claim_restart_released_detect_job_items_targets_restart_release_rows(self):
db = Database.__new__(Database)
conn = MagicMock()
cur = _FakeCursor()
cur.fetchall_queue = [[
(
101,
880,
501,
"token-a",
"detect_register",
"domain_pipeline",
"sync-overseas-20249",
"detect_register",
None,
"example.com",
0,
0,
0,
0,
None,
0,
0,
)
]]
db.connect = MagicMock(return_value=(conn, cur))
db.close = MagicMock()
db.claim_restart_released_detect_job_items = Database.claim_restart_released_detect_job_items.__get__(db, Database)
rows = db.claim_restart_released_detect_job_items("mainland-controller-01-a", 880, limit=16, lease_seconds=900)
self.assertEqual(1, len(rows))
executed_sql = [sql for sql, _ in cur.executed]
self.assertTrue(any("released after worker restart" in sql for sql in executed_sql))
self.assertTrue(any("released before execution after worker restart" in sql for sql in executed_sql))
self.assertTrue(any("FOR UPDATE SKIP LOCKED" in sql for sql in executed_sql))
def test_release_detect_job_items_can_be_disabled_explicitly(self):
db = Database.__new__(Database)
db.connect = MagicMock()
db.release_detect_job_items_for_node = Database.release_detect_job_items_for_node.__get__(db, Database)
with patch.dict("os.environ", {"DOMAINCHECK_ENABLE_NODE_ITEM_RELEASE": "0"}, clear=False):
self.assertEqual(0, db.release_detect_job_items_for_node("mainland-controller-01-a"))
db.connect.assert_not_called()
def test_release_detect_job_items_for_node_job_targets_single_job(self):
db = Database.__new__(Database)
conn = MagicMock()
cur = _FakeCursor()
cur.fetchone_queue = [(4, [876])]
db.connect = MagicMock(return_value=(conn, cur))
db.close = MagicMock()
db._refresh_detect_job_status_with_cursor = MagicMock()
db.release_detect_job_items_for_node_job = (
Database.release_detect_job_items_for_node_job.__get__(db, Database)
)
self.assertEqual(4, db.release_detect_job_items_for_node_job("mainland-controller-01-ba", 876))
executed_sql = [sql for sql, _ in cur.executed]
self.assertTrue(any("WHERE claimed_by = %s" in sql and "AND job_id = %s" in sql for sql in executed_sql))
self.assertTrue(any("FOR UPDATE SKIP LOCKED" in sql for sql in executed_sql))
db._refresh_detect_job_status_with_cursor.assert_called_once_with(cur, 876)
def test_release_single_detect_job_item_skips_job_refresh(self):
db = Database.__new__(Database)
conn = MagicMock()
cur = _FakeCursor()
cur.fetchone_queue = [(11,)]
db.connect = MagicMock(return_value=(conn, cur))
db.close = MagicMock()
db._refresh_detect_job_status_with_cursor = MagicMock()
db.release_detect_job_item = Database.release_detect_job_item.__get__(db, Database)
self.assertTrue(db.release_detect_job_item(101, "token-a", reason="session_replaced:7"))
executed_sql = [sql for sql, _ in cur.executed]
self.assertTrue(any("UPDATE detect_job_items" in sql for sql in executed_sql))
db._refresh_detect_job_status_with_cursor.assert_not_called()
def test_release_detect_job_items_batch_skips_job_refresh(self):
db = Database.__new__(Database)
conn = MagicMock()
cur = _FakeCursor()
cur.fetchall_queue = [[(101,), (102,)]]
cur.mogrify = MagicMock(side_effect=lambda sql, params: str(tuple(params)).encode("utf-8"))
db.connect = MagicMock(return_value=(conn, cur))
db.close = MagicMock()
db._refresh_detect_job_status_with_cursor = MagicMock()
db.release_detect_job_items_batch = Database.release_detect_job_items_batch.__get__(db, Database)
released = db.release_detect_job_items_batch(
[
(101, "token-a", "session_replaced:7"),
(102, "token-b", "queued_before_start"),
]
)
self.assertEqual(2, released)
executed_sql = [sql for sql, _ in cur.executed]
self.assertTrue(any("UPDATE detect_job_items" in sql for sql in executed_sql))
db._refresh_detect_job_status_with_cursor.assert_not_called()
def test_recycle_expired_detect_job_items_skips_when_advisory_lock_is_busy(self):
db = Database.__new__(Database)
conn = MagicMock()
cur = _FakeCursor()
cur.fetchone_queue = [(False,)]
db.connect = MagicMock(return_value=(conn, cur))
db.close = MagicMock()
db.recycle_expired_detect_job_items = Database.recycle_expired_detect_job_items.__get__(db, Database)
self.assertEqual(0, db.recycle_expired_detect_job_items())
executed_sql = [sql for sql, _ in cur.executed]
self.assertIn("SELECT pg_try_advisory_lock(%s)", executed_sql)
self.assertNotIn(
"WITH recycled AS (",
" ".join(executed_sql),
)
def test_recycle_expired_detect_job_items_runs_under_advisory_lock(self):
db = Database.__new__(Database)
conn = MagicMock()
cur = _FakeCursor()
cur.fetchone_queue = [(True,), (3, [11, 12])]
db.connect = MagicMock(return_value=(conn, cur))
db.close = MagicMock()
db._refresh_detect_job_status_with_cursor = MagicMock()
db.recycle_expired_detect_job_items = Database.recycle_expired_detect_job_items.__get__(db, Database)
self.assertEqual(3, db.recycle_expired_detect_job_items())
executed_sql = [sql for sql, _ in cur.executed]
self.assertIn("SELECT pg_try_advisory_lock(%s)", executed_sql)
self.assertTrue(any("expired_candidates" in sql and "WITH" in sql for sql in executed_sql))
self.assertTrue(any("FOR UPDATE SKIP LOCKED" in sql for sql in executed_sql))
self.assertIn("SELECT pg_advisory_unlock(%s)", executed_sql)
db._refresh_detect_job_status_with_cursor.assert_any_call(cur, 11)
db._refresh_detect_job_status_with_cursor.assert_any_call(cur, 12)
def test_recycle_stalled_detect_job_items_skips_when_advisory_lock_is_busy(self):
db = Database.__new__(Database)
conn = MagicMock()
cur = _FakeCursor()
cur.fetchone_queue = [(False,)]
db.connect = MagicMock(return_value=(conn, cur))
db.close = MagicMock()
db.recycle_stalled_detect_job_items = Database.recycle_stalled_detect_job_items.__get__(db, Database)
self.assertEqual(0, db.recycle_stalled_detect_job_items(876, stall_seconds=1800))
executed_sql = [sql for sql, _ in cur.executed]
self.assertIn("SELECT pg_try_advisory_lock(%s)", executed_sql)
self.assertNotIn(
"WITH stalled_candidates AS (",
" ".join(executed_sql),
)
def test_recycle_stalled_detect_job_items_runs_under_advisory_lock(self):
db = Database.__new__(Database)
conn = MagicMock()
cur = _FakeCursor()
cur.fetchone_queue = [(True,), (5, [876])]
db.connect = MagicMock(return_value=(conn, cur))
db.close = MagicMock()
db._refresh_detect_job_status_with_cursor = MagicMock()
db.recycle_stalled_detect_job_items = Database.recycle_stalled_detect_job_items.__get__(db, Database)
self.assertEqual(5, db.recycle_stalled_detect_job_items(876, stall_seconds=1800, batch_size=64))
executed_sql = [sql for sql, _ in cur.executed]
self.assertIn("SELECT pg_try_advisory_lock(%s)", executed_sql)
self.assertTrue(any("stalled_candidates" in sql and "WITH" in sql for sql in executed_sql))
self.assertTrue(any("FOR UPDATE SKIP LOCKED" in sql for sql in executed_sql))
self.assertIn("SELECT pg_advisory_unlock(%s)", executed_sql)
db._refresh_detect_job_status_with_cursor.assert_called_once_with(cur, 876)
def test_refresh_detect_job_status_uses_exists_queries_for_final_status(self):
db = Database.__new__(Database)
cur = _FakeCursor()
cur.fetchone_queue = [
("domain_pipeline",),
(False,), # dispatch_active_exists
(False,), # unprocessed_terminal_exists
(False,), # pending_exists
(True,), # failed_exists
(True,), # done_exists
]
Database._refresh_detect_job_status_with_cursor(db, cur, 876)
executed_sql = [sql for sql, _ in cur.executed]
self.assertTrue(any("SELECT COALESCE(task_mode, '')" in sql for sql in executed_sql))
self.assertTrue(any("SELECT EXISTS" in sql and "item.status IN ('claimed', 'running')" in sql for sql in executed_sql))
self.assertTrue(any("SELECT EXISTS" in sql and "controller_processed" in sql for sql in executed_sql))
self.assertTrue(any("SELECT EXISTS" in sql and "item.status = 'pending'" in sql for sql in executed_sql))
self.assertTrue(any("SELECT EXISTS" in sql and "item.status = 'failed'" in sql for sql in executed_sql))
self.assertTrue(any("SELECT EXISTS" in sql and "item.status IN ('completed', 'blacklisted')" in sql for sql in executed_sql))
self.assertTrue(any("UPDATE detect_jobs" in sql for sql in executed_sql))
self.assertTrue(any(params == ('partial_failed', 876) for _, params in cur.executed))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,200 @@
import sys
import unittest
from unittest.mock import patch
from unittest.mock import MagicMock
import json
import time
sys.path.insert(0, "/www/wwwroot/getDomain/domainCheck")
from app.utils.database import Database # noqa: E402
class _FakeConn:
def __init__(self):
self.committed = False
self.rolled_back = False
def commit(self):
self.committed = True
def rollback(self):
self.rolled_back = True
class _FakeCursor:
def __init__(self, fetchall_sequence):
self.fetchall_sequence = list(fetchall_sequence or [])
self.executed_sql = []
def mogrify(self, template, params):
rendered = []
for value in params:
if isinstance(value, str):
rendered.append(f"'{value}'")
else:
rendered.append(str(value))
return f"({', '.join(rendered)})".encode("utf-8")
def execute(self, sql, params=None):
self.executed_sql.append((sql, params))
def fetchall(self):
if self.fetchall_sequence:
return self.fetchall_sequence.pop(0)
return []
class DatabaseJobStatusRefreshTests(unittest.TestCase):
def test_mark_running_batch_skips_job_status_refresh(self):
db = Database.__new__(Database)
conn = _FakeConn()
cur = _FakeCursor(
fetchall_sequence=[
[(101,), (101,), (102,)],
]
)
db.connect = MagicMock(return_value=(conn, cur))
db.close = MagicMock()
db._refresh_detect_job_status_with_cursor = MagicMock()
updated = db.mark_detect_job_items_running_batch(
[(1, "token-a"), (2, "token-b"), (3, "token-c")]
)
self.assertEqual(3, updated)
self.assertTrue(conn.committed)
db._refresh_detect_job_status_with_cursor.assert_not_called()
def test_release_single_item_skips_job_status_refresh(self):
db = Database.__new__(Database)
conn = _FakeConn()
cur = _FakeCursor(fetchall_sequence=[[(101,)]] )
cur.fetchone = lambda: cur.fetchall_sequence.pop(0)[0] if cur.fetchall_sequence else None
db.connect = MagicMock(return_value=(conn, cur))
db.close = MagicMock()
db._refresh_detect_job_status_with_cursor = MagicMock()
released = db.release_detect_job_item(1, "token-a", reason="session replaced")
self.assertTrue(released)
self.assertTrue(conn.committed)
db._refresh_detect_job_status_with_cursor.assert_not_called()
def test_get_active_detect_job_uses_lightweight_candidate_query(self):
db = Database.__new__(Database)
db.fetch_one = MagicMock(return_value={"id": 9, "job_code": "sync-overseas-9"})
db.redis_client = None
with patch.dict(
"os.environ",
{
"DOMAINCHECK_TAIL_HANDOFF_ENABLED": "1",
"DOMAINCHECK_TAIL_HANDOFF_MAX_ACTIVE_ITEMS": "64",
"DOMAINCHECK_TAIL_HANDOFF_MAX_PENDING_ITEMS": "128",
"DOMAINCHECK_TAIL_HANDOFF_MIN_PENDING_ITEMS": "1",
"DOMAINCHECK_RUNNING_JOB_STALL_SECONDS": "1800",
},
clear=False,
):
result = db.get_active_detect_job()
self.assertEqual({"id": 9, "job_code": "sync-overseas-9"}, result)
sql, params = db.fetch_one.call_args[0]
self.assertIn("WITH tail_config AS", sql)
self.assertIn("candidate_jobs AS", sql)
self.assertIn("tail_handoff_candidate", sql)
self.assertIn("selection_reason", sql)
self.assertIn("LEFT JOIN LATERAL", sql)
self.assertIn("item.status IN ('pending', 'claimed', 'running')", sql)
self.assertIn("running_job_stalled", sql)
self.assertIn("latest_unfinished_activity_at", sql)
self.assertEqual((True, 64, 128, 1, 1800), params)
def test_get_active_detect_job_uses_cache_when_available(self):
db = Database.__new__(Database)
db.redis_client = MagicMock()
db.redis_client.get.return_value = json.dumps({"id": 12, "job_code": "sync-overseas-12"})
db.fetch_one = MagicMock()
result = db.get_active_detect_job()
self.assertEqual({"id": 12, "job_code": "sync-overseas-12"}, result)
db.fetch_one.assert_not_called()
def test_get_active_detect_job_writes_cache_after_query(self):
db = Database.__new__(Database)
db.redis_client = MagicMock()
db.redis_client.get.return_value = None
db.redis_client.set.return_value = True
db.fetch_one = MagicMock(return_value={"id": 13, "job_code": "sync-overseas-13", "items_pending": 5})
result = db.get_active_detect_job()
self.assertEqual({"id": 13, "job_code": "sync-overseas-13", "items_pending": 5}, result)
db.redis_client.setex.assert_called_once()
cache_key, ttl, payload = db.redis_client.setex.call_args[0]
self.assertEqual("domaincheck:active_detect_job_summary:v1", cache_key)
self.assertEqual(3, ttl)
self.assertEqual({"id": 13, "job_code": "sync-overseas-13", "items_pending": 5}, json.loads(payload))
def test_get_active_detect_job_uses_local_cache_after_first_query(self):
db = Database.__new__(Database)
db.redis_client = None
db.fetch_one = MagicMock(return_value={"id": 14, "job_code": "sync-overseas-14"})
first = db.get_active_detect_job()
second = db.get_active_detect_job()
self.assertEqual({"id": 14, "job_code": "sync-overseas-14"}, first)
self.assertEqual({"id": 14, "job_code": "sync-overseas-14"}, second)
db.fetch_one.assert_called_once()
def test_get_active_detect_job_uses_stale_local_cache_when_refresh_lock_is_busy(self):
db = Database.__new__(Database)
db.redis_client = MagicMock()
db.redis_client.get.return_value = None
db.redis_client.set.return_value = False
db.fetch_one = MagicMock()
now_ts = time.time()
db._set_local_active_detect_job_cache(
{"id": 15, "job_code": "sync-overseas-15"},
now_ts=now_ts - 4,
fresh_ttl_seconds=3,
stale_ttl_seconds=10,
)
with patch("time.sleep", return_value=None):
result = db.get_active_detect_job()
self.assertEqual({"id": 15, "job_code": "sync-overseas-15"}, result)
db.fetch_one.assert_not_called()
def test_finalize_batch_skips_job_status_refresh(self):
db = Database.__new__(Database)
conn = _FakeConn()
cur = _FakeCursor(
fetchall_sequence=[
[(1, 101), (2, 101), (3, 102)],
]
)
db.connect = MagicMock(return_value=(conn, cur))
db.close = MagicMock()
db._refresh_detect_job_status_with_cursor = MagicMock()
updated = db.finalize_detect_job_items_batch(
[
{"job_item_id": 1, "claim_token": "token-a", "final_status": "completed"},
{"job_item_id": 2, "claim_token": "token-b", "final_status": "failed"},
{"job_item_id": 3, "claim_token": "token-c", "final_status": "completed"},
]
)
self.assertEqual(3, updated)
self.assertTrue(conn.committed)
db._refresh_detect_job_status_with_cursor.assert_not_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,171 @@
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, "/www/wwwroot/getDomain/domainCheck")
from app.config import config # noqa: E402
from app.utils.database import _local_config_search_roots, _resolve_db_pool_limits # noqa: E402
class DatabasePoolLimitTests(unittest.TestCase):
def _write_json(self, root: str, name: str, payload: dict) -> None:
Path(root, name).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
def test_explicit_total_budget_override_keeps_previous_pool_shape(self):
with tempfile.TemporaryDirectory() as temp_dir, patch.object(
config, "NODE_CODE", "mainland-controller-01-a"
), patch.dict(
os.environ,
{
"WORKER_PARENT_NODE_CODE": "mainland-controller-01",
"DOMAINCHECK_DB_POOL_TOTAL_BUDGET": "480",
"DB_POOL_SIZE": "",
"DB_POOL_WARM_SIZE": "",
"DB_POOL_IDLE_KEEP_MAX": "",
},
clear=False,
):
self._write_json(temp_dir, "thread_count.json", {"thread_count": "1000"})
self._write_json(temp_dir, "process_count.json", {"process_count": "80"})
self._write_json(temp_dir, "node_thread_counts.json", {"mainland-controller-01": 1200})
self._write_json(temp_dir, "node_process_counts.json", {"mainland-controller-01": 80})
previous_cwd = os.getcwd()
try:
os.chdir(temp_dir)
limits = _resolve_db_pool_limits()
finally:
os.chdir(previous_cwd)
self.assertEqual(6, limits["pool_size"])
self.assertEqual(1, limits["pool_warm_size"])
self.assertEqual(3, limits["pool_idle_keep_max"])
self.assertEqual(1200, limits["scaling_hints"]["thread_count"])
self.assertEqual(80, limits["scaling_hints"]["process_count"])
def test_pool_budget_scales_down_for_mid_sized_multi_process_workers(self):
with tempfile.TemporaryDirectory() as temp_dir, patch.object(
config, "NODE_CODE", "mainland-worker-01-a"
), patch.dict(
os.environ,
{
"WORKER_PARENT_NODE_CODE": "mainland-worker-01",
"DB_POOL_SIZE": "",
"DB_POOL_WARM_SIZE": "",
"DB_POOL_IDLE_KEEP_MAX": "",
"DOMAINCHECK_DB_POOL_TOTAL_BUDGET": "",
},
clear=False,
):
self._write_json(temp_dir, "thread_count.json", {"thread_count": "1000"})
self._write_json(temp_dir, "process_count.json", {"process_count": "60"})
self._write_json(temp_dir, "node_thread_counts.json", {"mainland-worker-01": 1200})
self._write_json(temp_dir, "node_process_counts.json", {"mainland-worker-01": 60})
previous_cwd = os.getcwd()
try:
os.chdir(temp_dir)
limits = _resolve_db_pool_limits()
finally:
os.chdir(previous_cwd)
self.assertEqual(4, limits["pool_size"])
self.assertEqual(1, limits["pool_warm_size"])
self.assertEqual(2, limits["pool_idle_keep_max"])
self.assertEqual(60, limits["scaling_hints"]["process_count"])
def test_explicit_pool_env_overrides_win_over_auto_scaling(self):
with tempfile.TemporaryDirectory() as temp_dir, patch.object(
config, "NODE_CODE", "mainland-controller-01"
), patch.dict(
os.environ,
{
"DB_POOL_SIZE": "16",
"DB_POOL_WARM_SIZE": "4",
"DB_POOL_IDLE_KEEP_MAX": "8",
},
clear=False,
):
self._write_json(temp_dir, "thread_count.json", {"thread_count": "1000"})
self._write_json(temp_dir, "process_count.json", {"process_count": "80"})
previous_cwd = os.getcwd()
try:
os.chdir(temp_dir)
limits = _resolve_db_pool_limits()
finally:
os.chdir(previous_cwd)
self.assertEqual(16, limits["pool_size"])
self.assertEqual(4, limits["pool_warm_size"])
self.assertEqual(8, limits["pool_idle_keep_max"])
def test_pool_limits_can_resolve_parent_process_override_from_absolute_config_root(self):
with tempfile.TemporaryDirectory() as temp_dir, patch.object(
config, "NODE_CODE", "mainland-controller-01-u"
), patch.dict(
os.environ,
{
"WORKER_PARENT_NODE_CODE": "mainland-controller-01",
"DB_POOL_SIZE": "",
"DB_POOL_WARM_SIZE": "",
"DB_POOL_IDLE_KEEP_MAX": "",
"DOMAINCHECK_DB_POOL_TOTAL_BUDGET": "",
},
clear=False,
):
self._write_json(temp_dir, "thread_count.json", {"thread_count": "1000"})
self._write_json(temp_dir, "process_count.json", {"process_count": "80"})
self._write_json(temp_dir, "node_thread_counts.json", {"mainland-controller-01": 1000})
self._write_json(temp_dir, "node_process_counts.json", {"mainland-controller-01": 80})
with patch(
"app.utils.database._local_config_search_roots",
return_value=[Path("/nonexistent/domainCheck"), Path(temp_dir)],
):
limits = _resolve_db_pool_limits()
self.assertEqual(80, limits["scaling_hints"]["process_count"])
self.assertEqual("mainland-controller-01", limits["scaling_hints"]["parent_node_code"])
self.assertEqual(9, limits["pool_size"])
def test_controller_high_process_pool_keeps_ten_connections_at_hundred_processes(self):
with tempfile.TemporaryDirectory() as temp_dir, patch.object(
config, "NODE_CODE", "mainland-controller-01-aa"
), patch.dict(
os.environ,
{
"WORKER_PARENT_NODE_CODE": "mainland-controller-01",
"DB_POOL_SIZE": "",
"DB_POOL_WARM_SIZE": "",
"DB_POOL_IDLE_KEEP_MAX": "",
"DOMAINCHECK_DB_POOL_TOTAL_BUDGET": "",
},
clear=False,
):
self._write_json(temp_dir, "thread_count.json", {"thread_count": "1000"})
self._write_json(temp_dir, "process_count.json", {"process_count": "100"})
self._write_json(temp_dir, "node_thread_counts.json", {"mainland-controller-01": 1000})
self._write_json(temp_dir, "node_process_counts.json", {"mainland-controller-01": 100})
previous_cwd = os.getcwd()
try:
os.chdir(temp_dir)
limits = _resolve_db_pool_limits()
finally:
os.chdir(previous_cwd)
self.assertEqual(100, limits["scaling_hints"]["process_count"])
self.assertEqual(10, limits["pool_size"])
self.assertEqual(2, limits["pool_warm_size"])
self.assertEqual(5, limits["pool_idle_keep_max"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,170 @@
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DOMAINCHECK_ROOT = PROJECT_ROOT / "domainCheck"
if str(DOMAINCHECK_ROOT) not in sys.path:
sys.path.insert(0, str(DOMAINCHECK_ROOT))
from app.core.detect_engine import DetectEngine
from app.detectors.rdap_detector import RDAPDetector
class DetectEngineOutcomeTests(unittest.TestCase):
def _build_engine(self):
patchers = [
patch("app.core.detect_engine.Database"),
patch("app.core.detect_engine.RDAPDetector"),
patch("app.core.detect_engine.WaybackDetector"),
patch("app.core.detect_engine.BaiduDetector"),
patch("app.core.detect_engine.Qihu360Detector"),
patch("app.core.detect_engine.GoogleDetector"),
patch("app.core.detect_engine.ChinazDetector"),
patch("app.core.detect_engine.AizhanDetector"),
patch("app.core.detect_engine.JuziseoDetector"),
patch("app.core.detect_engine.JuchaDetector"),
]
started = [patcher.start() for patcher in patchers]
self.addCleanup(lambda: [patcher.stop() for patcher in reversed(patchers)])
engine = DetectEngine()
for started_mock in started:
started_mock.return_value = MagicMock()
return engine
def test_process_task_blacklisted_is_completed_without_retry(self):
engine = self._build_engine()
engine.db.get_task_by_id.return_value = {"id": 7, "domain_id": 42, "retry_count": 1}
engine._detect_domain_with_outcome = MagicMock(return_value=engine.OUTCOME_BLACKLISTED)
success = engine.process_task(7)
self.assertTrue(success)
self.assertEqual(
[(7, 1), (7, 2)],
[call.args for call in engine.db.update_task_status.call_args_list],
)
engine.db.update_task_retry_count.assert_not_called()
def test_process_task_failed_requeues_for_retry(self):
engine = self._build_engine()
engine.db.get_task_by_id.return_value = {"id": 9, "domain_id": 99, "retry_count": 0}
engine._detect_domain_with_outcome = MagicMock(return_value=engine.OUTCOME_FAILED)
success = engine.process_task(9)
self.assertFalse(success)
self.assertEqual(
[(9, 1), (9, 0)],
[call.args for call in engine.db.update_task_status.call_args_list],
)
engine.db.update_task_retry_count.assert_called_once_with(9, 1)
def test_deep_detect_fails_when_any_detector_returns_error(self):
engine = self._build_engine()
engine.baidu_detector.check_history.return_value = {"error": "timeout"}
engine.baidu_detector.check_site.return_value = {"has_收录": False, "subdomains": []}
engine.qihu360_detector.check_site.return_value = {"has_收录": False, "subdomains": []}
engine.google_detector.check_site.return_value = {"has_收录": False}
engine.chinaz_detector.check_domain.return_value = {"title": "", "category": "", "has_sensitive": False}
engine.aizhan_detector.check_domain.return_value = {"title": "", "risk": "", "has_sensitive": False}
engine.juziseo_detector.check_domain.return_value = {"history": {}, "backlink": {}}
engine.jucha_detector.check_domain.return_value = {"whois": {}, "beian": {}, "intercept": {"normal": True}}
engine.db.add_detection_result.return_value = True
outcome = engine._deep_detect(1, "example.com")
self.assertEqual(engine.OUTCOME_FAILED, outcome)
engine.db.add_detection_result.assert_not_called()
def test_deep_detect_fails_when_result_persistence_fails(self):
engine = self._build_engine()
engine.baidu_detector.check_history.return_value = {"has_history": False, "has_gray": False}
engine.baidu_detector.check_site.return_value = {"has_收录": False, "subdomains": []}
engine.qihu360_detector.check_site.return_value = {"has_收录": False, "subdomains": []}
engine.google_detector.check_site.return_value = {"has_收录": False}
engine.chinaz_detector.check_domain.return_value = {"title": "", "category": "", "has_sensitive": False}
engine.aizhan_detector.check_domain.return_value = {"title": "", "risk": "", "has_sensitive": False}
engine.juziseo_detector.check_domain.return_value = {"history": {}, "backlink": {}}
engine.jucha_detector.check_domain.return_value = {"whois": {}, "beian": {}, "intercept": {"normal": True}}
engine.db.add_detection_result.return_value = False
outcome = engine._deep_detect(1, "example.com")
self.assertEqual(engine.OUTCOME_FAILED, outcome)
engine.db.add_detection_result.assert_called_once()
def test_deep_detect_normalizes_results_before_persisting(self):
engine = self._build_engine()
engine.baidu_detector.check_history.return_value = {"has_history": True, "has_gray": False}
engine.baidu_detector.check_site.return_value = {"has_收录": True, "subdomains": ["www"]}
engine.qihu360_detector.check_site.return_value = {"has_收录": False, "subdomains": []}
engine.google_detector.check_site.return_value = {"has_收录": False}
engine.chinaz_detector.check_domain.return_value = {"title": "Example", "category": "", "has_sensitive": False}
engine.aizhan_detector.check_domain.return_value = {"title": "Example", "risk": "", "has_sensitive": False}
engine.juziseo_detector.check_domain.return_value = {
"history": {"has_sensitive": False, "has_baidu_history": True, "has_subdomains": False, "is_simplified": True},
"backlink": {"has_sensitive": False, "has_subdomains": False},
}
engine.jucha_detector.check_domain.return_value = {
"whois": {"status": ""},
"beian": {"has_beian": True, "beian_year": "2024", "is_enterprise": True, "beian_match": True},
"intercept": {"normal": True},
}
engine.db.add_detection_result.return_value = True
outcome = engine._deep_detect(1, "example.com")
self.assertEqual(engine.OUTCOME_SUCCESS, outcome)
persisted_args = engine.db.add_detection_result.call_args.args
self.assertTrue(persisted_args[1]["status"])
self.assertTrue(persisted_args[1]["has_history"])
self.assertTrue(persisted_args[2]["status"])
self.assertTrue(persisted_args[2]["has_收录"])
self.assertIn("state", persisted_args[7]["history"])
self.assertTrue(persisted_args[8]["beian"]["status"])
def test_deep_detect_stops_early_after_blacklist_hit(self):
engine = self._build_engine()
engine.baidu_detector.check_history.return_value = {"has_history": True, "has_gray": True}
engine.baidu_detector.check_site.return_value = {"has_收录": False, "subdomains": []}
engine.db.add_detection_result.return_value = True
outcome = engine._deep_detect(1, "example.com")
self.assertEqual(engine.OUTCOME_BLACKLISTED, outcome)
engine.qihu360_detector.check_site.assert_not_called()
engine.google_detector.check_site.assert_not_called()
engine.db.add_detection_result.assert_called_once()
def test_deep_detect_fails_when_nested_detector_returns_error(self):
engine = self._build_engine()
engine.baidu_detector.check_history.return_value = {"has_history": False, "has_gray": False}
engine.baidu_detector.check_site.return_value = {"has_收录": False, "subdomains": []}
engine.qihu360_detector.check_site.return_value = {"has_收录": False, "subdomains": []}
engine.google_detector.check_site.return_value = {"has_收录": False}
engine.chinaz_detector.check_domain.return_value = {"title": "", "category": "", "has_sensitive": False}
engine.aizhan_detector.check_domain.return_value = {"title": "", "risk": "", "has_sensitive": False}
engine.juziseo_detector.check_domain.return_value = {
"history": {"error": "HTTP 429"},
"backlink": {"has_sensitive": False, "has_subdomains": False},
}
engine.jucha_detector.check_domain.return_value = {"whois": {}, "beian": {}, "intercept": {"normal": True}}
outcome = engine._deep_detect(1, "example.com")
self.assertEqual(engine.OUTCOME_FAILED, outcome)
engine.db.add_detection_result.assert_not_called()
class RDAPDetectorStatusMappingTests(unittest.TestCase):
def test_check_register_status_uses_statuses_field(self):
detector = RDAPDetector()
with patch.object(detector, "check_domain", return_value={"statuses": ["clientHold"]}):
status = detector.check_register_status("example.com")
self.assertEqual(7, status)
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
import sys
import unittest
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DOMAINCHECK_ROOT = PROJECT_ROOT / "domainCheck"
if str(DOMAINCHECK_ROOT) not in sys.path:
sys.path.insert(0, str(DOMAINCHECK_ROOT))
from app.utils.detection_results import (
build_manual_detection_result,
normalize_detector_result,
resolve_detection_status,
)
class DetectionResultSchemaTests(unittest.TestCase):
def test_resolve_detection_status_supports_legacy_key(self):
self.assertTrue(resolve_detection_status({"has_收录": True}, "has_收录"))
self.assertFalse(resolve_detection_status({"has_history": False}, "has_history"))
def test_manual_detection_result_preserves_legacy_key(self):
result = build_manual_detection_result(True, legacy_key="has_收录")
self.assertTrue(result["status"])
self.assertTrue(result["has_收录"])
self.assertEqual("manual", result["state"])
def test_normalize_jucha_info_bubbles_nested_error(self):
normalized = normalize_detector_result(
"jucha_info",
{
"whois": {"error": "HTTP 403"},
"beian": {"has_beian": False, "beian_year": "", "is_enterprise": False, "beian_match": False},
"intercept": {"normal": True},
},
)
self.assertEqual("HTTP 403", normalized["error"])
self.assertEqual("error", normalized["state"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,82 @@
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DOMAINCHECK_ROOT = PROJECT_ROOT / "domainCheck"
if str(DOMAINCHECK_ROOT) not in sys.path:
sys.path.insert(0, str(DOMAINCHECK_ROOT))
import requests
from domainCheck.detect import geetest2, jucha, juming, juziseo
from domainCheck.detect.locked_pickle import load_pickle_locked, save_pickle_atomic
class GeetestCookieSafetyTests(unittest.TestCase):
def test_geetest_slide_asset_fetch_applies_timeout(self):
response = MagicMock()
response.content = b"binary"
with patch("domainCheck.detect.geetest2.requests.get", return_value=response) as mock_get:
slider = geetest2.slide()
with patch.object(slider, "tp_huanyuan", return_value=b"bg-bytes"):
with patch("domainCheck.detect.geetest2.quekou") as mock_quekou:
mock_quekou.return_value.get_distance.return_value = 12
slider.huak({"bg": "bg.png", "slice": "slice.png"})
self.assertGreaterEqual(mock_get.call_count, 2)
for call in mock_get.call_args_list:
self.assertEqual(slider.asset_timeout, call.kwargs["timeout"])
def test_locked_pickle_roundtrip_is_atomic(self):
with tempfile.TemporaryDirectory() as temp_dir:
target = Path(temp_dir) / "cookies.pkl"
save_pickle_atomic(str(target), {"sid": "abc"})
loaded = load_pickle_locked(str(target), default_factory=dict)
self.assertEqual({"sid": "abc"}, loaded)
def test_juziseo_cookie_roundtrip_uses_locked_pickle(self):
with tempfile.TemporaryDirectory() as temp_dir:
target = Path(temp_dir) / "juziseo.pkl"
detector = juziseo.Juziseo()
jar = requests.cookies.RequestsCookieJar()
jar.set("sid", "value")
detector.cookie = jar
detector.save_cookies(str(target))
loaded = juziseo.Juziseo()
loaded.load_cookies(str(target))
self.assertEqual("value", loaded.cookie.get("sid"))
def test_jucha_and_juming_cookie_roundtrip_use_locked_pickle(self):
with tempfile.TemporaryDirectory() as temp_dir:
jucha_path = Path(temp_dir) / "jucha.pkl"
juming_path = Path(temp_dir) / "juming.pkl"
jc = jucha.JC()
jc_jar = requests.cookies.RequestsCookieJar()
jc_jar.set("jc", "cookie")
jc.cookie = jc_jar
jc.save_cookies(str(jucha_path))
jm = juming.JM()
jm_jar = requests.cookies.RequestsCookieJar()
jm_jar.set("jm", "cookie")
jm.cookie = jm_jar
jm.save_cookies(str(juming_path))
loaded_jc = jucha.JC()
loaded_jc.load_cookies(str(jucha_path))
loaded_jm = juming.JM()
loaded_jm.load_cookies(str(juming_path))
self.assertEqual("cookie", loaded_jc.cookie.get("jc"))
self.assertEqual("cookie", loaded_jm.cookie.get("jm"))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,60 @@
import unittest
import sys
from pathlib import Path
from unittest.mock import MagicMock
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DOMAINCHECK_ROOT = PROJECT_ROOT / "domainCheck"
if str(DOMAINCHECK_ROOT) not in sys.path:
sys.path.insert(0, str(DOMAINCHECK_ROOT))
from domainCheck.detect import jucha, juming, juziseo
class LegacyDetectorTimeoutTests(unittest.TestCase):
def test_juziseo_request_helper_applies_default_timeout(self):
client = juziseo.Juziseo()
client.session = MagicMock()
client._request("get", "https://example.com/api", headers={"x": "1"})
client.session.get.assert_called_once_with(
"https://example.com/api",
headers={"x": "1"},
timeout=client.request_timeout,
)
def test_jucha_request_helper_applies_default_timeout(self):
client = jucha.JC()
client.session = MagicMock()
client._request("post", "https://example.com/api", data={"a": 1})
client.session.post.assert_called_once_with(
"https://example.com/api",
data={"a": 1},
timeout=client.request_timeout,
)
def test_juming_request_helper_applies_default_timeout(self):
client = juming.JM()
client.session = MagicMock()
client._request("post", "https://example.com/api", json={"a": 1})
client.session.post.assert_called_once_with(
"https://example.com/api",
json={"a": 1},
timeout=client.request_timeout,
)
def test_request_helper_preserves_explicit_timeout_override(self):
client = juming.JM()
client.session = MagicMock()
client._request("get", "https://example.com/download", timeout=60)
client.session.get.assert_called_once_with(
"https://example.com/download",
timeout=60,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,39 @@
import unittest
from unittest.mock import patch
from app.utils.redis_client import get_redis_client, reset_redis_clients_for_tests
class DomainCheckRedisClientTests(unittest.TestCase):
def tearDown(self) -> None:
reset_redis_clients_for_tests()
@patch("app.utils.redis_client.redis.Redis")
@patch("app.utils.redis_client.redis.BlockingConnectionPool")
def test_standard_client_is_cached_per_process(self, mock_pool, mock_redis) -> None:
client = object()
mock_redis.return_value = client
first = get_redis_client()
second = get_redis_client()
self.assertIs(first, client)
self.assertIs(second, client)
mock_pool.assert_called_once()
mock_redis.assert_called_once()
@patch("app.utils.redis_client.redis.Redis")
@patch("app.utils.redis_client.redis.BlockingConnectionPool")
def test_pubsub_role_uses_separate_cached_client(self, mock_pool, mock_redis) -> None:
mock_redis.side_effect = [object(), object()]
standard_client = get_redis_client()
pubsub_client = get_redis_client(role="pubsub")
self.assertIsNot(standard_client, pubsub_client)
self.assertEqual(2, mock_pool.call_count)
self.assertEqual(2, mock_redis.call_count)
if __name__ == "__main__":
unittest.main()

View File

@@ -7,6 +7,9 @@ from domainCheck.detect import register
class RegisterTimeoutConfigTests(unittest.TestCase):
def tearDown(self):
register._PROXY_MANAGERS.clear()
def test_proxy_timeout_uses_bounded_total(self):
with patch.dict("os.environ", {}, clear=False):
timeout = register._resolve_register_timeout({"http": "http://127.0.0.1:8080"})
@@ -59,6 +62,19 @@ class RegisterTimeoutConfigTests(unittest.TestCase):
allow_redirects=True,
)
def test_proxy_manager_cache_evicts_oldest_entry(self):
with patch.dict("os.environ", {"DOMAINCHECK_REGISTER_PROXY_SESSION_CACHE_SIZE": "2"}, clear=False):
first = register._get_http_manager({"http": "http://127.0.0.1:8080"})
second = register._get_http_manager({"http": "http://127.0.0.1:8081"})
third = register._get_http_manager({"http": "http://127.0.0.1:8082"})
self.assertEqual(2, len(register._PROXY_MANAGERS))
self.assertIsNotNone(second)
self.assertIsNotNone(third)
recreated_first = register._get_http_manager({"http": "http://127.0.0.1:8080"})
self.assertIsNot(first, recreated_first)
if __name__ == "__main__":
unittest.main()

View File

@@ -5,6 +5,9 @@ from domainCheck.detect import aizhan, baidu, c360, chinaz
class StepTimeoutBudgetTests(unittest.TestCase):
def tearDown(self):
c360._PROXY_SESSIONS.clear()
def test_baidu_timeout_respects_remaining_budget(self):
with patch.dict("os.environ", {}, clear=False):
timeout = baidu._resolve_baidu_timeout({"http": "http://127.0.0.1:8080"}, budget_seconds=1.1)
@@ -17,6 +20,33 @@ class StepTimeoutBudgetTests(unittest.TestCase):
self.assertLessEqual(timeout, 0.9)
self.assertGreaterEqual(timeout, 0.6)
def test_360_direct_session_disables_env_proxy_and_is_reused(self):
first = c360._get_session()
second = c360._get_session()
self.assertIs(first, second)
self.assertFalse(first.trust_env)
def test_360_proxy_session_is_reused_per_proxy_url(self):
first = c360._get_session({"http": "http://127.0.0.1:8080"})
second = c360._get_session({"https": "http://127.0.0.1:8080"})
third = c360._get_session({"http": "http://127.0.0.1:8081"})
self.assertIs(first, second)
self.assertIsNot(first, third)
self.assertFalse(first.trust_env)
def test_360_proxy_session_cache_evicts_oldest_entry(self):
with patch.dict("os.environ", {"DOMAINCHECK_360_PROXY_SESSION_CACHE_SIZE": "2"}, clear=False):
first = c360._get_session({"http": "http://127.0.0.1:8080"})
second = c360._get_session({"http": "http://127.0.0.1:8081"})
third = c360._get_session({"http": "http://127.0.0.1:8082"})
self.assertEqual(2, len(c360._PROXY_SESSIONS))
self.assertIsNotNone(second)
self.assertIsNotNone(third)
recreated_first = c360._get_session({"http": "http://127.0.0.1:8080"})
self.assertIsNot(first, recreated_first)
def test_chinaz_timeout_respects_remaining_budget(self):
timeout = chinaz._resolve_chinaz_timeout({"http": "http://127.0.0.1:8080"}, budget_seconds=1.3)
self.assertLessEqual(timeout, 1.3)

View File

@@ -92,7 +92,47 @@ class WaybackDetectorRecentYearsTest(unittest.TestCase):
# 最新快照会先独立尝试一次,再进入裁剪后的扫描窗口。
self.assertEqual(4, result["fetched_snapshot_count"])
def test_scan_snapshots_fast_degrades_when_latest_cdx_is_transient_failure(self):
def test_scan_snapshots_fetches_recent_cdx_window_instead_of_full_history(self):
detector = WaybackDetector.__new__(WaybackDetector)
fetch_limits = []
def fake_fetch(domain, limit=None, fast_latest=False):
fetch_limits.append((limit, fast_latest))
if fast_latest:
return {
"records": [{"timestamp": "20260101000000", "digest": "latest"}],
"error": None,
}
return {
"records": [
{"timestamp": "20260101000000", "digest": "latest"},
{"timestamp": "20251201000000", "digest": "older-1"},
{"timestamp": "20251101000000", "digest": "older-2"},
{"timestamp": "20251001000000", "digest": "older-3"},
],
"error": None,
}
detector._fetch_cdx_records_with_meta = fake_fetch
detector._load_cached_records = lambda domain: None
detector._save_cached_records = lambda domain, records: None
detector._save_cached_timestamps = lambda domain, timestamps: None
detector._fetch_snapshot_title = lambda domain, timestamp: {
"timestamp": timestamp,
"title": f"title-{timestamp}",
"ok": True,
}
detector._normalize_title = lambda title: title
detector._find_sensitive_word = lambda title, words: None
detector._log_info = lambda message: None
detector._handle_exception = lambda exc, domain: None
with patch("app.detectors.wayback_detector.config.WAYBACK_MAX_RECORDS", 3):
detector.scan_snapshots("example.com", sensitive_words=[], recent_years=5)
self.assertEqual((-18, False), fetch_limits[1])
def test_scan_snapshots_continues_with_cached_records_when_latest_cdx_is_transient_failure(self):
detector = WaybackDetector.__new__(WaybackDetector)
detector._fetch_cdx_records_with_meta = lambda domain, limit=None, fast_latest=False: {
"records": [],
@@ -104,19 +144,55 @@ class WaybackDetectorRecentYearsTest(unittest.TestCase):
]
detector._save_cached_records = lambda domain, records: None
detector._save_cached_timestamps = lambda domain, timestamps: None
detector._fetch_snapshot_title = lambda domain, timestamp: {
"timestamp": timestamp,
"title": f"title-{timestamp}",
"ok": True,
}
detector._normalize_title = lambda title: title
detector._find_sensitive_word = lambda title, words: None
detector._log_info = lambda message: None
detector._handle_exception = lambda exc, domain: None
detector._trip_transient_backoff = lambda: self.fail("single latest_cdx timeout should not trigger global backoff")
result = detector.scan_snapshots("example.com", sensitive_words=[], recent_years=5)
self.assertEqual(2, result["checked_snapshot_count"])
self.assertEqual(2, result["fetched_snapshot_count"])
self.assertGreaterEqual(result["request_error_count"], 1)
def test_scan_snapshots_skips_records_cdx_when_latest_cdx_transient_fails_without_cache(self):
detector = WaybackDetector.__new__(WaybackDetector)
fetch_calls = []
def fake_fetch(domain, limit=None, fast_latest=False):
fetch_calls.append((limit, fast_latest))
return {
"records": [],
"error": "HTTPSConnectionPool(host='web.archive.org', port=443): Max retries exceeded",
}
detector._fetch_cdx_records_with_meta = fake_fetch
detector._load_cached_records = lambda domain: None
detector._save_cached_records = lambda domain, records: None
detector._save_cached_timestamps = lambda domain, timestamps: None
detector._fetch_snapshot_title = lambda domain, timestamp: self.fail("should not fetch snapshot titles")
detector._normalize_title = lambda title: title
detector._find_sensitive_word = lambda title, words: None
detector._log_info = lambda message: None
detector._handle_exception = lambda exc, domain: None
trip_calls = []
detector._trip_transient_backoff = lambda: trip_calls.append("trip")
result = detector.scan_snapshots("example.com", sensitive_words=[], recent_years=5)
self.assertEqual([(-1, True)], fetch_calls)
self.assertEqual(1, len(trip_calls))
self.assertEqual(0, result["checked_snapshot_count"])
self.assertGreaterEqual(result["failed_snapshot_count"], 1)
self.assertEqual(0, result["fetched_snapshot_count"])
self.assertGreaterEqual(result["request_error_count"], 1)
def test_scan_snapshots_fast_degrades_when_latest_snapshot_is_transient_failure(self):
def test_scan_snapshots_continues_when_latest_snapshot_is_transient_failure(self):
detector = WaybackDetector.__new__(WaybackDetector)
detector._fetch_cdx_records_with_meta = lambda domain, limit=None, fast_latest=False: {
"records": [{"timestamp": "20260101000000", "digest": "latest"}] if fast_latest else [
@@ -128,21 +204,31 @@ class WaybackDetectorRecentYearsTest(unittest.TestCase):
detector._load_cached_records = lambda domain: None
detector._save_cached_records = lambda domain, records: None
detector._save_cached_timestamps = lambda domain, timestamps: None
detector._fetch_snapshot_title = lambda domain, timestamp: {
"timestamp": timestamp,
"title": "",
"ok": False,
"error": "HTTPSConnectionPool(host='web.archive.org', port=443): Max retries exceeded",
}
def fake_fetch(domain, timestamp):
if timestamp == "20260101000000":
return {
"timestamp": timestamp,
"title": "",
"ok": False,
"error": "HTTPSConnectionPool(host='web.archive.org', port=443): Max retries exceeded",
}
return {
"timestamp": timestamp,
"title": "older-title",
"ok": True,
}
detector._fetch_snapshot_title = fake_fetch
detector._normalize_title = lambda title: title
detector._find_sensitive_word = lambda title, words: None
detector._log_info = lambda message: None
detector._handle_exception = lambda exc, domain: None
detector._trip_transient_backoff = lambda: self.fail("single latest_snapshot timeout should not trigger global backoff")
result = detector.scan_snapshots("example.com", sensitive_words=[], recent_years=5)
self.assertEqual(1, result["checked_snapshot_count"])
self.assertEqual(0, result["fetched_snapshot_count"])
self.assertEqual(2, result["checked_snapshot_count"])
self.assertEqual(1, result["fetched_snapshot_count"])
self.assertGreaterEqual(result["failed_snapshot_count"], 1)
self.assertTrue(
any("latest_snapshot:" in item for item in result["request_errors"])
@@ -168,6 +254,87 @@ class WaybackDetectorRecentYearsTest(unittest.TestCase):
self.assertEqual(1, result["request_error_count"])
self.assertTrue(any("wayback_backoff_active:" in item for item in result["request_errors"]))
def test_scan_snapshots_does_not_trip_global_backoff_on_single_snapshot_timeout(self):
detector = WaybackDetector.__new__(WaybackDetector)
detector._fetch_cdx_records_with_meta = lambda domain, limit=None, fast_latest=False: {
"records": [{"timestamp": "20260101000000", "digest": "latest"}] if fast_latest else [
{"timestamp": "20260101000000", "digest": "latest"},
{"timestamp": "20250101000000", "digest": "older"},
],
"error": None,
}
detector._load_cached_records = lambda domain: None
detector._save_cached_records = lambda domain, records: None
detector._save_cached_timestamps = lambda domain, timestamps: None
def fake_fetch(domain, timestamp):
if timestamp == "20260101000000":
return {"timestamp": timestamp, "title": "latest", "ok": True}
return {
"timestamp": timestamp,
"title": "",
"ok": False,
"error": "HTTPSConnectionPool(host='web.archive.org', port=443): Max retries exceeded",
}
detector._fetch_snapshot_title = fake_fetch
detector._normalize_title = lambda title: title
detector._find_sensitive_word = lambda title, words: None
detector._log_info = lambda message: None
detector._handle_exception = lambda exc, domain: None
detector._trip_transient_backoff = lambda: self.fail("single snapshot timeout should not trigger global backoff")
result = detector.scan_snapshots("example.com", sensitive_words=[], recent_years=5)
self.assertEqual(2, result["checked_snapshot_count"])
self.assertEqual(1, result["fetched_snapshot_count"])
self.assertEqual(1, result["failed_snapshot_count"])
def test_scan_snapshots_trips_global_backoff_after_threshold_transient_failures(self):
detector = WaybackDetector.__new__(WaybackDetector)
detector._fetch_cdx_records_with_meta = lambda domain, limit=None, fast_latest=False: {
"records": [{"timestamp": "20260101000000", "digest": "latest"}] if fast_latest else [
{"timestamp": "20260101000000", "digest": "latest"},
{"timestamp": "20250101000000", "digest": "older-1"},
{"timestamp": "20240101000000", "digest": "older-2"},
],
"error": None,
}
detector._load_cached_records = lambda domain: None
detector._save_cached_records = lambda domain, records: None
detector._save_cached_timestamps = lambda domain, timestamps: None
def fake_fetch(domain, timestamp):
if timestamp == "20260101000000":
return {"timestamp": timestamp, "title": "latest", "ok": True}
return {
"timestamp": timestamp,
"title": "",
"ok": False,
"error": "HTTPSConnectionPool(host='web.archive.org', port=443): Max retries exceeded",
}
detector._fetch_snapshot_title = fake_fetch
detector._normalize_title = lambda title: title
detector._find_sensitive_word = lambda title, words: None
detector._log_info = lambda message: None
detector._handle_exception = lambda exc, domain: None
trip_calls = []
detector._trip_transient_backoff = lambda: trip_calls.append("trip")
result = detector.scan_snapshots("example.com", sensitive_words=[], recent_years=5)
self.assertEqual(1, len(trip_calls))
self.assertEqual(3, result["checked_snapshot_count"])
self.assertEqual(2, result["failed_snapshot_count"])
def test_build_session_disables_env_proxy(self):
detector = WaybackDetector.__new__(WaybackDetector)
session = detector._build_session()
self.assertFalse(session.trust_env)
if __name__ == "__main__":
unittest.main()