Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。

## 安装

Expand All @@ -111,7 +111,7 @@
- **只看你勾选的应用。** 浏览器、聊天和名单外应用一概不采集。
- **不存正文。** 通知标题和正文不落盘,本机只留最近 30 天的次数汇总。
- **报告不自动发送。** 报告不含正文、验证码或私聊,由爸妈预览后自己分享。
- **现在没有账号。** 无远程控制,无同步。调试版可主动启用 Clef API,单独开启小红书时只发送公开卡片标题,凭据由本人配置并在手机加密保存。
- **现在没有账号。** 无远程控制,无同步。调试版可主动启用 Clef API,单独开启的应用只发送小红书公开卡片标题或短视频推荐页当前一条的文案,凭据由本人配置并在手机加密保存。
- **后续 AI 设置单独开启。** 提示词整理和孩子账号同步尚在规划,发送范围和授权要求见 [路线图](docs/roadmap.md#ai-与联网)。
- **不越权。** 不屏蔽广告,不点“跳过”,不绕过应用限制,不自动卸载。

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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)
Expand Down Expand Up @@ -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() }
Expand All @@ -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 ·") }
Expand All @@ -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())
}
}
Expand Down
30 changes: 18 additions & 12 deletions app/src/debug/java/io/github/pathgao/housheng/ModelValidation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>()
set(value) { field = value; FeedService.instance?.invalidate() }

var enabled = false
Expand All @@ -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))
})
Expand Down
86 changes: 37 additions & 49 deletions app/src/debug/java/io/github/pathgao/housheng/RealFeedController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<FeedCard> {
if (root.packageName?.toString() != PACKAGE) return emptyList()
val nodes = mutableListOf<AccessibilityNodeInfo>()
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<FeedNode>? {
val nodes = mutableListOf<FeedNode>()
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<FeedCard> =
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)
Expand All @@ -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<String, Boolean>()
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 {
Expand All @@ -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()
Expand All @@ -105,27 +92,28 @@ 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()
}
}
next()
}

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 · 用户恢复显示")
}
}

Expand Down
Loading
Loading