From 6aad2ce48e7bbf85219361e7ac031095d7200975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=A8=E7=82=8E?= <635735027@qq.com> Date: Fri, 27 Mar 2026 14:29:38 +0800 Subject: [PATCH] feat: restore zhihu browser skills Reconnect the recovered Zhihu skill flows to the live browser runtime and resolve their resources relative to the executable so they work outside the repo root. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 344 ++- Cargo.toml | 2 + resources/skills/zhihu_hotlist_flow.json | 19 + resources/skills/zhihu_navigation_pages.json | 2481 ++++++++++++++++++ resources/skills/zhihu_write_flow.json | 126 + src/agent/mod.rs | 61 +- src/agent/runtime.rs | 7 +- src/compat/browser_tool_adapter.rs | 23 +- src/compat/config_adapter.rs | 20 +- src/compat/cron_adapter.rs | 5 +- src/compat/event_bridge.rs | 15 +- src/compat/runtime.rs | 19 +- src/config/settings.rs | 3 +- src/lib.rs | 1 + src/pipe/browser_tool.rs | 66 +- src/pipe/mod.rs | 6 +- src/skill/mod.rs | 85 + src/skill/router.rs | 92 + src/skill/zhihu.rs | 419 +++ src/skill/zhihu_hotlist.rs | 815 ++++++ src/skill/zhihu_hotlist_store.rs | 184 ++ src/skill/zhihu_navigation.rs | 890 +++++++ tests/common/mod.rs | 1 + tests/compat_browser_tool_test.rs | 52 +- tests/compat_config_test.rs | 14 +- tests/compat_runtime_test.rs | 19 +- tests/deepseek_provider_test.rs | 5 +- tests/runtime_task_flow_test.rs | 117 +- tests/skill_router_test.rs | 441 ++++ tests/zhihu_hotlist_skill_test.rs | 403 +++ tests/zhihu_navigation_skill_test.rs | 661 +++++ tests/zhihu_skill_test.rs | 357 +++ 32 files changed, 7607 insertions(+), 146 deletions(-) create mode 100644 resources/skills/zhihu_hotlist_flow.json create mode 100644 resources/skills/zhihu_navigation_pages.json create mode 100644 resources/skills/zhihu_write_flow.json create mode 100644 src/skill/mod.rs create mode 100644 src/skill/router.rs create mode 100644 src/skill/zhihu.rs create mode 100644 src/skill/zhihu_hotlist.rs create mode 100644 src/skill/zhihu_hotlist_store.rs create mode 100644 src/skill/zhihu_navigation.rs create mode 100644 tests/skill_router_test.rs create mode 100644 tests/zhihu_hotlist_skill_test.rs create mode 100644 tests/zhihu_navigation_skill_test.rs create mode 100644 tests/zhihu_skill_test.rs diff --git a/Cargo.lock b/Cargo.lock index 0169227..b02e6f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,6 +33,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -328,6 +329,12 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -418,7 +425,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" dependencies = [ "chrono", - "phf", + "phf 0.12.1", ] [[package]] @@ -604,12 +611,46 @@ dependencies = [ "typenum", ] +[[package]] +name = "cssparser" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3df4f93e5fbbe73ec01ec8d3f68bba73107993a5b1e7519273c32db9b0d5be" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.11.3", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dialoguer" version = "0.12.0" @@ -675,6 +716,21 @@ dependencies = [ "syn", ] +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "dunce" version = "1.0.5" @@ -687,6 +743,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ego-tree" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a0bb14ac04a9fcf170d0bbbef949b44cc492f4452bd20c095636956f653642" + [[package]] name = "either" version = "1.15.0" @@ -839,6 +901,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.32" @@ -936,6 +1008,15 @@ dependencies = [ "thread_local", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -946,6 +1027,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1089,6 +1179,20 @@ dependencies = [ "windows-link", ] +[[package]] +name = "html5ever" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "http" version = "1.4.0" @@ -1543,6 +1647,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + [[package]] name = "mail-parser" version = "0.11.2" @@ -1552,6 +1662,20 @@ dependencies = [ "hashify", ] +[[package]] +name = "markup5ever" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1632,6 +1756,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11ec1bc47d34ae756616f387c11fd0595f86f2cc7e6473bde9e3ded30cb902a1" +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nom" version = "7.1.3" @@ -1737,13 +1867,103 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared 0.10.0", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" dependencies = [ - "phf_shared", + "phf_shared 0.12.1", +] + +[[package]] +name = "phf_codegen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.2", ] [[package]] @@ -1752,7 +1972,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" dependencies = [ - "siphasher", + "siphasher 1.0.2", ] [[package]] @@ -1847,6 +2067,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "prettyplease" version = "0.2.37" @@ -1964,13 +2190,24 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -1985,6 +2222,16 @@ dependencies = [ "rand_core 0.10.0", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -2320,6 +2567,41 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scraper" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90460b31bfe1fc07be8262e42c665ad97118d4585869de9345a84d501a9eaf0" +dependencies = [ + "ahash", + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "once_cell", + "selectors", + "tendril", +] + +[[package]] +name = "selectors" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "fxhash", + "log", + "new_debug_unreachable", + "phf 0.10.1", + "phf_codegen 0.10.0", + "precomputed-hash", + "servo_arc", + "smallvec", +] + [[package]] name = "self_cell" version = "1.2.2" @@ -2418,6 +2700,15 @@ dependencies = [ "serde", ] +[[package]] +name = "servo_arc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d036d71a959e00c77a63538b90a6c2390969f9772b096ea837205c6bd0491a44" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sgclaw" version = "0.1.0" @@ -2428,7 +2719,9 @@ dependencies = [ "futures-util", "hex", "hmac", + "regex", "reqwest", + "scraper", "serde", "serde_json", "sha2", @@ -2506,6 +2799,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "siphasher" version = "1.0.2" @@ -2550,7 +2849,6 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.52.0", "windows-sys 0.59.0", ] @@ -2566,6 +2864,31 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2633,6 +2956,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index 3155d9b..1b758b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,9 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] } futures-util = "0.3" hex = "0.4" hmac = "0.12" +regex = "1" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +scraper = "0.20" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" diff --git a/resources/skills/zhihu_hotlist_flow.json b/resources/skills/zhihu_hotlist_flow.json new file mode 100644 index 0000000..627b0c4 --- /dev/null +++ b/resources/skills/zhihu_hotlist_flow.json @@ -0,0 +1,19 @@ +{ + "hotlist_url": "https://www.zhihu.com/hot", + "domains": { + "zhihu": "www.zhihu.com" + }, + "literals": { + "hotlist_guard": "热榜" + }, + "selectors": { + "hotlist_root": "main, body", + "hotlist_item": ".HotList-item, [data-hot-item], section ol li", + "hotlist_title_link": ".HotList-item-title a, h2 a, .ContentItem-title a", + "hotlist_summary": ".HotList-item-summary, .HotItem-content, .RichContent-inner, .ContentItem-excerpt", + "hotlist_heat": ".HotList-item-heat, .HotItem-metrics, .HotItem-hot", + "comment_list": ".Comments-list, .CommentListV2, [data-testid='comment-list'], .CommentList", + "comment_item": ".Comments-list > .CommentItem, .CommentListV2 > .CommentItem, .CommentItemV2, .CommentItem", + "comment_metric": ".CommentItem-metric, .CommentItem-footer button, .ContentItem-actions button, button" + } +} diff --git a/resources/skills/zhihu_navigation_pages.json b/resources/skills/zhihu_navigation_pages.json new file mode 100644 index 0000000..7933bfd --- /dev/null +++ b/resources/skills/zhihu_navigation_pages.json @@ -0,0 +1,2481 @@ +{ + "domains": { + "main": "www.zhihu.com", + "creator": "www.zhihu.com", + "editor": "zhuanlan.zhihu.com" + }, + "routes": { + "home": { + "title": "首页", + "domain_ref": "main", + "url": "https://www.zhihu.com/", + "aliases": [ + "知乎首页", + "知乎主页", + "知乎主站首页" + ], + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null + }, + "hot_list": { + "title": "热榜", + "domain_ref": "main", + "url": "https://www.zhihu.com/hot", + "aliases": [ + "知乎热榜", + "热榜页面", + "知乎热门榜", + "知乎热搜" + ], + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null + }, + "column_home": { + "title": "专栏页", + "domain_ref": "editor", + "url": "https://zhuanlan.zhihu.com/", + "aliases": [ + "知乎专栏", + "专栏首页", + "知乎专栏页", + "专栏页" + ], + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null + }, + "question_page": { + "title": "问题页", + "domain_ref": "main", + "url": "https://www.zhihu.com/question/waiting", + "aliases": [ + "知乎问题页", + "问题页", + "问题分栏", + "等你来答" + ], + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null + }, + "messages_page": { + "title": "消息分栏", + "domain_ref": "main", + "url": "https://www.zhihu.com/messages", + "aliases": [ + "知乎消息分栏", + "消息分栏", + "知乎消息页", + "消息页", + "私信页面" + ], + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null + }, + "notifications_page": { + "title": "通知分栏", + "domain_ref": "main", + "url": "https://www.zhihu.com/notifications", + "aliases": [ + "知乎通知分栏", + "通知分栏", + "知乎通知页", + "通知页" + ], + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null + }, + "search_results_page": { + "title": "搜索结果页", + "domain_ref": "main", + "url": "https://www.zhihu.com/search?type=content&q=%E7%9F%A5%E4%B9%8E", + "aliases": [ + "知乎搜索结果", + "搜索结果页", + "知乎搜索页" + ], + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null + }, + "creator_home": { + "title": "创作中心", + "domain_ref": "creator", + "url": "https://www.zhihu.com/creator", + "aliases": [ + "知乎创作中心", + "创作者中心", + "知乎创作者中心" + ], + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null + }, + "content_analysis": { + "title": "内容分析", + "domain_ref": "creator", + "url": "https://www.zhihu.com/creator/analytics/work/all", + "aliases": [ + "内容分析页面", + "知乎内容分析", + "知乎内容分析页面", + "创作中心内容分析", + "创作中心内容分析页面" + ], + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null + }, + "income_analysis": { + "title": "收益分析", + "domain_ref": "creator", + "url": "https://www.zhihu.com/creator/knowledge-income", + "aliases": [ + "收益分析页面", + "知乎收益分析", + "知乎收益分析页面", + "创作中心收益分析", + "盐粒收益分析" + ], + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null + }, + "draft_box_entry": { + "title": "草稿箱入口", + "domain_ref": "creator", + "url": "https://www.zhihu.com/creator/manage/creation/all", + "aliases": [ + "草稿箱", + "知乎草稿箱", + "草稿管理", + "草稿入口", + "内容管理" + ], + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null + }, + "answer_management": { + "title": "回答管理", + "domain_ref": "creator", + "url": "https://www.zhihu.com/creator/manage/creation/answer", + "aliases": [ + "回答管理页面", + "知乎回答管理", + "问答管理", + "内容管理问答", + "内容管理回答" + ], + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null + }, + "write_article": { + "title": "写文章", + "domain_ref": "editor", + "url": "https://zhuanlan.zhihu.com/write", + "aliases": [ + "知乎写文章", + "知乎编辑器", + "写知乎文章" + ], + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null + } + }, + "components": { + "top_nav_home": { + "title": "首页", + "domain_ref": "main", + "selector": "a[href='/']", + "aliases": [ + "顶部首页", + "首页按钮" + ], + "entry_route_ref": "home", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 800, + "capture_url": true + }, + "top_nav_hot": { + "title": "热榜", + "domain_ref": "main", + "selector": "a[href='/hot']", + "aliases": [ + "顶部热榜", + "热榜按钮" + ], + "entry_route_ref": "home", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 800, + "capture_url": true + }, + "top_nav_creator": { + "title": "创作中心入口", + "domain_ref": "main", + "selector": "a[href='/creator']", + "aliases": [ + "创作中心入口", + "创作中心按钮", + "创作者中心入口" + ], + "entry_route_ref": "home", + "result_domain_ref": "creator", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 800, + "capture_url": true + }, + "top_nav_notifications": { + "title": "通知菜单", + "domain_ref": "main", + "selector": "button[aria-label='通知'], a[href='/notifications']", + "aliases": [ + "知乎通知菜单", + "通知下拉菜单" + ], + "entry_route_ref": "home", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "notifications_tab_replies": { + "title": "回复我的", + "domain_ref": "main", + "selector": "a[href*='/notifications/replies'], a[href*='notifications'][href*='reply'], [role='tab'][aria-label*='回复'], button[aria-label*='回复我的']", + "aliases": [ + "回复我的", + "回复我的标签", + "回复通知" + ], + "entry_route_ref": "notifications_page", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "notifications_tab_votes_favorites": { + "title": "赞同与收藏", + "domain_ref": "main", + "selector": "a[href*='notifications'][href*='like'], a[href*='notifications'][href*='thank'], a[href*='notifications'][href*='collect'], [role='tab'][aria-label*='赞同'], [role='tab'][aria-label*='收藏']", + "aliases": [ + "赞同与收藏", + "赞同收藏", + "赞同与收藏标签" + ], + "entry_route_ref": "notifications_page", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "notifications_tab_follows": { + "title": "关注我的", + "domain_ref": "main", + "selector": "a[href*='notifications'][href*='follow'], [role='tab'][aria-label*='关注']", + "aliases": [ + "关注我的", + "关注通知", + "关注我的标签" + ], + "entry_route_ref": "notifications_page", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "notifications_tab_system": { + "title": "系统通知", + "domain_ref": "main", + "selector": "a[href*='notifications'][href*='system'], [role='tab'][aria-label*='系统'], button[aria-label*='系统通知']", + "aliases": [ + "系统通知", + "系统通知标签" + ], + "entry_route_ref": "notifications_page", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "notifications_settings_menu": { + "title": "通知设置菜单", + "domain_ref": "main", + "selector": "button[aria-label*='通知设置'], button[aria-label*='设置'], a[href*='notifications/settings']", + "aliases": [ + "通知设置", + "通知设置菜单", + "通知页设置" + ], + "entry_route_ref": "notifications_page", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 300, + "capture_url": false + }, + "top_nav_messages": { + "title": "私信入口", + "domain_ref": "main", + "selector": "a[href='/messages'], a[href='/inbox'], button[aria-label='私信']", + "aliases": [ + "私信", + "消息", + "消息入口", + "知乎私信", + "知乎消息" + ], + "entry_route_ref": "home", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 800, + "capture_url": true + }, + "messages_tab_all": { + "title": "全部私信", + "domain_ref": "main", + "selector": "a[href*='/messages/all'], a[href*='/inbox/all'], [role='tab'][aria-label*='全部']", + "aliases": [ + "全部私信", + "全部消息", + "全部私信标签" + ], + "entry_route_ref": "messages_page", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "messages_tab_unread": { + "title": "未读消息", + "domain_ref": "main", + "selector": "a[href*='/messages/unread'], a[href*='/inbox/unread'], [role='tab'][aria-label*='未读']", + "aliases": [ + "未读消息", + "未读私信", + "未读消息标签" + ], + "entry_route_ref": "messages_page", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "messages_tab_strangers": { + "title": "陌生人消息", + "domain_ref": "main", + "selector": "a[href*='/messages/stranger'], a[href*='/inbox/stranger'], a[href*='/messages/filtered'], [role='tab'][aria-label*='陌生人']", + "aliases": [ + "陌生人消息", + "陌生人私信", + "陌生人消息标签" + ], + "entry_route_ref": "messages_page", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "messages_settings_menu": { + "title": "消息设置菜单", + "domain_ref": "main", + "selector": "button[aria-label*='消息设置'], button[aria-label*='私信设置'], a[href*='messages/settings'], a[href*='inbox/settings']", + "aliases": [ + "消息设置", + "消息设置菜单", + "私信设置" + ], + "entry_route_ref": "messages_page", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 300, + "capture_url": false + }, + "top_nav_search": { + "title": "搜索框", + "domain_ref": "main", + "selector": "input[placeholder*='搜索']", + "aliases": [ + "搜索", + "搜索框", + "知乎搜索框" + ], + "entry_route_ref": "home", + "result_domain_ref": "main", + "wait_selector": "input[placeholder*='搜索']", + "wait_timeout_ms": 5000, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": null, + "capture_url": false + }, + "top_nav_avatar_menu": { + "title": "头像菜单", + "domain_ref": "main", + "selector": "button[aria-label='账号菜单'], img.Avatar, button:has(img)", + "aliases": [ + "头像菜单", + "个人菜单", + "账号菜单", + "头像下拉菜单" + ], + "entry_route_ref": "home", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": false + }, + "avatar_menu_profile_entry": { + "title": "个人主页入口", + "domain_ref": "main", + "selector": "div[role='menu'] a[href*='/people/'], ul[role='menu'] a[href*='/people/']", + "aliases": [ + "个人主页入口", + "头像菜单个人主页", + "个人主页按钮" + ], + "entry_route_ref": null, + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 800, + "capture_url": true + }, + "creator_sidebar_content_analysis": { + "title": "内容分析侧边栏入口", + "domain_ref": "creator", + "selector": "a[href='/creator/analytics/work/all']", + "aliases": [ + "侧边栏内容分析", + "内容分析按钮" + ], + "entry_route_ref": "creator_home", + "result_domain_ref": "creator", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 800, + "capture_url": true + }, + "creator_sidebar_income_analysis": { + "title": "收益分析侧边栏入口", + "domain_ref": "creator", + "selector": "a[href='/creator/knowledge-income']", + "aliases": [ + "侧边栏收益分析", + "收益分析按钮" + ], + "entry_route_ref": "creator_home", + "result_domain_ref": "creator", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 800, + "capture_url": true + }, + "creator_sidebar_draft_box": { + "title": "草稿箱侧边栏入口", + "domain_ref": "creator", + "selector": "a[href='/creator/manage/creation/all']", + "aliases": [ + "侧边栏草稿箱", + "草稿箱按钮" + ], + "entry_route_ref": "creator_home", + "result_domain_ref": "creator", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 800, + "capture_url": true + }, + "creator_sidebar_answer_management": { + "title": "回答管理侧边栏入口", + "domain_ref": "creator", + "selector": "a[href='/creator/manage/creation/answer']", + "aliases": [ + "侧边栏回答管理", + "回答管理按钮" + ], + "entry_route_ref": "creator_home", + "result_domain_ref": "creator", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 800, + "capture_url": true + }, + "creator_write_button": { + "title": "写文章入口按钮", + "domain_ref": "creator", + "selector": "div.css-1q62b6s > div.css-byu4by", + "aliases": [ + "写文章按钮", + "创作中心写文章", + "写文章入口" + ], + "entry_route_ref": "creator_home", + "result_domain_ref": "editor", + "wait_selector": "textarea[placeholder='请输入标题(最多 100 个字)']", + "wait_timeout_ms": 8000, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 1500, + "capture_url": true + }, + "profile_tab_answers": { + "title": "回答分栏", + "domain_ref": "main", + "selector": "a[href*='/answers'], [role='tab'][aria-label*='回答']", + "aliases": [ + "回答分栏", + "个人主页回答", + "回答标签" + ], + "entry_route_ref": null, + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "profile_tab_articles": { + "title": "文章分栏", + "domain_ref": "main", + "selector": "a[href*='/posts'], a[href*='/articles'], [role='tab'][aria-label*='文章']", + "aliases": [ + "文章分栏", + "个人主页文章", + "文章标签" + ], + "entry_route_ref": null, + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "profile_tab_ideas": { + "title": "想法分栏", + "domain_ref": "main", + "selector": "a[href*='/pins'], [role='tab'][aria-label*='想法']", + "aliases": [ + "想法分栏", + "个人主页想法", + "想法标签" + ], + "entry_route_ref": null, + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "profile_tab_videos": { + "title": "视频分栏", + "domain_ref": "main", + "selector": "a[href*='/zvideo'], [role='tab'][aria-label*='视频']", + "aliases": [ + "视频分栏", + "个人主页视频", + "视频标签" + ], + "entry_route_ref": null, + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "profile_tab_collections": { + "title": "收藏分栏", + "domain_ref": "main", + "selector": "a[href*='/collections'], [role='tab'][aria-label*='收藏']", + "aliases": [ + "收藏分栏", + "个人主页收藏", + "收藏标签" + ], + "entry_route_ref": null, + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "profile_tab_following": { + "title": "关注分栏", + "domain_ref": "main", + "selector": "a[href*='/following'], [role='tab'][aria-label*='关注']", + "aliases": [ + "关注分栏", + "个人主页关注", + "关注标签" + ], + "entry_route_ref": null, + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "profile_tab_followers": { + "title": "粉丝分栏", + "domain_ref": "main", + "selector": "a[href*='/followers'], [role='tab'][aria-label*='粉丝']", + "aliases": [ + "粉丝分栏", + "个人主页粉丝", + "粉丝标签" + ], + "entry_route_ref": null, + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "profile_tab_activity": { + "title": "动态分栏", + "domain_ref": "main", + "selector": "a[href*='/activities'], [role='tab'][aria-label*='动态']", + "aliases": [ + "动态分栏", + "个人主页动态", + "动态标签" + ], + "entry_route_ref": null, + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "question_sort_menu": { + "title": "问题排序菜单", + "domain_ref": "main", + "selector": "button[aria-label*='排序'], button[aria-label*='按'], [data-testid='sort-button']", + "aliases": [ + "问题排序", + "问题排序菜单", + "回答排序菜单" + ], + "entry_route_ref": "question_page", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 300, + "capture_url": false + }, + "question_filter_menu": { + "title": "问题筛选菜单", + "domain_ref": "main", + "selector": "button[aria-label*='筛选'], [data-testid='filter-button']", + "aliases": [ + "问题筛选", + "问题筛选菜单" + ], + "entry_route_ref": "question_page", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 300, + "capture_url": false + }, + "question_more_menu": { + "title": "问题更多菜单", + "domain_ref": "main", + "selector": "button[aria-label*='更多'], .MoreButton, [data-testid='more-button']", + "aliases": [ + "问题更多菜单", + "问题页更多", + "问题页三个点" + ], + "entry_route_ref": "question_page", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 300, + "capture_url": false + }, + "answer_sort_menu": { + "title": "回答排序菜单", + "domain_ref": "main", + "selector": "button[aria-label*='排序'], [data-testid='answer-sort']", + "aliases": [ + "回答排序", + "回答排序菜单" + ], + "entry_route_ref": "question_page", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 300, + "capture_url": false + }, + "answer_more_menu": { + "title": "回答更多菜单", + "domain_ref": "main", + "selector": ".AnswerItem button[aria-label*='更多'], .ContentItem-actions button[aria-label*='更多'], [data-testid='answer-more']", + "aliases": [ + "回答更多菜单", + "回答三个点", + "回答页更多" + ], + "entry_route_ref": "question_page", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 300, + "capture_url": false + }, + "author_card_menu": { + "title": "作者卡片菜单", + "domain_ref": "main", + "selector": ".AuthorInfo button[aria-label*='更多'], .UserLink button[aria-label*='更多'], [data-testid='author-card-more']", + "aliases": [ + "作者卡片菜单", + "作者更多菜单", + "作者卡片更多" + ], + "entry_route_ref": "question_page", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 300, + "capture_url": false + }, + "right_sidebar_menu": { + "title": "右侧栏菜单", + "domain_ref": "main", + "selector": ".Question-sideColumn button[aria-label*='更多'], .Question-sideColumn [data-testid='more-button'], .Sticky button[aria-label*='更多']", + "aliases": [ + "右侧栏菜单", + "右侧栏更多", + "侧栏菜单" + ], + "entry_route_ref": "question_page", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 300, + "capture_url": false + }, + "column_sort_menu": { + "title": "专栏排序菜单", + "domain_ref": "editor", + "selector": "button[aria-label*='排序'], [data-testid='column-sort']", + "aliases": [ + "专栏排序", + "专栏排序菜单" + ], + "entry_route_ref": "column_home", + "result_domain_ref": "editor", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 300, + "capture_url": false + }, + "column_filter_menu": { + "title": "专栏筛选菜单", + "domain_ref": "editor", + "selector": "button[aria-label*='筛选'], [data-testid='column-filter']", + "aliases": [ + "专栏筛选", + "专栏筛选菜单" + ], + "entry_route_ref": "column_home", + "result_domain_ref": "editor", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 300, + "capture_url": false + }, + "column_more_menu": { + "title": "专栏更多菜单", + "domain_ref": "editor", + "selector": "button[aria-label*='更多'], [data-testid='column-more']", + "aliases": [ + "专栏更多菜单", + "专栏页更多", + "专栏三个点" + ], + "entry_route_ref": "column_home", + "result_domain_ref": "editor", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 300, + "capture_url": false + }, + "home_feed_recommend_tab": { + "title": "推荐分栏", + "domain_ref": "main", + "selector": "a[href='/'], [role='tab'][aria-label*='推荐']", + "aliases": [ + "推荐分栏", + "首页推荐", + "推荐标签" + ], + "entry_route_ref": "home", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "home_feed_following_tab": { + "title": "关注分栏", + "domain_ref": "main", + "selector": "a[href='/follow'], [role='tab'][aria-label*='关注']", + "aliases": [ + "首页关注", + "关注动态", + "关注分栏" + ], + "entry_route_ref": "home", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "search_filter_menu": { + "title": "搜索筛选菜单", + "domain_ref": "main", + "selector": "button[aria-label*='筛选'], [data-testid='search-filter']", + "aliases": [ + "搜索筛选", + "搜索筛选菜单" + ], + "entry_route_ref": "search_results_page", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 300, + "capture_url": false + }, + "search_sort_menu": { + "title": "搜索排序菜单", + "domain_ref": "main", + "selector": "button[aria-label*='排序'], [data-testid='search-sort']", + "aliases": [ + "搜索排序", + "搜索排序菜单" + ], + "entry_route_ref": "search_results_page", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 300, + "capture_url": false + }, + "settings_account_menu": { + "title": "账号设置菜单", + "domain_ref": "main", + "selector": "a[href*='/settings/account'], button[aria-label*='账号设置']", + "aliases": [ + "账号设置", + "账号设置菜单" + ], + "entry_route_ref": "home", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "settings_privacy_menu": { + "title": "隐私设置菜单", + "domain_ref": "main", + "selector": "a[href*='/settings/privacy'], button[aria-label*='隐私设置']", + "aliases": [ + "隐私设置", + "隐私设置菜单" + ], + "entry_route_ref": "home", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "settings_security_menu": { + "title": "安全设置菜单", + "domain_ref": "main", + "selector": "a[href*='/settings/security'], button[aria-label*='安全设置']", + "aliases": [ + "安全设置", + "安全设置菜单" + ], + "entry_route_ref": "home", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 500, + "capture_url": true + }, + "settings_notifications_inner_menu": { + "title": "通知设置页内部菜单", + "domain_ref": "main", + "selector": "nav a[href*='notifications'], aside a[href*='notifications'], [role='tab'][aria-label*='通知']", + "aliases": [ + "通知设置页内部菜单", + "通知设置内部菜单" + ], + "entry_route_ref": "notifications_page", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 300, + "capture_url": false + }, + "settings_messages_inner_menu": { + "title": "消息设置页内部菜单", + "domain_ref": "main", + "selector": "nav a[href*='messages'], aside a[href*='messages'], [role='tab'][aria-label*='消息设置']", + "aliases": [ + "消息设置页内部菜单", + "消息设置内部菜单" + ], + "entry_route_ref": "messages_page", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 300, + "capture_url": false + }, + "context_more_menu": { + "title": "上下文更多菜单", + "domain_ref": "main", + "selector": "button[aria-label*='更多'], .MoreButton, [data-testid='more-button']", + "aliases": [ + "更多菜单", + "三个点菜单", + "上下文更多菜单" + ], + "entry_route_ref": "home", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 300, + "capture_url": false + }, + "hover_floating_menu": { + "title": "悬浮菜单", + "domain_ref": "main", + "selector": ".Popover, .Tooltip, .floating-menu, [role='menu']", + "aliases": [ + "悬浮菜单", + "浮动菜单" + ], + "entry_route_ref": "home", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 300, + "capture_url": false + }, + "dialog_menu": { + "title": "弹窗菜单", + "domain_ref": "main", + "selector": ".Modal, [role='dialog'], .Dialog", + "aliases": [ + "弹窗菜单", + "弹层菜单" + ], + "entry_route_ref": "home", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 300, + "capture_url": false + }, + "card_context_menu": { + "title": "卡片菜单", + "domain_ref": "main", + "selector": ".Card button[aria-label*='更多'], .List-item button[aria-label*='更多'], [data-testid='card-more']", + "aliases": [ + "卡片菜单", + "卡片更多菜单", + "卡片三个点" + ], + "entry_route_ref": "home", + "result_domain_ref": "main", + "wait_selector": null, + "wait_timeout_ms": null, + "expect_selector": null, + "expect_text": null, + "wait_after_ms": 300, + "capture_url": false + } + }, + "flows": { + "open_hot_from_home": { + "steps": [ + { + "name": "navigate_home", + "action": "navigate", + "route_ref": "home", + "capture_url": true, + "log_message": "navigate https://www.zhihu.com/" + }, + { + "name": "click_hot_entry", + "action": "click", + "component_ref": "top_nav_hot", + "capture_url": true, + "log_message": "click 热榜" + } + ] + }, + "open_creator_from_home": { + "steps": [ + { + "name": "navigate_home", + "action": "navigate", + "route_ref": "home", + "capture_url": true, + "log_message": "navigate https://www.zhihu.com/" + }, + { + "name": "click_creator_entry", + "action": "click", + "component_ref": "top_nav_creator", + "capture_url": true, + "log_message": "click 创作中心入口" + } + ] + }, + "open_notifications_menu": { + "steps": [ + { + "name": "navigate_home", + "action": "navigate", + "route_ref": "home", + "capture_url": true, + "log_message": "navigate https://www.zhihu.com/" + }, + { + "name": "click_notifications_menu", + "action": "click", + "component_ref": "top_nav_notifications", + "capture_url": true, + "log_message": "click 通知菜单" + } + ] + }, + "open_avatar_menu": { + "steps": [ + { + "name": "navigate_home", + "action": "navigate", + "route_ref": "home", + "capture_url": true, + "log_message": "navigate https://www.zhihu.com/" + }, + { + "name": "click_avatar_menu", + "action": "click", + "component_ref": "top_nav_avatar_menu", + "capture_url": false, + "log_message": "click 头像菜单" + } + ] + }, + "open_profile_from_avatar_menu": { + "steps": [ + { + "name": "navigate_home", + "action": "navigate", + "route_ref": "home", + "capture_url": true, + "log_message": "navigate https://www.zhihu.com/" + }, + { + "name": "click_avatar_menu", + "action": "click", + "component_ref": "top_nav_avatar_menu", + "capture_url": false, + "log_message": "click 头像菜单" + }, + { + "name": "click_profile_entry", + "action": "click", + "component_ref": "avatar_menu_profile_entry", + "capture_url": true, + "log_message": "click 个人主页入口" + } + ] + }, + "open_profile_answers_tab": { + "steps": [ + { + "name": "navigate_home", + "action": "navigate", + "route_ref": "home", + "capture_url": true, + "log_message": "navigate https://www.zhihu.com/" + }, + { + "name": "click_avatar_menu", + "action": "click", + "component_ref": "top_nav_avatar_menu", + "capture_url": false, + "log_message": "click 头像菜单" + }, + { + "name": "click_profile_entry", + "action": "click", + "component_ref": "avatar_menu_profile_entry", + "capture_url": true, + "log_message": "click 个人主页入口" + }, + { + "name": "click_profile_answers_tab", + "action": "click", + "component_ref": "profile_tab_answers", + "capture_url": true, + "log_message": "click 回答分栏" + } + ] + }, + "open_profile_articles_tab": { + "steps": [ + { + "name": "navigate_home", + "action": "navigate", + "route_ref": "home", + "capture_url": true, + "log_message": "navigate https://www.zhihu.com/" + }, + { + "name": "click_avatar_menu", + "action": "click", + "component_ref": "top_nav_avatar_menu", + "capture_url": false, + "log_message": "click 头像菜单" + }, + { + "name": "click_profile_entry", + "action": "click", + "component_ref": "avatar_menu_profile_entry", + "capture_url": true, + "log_message": "click 个人主页入口" + }, + { + "name": "click_profile_articles_tab", + "action": "click", + "component_ref": "profile_tab_articles", + "capture_url": true, + "log_message": "click 文章分栏" + } + ] + }, + "open_profile_ideas_tab": { + "steps": [ + { + "name": "navigate_home", + "action": "navigate", + "route_ref": "home", + "capture_url": true, + "log_message": "navigate https://www.zhihu.com/" + }, + { + "name": "click_avatar_menu", + "action": "click", + "component_ref": "top_nav_avatar_menu", + "capture_url": false, + "log_message": "click 头像菜单" + }, + { + "name": "click_profile_entry", + "action": "click", + "component_ref": "avatar_menu_profile_entry", + "capture_url": true, + "log_message": "click 个人主页入口" + }, + { + "name": "click_profile_ideas_tab", + "action": "click", + "component_ref": "profile_tab_ideas", + "capture_url": true, + "log_message": "click 想法分栏" + } + ] + }, + "open_profile_videos_tab": { + "steps": [ + { + "name": "navigate_home", + "action": "navigate", + "route_ref": "home", + "capture_url": true, + "log_message": "navigate https://www.zhihu.com/" + }, + { + "name": "click_avatar_menu", + "action": "click", + "component_ref": "top_nav_avatar_menu", + "capture_url": false, + "log_message": "click 头像菜单" + }, + { + "name": "click_profile_entry", + "action": "click", + "component_ref": "avatar_menu_profile_entry", + "capture_url": true, + "log_message": "click 个人主页入口" + }, + { + "name": "click_profile_videos_tab", + "action": "click", + "component_ref": "profile_tab_videos", + "capture_url": true, + "log_message": "click 视频分栏" + } + ] + }, + "open_profile_collections_tab": { + "steps": [ + { + "name": "navigate_home", + "action": "navigate", + "route_ref": "home", + "capture_url": true, + "log_message": "navigate https://www.zhihu.com/" + }, + { + "name": "click_avatar_menu", + "action": "click", + "component_ref": "top_nav_avatar_menu", + "capture_url": false, + "log_message": "click 头像菜单" + }, + { + "name": "click_profile_entry", + "action": "click", + "component_ref": "avatar_menu_profile_entry", + "capture_url": true, + "log_message": "click 个人主页入口" + }, + { + "name": "click_profile_collections_tab", + "action": "click", + "component_ref": "profile_tab_collections", + "capture_url": true, + "log_message": "click 收藏分栏" + } + ] + }, + "open_profile_following_tab": { + "steps": [ + { + "name": "navigate_home", + "action": "navigate", + "route_ref": "home", + "capture_url": true, + "log_message": "navigate https://www.zhihu.com/" + }, + { + "name": "click_avatar_menu", + "action": "click", + "component_ref": "top_nav_avatar_menu", + "capture_url": false, + "log_message": "click 头像菜单" + }, + { + "name": "click_profile_entry", + "action": "click", + "component_ref": "avatar_menu_profile_entry", + "capture_url": true, + "log_message": "click 个人主页入口" + }, + { + "name": "click_profile_following_tab", + "action": "click", + "component_ref": "profile_tab_following", + "capture_url": true, + "log_message": "click 关注分栏" + } + ] + }, + "open_profile_followers_tab": { + "steps": [ + { + "name": "navigate_home", + "action": "navigate", + "route_ref": "home", + "capture_url": true, + "log_message": "navigate https://www.zhihu.com/" + }, + { + "name": "click_avatar_menu", + "action": "click", + "component_ref": "top_nav_avatar_menu", + "capture_url": false, + "log_message": "click 头像菜单" + }, + { + "name": "click_profile_entry", + "action": "click", + "component_ref": "avatar_menu_profile_entry", + "capture_url": true, + "log_message": "click 个人主页入口" + }, + { + "name": "click_profile_followers_tab", + "action": "click", + "component_ref": "profile_tab_followers", + "capture_url": true, + "log_message": "click 粉丝分栏" + } + ] + }, + "open_profile_activity_tab": { + "steps": [ + { + "name": "navigate_home", + "action": "navigate", + "route_ref": "home", + "capture_url": true, + "log_message": "navigate https://www.zhihu.com/" + }, + { + "name": "click_avatar_menu", + "action": "click", + "component_ref": "top_nav_avatar_menu", + "capture_url": false, + "log_message": "click 头像菜单" + }, + { + "name": "click_profile_entry", + "action": "click", + "component_ref": "avatar_menu_profile_entry", + "capture_url": true, + "log_message": "click 个人主页入口" + }, + { + "name": "click_profile_activity_tab", + "action": "click", + "component_ref": "profile_tab_activity", + "capture_url": true, + "log_message": "click 动态分栏" + } + ] + }, + "open_account_settings_from_avatar_menu": { + "steps": [ + { + "name": "navigate_home", + "action": "navigate", + "route_ref": "home", + "capture_url": true, + "log_message": "navigate https://www.zhihu.com/" + }, + { + "name": "click_avatar_menu", + "action": "click", + "component_ref": "top_nav_avatar_menu", + "capture_url": false, + "log_message": "click 头像菜单" + }, + { + "name": "click_account_settings_entry", + "action": "click", + "component_ref": "settings_account_menu", + "capture_url": true, + "log_message": "click 账号设置菜单" + } + ] + }, + "open_privacy_settings_from_avatar_menu": { + "steps": [ + { + "name": "navigate_home", + "action": "navigate", + "route_ref": "home", + "capture_url": true, + "log_message": "navigate https://www.zhihu.com/" + }, + { + "name": "click_avatar_menu", + "action": "click", + "component_ref": "top_nav_avatar_menu", + "capture_url": false, + "log_message": "click 头像菜单" + }, + { + "name": "click_privacy_settings_entry", + "action": "click", + "component_ref": "settings_privacy_menu", + "capture_url": true, + "log_message": "click 隐私设置菜单" + } + ] + }, + "open_security_settings_from_avatar_menu": { + "steps": [ + { + "name": "navigate_home", + "action": "navigate", + "route_ref": "home", + "capture_url": true, + "log_message": "navigate https://www.zhihu.com/" + }, + { + "name": "click_avatar_menu", + "action": "click", + "component_ref": "top_nav_avatar_menu", + "capture_url": false, + "log_message": "click 头像菜单" + }, + { + "name": "click_security_settings_entry", + "action": "click", + "component_ref": "settings_security_menu", + "capture_url": true, + "log_message": "click 安全设置菜单" + } + ] + } + }, + "targets": { + "home": { + "title": "首页", + "kind": "route", + "summary_kind": "page", + "route_ref": "home", + "component_ref": null, + "flow_ref": null, + "aliases": [ + "知乎首页", + "知乎主页" + ] + }, + "hot_list": { + "title": "热榜", + "kind": "route", + "summary_kind": "page", + "route_ref": "hot_list", + "component_ref": null, + "flow_ref": null, + "aliases": [ + "知乎热榜", + "热榜页面", + "热搜榜" + ] + }, + "column_home": { + "title": "专栏页", + "kind": "route", + "summary_kind": "page", + "route_ref": "column_home", + "component_ref": null, + "flow_ref": null, + "aliases": [ + "知乎专栏", + "专栏首页", + "专栏页" + ] + }, + "question_page": { + "title": "问题页", + "kind": "route", + "summary_kind": "page", + "route_ref": "question_page", + "component_ref": null, + "flow_ref": null, + "aliases": [ + "知乎问题页", + "问题页", + "问题分栏", + "等你来答" + ] + }, + "messages_page": { + "title": "消息分栏", + "kind": "route", + "summary_kind": "page", + "route_ref": "messages_page", + "component_ref": null, + "flow_ref": null, + "aliases": [ + "知乎消息分栏", + "消息分栏", + "知乎消息页", + "消息页" + ] + }, + "messages_all_tab": { + "title": "全部私信", + "kind": "component", + "summary_kind": "entry", + "route_ref": null, + "component_ref": "messages_tab_all", + "flow_ref": null, + "aliases": [ + "全部私信", + "全部消息", + "全部私信标签", + "消息分栏全部私信" + ] + }, + "messages_unread_tab": { + "title": "未读消息", + "kind": "component", + "summary_kind": "entry", + "route_ref": null, + "component_ref": "messages_tab_unread", + "flow_ref": null, + "aliases": [ + "未读消息", + "未读私信", + "未读消息标签", + "消息分栏未读消息" + ] + }, + "messages_strangers_tab": { + "title": "陌生人消息", + "kind": "component", + "summary_kind": "entry", + "route_ref": null, + "component_ref": "messages_tab_strangers", + "flow_ref": null, + "aliases": [ + "陌生人消息", + "陌生人私信", + "陌生人消息标签", + "消息分栏陌生人消息" + ] + }, + "messages_settings_menu": { + "title": "消息设置菜单", + "kind": "component", + "summary_kind": "menu", + "route_ref": null, + "component_ref": "messages_settings_menu", + "flow_ref": null, + "aliases": [ + "消息设置", + "消息设置菜单", + "私信设置", + "消息分栏设置" + ] + }, + "notifications_page": { + "title": "通知分栏", + "kind": "route", + "summary_kind": "page", + "route_ref": "notifications_page", + "component_ref": null, + "flow_ref": null, + "aliases": [ + "知乎通知分栏", + "通知分栏", + "知乎通知页", + "通知页" + ] + }, + "search_results_page": { + "title": "搜索结果页", + "kind": "route", + "summary_kind": "page", + "route_ref": "search_results_page", + "component_ref": null, + "flow_ref": null, + "aliases": [ + "知乎搜索结果", + "搜索结果页", + "知乎搜索页" + ] + }, + "notifications_replies_tab": { + "title": "回复我的", + "kind": "component", + "summary_kind": "entry", + "route_ref": null, + "component_ref": "notifications_tab_replies", + "flow_ref": null, + "aliases": [ + "回复我的", + "回复我的标签", + "回复通知", + "通知分栏回复我的", + "通知里的回复我的" + ] + }, + "notifications_votes_favorites_tab": { + "title": "赞同与收藏", + "kind": "component", + "summary_kind": "entry", + "route_ref": null, + "component_ref": "notifications_tab_votes_favorites", + "flow_ref": null, + "aliases": [ + "赞同与收藏", + "赞同收藏", + "赞同与收藏标签", + "通知分栏赞同与收藏" + ] + }, + "notifications_follows_tab": { + "title": "关注我的", + "kind": "component", + "summary_kind": "entry", + "route_ref": null, + "component_ref": "notifications_tab_follows", + "flow_ref": null, + "aliases": [ + "关注我的", + "关注通知", + "关注我的标签", + "通知分栏关注我的" + ] + }, + "notifications_system_tab": { + "title": "系统通知", + "kind": "component", + "summary_kind": "entry", + "route_ref": null, + "component_ref": "notifications_tab_system", + "flow_ref": null, + "aliases": [ + "系统通知", + "系统通知标签", + "通知分栏系统通知" + ] + }, + "notifications_settings_menu": { + "title": "通知设置菜单", + "kind": "component", + "summary_kind": "menu", + "route_ref": null, + "component_ref": "notifications_settings_menu", + "flow_ref": null, + "aliases": [ + "通知设置", + "通知设置菜单", + "通知页设置", + "通知分栏设置" + ] + }, + "creator_home": { + "title": "创作中心", + "kind": "route", + "summary_kind": "page", + "route_ref": "creator_home", + "component_ref": null, + "flow_ref": null, + "aliases": [ + "知乎创作中心", + "创作者中心", + "知乎创作者中心" + ] + }, + "content_analysis": { + "title": "内容分析", + "kind": "route", + "summary_kind": "page", + "route_ref": "content_analysis", + "component_ref": null, + "flow_ref": null, + "aliases": [ + "知乎内容分析", + "内容分析页面" + ] + }, + "income_analysis": { + "title": "收益分析", + "kind": "route", + "summary_kind": "page", + "route_ref": "income_analysis", + "component_ref": null, + "flow_ref": null, + "aliases": [ + "知乎收益分析", + "收益分析页面" + ] + }, + "draft_box_entry": { + "title": "草稿箱入口", + "kind": "route", + "summary_kind": "page", + "route_ref": "draft_box_entry", + "component_ref": null, + "flow_ref": null, + "aliases": [ + "知乎草稿箱", + "草稿箱" + ] + }, + "answer_management": { + "title": "回答管理", + "kind": "route", + "summary_kind": "page", + "route_ref": "answer_management", + "component_ref": null, + "flow_ref": null, + "aliases": [ + "知乎回答管理", + "回答管理页面" + ] + }, + "write_article": { + "title": "写文章", + "kind": "route", + "summary_kind": "page", + "route_ref": "write_article", + "component_ref": null, + "flow_ref": null, + "aliases": [ + "知乎写文章", + "知乎编辑器", + "写知乎文章" + ] + }, + "home_tab": { + "title": "首页按钮", + "kind": "component", + "summary_kind": "entry", + "route_ref": null, + "component_ref": "top_nav_home", + "flow_ref": null, + "aliases": [ + "首页按钮", + "顶部首页" + ] + }, + "hot_tab": { + "title": "热榜按钮", + "kind": "component", + "summary_kind": "entry", + "route_ref": null, + "component_ref": "top_nav_hot", + "flow_ref": null, + "aliases": [ + "热榜按钮", + "顶部热榜" + ] + }, + "creator_entry_button": { + "title": "创作中心入口", + "kind": "component", + "summary_kind": "entry", + "route_ref": null, + "component_ref": "top_nav_creator", + "flow_ref": null, + "aliases": [ + "创作中心入口", + "创作中心按钮", + "创作者中心入口" + ] + }, + "notifications_menu": { + "title": "通知菜单", + "kind": "flow", + "summary_kind": "menu", + "route_ref": null, + "component_ref": null, + "flow_ref": "open_notifications_menu", + "aliases": [ + "通知菜单", + "知乎通知菜单", + "通知下拉菜单" + ] + }, + "messages_entry": { + "title": "私信入口", + "kind": "component", + "summary_kind": "entry", + "route_ref": null, + "component_ref": "top_nav_messages", + "flow_ref": null, + "aliases": [ + "私信入口", + "消息入口", + "知乎私信" + ] + }, + "notifications_entry": { + "title": "通知入口", + "kind": "component", + "summary_kind": "entry", + "route_ref": null, + "component_ref": "top_nav_notifications", + "flow_ref": null, + "aliases": [ + "通知入口", + "通知按钮", + "知乎通知入口" + ] + }, + "search_box": { + "title": "搜索框", + "kind": "component", + "summary_kind": "entry", + "route_ref": null, + "component_ref": "top_nav_search", + "flow_ref": null, + "aliases": [ + "搜索框", + "知乎搜索框", + "搜索入口" + ] + }, + "avatar_menu": { + "title": "头像菜单", + "kind": "flow", + "summary_kind": "menu", + "route_ref": null, + "component_ref": null, + "flow_ref": "open_avatar_menu", + "aliases": [ + "头像菜单", + "个人菜单", + "账号菜单" + ] + }, + "profile_page": { + "title": "个人主页", + "kind": "flow", + "summary_kind": "navigation", + "route_ref": null, + "component_ref": null, + "flow_ref": "open_profile_from_avatar_menu", + "aliases": [ + "知乎个人主页", + "个人主页", + "我的主页", + "我的个人主页" + ] + }, + "profile_answers_tab": { + "title": "回答分栏", + "kind": "flow", + "summary_kind": "navigation", + "route_ref": null, + "component_ref": null, + "flow_ref": "open_profile_answers_tab", + "aliases": [ + "个人主页回答", + "个人主页回答分栏", + "知乎个人主页回答分栏", + "回答分栏", + "回答标签" + ] + }, + "profile_articles_tab": { + "title": "文章分栏", + "kind": "flow", + "summary_kind": "navigation", + "route_ref": null, + "component_ref": null, + "flow_ref": "open_profile_articles_tab", + "aliases": [ + "个人主页文章", + "个人主页文章分栏", + "知乎个人主页文章分栏", + "文章分栏", + "文章标签" + ] + }, + "profile_ideas_tab": { + "title": "想法分栏", + "kind": "flow", + "summary_kind": "navigation", + "route_ref": null, + "component_ref": null, + "flow_ref": "open_profile_ideas_tab", + "aliases": [ + "个人主页想法", + "个人主页想法分栏", + "知乎个人主页想法分栏", + "想法分栏", + "想法标签" + ] + }, + "profile_videos_tab": { + "title": "视频分栏", + "kind": "flow", + "summary_kind": "navigation", + "route_ref": null, + "component_ref": null, + "flow_ref": "open_profile_videos_tab", + "aliases": [ + "个人主页视频", + "个人主页视频分栏", + "知乎个人主页视频分栏", + "视频分栏", + "视频标签" + ] + }, + "profile_collections_tab": { + "title": "收藏分栏", + "kind": "flow", + "summary_kind": "navigation", + "route_ref": null, + "component_ref": null, + "flow_ref": "open_profile_collections_tab", + "aliases": [ + "个人主页收藏", + "个人主页收藏分栏", + "知乎个人主页收藏分栏", + "收藏分栏", + "收藏标签" + ] + }, + "profile_following_tab": { + "title": "关注分栏", + "kind": "flow", + "summary_kind": "navigation", + "route_ref": null, + "component_ref": null, + "flow_ref": "open_profile_following_tab", + "aliases": [ + "个人主页关注", + "个人主页关注分栏", + "知乎个人主页关注分栏", + "关注分栏", + "关注标签" + ] + }, + "profile_followers_tab": { + "title": "粉丝分栏", + "kind": "flow", + "summary_kind": "navigation", + "route_ref": null, + "component_ref": null, + "flow_ref": "open_profile_followers_tab", + "aliases": [ + "个人主页粉丝", + "个人主页粉丝分栏", + "知乎个人主页粉丝分栏", + "粉丝分栏", + "粉丝标签" + ] + }, + "profile_activity_tab": { + "title": "动态分栏", + "kind": "flow", + "summary_kind": "navigation", + "route_ref": null, + "component_ref": null, + "flow_ref": "open_profile_activity_tab", + "aliases": [ + "个人主页动态", + "个人主页动态分栏", + "知乎个人主页动态分栏", + "动态分栏", + "动态标签" + ] + }, + "creator_write_button": { + "title": "写文章入口按钮", + "kind": "component", + "summary_kind": "entry", + "route_ref": null, + "component_ref": "creator_write_button", + "flow_ref": null, + "aliases": [ + "写文章按钮", + "创作中心写文章按钮", + "写文章入口" + ] + }, + "content_analysis_button": { + "title": "内容分析按钮", + "kind": "component", + "summary_kind": "entry", + "route_ref": null, + "component_ref": "creator_sidebar_content_analysis", + "flow_ref": null, + "aliases": [ + "内容分析按钮", + "侧边栏内容分析", + "创作中心内容分析按钮" + ] + }, + "income_analysis_button": { + "title": "收益分析按钮", + "kind": "component", + "summary_kind": "entry", + "route_ref": null, + "component_ref": "creator_sidebar_income_analysis", + "flow_ref": null, + "aliases": [ + "收益分析按钮", + "侧边栏收益分析", + "创作中心收益分析按钮" + ] + }, + "draft_box_button": { + "title": "草稿箱按钮", + "kind": "component", + "summary_kind": "entry", + "route_ref": null, + "component_ref": "creator_sidebar_draft_box", + "flow_ref": null, + "aliases": [ + "草稿箱按钮", + "侧边栏草稿箱", + "创作中心草稿箱按钮" + ] + }, + "answer_management_button": { + "title": "回答管理按钮", + "kind": "component", + "summary_kind": "entry", + "route_ref": null, + "component_ref": "creator_sidebar_answer_management", + "flow_ref": null, + "aliases": [ + "回答管理按钮", + "侧边栏回答管理", + "创作中心回答管理按钮" + ] + }, + "question_sort_menu": { + "title": "问题排序菜单", + "kind": "component", + "summary_kind": "menu", + "route_ref": null, + "component_ref": "question_sort_menu", + "flow_ref": null, + "aliases": [ + "问题排序", + "问题排序菜单", + "回答排序菜单" + ] + }, + "question_filter_menu": { + "title": "问题筛选菜单", + "kind": "component", + "summary_kind": "menu", + "route_ref": null, + "component_ref": "question_filter_menu", + "flow_ref": null, + "aliases": [ + "问题筛选", + "问题筛选菜单" + ] + }, + "question_more_menu": { + "title": "问题更多菜单", + "kind": "component", + "summary_kind": "menu", + "route_ref": null, + "component_ref": "question_more_menu", + "flow_ref": null, + "aliases": [ + "问题更多菜单", + "问题页更多", + "问题页三个点" + ] + }, + "answer_sort_menu": { + "title": "回答排序菜单", + "kind": "component", + "summary_kind": "menu", + "route_ref": null, + "component_ref": "answer_sort_menu", + "flow_ref": null, + "aliases": [ + "回答排序", + "回答排序菜单" + ] + }, + "answer_more_menu": { + "title": "回答更多菜单", + "kind": "component", + "summary_kind": "menu", + "route_ref": null, + "component_ref": "answer_more_menu", + "flow_ref": null, + "aliases": [ + "回答更多菜单", + "回答三个点", + "回答页更多" + ] + }, + "author_card_menu": { + "title": "作者卡片菜单", + "kind": "component", + "summary_kind": "menu", + "route_ref": null, + "component_ref": "author_card_menu", + "flow_ref": null, + "aliases": [ + "作者卡片菜单", + "作者更多菜单", + "作者卡片更多" + ] + }, + "right_sidebar_menu": { + "title": "右侧栏菜单", + "kind": "component", + "summary_kind": "menu", + "route_ref": null, + "component_ref": "right_sidebar_menu", + "flow_ref": null, + "aliases": [ + "右侧栏菜单", + "右侧栏更多", + "侧栏菜单" + ] + }, + "column_sort_menu": { + "title": "专栏排序菜单", + "kind": "component", + "summary_kind": "menu", + "route_ref": null, + "component_ref": "column_sort_menu", + "flow_ref": null, + "aliases": [ + "专栏排序", + "专栏排序菜单" + ] + }, + "column_filter_menu": { + "title": "专栏筛选菜单", + "kind": "component", + "summary_kind": "menu", + "route_ref": null, + "component_ref": "column_filter_menu", + "flow_ref": null, + "aliases": [ + "专栏筛选", + "专栏筛选菜单" + ] + }, + "column_more_menu": { + "title": "专栏更多菜单", + "kind": "component", + "summary_kind": "menu", + "route_ref": null, + "component_ref": "column_more_menu", + "flow_ref": null, + "aliases": [ + "专栏更多菜单", + "专栏页更多", + "专栏三个点" + ] + }, + "home_feed_recommend_tab": { + "title": "推荐分栏", + "kind": "component", + "summary_kind": "entry", + "route_ref": null, + "component_ref": "home_feed_recommend_tab", + "flow_ref": null, + "aliases": [ + "首页推荐", + "推荐分栏", + "推荐标签" + ] + }, + "home_feed_following_tab": { + "title": "关注分栏", + "kind": "component", + "summary_kind": "entry", + "route_ref": null, + "component_ref": "home_feed_following_tab", + "flow_ref": null, + "aliases": [ + "首页关注", + "关注动态", + "关注分栏" + ] + }, + "search_filter_menu": { + "title": "搜索筛选菜单", + "kind": "component", + "summary_kind": "menu", + "route_ref": null, + "component_ref": "search_filter_menu", + "flow_ref": null, + "aliases": [ + "搜索筛选", + "搜索筛选菜单" + ] + }, + "search_sort_menu": { + "title": "搜索排序菜单", + "kind": "component", + "summary_kind": "menu", + "route_ref": null, + "component_ref": "search_sort_menu", + "flow_ref": null, + "aliases": [ + "搜索排序", + "搜索排序菜单" + ] + }, + "settings_account_menu": { + "title": "账号设置菜单", + "kind": "flow", + "summary_kind": "navigation", + "route_ref": null, + "component_ref": null, + "flow_ref": "open_account_settings_from_avatar_menu", + "aliases": [ + "账号设置", + "账号设置菜单" + ] + }, + "settings_privacy_menu": { + "title": "隐私设置菜单", + "kind": "flow", + "summary_kind": "navigation", + "route_ref": null, + "component_ref": null, + "flow_ref": "open_privacy_settings_from_avatar_menu", + "aliases": [ + "隐私设置", + "隐私设置菜单" + ] + }, + "settings_security_menu": { + "title": "安全设置菜单", + "kind": "flow", + "summary_kind": "navigation", + "route_ref": null, + "component_ref": null, + "flow_ref": "open_security_settings_from_avatar_menu", + "aliases": [ + "安全设置", + "安全设置菜单" + ] + }, + "settings_notifications_inner_menu": { + "title": "通知设置页内部菜单", + "kind": "component", + "summary_kind": "menu", + "route_ref": null, + "component_ref": "settings_notifications_inner_menu", + "flow_ref": null, + "aliases": [ + "通知设置页内部菜单", + "通知设置内部菜单" + ] + }, + "settings_messages_inner_menu": { + "title": "消息设置页内部菜单", + "kind": "component", + "summary_kind": "menu", + "route_ref": null, + "component_ref": "settings_messages_inner_menu", + "flow_ref": null, + "aliases": [ + "消息设置页内部菜单", + "消息设置内部菜单" + ] + }, + "context_more_menu": { + "title": "上下文更多菜单", + "kind": "component", + "summary_kind": "menu", + "route_ref": null, + "component_ref": "context_more_menu", + "flow_ref": null, + "aliases": [ + "更多菜单", + "三个点菜单", + "上下文更多菜单" + ] + }, + "hover_floating_menu": { + "title": "悬浮菜单", + "kind": "component", + "summary_kind": "menu", + "route_ref": null, + "component_ref": "hover_floating_menu", + "flow_ref": null, + "aliases": [ + "悬浮菜单", + "浮动菜单" + ] + }, + "dialog_menu": { + "title": "弹窗菜单", + "kind": "component", + "summary_kind": "menu", + "route_ref": null, + "component_ref": "dialog_menu", + "flow_ref": null, + "aliases": [ + "弹窗菜单", + "弹层菜单" + ] + }, + "card_context_menu": { + "title": "卡片菜单", + "kind": "component", + "summary_kind": "menu", + "route_ref": null, + "component_ref": "card_context_menu", + "flow_ref": null, + "aliases": [ + "卡片菜单", + "卡片更多菜单", + "卡片三个点" + ] + }, + "open_hot_from_home": { + "title": "从首页打开热榜", + "kind": "flow", + "summary_kind": "navigation", + "route_ref": null, + "component_ref": null, + "flow_ref": "open_hot_from_home", + "aliases": [ + "从首页打开热榜", + "首页点热榜", + "首页进入热榜" + ] + }, + "open_creator_from_home": { + "title": "从首页进入创作中心", + "kind": "flow", + "summary_kind": "navigation", + "route_ref": null, + "component_ref": null, + "flow_ref": "open_creator_from_home", + "aliases": [ + "从首页进入创作中心", + "首页点创作中心", + "首页打开创作中心" + ] + } + } +} diff --git a/resources/skills/zhihu_write_flow.json b/resources/skills/zhihu_write_flow.json new file mode 100644 index 0000000..e3e73a2 --- /dev/null +++ b/resources/skills/zhihu_write_flow.json @@ -0,0 +1,126 @@ +{ + "entry_url": "https://www.zhihu.com/creator", + "editor_url": "https://zhuanlan.zhihu.com/write", + "domains": { + "creator": "www.zhihu.com", + "editor": "zhuanlan.zhihu.com" + }, + "literals": { + "write_entry_text": "写文章", + "title_placeholder": "请输入标题(最多 100 个字)", + "body_role": "textbox", + "publish_text": "发布", + "publish_confirm_text": "确认发布" + }, + "selectors": { + "creator_write_panel": "div.css-1q62b6s", + "creator_write_entry": "div.css-1q62b6s > div.css-byu4by", + "title_input": "textarea[placeholder='请输入标题(最多 100 个字)']", + "body_editor": "div.notranslate.public-DraftEditor-content[contenteditable='true'][role='textbox']", + "publish_button": "button.Button--primary.Button--blue", + "publish_confirm_dialog": "div[role='dialog']", + "publish_confirm_button": "div[role='dialog'] button.Button--primary.Button--blue", + "published_title": "h1" + }, + "steps": [ + { + "name": "navigate_creator", + "action": "navigate", + "expected_domain": "creator", + "url_ref": "entry_url", + "log_message": "navigate https://www.zhihu.com/creator" + }, + { + "name": "click_write_article", + "action": "click", + "expected_domain": "creator", + "selector_ref": "creator_write_entry", + "wait_after_ms": 1500, + "log_message": "click 写文章" + }, + { + "name": "wait_editor_ready", + "action": "waitForSelector", + "expected_domain": "editor", + "selector_ref": "title_input", + "timeout_ms": 8000, + "log_message": "wait for editor title input" + }, + { + "name": "type_title", + "action": "type", + "expected_domain": "editor", + "selector_ref": "title_input", + "text_source": "title", + "clear_first": true, + "log_message": "type article title into 请输入标题(最多 100 个字)" + }, + { + "name": "type_body", + "action": "type", + "expected_domain": "editor", + "selector_ref": "body_editor", + "text_source": "body", + "clear_first": true, + "log_message": "type article body into editor textbox" + }, + { + "name": "scroll_publish_button", + "action": "scrollTo", + "expected_domain": "editor", + "selector_ref": "publish_button", + "only_when_publish": true, + "log_message": "scroll to 发布" + }, + { + "name": "click_publish", + "action": "click", + "expected_domain": "editor", + "selector_ref": "publish_button", + "wait_after_ms": 800, + "only_when_publish": true, + "capture_url": true, + "log_message": "click 发布" + }, + { + "name": "wait_publish_confirm_dialog", + "action": "waitForSelector", + "expected_domain": "editor", + "selector_ref": "publish_confirm_dialog", + "timeout_ms": 8000, + "only_when_publish": true, + "log_message": "wait for publish confirm dialog" + }, + { + "name": "click_publish_confirm", + "action": "click", + "expected_domain": "editor", + "selector_ref": "publish_confirm_button", + "wait_after_ms": 1500, + "only_when_publish": true, + "capture_url": true, + "log_message": "click 确认发布" + }, + { + "name": "wait_published_title", + "action": "waitForSelector", + "expected_domain": "editor", + "selector_ref": "published_title", + "timeout_ms": 15000, + "only_when_publish": true, + "capture_url": true, + "log_message": "wait for published article title" + }, + { + "name": "confirm_published_title", + "action": "getText", + "expected_domain": "editor", + "selector_ref": "published_title", + "only_when_publish": true, + "expect_text_source": "title", + "allow_empty_text": true, + "capture_url": true, + "log_message": "verify published article title" + } + ] +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 323348d..ac3e6cc 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -9,6 +9,7 @@ use crate::config::DeepSeekSettings; use crate::pipe::{ AgentMessage, BrowserMessage, BrowserPipeTool, ConversationMessage, PipeError, Transport, }; +use crate::skill; #[derive(Debug, Clone, PartialEq, Eq)] pub struct AgentRuntimeContext { @@ -34,7 +35,7 @@ impl AgentRuntimeContext { let _ = args.next(); while let Some(arg) = args.next() { - if arg == OsString::from("--config-path") { + if arg.to_string_lossy() == "--config-path" { let Some(value) = args.next() else { return Err(PipeError::Protocol( "missing value for --config-path".to_string(), @@ -88,26 +89,58 @@ fn send_mode_log(transport: &T, mode: &str) -> Result<(), PipeErro }) } -fn explicit_non_task_response(history: &[ConversationMessage], instruction: &str) -> Option { +fn explicit_non_task_response( + history: &[ConversationMessage], + instruction: &str, +) -> Option { if !history.is_empty() { return None; } let trimmed = instruction.trim(); if trimmed.is_empty() { - return Some("sgClaw 目前只处理浏览器任务,请直接描述要打开、搜索、点击或提取的网页操作。".to_string()); + return Some( + "sgClaw 目前只处理浏览器任务,请直接描述要打开、搜索、点击或提取的网页操作。" + .to_string(), + ); } const TASK_HINTS: &[&str] = &[ - "打开", "搜索", "点击", "输入", "导航", "跳转", "访问", "提取", "获取", "网页", "页面", - "标签页", "百度", "知乎", "google", "open", "search", "click", "type", "navigate", + "打开", + "搜索", + "点击", + "输入", + "导航", + "跳转", + "访问", + "提取", + "获取", + "网页", + "页面", + "标签页", + "百度", + "知乎", + "google", + "open", + "search", + "click", + "type", + "navigate", ]; if TASK_HINTS.iter().any(|hint| trimmed.contains(hint)) { return None; } const CHITCHAT_INPUTS: &[&str] = &[ - "hi", "hello", "hey", "你好", "您好", "嗨", "在吗", "你是谁", "介绍一下你自己", + "hi", + "hello", + "hey", + "你好", + "您好", + "嗨", + "在吗", + "你是谁", + "介绍一下你自己", ]; if CHITCHAT_INPUTS .iter() @@ -194,6 +227,22 @@ pub fn handle_browser_message_with_context( }); } + match skill::try_execute_skill(transport, browser_tool, &instruction) { + Ok(Some(summary)) => { + return transport.send(&AgentMessage::TaskComplete { + success: true, + summary, + }); + } + Err(err) => { + return transport.send(&AgentMessage::TaskComplete { + success: false, + summary: err.to_string(), + }); + } + Ok(None) => {} + } + let task_context = CompatTaskContext { conversation_id: (!conversation_id.trim().is_empty()) .then_some(conversation_id.clone()), diff --git a/src/agent/runtime.rs b/src/agent/runtime.rs index 0a5f8fb..c070dc4 100644 --- a/src/agent/runtime.rs +++ b/src/agent/runtime.rs @@ -21,8 +21,7 @@ pub fn execute_task_with_provider( let messages = vec![ ChatMessage { role: "system".to_string(), - content: "You are sgClaw. Use browser_action to complete the browser task." - .to_string(), + content: "You are sgClaw. Use browser_action to complete the browser task.".to_string(), }, ChatMessage { role: "user".to_string(), @@ -35,8 +34,8 @@ pub fn execute_task_with_provider( .map_err(map_llm_error_to_pipe_error)?; for call in calls { - let browser_call = parse_browser_action_call(call) - .map_err(|err| PipeError::Protocol(err.to_string()))?; + let browser_call = + parse_browser_action_call(call).map_err(|err| PipeError::Protocol(err.to_string()))?; transport.send(&AgentMessage::LogEntry { level: "info".to_string(), diff --git a/src/compat/browser_tool_adapter.rs b/src/compat/browser_tool_adapter.rs index d33fa25..26026ed 100644 --- a/src/compat/browser_tool_adapter.rs +++ b/src/compat/browser_tool_adapter.rs @@ -60,14 +60,14 @@ impl Tool for ZeroClawBrowserTool { Err(err) => return Ok(failed_tool_result(err.to_string())), }; - let result = match self.browser_tool.invoke( - request.action, - request.params, - &request.expected_domain, - ) { - Ok(result) => result, - Err(err) => return Ok(failed_tool_result(err.to_string())), - }; + let result = + match self + .browser_tool + .invoke(request.action, request.params, &request.expected_domain) + { + Ok(result) => result, + Err(err) => return Ok(failed_tool_result(err.to_string())), + }; let output = serde_json::to_string(&json!({ "seq": result.seq, @@ -80,8 +80,7 @@ impl Tool for ZeroClawBrowserTool { Ok(ToolResult { success: result.success, output, - error: (!result.success) - .then(|| format_browser_action_error(&result.data)), + error: (!result.success).then(|| format_browser_action_error(&result.data)), }) } } @@ -92,7 +91,9 @@ struct BrowserActionRequest { params: Value, } -fn parse_browser_action_request(args: Value) -> Result { +fn parse_browser_action_request( + args: Value, +) -> Result { let mut args = match args { Value::Object(args) => args, other => { diff --git a/src/compat/config_adapter.rs b/src/compat/config_adapter.rs index be6fda1..766ec8b 100644 --- a/src/compat/config_adapter.rs +++ b/src/compat/config_adapter.rs @@ -8,7 +8,9 @@ use crate::config::DeepSeekSettings; const SGCLAW_ZEROCLAW_WORKSPACE_DIR: &str = ".sgclaw-zeroclaw-workspace"; -pub fn build_zeroclaw_config(workspace_root: &Path) -> Result { +pub fn build_zeroclaw_config( + workspace_root: &Path, +) -> Result { let settings = DeepSeekSettings::from_env()?; Ok(build_zeroclaw_config_from_settings( workspace_root, @@ -21,13 +23,15 @@ pub fn build_zeroclaw_config_from_settings( settings: &DeepSeekSettings, ) -> ZeroClawConfig { let workspace_dir = zeroclaw_workspace_dir(workspace_root); - let mut config = ZeroClawConfig::default(); - config.workspace_dir = workspace_dir.clone(); - config.config_path = workspace_dir.join("config.toml"); - config.default_provider = Some("deepseek".to_string()); - config.default_model = Some(settings.model.clone()); - config.api_key = Some(settings.api_key.clone()); - config.api_url = Some(settings.base_url.clone()); + let mut config = ZeroClawConfig { + workspace_dir: workspace_dir.clone(), + config_path: workspace_dir.join("config.toml"), + default_provider: Some("deepseek".to_string()), + default_model: Some(settings.model.clone()), + api_key: Some(settings.api_key.clone()), + api_url: Some(settings.base_url.clone()), + ..ZeroClawConfig::default() + }; configure_embedded_memory(&mut config); configure_embedded_cron(&mut config); config diff --git a/src/compat/cron_adapter.rs b/src/compat/cron_adapter.rs index 2ba9ab8..1fcaa61 100644 --- a/src/compat/cron_adapter.rs +++ b/src/compat/cron_adapter.rs @@ -65,7 +65,10 @@ where for job in jobs { if !matches!(job.job_type, JobType::Agent) { - anyhow::bail!("unsupported cron job type in sgclaw compat: {:?}", job.job_type); + anyhow::bail!( + "unsupported cron job type in sgclaw compat: {:?}", + job.job_type + ); } let started_at = Utc::now(); diff --git a/src/compat/event_bridge.rs b/src/compat/event_bridge.rs index c2cc5c8..fd891a4 100644 --- a/src/compat/event_bridge.rs +++ b/src/compat/event_bridge.rs @@ -9,10 +9,12 @@ pub fn log_entry_for_turn_event(event: &TurnEvent) -> Option { level: "info".to_string(), message: format_tool_call(name, args), }), - TurnEvent::ToolResult { output, .. } if is_tool_error(output) => Some(AgentMessage::LogEntry { - level: "error".to_string(), - message: output.trim_start_matches("Error: ").to_string(), - }), + TurnEvent::ToolResult { output, .. } if is_tool_error(output) => { + Some(AgentMessage::LogEntry { + level: "error".to_string(), + message: output.trim_start_matches("Error: ").to_string(), + }) + } _ => None, } } @@ -29,7 +31,10 @@ fn format_tool_call(name: &str, args: &Value) -> String { match action { "navigate" => { - let url = args.get("url").and_then(Value::as_str).unwrap_or(""); + let url = args + .get("url") + .and_then(Value::as_str) + .unwrap_or(""); format!("navigate {url}") } "type" => { diff --git a/src/compat/runtime.rs b/src/compat/runtime.rs index 3ce1c3c..170e700 100644 --- a/src/compat/runtime.rs +++ b/src/compat/runtime.rs @@ -7,18 +7,14 @@ use zeroclaw::agent::dispatcher::NativeToolDispatcher; use zeroclaw::agent::{Agent, TurnEvent}; use zeroclaw::config::Config as ZeroClawConfig; use zeroclaw::observability::{NoopObserver, Observer}; -use zeroclaw::providers::{ - self, ChatMessage, ChatRequest, ChatResponse, Provider, -}; -use zeroclaw::providers::traits::{ - ProviderCapabilities, StreamEvent, StreamOptions, StreamResult, -}; +use zeroclaw::providers::traits::{ProviderCapabilities, StreamEvent, StreamOptions, StreamResult}; +use zeroclaw::providers::{self, ChatMessage, ChatRequest, ChatResponse, Provider}; use crate::compat::browser_tool_adapter::{ZeroClawBrowserTool, BROWSER_ACTION_TOOL_NAME}; use crate::compat::config_adapter::build_zeroclaw_config_from_settings; -use crate::config::DeepSeekSettings; use crate::compat::event_bridge::log_entry_for_turn_event; use crate::compat::memory_adapter::build_memory; +use crate::config::DeepSeekSettings; use crate::pipe::{BrowserPipeTool, ConversationMessage, PipeError, Transport}; #[derive(Debug, Clone, Default)] @@ -123,10 +119,7 @@ fn build_agent( fn build_provider(config: &ZeroClawConfig) -> Result, PipeError> { let provider_name = config.default_provider.as_deref().unwrap_or("deepseek"); - let model_name = config - .default_model - .as_deref() - .unwrap_or("deepseek-chat"); + let model_name = config.default_model.as_deref().unwrap_or("deepseek-chat"); let runtime_options = providers::provider_runtime_options_from_config(config); let resolved_provider_name = if provider_name == "deepseek" { config @@ -191,7 +184,9 @@ impl Provider for NonStreamingProvider { model: &str, temperature: f64, ) -> anyhow::Result { - self.inner.chat_with_history(messages, model, temperature).await + self.inner + .chat_with_history(messages, model, temperature) + .await } async fn chat( diff --git a/src/config/settings.rs b/src/config/settings.rs index 219c1f1..84aee89 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -50,8 +50,7 @@ impl DeepSeekSettings { let config: RawDeepSeekSettings = serde_json::from_str(&raw) .map_err(|err| ConfigError::ConfigParse(path.to_path_buf(), err.to_string()))?; - Self::new(config.api_key, config.base_url, config.model) - .map_err(|err| err.with_path(path)) + Self::new(config.api_key, config.base_url, config.model).map_err(|err| err.with_path(path)) } fn new(api_key: String, base_url: String, model: String) -> Result { diff --git a/src/lib.rs b/src/lib.rs index 807fe53..6c48253 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod config; pub mod llm; pub mod pipe; pub mod security; +pub mod skill; use std::path::PathBuf; use std::sync::Arc; diff --git a/src/pipe/browser_tool.rs b/src/pipe/browser_tool.rs index 12210f5..579ad03 100644 --- a/src/pipe/browser_tool.rs +++ b/src/pipe/browser_tool.rs @@ -76,45 +76,35 @@ impl BrowserPipeTool { self.transport.send(&command)?; let started = Instant::now(); - loop { - let Some(remaining) = self.response_timeout.checked_sub(started.elapsed()) else { - return Err(PipeError::Timeout); - }; + let Some(remaining) = self.response_timeout.checked_sub(started.elapsed()) else { + return Err(PipeError::Timeout); + }; - match self.transport.recv_timeout(remaining)? { - BrowserMessage::Response { - seq: response_seq, - success, - data, - aom_snapshot, - timing, - } if response_seq == seq => { - return Ok(CommandOutput { - seq: response_seq, - success, - data, - aom_snapshot, - timing, - }); - } - BrowserMessage::Response { - seq: response_seq, .. - } => { - return Err(PipeError::Protocol(format!( - "received response seq {response_seq} while waiting for {seq}" - ))); - } - BrowserMessage::Init { .. } => { - return Err(PipeError::UnexpectedMessage( - "received duplicate init after handshake".to_string(), - )); - } - BrowserMessage::SubmitTask { .. } => { - return Err(PipeError::UnexpectedMessage( - "received submit_task while waiting for response".to_string(), - )); - } - } + match self.transport.recv_timeout(remaining)? { + BrowserMessage::Response { + seq: response_seq, + success, + data, + aom_snapshot, + timing, + } if response_seq == seq => Ok(CommandOutput { + seq: response_seq, + success, + data, + aom_snapshot, + timing, + }), + BrowserMessage::Response { + seq: response_seq, .. + } => Err(PipeError::Protocol(format!( + "received response seq {response_seq} while waiting for {seq}" + ))), + BrowserMessage::Init { .. } => Err(PipeError::UnexpectedMessage( + "received duplicate init after handshake".to_string(), + )), + BrowserMessage::SubmitTask { .. } => Err(PipeError::UnexpectedMessage( + "received submit_task while waiting for response".to_string(), + )), } } } diff --git a/src/pipe/mod.rs b/src/pipe/mod.rs index 133fa22..7df31ff 100644 --- a/src/pipe/mod.rs +++ b/src/pipe/mod.rs @@ -5,8 +5,8 @@ pub mod protocol; pub use browser_tool::{BrowserPipeTool, CommandOutput}; pub use handshake::{perform_handshake, HandshakeResult}; pub use protocol::{ - supported_actions, Action, AgentMessage, BrowserMessage, ConversationMessage, - SecurityFields, Timing, + supported_actions, Action, AgentMessage, BrowserMessage, ConversationMessage, SecurityFields, + Timing, }; use std::io::{BufRead, BufReader, Read, Write}; @@ -71,7 +71,7 @@ impl StdioTransport { continue; } - if line.as_bytes().len() > MAX_MESSAGE_BYTES { + if line.len() > MAX_MESSAGE_BYTES { let _ = tx.send(Err(PipeError::MessageTooLarge(line.len()))); continue; } diff --git a/src/skill/mod.rs b/src/skill/mod.rs new file mode 100644 index 0000000..1f339e7 --- /dev/null +++ b/src/skill/mod.rs @@ -0,0 +1,85 @@ +pub mod router; +pub mod zhihu; +pub mod zhihu_hotlist; +pub mod zhihu_hotlist_store; +pub mod zhihu_navigation; + +use std::path::PathBuf; + +use crate::pipe::{BrowserPipeTool, PipeError, Transport}; + +fn relative_skill_resource_path(resource_name: &str) -> PathBuf { + PathBuf::from("resources") + .join("skills") + .join(resource_name) +} + +pub(crate) fn skill_resource_path_from_executable( + executable_path: PathBuf, + resource_name: &str, +) -> PathBuf { + executable_path + .parent() + .map(|dir| dir.join("resources").join("skills").join(resource_name)) + .unwrap_or_else(|| relative_skill_resource_path(resource_name)) +} + +pub(crate) fn default_skill_resource_path(resource_name: &str) -> PathBuf { + std::env::current_exe() + .ok() + .map(|path| skill_resource_path_from_executable(path, resource_name)) + .filter(|path| path.exists()) + .unwrap_or_else(|| relative_skill_resource_path(resource_name)) +} + +pub fn try_execute_skill( + transport: &T, + browser_tool: &BrowserPipeTool, + instruction: &str, +) -> Result, PipeError> { + match router::route_instruction(instruction) + .map_err(|err| PipeError::Protocol(err.to_string()))? + { + Some(router::RoutedSkill::ZhihuWrite(req)) => { + let result = zhihu::execute(transport, browser_tool, req) + .map_err(|err| PipeError::Protocol(err.to_string()))?; + Ok(Some(result.summary)) + } + Some(router::RoutedSkill::ZhihuHotlistCollect(req)) => { + let result = zhihu_hotlist::execute_collect(transport, browser_tool, req) + .map_err(|err| PipeError::Protocol(err.to_string()))?; + Ok(Some(result.summary)) + } + Some(router::RoutedSkill::ZhihuHotlistReport(req)) => { + let result = zhihu_hotlist::execute_report(req) + .map_err(|err| PipeError::Protocol(err.to_string()))?; + Ok(Some(result.summary)) + } + Some(router::RoutedSkill::ZhihuNavigate(req)) => { + let result = zhihu_navigation::execute(transport, browser_tool, req) + .map_err(|err| PipeError::Protocol(err.to_string()))?; + Ok(Some(result.summary)) + } + None => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::skill_resource_path_from_executable; + + #[test] + fn skill_resource_path_uses_executable_directory_instead_of_cwd() { + let executable_path = PathBuf::from("/tmp/out/KylinRelease/sgclaw"); + + let resolved = + skill_resource_path_from_executable(executable_path, "zhihu_navigation_pages.json"); + + assert_eq!( + resolved, + PathBuf::from("/tmp/out/KylinRelease/resources/skills/zhihu_navigation_pages.json") + ); + } +} diff --git a/src/skill/router.rs b/src/skill/router.rs new file mode 100644 index 0000000..9969a98 --- /dev/null +++ b/src/skill/router.rs @@ -0,0 +1,92 @@ +use thiserror::Error; + +use super::zhihu::ZhihuWriteRequest; +use super::zhihu_hotlist::{ZhihuHotlistCollectRequest, ZhihuHotlistReportRequest}; +use super::zhihu_navigation::{ + try_route_alias as try_route_zhihu_navigation_alias, ZhihuNavigateRequest, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RoutedSkill { + ZhihuWrite(ZhihuWriteRequest), + ZhihuHotlistCollect(ZhihuHotlistCollectRequest), + ZhihuHotlistReport(ZhihuHotlistReportRequest), + ZhihuNavigate(ZhihuNavigateRequest), +} + +#[derive(Debug, Error)] +pub enum RouterError { + #[error("missing skill name after skill: prefix")] + MissingSkillName, + #[error("missing JSON arguments for skill: {0}")] + MissingArguments(String), + #[error("unknown skill: {0}")] + UnknownSkill(String), + #[error("invalid JSON arguments for skill {skill}: {message}")] + InvalidArguments { skill: String, message: String }, +} + +pub fn route_instruction(instruction: &str) -> Result, RouterError> { + let trimmed = instruction.trim(); + if trimmed.starts_with("skill:") { + return parse_explicit_skill(trimmed).map(Some); + } + + match try_route_zhihu_navigation_alias(trimmed) { + Ok(Some(req)) => Ok(Some(RoutedSkill::ZhihuNavigate(req))), + Ok(None) => Ok(None), + Err(err) => Err(RouterError::InvalidArguments { + skill: "zhihu_navigate".to_string(), + message: err.to_string(), + }), + } +} + +fn parse_explicit_skill(instruction: &str) -> Result { + let rest = instruction + .strip_prefix("skill:") + .ok_or(RouterError::MissingSkillName)? + .trim(); + + if rest.is_empty() { + return Err(RouterError::MissingSkillName); + } + + let split_at = rest + .find(char::is_whitespace) + .ok_or_else(|| RouterError::MissingArguments(rest.to_string()))?; + let name = rest[..split_at].trim(); + let args = rest[split_at..].trim(); + + if args.is_empty() { + return Err(RouterError::MissingArguments(name.to_string())); + } + + match name { + "zhihu_write" => serde_json::from_str::(args) + .map(RoutedSkill::ZhihuWrite) + .map_err(|err| RouterError::InvalidArguments { + skill: name.to_string(), + message: err.to_string(), + }), + "zhihu_hotlist_collect" => serde_json::from_str::(args) + .map(RoutedSkill::ZhihuHotlistCollect) + .map_err(|err| RouterError::InvalidArguments { + skill: name.to_string(), + message: err.to_string(), + }), + "zhihu_hotlist_report" => serde_json::from_str::(args) + .map(RoutedSkill::ZhihuHotlistReport) + .map_err(|err| RouterError::InvalidArguments { + skill: name.to_string(), + message: err.to_string(), + }), + "zhihu_navigate" => serde_json::from_str::(args) + .map(RoutedSkill::ZhihuNavigate) + .map_err(|err| RouterError::InvalidArguments { + skill: name.to_string(), + message: err.to_string(), + }), + other => Err(RouterError::UnknownSkill(other.to_string())), + } +} diff --git a/src/skill/zhihu.rs b/src/skill/zhihu.rs new file mode 100644 index 0000000..a212d0f --- /dev/null +++ b/src/skill/zhihu.rs @@ -0,0 +1,419 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use thiserror::Error; + +use crate::pipe::{Action, AgentMessage, BrowserPipeTool, Transport}; + +const ZHIHU_ARTICLE_URL_PREFIX: &str = "https://zhuanlan.zhihu.com/p/"; +const ZHIHU_ARTICLE_EDIT_SUFFIX: &str = "/edit"; + +fn default_publish() -> bool { + true +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct ZhihuWriteRequest { + pub title: String, + pub body: String, + #[serde(default = "default_publish")] + pub publish: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ZhihuWriteResult { + pub summary: String, + pub published: bool, + pub final_url: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ZhihuFlow { + pub entry_url: String, + pub editor_url: String, + pub domains: HashMap, + pub literals: HashMap, + pub selectors: HashMap, + pub steps: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FlowStep { + pub name: String, + pub action: String, + pub expected_domain: String, + pub selector_ref: Option, + pub url_ref: Option, + pub text_source: Option, + #[serde(default)] + pub clear_first: bool, + pub wait_after_ms: Option, + pub timeout_ms: Option, + pub outer: Option, + pub x: Option, + pub y: Option, + #[serde(default)] + pub only_when_publish: bool, + pub expect_contains: Option, + pub expect_text_source: Option, + #[serde(default)] + pub allow_empty_text: bool, + #[serde(default)] + pub capture_url: bool, + pub log_message: String, +} + +#[derive(Debug, Error)] +pub enum ZhihuSkillError { + #[error("title 不能为空")] + EmptyTitle, + #[error("body 不能为空")] + EmptyBody, + #[error("failed to load zhihu flow: {0}")] + FlowLoad(String), + #[error("unknown action in zhihu flow: {0}")] + UnknownAction(String), + #[error("missing selector ref in zhihu flow step: {0}")] + MissingSelectorRef(String), + #[error("missing url ref in zhihu flow step: {0}")] + MissingUrlRef(String), + #[error("missing selector in zhihu flow: {0}")] + MissingSelector(String), + #[error("missing domain in zhihu flow: {0}")] + MissingDomain(String), + #[error("missing text source in zhihu flow step: {0}")] + MissingTextSource(String), + #[error("missing scroll target in zhihu flow step: {0}")] + MissingScrollTarget(String), + #[error("browser action failed at step {step}: {message}")] + BrowserActionFailed { step: String, message: String }, + #[error("step {step} expected text containing `{expected}`, got `{actual}`")] + ExpectedTextMissing { + step: String, + expected: String, + actual: String, + }, + #[error("step {step} expected text `{expected}`, got `{actual}`")] + ExpectedTextMismatch { + step: String, + expected: String, + actual: String, + }, + #[error("step {step} did not return article url; cannot confirm article was published")] + MissingPublishedUrl { step: String }, +} + +pub fn default_flow_path() -> PathBuf { + super::default_skill_resource_path("zhihu_write_flow.json") +} + +pub fn load_flow() -> Result { + let path = default_flow_path(); + let contents = fs::read_to_string(&path) + .map_err(|err| ZhihuSkillError::FlowLoad(format!("{} ({})", err, path.display())))?; + serde_json::from_str(&contents) + .map_err(|err| ZhihuSkillError::FlowLoad(format!("{} ({})", err, path.display()))) +} + +pub fn execute( + transport: &T, + browser_tool: &BrowserPipeTool, + req: ZhihuWriteRequest, +) -> Result { + validate_request(&req)?; + let flow = load_flow()?; + let mut final_url = None; + let mut published_url = None; + let mut publish_capture_step = None; + + for step in &flow.steps { + if step.only_when_publish && !req.publish { + continue; + } + + transport + .send(&AgentMessage::LogEntry { + level: "info".to_string(), + message: step.log_message.clone(), + }) + .map_err(|err| ZhihuSkillError::BrowserActionFailed { + step: step.name.clone(), + message: err.to_string(), + })?; + + let action = parse_action(&step.action)?; + let expected_domain = resolve_domain(&flow, &step.expected_domain)?; + let params = build_params(&flow, step, &req)?; + let result = browser_tool + .invoke(action, params, &expected_domain) + .map_err(|err| ZhihuSkillError::BrowserActionFailed { + step: step.name.clone(), + message: err.to_string(), + })?; + + if !result.success { + return Err(ZhihuSkillError::BrowserActionFailed { + step: step.name.clone(), + message: result.data.to_string(), + }); + } + + if step.capture_url { + if let Some(url) = extract_url(&result.data) { + if step.only_when_publish { + if is_published_article_url(&url) { + published_url = normalize_published_article_url(&url); + } + } else { + final_url = Some(url); + } + } + if step.only_when_publish { + publish_capture_step = Some(step.name.clone()); + } + } + + if let Some(expected) = step.expect_contains.as_deref() { + let actual = extract_text(&result.data); + if !actual.contains(expected) { + return Err(ZhihuSkillError::ExpectedTextMissing { + step: step.name.clone(), + expected: expected.to_string(), + actual, + }); + } + } + + if let Some(source) = step.expect_text_source.as_deref() { + let expected = resolve_text_source(&req, source)?.to_string(); + let actual = extract_text(&result.data); + if actual.is_empty() && step.allow_empty_text { + continue; + } + if actual != expected { + return Err(ZhihuSkillError::ExpectedTextMismatch { + step: step.name.clone(), + expected, + actual, + }); + } + } + } + + if req.publish { + final_url = Some( + published_url.ok_or_else(|| ZhihuSkillError::MissingPublishedUrl { + step: publish_capture_step.unwrap_or_else(|| "publish_complete".to_string()), + })?, + ); + } + + Ok(ZhihuWriteResult { + summary: build_summary(&req, final_url.as_deref()), + published: req.publish, + final_url, + }) +} + +fn validate_request(req: &ZhihuWriteRequest) -> Result<(), ZhihuSkillError> { + if req.title.trim().is_empty() { + return Err(ZhihuSkillError::EmptyTitle); + } + if req.body.trim().is_empty() { + return Err(ZhihuSkillError::EmptyBody); + } + Ok(()) +} + +fn parse_action(name: &str) -> Result { + match name { + "click" => Ok(Action::Click), + "type" => Ok(Action::Type), + "navigate" => Ok(Action::Navigate), + "getText" => Ok(Action::GetText), + "getHtml" => Ok(Action::GetHtml), + "waitForSelector" => Ok(Action::WaitForSelector), + "scrollTo" => Ok(Action::ScrollTo), + other => Err(ZhihuSkillError::UnknownAction(other.to_string())), + } +} + +fn resolve_domain(flow: &ZhihuFlow, key: &str) -> Result { + flow.domains + .get(key) + .cloned() + .ok_or_else(|| ZhihuSkillError::MissingDomain(key.to_string())) +} + +fn resolve_selector<'a>(flow: &'a ZhihuFlow, key: &str) -> Result<&'a str, ZhihuSkillError> { + flow.selectors + .get(key) + .map(String::as_str) + .ok_or_else(|| ZhihuSkillError::MissingSelector(key.to_string())) +} + +fn resolve_text_source<'a>( + req: &'a ZhihuWriteRequest, + source: &str, +) -> Result<&'a str, ZhihuSkillError> { + match source { + "title" => Ok(req.title.as_str()), + "body" => Ok(req.body.as_str()), + other => Err(ZhihuSkillError::MissingTextSource(other.to_string())), + } +} + +fn build_params( + flow: &ZhihuFlow, + step: &FlowStep, + req: &ZhihuWriteRequest, +) -> Result { + match step.action.as_str() { + "navigate" => { + let url_ref = step + .url_ref + .as_deref() + .ok_or_else(|| ZhihuSkillError::MissingUrlRef(step.name.clone()))?; + let url = match url_ref { + "entry_url" => flow.entry_url.as_str(), + "editor_url" => flow.editor_url.as_str(), + other => { + return Err(ZhihuSkillError::MissingUrlRef(format!( + "{}:{}", + step.name, other + ))) + } + }; + Ok(json!({ "url": url })) + } + "click" => { + let selector_ref = step + .selector_ref + .as_deref() + .ok_or_else(|| ZhihuSkillError::MissingSelectorRef(step.name.clone()))?; + let selector = resolve_selector(flow, selector_ref)?; + let mut params = serde_json::Map::new(); + params.insert("selector".to_string(), Value::String(selector.to_string())); + if let Some(wait_after_ms) = step.wait_after_ms { + params.insert("wait_after".to_string(), Value::from(wait_after_ms)); + } + Ok(Value::Object(params)) + } + "type" => { + let selector_ref = step + .selector_ref + .as_deref() + .ok_or_else(|| ZhihuSkillError::MissingSelectorRef(step.name.clone()))?; + let selector = resolve_selector(flow, selector_ref)?; + let text_source = step + .text_source + .as_deref() + .ok_or_else(|| ZhihuSkillError::MissingTextSource(step.name.clone()))?; + let text = resolve_text_source(req, text_source)?; + Ok(json!({ + "selector": selector, + "text": text, + "clear_first": step.clear_first, + })) + } + "getText" => { + let selector_ref = step + .selector_ref + .as_deref() + .ok_or_else(|| ZhihuSkillError::MissingSelectorRef(step.name.clone()))?; + let selector = resolve_selector(flow, selector_ref)?; + Ok(json!({ "selector": selector })) + } + "getHtml" => { + let selector_ref = step + .selector_ref + .as_deref() + .ok_or_else(|| ZhihuSkillError::MissingSelectorRef(step.name.clone()))?; + let selector = resolve_selector(flow, selector_ref)?; + let mut params = serde_json::Map::new(); + params.insert("selector".to_string(), Value::String(selector.to_string())); + if let Some(outer) = step.outer { + params.insert("outer".to_string(), Value::Bool(outer)); + } + Ok(Value::Object(params)) + } + "waitForSelector" => { + let selector_ref = step + .selector_ref + .as_deref() + .ok_or_else(|| ZhihuSkillError::MissingSelectorRef(step.name.clone()))?; + let selector = resolve_selector(flow, selector_ref)?; + let mut params = serde_json::Map::new(); + params.insert("selector".to_string(), Value::String(selector.to_string())); + if let Some(timeout_ms) = step.timeout_ms { + params.insert("timeout_ms".to_string(), Value::from(timeout_ms)); + } + Ok(Value::Object(params)) + } + "scrollTo" => { + if let Some(selector_ref) = step.selector_ref.as_deref() { + let selector = resolve_selector(flow, selector_ref)?; + return Ok(json!({ "selector": selector })); + } + if step.x.is_none() && step.y.is_none() { + return Err(ZhihuSkillError::MissingScrollTarget(step.name.clone())); + } + let mut params = serde_json::Map::new(); + if let Some(x) = step.x { + params.insert("x".to_string(), Value::from(x)); + } + if let Some(y) = step.y { + params.insert("y".to_string(), Value::from(y)); + } + Ok(Value::Object(params)) + } + other => Err(ZhihuSkillError::UnknownAction(other.to_string())), + } +} + +pub fn extract_text(data: &Value) -> String { + data.get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .trim() + .to_string() +} + +fn extract_url(data: &Value) -> Option { + data.get("url") + .and_then(Value::as_str) + .map(str::trim) + .filter(|url| !url.is_empty()) + .map(ToOwned::to_owned) +} + +fn is_published_article_url(url: &str) -> bool { + normalize_published_article_url(url).is_some() +} + +fn normalize_published_article_url(url: &str) -> Option { + let trimmed = url.trim(); + if !trimmed.starts_with(ZHIHU_ARTICLE_URL_PREFIX) { + return None; + } + if trimmed.ends_with(ZHIHU_ARTICLE_EDIT_SUFFIX) { + return Some( + trimmed + .trim_end_matches(ZHIHU_ARTICLE_EDIT_SUFFIX) + .to_string(), + ); + } + Some(trimmed.to_string()) +} + +fn build_summary(req: &ZhihuWriteRequest, final_url: Option<&str>) -> String { + if req.publish { + let url = final_url.expect("publish flow must provide final_url before building summary"); + format!("知乎文章已发布:{} ({url})", req.title.trim()) + } else { + format!("知乎文章草稿已填充:{}", req.title.trim()) + } +} diff --git a/src/skill/zhihu_hotlist.rs b/src/skill/zhihu_hotlist.rs new file mode 100644 index 0000000..5862d76 --- /dev/null +++ b/src/skill/zhihu_hotlist.rs @@ -0,0 +1,815 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs; +use std::path::PathBuf; +use std::sync::OnceLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +use regex::Regex; +use scraper::{ElementRef, Html, Selector}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use thiserror::Error; +use uuid::Uuid; + +use crate::pipe::{Action, AgentMessage, BrowserPipeTool, CommandOutput, Transport}; + +use super::zhihu_hotlist_store::{ + load_latest_snapshot, load_snapshot, persist_snapshot, resolve_store_dir, + ZhihuCommentMetricSnapshot, ZhihuHotItemSnapshot, ZhihuHotlistCollectionStats, + ZhihuHotlistSnapshot, ZhihuHotlistStoreError, +}; + +const COLLECTOR_VERSION: &str = "zhihu_hotlist_v1"; +const DEFAULT_WAIT_TIMEOUT_MS: u64 = 5_000; +const DEFAULT_COMMENT_SCROLL_Y: i64 = 1_200; + +fn default_top_n() -> usize { + 10 +} + +fn default_comments_per_item() -> usize { + 20 +} + +fn default_report_top_n() -> usize { + 10 +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct ZhihuHotlistCollectRequest { + #[serde(default = "default_top_n")] + pub top_n: usize, + #[serde(default = "default_comments_per_item")] + pub comments_per_item: usize, + #[serde(default)] + pub store_dir: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ZhihuHotlistCollectResult { + pub summary: String, + pub snapshot_id: String, + pub item_count: usize, + pub snapshot_path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct ZhihuHotlistReportRequest { + #[serde(default)] + pub snapshot_id: Option, + #[serde(default)] + pub store_dir: Option, + #[serde(default = "default_report_top_n")] + pub top_n: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ZhihuHotlistReportResult { + pub summary: String, + pub snapshot_id: String, + pub item_count: usize, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ZhihuHotlistFlow { + pub hotlist_url: String, + pub domains: HashMap, + pub literals: HashMap, + pub selectors: HashMap, +} + +#[derive(Debug, Error)] +pub enum ZhihuHotlistSkillError { + #[error("top_n must be greater than 0")] + InvalidTopN, + #[error("comments_per_item must be greater than 0")] + InvalidCommentsPerItem, + #[error("failed to load zhihu hotlist flow: {0}")] + FlowLoad(String), + #[error("missing selector in zhihu hotlist flow: {0}")] + MissingSelector(String), + #[error("missing domain in zhihu hotlist flow: {0}")] + MissingDomain(String), + #[error("missing literal in zhihu hotlist flow: {0}")] + MissingLiteral(String), + #[error("invalid selector in zhihu hotlist flow `{name}`: {message}")] + InvalidSelector { name: String, message: String }, + #[error("browser action failed at step {step}: {message}")] + BrowserActionFailed { step: String, message: String }, + #[error("zhihu hotlist page did not expose any items")] + NoHotlistItems, + #[error("zhihu hotlist html did not include enough data for item extraction")] + IncompleteHotlistHtml, + #[error(transparent)] + Store(#[from] ZhihuHotlistStoreError), +} + +pub fn default_flow_path() -> PathBuf { + super::default_skill_resource_path("zhihu_hotlist_flow.json") +} + +pub fn load_flow() -> Result { + let path = default_flow_path(); + let contents = fs::read_to_string(&path) + .map_err(|err| ZhihuHotlistSkillError::FlowLoad(format!("{} ({})", err, path.display())))?; + serde_json::from_str(&contents) + .map_err(|err| ZhihuHotlistSkillError::FlowLoad(format!("{} ({})", err, path.display()))) +} + +pub fn execute_collect( + transport: &T, + browser_tool: &BrowserPipeTool, + req: ZhihuHotlistCollectRequest, +) -> Result { + validate_collect_request(&req)?; + let flow = load_flow()?; + let zhihu_domain = resolve_domain(&flow, "zhihu")?; + let hotlist_guard = resolve_literal(&flow, "hotlist_guard")?; + let hotlist_root_selector = resolve_selector(&flow, "hotlist_root")?; + let hotlist_item_selector = resolve_selector(&flow, "hotlist_item")?; + let comment_list_selector = resolve_selector(&flow, "comment_list")?; + let comment_item_selector = resolve_selector(&flow, "comment_item")?; + let comment_metric_selector = resolve_selector(&flow, "comment_metric")?; + + let page_url = ensure_hotlist_page( + transport, + browser_tool, + &flow, + &zhihu_domain, + hotlist_guard, + hotlist_root_selector, + hotlist_item_selector, + )?; + + let hotlist_html = run_action( + transport, + browser_tool, + "capture hotlist html", + Action::GetHtml, + json!({ "selector": hotlist_root_selector, "outer": true }), + &zhihu_domain, + )?; + let hotlist_items = parse_hotlist_items(&hotlist_html.data, &flow, req.top_n)?; + if hotlist_items.is_empty() { + return Err(ZhihuHotlistSkillError::NoHotlistItems); + } + + let mut items = Vec::with_capacity(hotlist_items.len()); + let mut partial_items = 0usize; + let mut items_with_comment_metrics = 0usize; + let mut total_comment_metric_records = 0usize; + + let comment_context = CommentCollectionContext { + zhihu_domain: &zhihu_domain, + comment_list_selector, + comment_item_selector, + comment_metric_selector, + page_root_selector: hotlist_root_selector, + comments_per_item: req.comments_per_item, + }; + + for hot_item in hotlist_items { + let comment_metrics = match collect_comment_metrics( + transport, + browser_tool, + &comment_context, + &hot_item.url, + hot_item.rank, + ) { + Ok(metrics) => metrics, + Err(_) => { + partial_items += 1; + Vec::new() + } + }; + + if !comment_metrics.is_empty() { + items_with_comment_metrics += 1; + total_comment_metric_records += comment_metrics.len(); + } + + items.push(ZhihuHotItemSnapshot { + rank: hot_item.rank, + item_id: hot_item.item_id, + url: hot_item.url, + title: hot_item.title, + summary: hot_item.summary, + heat_text: hot_item.heat_text.clone(), + heat_value: parse_count_text(&hot_item.heat_text), + comment_metrics, + }); + } + + let snapshot = ZhihuHotlistSnapshot { + snapshot_id: build_snapshot_id(), + captured_at_ms: now_unix_ms(), + page_url, + collector_version: COLLECTOR_VERSION.to_string(), + collection_stats: ZhihuHotlistCollectionStats { + requested_items: req.top_n, + collected_items: items.len(), + items_with_comment_metrics, + total_comment_metric_records, + partial_items, + }, + items, + }; + + let store_dir = resolve_store_dir(req.store_dir.as_deref()); + let persisted = persist_snapshot(&store_dir, &snapshot)?; + let summary = format!( + "知乎热榜快照已保存:{} 条热榜,{} 条评论指标记录 ({})", + snapshot.items.len(), + snapshot.collection_stats.total_comment_metric_records, + persisted.snapshot_path.display() + ); + + Ok(ZhihuHotlistCollectResult { + summary, + snapshot_id: snapshot.snapshot_id, + item_count: snapshot.items.len(), + snapshot_path: persisted.snapshot_path.display().to_string(), + }) +} + +pub fn execute_report( + req: ZhihuHotlistReportRequest, +) -> Result { + validate_report_request(&req)?; + let store_dir = resolve_store_dir(req.store_dir.as_deref()); + let snapshot = match req.snapshot_id.as_deref() { + Some(snapshot_id) if !snapshot_id.trim().is_empty() => { + load_snapshot(&store_dir, snapshot_id.trim())? + } + _ => load_latest_snapshot(&store_dir)?, + }; + + let mut lines = vec![format!( + "知乎热榜报告 {}: 共 {} 条,采集于 {}", + snapshot.snapshot_id, + snapshot.items.len(), + snapshot.captured_at_ms + )]; + + for item in snapshot.items.iter().take(req.top_n) { + let totals = aggregate_comment_metrics(&item.comment_metrics); + lines.push(format!( + "{}. {} | 热度 {} | 评论指标 {} 条 | 回复 {} | 赞同 {} | 收藏 {} | 红心 {}", + item.rank, + item.title, + item.heat_text, + item.comment_metrics.len(), + totals.reply_count, + totals.upvote_count, + totals.favorite_count, + totals.heart_count, + )); + } + + Ok(ZhihuHotlistReportResult { + summary: lines.join("\n"), + snapshot_id: snapshot.snapshot_id, + item_count: snapshot.items.len(), + }) +} + +fn validate_collect_request( + req: &ZhihuHotlistCollectRequest, +) -> Result<(), ZhihuHotlistSkillError> { + if req.top_n == 0 { + return Err(ZhihuHotlistSkillError::InvalidTopN); + } + if req.comments_per_item == 0 { + return Err(ZhihuHotlistSkillError::InvalidCommentsPerItem); + } + Ok(()) +} + +fn validate_report_request(req: &ZhihuHotlistReportRequest) -> Result<(), ZhihuHotlistSkillError> { + if req.top_n == 0 { + return Err(ZhihuHotlistSkillError::InvalidTopN); + } + Ok(()) +} + +fn ensure_hotlist_page( + transport: &T, + browser_tool: &BrowserPipeTool, + flow: &ZhihuHotlistFlow, + zhihu_domain: &str, + hotlist_guard: &str, + hotlist_root_selector: &str, + hotlist_item_selector: &str, +) -> Result { + let hotlist_probe = run_action( + transport, + browser_tool, + "probe current Zhihu page for hotlist guard", + Action::GetText, + json!({ "selector": hotlist_root_selector }), + zhihu_domain, + ); + + if let Ok(result) = hotlist_probe { + let text = extract_text(&result.data); + if text.contains(hotlist_guard) { + run_action( + transport, + browser_tool, + "wait for hotlist items on current page", + Action::WaitForSelector, + json!({ "selector": hotlist_item_selector, "timeout_ms": DEFAULT_WAIT_TIMEOUT_MS }), + zhihu_domain, + )?; + return Ok(extract_url(&result.data).unwrap_or_else(|| flow.hotlist_url.clone())); + } + } + + let navigate = run_action( + transport, + browser_tool, + "navigate to Zhihu hotlist", + Action::Navigate, + json!({ "url": flow.hotlist_url }), + zhihu_domain, + )?; + run_action( + transport, + browser_tool, + "wait for Zhihu hotlist items", + Action::WaitForSelector, + json!({ "selector": hotlist_item_selector, "timeout_ms": DEFAULT_WAIT_TIMEOUT_MS }), + zhihu_domain, + )?; + + Ok(extract_url(&navigate.data).unwrap_or_else(|| flow.hotlist_url.clone())) +} + +fn collect_comment_metrics( + transport: &T, + browser_tool: &BrowserPipeTool, + context: &CommentCollectionContext<'_>, + item_url: &str, + rank: usize, +) -> Result, ZhihuHotlistSkillError> { + let step_prefix = format!("collect comment metrics for hot item #{rank}"); + run_action( + transport, + browser_tool, + &format!("{step_prefix}: navigate detail page"), + Action::Navigate, + json!({ "url": item_url }), + context.zhihu_domain, + )?; + run_action( + transport, + browser_tool, + &format!("{step_prefix}: wait for page root"), + Action::WaitForSelector, + json!({ "selector": context.page_root_selector, "timeout_ms": DEFAULT_WAIT_TIMEOUT_MS }), + context.zhihu_domain, + )?; + run_action( + transport, + browser_tool, + &format!("{step_prefix}: scroll toward comments"), + Action::ScrollTo, + json!({ "y": DEFAULT_COMMENT_SCROLL_Y }), + context.zhihu_domain, + )?; + run_action( + transport, + browser_tool, + &format!("{step_prefix}: wait for comment list"), + Action::WaitForSelector, + json!({ "selector": context.comment_list_selector, "timeout_ms": DEFAULT_WAIT_TIMEOUT_MS }), + context.zhihu_domain, + )?; + run_action( + transport, + browser_tool, + &format!("{step_prefix}: scroll comment list into view"), + Action::ScrollTo, + json!({ "selector": context.comment_list_selector }), + context.zhihu_domain, + )?; + let comments_html = run_action( + transport, + browser_tool, + &format!("{step_prefix}: capture page html for comments"), + Action::GetHtml, + json!({ "selector": context.page_root_selector, "outer": true }), + context.zhihu_domain, + )?; + + Ok(parse_comment_metrics( + &comments_html.data, + context.comment_list_selector, + context.comment_item_selector, + context.comment_metric_selector, + context.comments_per_item, + )) +} + +fn run_action( + transport: &T, + browser_tool: &BrowserPipeTool, + step: &str, + action: Action, + params: Value, + expected_domain: &str, +) -> Result { + transport + .send(&AgentMessage::LogEntry { + level: "info".to_string(), + message: step.to_string(), + }) + .map_err(|err| ZhihuHotlistSkillError::BrowserActionFailed { + step: step.to_string(), + message: err.to_string(), + })?; + + let result = browser_tool + .invoke(action, params, expected_domain) + .map_err(|err| ZhihuHotlistSkillError::BrowserActionFailed { + step: step.to_string(), + message: err.to_string(), + })?; + + if !result.success { + return Err(ZhihuHotlistSkillError::BrowserActionFailed { + step: step.to_string(), + message: result.data.to_string(), + }); + } + + Ok(result) +} + +fn parse_hotlist_items( + data: &Value, + flow: &ZhihuHotlistFlow, + top_n: usize, +) -> Result, ZhihuHotlistSkillError> { + let html = extract_html(data); + if html.trim().is_empty() { + return Err(ZhihuHotlistSkillError::IncompleteHotlistHtml); + } + + let document = Html::parse_document(&html); + let item_selector = parse_selector("hotlist_item", resolve_selector(flow, "hotlist_item")?)?; + let title_link_selector = parse_selector( + "hotlist_title_link", + resolve_selector(flow, "hotlist_title_link")?, + )?; + let summary_selector = parse_selector( + "hotlist_summary", + resolve_selector(flow, "hotlist_summary")?, + )?; + let heat_selector = parse_selector("hotlist_heat", resolve_selector(flow, "hotlist_heat")?)?; + + let mut seen_urls = HashSet::new(); + let mut items = Vec::new(); + + for (index, element) in document.select(&item_selector).enumerate() { + let Some(link) = element.select(&title_link_selector).next() else { + continue; + }; + + let title = compact_text(&link); + if title.is_empty() { + continue; + } + + let href = link.value().attr("href").unwrap_or_default(); + let url = normalize_zhihu_url(href); + if url.is_empty() || !seen_urls.insert(url.clone()) { + continue; + } + + let summary = element + .select(&summary_selector) + .next() + .map(|node| compact_text(&node)) + .unwrap_or_default(); + let heat_text = element + .select(&heat_selector) + .next() + .map(|node| compact_text(&node)) + .unwrap_or_default(); + + items.push(ParsedHotItem { + rank: index + 1, + item_id: derive_item_id(&url), + url, + title, + summary, + heat_text, + }); + if items.len() >= top_n { + break; + } + } + + if items.is_empty() { + return Err(ZhihuHotlistSkillError::NoHotlistItems); + } + + for (index, item) in items.iter_mut().enumerate() { + item.rank = index + 1; + } + + Ok(items) +} + +fn parse_comment_metrics( + data: &Value, + comment_list_selector: &str, + comment_item_selector: &str, + comment_metric_selector: &str, + comments_per_item: usize, +) -> Vec { + let html = extract_html(data); + if html.trim().is_empty() { + return Vec::new(); + } + + let document = Html::parse_document(&html); + let comment_item_selector = match Selector::parse(comment_item_selector) { + Ok(selector) => selector, + Err(_) => return Vec::new(), + }; + let metric_selector = match Selector::parse(comment_metric_selector) { + Ok(selector) => selector, + Err(_) => return Vec::new(), + }; + let comment_list_selector = match Selector::parse(comment_list_selector) { + Ok(selector) => selector, + Err(_) => return Vec::new(), + }; + + let container = document + .select(&comment_list_selector) + .next() + .map(|node| node.html()) + .unwrap_or_else(|| html.clone()); + let scoped_document = Html::parse_fragment(&container); + + scoped_document + .select(&comment_item_selector) + .take(comments_per_item) + .enumerate() + .map(|(index, element)| { + build_comment_metric_snapshot(index + 1, &element, &metric_selector) + }) + .collect() +} + +fn build_comment_metric_snapshot( + position: usize, + element: &ElementRef<'_>, + metric_selector: &Selector, +) -> ZhihuCommentMetricSnapshot { + let mut raw_metrics = BTreeMap::new(); + let mut snapshot = ZhihuCommentMetricSnapshot { + position, + comment_id: element + .value() + .attr("data-id") + .or_else(|| element.value().attr("data-comment-id")) + .or_else(|| element.value().attr("id")) + .map(ToString::to_string), + reply_count: None, + upvote_count: None, + favorite_count: None, + heart_count: None, + raw_metrics: None, + }; + + for metric in element.select(metric_selector) { + let text = compact_text(&metric); + if text.is_empty() { + continue; + } + + let count = parse_count_text(&text).or(Some(0)); + let lowered = text.to_ascii_lowercase(); + if text.contains("回复") { + snapshot.reply_count = count; + } else if text.contains("赞") || lowered.contains("upvote") { + snapshot.upvote_count = count; + } else if text.contains("收藏") + || lowered.contains("favorite") + || lowered.contains("bookmark") + { + snapshot.favorite_count = count; + } else if text.contains("喜欢") + || text.contains("红心") + || text.contains('❤') + || text.contains('♥') + { + snapshot.heart_count = count; + } else if let Some(value) = count { + raw_metrics.insert(sanitize_metric_key(&text), value); + } + } + + if !raw_metrics.is_empty() { + snapshot.raw_metrics = Some(raw_metrics); + } + + snapshot +} + +fn parse_selector(name: &str, raw: &str) -> Result { + Selector::parse(raw).map_err(|err| ZhihuHotlistSkillError::InvalidSelector { + name: name.to_string(), + message: err.to_string(), + }) +} + +fn resolve_selector<'a>( + flow: &'a ZhihuHotlistFlow, + key: &str, +) -> Result<&'a str, ZhihuHotlistSkillError> { + flow.selectors + .get(key) + .map(String::as_str) + .ok_or_else(|| ZhihuHotlistSkillError::MissingSelector(key.to_string())) +} + +fn resolve_domain(flow: &ZhihuHotlistFlow, key: &str) -> Result { + flow.domains + .get(key) + .cloned() + .ok_or_else(|| ZhihuHotlistSkillError::MissingDomain(key.to_string())) +} + +fn resolve_literal<'a>( + flow: &'a ZhihuHotlistFlow, + key: &str, +) -> Result<&'a str, ZhihuHotlistSkillError> { + flow.literals + .get(key) + .map(String::as_str) + .ok_or_else(|| ZhihuHotlistSkillError::MissingLiteral(key.to_string())) +} + +fn extract_text(data: &Value) -> String { + data.get("text") + .and_then(Value::as_str) + .or_else(|| data.as_str()) + .unwrap_or_default() + .trim() + .to_string() +} + +fn extract_html(data: &Value) -> String { + data.get("html") + .and_then(Value::as_str) + .or_else(|| data.get("outer_html").and_then(Value::as_str)) + .or_else(|| data.as_str()) + .unwrap_or_default() + .to_string() +} + +fn extract_url(data: &Value) -> Option { + data.get("url") + .and_then(Value::as_str) + .map(ToString::to_string) +} + +fn compact_text(element: &ElementRef<'_>) -> String { + element + .text() + .map(str::trim) + .filter(|text| !text.is_empty()) + .collect::>() + .join(" ") +} + +fn normalize_zhihu_url(raw: &str) -> String { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return String::new(); + } + if trimmed.starts_with("https://") || trimmed.starts_with("http://") { + return trimmed.split('#').next().unwrap_or(trimmed).to_string(); + } + if let Some(rest) = trimmed.strip_prefix("//") { + return format!("https://{}", rest.split('#').next().unwrap_or(rest)); + } + if trimmed.starts_with('/') { + return format!("https://www.zhihu.com{}", trimmed); + } + format!("https://www.zhihu.com/{}", trimmed.trim_start_matches('/')) +} + +fn derive_item_id(url: &str) -> String { + let normalized = url + .trim() + .trim_start_matches("https://") + .trim_start_matches("http://"); + let path = normalized + .split_once('/') + .map(|(_, path)| path) + .unwrap_or_default() + .split('?') + .next() + .unwrap_or_default() + .trim_matches('/'); + if path.is_empty() { + "root".to_string() + } else { + path.replace('/', "_") + } +} + +fn aggregate_comment_metrics(metrics: &[ZhihuCommentMetricSnapshot]) -> AggregatedCommentMetrics { + let mut totals = AggregatedCommentMetrics::default(); + for metric in metrics { + totals.reply_count += metric.reply_count.unwrap_or(0); + totals.upvote_count += metric.upvote_count.unwrap_or(0); + totals.favorite_count += metric.favorite_count.unwrap_or(0); + totals.heart_count += metric.heart_count.unwrap_or(0); + } + totals +} + +fn sanitize_metric_key(text: &str) -> String { + let compact = text + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '_' + } + }) + .collect::(); + compact.trim_matches('_').to_string() +} + +fn build_snapshot_id() -> String { + format!("{}-{}", now_unix_ms(), Uuid::new_v4()) +} + +fn now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn parse_count_text(text: &str) -> Option { + let compact = text.replace([',', ' '], ""); + let captures = count_regex().captures(&compact)?; + let number = captures.name("number")?.as_str().parse::().ok()?; + let unit = captures + .name("unit") + .map(|unit| unit.as_str()) + .unwrap_or_default(); + let multiplier = match unit { + "万" | "w" | "W" => 10_000f64, + "亿" => 100_000_000f64, + "k" | "K" => 1_000f64, + "m" | "M" => 1_000_000f64, + _ => 1f64, + }; + Some((number * multiplier).round() as u64) +} + +fn count_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"(?P\d+(?:\.\d+)?)\s*(?P万|亿|[kKmMwW])?").unwrap() + }) +} + +#[derive(Debug, Clone, Copy)] +struct CommentCollectionContext<'a> { + zhihu_domain: &'a str, + comment_list_selector: &'a str, + comment_item_selector: &'a str, + comment_metric_selector: &'a str, + page_root_selector: &'a str, + comments_per_item: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedHotItem { + rank: usize, + item_id: String, + url: String, + title: String, + summary: String, + heat_text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +struct AggregatedCommentMetrics { + reply_count: u64, + upvote_count: u64, + favorite_count: u64, + heart_count: u64, +} diff --git a/src/skill/zhihu_hotlist_store.rs b/src/skill/zhihu_hotlist_store.rs new file mode 100644 index 0000000..005b226 --- /dev/null +++ b/src/skill/zhihu_hotlist_store.rs @@ -0,0 +1,184 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct ZhihuHotlistIndex { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latest_snapshot_id: Option, + #[serde(default)] + pub snapshots: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ZhihuHotlistIndexEntry { + pub snapshot_id: String, + pub captured_at_ms: u64, + pub path: String, + pub item_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ZhihuHotlistSnapshot { + pub snapshot_id: String, + pub captured_at_ms: u64, + pub page_url: String, + pub collector_version: String, + pub items: Vec, + pub collection_stats: ZhihuHotlistCollectionStats, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ZhihuHotItemSnapshot { + pub rank: usize, + pub item_id: String, + pub url: String, + pub title: String, + pub summary: String, + pub heat_text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub heat_value: Option, + #[serde(default)] + pub comment_metrics: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ZhihuCommentMetricSnapshot { + pub position: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub comment_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reply_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upvote_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub favorite_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub heart_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_metrics: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ZhihuHotlistCollectionStats { + pub requested_items: usize, + pub collected_items: usize, + pub items_with_comment_metrics: usize, + pub total_comment_metric_records: usize, + pub partial_items: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersistedSnapshotPaths { + pub snapshot_path: PathBuf, + pub index_path: PathBuf, +} + +#[derive(Debug, Error)] +pub enum ZhihuHotlistStoreError { + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + #[error("no persisted Zhihu hotlist snapshots found")] + NoSnapshots, + #[error("snapshot not found: {0}")] + SnapshotNotFound(String), +} + +pub fn default_store_dir() -> PathBuf { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join("data") + .join("zhihu_hotlist") +} + +pub fn resolve_store_dir(store_dir: Option<&str>) -> PathBuf { + match store_dir { + Some(path) if !path.trim().is_empty() => PathBuf::from(path), + _ => default_store_dir(), + } +} + +pub fn persist_snapshot( + base_dir: &Path, + snapshot: &ZhihuHotlistSnapshot, +) -> Result { + let snapshot_dir = snapshots_dir(base_dir); + fs::create_dir_all(&snapshot_dir)?; + + let snapshot_rel_path = format!("snapshots/{}.json", snapshot.snapshot_id); + let snapshot_path = base_dir.join(&snapshot_rel_path); + fs::write(&snapshot_path, serde_json::to_vec_pretty(snapshot)?)?; + + let mut index = load_index(base_dir)?; + index.latest_snapshot_id = Some(snapshot.snapshot_id.clone()); + index + .snapshots + .retain(|entry| entry.snapshot_id != snapshot.snapshot_id); + index.snapshots.push(ZhihuHotlistIndexEntry { + snapshot_id: snapshot.snapshot_id.clone(), + captured_at_ms: snapshot.captured_at_ms, + path: snapshot_rel_path, + item_count: snapshot.items.len(), + }); + index + .snapshots + .sort_by(|left, right| left.captured_at_ms.cmp(&right.captured_at_ms)); + + let index_path = index_path(base_dir); + fs::write(&index_path, serde_json::to_vec_pretty(&index)?)?; + + Ok(PersistedSnapshotPaths { + snapshot_path, + index_path, + }) +} + +pub fn load_index(base_dir: &Path) -> Result { + let path = index_path(base_dir); + if !path.exists() { + return Ok(ZhihuHotlistIndex::default()); + } + + let contents = fs::read_to_string(path)?; + Ok(serde_json::from_str(&contents)?) +} + +pub fn load_snapshot( + base_dir: &Path, + snapshot_id: &str, +) -> Result { + let path = base_dir + .join("snapshots") + .join(format!("{}.json", snapshot_id.trim())); + if !path.exists() { + return Err(ZhihuHotlistStoreError::SnapshotNotFound( + snapshot_id.trim().to_string(), + )); + } + + let contents = fs::read_to_string(path)?; + Ok(serde_json::from_str(&contents)?) +} + +pub fn load_latest_snapshot( + base_dir: &Path, +) -> Result { + let index = load_index(base_dir)?; + let snapshot_id = index + .latest_snapshot_id + .ok_or(ZhihuHotlistStoreError::NoSnapshots)?; + load_snapshot(base_dir, &snapshot_id) +} + +fn index_path(base_dir: &Path) -> PathBuf { + base_dir.join("index.json") +} + +fn snapshots_dir(base_dir: &Path) -> PathBuf { + base_dir.join("snapshots") +} diff --git a/src/skill/zhihu_navigation.rs b/src/skill/zhihu_navigation.rs new file mode 100644 index 0000000..26d4fb0 --- /dev/null +++ b/src/skill/zhihu_navigation.rs @@ -0,0 +1,890 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use thiserror::Error; + +use crate::pipe::{Action, AgentMessage, BrowserPipeTool, CommandOutput, Transport}; + +const DEFAULT_WAIT_TIMEOUT_MS: u64 = 5_000; + +fn default_ensure_loaded() -> bool { + true +} + +fn default_capture_url() -> bool { + true +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct ZhihuNavigateRequest { + pub page: String, + #[serde(default = "default_ensure_loaded")] + pub ensure_loaded: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ZhihuNavigateResult { + pub summary: String, + pub page: String, + pub final_url: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ZhihuNavigationCatalog { + pub domains: HashMap, + #[serde(default)] + pub routes: HashMap, + #[serde(default)] + pub components: HashMap, + #[serde(default)] + pub flows: HashMap, + pub targets: HashMap, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ZhihuRouteDefinition { + pub title: String, + pub domain_ref: String, + pub url: String, + #[serde(default)] + pub aliases: Vec, + pub wait_selector: Option, + pub wait_timeout_ms: Option, + pub expect_selector: Option, + pub expect_text: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ZhihuComponentDefinition { + pub title: String, + pub domain_ref: String, + pub selector: String, + #[serde(default)] + pub aliases: Vec, + pub entry_route_ref: Option, + pub result_domain_ref: Option, + pub wait_selector: Option, + pub wait_timeout_ms: Option, + pub expect_selector: Option, + pub expect_text: Option, + pub wait_after_ms: Option, + #[serde(default = "default_capture_url")] + pub capture_url: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ZhihuTargetKind { + Route, + Component, + Flow, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ZhihuSummaryKind { + Page, + Entry, + Menu, + Navigation, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ZhihuTargetDefinition { + pub title: String, + pub kind: ZhihuTargetKind, + pub summary_kind: Option, + pub route_ref: Option, + pub component_ref: Option, + pub flow_ref: Option, + #[serde(default)] + pub aliases: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ZhihuFlowDefinition { + pub steps: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ZhihuFlowStep { + pub name: String, + pub action: String, + pub route_ref: Option, + pub component_ref: Option, + pub expected_domain: Option, + pub timeout_ms: Option, + pub wait_after_ms: Option, + #[serde(default)] + pub capture_url: bool, + pub expect_text: Option, + pub log_message: String, +} + +#[derive(Debug, Error)] +pub enum ZhihuNavigationError { + #[error("page 不能为空")] + EmptyPage, + #[error("failed to load zhihu navigation catalog: {0}")] + CatalogLoad(String), + #[error("unknown zhihu target: {0}")] + UnknownTarget(String), + #[error("missing domain in zhihu navigation catalog: {0}")] + MissingDomain(String), + #[error("missing route in zhihu navigation catalog: {0}")] + MissingRoute(String), + #[error("missing component in zhihu navigation catalog: {0}")] + MissingComponent(String), + #[error("missing flow in zhihu navigation catalog: {0}")] + MissingFlow(String), + #[error("invalid target definition in zhihu navigation catalog: {0}")] + InvalidTargetDefinition(String), + #[error("missing route ref in zhihu navigation flow step: {0}")] + MissingRouteRef(String), + #[error("missing component ref in zhihu navigation flow step: {0}")] + MissingComponentRef(String), + #[error("unknown action in zhihu navigation flow: {0}")] + UnknownAction(String), + #[error("browser action failed at step {step}: {message}")] + BrowserActionFailed { step: String, message: String }, + #[error("step {step} expected text containing `{expected}`, got `{actual}`")] + ExpectedTextMissing { + step: String, + expected: String, + actual: String, + }, +} + +#[derive(Debug, Default)] +struct ExecutionState { + final_url: Option, +} + +#[derive(Debug, Clone, Copy)] +struct PostActionChecks<'a> { + expected_domain: &'a str, + wait_selector: Option<&'a str>, + wait_timeout_ms: Option, + expect_selector: Option<&'a str>, + expect_text: Option<&'a str>, + reset_url_when_absent: bool, +} + +pub fn default_catalog_path() -> PathBuf { + super::default_skill_resource_path("zhihu_navigation_pages.json") +} + +pub fn load_catalog() -> Result { + let path = default_catalog_path(); + let contents = fs::read_to_string(&path).map_err(|err| { + ZhihuNavigationError::CatalogLoad(format!("{} ({})", err, path.display())) + })?; + serde_json::from_str(&contents) + .map_err(|err| ZhihuNavigationError::CatalogLoad(format!("{} ({})", err, path.display()))) +} + +pub fn try_route_alias( + instruction: &str, +) -> Result, ZhihuNavigationError> { + let trimmed = instruction.trim(); + if !looks_like_navigation_intent(trimmed) { + return Ok(None); + } + + let catalog = load_catalog()?; + let normalized_instruction = normalize_text(trimmed); + let mut matches = Vec::new(); + + for (target_key, target) in &catalog.targets { + let score = best_target_match_score(&catalog, target, &normalized_instruction); + if score > 0 { + matches.push((target_key.as_str(), score)); + } + } + + if matches.is_empty() { + return Ok(None); + } + + matches.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(right.0))); + if matches.len() > 1 && matches[0].1 == matches[1].1 { + return Ok(None); + } + + Ok(Some(ZhihuNavigateRequest { + page: matches[0].0.to_string(), + ensure_loaded: true, + })) +} + +pub fn execute( + transport: &T, + browser_tool: &BrowserPipeTool, + req: ZhihuNavigateRequest, +) -> Result { + validate_request(&req)?; + let catalog = load_catalog()?; + let target_key = req.page.trim(); + let target = resolve_target(&catalog, target_key)?; + let mut state = ExecutionState::default(); + + match target.kind { + ZhihuTargetKind::Route => { + let route_ref = target.route_ref.as_deref().ok_or_else(|| { + ZhihuNavigationError::InvalidTargetDefinition(target_key.to_string()) + })?; + run_route( + transport, + browser_tool, + &catalog, + route_ref, + req.ensure_loaded, + &mut state, + )?; + } + ZhihuTargetKind::Component => { + let component_ref = target.component_ref.as_deref().ok_or_else(|| { + ZhihuNavigationError::InvalidTargetDefinition(target_key.to_string()) + })?; + run_component_target( + transport, + browser_tool, + &catalog, + component_ref, + req.ensure_loaded, + &mut state, + )?; + } + ZhihuTargetKind::Flow => { + let flow_ref = target.flow_ref.as_deref().ok_or_else(|| { + ZhihuNavigationError::InvalidTargetDefinition(target_key.to_string()) + })?; + run_flow(transport, browser_tool, &catalog, flow_ref, &mut state)?; + } + } + + let final_url = state.final_url.unwrap_or_default(); + Ok(ZhihuNavigateResult { + summary: build_summary(target, &final_url), + page: target_key.to_string(), + final_url, + }) +} + +fn validate_request(req: &ZhihuNavigateRequest) -> Result<(), ZhihuNavigationError> { + if req.page.trim().is_empty() { + return Err(ZhihuNavigationError::EmptyPage); + } + Ok(()) +} + +fn run_route( + transport: &T, + browser_tool: &BrowserPipeTool, + catalog: &ZhihuNavigationCatalog, + route_ref: &str, + ensure_loaded: bool, + state: &mut ExecutionState, +) -> Result<(), ZhihuNavigationError> { + let route = resolve_route(catalog, route_ref)?; + let expected_domain = resolve_domain(catalog, &route.domain_ref)?; + + send_log(transport, &format!("navigate {}", route.url), "navigate")?; + let navigate_result = invoke_browser_action( + browser_tool, + Action::Navigate, + json!({ "url": route.url }), + expected_domain.as_str(), + "navigate", + )?; + state.final_url = Some(extract_url(&navigate_result.data).unwrap_or_else(|| route.url.clone())); + + if ensure_loaded { + run_post_action_checks( + transport, + browser_tool, + PostActionChecks { + expected_domain: expected_domain.as_str(), + wait_selector: route.wait_selector.as_deref(), + wait_timeout_ms: route.wait_timeout_ms, + expect_selector: route.expect_selector.as_deref(), + expect_text: route.expect_text.as_deref(), + reset_url_when_absent: false, + }, + state, + )?; + } + + Ok(()) +} + +fn run_component_target( + transport: &T, + browser_tool: &BrowserPipeTool, + catalog: &ZhihuNavigationCatalog, + component_ref: &str, + ensure_loaded: bool, + state: &mut ExecutionState, +) -> Result<(), ZhihuNavigationError> { + let component = resolve_component(catalog, component_ref)?; + + if let Some(entry_route_ref) = component.entry_route_ref.as_deref() { + run_route( + transport, + browser_tool, + catalog, + entry_route_ref, + false, + state, + )?; + } + + let expected_domain = resolve_domain(catalog, &component.domain_ref)?; + send_log( + transport, + &format!("click {}", component.title), + component_ref, + )?; + let click_result = invoke_browser_action( + browser_tool, + Action::Click, + build_click_params(component.selector.as_str(), component.wait_after_ms), + expected_domain.as_str(), + component_ref, + )?; + + if component.capture_url { + if let Some(url) = extract_url(&click_result.data) { + state.final_url = Some(url); + } + } + + if ensure_loaded { + let result_domain_ref = component + .result_domain_ref + .as_deref() + .unwrap_or(component.domain_ref.as_str()); + let result_domain = resolve_domain(catalog, result_domain_ref)?; + run_post_action_checks( + transport, + browser_tool, + PostActionChecks { + expected_domain: result_domain.as_str(), + wait_selector: component.wait_selector.as_deref(), + wait_timeout_ms: component.wait_timeout_ms, + expect_selector: component.expect_selector.as_deref(), + expect_text: component.expect_text.as_deref(), + reset_url_when_absent: !component.capture_url, + }, + state, + )?; + } + + Ok(()) +} + +fn run_flow( + transport: &T, + browser_tool: &BrowserPipeTool, + catalog: &ZhihuNavigationCatalog, + flow_ref: &str, + state: &mut ExecutionState, +) -> Result<(), ZhihuNavigationError> { + let flow = resolve_flow(catalog, flow_ref)?; + + for step in &flow.steps { + send_log(transport, &step.log_message, step.name.as_str())?; + let action = parse_action(&step.action)?; + let is_navigate = matches!(action, Action::Navigate); + let (expected_domain, params, fallback_url) = + build_flow_step_action(catalog, step, &action)?; + let result = invoke_browser_action( + browser_tool, + action, + params, + expected_domain.as_str(), + step.name.as_str(), + )?; + + if is_navigate { + state.final_url = Some( + extract_url(&result.data) + .or(fallback_url.clone()) + .unwrap_or_default(), + ); + } else if step.capture_url { + if let Some(url) = extract_url(&result.data) { + state.final_url = Some(url); + } + } + + if let Some(expected_text) = step.expect_text.as_deref() { + let actual = extract_content(&result.data); + if !actual.contains(expected_text) { + return Err(ZhihuNavigationError::ExpectedTextMissing { + step: step.name.clone(), + expected: expected_text.to_string(), + actual, + }); + } + } + } + + Ok(()) +} + +fn build_flow_step_action( + catalog: &ZhihuNavigationCatalog, + step: &ZhihuFlowStep, + action: &Action, +) -> Result<(String, Value, Option), ZhihuNavigationError> { + match action { + Action::Navigate => { + let route_ref = step + .route_ref + .as_deref() + .ok_or_else(|| ZhihuNavigationError::MissingRouteRef(step.name.clone()))?; + let route = resolve_route(catalog, route_ref)?; + let domain_key = step + .expected_domain + .as_deref() + .unwrap_or(route.domain_ref.as_str()); + let expected_domain = resolve_domain(catalog, domain_key)?; + Ok(( + expected_domain, + json!({ "url": route.url }), + Some(route.url.clone()), + )) + } + Action::Click => { + let component_ref = step + .component_ref + .as_deref() + .ok_or_else(|| ZhihuNavigationError::MissingComponentRef(step.name.clone()))?; + let component = resolve_component(catalog, component_ref)?; + let domain_key = step + .expected_domain + .as_deref() + .unwrap_or(component.domain_ref.as_str()); + let expected_domain = resolve_domain(catalog, domain_key)?; + let wait_after_ms = step.wait_after_ms.or(component.wait_after_ms); + Ok(( + expected_domain, + build_click_params(component.selector.as_str(), wait_after_ms), + None, + )) + } + Action::WaitForSelector => { + let component_ref = step + .component_ref + .as_deref() + .ok_or_else(|| ZhihuNavigationError::MissingComponentRef(step.name.clone()))?; + let component = resolve_component(catalog, component_ref)?; + let domain_key = step + .expected_domain + .as_deref() + .unwrap_or(component.domain_ref.as_str()); + let expected_domain = resolve_domain(catalog, domain_key)?; + Ok(( + expected_domain, + json!({ + "selector": component.selector, + "timeout_ms": step.timeout_ms.unwrap_or(component.wait_timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS)), + }), + None, + )) + } + Action::GetText => { + let component_ref = step + .component_ref + .as_deref() + .ok_or_else(|| ZhihuNavigationError::MissingComponentRef(step.name.clone()))?; + let component = resolve_component(catalog, component_ref)?; + let domain_key = step + .expected_domain + .as_deref() + .unwrap_or(component.domain_ref.as_str()); + let expected_domain = resolve_domain(catalog, domain_key)?; + Ok(( + expected_domain, + json!({ "selector": component.selector }), + None, + )) + } + Action::GetHtml => { + let component_ref = step + .component_ref + .as_deref() + .ok_or_else(|| ZhihuNavigationError::MissingComponentRef(step.name.clone()))?; + let component = resolve_component(catalog, component_ref)?; + let domain_key = step + .expected_domain + .as_deref() + .unwrap_or(component.domain_ref.as_str()); + let expected_domain = resolve_domain(catalog, domain_key)?; + Ok(( + expected_domain, + json!({ "selector": component.selector, "outer": true }), + None, + )) + } + Action::ScrollTo => { + let component_ref = step + .component_ref + .as_deref() + .ok_or_else(|| ZhihuNavigationError::MissingComponentRef(step.name.clone()))?; + let component = resolve_component(catalog, component_ref)?; + let domain_key = step + .expected_domain + .as_deref() + .unwrap_or(component.domain_ref.as_str()); + let expected_domain = resolve_domain(catalog, domain_key)?; + Ok(( + expected_domain, + json!({ "selector": component.selector }), + None, + )) + } + other => Err(ZhihuNavigationError::UnknownAction( + other.as_str().to_string(), + )), + } +} + +fn run_post_action_checks( + transport: &T, + browser_tool: &BrowserPipeTool, + checks: PostActionChecks<'_>, + state: &mut ExecutionState, +) -> Result<(), ZhihuNavigationError> { + if let Some(selector) = checks.wait_selector { + send_log( + transport, + &format!("wait for {selector}"), + "wait_for_selector", + )?; + let wait_result = invoke_browser_action( + browser_tool, + Action::WaitForSelector, + json!({ + "selector": selector, + "timeout_ms": checks.wait_timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS), + }), + checks.expected_domain, + "wait_for_selector", + )?; + if let Some(url) = extract_url(&wait_result.data) { + state.final_url = Some(url); + } else if checks.reset_url_when_absent { + state.final_url = None; + } + } + + if let (Some(selector), Some(expected_text)) = (checks.expect_selector, checks.expect_text) { + send_log(transport, &format!("verify {selector}"), "verify_text")?; + let text_result = invoke_browser_action( + browser_tool, + Action::GetText, + json!({ "selector": selector }), + checks.expected_domain, + "verify_text", + )?; + if let Some(url) = extract_url(&text_result.data) { + state.final_url = Some(url); + } else if checks.reset_url_when_absent { + state.final_url = None; + } + let actual = extract_content(&text_result.data); + if !actual.contains(expected_text) { + return Err(ZhihuNavigationError::ExpectedTextMissing { + step: "verify_text".to_string(), + expected: expected_text.to_string(), + actual, + }); + } + } + + Ok(()) +} + +fn send_log( + transport: &T, + message: &str, + step: &str, +) -> Result<(), ZhihuNavigationError> { + transport + .send(&AgentMessage::LogEntry { + level: "info".to_string(), + message: message.to_string(), + }) + .map_err(|err| ZhihuNavigationError::BrowserActionFailed { + step: step.to_string(), + message: err.to_string(), + }) +} + +fn invoke_browser_action( + browser_tool: &BrowserPipeTool, + action: Action, + params: Value, + expected_domain: &str, + step: &str, +) -> Result { + let result = browser_tool + .invoke(action, params, expected_domain) + .map_err(|err| ZhihuNavigationError::BrowserActionFailed { + step: step.to_string(), + message: err.to_string(), + })?; + if !result.success { + return Err(ZhihuNavigationError::BrowserActionFailed { + step: step.to_string(), + message: result.data.to_string(), + }); + } + Ok(result) +} + +fn build_click_params(selector: &str, wait_after_ms: Option) -> Value { + let mut params = serde_json::Map::new(); + params.insert("selector".to_string(), Value::String(selector.to_string())); + if let Some(wait_after_ms) = wait_after_ms { + params.insert("wait_after".to_string(), Value::from(wait_after_ms)); + } + Value::Object(params) +} + +fn parse_action(name: &str) -> Result { + match name { + "click" => Ok(Action::Click), + "navigate" => Ok(Action::Navigate), + "getText" => Ok(Action::GetText), + "getHtml" => Ok(Action::GetHtml), + "waitForSelector" => Ok(Action::WaitForSelector), + "scrollTo" => Ok(Action::ScrollTo), + other => Err(ZhihuNavigationError::UnknownAction(other.to_string())), + } +} + +fn resolve_target<'a>( + catalog: &'a ZhihuNavigationCatalog, + target_key: &str, +) -> Result<&'a ZhihuTargetDefinition, ZhihuNavigationError> { + catalog + .targets + .get(target_key) + .ok_or_else(|| ZhihuNavigationError::UnknownTarget(target_key.to_string())) +} + +fn resolve_route<'a>( + catalog: &'a ZhihuNavigationCatalog, + route_ref: &str, +) -> Result<&'a ZhihuRouteDefinition, ZhihuNavigationError> { + catalog + .routes + .get(route_ref) + .ok_or_else(|| ZhihuNavigationError::MissingRoute(route_ref.to_string())) +} + +fn resolve_component<'a>( + catalog: &'a ZhihuNavigationCatalog, + component_ref: &str, +) -> Result<&'a ZhihuComponentDefinition, ZhihuNavigationError> { + catalog + .components + .get(component_ref) + .ok_or_else(|| ZhihuNavigationError::MissingComponent(component_ref.to_string())) +} + +fn resolve_flow<'a>( + catalog: &'a ZhihuNavigationCatalog, + flow_ref: &str, +) -> Result<&'a ZhihuFlowDefinition, ZhihuNavigationError> { + catalog + .flows + .get(flow_ref) + .ok_or_else(|| ZhihuNavigationError::MissingFlow(flow_ref.to_string())) +} + +fn resolve_domain( + catalog: &ZhihuNavigationCatalog, + key: &str, +) -> Result { + catalog + .domains + .get(key) + .cloned() + .ok_or_else(|| ZhihuNavigationError::MissingDomain(key.to_string())) +} + +fn best_target_match_score( + catalog: &ZhihuNavigationCatalog, + target: &ZhihuTargetDefinition, + normalized_instruction: &str, +) -> usize { + let best_len = collect_target_aliases(catalog, target) + .into_iter() + .map(|alias| normalize_text(alias.as_str())) + .filter(|alias| !alias.is_empty() && normalized_instruction.contains(alias)) + .map(|alias| alias.len()) + .max() + .unwrap_or(0); + + if best_len == 0 { + return 0; + } + + best_len * 100 + match_bonus(target, normalized_instruction) +} + +fn collect_target_aliases( + catalog: &ZhihuNavigationCatalog, + target: &ZhihuTargetDefinition, +) -> Vec { + let mut aliases = Vec::new(); + aliases.push(target.title.clone()); + aliases.extend(target.aliases.iter().cloned()); + + if let Some(route_ref) = target.route_ref.as_deref() { + if let Some(route) = catalog.routes.get(route_ref) { + aliases.push(route.title.clone()); + aliases.extend(route.aliases.iter().cloned()); + } + } + if let Some(component_ref) = target.component_ref.as_deref() { + if let Some(component) = catalog.components.get(component_ref) { + aliases.push(component.title.clone()); + aliases.extend(component.aliases.iter().cloned()); + } + } + + aliases.retain(|alias| !alias.trim().is_empty()); + aliases.sort(); + aliases.dedup(); + aliases +} + +fn match_bonus(target: &ZhihuTargetDefinition, normalized_instruction: &str) -> usize { + let mut bonus = 0; + let summary_kind = target_summary_kind(target); + + if normalized_instruction.contains("页面") && summary_kind == ZhihuSummaryKind::Page { + bonus += 20; + } + if ["按钮", "入口"] + .iter() + .any(|token| normalized_instruction.contains(token)) + && summary_kind == ZhihuSummaryKind::Entry + { + bonus += 20; + } + if ["菜单", "下拉"] + .iter() + .any(|token| normalized_instruction.contains(token)) + && summary_kind == ZhihuSummaryKind::Menu + { + bonus += 20; + } + + bonus +} + +fn target_summary_kind(target: &ZhihuTargetDefinition) -> ZhihuSummaryKind { + target.summary_kind.unwrap_or(match target.kind { + ZhihuTargetKind::Route => ZhihuSummaryKind::Page, + ZhihuTargetKind::Component => ZhihuSummaryKind::Entry, + ZhihuTargetKind::Flow => ZhihuSummaryKind::Navigation, + }) +} + +fn build_summary(target: &ZhihuTargetDefinition, final_url: &str) -> String { + match target_summary_kind(target) { + ZhihuSummaryKind::Page => { + format!("知乎页面已打开:{} ({final_url})", target.title) + } + ZhihuSummaryKind::Entry => { + if final_url.is_empty() { + format!("知乎入口已打开:{}", target.title) + } else { + format!("知乎入口已打开:{} ({final_url})", target.title) + } + } + ZhihuSummaryKind::Menu => format!("知乎菜单已打开:{}", target.title), + ZhihuSummaryKind::Navigation => { + if final_url.is_empty() { + format!("知乎导航已完成:{}", target.title) + } else { + format!("知乎导航已完成:{} ({final_url})", target.title) + } + } + } +} + +fn looks_like_navigation_intent(instruction: &str) -> bool { + let normalized = normalize_text(instruction); + let has_platform = ["知乎", "专栏", "创作中心", "创作者中心"] + .iter() + .any(|token| normalized.contains(token)); + let has_verb = ["打开", "进入", "跳转", "前往", "去", "点开", "展开", "切到"] + .iter() + .any(|token| normalized.contains(token)); + has_platform && has_verb +} + +fn normalize_text(text: &str) -> String { + text.chars() + .filter(|ch| { + !ch.is_whitespace() + && !matches!( + ch, + ',' | '。' + | ':' + | ';' + | '!' + | '?' + | '、' + | '(' + | ')' + | '【' + | '】' + | ',' + | '.' + | ':' + | ';' + | '!' + | '?' + | '(' + | ')' + | '[' + | ']' + | '"' + | '\'' + | '/' + | '\\' + | '-' + | '_' + ) + }) + .flat_map(|ch| ch.to_lowercase()) + .collect() +} + +fn extract_content(data: &Value) -> String { + data.get("text") + .and_then(Value::as_str) + .or_else(|| data.get("html").and_then(Value::as_str)) + .unwrap_or_default() + .trim() + .to_string() +} + +fn extract_url(data: &Value) -> Option { + data.get("url") + .and_then(Value::as_str) + .map(str::trim) + .filter(|url| !url.is_empty()) + .map(ToOwned::to_owned) +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index f79a5c6..654dbf0 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -17,6 +17,7 @@ impl MockTransport { } } + #[allow(dead_code)] pub fn sent_messages(&self) -> Vec { self.sent.lock().unwrap().clone() } diff --git a/tests/compat_browser_tool_test.rs b/tests/compat_browser_tool_test.rs index 485769a..3411f21 100644 --- a/tests/compat_browser_tool_test.rs +++ b/tests/compat_browser_tool_test.rs @@ -26,7 +26,9 @@ fn test_policy() -> MacPolicy { .unwrap() } -fn build_adapter(messages: Vec) -> (Arc, ZeroClawBrowserTool) { +fn build_adapter( + messages: Vec, +) -> (Arc, ZeroClawBrowserTool) { let transport = Arc::new(MockTransport::new(messages)); let browser_tool = BrowserPipeTool::new( transport.clone(), @@ -193,13 +195,11 @@ async fn zeroclaw_browser_tool_keeps_domain_validation_in_mac_policy() { assert!(!result.success); assert!(result.output.is_empty()); assert_eq!(transport.sent_messages().len(), 0); - assert!( - result - .error - .as_deref() - .unwrap() - .contains("domain is not allowed") - ); + assert!(result + .error + .as_deref() + .unwrap() + .contains("domain is not allowed")); } #[tokio::test] @@ -232,25 +232,19 @@ async fn zeroclaw_browser_tool_rejects_missing_required_action_parameters() { assert!(!missing_text_selector.success); assert!(!missing_navigate_url.success); assert_eq!(transport.sent_messages().len(), 0); - assert!( - missing_click_selector - .error - .as_deref() - .unwrap() - .contains("click requires selector") - ); - assert!( - missing_text_selector - .error - .as_deref() - .unwrap() - .contains("getText requires selector") - ); - assert!( - missing_navigate_url - .error - .as_deref() - .unwrap() - .contains("navigate requires url") - ); + assert!(missing_click_selector + .error + .as_deref() + .unwrap() + .contains("click requires selector")); + assert!(missing_text_selector + .error + .as_deref() + .unwrap() + .contains("getText requires selector")); + assert!(missing_navigate_url + .error + .as_deref() + .unwrap() + .contains("navigate requires url")); } diff --git a/tests/compat_config_test.rs b/tests/compat_config_test.rs index 41ac578..ed72ec7 100644 --- a/tests/compat_config_test.rs +++ b/tests/compat_config_test.rs @@ -3,9 +3,7 @@ use std::path::Path; use std::sync::{Mutex, OnceLock}; use sgclaw::compat::config_adapter::{ - build_zeroclaw_config, - build_zeroclaw_config_from_settings, - zeroclaw_workspace_dir, + build_zeroclaw_config, build_zeroclaw_config_from_settings, zeroclaw_workspace_dir, }; use sgclaw::config::DeepSeekSettings; use uuid::Uuid; @@ -49,11 +47,17 @@ fn zeroclaw_config_adapter_uses_deterministic_workspace_dir() { let workspace_dir = zeroclaw_workspace_dir(Path::new("/var/lib/sgclaw")); let config = build_zeroclaw_config_from_settings(Path::new("/var/lib/sgclaw"), &settings); - assert_eq!(workspace_dir, Path::new("/var/lib/sgclaw/.sgclaw-zeroclaw-workspace")); + assert_eq!( + workspace_dir, + Path::new("/var/lib/sgclaw/.sgclaw-zeroclaw-workspace") + ); assert_eq!(config.workspace_dir, workspace_dir); assert_eq!(config.default_provider.as_deref(), Some("deepseek")); assert_eq!(config.default_model.as_deref(), Some("deepseek-reasoner")); - assert_eq!(config.api_url.as_deref(), Some("https://proxy.example.com/v1")); + assert_eq!( + config.api_url.as_deref(), + Some("https://proxy.example.com/v1") + ); } #[test] diff --git a/tests/compat_runtime_test.rs b/tests/compat_runtime_test.rs index 8c6e352..2103034 100644 --- a/tests/compat_runtime_test.rs +++ b/tests/compat_runtime_test.rs @@ -3,7 +3,7 @@ mod common; use std::fs; use std::io::{Read, Write}; use std::net::TcpListener; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; use std::thread; use std::time::Duration; @@ -11,9 +11,7 @@ use std::time::Duration; use common::MockTransport; use serde_json::{json, Value}; use sgclaw::agent::{ - handle_browser_message, - handle_browser_message_with_context, - AgentRuntimeContext, + handle_browser_message, handle_browser_message_with_context, AgentRuntimeContext, }; use sgclaw::compat::runtime::{execute_task, CompatTaskContext}; use sgclaw::config::DeepSeekSettings; @@ -48,7 +46,7 @@ fn temp_workspace_root() -> PathBuf { root } -fn write_deepseek_config(root: &PathBuf, api_key: &str, base_url: &str, model: &str) -> PathBuf { +fn write_deepseek_config(root: &Path, api_key: &str, base_url: &str, model: &str) -> PathBuf { let config_path = root.join("sgclaw_config.json"); fs::write( &config_path, @@ -94,7 +92,7 @@ fn start_fake_deepseek_server( let payload = response.to_string(); let reply = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - payload.as_bytes().len(), + payload.len(), payload ); stream.write_all(reply.as_bytes()).unwrap(); @@ -281,7 +279,8 @@ fn compat_runtime_uses_zeroclaw_provider_path_and_executes_browser_actions() { } #[test] -fn handle_browser_message_prefers_compat_runtime_for_supported_instruction_when_deepseek_is_configured() { +fn handle_browser_message_prefers_compat_runtime_for_supported_instruction_when_deepseek_is_configured( +) { let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner()); let first_response = json!({ @@ -643,11 +642,9 @@ fn compat_runtime_includes_prior_turns_in_follow_up_provider_request() { assert_eq!(summary, "已在知乎搜索天气"); assert!(first_request_messages.iter().any(|message| { - message["role"] == json!("user") - && message["content"] == json!("打开百度搜索天气") + message["role"] == json!("user") && message["content"] == json!("打开百度搜索天气") })); assert!(first_request_messages.iter().any(|message| { - message["role"] == json!("assistant") - && message["content"] == json!("已在百度搜索天气") + message["role"] == json!("assistant") && message["content"] == json!("已在百度搜索天气") })); } diff --git a/tests/deepseek_provider_test.rs b/tests/deepseek_provider_test.rs index c994dc6..9f1c86b 100644 --- a/tests/deepseek_provider_test.rs +++ b/tests/deepseek_provider_test.rs @@ -60,8 +60,5 @@ fn deepseek_request_shape_matches_openai_compatible_chat_format() { assert_eq!(serialized["messages"][0]["role"], "system"); assert_eq!(serialized["messages"][1]["content"], "打开百度搜索天气"); assert_eq!(serialized["tools"][0]["type"], "function"); - assert_eq!( - serialized["tools"][0]["function"]["name"], - "browser_action" - ); + assert_eq!(serialized["tools"][0]["function"]["name"], "browser_action"); } diff --git a/tests/runtime_task_flow_test.rs b/tests/runtime_task_flow_test.rs index 4ccd7e6..c5414c8 100644 --- a/tests/runtime_task_flow_test.rs +++ b/tests/runtime_task_flow_test.rs @@ -12,9 +12,9 @@ fn test_policy() -> MacPolicy { MacPolicy::from_json_str( r#"{ "version": "1.0", - "domains": { "allowed": ["oa.example.com", "www.baidu.com"] }, + "domains": { "allowed": ["oa.example.com", "www.baidu.com", "www.zhihu.com", "zhuanlan.zhihu.com"] }, "pipe_actions": { - "allowed": ["click", "type", "navigate", "getText"], + "allowed": ["click", "type", "navigate", "getText", "getHtml", "waitForSelector", "scrollTo"], "blocked": ["eval", "executeJsInPage"] } }"#, @@ -120,3 +120,116 @@ fn submit_task_sends_three_commands_and_finishes_with_task_complete() { if *success && summary == "已在百度搜索天气" )); } + +#[test] +fn explicit_zhihu_skill_short_circuits_before_planner_fallback() { + let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response { + seq: 1, + success: true, + data: serde_json::json!({ "url": "https://www.zhihu.com/creator/analytics/work/all" }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 20, + }, + }])); + let tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message( + transport.as_ref(), + &tool, + BrowserMessage::SubmitTask { + instruction: + r#"skill:zhihu_navigate {"page":"content_analysis","ensure_loaded":false}"# + .to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: String::new(), + page_title: String::new(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + + assert_eq!(sent.len(), 3); + assert!(matches!( + &sent[0], + AgentMessage::LogEntry { level, message } + if level == "info" + && message == "navigate https://www.zhihu.com/creator/analytics/work/all" + )); + assert!(matches!( + &sent[1], + AgentMessage::Command { seq, action, security, .. } + if *seq == 1 + && action == &Action::Navigate + && security.expected_domain == "www.zhihu.com" + )); + assert!(matches!( + &sent[2], + AgentMessage::TaskComplete { success, summary } + if *success + && summary + == "知乎页面已打开:内容分析 (https://www.zhihu.com/creator/analytics/work/all)" + )); +} + +#[test] +fn natural_language_zhihu_navigation_short_circuits_before_planner_fallback() { + let transport = Arc::new(MockTransport::new(vec![BrowserMessage::Response { + seq: 1, + success: true, + data: serde_json::json!({ "url": "https://www.zhihu.com/" }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 20, + }, + }])); + let tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + handle_browser_message( + transport.as_ref(), + &tool, + BrowserMessage::SubmitTask { + instruction: "打开知乎首页".to_string(), + conversation_id: String::new(), + messages: vec![], + page_url: String::new(), + page_title: String::new(), + }, + ) + .unwrap(); + + let sent = transport.sent_messages(); + + assert_eq!(sent.len(), 3); + assert!(matches!( + &sent[0], + AgentMessage::LogEntry { level, message } + if level == "info" && message == "navigate https://www.zhihu.com/" + )); + assert!(matches!( + &sent[1], + AgentMessage::Command { seq, action, security, .. } + if *seq == 1 + && action == &Action::Navigate + && security.expected_domain == "www.zhihu.com" + )); + assert!(matches!( + &sent[2], + AgentMessage::TaskComplete { success, summary } + if *success && summary == "知乎页面已打开:首页 (https://www.zhihu.com/)" + )); +} diff --git a/tests/skill_router_test.rs b/tests/skill_router_test.rs new file mode 100644 index 0000000..5b7bdb6 --- /dev/null +++ b/tests/skill_router_test.rs @@ -0,0 +1,441 @@ +use sgclaw::skill::router::{route_instruction, RoutedSkill, RouterError}; + +#[test] +fn route_instruction_parses_explicit_zhihu_skill() { + let routed = route_instruction( + r#"skill:zhihu_write {"title":"自动发文能力测试","body":"第一段\n\n第二段","publish":false}"#, + ) + .unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuWrite(req)) + if req.title == "自动发文能力测试" + && req.body == "第一段\n\n第二段" + && !req.publish + )); +} + +#[test] +fn route_instruction_parses_explicit_zhihu_hotlist_collect_skill() { + let routed = route_instruction( + r#"skill:zhihu_hotlist_collect {"top_n":5,"comments_per_item":8,"store_dir":"data/zhihu_hotlist"}"#, + ) + .unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuHotlistCollect(req)) + if req.top_n == 5 + && req.comments_per_item == 8 + && req.store_dir.as_deref() == Some("data/zhihu_hotlist") + )); +} + +#[test] +fn route_instruction_parses_explicit_zhihu_hotlist_report_skill() { + let routed = + route_instruction(r#"skill:zhihu_hotlist_report {"snapshot_id":"snap-1","top_n":3}"#) + .unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuHotlistReport(req)) + if req.snapshot_id.as_deref() == Some("snap-1") + && req.top_n == 3 + )); +} + +#[test] +fn route_instruction_parses_explicit_zhihu_navigation_skill() { + let routed = route_instruction( + r#"skill:zhihu_navigate {"page":"content_analysis","ensure_loaded":true}"#, + ) + .unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "content_analysis" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_content_analysis_natural_language() { + let routed = route_instruction("帮我打开知乎中的内容分析页面").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "content_analysis" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_short_zhihu_content_analysis_phrase() { + let routed = route_instruction("打开知乎内容分析").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "content_analysis" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_income_analysis_natural_language() { + let routed = route_instruction("打开知乎收益分析页面").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "income_analysis" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_home_natural_language() { + let routed = route_instruction("打开知乎首页").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "home" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_hot_list_natural_language() { + let routed = route_instruction("打开知乎热榜页面").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "hot_list" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_column_home_natural_language() { + let routed = route_instruction("打开知乎专栏页").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "column_home" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_question_page_natural_language() { + let routed = route_instruction("打开知乎问题页").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "question_page" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_messages_page_natural_language() { + let routed = route_instruction("打开知乎消息分栏").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "messages_page" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_messages_all_tab_natural_language() { + let routed = route_instruction("打开知乎消息分栏全部私信").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "messages_all_tab" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_messages_unread_tab_natural_language() { + let routed = route_instruction("打开知乎消息分栏未读消息").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "messages_unread_tab" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_messages_strangers_tab_natural_language() { + let routed = route_instruction("打开知乎消息分栏陌生人消息").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "messages_strangers_tab" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_messages_settings_menu_natural_language() { + let routed = route_instruction("打开知乎消息设置菜单").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "messages_settings_menu" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_notifications_page_natural_language() { + let routed = route_instruction("打开知乎通知分栏").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "notifications_page" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_notifications_replies_tab_natural_language() { + let routed = route_instruction("打开知乎通知分栏回复我的").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "notifications_replies_tab" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_notifications_votes_favorites_tab_natural_language() { + let routed = route_instruction("打开知乎通知分栏赞同与收藏").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "notifications_votes_favorites_tab" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_notifications_follows_tab_natural_language() { + let routed = route_instruction("打开知乎通知分栏关注我的").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "notifications_follows_tab" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_notifications_system_tab_natural_language() { + let routed = route_instruction("打开知乎通知分栏系统通知").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "notifications_system_tab" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_notifications_settings_menu_natural_language() { + let routed = route_instruction("打开知乎通知设置菜单").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "notifications_settings_menu" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_profile_page_natural_language() { + let routed = route_instruction("打开知乎个人主页").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "profile_page" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_profile_answers_tab_natural_language() { + let routed = route_instruction("打开知乎个人主页回答分栏").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "profile_answers_tab" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_profile_followers_tab_natural_language() { + let routed = route_instruction("打开知乎个人主页粉丝分栏").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "profile_followers_tab" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_account_settings_natural_language() { + let routed = route_instruction("打开知乎账号设置菜单").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "settings_account_menu" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_privacy_settings_natural_language() { + let routed = route_instruction("打开知乎隐私设置菜单").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "settings_privacy_menu" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_security_settings_natural_language() { + let routed = route_instruction("打开知乎安全设置菜单").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "settings_security_menu" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_search_filter_menu_natural_language() { + let routed = route_instruction("打开知乎搜索筛选菜单").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "search_filter_menu" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_context_more_menu_natural_language() { + let routed = route_instruction("打开知乎更多菜单").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "context_more_menu" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_notifications_menu_natural_language() { + let routed = route_instruction("打开知乎通知菜单").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "notifications_menu" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_notifications_entry_natural_language() { + let routed = route_instruction("打开知乎通知按钮").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "notifications_entry" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_zhihu_search_box_natural_language() { + let routed = route_instruction("打开知乎搜索框").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "search_box" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_creator_write_button_natural_language() { + let routed = route_instruction("打开知乎创作中心写文章按钮").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "creator_write_button" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_routes_open_hot_from_home_flow_natural_language() { + let routed = route_instruction("从知乎首页进入热榜").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "open_hot_from_home" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_returns_none_for_non_skill_text() { + let routed = route_instruction("打开百度搜索天气").unwrap(); + + assert!(routed.is_none()); +} + +#[test] +fn route_instruction_returns_none_for_vague_zhihu_navigation_text() { + let routed = route_instruction("打开知乎").unwrap(); + + assert!(routed.is_none()); +} + +#[test] +fn route_instruction_returns_none_for_ambiguous_zhihu_notification_phrase() { + let routed = route_instruction("打开知乎通知").unwrap(); + + assert!(routed.is_none()); +} + +#[test] +fn route_instruction_routes_zhihu_hot_button_phrase_to_hot_tab() { + let routed = route_instruction("打开知乎热榜按钮").unwrap(); + + assert!(matches!( + routed, + Some(RoutedSkill::ZhihuNavigate(req)) + if req.page == "hot_tab" && req.ensure_loaded + )); +} + +#[test] +fn route_instruction_rejects_unknown_skill_name() { + let err = route_instruction(r#"skill:unknown {"x":1}"#).unwrap_err(); + + assert!(matches!(err, RouterError::UnknownSkill(name) if name == "unknown")); +} diff --git a/tests/zhihu_hotlist_skill_test.rs b/tests/zhihu_hotlist_skill_test.rs new file mode 100644 index 0000000..a40d9f1 --- /dev/null +++ b/tests/zhihu_hotlist_skill_test.rs @@ -0,0 +1,403 @@ +mod common; + +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use common::MockTransport; +use sgclaw::pipe::{BrowserMessage, BrowserPipeTool, Timing}; +use sgclaw::security::MacPolicy; +use sgclaw::skill::zhihu_hotlist::{ + execute_collect, execute_report, load_flow, ZhihuHotlistCollectRequest, + ZhihuHotlistReportRequest, +}; +use sgclaw::skill::zhihu_hotlist_store::load_latest_snapshot; + +fn test_policy() -> MacPolicy { + MacPolicy::from_json_str( + r#"{ + "version": "1.0", + "domains": { "allowed": ["www.zhihu.com", "zhuanlan.zhihu.com"] }, + "pipe_actions": { + "allowed": ["click", "type", "navigate", "getText", "getHtml", "waitForSelector", "scrollTo"], + "blocked": [] + } + }"#, + ) + .unwrap() +} + +fn temp_store_dir(label: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("sgclaw-{label}-{unique}")) +} + +fn hotlist_html() -> String { + r#" + + +
+
+

