有些商品数据可以从 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 采集不一样。屏幕本身就是数据源。
一个实际可用的流程是:
打开商品 deep link
等屏幕稳定
dump UI XML
扫描 text 和 content description
过滤可能是标签的字符串
用屏幕坐标排除无关区域
当标签横向溢出时滑动
按出现顺序去重
伪代码:
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 应该展示状态,而不是隐藏状态。pending、running、success、failed 不是后端细节,它们就是产品本身。
当采集依赖远程手机时,沉默很昂贵。用户需要知道设备是离线、忙碌、等待中、采集中,还是失败了。
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 执行、为什么失败,以及哪些信号值得随时间追踪。