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 `别的标题`")); +}