/zh/posts/app-surface-data-collection-notes

App 表面数据采集设计笔记

EN

有些商品数据可以从 Web 接口里拿到。有些数据只存在于 App 屏幕上。真正有意思的设计问题,不只是“怎么抓”,而是怎么做一个小系统,把这两种表面组合起来,同时不要变得太脆弱。

移动端独占信息会改变架构。如果某个信号只在 App 里可见,采集器就需要真实 Android worker。ADB 就变成控制平面:打开 App,进入商品页,dump UI,解析屏幕,再把结果写回同一套商品数据模型。

对于购物或内容平台,一个有用的内部工具通常需要三类信息:

  • 稳定标识,比如商品 ID 和规范 URL
  • 结构化公开数据,比如标题、店铺、价格、销量文本和 SKU 变体
  • App 才能看到的信号,比如标签、榜单提示、近期加购、收藏或可见的社会证明

这些信息不会从一个干净 API 里一起出现。设计必须接受这个现实,然后让采集路径可观察、可重试,并且足够朴素,能被长期操作。

1. 先归一化输入,再做昂贵动作

第一个边界是输入归一化。

用户会粘贴很乱的东西:短链、长链、复制出来的分享文案、deep link,或者只是一个商品 ID。系统其他部分不应该关心这些差异。它们应该只收到一个规范商品身份。

function resolveProductInput(input):
text = trim(input)

if text contains direct goods id:
return { itemId, source: "direct" }

urls = extractUrls(text)

for url in urls:
if url contains goods id:
return { itemId, source: "url", resolvedUrl: url }

if url is short link:
finalUrl = followRedirect(url)
if finalUrl contains goods id:
return { itemId, source: "short_link", resolvedUrl: finalUrl }

raise "no product id found"

这样后续操作会简单很多。Web 采集、App 采集、商品表、查询历史和指标任务都只围绕 item ID 说话。

我喜欢这个模式,因为它把混乱留在边缘。用户可以粘贴几乎任何东西,但系统内部只有一个耐用的 key。

2. 把 Web 数据和 App 细节数据拆开

第二个设计选择,是把便宜的 Web 数据和昂贵的 App 细节数据分开。

Web 数据通常更快、更稳:

  • 商品标题
  • 销量文本
  • 店铺名
  • 店铺 URL
  • 地理位置
  • SKU 变体
  • 价格字段

App UI 采集更慢,也更容易受运行环境影响:

  • 需要真实 Android 设备或模拟器
  • 依赖当前 UI 结构
  • 可能因为加载状态、登录状态、App 更新或网络条件失败
  • 会占用稀缺设备槽位

所以默认流程应该分层:

function addProduct(input, includeDetail):
resolved = resolveProductInput(input)
product = upsertProduct(resolved.itemId)

product.webStatus = "running"
product = refreshWebSnapshot(product)

if includeDetail:
enqueueAppDetailTask(product)
product.detailStatus = "pending"

return product

这样用户能很快得到一条有用记录。App 细节采集变成可选补充,而不是整个商品创建流程的阻塞点。

3. 把设备当成池,而不是常量

远程 Android 采集不应该假设永远只有一台完美设备。之所以需要 Android worker,是因为有些信息只暴露在移动端 App 表面。一旦手机进入数据路径,它就应该被当成基础设施建模,而不是藏在脚本里的隐式依赖。

更好的模型是一个小设备池。每台设备有自己的元数据:

  • 名称
  • ADB serial
  • 手机 IP
  • SSH 反向端口
  • App 包名
  • 在线状态
  • 忙闲状态
  • 最近一次成功采集时间

任务分发可以保持保守:

function collectMobileOnlySignals(product):
device = selectIdleDevice(product.preferredDeviceId)
if device is null:
enqueue(product)
return "pending"

with adbSession(device.serial):
openProductInApp(product.itemId)
xml = dumpUi()
tags = parseVisibleTags(xml)
saveEvidence(product, xml, tags, device)

function selectIdleDevice(preferredDeviceId = null):
statuses = adbDevices()
busy = queryRunningTaskDeviceIds()
devices = activeDevicesOrderedById()

if preferredDeviceId exists:
device = find(devices, preferredDeviceId)
if device is online and device.id not in busy:
return device
return null

for device in devices:
if device.id in busy:
continue
if statuses[device.serial] == "device":
return device

return null

这很重要,因为设备不只是实现细节。它是稀缺 worker。如果两个任务同时操作同一台手机,UI 状态会变得不可预测。

4. App 采集应该是队列

一旦把设备当成 worker,App 细节采集自然就会变成队列。

任务记录应该保存:

  • 商品 ID
  • 原始输入和规范 URL
  • 状态:pending、running、success、failed
  • 分配到的设备
  • 发起请求的 API key 或用户
  • 开始和结束时间
  • 提取到的标签
  • 原始 UI 节点
  • 错误信息
  • 耗时

分发器可以保持简单:

