feat: 添加依赖自动检测与安装、选项记忆、KDocs登录优化
- 新增依赖检测模块:启动时自动检测wkhtmltoimage和Playwright Chromium - 新增依赖安装对话框:缺失时提示用户一键下载安装 - 修复选项记忆功能:浏览类型、自动截图、自动上传选项现在会保存 - 优化KDocs登录检测:未登录时自动切换到金山文档页面并显示二维码 - 简化日志输出:移除debug信息,保留用户友好的状态提示 - 新增账号变化信号:账号管理页面的修改会自动同步到浏览任务页面 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -107,10 +107,10 @@ class APIBrowser:
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < max_retries:
|
||||
self.log(f"[API] 请求超时,{retry_delay}秒后重试 ({attempt}/{max_retries})...")
|
||||
self.log(f" 请求超时,{retry_delay}秒后重试 ({attempt}/{max_retries})...")
|
||||
time.sleep(retry_delay)
|
||||
else:
|
||||
self.log(f"[API] 请求失败,已重试{max_retries}次: {str(e)}")
|
||||
self.log(f" 请求失败,已重试{max_retries}次: {str(e)}")
|
||||
|
||||
raise last_error
|
||||
|
||||
@@ -125,7 +125,7 @@ class APIBrowser:
|
||||
|
||||
def login(self, username: str, password: str) -> bool:
|
||||
"""登录"""
|
||||
self.log(f"[API] 登录: {username}")
|
||||
self.log(f" 登录: {username}")
|
||||
self._username = username
|
||||
|
||||
try:
|
||||
@@ -152,17 +152,17 @@ class APIBrowser:
|
||||
|
||||
if self.index_url_pattern in resp.url:
|
||||
self.logged_in = True
|
||||
self.log(f"[API] 登录成功")
|
||||
self.log(f" 登录成功")
|
||||
return True
|
||||
else:
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
error = soup.find(id="lblMsg")
|
||||
error_msg = error.get_text().strip() if error else "未知错误"
|
||||
self.log(f"[API] 登录失败: {error_msg}")
|
||||
self.log(f" 登录失败: {error_msg}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"[API] 登录异常: {str(e)}")
|
||||
self.log(f" 登录异常: {str(e)}")
|
||||
return False
|
||||
|
||||
def get_real_name(self) -> Optional[str]:
|
||||
@@ -217,10 +217,10 @@ class APIBrowser:
|
||||
with open(cookies_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
self.log(f"[API] Cookies已保存供截图使用")
|
||||
self.log(f" Cookies已保存供截图使用")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.log(f"[API] 保存cookies失败: {e}")
|
||||
self.log(f" 保存cookies失败: {e}")
|
||||
return False
|
||||
|
||||
def get_article_list_page(self, bz: int = 0, page: int = 1) -> tuple:
|
||||
@@ -377,7 +377,7 @@ class APIBrowser:
|
||||
# 根据浏览类型确定bz参数(网站更新后 bz=0 为应读)
|
||||
bz = 0
|
||||
|
||||
self.log(f"[API] 开始浏览 '{browse_type}' (bz={bz})...")
|
||||
self.log(f" 开始浏览 '{browse_type}' (bz={bz})...")
|
||||
|
||||
try:
|
||||
total_items = 0
|
||||
@@ -387,12 +387,12 @@ class APIBrowser:
|
||||
articles, total_pages, _ = self.get_article_list_page(bz, 1)
|
||||
|
||||
if not articles:
|
||||
self.log(f"[API] '{browse_type}' 没有待处理内容")
|
||||
self.log(f" '{browse_type}' 没有待处理内容")
|
||||
result.success = True
|
||||
return result
|
||||
|
||||
total_records = self.last_total_records
|
||||
self.log(f"[API] 共 {total_records} 条记录,开始处理...")
|
||||
self.log(f" 共 {total_records} 条记录,开始处理...")
|
||||
|
||||
# 上报初始进度
|
||||
if progress_callback:
|
||||
@@ -404,7 +404,7 @@ class APIBrowser:
|
||||
|
||||
for iteration in range(max_iterations):
|
||||
if should_stop_callback and should_stop_callback():
|
||||
self.log("[API] 收到停止信号")
|
||||
self.log(" 收到停止信号")
|
||||
break
|
||||
|
||||
if not articles:
|
||||
@@ -428,7 +428,7 @@ class APIBrowser:
|
||||
try:
|
||||
attachments, article_info = self.get_article_attachments(article_href)
|
||||
except Exception as e:
|
||||
self.log(f"[API] 获取文章失败: {title} | {str(e)}")
|
||||
self.log(f" 获取文章失败: {title} | {str(e)}")
|
||||
continue
|
||||
|
||||
total_items += 1
|
||||
@@ -446,10 +446,10 @@ class APIBrowser:
|
||||
for attach in attachments:
|
||||
if self.mark_attachment_read(attach["id"], attach["channel_id"]):
|
||||
total_attachments += 1
|
||||
self.log(f"[API] [{total_items}] {title} - {len(attachments)}个附件")
|
||||
self.log(f" [{total_items}] {title} - {len(attachments)}个附件")
|
||||
else:
|
||||
status = "已标记" if article_marked else "标记失败"
|
||||
self.log(f"[API] [{total_items}] {title} - 无附件({status})")
|
||||
self.log(f" [{total_items}] {title} - 无附件({status})")
|
||||
|
||||
# 上报进度
|
||||
if progress_callback:
|
||||
@@ -472,10 +472,10 @@ class APIBrowser:
|
||||
if new_total_pages > 0:
|
||||
total_pages = new_total_pages
|
||||
except Exception as e:
|
||||
self.log(f"[API] 获取第{current_page}页列表失败: {str(e)}")
|
||||
self.log(f" 获取第{current_page}页列表失败: {str(e)}")
|
||||
break
|
||||
|
||||
self.log(f"[API] 浏览完成: {total_items} 条内容,{total_attachments} 个附件")
|
||||
self.log(f" 浏览完成: {total_items} 条内容,{total_attachments} 个附件")
|
||||
result.success = True
|
||||
result.total_items = total_items
|
||||
result.total_attachments = total_attachments
|
||||
@@ -483,7 +483,7 @@ class APIBrowser:
|
||||
|
||||
except Exception as e:
|
||||
result.error_message = str(e)
|
||||
self.log(f"[API] 浏览出错: {str(e)}")
|
||||
self.log(f" 浏览出错: {str(e)}")
|
||||
return result
|
||||
|
||||
def close(self):
|
||||
|
||||
@@ -41,6 +41,40 @@ class KDocsUploader:
|
||||
if self._log_callback:
|
||||
self._log_callback(msg)
|
||||
|
||||
def _find_visible_element(self, text: str, use_role: bool = False, role: str = "button"):
|
||||
"""找到包含指定文本的可见元素
|
||||
|
||||
Args:
|
||||
text: 要查找的文本
|
||||
use_role: 是否使用role查找(默认False,使用文本查找)
|
||||
role: 使用role查找时的角色类型
|
||||
|
||||
Returns:
|
||||
可见的元素,或None
|
||||
"""
|
||||
if not self._page:
|
||||
return None
|
||||
|
||||
try:
|
||||
if use_role:
|
||||
els = self._page.get_by_role(role, name=text)
|
||||
else:
|
||||
els = self._page.locator(f"text={text}")
|
||||
|
||||
count = els.count()
|
||||
for i in range(count):
|
||||
el = els.nth(i)
|
||||
try:
|
||||
if el.is_visible(timeout=500):
|
||||
box = el.bounding_box()
|
||||
if box and box.get('width', 0) > 0 and box.get('height', 0) > 0:
|
||||
return el
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _ensure_playwright(self, use_storage_state: bool = True) -> bool:
|
||||
"""确保Playwright已启动"""
|
||||
if sync_playwright is None:
|
||||
@@ -53,9 +87,8 @@ class KDocsUploader:
|
||||
if self._playwright is None:
|
||||
self._playwright = sync_playwright().start()
|
||||
if self._browser is None:
|
||||
# 调试模式:有头模式,方便查看浏览器行为
|
||||
# 生产环境改回 "true"
|
||||
headless = os.environ.get("KDOCS_HEADLESS", "false").lower() != "false"
|
||||
# 默认无头模式,设置环境变量 KDOCS_HEADLESS=false 可切换为有头模式调试
|
||||
headless = os.environ.get("KDOCS_HEADLESS", "true").lower() != "false"
|
||||
# 使用系统安装的Chrome浏览器(支持微信快捷登录)
|
||||
# channel='chrome' 会使用系统Chrome,而不是Playwright自带的Chromium
|
||||
chrome_args = [
|
||||
@@ -69,10 +102,8 @@ class KDocsUploader:
|
||||
channel='chrome', # 使用系统Chrome
|
||||
args=chrome_args
|
||||
)
|
||||
self.log("[KDocs] 使用系统Chrome浏览器")
|
||||
except Exception as e:
|
||||
# 如果系统没有Chrome,回退到Chromium
|
||||
self.log(f"[KDocs] 系统Chrome不可用({e}),使用Chromium")
|
||||
self._browser = self._playwright.chromium.launch(headless=headless, args=chrome_args)
|
||||
if self._context is None:
|
||||
storage_state = str(KDOCS_LOGIN_STATE_FILE)
|
||||
@@ -162,7 +193,6 @@ class KDocsUploader:
|
||||
try:
|
||||
join_btn = page.get_by_role("button", name="登录并加入编辑")
|
||||
if join_btn.count() > 0 and join_btn.first.is_visible(timeout=500):
|
||||
self.log("[KDocs] 点击加入编辑按钮")
|
||||
join_btn.first.click()
|
||||
time.sleep(1)
|
||||
except Exception:
|
||||
@@ -172,7 +202,6 @@ class KDocsUploader:
|
||||
|
||||
# 检查是否在登录页面
|
||||
if self._is_login_url(url):
|
||||
self.log(f"[KDocs] 检测到登录页面URL: {url}")
|
||||
return True
|
||||
|
||||
# 只检查登录页面上的登录按钮(排除文档页面的邀请对话框)
|
||||
@@ -181,7 +210,6 @@ class KDocsUploader:
|
||||
try:
|
||||
btn = page.get_by_role("button", name=text)
|
||||
if btn.count() > 0 and btn.first.is_visible(timeout=500):
|
||||
self.log(f"[KDocs] 检测到登录按钮: {text}")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -198,7 +226,6 @@ class KDocsUploader:
|
||||
if el.is_visible(timeout=200):
|
||||
box = el.bounding_box()
|
||||
if box and 80 <= box.get("width", 0) <= 400:
|
||||
self.log(f"[KDocs] 检测到二维码元素: {selector}")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -220,120 +247,60 @@ class KDocsUploader:
|
||||
storage_state = str(KDOCS_LOGIN_STATE_FILE)
|
||||
KDOCS_LOGIN_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._context.storage_state(path=storage_state)
|
||||
self.log("[KDocs] 登录状态已保存")
|
||||
except Exception as e:
|
||||
self.log(f"[KDocs] 保存登录状态失败: {e}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _ensure_login_dialog(self, use_quick_login: bool = False):
|
||||
"""确保打开登录对话框
|
||||
"""确保打开登录对话框并进入扫码页面"""
|
||||
buttons_priority = [
|
||||
"登录并加入编辑",
|
||||
"立即登录",
|
||||
"去登录",
|
||||
]
|
||||
|
||||
Args:
|
||||
use_quick_login: 是否尝试使用微信快捷登录
|
||||
"""
|
||||
agree_names = ["同意", "同意并继续", "我同意", "确定", "确认"]
|
||||
|
||||
# 循环处理登录流程
|
||||
max_clicks = 8
|
||||
for round_num in range(max_clicks):
|
||||
max_clicks = 12
|
||||
for _ in range(max_clicks):
|
||||
clicked = False
|
||||
current_url = self._page.url
|
||||
|
||||
# 检查是否已经到达文档页面(登录成功)
|
||||
# 需要确保不是临时跳转,等待页面稳定
|
||||
if "kdocs.cn/l/" in current_url or "www.kdocs.cn/l/" in current_url:
|
||||
time.sleep(1) # 等待页面稳定
|
||||
stable_url = self._page.url
|
||||
if "kdocs.cn/l/" in stable_url and "account.wps.cn" not in stable_url:
|
||||
self.log("[KDocs] 已到达文档页面,登录成功")
|
||||
if "kdocs.cn/l/" in current_url and "account.wps.cn" not in current_url:
|
||||
time.sleep(1)
|
||||
has_login_elements = (
|
||||
self._find_visible_element("立即登录", use_role=True) or
|
||||
self._find_visible_element("登录并加入编辑", use_role=True) or
|
||||
self._find_visible_element("微信扫码登录") or
|
||||
self._find_visible_element("微信快捷登录")
|
||||
)
|
||||
if not has_login_elements:
|
||||
return
|
||||
|
||||
# 1. 先检查是否有隐私协议同意按钮
|
||||
for name in agree_names:
|
||||
try:
|
||||
btn = self._page.get_by_role("button", name=name)
|
||||
if btn.count() > 0 and btn.first.is_visible(timeout=300):
|
||||
self.log(f"[KDocs] 点击同意按钮: {name}")
|
||||
btn.first.click()
|
||||
time.sleep(1)
|
||||
clicked = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if clicked:
|
||||
continue
|
||||
# 检查是否已经到达登录二维码页面
|
||||
qr_page_indicators = ["微信扫码登录", "微信快捷登录"]
|
||||
for indicator in qr_page_indicators:
|
||||
if self._find_visible_element(indicator):
|
||||
return
|
||||
|
||||
# 2. 如果启用快捷登录且在登录页面(account.wps.cn),尝试点击"微信快捷登录"
|
||||
if use_quick_login and "account.wps.cn" in current_url:
|
||||
try:
|
||||
quick_login = self._page.get_by_text("微信快捷登录", exact=False)
|
||||
if quick_login.count() > 0 and quick_login.first.is_visible(timeout=500):
|
||||
self.log("[KDocs] 点击微信快捷登录")
|
||||
quick_login.first.click()
|
||||
time.sleep(3) # 等待快捷登录处理
|
||||
# 检查是否登录成功
|
||||
if "kdocs.cn/l/" in self._page.url:
|
||||
self.log("[KDocs] 微信快捷登录成功")
|
||||
return
|
||||
clicked = True
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. 点击"立即登录"进入登录页面
|
||||
try:
|
||||
btn = self._page.get_by_role("button", name="立即登录")
|
||||
if btn.count() > 0 and btn.first.is_visible(timeout=500):
|
||||
self.log("[KDocs] 点击立即登录")
|
||||
btn.first.click()
|
||||
# 按优先级点击登录按钮
|
||||
for btn_name in buttons_priority:
|
||||
el = self._find_visible_element(btn_name, use_role=True)
|
||||
if el:
|
||||
el.click(force=True)
|
||||
time.sleep(2)
|
||||
clicked = True
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
# 4. 点击"登录并加入编辑"(文档页面的邀请对话框)
|
||||
try:
|
||||
btn = self._page.get_by_role("button", name="登录并加入编辑")
|
||||
if btn.count() > 0 and btn.first.is_visible(timeout=500):
|
||||
self.log("[KDocs] 点击登录并加入编辑")
|
||||
btn.first.click()
|
||||
time.sleep(1.5)
|
||||
clicked = True
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 如果没有点击到任何按钮,退出循环
|
||||
if not clicked:
|
||||
self.log("[KDocs] 未找到更多可点击的按钮")
|
||||
break
|
||||
for btn_name in buttons_priority:
|
||||
el = self._find_visible_element(btn_name)
|
||||
if el:
|
||||
el.click(force=True)
|
||||
time.sleep(2)
|
||||
clicked = True
|
||||
break
|
||||
|
||||
# 最后确保点击微信扫码登录(切换到扫码模式)
|
||||
wechat_names = ["微信登录", "微信扫码登录", "扫码登录", "微信扫码"]
|
||||
for name in wechat_names:
|
||||
try:
|
||||
btn = self._page.get_by_role("button", name=name)
|
||||
if btn.is_visible(timeout=1000):
|
||||
self.log(f"[KDocs] 点击微信登录: {name}")
|
||||
btn.click()
|
||||
time.sleep(1)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 尝试用文本查找微信登录
|
||||
for name in wechat_names:
|
||||
try:
|
||||
el = self._page.get_by_text(name, exact=False).first
|
||||
if el.is_visible(timeout=500):
|
||||
self.log(f"[KDocs] 点击微信登录文本: {name}")
|
||||
el.click()
|
||||
time.sleep(1)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.log("[KDocs] 未找到登录按钮,可能页面已在登录状态或需要手动操作")
|
||||
if not clicked:
|
||||
time.sleep(1)
|
||||
|
||||
def _capture_qr_image(self) -> Optional[bytes]:
|
||||
"""捕获登录二维码图片"""
|
||||
@@ -436,31 +403,24 @@ class KDocsUploader:
|
||||
return {"success": False, "error": self._last_error or "打开文档失败"}
|
||||
|
||||
# 检查是否已登录
|
||||
self.log(f"[KDocs] 当前页面URL: {self._page.url}")
|
||||
if not force and self._is_logged_in():
|
||||
self._logged_in = True
|
||||
self._save_login_state()
|
||||
return {"success": True, "logged_in": True, "qr_image": ""}
|
||||
|
||||
# 需要登录,获取二维码
|
||||
self.log("[KDocs] 需要登录,尝试打开登录对话框...")
|
||||
self._ensure_login_dialog()
|
||||
time.sleep(2) # 等待登录对话框加载
|
||||
time.sleep(2)
|
||||
|
||||
self.log("[KDocs] 尝试捕获二维码...")
|
||||
qr_image = None
|
||||
for i in range(15): # 增加尝试次数
|
||||
for _ in range(15):
|
||||
qr_image = self._capture_qr_image()
|
||||
if qr_image and len(qr_image) > 1024:
|
||||
self.log(f"[KDocs] 二维码捕获成功,大小: {len(qr_image)} bytes")
|
||||
break
|
||||
self.log(f"[KDocs] 第{i+1}次尝试捕获二维码...")
|
||||
time.sleep(1)
|
||||
|
||||
if not qr_image:
|
||||
# 尝试截取整个页面帮助调试
|
||||
self.log("[KDocs] 二维码捕获失败,当前页面可能没有显示二维码")
|
||||
return {"success": False, "error": "二维码获取失败,请检查网络或手动打开金山文档链接确认"}
|
||||
return {"success": False, "error": "二维码获取失败,请检查网络"}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -490,7 +450,6 @@ class KDocsUploader:
|
||||
try:
|
||||
confirm_btn = frame.get_by_role("button", name=name)
|
||||
if confirm_btn.count() > 0 and confirm_btn.first.is_visible(timeout=200):
|
||||
self.log(f"[KDocs] 找到确认按钮: {name}")
|
||||
confirm_btn.first.click()
|
||||
clicked_confirm = True
|
||||
time.sleep(3)
|
||||
@@ -504,7 +463,6 @@ class KDocsUploader:
|
||||
try:
|
||||
el = frame.get_by_text(name, exact=True)
|
||||
if el.count() > 0 and el.first.is_visible(timeout=200):
|
||||
self.log(f"[KDocs] 找到确认文本: {name}")
|
||||
el.first.click()
|
||||
clicked_confirm = True
|
||||
time.sleep(3)
|
||||
@@ -515,7 +473,6 @@ class KDocsUploader:
|
||||
# 尝试用CSS选择器查找
|
||||
if not clicked_confirm:
|
||||
try:
|
||||
# WPS登录页面的确认按钮可能的选择器
|
||||
selectors = [
|
||||
"button.ant-btn-primary",
|
||||
"button[type='primary']",
|
||||
@@ -537,7 +494,6 @@ class KDocsUploader:
|
||||
if btn.is_visible(timeout=100):
|
||||
btn_text = btn.inner_text() or ""
|
||||
if any(kw in btn_text for kw in ["确认", "登录", "确定"]):
|
||||
self.log(f"[KDocs] 找到按钮(CSS): {btn_text}")
|
||||
btn.click()
|
||||
clicked_confirm = True
|
||||
time.sleep(3)
|
||||
@@ -549,37 +505,28 @@ class KDocsUploader:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 如果点击了确认按钮,等待页面自动跳转(不要reload!)
|
||||
if clicked_confirm:
|
||||
self.log("[KDocs] 已点击确认,等待页面跳转...")
|
||||
time.sleep(3) # 等待页面自动跳转
|
||||
time.sleep(3)
|
||||
|
||||
# 检查当前URL是否已经到达文档页面
|
||||
current_url = self._page.url
|
||||
self.log(f"[KDocs] 当前URL: {current_url}")
|
||||
|
||||
# 直接检查URL判断是否已登录
|
||||
if "kdocs.cn/l/" in current_url and "account.wps.cn" not in current_url:
|
||||
# 已到达文档页面,登录成功
|
||||
logged_in = True
|
||||
# 尝试点击可能存在的"加入编辑"按钮
|
||||
try:
|
||||
join_btn = self._page.get_by_role("button", name="登录并加入编辑")
|
||||
if join_btn.count() > 0 and join_btn.first.is_visible(timeout=500):
|
||||
self.log("[KDocs] 点击加入编辑")
|
||||
join_btn.first.click()
|
||||
time.sleep(1)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# 还在登录页面或其他页面
|
||||
logged_in = self._is_logged_in()
|
||||
|
||||
self._logged_in = logged_in
|
||||
|
||||
if logged_in:
|
||||
self._save_login_state()
|
||||
self.log("[KDocs] 登录状态检测:已登录")
|
||||
|
||||
return {"success": True, "logged_in": logged_in}
|
||||
|
||||
@@ -701,7 +648,6 @@ class KDocsUploader:
|
||||
file_chooser.set_files(image_path)
|
||||
|
||||
time.sleep(2)
|
||||
self.log(f"[KDocs] 图片已上传到 {cell_address}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -764,7 +710,6 @@ class KDocsUploader:
|
||||
pass
|
||||
|
||||
# 搜索姓名找到行
|
||||
self.log(f"[KDocs] 搜索人员: {name}")
|
||||
row_num = self._search_and_get_row(
|
||||
name,
|
||||
expected_col=kdocs_config.name_column,
|
||||
@@ -775,8 +720,6 @@ class KDocsUploader:
|
||||
if row_num < 0:
|
||||
return {"success": False, "error": f"未找到人员: {name}"}
|
||||
|
||||
self.log(f"[KDocs] 找到人员在第 {row_num} 行")
|
||||
|
||||
# 上传图片
|
||||
if self._upload_image_to_cell(row_num, image_path, kdocs_config.image_column):
|
||||
return {"success": True}
|
||||
|
||||
@@ -312,7 +312,6 @@ def take_screenshot(
|
||||
)
|
||||
|
||||
if success and os.path.exists(screenshot_path) and os.path.getsize(screenshot_path) > 1000:
|
||||
log(f"[OK] 截图成功: {screenshot_filename}")
|
||||
result.success = True
|
||||
result.filename = screenshot_filename
|
||||
result.filepath = screenshot_path
|
||||
|
||||
Reference in New Issue
Block a user