Files
getDomain/domain-api/app/services/juming_service.py
Your Name ebf632e651 first
2026-04-16 21:35:47 +08:00

495 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import ast
import pickle
import re
import sys
from collections.abc import Callable
from datetime import date, datetime, timedelta
from pathlib import Path
from requests.cookies import RequestsCookieJar
from app.core.db import get_db
from app.core.redis_client import get_redis
from app.core.config import settings
from app.core.files import read_runtime_json, write_runtime_json
from app.services.import_worker_service import normalize_domain
DOMAIN_ROOT = Path(settings.domain_root)
if str(DOMAIN_ROOT) not in sys.path:
sys.path.insert(0, str(DOMAIN_ROOT))
from detect.juming import JM # type: ignore # noqa: E402
from detect.jucha import JC # type: ignore # noqa: E402
YKJ_DOMAIN_PATTERN = re.compile(r"<a class='yda1 ydz' ym='([^']*)'")
JUMING_COOKIE_FILE = DOMAIN_ROOT / "juming_cookies.pkl"
JUCHA_COOKIE_FILE = DOMAIN_ROOT / "jucha_cookies.pkl"
LEGACY_JUMING_COOKIE_FILES = [
Path.cwd() / "juming_cookies.pkl",
Path(__file__).resolve().parents[2] / "juming_cookies.pkl",
]
DELETE_LIST_SOURCE_TYPE = 2
FIXED_PRICE_SOURCE_TYPE = 1
JUMING_PREFERENCES_FILE = "juming_preferences.json"
class TaskStoppedError(RuntimeError):
pass
def _default_juming_preferences() -> dict:
return {
"mode": "delete_list",
"page_start": 1,
"page_size": 500,
"page_count": 1,
"crawl_date": date.today().isoformat(),
"auto_date": True,
}
def get_juming_preferences() -> dict:
defaults = _default_juming_preferences()
stored = read_runtime_json(JUMING_PREFERENCES_FILE, default={})
payload = {
"mode": str(stored.get("mode", defaults["mode"])) if stored else defaults["mode"],
"page_start": int(stored.get("page_start", defaults["page_start"])) if stored else defaults["page_start"],
"page_size": int(stored.get("page_size", defaults["page_size"])) if stored else defaults["page_size"],
"page_count": int(stored.get("page_count", defaults["page_count"])) if stored else defaults["page_count"],
"crawl_date": str(stored.get("crawl_date", defaults["crawl_date"])) if stored else defaults["crawl_date"],
"auto_date": bool(stored.get("auto_date", defaults["auto_date"])) if stored else defaults["auto_date"],
}
if payload["mode"] not in {"delete_list", "fixed_price"}:
payload["mode"] = defaults["mode"]
payload["page_start"] = max(payload["page_start"], 1)
payload["page_size"] = min(max(payload["page_size"], 1), 1000)
payload["page_count"] = min(max(payload["page_count"], 1), 20)
if not payload["crawl_date"]:
payload["crawl_date"] = defaults["crawl_date"]
return payload
def update_juming_preferences(payload: dict) -> dict:
current = get_juming_preferences()
next_payload = {
"mode": str(payload.get("mode", current["mode"]) or current["mode"]),
"page_start": int(payload.get("page_start", current["page_start"]) or current["page_start"]),
"page_size": int(payload.get("page_size", current["page_size"]) or current["page_size"]),
"page_count": int(payload.get("page_count", current["page_count"]) or current["page_count"]),
"crawl_date": str(payload.get("crawl_date", current["crawl_date"]) or current["crawl_date"]),
"auto_date": bool(payload.get("auto_date", current["auto_date"])),
}
if next_payload["mode"] not in {"delete_list", "fixed_price"}:
raise ValueError("无效的聚名采集类型")
next_payload["page_start"] = max(next_payload["page_start"], 1)
next_payload["page_size"] = min(max(next_payload["page_size"], 1), 1000)
next_payload["page_count"] = min(max(next_payload["page_count"], 1), 20)
write_runtime_json(JUMING_PREFERENCES_FILE, next_payload)
return next_payload
def _cookie_dict_to_jar(cookie_dict: dict[str, str]) -> RequestsCookieJar:
cookie_jar = RequestsCookieJar()
for name, value in cookie_dict.items():
cookie_jar.set(name, value)
return cookie_jar
def _emit_log(log: Callable[[str], None] | None, message: str) -> None:
if log:
log(message)
def _check_stop(should_stop: Callable[[], bool] | None) -> None:
if should_stop and should_stop():
raise TaskStoppedError("任务已停止")
def _cookie_jar_to_dict(cookie_jar: RequestsCookieJar) -> dict[str, str]:
return {str(cookie.name): str(cookie.value) for cookie in cookie_jar}
def _persist_juming_cookie(cookie_jar: RequestsCookieJar) -> None:
JUMING_COOKIE_FILE.parent.mkdir(parents=True, exist_ok=True)
with JUMING_COOKIE_FILE.open("wb") as handle:
pickle.dump(cookie_jar, handle)
try:
redis_client = get_redis()
redis_client.set("domain_tool:juming_cookies", str(_cookie_jar_to_dict(cookie_jar)))
except Exception:
pass
def _jucha_cookie_status() -> dict:
if JUCHA_COOKIE_FILE.exists():
return {
"cookie_ready": True,
"cookie_file": str(JUCHA_COOKIE_FILE),
}
return {
"cookie_ready": False,
"cookie_file": str(JUCHA_COOKIE_FILE),
}
def _load_juming_cookie() -> tuple[RequestsCookieJar | None, str]:
if JUMING_COOKIE_FILE.exists():
try:
with JUMING_COOKIE_FILE.open("rb") as handle:
loaded = pickle.load(handle)
if isinstance(loaded, RequestsCookieJar):
return loaded, "local"
if isinstance(loaded, dict):
return _cookie_dict_to_jar({str(k): str(v) for k, v in loaded.items()}), "local"
except Exception:
pass
for legacy_path in LEGACY_JUMING_COOKIE_FILES:
if not legacy_path.exists() or legacy_path == JUMING_COOKIE_FILE:
continue
try:
with legacy_path.open("rb") as handle:
loaded = pickle.load(handle)
if isinstance(loaded, RequestsCookieJar):
_persist_juming_cookie(loaded)
return loaded, f"migrated:{legacy_path}"
if isinstance(loaded, dict):
cookie_jar = _cookie_dict_to_jar({str(k): str(v) for k, v in loaded.items()})
_persist_juming_cookie(cookie_jar)
return cookie_jar, f"migrated:{legacy_path}"
except Exception:
continue
try:
redis_client = get_redis()
raw = redis_client.get("domain_tool:juming_cookies")
if raw:
parsed = ast.literal_eval(raw)
if isinstance(parsed, dict) and parsed:
cookie_jar = _cookie_dict_to_jar({str(k): str(v) for k, v in parsed.items()})
try:
with JUMING_COOKIE_FILE.open("wb") as handle:
pickle.dump(cookie_jar, handle)
except Exception:
pass
return cookie_jar, "redis"
except Exception:
pass
return None, "missing"
def get_juming_status() -> dict:
cookie_jar, storage = _load_juming_cookie()
status = {
"cookie_ready": cookie_jar is not None,
"cookie_storage": storage,
"cookie_file": str(JUMING_COOKIE_FILE),
"cookie_count": len(_cookie_jar_to_dict(cookie_jar)) if cookie_jar is not None else 0,
"jucha": _jucha_cookie_status(),
"supported_modes": [
{"label": "聚名一口价", "value": "fixed_price", "source_type": FIXED_PRICE_SOURCE_TYPE},
{"label": "聚名过期删除", "value": "delete_list", "source_type": DELETE_LIST_SOURCE_TYPE},
],
"defaults": get_juming_preferences(),
}
status["linked_jucha"] = {
"attempted": False,
"ok": bool(status["jucha"]["cookie_ready"]),
"message": "聚查登录态已就绪" if status["jucha"]["cookie_ready"] else "尚未检测到聚查 Cookie",
}
return status
def login_juming(email: str, password: str) -> dict:
account = str(email or "").strip()
secret = str(password or "").strip()
if not account or not secret:
raise ValueError("请输入聚名账号和密码")
jm = JM()
jm.load_cookies()
login_result = jm.user_zh_p_login(account, secret)
if not login_result[0]:
raise ValueError(f"聚名登录失败: {login_result[1]}")
jm.save_cookies()
_persist_juming_cookie(jm.cookie)
linked_jucha = {
"attempted": True,
"ok": False,
"message": "未执行",
}
try:
jc = JC()
jc.load_juming_cookies()
linked_ok, linked_message = jc.auth_login()
linked_jucha["ok"] = bool(linked_ok)
linked_jucha["message"] = str(linked_message)
if linked_ok:
jc.save_cookies()
except Exception as exc:
linked_jucha["message"] = f"聚查联名登录失败: {exc}"
status = get_juming_status()
status["linked_jucha"] = linked_jucha
return status
def login_jucha_with_juming_cookie() -> dict:
if not JUMING_COOKIE_FILE.exists():
raise ValueError("请先完成聚名登录,当前未检测到聚名 Cookie")
jc = JC()
jc.load_juming_cookies()
linked_ok, linked_message = jc.auth_login()
if not linked_ok:
raise ValueError(f"聚查登录失败: {linked_message}")
jc.save_cookies()
status = get_juming_status()
status["linked_jucha"] = {
"attempted": True,
"ok": True,
"message": str(linked_message),
}
return status
def upload_juming_cookie(filename: str, content: bytes) -> dict:
name = Path(filename or "juming_cookies.pkl").name.lower()
if not (name.endswith(".pkl") or name.endswith(".pickle") or name.endswith(".json") or name.endswith(".txt")):
raise ValueError("仅支持上传 .pkl / .pickle / .json / .txt 格式的聚名 Cookie 文件")
cookie_jar: RequestsCookieJar | None = None
parse_error: str | None = None
if name.endswith(".pkl") or name.endswith(".pickle"):
try:
loaded = pickle.loads(content)
if isinstance(loaded, RequestsCookieJar):
cookie_jar = loaded
elif isinstance(loaded, dict):
cookie_jar = _cookie_dict_to_jar({str(k): str(v) for k, v in loaded.items()})
except Exception as exc:
parse_error = str(exc)
else:
try:
text = content.decode("utf-8")
parsed = ast.literal_eval(text)
if isinstance(parsed, dict):
cookie_jar = _cookie_dict_to_jar({str(k): str(v) for k, v in parsed.items()})
except Exception as exc:
parse_error = str(exc)
if cookie_jar is None:
raise ValueError(f"聚名 Cookie 文件解析失败: {parse_error or '内容不符合预期'}")
if not _cookie_jar_to_dict(cookie_jar):
raise ValueError("聚名 Cookie 文件为空,未检测到有效 Cookie")
_persist_juming_cookie(cookie_jar)
return {
"cookie_ready": True,
"cookie_storage": "upload",
"cookie_file": str(JUMING_COOKIE_FILE),
"cookie_count": len(_cookie_jar_to_dict(cookie_jar)),
}
def _insert_domains(
domains: list[str],
source_type: int,
log: Callable[[str], None] | None = None,
should_stop: Callable[[], bool] | None = None,
) -> dict:
total = len(domains)
normalized_rows: list[tuple[str, str]] = []
invalid = 0
_emit_log(log, f"开始入库处理,共收到 {total} 个原始域名")
for value in domains:
_check_stop(should_stop)
normalized = normalize_domain(value)
if not normalized:
invalid += 1
continue
tld = normalized.rsplit(".", 1)[-1]
normalized_rows.append((normalized, tld))
existing_set: set[str] = set()
inserted = 0
with get_db() as conn:
with conn.cursor() as cur:
normalized_domains = [row[0] for row in normalized_rows]
if normalized_domains:
cur.execute("select domain from domains where domain = any(%s)", (normalized_domains,))
existing_set = {row[0] for row in cur.fetchall()}
if existing_set:
_emit_log(log, f"检测到 {len(existing_set)} 个已存在域名,将自动跳过")
inserted_since_commit = 0
for domain, tld in normalized_rows:
_check_stop(should_stop)
if domain in existing_set:
continue
cur.execute(
"""
insert into domains (
domain, tld, source_type, use_status, detect_status, register_status,
has_beian, company_type, website_url, beian_year, snapshot_years,
expire_date, create_time, update_time, review_status, detect_time,
backlink_count, jucha_status, juziseo_status
) values (
%s, %s, %s, 0, 0, 0,
1, null, null, null, null,
null, now(), now(), 0, null,
0, 0, 0
)
returning id
""",
(domain, tld, source_type),
)
domain_id = cur.fetchone()[0]
cur.execute(
"""
insert into detect_tasks (domain_id, task_type, status, priority, retry_count, create_time, update_time)
values (%s, 1, 1, 5, 0, now(), now())
""",
(domain_id,),
)
inserted += 1
inserted_since_commit += 1
if inserted_since_commit >= 500:
conn.commit()
inserted_since_commit = 0
conn.commit()
valid = len(normalized_rows)
exists = len(existing_set)
_emit_log(log, f"入库完成:有效 {valid},新增 {inserted},已存在 {exists},无效 {invalid}")
return {
"total": total,
"valid": valid,
"added": inserted,
"exists": exists,
"invalid": invalid,
"failed": max(valid - exists - inserted, 0),
}
def _crawl_fixed_price(
page_start: int,
page_size: int,
page_count: int,
log: Callable[[str], None] | None = None,
should_stop: Callable[[], bool] | None = None,
) -> tuple[list[str], list[dict[str, int]]]:
cookie_jar, _ = _load_juming_cookie()
jm = JM()
jm.cookie = cookie_jar or RequestsCookieJar()
domains: list[str] = []
pages: list[dict[str, int]] = []
current_page = page_start
_emit_log(log, f"开始采集一口价域名:起始页 {page_start},每页 {page_size},最多 {page_count}")
for _ in range(page_count):
_check_stop(should_stop)
_emit_log(log, f"正在抓取第 {current_page}")
success, html = jm.ykj_get_list(page=current_page, page_size=page_size)
if not success:
raise RuntimeError(str(html))
page_domains = [item.strip() for item in YKJ_DOMAIN_PATTERN.findall(html) if item.strip()]
domains.extend(page_domains)
pages.append({"page": current_page, "count": len(page_domains)})
_emit_log(log, f"{current_page} 页抓取到 {len(page_domains)} 个域名,累计 {len(domains)}")
if len(page_domains) < page_size:
_emit_log(log, "当前页返回数量小于分页数量,判定已到末页,停止继续抓取")
break
current_page += 1
return domains, pages
def _crawl_delete_list(
crawl_date: str,
auto_date: bool,
log: Callable[[str], None] | None = None,
should_stop: Callable[[], bool] | None = None,
) -> tuple[list[str], list[dict[str, int]]]:
cookie_jar, _ = _load_juming_cookie()
jm = JM()
jm.cookie = cookie_jar or RequestsCookieJar()
start_date = datetime.strptime(crawl_date, "%Y-%m-%d").date()
end_date = date.today() + timedelta(days=4)
current_date = start_date
domains: list[str] = []
dates: list[dict[str, int]] = []
_emit_log(log, f"开始采集删除列表:起始日期 {crawl_date},自动追加日期 {'开启' if auto_date else '关闭'}")
while current_date <= end_date:
_check_stop(should_stop)
_emit_log(log, f"正在抓取 {current_date.isoformat()} 的删除列表")
domains_for_date = [item.strip() for item in jm.new_cha_del(current_date.isoformat()) if item.strip()]
domains.extend(domains_for_date)
dates.append({"date": current_date.isoformat(), "count": len(domains_for_date)})
_emit_log(log, f"{current_date.isoformat()} 抓取到 {len(domains_for_date)} 个域名,累计 {len(domains)}")
if not auto_date:
break
current_date += timedelta(days=1)
return domains, dates
def crawl_juming(
payload: dict,
log: Callable[[str], None] | None = None,
should_stop: Callable[[], bool] | None = None,
) -> dict:
mode = str(payload.get("mode") or "delete_list").strip()
if mode not in {"fixed_price", "delete_list"}:
raise ValueError("仅支持 fixed_price 或 delete_list")
cookie_jar, storage = _load_juming_cookie()
if cookie_jar is None:
raise ValueError("未找到聚名 Cookie请先在桌面版系统设置完成聚名登录或将 Cookie 同步到服务器")
_emit_log(log, f"检测到聚名登录态,来源:{storage}")
_check_stop(should_stop)
if mode == "fixed_price":
page_start = max(int(payload.get("page_start") or 1), 1)
page_size = min(max(int(payload.get("page_size") or 500), 1), 1000)
page_count = min(max(int(payload.get("page_count") or 1), 1), 20)
domains, pages = _crawl_fixed_price(page_start, page_size, page_count, log=log, should_stop=should_stop)
stats = _insert_domains(domains, FIXED_PRICE_SOURCE_TYPE, log=log, should_stop=should_stop)
return {
"mode": mode,
"cookie_storage": storage,
"pages": pages,
"domains_found": len(domains),
"stats": stats,
"sample_domains": domains[:20],
}
crawl_date = str(payload.get("crawl_date") or date.today().isoformat())
auto_date = bool(payload.get("auto_date", True))
domains, dates = _crawl_delete_list(crawl_date, auto_date, log=log, should_stop=should_stop)
stats = _insert_domains(domains, DELETE_LIST_SOURCE_TYPE, log=log, should_stop=should_stop)
return {
"mode": mode,
"cookie_storage": storage,
"dates": dates,
"domains_found": len(domains),
"stats": stats,
"sample_domains": domains[:20],
}