fix(collect): handle login target close race
Tests / Python 3.11 / Windows (push) Has been cancelled
Tests / Python 3.11 / Windows (push) Has been cancelled
This commit is contained in:
@@ -327,6 +327,10 @@ def login_status_text(status) -> str:
|
||||
return "未登录"
|
||||
if reason == "NO_SESSION_COOKIE":
|
||||
return "未登录"
|
||||
if reason == "LOGIN_CHECK_TARGET_UNAVAILABLE" or str(reason).startswith(
|
||||
"LOGIN_CHECK_FAILED"
|
||||
):
|
||||
return "登录状态暂不可确认"
|
||||
if reason:
|
||||
return f"未登录({reason})"
|
||||
return "未登录"
|
||||
|
||||
+41
-2
@@ -22,12 +22,12 @@ def _base(host=None):
|
||||
return f"http://{host or CDP_HOST}"
|
||||
|
||||
|
||||
def http_get(path, host=None):
|
||||
def http_get(path, host=None, timeout=10):
|
||||
import requests
|
||||
|
||||
s = requests.Session()
|
||||
s.trust_env = False # 忽略环境代理
|
||||
return s.get(f"{_base(host)}{path}", timeout=10).json()
|
||||
return s.get(f"{_base(host)}{path}", timeout=timeout).json()
|
||||
|
||||
|
||||
def close_tab(target_id, host=None):
|
||||
@@ -43,6 +43,45 @@ def close_tab(target_id, host=None):
|
||||
return response.ok
|
||||
|
||||
|
||||
def wait_target_closed(target_id, host=None, timeout=2.0, poll_interval=0.1):
|
||||
"""Wait briefly for a closed target to disappear from Chrome's target list."""
|
||||
|
||||
if not target_id:
|
||||
return True
|
||||
timeout = max(0.0, float(timeout))
|
||||
poll_interval = max(0.01, float(poll_interval))
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
remaining = max(0.0, deadline - time.monotonic())
|
||||
request_timeout = max(0.05, min(1.0, remaining))
|
||||
try:
|
||||
targets = http_get("/json", host=host, timeout=request_timeout)
|
||||
except Exception:
|
||||
targets = None
|
||||
if targets is not None and not any(
|
||||
str(target.get("id") or "") == str(target_id)
|
||||
for target in targets
|
||||
):
|
||||
return True
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return False
|
||||
time.sleep(min(poll_interval, remaining))
|
||||
|
||||
|
||||
def close_tab_and_wait(target_id, host=None, timeout=2.0, poll_interval=0.1):
|
||||
"""Request target closure and confirm disappearance within a bounded budget."""
|
||||
|
||||
if not close_tab(target_id, host=host):
|
||||
return False
|
||||
return wait_target_closed(
|
||||
target_id,
|
||||
host=host,
|
||||
timeout=timeout,
|
||||
poll_interval=poll_interval,
|
||||
)
|
||||
|
||||
|
||||
def activate_tab(target_id, host=None):
|
||||
"""Bring an existing Chrome target/page to the foreground."""
|
||||
|
||||
|
||||
+215
-60
@@ -6,7 +6,15 @@ import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from . import appconfig, image_paths
|
||||
from .cdp import CDP, close_tab, create_tab, create_tab_info, find_product_tab, http_get
|
||||
from .cdp import (
|
||||
CDP,
|
||||
close_tab,
|
||||
close_tab_and_wait,
|
||||
create_tab,
|
||||
create_tab_info,
|
||||
find_product_tab,
|
||||
http_get,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_REGION_HOST = "seller.shopee.tw"
|
||||
@@ -669,71 +677,206 @@ def _current_url(cdp):
|
||||
return ""
|
||||
|
||||
|
||||
def _wait_for_login_probe(cdp, timeout=8):
|
||||
end = time.time() + timeout
|
||||
last_url = ""
|
||||
def _wait_for_login_probe(cdp, timeout=8, initial_url=""):
|
||||
deadline = time.monotonic() + max(0.0, float(timeout))
|
||||
last_url = str(initial_url or "")
|
||||
cookie_names = set()
|
||||
while time.time() < end:
|
||||
last_url = _current_url(cdp) or last_url
|
||||
cookie_read_succeeded = False
|
||||
probe_error = None
|
||||
first_probe = True
|
||||
while first_probe or time.monotonic() < deadline:
|
||||
first_probe = False
|
||||
try:
|
||||
last_url = cdp.val("location.href") or last_url
|
||||
except Exception as exc:
|
||||
return {
|
||||
"url": last_url,
|
||||
"cookie_names": cookie_names,
|
||||
"cookie_read_succeeded": cookie_read_succeeded,
|
||||
"target_available": False,
|
||||
"probe_error": exc.__class__.__name__,
|
||||
}
|
||||
if _is_login_url(last_url):
|
||||
return {
|
||||
"url": last_url,
|
||||
"cookie_names": cookie_names,
|
||||
"cookie_read_succeeded": cookie_read_succeeded,
|
||||
"target_available": True,
|
||||
"probe_error": probe_error,
|
||||
}
|
||||
try:
|
||||
cookies = cdp.send("Network.getAllCookies").get("cookies", [])
|
||||
cookie_names = {
|
||||
c.get("name")
|
||||
for c in cookies
|
||||
if "shopee" in (c.get("domain") or "")
|
||||
except Exception as exc:
|
||||
return {
|
||||
"url": last_url,
|
||||
"cookie_names": cookie_names,
|
||||
"cookie_read_succeeded": cookie_read_succeeded,
|
||||
"target_available": False,
|
||||
"probe_error": exc.__class__.__name__,
|
||||
}
|
||||
except Exception:
|
||||
cookie_names = set()
|
||||
if _is_login_url(last_url) or "SPC_ST" in cookie_names or "SPC_U" in cookie_names:
|
||||
cookie_read_succeeded = True
|
||||
cookie_names = {
|
||||
cookie.get("name")
|
||||
for cookie in cookies
|
||||
if "shopee" in (cookie.get("domain") or "")
|
||||
}
|
||||
if "SPC_ST" in cookie_names or "SPC_U" in cookie_names:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
return last_url, cookie_names
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
time.sleep(min(0.5, remaining))
|
||||
return {
|
||||
"url": last_url,
|
||||
"cookie_names": cookie_names,
|
||||
"cookie_read_succeeded": cookie_read_succeeded,
|
||||
"target_available": cookie_read_succeeded,
|
||||
"probe_error": probe_error,
|
||||
}
|
||||
|
||||
|
||||
def _login_target_priority(target):
|
||||
url = str((target or {}).get("url") or "").lower()
|
||||
if _is_login_url(url):
|
||||
return 3
|
||||
if "/portal/product/" in url:
|
||||
return 2
|
||||
if "/portal/" in url:
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
def _login_target_key(target):
|
||||
target = target or {}
|
||||
return str(target.get("webSocketDebuggerUrl") or target.get("id") or target.get("url") or "")
|
||||
|
||||
|
||||
def _shopee_page_targets(host):
|
||||
pages = [
|
||||
target
|
||||
for target in http_get("/json", host=host)
|
||||
if target.get("type") == "page"
|
||||
and "shopee" in str(target.get("url") or "").lower()
|
||||
and target.get("webSocketDebuggerUrl")
|
||||
]
|
||||
return sorted(pages, key=_login_target_priority)
|
||||
|
||||
|
||||
def login_status(account, timeout=8) -> dict:
|
||||
"""Return detailed Shopee login status for an account's CDP session."""
|
||||
|
||||
host = _cdp_host(account)
|
||||
pages = [t for t in http_get("/json", host=host) if t.get("type") == "page"]
|
||||
shopee_page = next((p for p in pages if "shopee" in (p.get("url") or "")), None)
|
||||
if not shopee_page:
|
||||
ws = create_tab(_seller_home_url(account), host=host)
|
||||
initial_url = _seller_home_url(account)
|
||||
cdp = CDP(ws)
|
||||
else:
|
||||
initial_url = shopee_page.get("url") or ""
|
||||
if _is_login_url(initial_url):
|
||||
deadline = time.monotonic() + max(0.0, float(timeout))
|
||||
attempted_targets = set()
|
||||
opened_home = False
|
||||
probe_attempts = 0
|
||||
last_probe = {
|
||||
"url": "",
|
||||
"cookie_names": set(),
|
||||
"cookie_read_succeeded": False,
|
||||
"target_available": False,
|
||||
"probe_error": None,
|
||||
}
|
||||
while True:
|
||||
candidates = [
|
||||
target
|
||||
for target in _shopee_page_targets(host)
|
||||
if _login_target_key(target) not in attempted_targets
|
||||
]
|
||||
if not candidates and not opened_home:
|
||||
home_url = _seller_home_url(account)
|
||||
ws = create_tab(home_url, host=host)
|
||||
candidates = [
|
||||
{
|
||||
"type": "page",
|
||||
"url": home_url,
|
||||
"webSocketDebuggerUrl": ws,
|
||||
}
|
||||
]
|
||||
opened_home = True
|
||||
if not candidates:
|
||||
break
|
||||
for target in candidates:
|
||||
key = _login_target_key(target)
|
||||
if key:
|
||||
attempted_targets.add(key)
|
||||
probe_attempts += 1
|
||||
initial_url = str(target.get("url") or "")
|
||||
if _is_login_url(initial_url):
|
||||
return {
|
||||
"logged_in": False,
|
||||
"reason": "LOGIN_PAGE",
|
||||
"url": initial_url,
|
||||
"host": host,
|
||||
"cookie_names": [],
|
||||
"cookie_read_succeeded": False,
|
||||
"probe_error": None,
|
||||
"probe_attempts": probe_attempts,
|
||||
}
|
||||
cdp = None
|
||||
try:
|
||||
cdp = CDP(target["webSocketDebuggerUrl"])
|
||||
_ensure_page_domains(cdp)
|
||||
remaining = max(0.0, deadline - time.monotonic())
|
||||
probe = _wait_for_login_probe(
|
||||
cdp,
|
||||
timeout=remaining,
|
||||
initial_url=initial_url,
|
||||
)
|
||||
except Exception as exc:
|
||||
probe = {
|
||||
"url": initial_url,
|
||||
"cookie_names": set(),
|
||||
"cookie_read_succeeded": False,
|
||||
"target_available": False,
|
||||
"probe_error": exc.__class__.__name__,
|
||||
}
|
||||
finally:
|
||||
if cdp is not None:
|
||||
cdp.close()
|
||||
last_probe = probe
|
||||
url = str(probe.get("url") or initial_url)
|
||||
names = set(probe.get("cookie_names") or [])
|
||||
if _is_login_url(url):
|
||||
return {
|
||||
"logged_in": False,
|
||||
"reason": "LOGIN_PAGE",
|
||||
"url": url,
|
||||
"host": host,
|
||||
"cookie_names": sorted(name for name in names if name),
|
||||
"cookie_read_succeeded": bool(probe.get("cookie_read_succeeded")),
|
||||
"probe_error": probe.get("probe_error"),
|
||||
"probe_attempts": probe_attempts,
|
||||
}
|
||||
if not probe.get("cookie_read_succeeded") or not probe.get(
|
||||
"target_available"
|
||||
):
|
||||
continue
|
||||
logged_in = "SPC_ST" in names or "SPC_U" in names
|
||||
return {
|
||||
"logged_in": False,
|
||||
"reason": "LOGIN_PAGE",
|
||||
"url": initial_url,
|
||||
"logged_in": logged_in,
|
||||
"reason": None if logged_in else "NO_SESSION_COOKIE",
|
||||
"url": url,
|
||||
"host": host,
|
||||
"cookie_names": [],
|
||||
"cookie_names": sorted(name for name in names if name),
|
||||
"cookie_read_succeeded": True,
|
||||
"probe_error": probe.get("probe_error"),
|
||||
"probe_attempts": probe_attempts,
|
||||
}
|
||||
cdp = CDP(shopee_page["webSocketDebuggerUrl"])
|
||||
|
||||
try:
|
||||
_ensure_page_domains(cdp)
|
||||
url, names = _wait_for_login_probe(cdp, timeout=timeout)
|
||||
url = url or initial_url
|
||||
if _is_login_url(url):
|
||||
reason = "LOGIN_PAGE"
|
||||
logged_in = False
|
||||
elif "SPC_ST" in names or "SPC_U" in names:
|
||||
reason = None
|
||||
logged_in = True
|
||||
else:
|
||||
reason = "NO_SESSION_COOKIE"
|
||||
logged_in = False
|
||||
return {
|
||||
"logged_in": logged_in,
|
||||
"reason": reason,
|
||||
"url": url,
|
||||
"host": host,
|
||||
"cookie_names": sorted(name for name in names if name),
|
||||
}
|
||||
finally:
|
||||
cdp.close()
|
||||
if time.monotonic() >= deadline:
|
||||
break
|
||||
return {
|
||||
"logged_in": False,
|
||||
"reason": "LOGIN_CHECK_TARGET_UNAVAILABLE",
|
||||
"url": str(last_probe.get("url") or ""),
|
||||
"host": host,
|
||||
"cookie_names": sorted(
|
||||
name for name in (last_probe.get("cookie_names") or []) if name
|
||||
),
|
||||
"cookie_read_succeeded": False,
|
||||
"probe_error": last_probe.get("probe_error"),
|
||||
"probe_attempts": probe_attempts,
|
||||
}
|
||||
|
||||
|
||||
def is_logged_in(account) -> bool:
|
||||
@@ -742,7 +885,7 @@ def is_logged_in(account) -> bool:
|
||||
return bool(login_status(account).get("logged_in"))
|
||||
|
||||
|
||||
def _close_open_product_failure(cdp):
|
||||
def _close_open_product_failure(cdp, confirm_target_closed=False):
|
||||
target_id = getattr(cdp, "target_id", None)
|
||||
created_by_app = bool(getattr(cdp, "created_by_app", False))
|
||||
host = getattr(cdp, "cdp_host", None)
|
||||
@@ -751,7 +894,10 @@ def _close_open_product_failure(cdp):
|
||||
finally:
|
||||
if created_by_app and target_id:
|
||||
try:
|
||||
close_tab(target_id, host=host)
|
||||
if confirm_target_closed:
|
||||
close_tab_and_wait(target_id, host=host, timeout=2.0)
|
||||
else:
|
||||
close_tab(target_id, host=host)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -786,7 +932,7 @@ def open_product(account, item_id, on_step=None, bring_to_front=True) -> CDP:
|
||||
_wait_ready(cdp)
|
||||
return cdp
|
||||
except Exception:
|
||||
_close_open_product_failure(cdp)
|
||||
_close_open_product_failure(cdp, confirm_target_closed=not bring_to_front)
|
||||
raise
|
||||
|
||||
|
||||
@@ -847,6 +993,7 @@ def collect(account, task, on_step=None) -> dict:
|
||||
|
||||
item_id = _item_id(task)
|
||||
cdp = open_product(account, item_id, on_step=on_step, bring_to_front=False)
|
||||
result = None
|
||||
try:
|
||||
_notify_collect_step(on_step, "read_title")
|
||||
old_title = read_title(cdp)
|
||||
@@ -862,13 +1009,15 @@ def collect(account, task, on_step=None) -> dict:
|
||||
)
|
||||
_notify_collect_step(on_step, "download_cover")
|
||||
old_cover_path = download_cover(old_cover_src, out_path)
|
||||
return {
|
||||
result = {
|
||||
"old_title": old_title,
|
||||
"old_cover_src": old_cover_src,
|
||||
"old_cover_path": old_cover_path,
|
||||
}
|
||||
finally:
|
||||
_close_collected_product(cdp)
|
||||
close_target_confirmed = _close_collected_product(cdp)
|
||||
result["close_target_confirmed"] = close_target_confirmed
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -897,20 +1046,26 @@ def _close_collected_product(cdp):
|
||||
target_id = getattr(cdp, "target_id", None)
|
||||
created_by_app = bool(getattr(cdp, "created_by_app", False))
|
||||
host = getattr(cdp, "cdp_host", None)
|
||||
close_target_confirmed = None
|
||||
try:
|
||||
cdp.close()
|
||||
finally:
|
||||
if created_by_app and target_id:
|
||||
try:
|
||||
close_tab(target_id, host=host)
|
||||
close_target_confirmed = close_tab_and_wait(
|
||||
target_id,
|
||||
host=host,
|
||||
timeout=2.0,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
close_target_confirmed = False
|
||||
return close_target_confirmed
|
||||
|
||||
|
||||
def close_readonly_product(cdp):
|
||||
"""Close a read-only product CDP session using the collection tab cleanup rules."""
|
||||
|
||||
_close_collected_product(cdp)
|
||||
return _close_collected_product(cdp)
|
||||
|
||||
|
||||
def change_title(cdp, new_title) -> dict:
|
||||
|
||||
+58
-4
@@ -1997,6 +1997,25 @@ class CollectWorker(BaseWorker):
|
||||
},
|
||||
on_step=on_step,
|
||||
)
|
||||
if result.get("close_target_confirmed") is False:
|
||||
self._log_run_event(
|
||||
"step=close_product result=uncertain detail=任务 {task_id} 商品 {item_id} 商品页已请求关闭,但未在短时间内确认关闭;采集结果已保留,继续处理后续任务".format(
|
||||
task_id=task.id,
|
||||
item_id=task.item_id,
|
||||
),
|
||||
task=task,
|
||||
level="warning",
|
||||
)
|
||||
self._write_diagnostic_log(
|
||||
"采集商品页关闭确认超时",
|
||||
level="WARNING",
|
||||
step="close_product",
|
||||
task=task,
|
||||
payload={
|
||||
"alias": getattr(account, "alias", None),
|
||||
"close_target_confirmed": False,
|
||||
},
|
||||
)
|
||||
current_step = "db_write"
|
||||
self._emit_activity(
|
||||
"task_step",
|
||||
@@ -2250,17 +2269,31 @@ class CollectWorker(BaseWorker):
|
||||
}
|
||||
|
||||
def _confirmed_login_status(self, account, context, task=None):
|
||||
started = time.monotonic()
|
||||
subject = self._login_check_subject(account, task)
|
||||
last_status = {}
|
||||
for attempt in range(1, self.LOGIN_CHECK_ATTEMPTS + 1):
|
||||
status = dict(self._login_status(account) or {})
|
||||
status["login_check_attempts"] = attempt
|
||||
last_status = status
|
||||
if status.get("logged_in") or self._is_definitive_logged_out(status):
|
||||
if status.get("logged_in"):
|
||||
if attempt > 1:
|
||||
self._log_run_event(
|
||||
"step=login_check result=recovered detail={subject} 登录检测已恢复,第{attempt}/{total}次确认已登录 elapsed_ms={elapsed_ms}".format(
|
||||
subject=subject,
|
||||
attempt=attempt,
|
||||
total=self.LOGIN_CHECK_ATTEMPTS,
|
||||
elapsed_ms=self._elapsed_ms(started),
|
||||
),
|
||||
task=task,
|
||||
)
|
||||
return status
|
||||
if self._is_definitive_logged_out(status):
|
||||
return status
|
||||
if attempt < self.LOGIN_CHECK_ATTEMPTS:
|
||||
self._log_run_event(
|
||||
"step=login_check result=retry detail=账号 {alias} 第{attempt}/{total}次登录检测暂不确定,{delay:g}秒后重试: {detail}".format(
|
||||
alias=getattr(account, "alias", ""),
|
||||
"step=login_check result=retry detail={subject} 登录状态暂时无法读取,第{attempt}/{total}次检测后将在{delay:g}秒后重试:{detail}".format(
|
||||
subject=subject,
|
||||
attempt=attempt,
|
||||
total=self.LOGIN_CHECK_ATTEMPTS,
|
||||
delay=self.LOGIN_CHECK_RETRY_DELAY_SECONDS,
|
||||
@@ -2282,11 +2315,22 @@ class CollectWorker(BaseWorker):
|
||||
"reason": last_status.get("reason"),
|
||||
"url": last_status.get("url"),
|
||||
"cookie_names": list(last_status.get("cookie_names") or []),
|
||||
"cookie_read_succeeded": bool(
|
||||
last_status.get("cookie_read_succeeded")
|
||||
),
|
||||
"probe_error": last_status.get("probe_error"),
|
||||
"probe_attempts": last_status.get("probe_attempts"),
|
||||
"attempts": last_status.get("login_check_attempts"),
|
||||
},
|
||||
)
|
||||
return last_status
|
||||
|
||||
def _login_check_subject(self, account, task=None):
|
||||
alias = str(getattr(account, "alias", "") or "未知账号")
|
||||
if task is None:
|
||||
return f"账号 {alias}"
|
||||
return f"任务 {task.id} 商品 {task.item_id} 账号 {alias}"
|
||||
|
||||
def _is_definitive_logged_out(self, status):
|
||||
reason = str((status or {}).get("reason") or "").strip()
|
||||
url = str((status or {}).get("url") or "").lower()
|
||||
@@ -2296,7 +2340,17 @@ class CollectWorker(BaseWorker):
|
||||
|
||||
def _login_status_detail(self, status):
|
||||
status = status or {}
|
||||
reason = status.get("reason") or "未知原因"
|
||||
raw_reason = str(status.get("reason") or "").strip()
|
||||
if raw_reason == "LOGIN_CHECK_TARGET_UNAVAILABLE":
|
||||
reason = "CDP页面暂时不可用"
|
||||
elif raw_reason.startswith("LOGIN_CHECK_FAILED"):
|
||||
reason = "登录检测调用失败"
|
||||
elif raw_reason == "NO_SESSION_COOKIE":
|
||||
reason = "暂未读取到登录会话"
|
||||
elif raw_reason == "LOGIN_PAGE":
|
||||
reason = "检测到登录页面"
|
||||
else:
|
||||
reason = raw_reason or "未知原因"
|
||||
url = status.get("url") or "未知URL"
|
||||
cookie_names = [str(name) for name in (status.get("cookie_names") or []) if name]
|
||||
cookie_text = ",".join(sorted(cookie_names)) if cookie_names else "未读到登录Cookie"
|
||||
|
||||
@@ -393,10 +393,10 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
|
||||
### 6.1 采集(① Tab,只读)
|
||||
|
||||
- 用账号 Chrome 打开商品页,等就绪,读旧标题(标题输入框 value)。
|
||||
- `open_product` 先复用已打开的同商品 tab;没有才新建商品编辑页 tab。采集完成后只关闭本次程序自动新建的商品 tab,不关闭用户原本已经打开的 tab。`CDP.close()` 只断开 WebSocket 控制连接,不等于关闭浏览器 tab。
|
||||
- `open_product` 先复用已打开的同商品 tab;没有才新建商品编辑页 tab。采集完成后只关闭本次程序自动新建的商品 tab,并在最多 2 秒内轮询 `/json` 确认该 target ID 已消失;确认超时只记警告并保留采集成功结果。用户原本已经打开的 tab 不关闭、不等待。`CDP.close()` 只断开 WebSocket 控制连接,不等于关闭浏览器 tab。
|
||||
- ①采集调用 `open_product(..., bring_to_front=False)`,不主动执行 `Page.bringToFront`;新建商品 tab 时尝试 `Target.createTarget(background=true)` 降低 Chrome 抢焦点概率,若当前 Chrome/CDP 不接受该参数则退回普通新建 tab。③更新是上传、拖拽和线上提交流程,每条任务均以前台方式打开或激活当前商品 tab,优先保障页面交互稳定;后台态失败安全恢复逻辑仅为兼容直接调用保留,正常③批量路径不依赖它。
|
||||
- 采集前和采集中途的登录检测必须区分“明确未登录”和“暂时不确定”。明确 `LOGIN_PAGE` / 登录页 URL 才整组略过该账号后续任务;`NO_SESSION_COOKIE`、检测超时或 CDP 短暂异常只记录为不确定并继续尝试采集当前商品,不得级联跳过同账号剩余任务。
|
||||
- 若商品 ID 失效、无权限或店铺不匹配导致商品编辑页无法就绪,`open_product` 必须读取/捕获 Shopee toast,把最近错误文案写入采集失败原因和诊断日志,不能只返回泛化超时。①列表只在明确捕获商品失效类 toast 时把“阶段”显示为“商品失效”;底层 `stage` 不新增中文值。若这个失败发生在程序自动新建的商品 tab 内,`open_product` 要关闭该 tab;复用用户已有 tab 不关闭。
|
||||
- 采集前和采集中途的登录检测必须区分“明确未登录”和“暂时不确定”。明确 `LOGIN_PAGE` / 登录页 URL 才整组略过该账号后续任务;`NO_SESSION_COOKIE`、检测超时或 CDP 短暂异常只记录为不确定并继续尝试采集当前商品,不得级联跳过同账号剩余任务。Cookie API 调用失败不能伪装成空 Cookie;登录检测若连到正在关闭的旧商品 target,应在本次检测预算内重新枚举并改连其他有效 Shopee 页面。
|
||||
- 若商品 ID 失效、无权限或店铺不匹配导致商品编辑页无法就绪,`open_product` 必须读取/捕获 Shopee toast,把最近错误文案写入采集失败原因和诊断日志,不能只返回泛化超时。①列表只在明确捕获商品失效类 toast 时把“阶段”显示为“商品失效”;底层 `stage` 不新增中文值。若这个失败发生在后台只读、程序自动新建的商品 tab 内,`open_product` 要关闭并执行同样的有界 target 消失确认;复用用户已有 tab 不关闭。③前台更新打开失败仍沿用既有清理路径,不引入额外等待。
|
||||
|
||||
- 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`。
|
||||
- 写 `old_title/old_cover_path`、stage=collected;批量回写 Excel 旧字段。
|
||||
@@ -452,7 +452,8 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
|
||||
|
||||
### 6.4 登录检测
|
||||
|
||||
- 无 Shopee tab 时打开卖家中心根地址 `https://<region_host>/`(默认 `https://seller.shopee.tw/`),重定向到登录页或缺会话 Cookie(`SPC_ST`/`SPC_U`)→ 未登录;不自动登录,提示人工登录。
|
||||
- 无 Shopee tab 时打开卖家中心根地址 `https://<region_host>/`(默认 `https://seller.shopee.tw/`);不自动登录。重定向到登录页返回明确 `LOGIN_PAGE`;页面 target 可用且 Cookie API 至少成功一次、但确实缺少 `SPC_ST`/`SPC_U` 时返回 `NO_SESSION_COOKIE`;websocket/target 正在关闭、整个检测周期一次 Cookie 都未成功读取时返回 `LOGIN_CHECK_TARGET_UNAVAILABLE`,界面显示“登录状态暂不可确认”,不得误报账号退出登录。
|
||||
- 多个 Shopee page target 并存时优先稳定的卖家中心 portal 页面,再尝试商品页,显式登录页最后判定;单个 target 连接或 Cookie 读取失败后立即重枚举/改连其他未尝试 target,不等待完整 8 秒才交给外层重试。①采集的 retry/recovered 日志必须带当前任务 ID、商品 ID;关闭上一条商品页的 target 仍短暂出现在 `/json` 时,不得把上一条 URL 误记为下一条账号掉线。
|
||||
- ④「启动登录」只负责准备该账号独立 user-data-dir + CDP 端口的 Chrome,供用户人工登录;该入口必须幂等:若端口已响应,复用现有账号 Chrome 并打开/激活卖家中心登录 tab,不再新开 Chrome;若端口未响应,才启动 Chrome。「检测登录」只验证当前 Chrome/CDP/会话 Cookie 是否可用。① 采集的预检会复用同一幂等启动能力,自动确保本轮匹配账号 Chrome 就绪但不自动登录;③ 更新的预检只检测,不自动启动缺失浏览器。若商品详情页或卖家中心重定向到 `accounts.shopee.tw/seller/login`,必须按登录页处理,返回 `LOGIN_PAGE` 并在 GUI 显示未登录。
|
||||
- T-585 后新配置的 `chrome_path` 默认留空。程序在数据目录就绪、强制升级检查通过后、创建主窗口前,仅当当前路径为空或不可启动时,按当前用户/本机 Windows App Paths(含 64/32 位视图)、标准 Chrome 目录和 `PATH` 自动寻找 `chrome.exe`;命中才持久化归一化绝对路径,并在状态栏显示“已自动定位 Chrome”。有效的自定义/便携版路径和可从 PATH 启动的 `chrome.exe` 不覆盖;不扫描整盘、不检测 Edge 或 Chromium。⑤的“自动检测”只填入输入框并标记未保存,仍由用户点“保存设置”确认写入。
|
||||
|
||||
@@ -463,7 +464,8 @@ data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新
|
||||
| Chrome 启动参数 | 全关后带 `--remote-debugging-port=<port> --remote-allow-origins=* --user-data-dir=<dir>`;缺 allow-origins 则 WebSocket 403 |
|
||||
| 代理干扰 | 清除 `*_proxy`(requests `trust_env=False`),否则连本地 CDP 超时 |
|
||||
| WebSocket Origin | `websocket-client` `suppress_origin=True` |
|
||||
| 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。采集只关闭本轮自动新建的商品页,复用的用户已有 tab 不关闭;③ 更新时程序自动新建的商品页成功/失败都关闭,成功提交且确认跳回商品列表页时关闭前等待 2 秒;③ 复用用户已有商品页时不关闭页面 |
|
||||
| 关闭连接 vs 关闭 tab | `CDP.close()` 只关闭 WebSocket;需要关闭浏览器页面时必须调用浏览器 target 关闭接口。①采集和⑥只读商品页关闭本轮自动新建 target 后,最多等待 2 秒确认 target 从 `/json` 消失;超时只记诊断,不覆盖成功结果,复用的用户已有 tab 不关闭。③ 更新时程序自动新建的商品页成功/失败都关闭,成功提交且确认跳回商品列表页时关闭前等待 2 秒;③ 复用用户已有商品页时不关闭页面 |
|
||||
| 登录 target 竞态 | `/json/close/<target_id>` 返回成功不代表 target 已立即从 `/json` 消失。登录检测不得固定使用枚举到的第一个 Shopee page;连接或 Cookie API 因 target 销毁失败时应快速重选有效页面。只有 Cookie API 成功返回且确实缺会话 Cookie 才是 `NO_SESSION_COOKIE`;一次都未成功读取是 `LOGIN_CHECK_TARGET_UNAVAILABLE` |
|
||||
| 前台激活 | ①采集和⑥商品套图只读打开商品页时不主动 `Page.bringToFront`;新建 tab 尝试 `Target.createTarget(background=true)`,不支持时退回普通新建。③更新真实提交每条任务都以前台方式新建或激活商品 tab,并执行 `Page.bringToFront`,保障上传、图片管理器刷新和拖拽排序稳定;后台态封面恢复逻辑仅保留给兼容直接调用,不作为正常③批量路径 |
|
||||
| SPA 就绪 | 不用 load 事件;轮询“标题输入框 + 图片 itembox + 上传输入框”三者都在 |
|
||||
| 商品页错误 toast | Shopee 错误提示使用 `.eds-toasts` / `.eds-toast__content`,可能很快隐藏或 `display:none`。打开商品页/等待 SPA 就绪前应注入 `MutationObserver` 或等价监听,把 toast 文本、`outerHTML`、当前 URL、时间、可见状态保存到页面缓存(如 `window.__cmshopee_toasts`);等待详情页关键元素超时时,再兜底读取当前 DOM 中的 toast。最近错误 toast 应优先成为 `open_product` 失败原因,并写入 DB 运行日志和本地脱敏诊断日志。只有明确商品失效/不存在/无权限类 toast 才驱动①阶段列显示“商品失效”;网络、CDP、未登录、页面超时、风控等其他失败仍显示“失败” |
|
||||
|
||||
+10
-7
@@ -244,15 +244,17 @@ is_running(port, host="127.0.0.1") -> bool
|
||||
|
||||
```python
|
||||
CDP_HOST: str
|
||||
http_get(path, host=None); find_product_tab(item_id, host=None); create_tab_info(url, host=None); create_tab(url, host=None)
|
||||
http_get(path, host=None, timeout=10); find_product_tab(item_id, host=None); create_tab_info(url, host=None); create_tab(url, host=None)
|
||||
close_tab(target_id, host=None) -> bool # 关闭浏览器 target/page;区别于 CDP.close()
|
||||
wait_target_closed(target_id, host=None, timeout=2.0, poll_interval=0.1) -> bool
|
||||
close_tab_and_wait(target_id, host=None, timeout=2.0, poll_interval=0.1) -> bool
|
||||
class CDP: send/ev/val/object_id/drag/close # close 只断开 WebSocket;suppress_origin、trust_env=False
|
||||
```
|
||||
|
||||
## editor 模块(`app/editor.py`,已建,重构自现有脚本)
|
||||
|
||||
```python
|
||||
login_status(account, timeout=8) -> dict # {logged_in, reason, url, host, cookie_names}
|
||||
login_status(account, timeout=8) -> dict # {logged_in, reason, url, host, cookie_names, cookie_read_succeeded, probe_error, probe_attempts}
|
||||
is_logged_in(account) -> bool # login_status(...).logged_in;重定向登录页或缺 SPC_ST/SPC_U → False
|
||||
install_toast_observer(cdp) -> None # 监听 Shopee `.eds-toasts`,保存最近 toast 文本/HTML/URL/时间
|
||||
read_page_toasts(cdp) -> list[dict] # [{text, html, url, visible, created_at}],用于失败诊断
|
||||
@@ -262,7 +264,7 @@ open_product(account, item_id) -> CDP # 连端口、导航商品页、等
|
||||
read_title(cdp) -> str
|
||||
read_cover_src(cdp) -> str # 第一张 itembox 的 img.src
|
||||
download_cover(src, out_path) -> str # 下载旧封面到本地
|
||||
collect(account, task) -> dict # -> {old_title, old_cover_path}
|
||||
collect(account, task) -> dict # -> {old_title, old_cover_path, close_target_confirmed}
|
||||
|
||||
# 应用
|
||||
change_title(cdp, new_title) -> dict # {ok, value, modelvalue},要求三者相等
|
||||
@@ -275,15 +277,15 @@ apply_task(account, task, close_success_tab=False) -> dict
|
||||
# -> {committed, error}
|
||||
```
|
||||
|
||||
`login_status()` 不自动登录;无 Shopee tab 时打开卖家中心根地址 `https://<region_host>/`(默认 `https://seller.shopee.tw/`)用于检测/人工登录。判断规则:最终 URL 是登录页 → `LOGIN_PAGE`;其中必须显式识别 `https://accounts.shopee.tw/seller/login...` 这类 Shopee accounts 登录页;缺少 `SPC_ST`/`SPC_U` → `NO_SESSION_COOKIE`;有会话 Cookie → 已登录。
|
||||
`login_status()` 不自动登录;无 Shopee tab 时打开卖家中心根地址 `https://<region_host>/`(默认 `https://seller.shopee.tw/`)用于检测/人工登录。判断规则:最终 URL 是登录页 → `LOGIN_PAGE`,其中必须显式识别 `https://accounts.shopee.tw/seller/login...` 这类 Shopee accounts 登录页;Cookie API 至少成功一次且确实缺少 `SPC_ST`/`SPC_U` → `NO_SESSION_COOKIE`;有会话 Cookie → 已登录;所有候选 target 都因关闭/连接错误而一次 Cookie 都未成功读取 → `LOGIN_CHECK_TARGET_UNAVAILABLE`。多个 Shopee page 并存时优先稳定 portal 页面,候选连接或 Cookie 读取失败后在同一超时预算内快速重选,不能把 target 错误降级成空 Cookie。
|
||||
|
||||
采集 tab 生命周期:
|
||||
|
||||
- `CDP.close()` 只断开当前 websocket 控制连接,不关闭 Chrome 页面。
|
||||
- `open_product()` 若复用已存在商品 tab,则标记为用户已有页面;若调用 `create_tab()` 新建,则记录 target id。
|
||||
- `open_product()` 进入/刷新商品编辑页后要安装 toast 监听;若标题输入框、图片管理器、上传入口等关键元素等待超时,或页面明显不是商品编辑页,应读取最近 `.eds-toast__content`。如果存在错误 toast,例如 `please input correct product id`,返回/抛出的错误信息必须包含该文案,并把 toast 文本、`outerHTML`、URL、时间、可见状态交给上层运行日志/诊断日志;不得记录 Cookie、密码、token。调用方只在明确商品失效/商品不存在/无权限类 toast 时写 `last_error=商品失效:<原始toast>`,数据库 `stage/status` 仍使用既有流程值。若失败发生在 `open_product()` 返回 `cdp` 前,`open_product()` 自己负责清理:自动新建 tab 断开 CDP 后关闭浏览器 target,复用用户已有 tab 只断开 CDP。
|
||||
- `open_product()` 进入/刷新商品编辑页后要安装 toast 监听;若标题输入框、图片管理器、上传入口等关键元素等待超时,或页面明显不是商品编辑页,应读取最近 `.eds-toast__content`。如果存在错误 toast,例如 `please input correct product id`,返回/抛出的错误信息必须包含该文案,并把 toast 文本、`outerHTML`、URL、时间、可见状态交给上层运行日志/诊断日志;不得记录 Cookie、密码、token。调用方只在明确商品失效/商品不存在/无权限类 toast 时写 `last_error=商品失效:<原始toast>`,数据库 `stage/status` 仍使用既有流程值。若失败发生在 `open_product()` 返回 `cdp` 前,`open_product()` 自己负责清理:后台只读自动新建 tab 断开 CDP、关闭 target 并执行最多 2 秒的关闭确认;③前台更新自动新建 tab 沿用原关闭路径;复用用户已有 tab 只断开 CDP。
|
||||
|
||||
- `collect()` 结束时只关闭本轮自动新建的商品编辑页 tab;如果 `open_product()` 尚未返回就失败,也由 `open_product()` 关闭本轮自动新建 tab;用户原本打开的商品 tab 不关闭。
|
||||
- `collect()` 结束时只关闭本轮自动新建的商品编辑页 tab,并通过 `close_tab_and_wait()` 在最多 2 秒内确认 target 从 `/json` 消失,结果写入 `close_target_confirmed`。确认超时只记警告,不覆盖已成功读取的标题/封面;如果 `open_product()` 尚未返回就失败,也由 `open_product()` 关闭本轮自动新建 tab;用户原本打开的商品 tab 不关闭、不等待。
|
||||
- ③ 更新流程中程序自动新建的商品编辑页成功/失败都关闭,复用用户原本打开的 tab 只断开 CDP、不关闭页面;`open_product()` 内部打开失败的新建 tab 仍由 `open_product()` 自行关闭。Shopee 确认成功后可能把当前 tab 跳回 `/portal/product/list/all?operationSortBy=modified_time`,`click_update()` 会把该 URL 记录到 `post_update.url` 并标记 `redirected_to_list=true`;自动新建页成功关闭前等待 2 秒。
|
||||
- `click_update()` 的提交成功定义:页面主「更新」按钮已点击,且 Shopee 站点侧确认框未出现或已在可见 `.eds-modal__content` / `.eds-modal__box` 内点击主按钮「更新」。如果确认框仍停留、只点到页面主按钮、或误入「立即優化」,必须返回失败;若 tab 是本轮自动新建,失败后由 `apply_task()` 关闭该 tab。
|
||||
- T-404/T-502 封面更新删除前,`apply_task()` 应把任务的 `old_cover_path` 传给 `replace_cover()`;`replace_cover()` 只有在本地旧封面备份存在时才允许进入删第一张流程。更新封面统一先删当前第一张,不再只在满 9 张时删除;8 张商品图也按替换语义先删再上传。
|
||||
@@ -483,7 +485,8 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
|
||||
- 账号列优先显示匹配到的 `accounts.account_name`;未匹配账号时保留 Excel 输入账号名。
|
||||
- 别名未匹配 `accounts.alias` 时列表阶段列显示“略过”;点击「采集旧标题/旧封面」后由 `CollectWorker` 逐条写库为 `skipped`,原因 `别名未匹配账号`。
|
||||
- 「采集旧标题/旧封面」通过 `CollectWorker` 后台执行,只处理 `stage=imported` 的任务;采集前先做账号就绪预检。无账号、当前批次匹配账号未启动 CDP 端口或未登录时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理,不进入逐条采集、不写 skipped/failed。预检通过后,已匹配任务调用 `editor.collect()` 下载旧封面到 `image_dir/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg` 并 `db.set_collected()`;别名未匹配任务仍逐条 `mark_skipped`;单条失败 `mark_failed(..., "collect", error)` 后继续。采集完成且本轮成功采集数量大于 0 时,自动触发当前批次旧字段回写;锁文件失败时只提示,不回滚 SQLite。
|
||||
- 采集打开商品页时,若本轮自动新建 tab,采集完成/失败后会关闭该 tab;若失败发生在 `open_product()` 内部且尚未返回 `cdp`,也要关闭本轮自动新建 tab;若复用用户已打开的商品页,只断开 CDP 连接不关闭页面。
|
||||
- 采集打开商品页时,若本轮自动新建 tab,采集完成后会关闭该 tab,并在最多 2 秒内确认 target 已从 `/json` 消失;确认超时只写 warning/诊断,不把采集成功改成失败。若失败发生在 `open_product()` 内部且尚未返回 `cdp`,也要关闭本轮自动新建 tab;若复用用户已打开的商品页,只断开 CDP 连接不关闭页面。
|
||||
- 采集中途登录检测必须快速跳过正在销毁的旧商品 target,改连其他有效 Shopee 页面。Cookie API 调用失败返回 `LOGIN_CHECK_TARGET_UNAVAILABLE`,只有 Cookie API 成功返回空会话时才返回 `NO_SESSION_COOKIE`;两者都不按明确掉登录批量略过,显式 `LOGIN_PAGE` 仍按账号需登录处理。retry/recovered 运行日志包含当前任务 ID 和商品 ID,避免与上一条采集成功日志混淆。
|
||||
- 采集打开商品页失败时,`CollectWorker` 应把 `open_product()` 捕获到的 Shopee toast 文案写入 `run_log_events` 和 `tasks.last_error`;商品 ID 失效、无权限、店铺不匹配等场景不得只显示泛化超时。① `TaskTableModel` 的“阶段”列只在 `last_error` 明确为商品失效类错误时显示“商品失效”,否则仍按 `status=failed` 显示“失败”;底层不新增 `stage` 枚举。
|
||||
|
||||
- 「停止」调用 worker 的协作式 `cancel()`,已开始的单条跑到安全边界后结束。
|
||||
|
||||
+14
-2
@@ -3,7 +3,7 @@ id: T-630
|
||||
title: ①采集关闭商品页后的登录检测竞态修复
|
||||
phase: 2
|
||||
deps: [T-620]
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-07-14
|
||||
---
|
||||
|
||||
@@ -131,4 +131,16 @@ step=login_check result=retry detail=任务 71 商品 52313423890 账号 <别名
|
||||
|
||||
## 执行记录
|
||||
|
||||
- 待实现。
|
||||
- 2026-07-14 完成。
|
||||
- `app/cdp.py` 新增有界 `wait_target_closed()` / `close_tab_and_wait()`:关闭 target 后按 ID 轮询 `/json`,默认最多等待 2 秒,不使用固定休眠。
|
||||
- `app/editor.py` 的登录检测现在保留 Cookie API 是否成功、探测错误类别和尝试次数;URL/Cookie 探测失败返回 `LOGIN_CHECK_TARGET_UNAVAILABLE`,不会伪装成 `NO_SESSION_COOKIE`。多个 Shopee 页面并存时优先稳定 portal,失效 target 会在同一检测预算内快速重选。
|
||||
- ①采集及⑥共用的后台只读商品页,在成功采集或 `open_product()` 打开失败时都会确认程序新建 target 已消失;关闭确认超时仅返回 `close_target_confirmed=False` 并记录警告,不覆盖采集结果。③前台更新和提交后 2 秒观察逻辑未修改。
|
||||
- `CollectWorker` 的登录 retry/recovered 日志已带任务 ID、商品 ID 和账号;首次不确定、后续恢复时明确记录恢复。关闭确认超时写可见警告和本地诊断,任务仍按采集成功落库。账号管理把 target 暂不可用显示为“登录状态暂不可确认”。
|
||||
- 已同步 `docs/04-architecture.md` 与 `docs/api.md`,并补充 CDP、editor、账号状态和 worker 回归测试。
|
||||
- 自动验证通过:
|
||||
- `py -3.10 -m unittest tests.test_editor_login`:50 项通过;
|
||||
- 仅导出本任务暂存内容的干净副本运行 `py -3.10 -m unittest tests.test_gui`:182 项通过;
|
||||
- 同一干净副本运行 `py -3.10 -m unittest discover -s tests`:470 项通过;
|
||||
- `python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check` 均通过。
|
||||
- 真实 CDP 验证:当前已有 1 个账号 Chrome 运行且登录检测成功;最终代码连续 3 轮“后台新建只读 target → 有界关闭 → 立即检测登录”均通过,关闭确认均为 true、登录均为 true、`probe_attempts=1`,单轮分别耗时约 0.14 秒、0.05 秒、0.03 秒,无假 `NO_SESSION_COOKIE`,且 target 均已确认消失。
|
||||
- 商品采集实测限制:按仓库规则尝试测试商品 `51100639510` 时,Shopee 返回“商品失效:please input correct product id”,无法继续完成该商品的标题/封面三轮实采;未改用真实运营商品规避测试边界。标题、封面下载和 SQLite 流程由自动回归覆盖。
|
||||
|
||||
@@ -192,6 +192,26 @@ class AccountsTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_login_status_text_marks_target_failure_as_temporarily_uncertain(self):
|
||||
self.assertEqual(
|
||||
"登录状态暂不可确认",
|
||||
accounts.login_status_text(
|
||||
{
|
||||
"logged_in": False,
|
||||
"reason": "LOGIN_CHECK_TARGET_UNAVAILABLE",
|
||||
}
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
"登录状态暂不可确认",
|
||||
accounts.login_status_text(
|
||||
{
|
||||
"logged_in": False,
|
||||
"reason": "LOGIN_CHECK_FAILED: Target closed",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def test_duplicate_alias_raises_clear_error(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
|
||||
@@ -35,6 +35,60 @@ class FakeBrowserCDP:
|
||||
|
||||
|
||||
class CdpTests(unittest.TestCase):
|
||||
def test_wait_target_closed_returns_when_target_disappears(self):
|
||||
targets = [
|
||||
[{"id": "target-closing", "type": "page"}],
|
||||
[],
|
||||
]
|
||||
with mock.patch("app.cdp.http_get", side_effect=targets) as http_get, mock.patch(
|
||||
"app.cdp.time.sleep"
|
||||
) as sleep:
|
||||
closed = cdp.wait_target_closed(
|
||||
"target-closing",
|
||||
host="127.0.0.1:9222",
|
||||
timeout=1.0,
|
||||
poll_interval=0.1,
|
||||
)
|
||||
|
||||
self.assertTrue(closed)
|
||||
sleep.assert_called_once_with(0.1)
|
||||
self.assertLessEqual(http_get.call_args_list[0].kwargs["timeout"], 1.0)
|
||||
|
||||
def test_wait_target_closed_stops_at_timeout(self):
|
||||
with mock.patch(
|
||||
"app.cdp.http_get",
|
||||
return_value=[{"id": "target-closing", "type": "page"}],
|
||||
) as http_get, mock.patch("app.cdp.time.sleep") as sleep:
|
||||
closed = cdp.wait_target_closed(
|
||||
"target-closing",
|
||||
host="127.0.0.1:9222",
|
||||
timeout=0,
|
||||
)
|
||||
|
||||
self.assertFalse(closed)
|
||||
sleep.assert_not_called()
|
||||
self.assertEqual(0.05, http_get.call_args.kwargs["timeout"])
|
||||
|
||||
def test_close_tab_and_wait_only_waits_after_close_request_succeeds(self):
|
||||
with mock.patch("app.cdp.close_tab", return_value=True) as close_tab, mock.patch(
|
||||
"app.cdp.wait_target_closed",
|
||||
return_value=True,
|
||||
) as wait_target_closed:
|
||||
closed = cdp.close_tab_and_wait(
|
||||
"target-closing",
|
||||
host="127.0.0.1:9222",
|
||||
timeout=1.5,
|
||||
)
|
||||
|
||||
self.assertTrue(closed)
|
||||
close_tab.assert_called_once_with("target-closing", host="127.0.0.1:9222")
|
||||
wait_target_closed.assert_called_once_with(
|
||||
"target-closing",
|
||||
host="127.0.0.1:9222",
|
||||
timeout=1.5,
|
||||
poll_interval=0.1,
|
||||
)
|
||||
|
||||
def test_create_tab_info_can_request_background_target(self):
|
||||
FakeBrowserCDP.sent = []
|
||||
FakeBrowserCDP.fail_first_create = False
|
||||
|
||||
+138
-5
@@ -382,6 +382,104 @@ class EditorLoginTests(unittest.TestCase):
|
||||
self.assertIn("SPC_ST", status["cookie_names"])
|
||||
self.assertTrue(FakeCDP.instances[0].closed)
|
||||
|
||||
def test_login_status_reselects_live_target_after_closing_target_fails(self):
|
||||
pages = [
|
||||
{
|
||||
"id": "target-closing",
|
||||
"type": "page",
|
||||
"url": "https://seller.shopee.tw/portal/product/48663456321",
|
||||
"webSocketDebuggerUrl": "ws-closing",
|
||||
},
|
||||
{
|
||||
"id": "target-live",
|
||||
"type": "page",
|
||||
"url": "https://seller.shopee.tw/portal/product/52313423890",
|
||||
"webSocketDebuggerUrl": "ws-live",
|
||||
},
|
||||
]
|
||||
|
||||
def cdp_factory(ws):
|
||||
instance = FakeCDP(
|
||||
ws,
|
||||
url=next(page["url"] for page in pages if page["webSocketDebuggerUrl"] == ws),
|
||||
cookies=[cookie("SPC_ST")] if ws == "ws-live" else [],
|
||||
)
|
||||
if ws == "ws-closing":
|
||||
original_send = instance.send
|
||||
|
||||
def send(method, params=None):
|
||||
if method == "Network.getAllCookies":
|
||||
raise RuntimeError("Target closed")
|
||||
return original_send(method, params)
|
||||
|
||||
instance.send = send
|
||||
return instance
|
||||
|
||||
with mock.patch("app.editor.http_get", return_value=pages), mock.patch(
|
||||
"app.editor.CDP",
|
||||
side_effect=cdp_factory,
|
||||
), mock.patch("app.editor.time.sleep"):
|
||||
status = editor.login_status({"debug_port": 9222}, timeout=1)
|
||||
|
||||
self.assertTrue(status["logged_in"])
|
||||
self.assertIsNone(status["reason"])
|
||||
self.assertEqual(2, status["probe_attempts"])
|
||||
self.assertEqual(["ws-closing", "ws-live"], [item.ws for item in FakeCDP.instances])
|
||||
self.assertTrue(all(item.closed for item in FakeCDP.instances))
|
||||
|
||||
def test_login_status_target_failure_is_not_no_session_cookie(self):
|
||||
page = {
|
||||
"id": "target-closing",
|
||||
"type": "page",
|
||||
"url": "https://seller.shopee.tw/portal/product/48663456321",
|
||||
"webSocketDebuggerUrl": "ws-closing",
|
||||
}
|
||||
|
||||
def cdp_factory(ws):
|
||||
instance = FakeCDP(ws, url=page["url"])
|
||||
|
||||
def send(method, params=None):
|
||||
if method == "Network.getAllCookies":
|
||||
raise RuntimeError("Target closed")
|
||||
return {}
|
||||
|
||||
instance.send = send
|
||||
return instance
|
||||
|
||||
with mock.patch("app.editor.http_get", return_value=[page]), mock.patch(
|
||||
"app.editor.CDP",
|
||||
side_effect=cdp_factory,
|
||||
):
|
||||
status = editor.login_status({"debug_port": 9222}, timeout=0)
|
||||
|
||||
self.assertFalse(status["logged_in"])
|
||||
self.assertEqual("LOGIN_CHECK_TARGET_UNAVAILABLE", status["reason"])
|
||||
self.assertFalse(status["cookie_read_succeeded"])
|
||||
self.assertNotEqual("NO_SESSION_COOKIE", status["reason"])
|
||||
|
||||
def test_login_status_url_probe_failure_is_not_no_session_cookie(self):
|
||||
page = {
|
||||
"id": "target-closing",
|
||||
"type": "page",
|
||||
"url": "https://seller.shopee.tw/portal/product/48663456321",
|
||||
"webSocketDebuggerUrl": "ws-closing",
|
||||
}
|
||||
instance = FakeCDP("ws-closing", url=page["url"], cookies=[])
|
||||
|
||||
def fail_url_probe(_expression):
|
||||
raise RuntimeError("Target closed")
|
||||
|
||||
instance.val = fail_url_probe
|
||||
with mock.patch("app.editor.http_get", return_value=[page]), mock.patch(
|
||||
"app.editor.CDP",
|
||||
return_value=instance,
|
||||
):
|
||||
status = editor.login_status({"debug_port": 9222}, timeout=0)
|
||||
|
||||
self.assertFalse(status["logged_in"])
|
||||
self.assertEqual("LOGIN_CHECK_TARGET_UNAVAILABLE", status["reason"])
|
||||
self.assertFalse(status["cookie_read_succeeded"])
|
||||
|
||||
def test_login_status_false_without_session_cookie(self):
|
||||
with mock.patch(
|
||||
"app.editor.http_get",
|
||||
@@ -604,6 +702,35 @@ class EditorLoginTests(unittest.TestCase):
|
||||
close_tab.assert_not_called()
|
||||
create_tab_info.assert_not_called()
|
||||
|
||||
def test_open_product_background_failure_confirms_created_target_closed(self):
|
||||
fake = FakeProductCDP(
|
||||
"ws-new",
|
||||
ready=False,
|
||||
toasts=[{"text": "please input correct product id", "visible": False}],
|
||||
)
|
||||
with mock.patch("app.editor.find_product_tab", return_value=None), mock.patch(
|
||||
"app.editor.create_tab_info",
|
||||
return_value={"id": "target-new", "webSocketDebuggerUrl": "ws-new"},
|
||||
), mock.patch("app.editor.CDP", return_value=fake), mock.patch(
|
||||
"app.editor._ensure_page_domains"
|
||||
), mock.patch(
|
||||
"app.editor.close_tab_and_wait",
|
||||
return_value=True,
|
||||
) as close_tab_and_wait:
|
||||
with self.assertRaises(editor.EditorError):
|
||||
editor.open_product(
|
||||
{"debug_port": 9223},
|
||||
"bad-item",
|
||||
bring_to_front=False,
|
||||
)
|
||||
|
||||
self.assertTrue(fake.closed)
|
||||
close_tab_and_wait.assert_called_once_with(
|
||||
"target-new",
|
||||
host="127.0.0.1:9223",
|
||||
timeout=2.0,
|
||||
)
|
||||
|
||||
def test_collect_closes_only_auto_created_product_tab(self):
|
||||
cdp = FakeProductCDP("ws-new")
|
||||
cdp.target_id = "target-new"
|
||||
@@ -619,7 +746,7 @@ class EditorLoginTests(unittest.TestCase):
|
||||
), mock.patch(
|
||||
"app.editor.download_cover",
|
||||
return_value="images/main/51100639510_old.jpg",
|
||||
), mock.patch("app.editor.close_tab", return_value=True) as close_tab:
|
||||
), mock.patch("app.editor.close_tab_and_wait", return_value=True) as close_tab_and_wait:
|
||||
result = editor.collect(
|
||||
{"debug_port": 9222},
|
||||
{"item_id": "51100639510", "old_cover_path": "old.jpg"},
|
||||
@@ -633,7 +760,12 @@ class EditorLoginTests(unittest.TestCase):
|
||||
bring_to_front=False,
|
||||
)
|
||||
self.assertTrue(cdp.closed)
|
||||
close_tab.assert_called_once_with("target-new", host="127.0.0.1:9222")
|
||||
self.assertTrue(result["close_target_confirmed"])
|
||||
close_tab_and_wait.assert_called_once_with(
|
||||
"target-new",
|
||||
host="127.0.0.1:9222",
|
||||
timeout=2.0,
|
||||
)
|
||||
|
||||
def test_collect_keeps_reused_product_tab_open(self):
|
||||
cdp = FakeProductCDP("ws-existing")
|
||||
@@ -650,14 +782,15 @@ class EditorLoginTests(unittest.TestCase):
|
||||
), mock.patch(
|
||||
"app.editor.download_cover",
|
||||
return_value="images/main/51100639510_old.jpg",
|
||||
), mock.patch("app.editor.close_tab") as close_tab:
|
||||
editor.collect(
|
||||
), mock.patch("app.editor.close_tab_and_wait") as close_tab_and_wait:
|
||||
result = editor.collect(
|
||||
{"debug_port": 9222},
|
||||
{"item_id": "51100639510", "old_cover_path": "old.jpg"},
|
||||
)
|
||||
|
||||
self.assertTrue(cdp.closed)
|
||||
close_tab.assert_not_called()
|
||||
self.assertIsNone(result["close_target_confirmed"])
|
||||
close_tab_and_wait.assert_not_called()
|
||||
|
||||
def test_read_product_image_urls_returns_all_images_in_page_order(self):
|
||||
cdp = FakeProductCDP("ws-existing", rects=cover_rects(3, prefix="main"))
|
||||
|
||||
+27
-3
@@ -8465,6 +8465,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
db_path=cfg["db_path"],
|
||||
config=cfg,
|
||||
preflight=False,
|
||||
diagnostic_log_dir=os.path.join(temp_dir, "logs"),
|
||||
).execute()
|
||||
|
||||
self.assertTrue(summary["ok"])
|
||||
@@ -8480,6 +8481,19 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertEqual(1, sleep.call_count)
|
||||
self.assertEqual(2, collect.call_count)
|
||||
|
||||
events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"])
|
||||
messages = "\n".join(event.message for event in events)
|
||||
self.assertIn(
|
||||
"step=login_check result=retry detail=任务 "
|
||||
f"{tasks[0].id} 商品 51100639510",
|
||||
messages,
|
||||
)
|
||||
self.assertIn(
|
||||
"step=login_check result=recovered detail=任务 "
|
||||
f"{tasks[0].id} 商品 51100639510",
|
||||
messages,
|
||||
)
|
||||
|
||||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
self.assertTrue(all(task.stage == "collected" for task in updated))
|
||||
self.assertTrue(all(task.status == "success" for task in updated))
|
||||
@@ -8516,7 +8530,11 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
with mock.patch("app.gui.accounts.detect_login", return_value=status) as detect_login, \
|
||||
mock.patch(
|
||||
"app.gui.editor.collect",
|
||||
return_value={"old_title": "旧标题", "old_cover_path": "old.jpg"},
|
||||
return_value={
|
||||
"old_title": "旧标题",
|
||||
"old_cover_path": "old.jpg",
|
||||
"close_target_confirmed": False,
|
||||
},
|
||||
) as collect, \
|
||||
mock.patch("app.gui.workers.time.sleep") as sleep:
|
||||
summary = CollectWorker(
|
||||
@@ -8524,6 +8542,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
db_path=cfg["db_path"],
|
||||
config=cfg,
|
||||
preflight=False,
|
||||
diagnostic_log_dir=os.path.join(temp_dir, "logs"),
|
||||
).execute()
|
||||
|
||||
self.assertTrue(summary["ok"])
|
||||
@@ -8544,6 +8563,11 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"])
|
||||
messages = "\n".join(event.message for event in events)
|
||||
self.assertIn("登录状态检测暂时不稳定", messages)
|
||||
self.assertIn(
|
||||
"step=close_product result=uncertain detail=任务 "
|
||||
f"{tasks[0].id} 商品 51100639510",
|
||||
messages,
|
||||
)
|
||||
self.assertNotIn("采集中途掉登录", messages)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
@@ -8604,9 +8628,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"])
|
||||
messages = "\n".join(event.message for event in events)
|
||||
self.assertIn("LOGIN_CHECK_FAILED", messages)
|
||||
self.assertIn("登录检测调用失败", messages)
|
||||
self.assertNotIn("LOGIN_CHECK_FAILED", messages)
|
||||
self.assertNotIn("SECRET-TOKEN", messages)
|
||||
self.assertIn("token=***", messages)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user