feat: stabilize multi-region runtime sync and worker orchestration
This commit is contained in:
@@ -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}")
|
||||
# 更新任务状态为失败
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
194
domainCheck/app/utils/detection_results.py
Normal file
194
domainCheck/app/utils/detection_results.py
Normal 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"}
|
||||
}
|
||||
129
domainCheck/app/utils/redis_client.py
Normal file
129
domainCheck/app/utils/redis_client.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 # 不自动重定向
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
91
domainCheck/detect/locked_pickle.py
Normal file
91
domainCheck/detect/locked_pickle.py
Normal 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)
|
||||
@@ -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
4
domainCheck/node_process_counts.json
Normal file
4
domainCheck/node_process_counts.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"mainland-controller-01": 160,
|
||||
"mainland-worker-01": 1
|
||||
}
|
||||
3
domainCheck/process_count.json
Normal file
3
domainCheck/process_count.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"process_count": "160"
|
||||
}
|
||||
@@ -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__":
|
||||
|
||||
440
domainCheck/tests/test_database_connection_pool.py
Normal file
440
domainCheck/tests/test_database_connection_pool.py
Normal 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()
|
||||
200
domainCheck/tests/test_database_job_status_refresh.py
Normal file
200
domainCheck/tests/test_database_job_status_refresh.py
Normal 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()
|
||||
171
domainCheck/tests/test_database_pool_limits.py
Normal file
171
domainCheck/tests/test_database_pool_limits.py
Normal 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()
|
||||
170
domainCheck/tests/test_detect_engine_outcomes.py
Normal file
170
domainCheck/tests/test_detect_engine_outcomes.py
Normal 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
44
domainCheck/tests/test_detection_result_schema.py
Normal file
44
domainCheck/tests/test_detection_result_schema.py
Normal 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()
|
||||
82
domainCheck/tests/test_geetest_cookie_safety.py
Normal file
82
domainCheck/tests/test_geetest_cookie_safety.py
Normal 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()
|
||||
60
domainCheck/tests/test_legacy_detector_timeouts.py
Normal file
60
domainCheck/tests/test_legacy_detector_timeouts.py
Normal 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()
|
||||
39
domainCheck/tests/test_redis_client.py
Normal file
39
domainCheck/tests/test_redis_client.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user