@skrillex1224/android-toolkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # Android Toolkit
2
+
3
+ `android-toolkit` 是原生 Android androider 的最小公共底座,入口格式对齐
4
+ `playwright-toolkit`:业务 actor 只通过 `useAndroidToolKit()` 获取模块,不直接引用内部文件。
5
+
6
+ ## 快速开始
7
+
8
+ ```js
9
+ import { useAndroidToolKit } from '../android-toolkit/index.js';
10
+
11
+ const { Launch } = useAndroidToolKit();
12
+
13
+ await Launch.run(async ({ input, ctx, kit }) => {
14
+ const result = await kit.ApifyKit.runStep('执行业务流程', null, async () => {
15
+ await kit.Device.forceStopApp(ctx, ctx.packageName);
16
+ await kit.Device.tapRatio(ctx, 0.5, 0.5);
17
+ return { answer: `echo: ${input.query}`, sources: [] };
18
+ });
19
+
20
+ await kit.ApifyKit.pushSuccess(result);
21
+ }, {
22
+ inputPath,
23
+ outputPath,
24
+ contextDefaults: {
25
+ packageName: 'com.example.app'
26
+ }
27
+ });
28
+ ```
29
+
30
+ ## 保留模块
31
+
32
+ | 模块 | 说明 |
33
+ | --- | --- |
34
+ | `Launch` | 接受显式 `inputPath` / `outputPath`,创建 `ctx`,调用业务 handler |
35
+ | `ApifyKit` | 提供 `runStep`、`runStepLoose`、`pushSuccess`、`pushFailed`,并统一补齐 `code/status/timestamp/data` |
36
+ | `Device` | ADB 操作层:启动、点击、滑动、输入中文、截图、Activity 检查 |
37
+ | `Frida` | 启动 WebView event recorder,记录 `evaluateJavascript` / `loadUrl` 注入事件 |
38
+ | `Context` | 从显式 input/defaults 生成 Android 运行上下文 |
39
+ | `Errors` | `CrawlerError` 和错误序列化;失败码由业务显式抛出的 `CrawlerError` 决定 |
40
+
41
+ ## 当前边界
42
+
43
+ 放进 toolkit:
44
+
45
+ - ADB 执行、云手机 TCP ADB 离线重连、点击、滑动、截图、中文输入。
46
+ - Frida attach 目标 App pid,并启动 WebView JS 注入事件 recorder。
47
+ - `runStep` / `runStepLoose` / `pushSuccess` / `pushFailed`。
48
+ - 通用错误码和失败 dataset 兜底结构。
49
+
50
+ 不放进 toolkit:
51
+
52
+ - 任意平台的包名、Activity、WebView class 名称。
53
+ - 任意平台的事件名、正文边界、引用来源解析规则。
54
+ - 任意平台的成功标准和业务错误码。
55
+
56
+ 这些都留在具体 `*-androider` 内,例如 `wechat-androider/src/utils.js`。
package/entrys/node.js ADDED
@@ -0,0 +1,24 @@
1
+ import { Launch } from '../src/launch.js';
2
+ import { ApifyKit } from '../src/apify-kit.js';
3
+ import { Device } from '../src/device.js';
4
+ import { Frida } from '../src/frida-client.js';
5
+ import * as Constants from '../src/constants.js';
6
+ import * as Errors from '../src/errors.js';
7
+ import { Logger } from '../src/logger.js';
8
+ import { Context } from '../src/context.js';
9
+
10
+ // Unified Entry Point
11
+ // 和 playwright-toolkit 的 usePlaywrightToolKit() 保持同一种入口格式:
12
+ // visitor/androider 只从这里取模块,不直接穿透到内部文件。
13
+ export const useAndroidToolKit = () => {
14
+ return {
15
+ Launch,
16
+ ApifyKit,
17
+ Device,
18
+ Frida,
19
+ Constants,
20
+ Errors,
21
+ Logger,
22
+ Context
23
+ };
24
+ };
package/index.js ADDED
@@ -0,0 +1 @@
1
+ export { useAndroidToolKit } from './entrys/node.js';
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@skrillex1224/android-toolkit",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "index.js",
6
+ "exports": {
7
+ ".": "./index.js"
8
+ },
9
+ "files": [
10
+ "index.js",
11
+ "entrys",
12
+ "src",
13
+ "scripts",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "postinstall": "node scripts/postinstall.js",
18
+ "check": "node --check index.js && node --check entrys/node.js && node --check src/launch.js && node --check src/apify-kit.js && node --check src/context.js && node --check src/constants.js && node --check src/device.js && node --check src/errors.js && node --check src/frida-client.js && node --check src/logger.js && node --check src/internals/frida/webview_event_agent.js",
19
+ "test": "node --test test/*.test.js"
20
+ },
21
+ "engines": {
22
+ "node": ">=18"
23
+ }
24
+ }
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {execFileSync, spawnSync} from 'node:child_process';
4
+
5
+ log('检查 Android toolkit 运行环境');
6
+ checkAdb();
7
+ installOrCheckFrida();
8
+
9
+ function checkAdb() {
10
+ const adb = findCommand('adb');
11
+ if (adb) {
12
+ log(`已找到 adb: ${adb}`);
13
+ return;
14
+ }
15
+ warn(`未找到 adb。请安装 Android platform-tools,并确保 \`adb\` 在 PATH 中。${installHintForAdb()}`);
16
+ }
17
+
18
+ function installOrCheckFrida() {
19
+ const frida = findCommand('frida');
20
+ if (frida) {
21
+ log(`已找到 frida: ${frida}`);
22
+ return;
23
+ }
24
+
25
+ const pip3 = findCommand('pip3');
26
+ const python3 = findCommand('python3');
27
+ if (!pip3 || !python3) {
28
+ warn(`未找到 frida,且当前机器缺少 python3/pip3,无法自动安装 frida-tools。${installHintForFrida()}`);
29
+ return;
30
+ }
31
+
32
+ try {
33
+ log('尝试自动安装 frida-tools 到用户目录');
34
+ execFileSync(python3, ['-m', 'pip', 'install', '--user', '--upgrade', 'frida-tools'], {stdio: 'inherit'});
35
+ const installedFrida = findCommand('frida');
36
+ if (installedFrida) {
37
+ log(`frida 安装成功: ${installedFrida}`);
38
+ return;
39
+ }
40
+ warn('frida-tools 已安装,但 `frida` 仍不在 PATH 中。请把 Python user bin 加入 PATH。');
41
+ return;
42
+ } catch (error) {
43
+ warn(`自动安装 frida-tools 失败: ${error.message || String(error)}`);
44
+ }
45
+
46
+ warn(`未能自动准备 frida,请手动确认 \`frida --version\` 可执行。${installHintForFrida()}`);
47
+ }
48
+
49
+ function findCommand(command) {
50
+ const result = spawnSync('which', [command], {encoding: 'utf8'});
51
+ return result.status === 0 ? String(result.stdout || '').trim() : '';
52
+ }
53
+
54
+ function installHintForAdb() {
55
+ if (process.platform === 'darwin') return ' macOS 可用 `brew install android-platform-tools`。';
56
+ if (process.platform === 'linux') return ' Linux 可安装 Android platform-tools,或用系统包管理器安装 adb(如 apt/dnf/pacman/apk)。';
57
+ return '';
58
+ }
59
+
60
+ function installHintForFrida() {
61
+ if (process.platform === 'darwin') return ' macOS 也可用 `python3 -m pip install --user frida-tools`。';
62
+ if (process.platform === 'linux') return ' Linux 也可用 `python3 -m pip install --user frida-tools`。';
63
+ return '';
64
+ }
65
+
66
+ function log(message) {
67
+ console.log(`[android-toolkit] ${message}`);
68
+ }
69
+
70
+ function warn(message) {
71
+ console.warn(`[android-toolkit] ${message}`);
72
+ }
@@ -0,0 +1,177 @@
1
+ import fs from 'node:fs';
2
+
3
+ import {Code, Status} from './constants.js';
4
+ import {createAndroidContext} from './context.js';
5
+ import {CrawlerError, serializeError} from './errors.js';
6
+ import {Logger, sleep} from './logger.js';
7
+
8
+ let instance = null;
9
+
10
+ async function createApifyKit(options = {}) {
11
+ const io = {
12
+ inputPath: options.inputPath || '',
13
+ outputPath: requiredOption(options.outputPath, 'outputPath')
14
+ };
15
+ const input = options.input || (io.inputPath ? JSON.parse(fs.readFileSync(io.inputPath, 'utf8')) : {});
16
+ const ctx = options.ctx || createAndroidContext(input, options.contextDefaults || {});
17
+ let pushed = false;
18
+ let lastDevice = null;
19
+
20
+ return {
21
+ ctx,
22
+ input,
23
+ inputPath: io.inputPath,
24
+ outputPath: io.outputPath,
25
+
26
+ /**
27
+ * Android 版 runStep。
28
+ *
29
+ * 设计上贴近 playwright-toolkit 的 ApifyKit.runStep:
30
+ * - step 是中文步骤名;
31
+ * - target 位置保留给 device/session,便于未来接入云真机 live view;
32
+ * - actionFn 是真正业务动作;
33
+ * - retry 支持 direct / before hook;
34
+ * - failActor=true 时会先 pushFailed,再把错误继续抛给 Launch。
35
+ */
36
+ async runStep(step, target, actionFn, options = {}) {
37
+ if (target) lastDevice = target;
38
+ const {failActor = true, retry = {}} = options;
39
+ const retryTimes = Number(retry.times || 0);
40
+ let lastError = null;
41
+
42
+ for (let attempt = 0; attempt <= retryTimes; attempt += 1) {
43
+ const suffix = attempt > 0 ? ` (重试 #${attempt})` : '';
44
+ Logger.start(`[Step] ${step}${suffix}`);
45
+ try {
46
+ const result = await actionFn();
47
+ Logger.success(`[Step] ${step}${suffix}`);
48
+ return result;
49
+ } catch (error) {
50
+ lastError = error;
51
+ Logger.fail(`[Step] ${step}${suffix}`, error);
52
+ if (attempt < retryTimes) {
53
+ if (typeof retry.before === 'function') {
54
+ Logger.start(`[RetryStep] 执行 before 钩子: ${step}`);
55
+ await retry.before(target, attempt + 1, error);
56
+ Logger.success(`[RetryStep] before 钩子完成: ${step}`);
57
+ } else {
58
+ const delayMs = Number(retry.delayMs || 3000);
59
+ Logger.start(`[RetryStep] 等待 ${delayMs}ms: ${step}`);
60
+ await sleep(delayMs);
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ if (failActor) {
67
+ await this.pushFailed(lastError, {
68
+ step,
69
+ retryAttempts: retryTimes
70
+ }, {target});
71
+ }
72
+ throw lastError;
73
+ },
74
+
75
+ /**
76
+ * 宽松版 runStep。
77
+ *
78
+ * 和 playwright-toolkit 一样:只负责打日志和抛错,不主动写失败 dataset。
79
+ * 业务层可以用它包“可选截图/可选关闭弹窗/可选诊断”这类步骤。
80
+ */
81
+ async runStepLoose(step, target, fn, options = {}) {
82
+ return this.runStep(step, target, fn, {
83
+ ...options,
84
+ failActor: false
85
+ });
86
+ },
87
+
88
+ /**
89
+ * 写成功 dataset。
90
+ *
91
+ * 对齐 playwright-toolkit:业务 actor 只传业务数据,toolkit 统一补
92
+ * code/status/timestamp/data 外壳。这样 commander/transformer 看到的是同类结构。
93
+ */
94
+ async pushSuccess(data = {}, options = {}) {
95
+ pushed = true;
96
+ fs.writeFileSync(io.outputPath, JSON.stringify([{
97
+ code: Code.Success,
98
+ status: Status.Success,
99
+ timestamp: new Date().toISOString(),
100
+ data: sanitizeData(data),
101
+ ...(options.meta ? {meta: sanitizeData(options.meta)} : {})
102
+ }], null, 2) + '\n', 'utf8');
103
+ Logger.success('pushSuccess', 'Data pushed');
104
+ },
105
+
106
+ /**
107
+ * 写失败 dataset。
108
+ *
109
+ * 失败结构也对齐 playwright-toolkit:CrawlerError 决定 code/context,
110
+ * 普通 Error 统一按 UnknownError 处理,业务可预期失败必须显式抛 CrawlerError。
111
+ */
112
+ async pushFailed(error, meta = {}) {
113
+ const normalized = CrawlerError.isCrawlerError(error)
114
+ ? error
115
+ : CrawlerError.from(error, {code: Code.UnknownError, context: meta});
116
+
117
+ pushed = true;
118
+ fs.writeFileSync(io.outputPath, JSON.stringify([{
119
+ code: normalized.code,
120
+ status: Status.Failed,
121
+ error: serializeError(normalized),
122
+ meta: sanitizeData(meta),
123
+ context: sanitizeData(normalized.context),
124
+ timestamp: new Date().toISOString()
125
+ }], null, 2) + '\n', 'utf8');
126
+ Logger.success('pushFailed', 'Error data pushed');
127
+ },
128
+
129
+ hasPushed() {
130
+ return pushed;
131
+ },
132
+
133
+ getLastDevice() {
134
+ return lastDevice;
135
+ }
136
+ };
137
+ }
138
+
139
+ async function useApifyKit(options = {}) {
140
+ if (!instance || options.reset) {
141
+ instance = await createApifyKit(options);
142
+ }
143
+ return instance;
144
+ }
145
+
146
+ export const ApifyKit = {
147
+ useApifyKit
148
+ };
149
+
150
+ function requiredOption(value, name) {
151
+ const clean = String(value || '').trim();
152
+ if (!clean) throw new Error(`ApifyKit.useApifyKit requires ${name}`);
153
+ return clean;
154
+ }
155
+
156
+ function sanitizeData(value, depth = 0, seen = new WeakSet()) {
157
+ if (depth > 8) return '[MaxDepth]';
158
+ if (value == null) return value;
159
+ const valueType = typeof value;
160
+ if (valueType === 'string' || valueType === 'number' || valueType === 'boolean') return value;
161
+ if (valueType === 'bigint') return value.toString();
162
+ if (valueType === 'function' || valueType === 'symbol') return undefined;
163
+ if (value instanceof Error) return serializeError(value);
164
+ if (Array.isArray(value)) {
165
+ return value.map((item) => sanitizeData(item, depth + 1, seen)).filter((item) => item !== undefined);
166
+ }
167
+ if (valueType !== 'object') return String(value);
168
+ if (seen.has(value)) return '[Circular]';
169
+ seen.add(value);
170
+
171
+ const out = {};
172
+ for (const [key, item] of Object.entries(value)) {
173
+ const safe = sanitizeData(item, depth + 1, seen);
174
+ if (safe !== undefined) out[key] = safe;
175
+ }
176
+ return out;
177
+ }
@@ -0,0 +1,24 @@
1
+ export const Code = {
2
+ Success: 0,
3
+ UnknownError: -1,
4
+ NotLogin: 30000001,
5
+ Chaptcha: 30000002,
6
+ Timeout: 30000003,
7
+ InitialTimeout: 30000004,
8
+ OverallTimeout: 30000005,
9
+
10
+ // Android-toolkit 扩展码从 300100xx 开始,避免改动 Playwright-toolkit 既有码值。
11
+ InvalidRequest: 30010001,
12
+ AdbUnavailable: 30010002,
13
+ ContentUnavailable: 30010003,
14
+ SourceExtractionFailed: 30010004,
15
+ AutomationFailed: 30010005,
16
+ RunnerFailed: 30010006,
17
+ AppiumUnavailable: 30010007,
18
+ FridaUnavailable: 30010008
19
+ };
20
+
21
+ export const Status = {
22
+ Success: 'SUCCESS',
23
+ Failed: 'FAILED'
24
+ };
package/src/context.js ADDED
@@ -0,0 +1,98 @@
1
+ import path from 'node:path';
2
+ import {execFileSync} from 'node:child_process';
3
+ import {fileURLToPath} from 'node:url';
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ const toolkitRoot = path.resolve(__dirname, '..');
7
+
8
+ export const Context = {
9
+ createAndroidContext,
10
+ firstNonEmpty,
11
+ trimRight,
12
+ toolkitPath
13
+ };
14
+
15
+ /**
16
+ * 创建 Android actor 的运行上下文。
17
+ *
18
+ * 这里刻意只接受 input/defaults 参数,不读取环境变量,也不写任何业务默认值:
19
+ * - 不知道具体 app 的包名;
20
+ * - 不知道当前 visitor 要 attach 哪些 WebView 类;
21
+ *
22
+ * ADB/Frida 是机器环境,不是业务 input。toolkit 只从 PATH 查找命令;
23
+ * 安装和 PATH 准备由 postinstall 或部署环境负责。
24
+ */
25
+ export function createAndroidContext(input = {}, defaults = {}) {
26
+ const runtime = input.runtime || {};
27
+ const device = normalizeRuntimeDevice(runtime.device);
28
+
29
+ return {
30
+ runId: firstNonEmpty(defaults.runId),
31
+ query: String(input.query || defaults.query || '').trim(),
32
+ mode: firstNonEmpty(defaults.mode, 'ai_search'),
33
+ serial: firstNonEmpty(device.serial, defaults.serial),
34
+ adbPath: resolveAdbPath(defaults),
35
+ packageName: firstNonEmpty(defaults.packageName),
36
+ fridaPath: resolveFridaPath(defaults),
37
+ // Frida WebView event agent 是当前保留的唯一 Frida agent:
38
+ // 它 hook WebView 执行 JS 字符串的入口,业务方再从注入事件中解析数据。
39
+ fridaWebViewEventAgentScript: firstNonEmpty(
40
+ defaults.fridaWebViewEventAgentScript,
41
+ toolkitPath('src/internals/frida/webview_event_agent.js')
42
+ ),
43
+ appiumServerUrl: trimRight(firstNonEmpty(defaults.appiumServerUrl), '/'),
44
+ appiumSessionId: firstNonEmpty(defaults.appiumSessionId),
45
+ appVersion: firstNonEmpty(defaults.appVersion),
46
+ slotKey: firstNonEmpty(defaults.slotKey),
47
+ instanceId: firstNonEmpty(defaults.instanceId),
48
+ externalIp: firstNonEmpty(defaults.externalIp)
49
+ };
50
+ }
51
+
52
+ export function toolkitPath(relativePath) {
53
+ return path.resolve(toolkitRoot, relativePath);
54
+ }
55
+
56
+ export function firstNonEmpty(...values) {
57
+ for (const value of values) {
58
+ const clean = String(value ?? '').trim();
59
+ if (clean) return clean;
60
+ }
61
+ return '';
62
+ }
63
+
64
+ export function trimRight(value, suffix) {
65
+ let out = String(value || '').trim();
66
+ while (suffix && out.endsWith(suffix)) out = out.slice(0, -suffix.length);
67
+ return out;
68
+ }
69
+
70
+ function normalizeRuntimeDevice(value) {
71
+ if (typeof value === 'string') return {serial: value};
72
+ if (value && typeof value === 'object') return value;
73
+ return {};
74
+ }
75
+
76
+ function resolveAdbPath(defaults = {}) {
77
+ return firstNonEmpty(
78
+ defaults.adbPath,
79
+ findCommand('adb'),
80
+ 'adb'
81
+ );
82
+ }
83
+
84
+ function resolveFridaPath(defaults = {}) {
85
+ return firstNonEmpty(
86
+ defaults.fridaPath,
87
+ findCommand('frida'),
88
+ 'frida'
89
+ );
90
+ }
91
+
92
+ function findCommand(command) {
93
+ try {
94
+ return String(execFileSync('which', [command], {encoding: 'utf8'})).trim();
95
+ } catch {
96
+ return '';
97
+ }
98
+ }