diff --git a/README.md b/README.md index fe36826..ce89bac 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ 截图来自 Android 13 模拟器,数字是演示数据。 > [!NOTE] -> 调试预览版新增**小红书 9.49.0 发现页标题筛选**,需单独确认开启,命中卡片可遮挡并恢复。抖音、快手尚未开放,视频画面识别未实现。见 [模型与真机验证](docs/clef-validation.md)。 +> 调试预览版有**小红书 9.49.0 发现页标题筛选**,以及**抖音、抖音极速版 40.6.0 和快手、快手极速版 14.8.40 推荐页视频文案筛选**。每个应用需单独确认开启,命中的卡片或视频模糊遮挡并可恢复,视频不会自动划走。视频文案筛选尚未在真机验证,视频画面识别未实现。见 [模型与真机验证](docs/clef-validation.md)。 ## 安装 @@ -111,7 +111,7 @@ - **只看你勾选的应用。** 浏览器、聊天和名单外应用一概不采集。 - **不存正文。** 通知标题和正文不落盘,本机只留最近 30 天的次数汇总。 - **报告不自动发送。** 报告不含正文、验证码或私聊,由爸妈预览后自己分享。 -- **现在没有账号。** 无远程控制,无同步。调试版可主动启用 Clef API,单独开启小红书时只发送公开卡片标题,凭据由本人配置并在手机加密保存。 +- **现在没有账号。** 无远程控制,无同步。调试版可主动启用 Clef API,单独开启的应用只发送小红书公开卡片标题或短视频推荐页当前一条的文案,凭据由本人配置并在手机加密保存。 - **后续 AI 设置单独开启。** 提示词整理和孩子账号同步尚在规划,发送范围和授权要求见 [路线图](docs/roadmap.md#ai-与联网)。 - **不越权。** 不屏蔽广告,不点“跳过”,不绕过应用限制,不自动卸载。 diff --git a/app/src/androidTest/java/io/github/pathgao/housheng/DeliveryCleanupTest.kt b/app/src/androidTest/java/io/github/pathgao/housheng/DeliveryCleanupTest.kt index 2b80f21..2622fa6 100644 --- a/app/src/androidTest/java/io/github/pathgao/housheng/DeliveryCleanupTest.kt +++ b/app/src/androidTest/java/io/github/pathgao/housheng/DeliveryCleanupTest.kt @@ -14,7 +14,7 @@ class DeliveryCleanupTest { val credentials = ClefCredentials(context).load() instrumentation.runOnMainSync { Session.stop(); Session.clear() - ModelValidation.realEnabled = false; ModelValidation.enabled = false + ModelValidation.realEnabled = emptySet(); ModelValidation.enabled = false for (kind in listOf("pages", "notifications")) Preferences(context).select(kind, Session.FIXTURE, false) } assertTrue(context.getSharedPreferences("settings", 0).edit().commit()) diff --git a/app/src/androidTest/java/io/github/pathgao/housheng/RealAppValidationTest.kt b/app/src/androidTest/java/io/github/pathgao/housheng/RealAppValidationTest.kt index df83ab3..9499b67 100644 --- a/app/src/androidTest/java/io/github/pathgao/housheng/RealAppValidationTest.kt +++ b/app/src/androidTest/java/io/github/pathgao/housheng/RealAppValidationTest.kt @@ -26,7 +26,7 @@ class RealAppValidationTest { instrumentation.runOnMainSync { Session.stop(); Session.clear() prefs.saveRules("") - ModelValidation.realEnabled = true + ModelValidation.realEnabled = setOf(RealFeeds.XIAOHONGSHU) Session.feedExecution = true FeedMask.liveBlurAllowed = prefix.isEmpty() } @@ -42,7 +42,7 @@ class RealAppValidationTest { Thread.sleep(4000) instrumentation.sendStatus(0, Bundle().apply { putString("realFeed", Session.journal()) }) val root = DeviceUi.automation.rootInActiveWindow ?: error("No public feed") - val card = try { XiaohongshuFeed.read(root).first() } finally { root.recycle() } + val card = try { readFeed(root).first() } finally { root.recycle() } snap("before") instrumentation.runOnMainSync { prefs.saveRules(card.title) @@ -91,7 +91,7 @@ class RealAppValidationTest { return null } val before = DeviceUi.automation.rootInActiveWindow ?: error("No feed before scroll") - val oldTitles = try { XiaohongshuFeed.read(before).map { it.title } } finally { before.recycle() } + val oldTitles = try { readFeed(before).map { it.title } } finally { before.recycle() } instrumentation.runOnMainSync { prefs.saveRules(""); Session.clear() } val scrollRoot = DeviceUi.automation.rootInActiveWindow ?: error("No feed") val list = try { listNode(scrollRoot) } finally { scrollRoot.recycle() } @@ -102,7 +102,7 @@ class RealAppValidationTest { while (Session.now() < scrollEnd) { val latest = DeviceUi.automation.rootInActiveWindow if (latest != null) try { - val titles = XiaohongshuFeed.read(latest).map { it.title } + val titles = readFeed(latest).map { it.title } changed = titles.isNotEmpty() && titles != oldTitles } finally { latest.recycle() } instrumentation.runOnMainSync { resumed = Session.journal().contains("小红书 Clef ·") } @@ -116,7 +116,7 @@ class RealAppValidationTest { assertNull("Leaving the feed must remove overlays", revealNode()) instrumentation.sendStatus(0, Bundle().apply { putString("realScroll", "Real list advanced, Clef resumed, overlays cleared on app switch") }) } finally { - instrumentation.runOnMainSync { Session.stop(); ModelValidation.realEnabled = false; FeedMask.liveBlurAllowed = true } + instrumentation.runOnMainSync { Session.stop(); ModelValidation.realEnabled = emptySet(); FeedMask.liveBlurAllowed = true } assertTrue(instrumentation.targetContext.getSharedPreferences("settings", android.content.Context.MODE_PRIVATE).edit().putString("rules", originalRules).commit()) } } diff --git a/app/src/debug/java/io/github/pathgao/housheng/ModelValidation.kt b/app/src/debug/java/io/github/pathgao/housheng/ModelValidation.kt index d340477..8bf0125 100644 --- a/app/src/debug/java/io/github/pathgao/housheng/ModelValidation.kt +++ b/app/src/debug/java/io/github/pathgao/housheng/ModelValidation.kt @@ -52,7 +52,8 @@ class LoopbackModelClient(private val port: Int = 18765, private val timeoutMs: } object ModelValidation { - var realEnabled = false + /** Real apps the user confirmed one by one; kept in memory only, so every process start begins with all of them off. */ + var realEnabled = emptySet() set(value) { field = value; FeedService.instance?.invalidate() } var enabled = false @@ -79,17 +80,22 @@ object ModelValidation { } fun addControls(body: LinearLayout) { - body.addView(ProductUi.switchRow(body.context, "小红书真实信息流", "仅发现页卡片标题,发送给 Clef 判断").apply { - isChecked = realEnabled - setOnCheckedChangeListener { _, checked -> - if (!checked) realEnabled = false - else android.app.AlertDialog.Builder(context).setTitle("启用小红书筛选?") - .setMessage("将发现页公开卡片标题发送给 Cloudflare Clef。不会上传图片、作者、评论或私信。开启内容处理后,命中卡片会遮挡,可点按恢复。当前适配小红书 9.49.0,其他版本保留原页。") - .setPositiveButton("启用") { _, _ -> realEnabled = true } - .setNegativeButton("取消") { _, _ -> isChecked = false } - .setOnCancelListener { isChecked = false }.show() - } - }) + for (pkg in RealFeeds.packages) { + val name = AppCatalog.sources.getValue(pkg) + val cards = pkg == RealFeeds.XIAOHONGSHU + body.addView(ProductUi.switchRow(body.context, "${name}真实信息流", if (cards) "仅发现页卡片标题,发送给 Clef 判断" else "仅推荐页当前视频的文案,发送给 Clef 判断").apply { + isChecked = pkg in realEnabled + setOnCheckedChangeListener { _, checked -> + if (!checked) realEnabled -= pkg + else android.app.AlertDialog.Builder(context).setTitle("启用${name}筛选?") + .setMessage(if (cards) "将发现页公开卡片标题发送给 Cloudflare Clef。不会上传图片、作者、评论或私信。开启内容处理后,命中卡片会遮挡,可点按恢复。当前适配小红书 9.49.0,其他版本保留原页。" + else "将推荐页当前视频的公开标题和话题发送给 Cloudflare Clef。不会上传画面、作者、评论或私信,广告、直播和无法确认的页面不读取。开启内容处理后,命中视频整块模糊遮挡,不会自动划走,可点按恢复。当前适配${name} ${RealFeeds.version(pkg)},其他版本保留原页。") + .setPositiveButton("启用") { _, _ -> realEnabled += pkg } + .setNegativeButton("取消") { _, _ -> isChecked = false } + .setOnCancelListener { isChecked = false }.show() + } + }) + } body.addView(ProductUi.button(body.context, "配置 Clef API / 测试连接") { body.context.startActivity(android.content.Intent(body.context, ClefSettingsActivity::class.java)) }) diff --git a/app/src/debug/java/io/github/pathgao/housheng/RealFeedController.kt b/app/src/debug/java/io/github/pathgao/housheng/RealFeedController.kt index 6356eca..ffa533b 100644 --- a/app/src/debug/java/io/github/pathgao/housheng/RealFeedController.kt +++ b/app/src/debug/java/io/github/pathgao/housheng/RealFeedController.kt @@ -7,45 +7,27 @@ import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo import java.util.concurrent.Executors -internal data class FeedCard(val title: String, val bounds: Rect) - -internal object XiaohongshuFeed { - const val PACKAGE = "com.xingin.xhs" - fun read(root: AccessibilityNodeInfo): List { - if (root.packageName?.toString() != PACKAGE) return emptyList() - val nodes = mutableListOf() - var complete = true - fun walk(node: AccessibilityNodeInfo, depth: Int) { - if (nodes.size >= 300 || depth > 30) { complete = false; return } - if (!node.isVisibleToUser) return - nodes.add(AccessibilityNodeInfo.obtain(node)) - for (i in 0 until node.childCount.coerceAtMost(100)) node.getChild(i)?.let { - try { walk(it, depth + 1) } finally { it.recycle() } - } +/** Visible nodes in pre-order, root first; null when the tree is too large to read completely. */ +private fun visibleNodes(root: AccessibilityNodeInfo): List? { + val nodes = mutableListOf() + var complete = true + fun walk(node: AccessibilityNodeInfo, depth: Int) { + if (nodes.size >= 300 || depth > 30) { complete = false; return } + if (!node.isVisibleToUser) return + val r = Rect().also(node::getBoundsInScreen) + nodes.add(FeedNode(node.text?.toString().orEmpty(), node.contentDescription?.toString().orEmpty(), node.viewIdResourceName.orEmpty(), + node.className?.toString().orEmpty(), Box(r.left, r.top, r.right, r.bottom), node.isSelected, node.isScrollable)) + for (i in 0 until node.childCount.coerceAtMost(100)) node.getChild(i)?.let { + try { walk(it, depth + 1) } finally { it.recycle() } } - try { - walk(root, 0) - if (!complete || nodes.any { it.className?.toString() in setOf("android.webkit.WebView", "android.widget.EditText") }) return emptyList() - if (!listOf("首页", "发现").all { label -> nodes.any { it.isSelected && it.contentDescription?.toString() == label } }) return emptyList() - val lists = nodes.filter { it.className?.toString() == "androidx.recyclerview.widget.RecyclerView" && it.isScrollable } - if (lists.size != 1) return emptyList() - val area = Rect().also(lists.single()::getBoundsInScreen) - return nodes.mapNotNull { node -> - val title = publicFeedTitle(node.contentDescription?.toString().orEmpty()) ?: return@mapNotNull null - val bounds = Rect().also(node::getBoundsInScreen) - if (!area.contains(bounds) || bounds.width() !in area.width() / 3..area.width() * 2 / 3 || bounds.height() < bounds.width() / 2) return@mapNotNull null - val protected = nodes.any { child -> - val rect = Rect().also(child::getBoundsInScreen) - bounds.contains(rect) && listOf(child.text, child.contentDescription).any { value -> - value != null && listOf("广告", "赞助", "推广", "品牌合作").any { value.contains(it) } - } - } - if (protected) null else FeedCard(title, bounds) - }.distinct().take(4) - } finally { nodes.forEach { it.recycle() } } } + walk(root, 0) + return nodes.takeIf { complete } } +internal fun readFeed(root: AccessibilityNodeInfo): List = + RealFeeds.read(root.packageName?.toString().orEmpty(), visibleNodes(root) ?: emptyList()) + internal class RealFeedController(private val service: FeedService) { private val main = Handler(Looper.getMainLooper()) private val worker = Executors.newFixedThreadPool(2) @@ -59,12 +41,16 @@ internal class RealFeedController(private val service: FeedService) { private var closed = false private var observedAt = 0L private var refresh: Runnable? = null - private val supportedVersion by lazy { - runCatching { service.packageManager.getPackageInfo(XiaohongshuFeed.PACKAGE, 0).versionName == "9.49.0" }.getOrDefault(false) + private var source = "" + private val label get() = AppCatalog.sources[source] ?: source + private val versions = mutableMapOf() + private fun supported(pkg: String) = versions.getOrPut(pkg) { + runCatching { RealFeeds.supports(pkg, service.packageManager.getPackageInfo(pkg, 0).versionName) }.getOrDefault(false) } fun observe(root: AccessibilityNodeInfo, event: AccessibilityEvent): Boolean { - if (root.packageName?.toString() != XiaohongshuFeed.PACKAGE || !ModelValidation.realEnabled || !supportedVersion) { clear(); return false } + val pkg = root.packageName?.toString().orEmpty() + if (pkg !in ModelValidation.realEnabled || !supported(pkg)) { clear(); return false } if (event.eventType == AccessibilityEvent.TYPE_VIEW_SCROLLED) { clear() refresh = Runnable { @@ -75,18 +61,19 @@ internal class RealFeedController(private val service: FeedService) { }.also { main.postDelayed(it, 120) } return true } - val cards = XiaohongshuFeed.read(root) - if (cards == current) return true + val cards = readFeed(root) + if (cards == current && pkg == source) return true removeMasks() + source = pkg current = cards pending = cards observedAt = Session.now() generation++ if (cards.isEmpty()) return true - Session.record("小红书发现页 · ${cards.size} 张可读取卡片") + Session.record("$label · ${cards.size} 条可读取内容") val rules = Preferences(service).classifier() val localMatches = cards.filter { rules.classify(it.title) == Decision.SKIP } - if (localMatches.isNotEmpty()) Session.record("小红书 · 本地规则命中 ${localMatches.size} 张") + if (localMatches.isNotEmpty()) Session.record("$label · 本地规则命中 ${localMatches.size} 条") pending = pending.filterNot { it in localMatches } cards.filter { it in localMatches || answers[it.title] == "filter" }.forEach(::mask) next() @@ -105,12 +92,12 @@ internal class RealFeedController(private val service: FeedService) { main.post { busy-- if (closed) return@post - if (generation == token && ModelValidation.realEnabled) { + if (generation == token && source in ModelValidation.realEnabled) { if (answers.size >= 32) answers.remove(answers.keys.first()) answers[card.title] = answer - Session.record("小红书 Clef · $answer · ${Session.now() - started}ms") + Session.record("$label Clef · $answer · ${Session.now() - started}ms") if (answer == "filter" && Session.now() - observedAt < 3000) mask(card) - } else Session.record("小红书 · 页面变化,丢弃旧判断") + } else Session.record("$label · 页面变化,丢弃旧判断") next() } } @@ -118,14 +105,15 @@ internal class RealFeedController(private val service: FeedService) { } private fun mask(card: FeedCard) { - if (!Session.feedExecution || card.title in revealed || card in masks || !ModelValidation.realEnabled) return + if (!Session.feedExecution || card.title in revealed || card in masks || source !in ModelValidation.realEnabled) return val root = service.rootInActiveWindow ?: return - val valid = try { card in XiaohongshuFeed.read(root) } finally { root.recycle() } + val valid = try { card in readFeed(root) } finally { root.recycle() } if (!valid) return - masks.show(card, card.bounds, "小红书") { + val label = label + masks.show(card, card.box.let { Rect(it.left, it.top, it.right, it.bottom) }, label) { if (revealed.size >= 32) revealed.remove(revealed.first()) revealed.add(card.title) - Session.record("小红书 · 用户恢复显示") + Session.record("$label · 用户恢复显示") } } diff --git a/app/src/debug/java/io/github/pathgao/housheng/RealFeeds.kt b/app/src/debug/java/io/github/pathgao/housheng/RealFeeds.kt new file mode 100644 index 0000000..3899136 --- /dev/null +++ b/app/src/debug/java/io/github/pathgao/housheng/RealFeeds.kt @@ -0,0 +1,80 @@ +package io.github.pathgao.housheng + +/** Screen rectangle with android.graphics.Rect semantics, usable in JVM tests where Rect is a stub. */ +internal data class Box(val left: Int, val top: Int, val right: Int, val bottom: Int) { + val width get() = right - left + val height get() = bottom - top + operator fun contains(other: Box) = left < right && top < bottom && + left <= other.left && top <= other.top && right >= other.right && bottom >= other.bottom +} + +/** One visible accessibility node, copied out of the live tree. */ +internal data class FeedNode( + val text: String = "", val desc: String = "", val id: String = "", val className: String = "", + val box: Box, val selected: Boolean = false, val scrollable: Boolean = false +) + +internal data class FeedCard(val title: String, val box: Box) + +internal object RealFeeds { + const val XIAOHONGSHU = "com.xingin.xhs" + private val douyin = setOf("com.ss.android.ugc.aweme", "com.ss.android.ugc.aweme.lite") + private val kuaishou = setOf("com.smile.gifmaker", "com.kuaishou.nebula") + private val versions = mapOf(XIAOHONGSHU to "9.49.0") + douyin.associateWith { "40.6.0" } + kuaishou.associateWith { "14.8.40" } + val packages = versions.keys.toList() + + fun version(pkg: String) = versions[pkg] + + /** Page structure differs between releases, so only the surveyed version is read; Kuaishou appends a build number. */ + fun supports(pkg: String, installed: String?): Boolean { + val expected = versions[pkg] ?: return false + return installed == expected || pkg in kuaishou && installed?.startsWith("$expected.") == true + } + + /** Items to judge on the current screen, in pre-order [nodes] with the root first; empty whenever the page is not a confirmed public feed. */ + fun read(pkg: String, nodes: List): List { + if (nodes.isEmpty() || nodes.any { it.className == "android.webkit.WebView" || it.className == "android.widget.EditText" }) return emptyList() + return when (pkg) { + XIAOHONGSHU -> xiaohongshu(nodes) + in douyin -> video(nodes, "$pkg:id/desc", { it.desc.startsWith("推荐,已选中") }, { it.desc == "首页,按钮" }) + in kuaishou -> video(nodes, "$pkg:id/element_caption_label", + { it.id == "$pkg:id/nasa_featured_default_search_view" || it.id == "$pkg:id/thanos_home_top_search" }, + { it.id == "$pkg:id/shoot_container" }) + else -> emptyList() + } + } + + private fun FeedNode.mentions(words: List) = words.any { text.contains(it) || desc.contains(it) } + + private fun xiaohongshu(nodes: List): List { + if (!listOf("首页", "发现").all { label -> nodes.any { it.selected && it.desc == label } }) return emptyList() + val area = nodes.singleOrNull { it.className == "androidx.recyclerview.widget.RecyclerView" && it.scrollable }?.box ?: return emptyList() + return nodes.mapNotNull { node -> + val title = publicFeedTitle(node.desc) ?: return@mapNotNull null + val box = node.box + if (box !in area || box.width !in area.width / 3..area.width * 2 / 3 || box.height < box.width / 2) return@mapNotNull null + if (nodes.any { it.box in box && it.mentions(listOf("广告", "赞助", "推广", "品牌合作")) }) null else FeedCard(title, box) + }.distinct().take(4) + } + + // Ads, live previews, comment panels and login prompts share the feed screen; any of these markers means the screen is not read. + private val videoBlockers = listOf("广告", "开发者:", "立即下载", "直播中", "直播间", "条评论", "一键登录", "用户协议") + + /** The covered area runs from the top tab bar to the bottom navigation. Neighbouring items stay in the tree, so exactly one caption must lie inside it. */ + private fun video(nodes: List, captionId: String, isTop: (FeedNode) -> Boolean, isBottom: (FeedNode) -> Boolean): List { + if (nodes.any { it.mentions(videoBlockers) }) return emptyList() + val top = nodes.singleOrNull(isTop) ?: return emptyList() + val bottom = nodes.singleOrNull(isBottom) ?: return emptyList() + val screen = nodes.first().box + val area = Box(screen.left, top.box.bottom, screen.right, bottom.box.top) + if (area.height < screen.height / 2) return emptyList() + val caption = nodes.singleOrNull { it.id == captionId && it.box.width > 0 && it.box.height > 0 && it.box in area } ?: return emptyList() + return listOfNotNull(videoTitle(caption.text)?.let { FeedCard(it, area) }) + } +} + +/** Caption text without the expand marker or @mentions, which name other accounts. */ +internal fun videoTitle(caption: String): String? = caption.replace("​", "").trim() + .removeSuffix(">").trim().removeSuffix("展开").trim().removeSuffix("...").removeSuffix("…") + .replace(Regex("@\\S+"), " ").replace(Regex("\\s+"), " ").trim() + .takeIf { it.length in 2..1000 } diff --git a/app/src/testDebug/java/io/github/pathgao/housheng/RealFeedsTest.kt b/app/src/testDebug/java/io/github/pathgao/housheng/RealFeedsTest.kt new file mode 100644 index 0000000..590cda0 --- /dev/null +++ b/app/src/testDebug/java/io/github/pathgao/housheng/RealFeedsTest.kt @@ -0,0 +1,97 @@ +package io.github.pathgao.housheng + +import org.junit.Assert.* +import org.junit.Test + +/** Synthetic trees shaped like the surveyed screens; no real dump or caption is copied here. */ +class RealFeedsTest { + private val douyin = "com.ss.android.ugc.aweme" + private val kuaishou = "com.smile.gifmaker" + private val root = FeedNode(className = "android.widget.FrameLayout", box = Box(0, 0, 1080, 2340)) + + private fun douyinScreen(pkg: String = douyin, caption: String = "合成标题 #话题 @朋友 展开", tab: String = "推荐,已选中,按钮") = listOf( + root, + FeedNode(desc = tab, box = Box(800, 90, 940, 210)), + FeedNode(desc = "评论12,按钮", box = Box(914, 1300, 1079, 1500)), + FeedNode(text = "@合成作者", id = "$pkg:id/title", box = Box(33, 1800, 300, 1870)), + FeedNode(text = caption, id = "$pkg:id/desc", box = Box(33, 1870, 820, 2010)), + FeedNode(text = "首页", desc = "首页,按钮", box = Box(60, 2100, 150, 2170)) + ) + + private fun kuaishouScreen(pkg: String = kuaishou, search: String = "nasa_featured_default_search_view") = listOf( + root, + FeedNode(desc = "查找", id = "$pkg:id/$search", box = Box(940, 95, 1050, 205)), + FeedNode(text = "@合成作者", id = "$pkg:id/user_name_text_view", box = Box(30, 1820, 380, 1890)), + FeedNode(text = "合成文案 #话题​… 展开", id = "$pkg:id/element_caption_label", box = Box(30, 1910, 800, 2040)), + FeedNode(desc = "拍摄", id = "$pkg:id/shoot_container", box = Box(437, 2076, 643, 2210)) + ) + + private fun node(text: String = "", desc: String = "") = FeedNode(text = text, desc = desc, box = Box(300, 1800, 500, 1850)) + + @Test fun douyinReadsOnlyTheCurrentCaptionAndCoversTheFeedArea() { + for (pkg in listOf(douyin, "com.ss.android.ugc.aweme.lite")) { + assertEquals(listOf(FeedCard("合成标题 #话题", Box(0, 210, 1080, 2100))), RealFeeds.read(pkg, douyinScreen(pkg))) + } + assertEquals("another app's ids never match", emptyList(), RealFeeds.read(douyin, douyinScreen("com.ss.android.ugc.aweme.lite"))) + } + + @Test fun neighbouringItemsOutsideTheScreenAreIgnoredButTwoVisibleCaptionsAreRejected() { + val offscreen = listOf(Box(33, 3812, 824, 2075), Box(33, 0, 824, -97), Box(33, 2180, 824, 2300)).map { FeedNode(text = "下一条", id = "$douyin:id/desc", box = it) } + assertEquals("合成标题 #话题", RealFeeds.read(douyin, douyinScreen() + offscreen).single().title) + val second = FeedNode(text = "下一条", id = "$douyin:id/desc", box = Box(33, 1500, 820, 1600)) + assertTrue(RealFeeds.read(douyin, douyinScreen() + second).isEmpty()) + } + + @Test fun douyinNeedsTheSelectedRecommendTabAndTheBottomNavigation() { + assertTrue(RealFeeds.read(douyin, douyinScreen(tab = "推荐,按钮")).isEmpty()) + assertTrue(RealFeeds.read(douyin, douyinScreen(tab = "关注,已选中,按钮")).isEmpty()) + assertTrue(RealFeeds.read(douyin, douyinScreen().filterNot { it.desc == "首页,按钮" }).isEmpty()) + val lowTab = douyinScreen().map { if (it.desc.startsWith("推荐")) it.copy(box = Box(800, 1200, 940, 1320)) else it } + assertTrue("a tab bar halfway down is not the full-screen feed", RealFeeds.read(douyin, lowTab).isEmpty()) + } + + @Test fun adsLivePreviewsCommentsAndLoginAreNeverRead() { + assertTrue(RealFeeds.read(douyin, douyinScreen(caption = "合成短剧,赶紧看 广告 ")).isEmpty()) + val blockers = listOf(node(text = "开发者:"), node(text = "立即下载"), node(desc = "立即下载"), node(desc = "点击进入直播间按钮"), + node(text = "@合成店铺", desc = "@合成店铺直播中,按钮"), node(text = "12条评论"), node(text = "一键登录"), node(text = "已阅读并同意 用户协议")) + for (blocker in blockers) assertTrue(blocker.toString(), RealFeeds.read(douyin, douyinScreen() + blocker).isEmpty()) + assertTrue(RealFeeds.read(kuaishou, kuaishouScreen() + node(text = "广告")).isEmpty()) + assertTrue(RealFeeds.read(douyin, douyinScreen() + FeedNode(className = "android.widget.EditText", box = Box(0, 2000, 1080, 2100))).isEmpty()) + } + + @Test fun kuaishouReadsTheCaptionWithoutTheAuthor() { + assertEquals(listOf(FeedCard("合成文案 #话题", Box(0, 205, 1080, 2076))), RealFeeds.read(kuaishou, kuaishouScreen())) + assertEquals("合成文案 #话题", RealFeeds.read("com.kuaishou.nebula", kuaishouScreen("com.kuaishou.nebula", "thanos_home_top_search")).single().title) + assertTrue("no home search bar", RealFeeds.read(kuaishou, kuaishouScreen(search = "left_btn")).isEmpty()) + assertTrue("no bottom navigation", RealFeeds.read(kuaishou, kuaishouScreen().dropLast(1)).isEmpty()) + assertTrue("no caption, e.g. a short drama", RealFeeds.read(kuaishou, kuaishouScreen().filterNot { "caption" in it.id }).isEmpty()) + } + + @Test fun onlyTheSurveyedVersionsAreRead() { + assertTrue(RealFeeds.supports(douyin, "40.6.0")) + assertTrue(RealFeeds.supports("com.kuaishou.nebula", "14.8.40.10322")) + assertTrue(RealFeeds.supports(kuaishou, "14.8.40")) + assertTrue(RealFeeds.supports(RealFeeds.XIAOHONGSHU, "9.49.0")) + for ((pkg, version) in listOf(douyin to "40.6.1", douyin to "40.6.0.1", "com.ss.android.ugc.aweme.lite" to null, kuaishou to "14.8.401", + kuaishou to "14.8.4", RealFeeds.XIAOHONGSHU to "9.49.0.1", "com.tencent.mm" to "40.6.0")) assertFalse("$pkg $version", RealFeeds.supports(pkg, version)) + } + + @Test fun captionsLoseTheExpandMarkerAndMentions() { + assertEquals("合成文案 #话题", videoTitle("合成文案 #话题​… 展开")) + assertEquals("合成文案 问一下", videoTitle("合成文案 @朋友 问一下... 展开>")) + assertEquals("第一行 第二行", videoTitle("第一行\n第二行 >")) + for (value in listOf("字", "@朋友 展开", "字".repeat(1001))) assertNull(value.take(20), videoTitle(value)) + } + + @Test fun xiaohongshuKeepsItsDiscoveryCardRules() { + val tabs = listOf(FeedNode(desc = "首页", box = Box(0, 2100, 200, 2200), selected = true), FeedNode(desc = "发现", box = Box(400, 90, 520, 200), selected = true)) + val list = FeedNode(className = "androidx.recyclerview.widget.RecyclerView", box = Box(0, 300, 1080, 2100), scrollable = true) + val left = FeedNode(desc = "笔记 合成卡片 来自作者 12赞", box = Box(10, 320, 535, 1100)) + val right = FeedNode(desc = "视频 合成视频 来自作者 3万赞", box = Box(545, 320, 1070, 1100)) + val screen = listOf(root) + tabs + list + left + right + assertEquals(listOf("合成卡片", "合成视频"), RealFeeds.read(RealFeeds.XIAOHONGSHU, screen).map { it.title }) + assertEquals(listOf("合成卡片"), RealFeeds.read(RealFeeds.XIAOHONGSHU, screen + FeedNode(text = "广告", box = Box(600, 1000, 700, 1050))).map { it.title }) + assertTrue(RealFeeds.read(RealFeeds.XIAOHONGSHU, screen - tabs[1]).isEmpty()) + assertTrue(RealFeeds.read(RealFeeds.XIAOHONGSHU, screen + list).isEmpty()) + } +} diff --git a/docs/app-scope.md b/docs/app-scope.md index 771d9eb..8fa1f76 100644 --- a/docs/app-scope.md +++ b/docs/app-scope.md @@ -6,21 +6,21 @@ | 应用 | Android包名 | 当前页面能力 | |---|---|---| -| 抖音 | com.ss.android.ugc.aweme | 待信息流页面适配,不读取 | -| 抖音极速版 | com.ss.android.ugc.aweme.lite | 待信息流页面适配,不读取 | -| 快手 | com.smile.gifmaker | 待信息流页面适配,不读取 | -| 快手极速版 | com.kuaishou.nebula | 待信息流页面适配,不读取 | +| 抖音 | com.ss.android.ugc.aweme | 调试版可单独启用 40.6.0 推荐页视频文案筛选,未经真机验证 | +| 抖音极速版 | com.ss.android.ugc.aweme.lite | 调试版可单独启用 40.6.0 推荐页视频文案筛选,未经真机验证 | +| 快手 | com.smile.gifmaker | 调试版可单独启用 14.8.40 推荐信息流视频文案筛选,未经真机验证 | +| 快手极速版 | com.kuaishou.nebula | 调试版可单独启用 14.8.40 推荐信息流视频文案筛选,未经真机验证 | | 小红书 | com.xingin.xhs | 调试版可单独启用 9.49.0 首页发现卡片标题筛选 | 验证场是独立测试来源,其页面和统计数据不得混入父母真实统计。名单集中在 `AppCatalog.kt`。名单是允许适配的范围,不是安全应用认证或内容质量背书。 浏览器、微信、QQ、银行、支付、邮箱及其他名单外应用不采集页面或通知。浏览器可以出现在安装清单中,但其网页、地址、搜索、历史和通知不读取。内嵌网页、私信、账号、登录、支付、设置等页面也必须排除。 -小红书实验入口先核对应用版本、已选中的首页与发现标签、单一推荐列表和卡片边界,随后只提取卡片标题。可识别的广告、推广、赞助和品牌合作卡片排除,编辑框、网页和无法确认的页面不处理。其他真实应用仍未开放。无法确认就退出,不能先全屏读取再判断隐私。截图同样遵守范围限制,当前仅保留验证场显式截图。遮挡优先用系统实时模糊;系统不支持或关闭模糊时,截取一帧只在内存中裁出目标区域做高斯模糊,不保存、不发给 Clef,截图失败则改用纯色。 +小红书实验入口先核对应用版本、已选中的首页与发现标签、单一推荐列表和卡片边界,随后只提取卡片标题。可识别的广告、推广、赞助和品牌合作卡片排除,编辑框、网页和无法确认的页面不处理。抖音系先核对 40.6.0、已选中的“推荐”标签和底部导航,快手系先核对 14.8.40、信息流顶部搜索按钮和底部拍摄入口,再只提取两者之间唯一一条文案,不提取作者、点赞、评论和音乐。出现广告、下载、直播、评论区或登录标记的整屏都不读、不遮挡。无法确认就退出,不能先全屏读取再判断隐私。截图同样遵守范围限制,当前仅保留验证场显式截图。遮挡优先用系统实时模糊;系统不支持或关闭模糊时,截取一帧只在内存中裁出目标区域做高斯模糊,不保存、不发给 Clef,截图失败则改用纯色。 通知仍需逐个勾选来源。受保护类别和设为始终放行的来源不读取正文,但可按来源记录收到次数。其余通知按明确关键词处理。广告屏蔽、广告跳过仍不在产品范围内。 -调试版可在设置与诊断配置Clef API,默认关闭模型处理。合成验证场可选择手机直连或 USB 电脑桥接,两者不自动回退。小红书需另行确认开启,只通过手机直连发送公开卡片标题,不发送作者、点赞量、图片、评论、私信或真实通知。最多同时请求 2 条,待处理卡片最多 4 条。判断和手动放行只保存在内存,分别最多 32 条,不写入报告。手机凭据使用Android Keystore加密保存,禁止备份和设备迁移,用户可在配置页删除。当前发行构建仍无网络权限。 +调试版可在设置与诊断配置Clef API,默认关闭模型处理。合成验证场可选择手机直连或 USB 电脑桥接,两者不自动回退。每个真实应用需另行确认开启,只通过手机直连发送小红书公开卡片标题或短视频当前一条的文案(含话题,去掉 @ 提及),不发送作者、点赞量、图片、画面、评论、私信或真实通知。命中的短视频整块模糊遮挡,不自动划走。最多同时请求 2 条,待处理卡片最多 4 条。判断和手动放行只保存在内存,分别最多 32 条,不写入报告。手机凭据使用Android Keystore加密保存,禁止备份和设备迁移,用户可在配置页删除。当前发行构建仍无网络权限。 ## 通知汇总与家庭报告 diff --git a/docs/development.md b/docs/development.md index 7f70470..95fefb8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -30,7 +30,7 @@ adb shell am instrument -w -r -e realApps true -e class io.github.pathgao.houshe ## 当前限制 - 小红书只识别发现页标题,不能理解图片和视频。广告标记无法通过无障碍读取的情况仍需实测,不宣称能可靠区分所有商业内容。 -- 抖音极速版 40.6.0 的本机推荐页无障碍树没有视频标题,当前保持不读取、不操作。尚未接入 OCR 或视频理解。 +- 抖音系 40.6.0 与快手系 14.8.40 的视频文案筛选只按小米 10S 采样数据离线回放过,未在真机运行。快手只采到未登录状态,广告和直播样本缺失。尚未接入 OCR 或视频理解,没有文案的视频不处理。 - 24–72 小时后台稳定性、不同运营商网络、其他品牌和跨应用版本兼容性尚未验收。 最新结果见 [Clef 验证](clef-validation.md),待办与不做的范围见 [路线图](roadmap.md)。 diff --git a/docs/roadmap.md b/docs/roadmap.md index 9bd363a..c3893d8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -15,7 +15,7 @@ 前期主线是快速、可靠的信息流筛选:先完成真实页面适配、判断与执行,以及误筛后的放行调整。下述后期解说能力不作为前期交付的依赖,不让长文本生成阻塞日常浏览。 1. **日常稳定性**:验证24–72小时使用、锁屏过夜、省电、内存回收、清理最近任务和重启后的连接与恢复行为。功能测试通过不能替代这项验收。 -2. **真实信息流适配**:先确认普通信息流区域,再读取和分类。调试版已支持Clef手机直连与USB电脑桥接,新增小红书 9.49.0 发现页标题筛选和逐卡片遮挡,仍需完善跨版本适配、误筛评估和长期稳定性。抖音极速版 40.6.0 推荐页在本机无障碍树中没有暴露视频标题,暂不处理。当前不能生成真实话题比例。接入与[固定决策协议](../validation/clef/README.md)、本轮[模型验证](clef-validation.md)均已记录。 +2. **真实信息流适配**:先确认普通信息流区域,再读取和分类。调试版已支持Clef手机直连与USB电脑桥接,新增小红书 9.49.0 发现页标题筛选和逐卡片遮挡,仍需完善跨版本适配、误筛评估和长期稳定性。抖音系 40.6.0 与快手系 14.8.40 推荐页视频文案筛选已按真机采样接入,未在真机运行。当前不能生成真实话题比例。接入与[固定决策协议](../validation/clef/README.md)、本轮[模型验证](clef-validation.md)均已记录。 3. **家庭协作演进**:当前由孩子阅读报告、指导父母在本机调整。计划让孩子账号远程调整父母手机上的部分设置,先从信息质量滑块和提示词开始,尚未实现。要求如下: - 父母在自己手机上同意绑定,随时可以解除。 - 孩子能改哪些设置由父母选定。孩子不能关闭电话、短信、闹钟等受保护通知,也看不到通知正文和浏览内容。