diff --git a/src/compat/workflow_executor.rs b/src/compat/workflow_executor.rs index 12f0a8b..95f97a7 100644 --- a/src/compat/workflow_executor.rs +++ b/src/compat/workflow_executor.rs @@ -5,16 +5,30 @@ use regex::Regex; use serde_json::{json, Value}; use zeroclaw::tools::Tool; +use crate::compat::runtime::CompatTaskContext; use crate::compat::openxml_office_tool::OpenXmlOfficeTool; use crate::compat::screen_html_export_tool::ScreenHtmlExportTool; -use crate::pipe::{Action, AgentMessage, BrowserPipeTool, PipeError, Transport}; +use crate::pipe::{ + Action, + AgentMessage, + BrowserPipeTool, + ConversationMessage, + PipeError, + Transport, +}; const ZHIHU_DOMAIN: &str = "www.zhihu.com"; +const ZHIHU_EDITOR_DOMAIN: &str = "zhuanlan.zhihu.com"; const ZHIHU_HOT_URL: &str = "https://www.zhihu.com/hot"; +const ZHIHU_CREATOR_URL: &str = "https://www.zhihu.com/creator"; +const ZHIHU_EDITOR_URL: &str = "https://zhuanlan.zhihu.com/write"; #[derive(Debug, Clone, PartialEq, Eq)] pub enum WorkflowRoute { ZhihuHotlistExportXlsx, ZhihuHotlistScreen, + ZhihuArticleEntry, + ZhihuArticleDraft, + ZhihuArticlePublish, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -24,25 +38,47 @@ struct HotlistItem { heat: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct ArticleDraft { + title: String, + body: String, +} + pub fn detect_route( instruction: &str, page_url: Option<&str>, page_title: Option<&str>, ) -> Option { - if !crate::runtime::is_zhihu_hotlist_task(instruction, page_url, page_title) { - return None; + if crate::runtime::is_zhihu_hotlist_task(instruction, page_url, page_title) { + let normalized = instruction.to_ascii_lowercase(); + if normalized.contains("dashboard") || instruction.contains("大屏") || instruction.contains("新标签页") { + return Some(WorkflowRoute::ZhihuHotlistScreen); + } + if normalized.contains("excel") || normalized.contains("xlsx") || instruction.contains("导出") { + return Some(WorkflowRoute::ZhihuHotlistExportXlsx); + } } - - let normalized = instruction.to_ascii_lowercase(); - if normalized.contains("dashboard") || instruction.contains("大屏") || instruction.contains("新标签页") { - return Some(WorkflowRoute::ZhihuHotlistScreen); + if task_requests_zhihu_article_entry(instruction, page_url, page_title) { + return Some(WorkflowRoute::ZhihuArticleEntry); } - if normalized.contains("excel") || normalized.contains("xlsx") || instruction.contains("导出") { - return Some(WorkflowRoute::ZhihuHotlistExportXlsx); + if crate::runtime::task_requests_zhihu_article_publish(instruction, page_url, page_title) { + return Some(WorkflowRoute::ZhihuArticlePublish); + } + if crate::runtime::is_zhihu_write_task(instruction, page_url, page_title) { + return Some(WorkflowRoute::ZhihuArticleDraft); } None } +pub fn prefers_direct_execution(route: &WorkflowRoute) -> bool { + matches!( + route, + WorkflowRoute::ZhihuArticleEntry | + WorkflowRoute::ZhihuArticleDraft | + WorkflowRoute::ZhihuArticlePublish + ) +} + pub fn should_fallback_after_summary(summary: &str, route: &WorkflowRoute) -> bool { let normalized = summary.to_ascii_lowercase(); if normalized.contains(".xlsx") || normalized.contains(".html") { @@ -52,10 +88,19 @@ pub fn should_fallback_after_summary(summary: &str, route: &WorkflowRoute) -> bo let looks_like_denial = summary.contains("拒绝") || normalized.contains("denied") || normalized.contains("failed") || + normalized.contains("protocol error") || + normalized.contains("maximum tool iterations") || summary.contains("失败") || summary.contains("无法"); - looks_like_denial || matches!(route, WorkflowRoute::ZhihuHotlistExportXlsx | WorkflowRoute::ZhihuHotlistScreen) + looks_like_denial || matches!( + route, + WorkflowRoute::ZhihuHotlistExportXlsx | + WorkflowRoute::ZhihuHotlistScreen | + WorkflowRoute::ZhihuArticleEntry | + WorkflowRoute::ZhihuArticleDraft | + WorkflowRoute::ZhihuArticlePublish + ) } pub fn execute_route( @@ -63,19 +108,33 @@ pub fn execute_route( browser_tool: &BrowserPipeTool, workspace_root: &Path, instruction: &str, + task_context: &CompatTaskContext, route: WorkflowRoute, ) -> Result { - let top_n = extract_top_n(instruction); - let items = collect_hotlist_items(transport, browser_tool, top_n)?; - if items.is_empty() { - return Err(PipeError::Protocol( - "知乎热榜采集失败:未能从页面文本中解析到热榜条目".to_string(), - )); - } - match route { - WorkflowRoute::ZhihuHotlistExportXlsx => export_xlsx(transport, workspace_root, &items), - WorkflowRoute::ZhihuHotlistScreen => export_screen(transport, workspace_root, &items), + WorkflowRoute::ZhihuHotlistExportXlsx | WorkflowRoute::ZhihuHotlistScreen => { + let top_n = extract_top_n(instruction); + let items = collect_hotlist_items(transport, browser_tool, top_n)?; + if items.is_empty() { + return Err(PipeError::Protocol( + "知乎热榜采集失败:未能从页面文本中解析到热榜条目".to_string(), + )); + } + match route { + WorkflowRoute::ZhihuHotlistExportXlsx => export_xlsx(transport, workspace_root, &items), + WorkflowRoute::ZhihuHotlistScreen => export_screen(transport, workspace_root, &items), + _ => unreachable!("handled by outer match"), + } + } + WorkflowRoute::ZhihuArticleEntry => { + execute_zhihu_article_entry_route(transport, browser_tool) + } + WorkflowRoute::ZhihuArticleDraft => { + execute_zhihu_article_route(transport, browser_tool, instruction, task_context, false) + } + WorkflowRoute::ZhihuArticlePublish => { + execute_zhihu_article_route(transport, browser_tool, instruction, task_context, true) + } } } @@ -210,26 +269,153 @@ fn export_screen( Ok(format!("已生成知乎热榜大屏 {output_path}")) } -fn load_hotlist_extractor_script(top_n: usize) -> Result { - let script_path = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR"))) - .join("skill_lib") - .join("skills") - .join("zhihu-hotlist") - .join("scripts") - .join("extract_hotlist.js"); - let script = fs::read_to_string(&script_path).map_err(|err| { - PipeError::Protocol(format!( - "failed to read zhihu hotlist extractor script {}: {err}", - script_path.display() - )) +fn execute_zhihu_article_route( + transport: &T, + browser_tool: &BrowserPipeTool, + instruction: &str, + task_context: &CompatTaskContext, + publish_mode: bool, +) -> Result { + let Some(article) = extract_article_draft(instruction, &task_context.messages) else { + return Ok( + "这类知乎文章任务需要同时提供标题和正文后我才能继续确定性写作流程。请按“标题:…\\n正文:…”的格式补充内容。" + .to_string(), + ); + }; + + if publish_mode && !has_explicit_publish_confirmation(instruction) { + return Ok(build_publish_confirmation_message(&article)); + } + + navigate_zhihu_page(transport, browser_tool, ZHIHU_CREATOR_URL)?; + transport.send(&AgentMessage::LogEntry { + level: "info".to_string(), + message: "call zhihu-navigate.open_creator_entry".to_string(), })?; - Ok(format!( - "(function() {{\nconst args = {};\n{}\n}})()", + let creator_state = execute_browser_skill_script( + browser_tool, + "zhihu-navigate", + "open_creator_entry.js", + json!({ "desired_target": "article_editor" }), + ZHIHU_DOMAIN, + )?; + if is_login_required_payload(&creator_state) { + return Ok(build_login_block_message(payload_current_url(&creator_state))); + } + if payload_status(&creator_state) == Some("creator_home") { + return Ok(build_creator_entry_missing_message(payload_current_url( + &creator_state, + ))); + } + navigate_to_editor_after_creator_entry(transport, browser_tool, &creator_state)?; + + transport.send(&AgentMessage::LogEntry { + level: "info".to_string(), + message: "call zhihu-write.prepare_article_editor".to_string(), + })?; + let editor_state = execute_browser_skill_script( + browser_tool, + "zhihu-write", + "prepare_article_editor.js", + json!({ "desired_mode": if publish_mode { "publish" } else { "draft" } }), + ZHIHU_EDITOR_DOMAIN, + )?; + if is_login_required_payload(&editor_state) { + return Ok(build_login_block_message(payload_current_url(&editor_state))); + } + if payload_status(&editor_state) != Some("editor_ready") { + return Ok(build_editor_unavailable_message(payload_current_url(&editor_state))); + } + + transport.send(&AgentMessage::LogEntry { + level: "info".to_string(), + message: "call zhihu-write.fill_article_draft".to_string(), + })?; + let fill_result = execute_browser_skill_script( + browser_tool, + "zhihu-write", + "fill_article_draft.js", + json!({ + "title": article.title, + "body": article.body, + "publish_mode": publish_mode.to_string(), + }), + ZHIHU_EDITOR_DOMAIN, + )?; + if is_login_required_payload(&fill_result) { + return Ok(build_login_block_message(payload_current_url(&fill_result))); + } + + match payload_status(&fill_result) { + Some("draft_ready") => Ok(format!("已进入知乎文章编辑器并写入草稿《{}》", article.title)), + Some("publish_clicked") | Some("publish_submitted") => { + Ok(format!("已提交知乎文章发布流程《{}》", article.title)) + } + Some("publish_button_missing") => Err(PipeError::Protocol( + "知乎文章流程失败:未找到发布按钮".to_string(), + )), + Some("editor_not_ready") => Err(PipeError::Protocol( + "知乎文章流程失败:编辑器尚未准备就绪".to_string(), + )), + _ => Err(PipeError::Protocol(format!( + "知乎文章流程失败:浏览器脚本返回了未知状态 {fill_result}" + ))), + } +} + +fn execute_zhihu_article_entry_route( + transport: &T, + browser_tool: &BrowserPipeTool, +) -> Result { + navigate_zhihu_page(transport, browser_tool, ZHIHU_CREATOR_URL)?; + transport.send(&AgentMessage::LogEntry { + level: "info".to_string(), + message: "call zhihu-navigate.open_creator_entry".to_string(), + })?; + let creator_state = execute_browser_skill_script( + browser_tool, + "zhihu-navigate", + "open_creator_entry.js", + json!({ "desired_target": "article_editor" }), + ZHIHU_DOMAIN, + )?; + if is_login_required_payload(&creator_state) { + return Ok(build_login_block_message(payload_current_url(&creator_state))); + } + if payload_status(&creator_state) == Some("creator_home") { + return Ok(build_creator_entry_missing_message(payload_current_url( + &creator_state, + ))); + } + navigate_to_editor_after_creator_entry(transport, browser_tool, &creator_state)?; + + transport.send(&AgentMessage::LogEntry { + level: "info".to_string(), + message: "call zhihu-write.prepare_article_editor".to_string(), + })?; + let editor_state = execute_browser_skill_script( + browser_tool, + "zhihu-write", + "prepare_article_editor.js", + json!({ "desired_mode": "draft" }), + ZHIHU_EDITOR_DOMAIN, + )?; + if is_login_required_payload(&editor_state) { + return Ok(build_login_block_message(payload_current_url(&editor_state))); + } + if payload_status(&editor_state) == Some("editor_ready") { + return Ok("已进入知乎文章编辑器。".to_string()); + } + + Ok(build_editor_unavailable_message(payload_current_url(&editor_state))) +} + +fn load_hotlist_extractor_script(top_n: usize) -> Result { + load_browser_skill_script( + "zhihu-hotlist", + "extract_hotlist.js", json!({ "top_n": top_n.to_string() }), - script - )) + ) } fn parse_hotlist_items_payload(payload: &Value) -> Result, PipeError> { @@ -283,3 +469,262 @@ fn extract_top_n(instruction: &str) -> usize { .filter(|value| *value > 0) .unwrap_or(10) } + +fn navigate_zhihu_page( + transport: &T, + browser_tool: &BrowserPipeTool, + url: &str, +) -> Result<(), PipeError> { + transport.send(&AgentMessage::LogEntry { + level: "info".to_string(), + message: format!("navigate {url}"), + })?; + let response = browser_tool.invoke(Action::Navigate, json!({ "url": url }), ZHIHU_DOMAIN)?; + if response.success { + Ok(()) + } else { + Err(PipeError::Protocol(format!("navigate failed: {}", response.data))) + } +} + +fn execute_browser_skill_script( + browser_tool: &BrowserPipeTool, + skill_name: &str, + script_name: &str, + args: Value, + expected_domain: &str, +) -> Result { + let wrapped_script = load_browser_skill_script(skill_name, script_name, args)?; + let response = browser_tool.invoke( + Action::Eval, + json!({ "script": wrapped_script }), + expected_domain, + )?; + if !response.success { + return Err(PipeError::Protocol(format!( + "browser script failed: {}", + response.data + ))); + } + + Ok(normalize_payload(response.data.get("text").unwrap_or(&response.data))) +} + +fn navigate_to_editor_after_creator_entry( + transport: &T, + browser_tool: &BrowserPipeTool, + creator_state: &Value, +) -> Result<(), PipeError> { + let status = payload_status(creator_state); + if status == Some("editor_ready") { + return Ok(()); + } + + let target_url = payload_next_url(creator_state).unwrap_or(ZHIHU_EDITOR_URL); + if status == Some("creator_entry_clicked") || status == Some("creator_entry_found") { + transport.send(&AgentMessage::LogEntry { + level: "info".to_string(), + message: format!("navigate {target_url}"), + })?; + let response = browser_tool.invoke( + Action::Navigate, + json!({ "url": target_url }), + ZHIHU_EDITOR_DOMAIN, + )?; + if !response.success { + return Err(PipeError::Protocol(format!( + "navigate failed: {}", + response.data + ))); + } + } + + Ok(()) +} + +fn load_browser_skill_script( + skill_name: &str, + script_name: &str, + args: Value, +) -> Result { + let script_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR"))) + .join("skill_lib") + .join("skills") + .join(skill_name) + .join("scripts") + .join(script_name); + let script = fs::read_to_string(&script_path).map_err(|err| { + PipeError::Protocol(format!( + "failed to read browser script {}: {err}", + script_path.display() + )) + })?; + Ok(format!( + "(function() {{\nconst args = {};\n{}\n}})()", + args, + script + )) +} + +fn normalize_payload(payload: &Value) -> Value { + if let Some(text) = payload.as_str() { + serde_json::from_str::(text).unwrap_or_else(|_| Value::String(text.to_string())) + } else { + payload.clone() + } +} + +fn payload_status(payload: &Value) -> Option<&str> { + payload.get("status").and_then(Value::as_str) +} + +fn payload_current_url(payload: &Value) -> Option<&str> { + payload.get("current_url").and_then(Value::as_str) +} + +fn payload_next_url(payload: &Value) -> Option<&str> { + payload.get("next_url").and_then(Value::as_str) +} + +fn is_login_required_payload(payload: &Value) -> bool { + payload_status(payload) == Some("login_required") +} + +fn build_login_block_message(current_url: Option<&str>) -> String { + let suffix = current_url + .filter(|value| !value.is_empty()) + .map(|value| format!(" 当前页面:{value}。")) + .unwrap_or_default(); + format!( + "当前知乎浏览器会话未登录,无法进入创作者中心或发布文章。请先登录知乎后再继续。{suffix}" + ) +} + +fn build_editor_unavailable_message(current_url: Option<&str>) -> String { + let suffix = current_url + .filter(|value| !value.is_empty()) + .map(|value| format!(" 当前页面:{value}。")) + .unwrap_or_default(); + format!( + "已进入知乎创作者流程,但当前未检测到文章编辑器。可能原因是页面仍在加载、当前账号暂未开放写作入口,或知乎页面结构发生变化。请确认当前知乎账号已登录且具备发文权限,然后在页面稳定后重试。{suffix}" + ) +} + +fn build_creator_entry_missing_message(current_url: Option<&str>) -> String { + let suffix = current_url + .filter(|value| !value.is_empty()) + .map(|value| format!(" 当前页面:{value}。")) + .unwrap_or_default(); + format!( + "已进入知乎创作者中心,但当前未找到“写文章”入口。请确认页面已加载完成,且当前账号具备文章发布入口后再重试。{suffix}" + ) +} + +fn build_publish_confirmation_message(article: &ArticleDraft) -> String { + format!( + "我已收到这篇知乎文章的内容,但在当前会话里还没有拿到明确发布确认。\n\n标题:{}\n正文:{}\n\n如果你确定现在要发布,请直接回复“确认发布”。在收到明确确认之前,我不会执行任何发布动作。", + article.title, + article.body + ) +} + +fn has_explicit_publish_confirmation(instruction: &str) -> bool { + let trimmed = instruction.trim(); + trimmed.contains("确认发布") || + trimmed.contains("确认发表") || + trimmed.contains("现在发布") || + trimmed.contains("立即发布") || + trimmed.contains("可以发布") +} + +fn task_requests_zhihu_article_entry( + instruction: &str, + page_url: Option<&str>, + page_title: Option<&str>, +) -> bool { + if !crate::runtime::is_zhihu_write_task(instruction, page_url, page_title) { + return false; + } + + let normalized = instruction.to_ascii_lowercase(); + let asks_to_open = normalized.contains("open") || + normalized.contains("goto") || + normalized.contains("go to") || + instruction.contains("打开") || + instruction.contains("进入") || + instruction.contains("去"); + let mentions_entry = instruction.contains("页面") || + instruction.contains("入口") || + instruction.contains("创作中心") || + instruction.contains("写文章") || + instruction.contains("发文章"); + let has_article_inputs = parse_article_draft(instruction).is_some(); + + asks_to_open && mentions_entry && !has_article_inputs +} + +fn extract_article_draft( + instruction: &str, + messages: &[ConversationMessage], +) -> Option { + parse_article_draft(instruction).or_else(|| { + messages + .iter() + .rev() + .filter(|message| message.role == "user") + .find_map(|message| parse_article_draft(&message.content)) + }) +} + +fn parse_article_draft(text: &str) -> Option { + let normalized = normalize_article_draft_input(text); + let title_re = Regex::new(r"(?m)^标题[::]\s*(.+?)\s*$").expect("valid zhihu title regex"); + let body_re = + Regex::new(r"(?s)正文[::]\s*(.+)$").expect("valid zhihu body regex"); + let inline_title_re = Regex::new(r"标题(?:是|为)\s*([^,,\n]+)") + .expect("valid inline zhihu title regex"); + let inline_body_re = Regex::new(r"(?s)正文(?:是|为)\s*(.+)$") + .expect("valid inline zhihu body regex"); + + let title = title_re + .captures(&normalized) + .and_then(|capture| capture.get(1)) + .map(|value| value.as_str().trim().to_string()) + .or_else(|| { + inline_title_re + .captures(&normalized) + .and_then(|capture| capture.get(1)) + .map(|value| value.as_str().trim().to_string()) + })?; + let body = body_re + .captures(&normalized) + .and_then(|capture| capture.get(1)) + .map(|value| value.as_str().trim().to_string()) + .or_else(|| { + inline_body_re + .captures(&normalized) + .and_then(|capture| capture.get(1)) + .map(|value| value.as_str().trim().trim_end_matches('。').to_string()) + })?; + + if title.is_empty() || body.is_empty() { + return None; + } + + Some(ArticleDraft { title, body }) +} + +fn normalize_article_draft_input(text: &str) -> String { + let trimmed = text.trim(); + let unquoted = if trimmed.len() >= 2 && + ((trimmed.starts_with('"') && trimmed.ends_with('"')) || + (trimmed.starts_with('\'') && trimmed.ends_with('\''))) + { + &trimmed[1..trimmed.len() - 1] + } else { + trimmed + }; + unquoted.replace("\\n", "\n") +} diff --git a/tests/compat_runtime_test.rs b/tests/compat_runtime_test.rs index 2b3e365..82449a1 100644 --- a/tests/compat_runtime_test.rs +++ b/tests/compat_runtime_test.rs @@ -1919,6 +1919,62 @@ fn browser_attached_excel_request_uses_execution_contract_not_skill_source_stuff assert!(!first_request.contains("Preloaded skill context:")); } +#[test] +fn browser_attached_publish_request_injects_confirmation_contract() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let response = json!({ + "choices": [{ + "message": { + "content": "请先确认是否发布" + } + }] + }); + let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![response]); + + let workspace_root = temp_workspace_root(); + let mut settings = SgClawSettings::from_legacy_deepseek_fields( + "deepseek-test-key".to_string(), + base_url, + "deepseek-chat".to_string(), + Some(real_skill_lib_root()), + ) + .unwrap(); + settings.runtime_profile = RuntimeProfile::BrowserAttached; + + let transport = Arc::new(MockTransport::new(vec![])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + zhihu_test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + execute_task_with_sgclaw_settings( + transport.as_ref(), + browser_tool, + "请直接发表这篇知乎文章,标题是测试标题,正文是第一段内容", + &CompatTaskContext { + conversation_id: None, + messages: vec![], + page_url: Some("https://www.zhihu.com/creator".to_string()), + page_title: Some("知乎创作中心".to_string()), + }, + &workspace_root, + &settings, + ) + .unwrap(); + server_handle.join().unwrap(); + + let request_bodies = requests.lock().unwrap().clone(); + let first_request = request_bodies[0].to_string(); + + assert!(first_request.contains("Zhihu article publish contract")); + assert!(first_request.contains("must not click publish without explicit human confirmation")); + assert!(first_request.contains("ask for confirmation concisely")); + assert!(first_request.contains("stop after the confirmation request")); +} + #[test] fn handle_browser_message_executes_real_zhihu_hotlist_skill_flow() { let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); @@ -2491,6 +2547,7 @@ fn browser_submit_path_prefers_zeroclaw_process_message_orchestrator() { .unwrap(); let sent = transport.sent_messages(); + dbg!(&sent); assert!(sent.iter().any(|message| { matches!( @@ -2508,6 +2565,592 @@ fn browser_submit_path_prefers_zeroclaw_process_message_orchestrator() { })); } +#[test] +fn browser_submit_path_prefers_zeroclaw_process_message_orchestrator_for_zhihu_publish() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let workspace_root = temp_workspace_root(); + let config_path = write_deepseek_config_with_skills_dir( + &workspace_root, + "deepseek-test-key", + "http://127.0.0.1:9", + "deepseek-chat", + Some(real_skill_lib_root().to_str().unwrap()), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + zhihu_test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "请直接发表这篇知乎文章,标题是测试标题,正文是第一段内容".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "https://www.zhihu.com/".to_string(), + page_title: "知乎".to_string(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "mode" && message == "zeroclaw_process_message_primary" + ) + })); + assert!(!sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "mode" && (message == "compat_llm_primary" || message == "compat_skill_runner_primary") + ) + })); +} + +#[test] +fn zhihu_publish_task_matches_primary_orchestration_gate() { + assert!(sgclaw::compat::orchestration::should_use_primary_orchestration( + "请直接发表这篇知乎文章,标题是测试标题,正文是第一段内容", + Some("https://www.zhihu.com/"), + Some("知乎"), + )); +} + +#[test] +fn zhihu_article_entry_task_matches_primary_orchestration_gate() { + assert!(sgclaw::compat::orchestration::should_use_primary_orchestration( + "打开知乎发文章页面", + Some("https://www.zhihu.com/"), + Some("知乎"), + )); +} + +#[test] +fn zhihu_publish_without_article_inputs_returns_missing_fields_prompt() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let workspace_root = temp_workspace_root(); + let config_path = write_deepseek_config_with_skills_dir( + &workspace_root, + "deepseek-test-key", + "http://127.0.0.1:9", + "deepseek-chat", + Some(real_skill_lib_root().to_str().unwrap()), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + zhihu_test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "请发表知乎文章".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "https://www.zhihu.com/".to_string(), + page_title: "知乎".to_string(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if *success && + summary.contains("标题") && + summary.contains("正文") + ) + })); + assert!(!sent.iter().any(|message| { + matches!(message, AgentMessage::Command { .. }) + })); +} + +#[test] +fn zhihu_publish_accepts_literal_backslash_n_between_title_and_body() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let workspace_root = temp_workspace_root(); + let config_path = write_deepseek_config_with_skills_dir( + &workspace_root, + "deepseek-test-key", + "http://127.0.0.1:9", + "deepseek-chat", + Some(real_skill_lib_root().to_str().unwrap()), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![ + success_browser_response(1, json!({ "navigated": true })), + success_browser_response( + 2, + json!({ + "text": { + "status": "creator_entry_clicked", + "current_url": "https://www.zhihu.com/creator", + "next_url": "https://zhuanlan.zhihu.com/write" + } + }), + ), + success_browser_response(3, json!({ "navigated": true })), + success_browser_response( + 4, + json!({ + "text": { + "status": "editor_ready", + "current_url": "https://zhuanlan.zhihu.com/write" + } + }), + ), + success_browser_response( + 5, + json!({ + "text": { + "status": "draft_ready", + "current_url": "https://zhuanlan.zhihu.com/write", + "title": "ai时代,普通人如何自救" + } + }), + ), + ])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + zhihu_test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "标题:ai时代,普通人如何自救 \\n正文:第一段内容。 第二段内容。".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "https://www.zhihu.com/creator".to_string(), + page_title: "知乎创作中心".to_string(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if *success && summary == "已进入知乎文章编辑器并写入草稿《ai时代,普通人如何自救》" + ) + })); +} + +#[test] +fn zhihu_article_entry_opens_editor_without_generic_selector_probing() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let workspace_root = temp_workspace_root(); + let config_path = write_deepseek_config_with_skills_dir( + &workspace_root, + "deepseek-test-key", + "http://127.0.0.1:9", + "deepseek-chat", + Some(real_skill_lib_root().to_str().unwrap()), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![ + success_browser_response(1, json!({ "navigated": true })), + success_browser_response( + 2, + json!({ + "text": { + "status": "creator_entry_clicked", + "current_url": "https://www.zhihu.com/creator", + "next_url": "https://zhuanlan.zhihu.com/write" + } + }), + ), + success_browser_response(3, json!({ "navigated": true })), + success_browser_response( + 4, + json!({ + "text": { + "status": "editor_ready", + "current_url": "https://zhuanlan.zhihu.com/write" + } + }), + ), + ])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + zhihu_test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "打开知乎发文章页面".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "https://www.zhihu.com/".to_string(), + page_title: "知乎".to_string(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if *success && summary.contains("编辑器") + ) + })); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "mode" && message == "zeroclaw_process_message_primary" + ) + })); + assert!(!sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "info" && + (message.starts_with("getText ") || message.starts_with("click ")) + ) + })); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::Command { action, params, .. } + if action == &Action::Navigate && + params["url"].as_str() == Some("https://zhuanlan.zhihu.com/write") + ) + })); +} + +#[test] +fn zhihu_article_entry_reports_editor_unavailable_without_protocol_error() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let workspace_root = temp_workspace_root(); + let config_path = write_deepseek_config_with_skills_dir( + &workspace_root, + "deepseek-test-key", + "http://127.0.0.1:9", + "deepseek-chat", + Some(real_skill_lib_root().to_str().unwrap()), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![ + success_browser_response(1, json!({ "navigated": true })), + success_browser_response( + 2, + json!({ + "text": { + "status": "creator_entry_clicked", + "current_url": "https://www.zhihu.com/creator", + "next_url": "https://zhuanlan.zhihu.com/write" + } + }), + ), + success_browser_response(3, json!({ "navigated": true })), + success_browser_response( + 4, + json!({ + "text": { + "status": "editor_unavailable", + "current_url": "https://zhuanlan.zhihu.com/write" + } + }), + ), + ])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + zhihu_test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "打开知乎发文章页面".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "https://www.zhihu.com/".to_string(), + page_title: "知乎".to_string(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if *success && + summary.contains("未检测到文章编辑器") + ) + })); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::Command { action, params, .. } + if action == &Action::Navigate && + params["url"].as_str() == Some("https://zhuanlan.zhihu.com/write") + ) + })); +} + +#[test] +fn zhihu_article_entry_stops_when_creator_page_has_no_write_entry() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let workspace_root = temp_workspace_root(); + let config_path = write_deepseek_config_with_skills_dir( + &workspace_root, + "deepseek-test-key", + "http://127.0.0.1:9", + "deepseek-chat", + Some(real_skill_lib_root().to_str().unwrap()), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![ + success_browser_response(1, json!({ "navigated": true })), + success_browser_response( + 2, + json!({ + "text": { + "status": "creator_home", + "current_url": "https://www.zhihu.com/creator", + "desired_target": "article_editor" + } + }), + ), + ])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + zhihu_test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "打开知乎发文章页面".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "https://www.zhihu.com/".to_string(), + page_title: "知乎".to_string(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if *success && summary.contains("未找到“写文章”入口") + ) + })); + assert_eq!( + sent.iter() + .filter(|message| matches!(message, AgentMessage::Command { .. })) + .count(), + 2 + ); + assert!(!sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "info" && message == "call zhihu-write.prepare_article_editor" + ) + })); +} + +#[test] +fn zhihu_publish_without_confirmation_returns_confirmation_before_any_browser_probing() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let workspace_root = temp_workspace_root(); + let config_path = write_deepseek_config_with_skills_dir( + &workspace_root, + "deepseek-test-key", + "http://127.0.0.1:9", + "deepseek-chat", + Some(real_skill_lib_root().to_str().unwrap()), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + zhihu_test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "请直接发表这篇知乎文章,标题是测试标题,正文是第一段内容".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: "https://www.zhihu.com/".to_string(), + page_title: "知乎".to_string(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if *success && summary.contains("确认发布") + ) + })); + assert!(!sent.iter().any(|message| { + matches!(message, AgentMessage::Command { .. }) + })); + assert!(!sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "info" && + (message.starts_with("navigate ") || + message.starts_with("getText ") || + message.starts_with("click ") || + message.starts_with("type ")) + ) + })); +} + +#[test] +fn zhihu_publish_after_confirmation_reports_login_block_without_selector_probing() { + let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); + + let workspace_root = temp_workspace_root(); + let config_path = write_deepseek_config_with_skills_dir( + &workspace_root, + "deepseek-test-key", + "http://127.0.0.1:9", + "deepseek-chat", + Some(real_skill_lib_root().to_str().unwrap()), + ); + let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone()); + + let transport = Arc::new(MockTransport::new(vec![ + success_browser_response(1, json!({ "navigated": true, "url": "https://www.zhihu.com/signin?next=%2Fcreator" })), + success_browser_response( + 2, + json!({ + "text": { + "status": "login_required", + "current_url": "https://www.zhihu.com/signin?next=%2Fcreator" + } + }), + ), + ])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + zhihu_test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message_with_context( + transport.as_ref(), + &browser_tool, + &runtime_context, + BrowserMessage::SubmitTask { + instruction: "确认发布".to_string(), + conversation_id: "conversation-1".to_string(), + messages: vec![ConversationMessage { + role: "user".to_string(), + content: "请直接发表这篇知乎文章,标题是测试标题,正文是第一段内容".to_string(), + }], + page_url: "https://www.zhihu.com/".to_string(), + page_title: "知乎".to_string(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::TaskComplete { success, summary } + if *success && (summary.contains("未登录") || summary.contains("登录")) + ) + })); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::Command { action, params, .. } + if action == &Action::Navigate && + params["url"].as_str() == Some("https://www.zhihu.com/creator") + ) + })); + assert!(sent.iter().any(|message| { + matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval) + })); + assert!(!sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "info" && + (message.starts_with("getText ") || message.starts_with("click ")) + ) + })); +} + #[test] fn browser_orchestration_registers_superrpa_tools_natively() { let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); @@ -2948,103 +3591,12 @@ fn handle_browser_message_executes_real_zhihu_navigate_skill_flow() { fn handle_browser_message_executes_real_zhihu_write_skill_flow() { let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); - let first_response = json!({ - "choices": [{ - "message": { - "content": "", - "tool_calls": [{ - "id": "call_1", - "type": "function", - "function": { - "name": "read_skill", - "arguments": serde_json::to_string(&json!({ - "name": "zhihu-write" - })).unwrap() - } - }] - } - }] - }); - let second_response = json!({ - "choices": [{ - "message": { - "content": "", - "tool_calls": [ - { - "id": "call_2", - "type": "function", - "function": { - "name": "browser_action", - "arguments": serde_json::to_string(&json!({ - "action": "navigate", - "expected_domain": "www.zhihu.com", - "url": "https://www.zhihu.com/creator" - })).unwrap() - } - }, - { - "id": "call_3", - "type": "function", - "function": { - "name": "browser_action", - "arguments": serde_json::to_string(&json!({ - "action": "click", - "expected_domain": "www.zhihu.com", - "selector": "a[href='https://zhuanlan.zhihu.com/write']" - })).unwrap() - } - }, - { - "id": "call_4", - "type": "function", - "function": { - "name": "browser_action", - "arguments": serde_json::to_string(&json!({ - "action": "type", - "expected_domain": "zhuanlan.zhihu.com", - "selector": "input[placeholder='请输入标题']", - "text": "测试标题", - "clear_first": true - })).unwrap() - } - }, - { - "id": "call_5", - "type": "function", - "function": { - "name": "browser_action", - "arguments": serde_json::to_string(&json!({ - "action": "type", - "expected_domain": "zhuanlan.zhihu.com", - "selector": ".public-DraftEditor-content", - "text": "第一段内容", - "clear_first": true - })).unwrap() - } - } - ] - } - }] - }); - let third_response = json!({ - "choices": [{ - "message": { - "content": "已完成知乎文章草稿填写" - } - }] - }); - let (base_url, requests, server_handle) = start_fake_deepseek_server(vec![ - first_response, - second_response, - third_response, - ]); - let workspace_root = temp_workspace_root(); let skills_dir = real_skill_lib_root(); let config_path = write_deepseek_config_with_skills_dir( &workspace_root, "deepseek-test-key", - &base_url, + "http://127.0.0.1:9", "deepseek-chat", Some(skills_dir.to_str().unwrap()), ); @@ -3052,9 +3604,36 @@ fn handle_browser_message_executes_real_zhihu_write_skill_flow() { let transport = Arc::new(MockTransport::new(vec![ success_browser_response(1, json!({ "navigated": true })), - success_browser_response(2, json!({ "clicked": true })), - success_browser_response(3, json!({ "typed": true })), - success_browser_response(4, json!({ "typed": true })), + success_browser_response( + 2, + json!({ + "text": { + "status": "creator_entry_clicked", + "current_url": "https://www.zhihu.com/creator", + "next_url": "https://zhuanlan.zhihu.com/write" + } + }), + ), + success_browser_response(3, json!({ "navigated": true })), + success_browser_response( + 4, + json!({ + "text": { + "status": "editor_ready", + "current_url": "https://zhuanlan.zhihu.com/write" + } + }), + ), + success_browser_response( + 5, + json!({ + "text": { + "status": "draft_ready", + "current_url": "https://zhuanlan.zhihu.com/write", + "title": "测试标题" + } + }), + ), ])); let browser_tool = BrowserPipeTool::new( transport.clone(), @@ -3076,24 +3655,42 @@ fn handle_browser_message_executes_real_zhihu_write_skill_flow() { }, ) .unwrap(); - server_handle.join().unwrap(); let sent = transport.sent_messages(); - let request_bodies = requests.lock().unwrap().clone(); - let tool_content = tool_message_content(&request_bodies[1], "call_1").unwrap(); assert!(sent.iter().any(|message| { matches!( message, AgentMessage::TaskComplete { success, summary } - if *success && summary == "已完成知乎文章草稿填写" + if *success && summary == "已进入知乎文章编辑器并写入草稿《测试标题》" ) })); assert!(sent.iter().any(|message| { matches!( message, AgentMessage::LogEntry { level, message } - if level == "info" && message == "read_skill zhihu-write@0.1.0" + if level == "mode" && message == "zeroclaw_process_message_primary" + ) + })); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "info" && message == "call zhihu-navigate.open_creator_entry" + ) + })); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "info" && message == "call zhihu-write.prepare_article_editor" + ) + })); + assert!(sent.iter().any(|message| { + matches!( + message, + AgentMessage::LogEntry { level, message } + if level == "info" && message == "call zhihu-write.fill_article_draft" ) })); assert!(sent.iter().any(|message| { @@ -3108,28 +3705,18 @@ fn handle_browser_message_executes_real_zhihu_write_skill_flow() { matches!( message, AgentMessage::Command { action, params, .. } - if action == &Action::Click && - params["selector"].as_str() == - Some("a[href='https://zhuanlan.zhihu.com/write']") + if action == &Action::Navigate && + params["url"].as_str() == Some("https://zhuanlan.zhihu.com/write") ) })); - assert!(sent.iter().any(|message| { + assert!(sent.iter().filter(|message| { + matches!(message, AgentMessage::Command { action, .. } if action == &Action::Eval) + }).count() >= 2); + assert!(!sent.iter().any(|message| { matches!( message, - AgentMessage::Command { action, params, .. } - if action == &Action::Type && - params["selector"].as_str() == Some("input[placeholder='请输入标题']") + AgentMessage::LogEntry { level, message } + if level == "mode" && (message == "compat_llm_primary" || message == "compat_skill_runner_primary") ) })); - assert!(sent.iter().any(|message| { - matches!( - message, - AgentMessage::Command { action, params, .. } - if action == &Action::Type && - params["selector"].as_str() == Some(".public-DraftEditor-content") - ) - })); - assert_eq!(request_bodies.len(), 3); - assert!(tool_content.len() > 100); - assert!(tool_content.contains("publish a Zhihu article")); } diff --git a/tests/skill_script_zhihu_navigate_test.py b/tests/skill_script_zhihu_navigate_test.py new file mode 100644 index 0000000..8b4f3f2 --- /dev/null +++ b/tests/skill_script_zhihu_navigate_test.py @@ -0,0 +1,146 @@ +import json +import subprocess +import textwrap +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT_PATH = ( + REPO_ROOT.parent / "skill_lib" / "skills" / "zhihu-navigate" / "scripts" / + "open_creator_entry.js" +) + + +def run_open_creator_entry(*, body_text: str, selectors: dict[str, list[dict]]) -> dict: + node_script = textwrap.dedent( + f""" + import fs from 'node:fs'; + import vm from 'node:vm'; + + const scriptPath = {json.dumps(str(SCRIPT_PATH))}; + const selectorMap = {json.dumps(selectors, ensure_ascii=False)}; + const bodyText = {json.dumps(body_text, ensure_ascii=False)}; + const source = fs.readFileSync(scriptPath, 'utf8'); + + function createNode(spec) {{ + const node = {{ + tagName: String(spec?.tagName || 'DIV').toUpperCase(), + textContent: String(spec?.textContent ?? ''), + innerText: String(spec?.innerText ?? spec?.textContent ?? ''), + href: String(spec?.href ?? ''), + clicked: false, + click() {{ + this.clicked = true; + }}, + getBoundingClientRect() {{ + return {{ + width: spec?.visible === false ? 0 : 120, + height: spec?.visible === false ? 0 : 32, + }}; + }}, + }}; + return node; + }} + + const created = new Map(); + function createNodeList(selector) {{ + const specs = selectorMap[selector] || []; + return specs.map((spec, index) => {{ + const key = `${{selector}}#${{index}}`; + if (!created.has(key)) {{ + created.set(key, createNode(spec)); + }} + return created.get(key); + }}); + }} + + const bodyNode = createNode({{ tagName: 'BODY', textContent: bodyText, innerText: bodyText }}); + const context = {{ + args: {{ desired_target: 'article_editor' }}, + location: {{ href: 'https://www.zhihu.com/creator' }}, + document: {{ + body: bodyNode, + querySelector(selector) {{ + if (selector === 'body') {{ + return bodyNode; + }} + return createNodeList(selector)[0] || null; + }}, + querySelectorAll(selector) {{ + return createNodeList(selector); + }}, + }}, + console, + JSON, + Math, + Number, + Object, + RegExp, + Set, + String, + Array, + Error, + }}; + + try {{ + const result = vm.runInNewContext(`(function(){{\\n${{source}}\\n}})()`, context); + process.stdout.write(JSON.stringify({{ ok: true, result, created: Object.fromEntries(created) }})); + }} catch (error) {{ + process.stdout.write(JSON.stringify({{ + ok: false, + error: String(error && error.message ? error.message : error), + }})); + process.exitCode = 1; + }} + """ + ) + completed = subprocess.run( + ["node", "--input-type=module", "-e", node_script], + check=False, + capture_output=True, + text=True, + ) + payload = json.loads(completed.stdout) + if completed.returncode != 0: + raise AssertionError(payload["error"]) + return payload + + +class SkillScriptZhihuNavigateTest(unittest.TestCase): + def test_open_creator_entry_clicks_anchor_write_entry(self): + payload = run_open_creator_entry( + body_text="创作者中心 写文章", + selectors={ + "a[href], button, [role='button']": [ + { + "tagName": "a", + "textContent": "写文章", + "href": "https://zhuanlan.zhihu.com/write", + } + ] + }, + ) + + self.assertEqual(payload["result"]["status"], "creator_entry_clicked") + self.assertTrue(payload["created"]["a[href], button, [role='button']#0"]["clicked"]) + + def test_open_creator_entry_clicks_button_write_entry(self): + payload = run_open_creator_entry( + body_text="创作者中心 发布内容", + selectors={ + "a[href], button, [role='button']": [ + { + "tagName": "button", + "textContent": "写文章", + } + ] + }, + ) + + self.assertEqual(payload["result"]["status"], "creator_entry_clicked") + self.assertTrue(payload["created"]["a[href], button, [role='button']#0"]["clicked"]) + + +if __name__ == "__main__": + unittest.main()