第一条热榜

+
第一条摘要
+
1234 热度
+
+
+

第二条热榜

+
第二条摘要
+
5.6 万热度
+
+
+ + + "# + .to_string() +} + +fn comment_html( + first_reply: u64, + first_upvote: u64, + second_reply: u64, + second_upvote: u64, +) -> String { + format!( + r#" + + +
+
+ + + + +
+
+ + + + +
+
+ + + "# + ) +} + +#[test] +fn load_hotlist_flow_preserves_expected_selectors() { + let flow = load_flow().unwrap(); + + assert_eq!(flow.hotlist_url, "https://www.zhihu.com/hot"); + assert_eq!(flow.domains["zhihu"], "www.zhihu.com"); + assert!(flow.selectors["hotlist_item"].contains("HotList-item")); + assert!(flow.selectors["comment_metric"].contains("button")); +} + +#[test] +fn zhihu_hotlist_collect_persists_snapshot_and_report_reads_latest() { + let store_dir = temp_store_dir("hotlist-collect"); + let transport = Arc::new(MockTransport::new(vec![ + BrowserMessage::Response { + seq: 1, + success: true, + data: serde_json::json!({ "text": "知乎热榜 当前页", "url": "https://www.zhihu.com/hot" }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + BrowserMessage::Response { + seq: 2, + success: true, + data: serde_json::json!({ "ready": true }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + BrowserMessage::Response { + seq: 3, + success: true, + data: serde_json::json!({ "html": hotlist_html(), "url": "https://www.zhihu.com/hot" }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + BrowserMessage::Response { + seq: 4, + success: true, + data: serde_json::json!({ "url": "https://www.zhihu.com/question/123" }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + BrowserMessage::Response { + seq: 5, + success: true, + data: serde_json::json!({ "ready": true }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + BrowserMessage::Response { + seq: 6, + success: true, + data: serde_json::json!({ "scrolled": true }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + BrowserMessage::Response { + seq: 7, + success: true, + data: serde_json::json!({ "ready": true }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + BrowserMessage::Response { + seq: 8, + success: true, + data: serde_json::json!({ "scrolled": true }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + BrowserMessage::Response { + seq: 9, + success: true, + data: serde_json::json!({ "html": comment_html(3, 15, 1, 8), "url": "https://www.zhihu.com/question/123" }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + BrowserMessage::Response { + seq: 10, + success: true, + data: serde_json::json!({ "url": "https://www.zhihu.com/question/456" }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + BrowserMessage::Response { + seq: 11, + success: true, + data: serde_json::json!({ "ready": true }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + BrowserMessage::Response { + seq: 12, + success: true, + data: serde_json::json!({ "scrolled": true }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + BrowserMessage::Response { + seq: 13, + success: true, + data: serde_json::json!({ "ready": true }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + BrowserMessage::Response { + seq: 14, + success: true, + data: serde_json::json!({ "scrolled": true }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + BrowserMessage::Response { + seq: 15, + success: true, + data: serde_json::json!({ "html": comment_html(5, 20, 4, 16), "url": "https://www.zhihu.com/question/456" }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + ])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + let result = execute_collect( + transport.as_ref(), + &browser_tool, + ZhihuHotlistCollectRequest { + top_n: 2, + comments_per_item: 2, + store_dir: Some(store_dir.display().to_string()), + }, + ) + .unwrap(); + + assert_eq!(result.item_count, 2); + assert!(result.summary.contains("知乎热榜快照已保存")); + + let snapshot = load_latest_snapshot(&store_dir).unwrap(); + assert_eq!(snapshot.items.len(), 2); + assert_eq!(snapshot.items[0].title, "第一条热榜"); + assert_eq!(snapshot.items[0].summary, "第一条摘要"); + assert_eq!(snapshot.items[0].heat_value, Some(1234)); + assert_eq!(snapshot.items[0].comment_metrics.len(), 2); + assert_eq!(snapshot.items[0].comment_metrics[0].reply_count, Some(3)); + assert_eq!(snapshot.items[0].comment_metrics[0].upvote_count, Some(15)); + assert_eq!(snapshot.items[1].heat_value, Some(56_000)); + assert_eq!(snapshot.collection_stats.total_comment_metric_records, 4); + + let report = execute_report(ZhihuHotlistReportRequest { + snapshot_id: Some(result.snapshot_id.clone()), + store_dir: Some(store_dir.display().to_string()), + top_n: 2, + }) + .unwrap(); + + assert!(report.summary.contains("第一条热榜")); + assert!(report.summary.contains("第二条热榜")); + assert!(report.summary.contains("回复 4")); + assert!(report.summary.contains("赞同 23")); + + let _ = fs::remove_dir_all(&store_dir); +} + +#[test] +fn zhihu_hotlist_collect_persists_partial_snapshot_when_comment_capture_fails() { + let store_dir = temp_store_dir("hotlist-partial"); + let transport = Arc::new(MockTransport::new(vec![ + BrowserMessage::Response { + seq: 1, + success: true, + data: serde_json::json!({ "text": "知乎热榜 当前页", "url": "https://www.zhihu.com/hot" }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + BrowserMessage::Response { + seq: 2, + success: true, + data: serde_json::json!({ "ready": true }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + BrowserMessage::Response { + seq: 3, + success: true, + data: serde_json::json!({ "html": hotlist_html(), "url": "https://www.zhihu.com/hot" }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + BrowserMessage::Response { + seq: 4, + success: true, + data: serde_json::json!({ "url": "https://www.zhihu.com/question/123" }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + BrowserMessage::Response { + seq: 5, + success: true, + data: serde_json::json!({ "ready": true }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + BrowserMessage::Response { + seq: 6, + success: true, + data: serde_json::json!({ "scrolled": true }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + BrowserMessage::Response { + seq: 7, + success: false, + data: serde_json::json!({ "error": "comment list missing" }), + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + }, + ])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + let result = execute_collect( + transport.as_ref(), + &browser_tool, + ZhihuHotlistCollectRequest { + top_n: 1, + comments_per_item: 2, + store_dir: Some(store_dir.display().to_string()), + }, + ) + .unwrap(); + + let snapshot = load_latest_snapshot(&store_dir).unwrap(); + assert_eq!(result.item_count, 1); + assert_eq!(snapshot.collection_stats.partial_items, 1); + assert_eq!(snapshot.collection_stats.total_comment_metric_records, 0); + assert!(snapshot.items[0].comment_metrics.is_empty()); + + let _ = fs::remove_dir_all(&store_dir); +} diff --git a/tests/zhihu_navigation_skill_test.rs b/tests/zhihu_navigation_skill_test.rs new file mode 100644 index 0000000..f239943 --- /dev/null +++ b/tests/zhihu_navigation_skill_test.rs @@ -0,0 +1,661 @@ +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use common::MockTransport; +use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing}; +use sgclaw::security::MacPolicy; +use sgclaw::skill::zhihu_navigation::{execute, load_catalog, ZhihuNavigateRequest}; + +fn test_policy() -> MacPolicy { + MacPolicy::from_json_str( + r#"{ + "version": "1.0", + "domains": { "allowed": ["www.zhihu.com", "zhuanlan.zhihu.com"] }, + "pipe_actions": { + "allowed": ["click", "navigate", "getText", "waitForSelector"], + "blocked": [] + } + }"#, + ) + .unwrap() +} + +fn response(seq: u64, data: serde_json::Value) -> BrowserMessage { + BrowserMessage::Response { + seq, + success: true, + data, + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + } +} + +#[test] +fn load_catalog_preserves_confirmed_content_analysis_route() { + let catalog = load_catalog().unwrap(); + + assert_eq!(catalog.domains["creator"], "www.zhihu.com"); + assert_eq!( + catalog.routes["content_analysis"].url, + "https://www.zhihu.com/creator/analytics/work/all" + ); + assert_eq!( + catalog.targets["content_analysis"].route_ref.as_deref(), + Some("content_analysis") + ); + assert!(catalog.routes["content_analysis"] + .aliases + .iter() + .any(|alias| alias == "知乎内容分析页面")); +} + +#[test] +fn load_catalog_includes_top_level_navigation_targets() { + let catalog = load_catalog().unwrap(); + + assert_eq!(catalog.routes["home"].url, "https://www.zhihu.com/"); + assert_eq!(catalog.routes["hot_list"].url, "https://www.zhihu.com/hot"); + assert_eq!( + catalog.routes["column_home"].url, + "https://zhuanlan.zhihu.com/" + ); + assert_eq!( + catalog.routes["messages_page"].url, + "https://www.zhihu.com/messages" + ); + assert_eq!( + catalog.routes["notifications_page"].url, + "https://www.zhihu.com/notifications" + ); + assert_eq!( + catalog.targets["messages_unread_tab"] + .component_ref + .as_deref(), + Some("messages_tab_unread") + ); + assert_eq!( + catalog.targets["notifications_replies_tab"] + .component_ref + .as_deref(), + Some("notifications_tab_replies") + ); + assert_eq!( + catalog.targets["notifications_settings_menu"] + .component_ref + .as_deref(), + Some("notifications_settings_menu") + ); + assert_eq!( + catalog.targets["profile_page"].flow_ref.as_deref(), + Some("open_profile_from_avatar_menu") + ); + assert_eq!( + catalog.targets["notifications_menu"].flow_ref.as_deref(), + Some("open_notifications_menu") + ); + assert_eq!( + catalog.targets["search_box"].component_ref.as_deref(), + Some("top_nav_search") + ); + assert_eq!( + catalog.components["creator_write_button"] + .result_domain_ref + .as_deref(), + Some("editor") + ); +} + +#[test] +fn load_catalog_includes_expanded_profile_and_settings_flows() { + let catalog = load_catalog().unwrap(); + + assert_eq!( + catalog.targets["profile_answers_tab"].flow_ref.as_deref(), + Some("open_profile_answers_tab") + ); + assert_eq!( + catalog.targets["profile_followers_tab"].flow_ref.as_deref(), + Some("open_profile_followers_tab") + ); + assert_eq!( + catalog.targets["settings_account_menu"].flow_ref.as_deref(), + Some("open_account_settings_from_avatar_menu") + ); + assert_eq!( + catalog.targets["settings_privacy_menu"].flow_ref.as_deref(), + Some("open_privacy_settings_from_avatar_menu") + ); + assert_eq!( + catalog.targets["settings_security_menu"] + .flow_ref + .as_deref(), + Some("open_security_settings_from_avatar_menu") + ); +} + +#[test] +fn zhihu_navigation_skill_opens_content_analysis_page() { + let transport = Arc::new(MockTransport::new(vec![response( + 1, + serde_json::json!({ "url": "https://www.zhihu.com/creator/analytics/work/all" }), + )])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + let result = execute( + transport.as_ref(), + &browser_tool, + ZhihuNavigateRequest { + page: "content_analysis".to_string(), + ensure_loaded: true, + }, + ) + .unwrap(); + let sent = transport.sent_messages(); + + assert_eq!( + result.summary, + "知乎页面已打开:内容分析 (https://www.zhihu.com/creator/analytics/work/all)" + ); + assert_eq!(result.page, "content_analysis"); + assert_eq!( + result.final_url, + "https://www.zhihu.com/creator/analytics/work/all" + ); + assert_eq!(sent.len(), 2); + assert!(matches!( + &sent[0], + AgentMessage::LogEntry { level, message } + if level == "info" + && message == "navigate https://www.zhihu.com/creator/analytics/work/all" + )); + assert!(matches!( + &sent[1], + AgentMessage::Command { seq, action, .. } + if *seq == 1 && action == &Action::Navigate + )); +} + +#[test] +fn zhihu_navigation_skill_clicks_creator_write_button() { + let transport = Arc::new(MockTransport::new(vec![ + response( + 1, + serde_json::json!({ "url": "https://www.zhihu.com/creator" }), + ), + response( + 2, + serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/write" }), + ), + response( + 3, + serde_json::json!({ "ready": true, "url": "https://zhuanlan.zhihu.com/write" }), + ), + ])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + let result = execute( + transport.as_ref(), + &browser_tool, + ZhihuNavigateRequest { + page: "creator_write_button".to_string(), + ensure_loaded: true, + }, + ) + .unwrap(); + let sent = transport.sent_messages(); + + assert_eq!( + result.summary, + "知乎入口已打开:写文章入口按钮 (https://zhuanlan.zhihu.com/write)" + ); + assert_eq!(result.page, "creator_write_button"); + assert_eq!(result.final_url, "https://zhuanlan.zhihu.com/write"); + assert_eq!(sent.len(), 6); + assert!(matches!( + &sent[0], + AgentMessage::LogEntry { level, message } + if level == "info" && message == "navigate https://www.zhihu.com/creator" + )); + assert!(matches!( + &sent[1], + AgentMessage::Command { seq, action, .. } + if *seq == 1 && action == &Action::Navigate + )); + assert!(matches!( + &sent[2], + AgentMessage::LogEntry { level, message } + if level == "info" && message == "click 写文章入口按钮" + )); + assert!(matches!( + &sent[3], + AgentMessage::Command { seq, action, security, .. } + if *seq == 2 && action == &Action::Click && security.expected_domain == "www.zhihu.com" + )); + assert!(matches!( + &sent[4], + AgentMessage::LogEntry { level, message } + if level == "info" && message.contains("wait for textarea") + )); + assert!(matches!( + &sent[5], + AgentMessage::Command { seq, action, security, .. } + if *seq == 3 && action == &Action::WaitForSelector && security.expected_domain == "zhuanlan.zhihu.com" + )); +} + +#[test] +fn zhihu_navigation_skill_opens_notifications_menu_flow() { + let transport = Arc::new(MockTransport::new(vec![ + response(1, serde_json::json!({ "url": "https://www.zhihu.com/" })), + response(2, serde_json::json!({ "clicked": true })), + ])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + let result = execute( + transport.as_ref(), + &browser_tool, + ZhihuNavigateRequest { + page: "notifications_menu".to_string(), + ensure_loaded: true, + }, + ) + .unwrap(); + let sent = transport.sent_messages(); + + assert_eq!(result.summary, "知乎菜单已打开:通知菜单"); + assert_eq!(result.page, "notifications_menu"); + assert_eq!(result.final_url, "https://www.zhihu.com/"); + assert_eq!(sent.len(), 4); + assert!(matches!( + &sent[0], + AgentMessage::LogEntry { level, message } + if level == "info" && message == "navigate https://www.zhihu.com/" + )); + assert!(matches!( + &sent[1], + AgentMessage::Command { seq, action, .. } + if *seq == 1 && action == &Action::Navigate + )); + assert!(matches!( + &sent[2], + AgentMessage::LogEntry { level, message } + if level == "info" && message == "click 通知菜单" + )); + assert!(matches!( + &sent[3], + AgentMessage::Command { seq, action, security, .. } + if *seq == 2 && action == &Action::Click && security.expected_domain == "www.zhihu.com" + )); +} + +#[test] +fn zhihu_navigation_skill_opens_profile_page_from_avatar_menu() { + let transport = Arc::new(MockTransport::new(vec![ + response(1, serde_json::json!({ "url": "https://www.zhihu.com/" })), + response(2, serde_json::json!({ "clicked": true })), + response( + 3, + serde_json::json!({ "clicked": true, "url": "https://www.zhihu.com/people/test-user" }), + ), + ])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + let result = execute( + transport.as_ref(), + &browser_tool, + ZhihuNavigateRequest { + page: "profile_page".to_string(), + ensure_loaded: true, + }, + ) + .unwrap(); + let sent = transport.sent_messages(); + + assert_eq!( + result.summary, + "知乎导航已完成:个人主页 (https://www.zhihu.com/people/test-user)" + ); + assert_eq!(result.page, "profile_page"); + assert_eq!(result.final_url, "https://www.zhihu.com/people/test-user"); + assert_eq!(sent.len(), 6); + assert!(matches!( + &sent[0], + AgentMessage::LogEntry { level, message } + if level == "info" && message == "navigate https://www.zhihu.com/" + )); + assert!(matches!( + &sent[1], + AgentMessage::Command { seq, action, .. } + if *seq == 1 && action == &Action::Navigate + )); + assert!(matches!( + &sent[2], + AgentMessage::LogEntry { level, message } + if level == "info" && message == "click 头像菜单" + )); + assert!(matches!( + &sent[3], + AgentMessage::Command { seq, action, .. } + if *seq == 2 && action == &Action::Click + )); + assert!(matches!( + &sent[4], + AgentMessage::LogEntry { level, message } + if level == "info" && message == "click 个人主页入口" + )); + assert!(matches!( + &sent[5], + AgentMessage::Command { seq, action, security, .. } + if *seq == 3 && action == &Action::Click && security.expected_domain == "www.zhihu.com" + )); +} + +#[test] +fn zhihu_navigation_skill_opens_profile_answers_tab_from_avatar_menu() { + let transport = Arc::new(MockTransport::new(vec![ + response(1, serde_json::json!({ "url": "https://www.zhihu.com/" })), + response(2, serde_json::json!({ "clicked": true })), + response( + 3, + serde_json::json!({ "clicked": true, "url": "https://www.zhihu.com/people/test-user" }), + ), + response( + 4, + serde_json::json!({ "clicked": true, "url": "https://www.zhihu.com/people/test-user/answers" }), + ), + ])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + let result = execute( + transport.as_ref(), + &browser_tool, + ZhihuNavigateRequest { + page: "profile_answers_tab".to_string(), + ensure_loaded: true, + }, + ) + .unwrap(); + let sent = transport.sent_messages(); + + assert_eq!( + result.summary, + "知乎导航已完成:回答分栏 (https://www.zhihu.com/people/test-user/answers)" + ); + assert_eq!(result.page, "profile_answers_tab"); + assert_eq!( + result.final_url, + "https://www.zhihu.com/people/test-user/answers" + ); + assert_eq!(sent.len(), 8); + assert!(matches!( + &sent[0], + AgentMessage::LogEntry { level, message } + if level == "info" && message == "navigate https://www.zhihu.com/" + )); + assert!(matches!( + &sent[1], + AgentMessage::Command { seq, action, .. } + if *seq == 1 && action == &Action::Navigate + )); + assert!(matches!( + &sent[2], + AgentMessage::LogEntry { level, message } + if level == "info" && message == "click 头像菜单" + )); + assert!(matches!( + &sent[3], + AgentMessage::Command { seq, action, .. } + if *seq == 2 && action == &Action::Click + )); + assert!(matches!( + &sent[4], + AgentMessage::LogEntry { level, message } + if level == "info" && message == "click 个人主页入口" + )); + assert!(matches!( + &sent[5], + AgentMessage::Command { seq, action, .. } + if *seq == 3 && action == &Action::Click + )); + assert!(matches!( + &sent[6], + AgentMessage::LogEntry { level, message } + if level == "info" && message == "click 回答分栏" + )); + assert!(matches!( + &sent[7], + AgentMessage::Command { seq, action, security, .. } + if *seq == 4 && action == &Action::Click && security.expected_domain == "www.zhihu.com" + )); +} + +#[test] +fn zhihu_navigation_skill_opens_account_settings_from_avatar_menu() { + let transport = Arc::new(MockTransport::new(vec![ + response(1, serde_json::json!({ "url": "https://www.zhihu.com/" })), + response(2, serde_json::json!({ "clicked": true })), + response( + 3, + serde_json::json!({ "clicked": true, "url": "https://www.zhihu.com/settings/account" }), + ), + ])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + let result = execute( + transport.as_ref(), + &browser_tool, + ZhihuNavigateRequest { + page: "settings_account_menu".to_string(), + ensure_loaded: true, + }, + ) + .unwrap(); + let sent = transport.sent_messages(); + + assert_eq!( + result.summary, + "知乎导航已完成:账号设置菜单 (https://www.zhihu.com/settings/account)" + ); + assert_eq!(result.page, "settings_account_menu"); + assert_eq!(result.final_url, "https://www.zhihu.com/settings/account"); + assert_eq!(sent.len(), 6); + assert!(matches!( + &sent[0], + AgentMessage::LogEntry { level, message } + if level == "info" && message == "navigate https://www.zhihu.com/" + )); + assert!(matches!( + &sent[1], + AgentMessage::Command { seq, action, .. } + if *seq == 1 && action == &Action::Navigate + )); + assert!(matches!( + &sent[2], + AgentMessage::LogEntry { level, message } + if level == "info" && message == "click 头像菜单" + )); + assert!(matches!( + &sent[3], + AgentMessage::Command { seq, action, .. } + if *seq == 2 && action == &Action::Click + )); + assert!(matches!( + &sent[4], + AgentMessage::LogEntry { level, message } + if level == "info" && message == "click 账号设置菜单" + )); + assert!(matches!( + &sent[5], + AgentMessage::Command { seq, action, security, .. } + if *seq == 3 && action == &Action::Click && security.expected_domain == "www.zhihu.com" + )); +} + +#[test] +fn zhihu_navigation_skill_opens_notifications_replies_tab() { + let transport = Arc::new(MockTransport::new(vec![ + response( + 1, + serde_json::json!({ "url": "https://www.zhihu.com/notifications" }), + ), + response( + 2, + serde_json::json!({ "clicked": true, "url": "https://www.zhihu.com/notifications/replies" }), + ), + ])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + let result = execute( + transport.as_ref(), + &browser_tool, + ZhihuNavigateRequest { + page: "notifications_replies_tab".to_string(), + ensure_loaded: true, + }, + ) + .unwrap(); + let sent = transport.sent_messages(); + + assert_eq!( + result.summary, + "知乎入口已打开:回复我的 (https://www.zhihu.com/notifications/replies)" + ); + assert_eq!(sent.len(), 4); + assert!(matches!( + &sent[0], + AgentMessage::LogEntry { level, message } + if level == "info" && message == "navigate https://www.zhihu.com/notifications" + )); + assert!(matches!( + &sent[1], + AgentMessage::Command { seq, action, .. } + if *seq == 1 && action == &Action::Navigate + )); + assert!(matches!( + &sent[2], + AgentMessage::LogEntry { level, message } + if level == "info" && message == "click 回复我的" + )); + assert!(matches!( + &sent[3], + AgentMessage::Command { seq, action, security, .. } + if *seq == 2 && action == &Action::Click && security.expected_domain == "www.zhihu.com" + )); +} + +#[test] +fn zhihu_navigation_skill_opens_messages_settings_menu() { + let transport = Arc::new(MockTransport::new(vec![ + response( + 1, + serde_json::json!({ "url": "https://www.zhihu.com/messages" }), + ), + response(2, serde_json::json!({ "clicked": true })), + ])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + let result = execute( + transport.as_ref(), + &browser_tool, + ZhihuNavigateRequest { + page: "messages_settings_menu".to_string(), + ensure_loaded: true, + }, + ) + .unwrap(); + let sent = transport.sent_messages(); + + assert_eq!(result.summary, "知乎菜单已打开:消息设置菜单"); + assert_eq!(result.final_url, "https://www.zhihu.com/messages"); + assert_eq!(sent.len(), 4); + assert!(matches!( + &sent[0], + AgentMessage::LogEntry { level, message } + if level == "info" && message == "navigate https://www.zhihu.com/messages" + )); + assert!(matches!( + &sent[1], + AgentMessage::Command { seq, action, .. } + if *seq == 1 && action == &Action::Navigate + )); + assert!(matches!( + &sent[2], + AgentMessage::LogEntry { level, message } + if level == "info" && message == "click 消息设置菜单" + )); + assert!(matches!( + &sent[3], + AgentMessage::Command { seq, action, security, .. } + if *seq == 2 && action == &Action::Click && security.expected_domain == "www.zhihu.com" + )); +} + +#[test] +fn zhihu_navigation_skill_rejects_unknown_target() { + let transport = Arc::new(MockTransport::new(vec![])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + let err = execute( + transport.as_ref(), + &browser_tool, + ZhihuNavigateRequest { + page: "unknown_target".to_string(), + ensure_loaded: true, + }, + ) + .unwrap_err(); + + assert!(err + .to_string() + .contains("unknown zhihu target: unknown_target")); +} diff --git a/tests/zhihu_skill_test.rs b/tests/zhihu_skill_test.rs new file mode 100644 index 0000000..4d4e869 --- /dev/null +++ b/tests/zhihu_skill_test.rs @@ -0,0 +1,357 @@ +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use common::MockTransport; +use sgclaw::pipe::{Action, AgentMessage, BrowserMessage, BrowserPipeTool, Timing}; +use sgclaw::security::MacPolicy; +use sgclaw::skill::zhihu::{execute, load_flow, ZhihuWriteRequest}; + +fn test_policy() -> MacPolicy { + MacPolicy::from_json_str( + r#"{ + "version": "1.0", + "domains": { "allowed": ["www.zhihu.com", "zhuanlan.zhihu.com"] }, + "pipe_actions": { + "allowed": ["click", "type", "navigate", "getText", "getHtml", "waitForSelector", "scrollTo"], + "blocked": [] + } + }"#, + ) + .unwrap() +} + +fn response(seq: u64, data: serde_json::Value) -> BrowserMessage { + BrowserMessage::Response { + seq, + success: true, + data, + aom_snapshot: vec![], + timing: Timing { + queue_ms: 1, + exec_ms: 10, + }, + } +} + +#[test] +fn load_flow_preserves_validated_zhihu_literals() { + let flow = load_flow().unwrap(); + + assert_eq!(flow.entry_url, "https://www.zhihu.com/creator"); + assert_eq!(flow.editor_url, "https://zhuanlan.zhihu.com/write"); + assert_eq!(flow.literals["write_entry_text"], "写文章"); + assert_eq!(flow.literals["publish_confirm_text"], "确认发布"); + assert_eq!( + flow.literals["title_placeholder"], + "请输入标题(最多 100 个字)" + ); + assert_eq!( + flow.selectors["creator_write_entry"], + "div.css-1q62b6s > div.css-byu4by" + ); + assert_eq!( + flow.selectors["publish_confirm_button"], + "div[role='dialog'] button.Button--primary.Button--blue" + ); +} + +#[test] +fn zhihu_skill_stops_before_publish_when_publish_is_false() { + let transport = Arc::new(MockTransport::new(vec![ + response( + 1, + serde_json::json!({ "url": "https://www.zhihu.com/creator" }), + ), + response( + 2, + serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/write" }), + ), + response( + 3, + serde_json::json!({ "ready": true, "url": "https://zhuanlan.zhihu.com/write" }), + ), + response(4, serde_json::json!({ "typed": true })), + response(5, serde_json::json!({ "typed": true })), + ])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + let result = execute( + transport.as_ref(), + &browser_tool, + ZhihuWriteRequest { + title: "自动发文能力测试".to_string(), + body: "第一段\n\n第二段".to_string(), + publish: false, + }, + ) + .unwrap(); + let sent = transport.sent_messages(); + + assert_eq!(result.summary, "知乎文章草稿已填充:自动发文能力测试"); + assert_eq!(sent.len(), 10); + assert!(matches!( + &sent[5], + AgentMessage::Command { seq, action, .. } + if *seq == 3 && action == &Action::WaitForSelector + )); + assert!(matches!( + &sent[9], + AgentMessage::Command { seq, action, .. } + if *seq == 5 && action == &Action::Type + )); +} + +#[test] +fn zhihu_skill_publishes_only_after_confirming_dialog_title_and_final_url() { + let transport = Arc::new(MockTransport::new(vec![ + response( + 1, + serde_json::json!({ "url": "https://www.zhihu.com/creator" }), + ), + response( + 2, + serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/write" }), + ), + response( + 3, + serde_json::json!({ "ready": true, "url": "https://zhuanlan.zhihu.com/write" }), + ), + response(4, serde_json::json!({ "typed": true })), + response(5, serde_json::json!({ "typed": true })), + response(6, serde_json::json!({ "scrolled": true })), + response( + 7, + serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/write" }), + ), + response(8, serde_json::json!({ "ready": true })), + response( + 9, + serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/p/123456" }), + ), + response( + 10, + serde_json::json!({ "ready": true, "url": "https://zhuanlan.zhihu.com/p/123456" }), + ), + response( + 11, + serde_json::json!({ "text": "自动发文能力测试", "url": "https://zhuanlan.zhihu.com/p/123456" }), + ), + ])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + let result = execute( + transport.as_ref(), + &browser_tool, + ZhihuWriteRequest { + title: "自动发文能力测试".to_string(), + body: "第一段\n\n第二段".to_string(), + publish: true, + }, + ) + .unwrap(); + let sent = transport.sent_messages(); + + assert_eq!( + result.summary, + "知乎文章已发布:自动发文能力测试 (https://zhuanlan.zhihu.com/p/123456)" + ); + assert_eq!( + result.final_url.as_deref(), + Some("https://zhuanlan.zhihu.com/p/123456") + ); + assert!(result.published); + assert_eq!(sent.len(), 22); + assert!(matches!( + &sent[11], + AgentMessage::Command { seq, action, .. } + if *seq == 6 && action == &Action::ScrollTo + )); + assert!(matches!( + &sent[21], + AgentMessage::Command { seq, action, .. } + if *seq == 11 && action == &Action::GetText + )); +} + +#[test] +fn zhihu_skill_accepts_edit_url_as_published_article_url() { + let transport = Arc::new(MockTransport::new(vec![ + response( + 1, + serde_json::json!({ "url": "https://www.zhihu.com/creator" }), + ), + response( + 2, + serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/write" }), + ), + response( + 3, + serde_json::json!({ "ready": true, "url": "https://zhuanlan.zhihu.com/write" }), + ), + response(4, serde_json::json!({ "typed": true })), + response(5, serde_json::json!({ "typed": true })), + response(6, serde_json::json!({ "scrolled": true })), + response( + 7, + serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/write" }), + ), + response(8, serde_json::json!({ "ready": true })), + response( + 9, + serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/p/123456/edit" }), + ), + response( + 10, + serde_json::json!({ "ready": true, "url": "https://zhuanlan.zhihu.com/p/123456/edit" }), + ), + response( + 11, + serde_json::json!({ "text": "自动发文能力测试", "url": "https://zhuanlan.zhihu.com/p/123456/edit" }), + ), + ])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + let result = execute( + transport.as_ref(), + &browser_tool, + ZhihuWriteRequest { + title: "自动发文能力测试".to_string(), + body: "第一段\n\n第二段".to_string(), + publish: true, + }, + ) + .unwrap(); + + assert_eq!( + result.final_url.as_deref(), + Some("https://zhuanlan.zhihu.com/p/123456") + ); + assert_eq!( + result.summary, + "知乎文章已发布:自动发文能力测试 (https://zhuanlan.zhihu.com/p/123456)" + ); +} + +#[test] +fn zhihu_skill_fails_when_publish_confirmation_never_returns_article_url() { + let transport = Arc::new(MockTransport::new(vec![ + response( + 1, + serde_json::json!({ "url": "https://www.zhihu.com/creator" }), + ), + response( + 2, + serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/write" }), + ), + response( + 3, + serde_json::json!({ "ready": true, "url": "https://zhuanlan.zhihu.com/write" }), + ), + response(4, serde_json::json!({ "typed": true })), + response(5, serde_json::json!({ "typed": true })), + response(6, serde_json::json!({ "scrolled": true })), + response( + 7, + serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/write" }), + ), + response(8, serde_json::json!({ "ready": true })), + response(9, serde_json::json!({ "clicked": true })), + response(10, serde_json::json!({ "ready": true })), + response(11, serde_json::json!({ "text": "自动发文能力测试" })), + ])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + let err = execute( + transport.as_ref(), + &browser_tool, + ZhihuWriteRequest { + title: "自动发文能力测试".to_string(), + body: "第一段\n\n第二段".to_string(), + publish: true, + }, + ) + .unwrap_err(); + + assert!(err.to_string().contains("did not return article url")); +} + +#[test] +fn zhihu_skill_fails_when_published_title_does_not_match_request_title() { + let transport = Arc::new(MockTransport::new(vec![ + response( + 1, + serde_json::json!({ "url": "https://www.zhihu.com/creator" }), + ), + response( + 2, + serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/write" }), + ), + response( + 3, + serde_json::json!({ "ready": true, "url": "https://zhuanlan.zhihu.com/write" }), + ), + response(4, serde_json::json!({ "typed": true })), + response(5, serde_json::json!({ "typed": true })), + response(6, serde_json::json!({ "scrolled": true })), + response( + 7, + serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/write" }), + ), + response(8, serde_json::json!({ "ready": true })), + response( + 9, + serde_json::json!({ "clicked": true, "url": "https://zhuanlan.zhihu.com/p/123456" }), + ), + response( + 10, + serde_json::json!({ "ready": true, "url": "https://zhuanlan.zhihu.com/p/123456" }), + ), + response( + 11, + serde_json::json!({ "text": "别的标题", "url": "https://zhuanlan.zhihu.com/p/123456" }), + ), + ])); + let browser_tool = BrowserPipeTool::new( + transport.clone(), + test_policy(), + vec![1, 2, 3, 4, 5, 6, 7, 8], + ) + .with_response_timeout(Duration::from_secs(1)); + + let err = execute( + transport.as_ref(), + &browser_tool, + ZhihuWriteRequest { + title: "自动发文能力测试".to_string(), + body: "第一段\n\n第二段".to_string(), + publish: true, + }, + ) + .unwrap_err(); + + assert!(err + .to_string() + .contains("expected text `自动发文能力测试`, got `别的标题`")); +}