function dispatchPendingTasks():
tasks = oldestPendingTasks(limit = 10)

for task in tasks:
device = selectIdleDevice(task.preferredDeviceId)
if device is null:
continue

task.status = "running"
task.deviceId = device.id
task.startedAt = now()
save(task)

runTask(task.id)

function runTask(taskId):
task = loadTask(taskId)
device = loadDevice(task.deviceId)

try:
result = collectFromApp(task.itemId, device.serial)
task.status = "success"
task.tags = result.tags
task.rawItems = result.items
task.elapsedMs = result.elapsedMs
task.finishedAt = now()
markProductDetailSuccess(task.productId)
saveMetrics(task.productId, result.tags)
markDeviceSeen(device)
catch error:
task.status = "failed"
task.error = error.message
task.finishedAt = now()
markProductDetailFailed(task.productId)

save(task)
dispatchPendingTasks()

重点不是太早做一个大型任务系统,而是把状态显式化到足够能观察失败、能重试。

5. 把 App 屏幕当成证据来解析

App UI 采集和 API 采集不一样。屏幕本身就是数据源。

一个实际可用的流程是:

  1. 打开商品 deep link
  2. 等屏幕稳定
  3. dump UI XML
  4. 扫描 text 和 content description
  5. 过滤可能是标签的字符串
  6. 用屏幕坐标排除无关区域
  7. 当标签横向溢出时滑动
  8. 按出现顺序去重

伪代码:

function collectFromApp(itemId, serial):
openDeepLink(serial, "app://goods_detail/" + itemId)
waitForSettledScreen()

screen = getScreenSize(serial)
allItems = []
seen = set()
lastTagY = null

for round in 0..maxSwipes:
xml = dumpUiXml(serial)

if pageShowsLoadError(xml):
raise "product page load failed"

items = extractCandidateTags(xml, screen.height)

for item in items:
allItems.append(item)
seen.add(item.text)

if noNewTagsFound(seen):
break

lastTagY = medianY(items)
swipeTagArea(serial, y = lastTagY)
waitForSettle()

return uniqueByText(allItems)

这本质上是启发式的。UI 提取不是一个干净契约。更好的设计是保留足够多的原始证据,让之后可以调试错误匹配。

所以保存 raw UI node 信息很有用。它能把“采集器漏了东西”变成一个可以检查的问题。

6. 不要只保存最终标签

最终标签很方便,但不够。

为了分析,我希望有三层数据:

  • 商品记录
  • 查询或采集历史
  • 随时间变化的指标

商品记录回答“这个商品现在是什么”。查询历史回答“采集过程中发生了什么”。指标表回答“某个信号如何变化”。

比如 24小时内123人加购 这样的标签,既是展示文本,也是结构化数据。它应该作为原文保存,也可以变成指标:

patterns = [
("24h_cart", /24小时内(\d+)\+?人加购/),
("7d_fav", /近7天新增(\d+)\+?人收藏/)
]

function saveMetrics(product, tags):
for tag in tags:
for dimension, pattern in patterns:
if pattern matches tag:
insertMetric(product.id, product.itemId, dimension, capturedNumber)

这样工具就不只是一个采集器,而是一个小型商品信号时间序列表面。

7. 控制台要偏运维,而不是营销大屏

前端应该像一个操作台,而不是营销 dashboard。

重要页面都很实际:

  • 商品列表和筛选
  • 细节补充状态
  • 最近一次查询结果
  • 设备状态和忙闲状态
  • API key 管理
  • 市集采集进度
  • 指标汇总和历史

UI 应该展示状态,而不是隐藏状态。pendingrunningsuccessfailed 不是后端细节,它们就是产品本身。

当采集依赖远程手机时,沉默很昂贵。用户需要知道设备是离线、忙碌、等待中、采集中,还是失败了。

8. 密钥和权限保持窄

即使是一个小型内部工具,也需要一个安全形状。

简单版本已经够用:

  • 原始 API key 只展示一次
  • 数据库只保存 SHA-256 hash
  • key 可以过期
  • key 可以停用
  • 设备管理和 key 管理是独立权限

伪代码:

function createKey(name, expiresAt, permissions):
raw = "sk-" + secureRandom()

record = {
name,
keyPrefix: firstVisiblePart(raw),
keyHash: sha256(raw),
expiresAt,
permissions,
status: "active"
}

save(record)
return raw

function verifyKey(raw):
if raw does not start with "sk-":
return null

record = findByHash(sha256(raw))

if record is missing or revoked or expired:
return null

record.lastUsedAt = now()
return record

这样能让操作访问保持简单,同时不在数据库里保存可复用明文密钥。

我现在的规则

对于 App 表面数据采集,我的规则是:

normalize early, collect in layers, store evidence, extract metrics late

有用的系统不是只在某一次完美抓取屏幕。它应该能解释自己尝试了什么、找到了什么、由哪个 worker 执行、为什么失败,以及哪些信号值得随时间追踪。