@skrillex1224/android-toolkit 0.1.2 → 0.1.8
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 +37 -6
- package/entrys/node.js +2 -0
- package/index.d.ts +191 -0
- package/package.json +7 -2
- package/src/apify-kit.js +81 -41
- package/src/constants.js +52 -1
- package/src/context.js +2 -11
- package/src/device.js +488 -71
- package/src/frida-client.js +596 -174
- package/src/internals/compression.js +188 -0
- package/src/internals/frida/webview_event_agent.js +4 -0
- package/src/launch.js +27 -9
- package/src/logger.js +60 -2
- package/src/share.js +644 -0
package/README.md
CHANGED
|
@@ -13,20 +13,20 @@ const { Launch } = useAndroidToolKit();
|
|
|
13
13
|
await Launch.run(async ({ input, ctx, kit }) => {
|
|
14
14
|
const result = await kit.ApifyKit.runStep('执行业务流程', null, async () => {
|
|
15
15
|
await kit.Device.forceStopApp(ctx, ctx.packageName);
|
|
16
|
-
await kit.Device.
|
|
16
|
+
await kit.Device.click(ctx, { x: 360, y: 640 });
|
|
17
17
|
return { answer: `echo: ${input.query}`, sources: [] };
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
await kit.ApifyKit.pushSuccess(result);
|
|
21
21
|
}, {
|
|
22
22
|
inputPath,
|
|
23
|
-
outputPath
|
|
24
|
-
contextDefaults: {
|
|
25
|
-
packageName: 'com.example.app'
|
|
26
|
-
}
|
|
23
|
+
outputPath
|
|
27
24
|
});
|
|
28
25
|
```
|
|
29
26
|
|
|
27
|
+
`ctx.packageName` 来自后端注入的 `input.runtime.device.packageName`。Android Toolkit
|
|
28
|
+
不把包名放进 `Constants.ActorInfo`,也不在业务包里维护包名真源。
|
|
29
|
+
|
|
30
30
|
## 保留模块
|
|
31
31
|
|
|
32
32
|
| 模块 | 说明 |
|
|
@@ -34,16 +34,47 @@ await Launch.run(async ({ input, ctx, kit }) => {
|
|
|
34
34
|
| `Launch` | 接受显式 `inputPath` / `outputPath`,创建 `ctx`,调用业务 handler |
|
|
35
35
|
| `ApifyKit` | 提供 `runStep`、`runStepLoose`、`pushSuccess`、`pushFailed`,并统一补齐 `code/status/timestamp/data` |
|
|
36
36
|
| `Device` | ADB 操作层:启动、点击、滑动、输入中文、截图、Activity 检查 |
|
|
37
|
-
| `Frida` |
|
|
37
|
+
| `Frida` | Frida attach、短脚本执行、SQLite 查询、WebView event recorder |
|
|
38
|
+
| `Share` | 对齐 `playwright-toolkit` 的分享能力:高视口截图、分享链接捕获、截图压缩 |
|
|
38
39
|
| `Context` | 从显式 input/defaults 生成 Android 运行上下文 |
|
|
39
40
|
| `Errors` | `CrawlerError` 和错误序列化;失败码由业务显式抛出的 `CrawlerError` 决定 |
|
|
40
41
|
|
|
42
|
+
## Share
|
|
43
|
+
|
|
44
|
+
`Share.captureScreen(ctx, options)` 用于采集 Android 页面长图。实现策略是先用
|
|
45
|
+
Frida 扫描当前 Activity 的 View 树,读取所有可滚动 View 的
|
|
46
|
+
`computeVerticalScrollRange()`,取最大所需高度后临时执行 `wm size WxH`,再用
|
|
47
|
+
`screencap` 一次性截图。它不裁剪状态栏和导航栏;如果 View 树无法读取,会直接报错,
|
|
48
|
+
不会退回普通视口截图。默认压缩阈值与 `playwright-toolkit` 一致:base64 最大
|
|
49
|
+
5MiB,JPEG,质量 `0.72`,最低质量 `0.38`,最低缩放 `0.25`。
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
const screenshotBase64 = await Share.captureScreen(ctx, {
|
|
53
|
+
Frida,
|
|
54
|
+
actorInfo: Constants.ActorInfo['doubao.android'].key,
|
|
55
|
+
maxHeight: 8000
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`Share.captureLink(ctx, options)` 负责按 `Constants.ActorInfo[actor].share.prefix`
|
|
60
|
+
校验分享链接。Android 当前稳定模式是 `clipboard`:业务层执行点击分享、复制链接,
|
|
61
|
+
toolkit 通过 `Frida.runScript` 轮询系统剪贴板候选内容并提取匹配 prefix 的 URL。
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
const result = await Share.captureLink(ctx, {
|
|
65
|
+
Frida,
|
|
66
|
+
actorInfo: Constants.ActorInfo['doubao.android'].key,
|
|
67
|
+
performActions: async () => clickShareAndCopyLink()
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
41
71
|
## 当前边界
|
|
42
72
|
|
|
43
73
|
放进 toolkit:
|
|
44
74
|
|
|
45
75
|
- ADB 执行、云手机 TCP ADB 离线重连、点击、滑动、截图、中文输入。
|
|
46
76
|
- Frida attach 目标 App pid,并启动 WebView JS 注入事件 recorder。
|
|
77
|
+
- App 进程内 SQLite 查询:业务方提供 db 路径规则、SQL 和参数,toolkit 只返回 cursor rows。
|
|
47
78
|
- `runStep` / `runStepLoose` / `pushSuccess` / `pushFailed`。
|
|
48
79
|
- 通用错误码和失败 dataset 兜底结构。
|
|
49
80
|
|
package/entrys/node.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Launch } from '../src/launch.js';
|
|
|
2
2
|
import { ApifyKit } from '../src/apify-kit.js';
|
|
3
3
|
import { Device } from '../src/device.js';
|
|
4
4
|
import { Frida } from '../src/frida-client.js';
|
|
5
|
+
import { Share } from '../src/share.js';
|
|
5
6
|
import * as Constants from '../src/constants.js';
|
|
6
7
|
import * as Errors from '../src/errors.js';
|
|
7
8
|
import { Logger } from '../src/logger.js';
|
|
@@ -16,6 +17,7 @@ export const useAndroidToolKit = () => {
|
|
|
16
17
|
ApifyKit,
|
|
17
18
|
Device,
|
|
18
19
|
Frida,
|
|
20
|
+
Share,
|
|
19
21
|
Constants,
|
|
20
22
|
Errors,
|
|
21
23
|
Logger,
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
export interface AndroidContext {
|
|
2
|
+
adbPath?: string;
|
|
3
|
+
fridaPath?: string;
|
|
4
|
+
fridaServerPath?: string;
|
|
5
|
+
fridaWebViewEventAgentScript?: string;
|
|
6
|
+
serial: string;
|
|
7
|
+
packageName: string;
|
|
8
|
+
query: string;
|
|
9
|
+
runId?: string;
|
|
10
|
+
[key: string]: any;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface AndroidActorShareInfo {
|
|
14
|
+
mode: 'clipboard' | 'custom';
|
|
15
|
+
prefix: string;
|
|
16
|
+
xurl: Array<string | string[]>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AndroidActorInfoItem {
|
|
20
|
+
key: string;
|
|
21
|
+
name: string;
|
|
22
|
+
icon: string;
|
|
23
|
+
share: AndroidActorShareInfo;
|
|
24
|
+
[key: string]: any;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AndroidConstantsModule {
|
|
28
|
+
Code: Record<string, number>;
|
|
29
|
+
Status: Record<string, string>;
|
|
30
|
+
ActorInfo: Record<string, AndroidActorInfoItem>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AndroidCrawlerErrorInfo {
|
|
34
|
+
message: string;
|
|
35
|
+
code?: number;
|
|
36
|
+
context?: Record<string, any>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AndroidCrawlerError extends Error {
|
|
40
|
+
code: number;
|
|
41
|
+
context?: Record<string, any>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AndroidErrorsModule {
|
|
45
|
+
CrawlerError: new (info: AndroidCrawlerErrorInfo) => AndroidCrawlerError;
|
|
46
|
+
serializeError(error: unknown): Record<string, any>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AndroidLogger {
|
|
50
|
+
debug(message: string, meta?: Record<string, any>): void;
|
|
51
|
+
info(message: string, meta?: Record<string, any>): void;
|
|
52
|
+
warn(message: string, meta?: Record<string, any>): void;
|
|
53
|
+
success(message: string, meta?: Record<string, any>): void;
|
|
54
|
+
fail(message: string, error?: unknown): void;
|
|
55
|
+
start(message: string, meta?: Record<string, any>): void;
|
|
56
|
+
duration(startedAt: number): string;
|
|
57
|
+
createLogger(scope: string): AndroidLogger;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface AndroidDeviceModule {
|
|
61
|
+
adbShell(ctx: AndroidContext, args: string[], options?: Record<string, any>): Promise<string>;
|
|
62
|
+
adbExec(ctx: AndroidContext, args: string[], options?: Record<string, any>): Promise<string>;
|
|
63
|
+
forceStopApp(ctx: AndroidContext, packageName?: string): Promise<void>;
|
|
64
|
+
startActivity(ctx: AndroidContext, component: string, args?: string[]): Promise<void>;
|
|
65
|
+
startLauncher(ctx: AndroidContext, packageName?: string, launcherActivity?: string): Promise<void>;
|
|
66
|
+
waitForForeground(ctx: AndroidContext, packageName?: string, timeoutMs?: number): Promise<boolean>;
|
|
67
|
+
screenSize(ctx: AndroidContext): Promise<{ width: number; height: number }>;
|
|
68
|
+
screenDensity(ctx: AndroidContext): Promise<number>;
|
|
69
|
+
overrideScreenSize(ctx: AndroidContext, width: number, height: number): Promise<void>;
|
|
70
|
+
resetScreenSize(ctx: AndroidContext): Promise<void>;
|
|
71
|
+
resetScreenDensity(ctx: AndroidContext): Promise<void>;
|
|
72
|
+
click(ctx: AndroidContext, pointOrX: any, y?: number): Promise<void>;
|
|
73
|
+
move(ctx: AndroidContext, from: { x: number; y: number }, to: { x: number; y: number }, durationMs?: number): Promise<void>;
|
|
74
|
+
pressBack(ctx: AndroidContext): Promise<void>;
|
|
75
|
+
wakeAndUnlock(ctx: AndroidContext): Promise<void>;
|
|
76
|
+
focusedActivity(ctx: AndroidContext): Promise<string>;
|
|
77
|
+
waitForActivity(ctx: AndroidContext, predicate: (focused: string) => boolean, timeoutMs?: number, intervalMs?: number): Promise<string>;
|
|
78
|
+
screenshotPng(ctx: AndroidContext): Promise<Buffer>;
|
|
79
|
+
screenshotBase64(ctx: AndroidContext): Promise<string>;
|
|
80
|
+
[key: string]: any;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface AndroidFridaModule {
|
|
84
|
+
runScript(ctx: AndroidContext, source: string, options?: Record<string, any>): Promise<any>;
|
|
85
|
+
querySQLite(ctx: AndroidContext, options?: Record<string, any>): Promise<any>;
|
|
86
|
+
startWebViewEventRecorder(ctx: AndroidContext, config?: Record<string, any>, options?: Record<string, any>): Promise<any>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface AndroidShareScreenCompressionOptions {
|
|
90
|
+
enabled?: boolean;
|
|
91
|
+
maxBytes?: number;
|
|
92
|
+
maxBase64Bytes?: number;
|
|
93
|
+
type?: 'jpeg' | 'jpg';
|
|
94
|
+
outputType?: 'jpeg' | 'jpg';
|
|
95
|
+
quality?: number;
|
|
96
|
+
minQuality?: number;
|
|
97
|
+
minScale?: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface AndroidShareScreenCaptureOptions {
|
|
101
|
+
Frida?: AndroidFridaModule;
|
|
102
|
+
frida?: AndroidFridaModule;
|
|
103
|
+
actorInfo?: string | AndroidActorInfoItem;
|
|
104
|
+
actor?: string | AndroidActorInfoItem;
|
|
105
|
+
actorKey?: string | AndroidActorInfoItem;
|
|
106
|
+
restore?: boolean;
|
|
107
|
+
maxHeight?: number;
|
|
108
|
+
width?: number;
|
|
109
|
+
height?: number;
|
|
110
|
+
settleMs?: number;
|
|
111
|
+
scrollToTop?: boolean;
|
|
112
|
+
timeoutMs?: number;
|
|
113
|
+
fridaTimeoutSeconds?: number;
|
|
114
|
+
maxBytes?: number;
|
|
115
|
+
maxBase64Bytes?: number;
|
|
116
|
+
type?: 'jpeg' | 'jpg';
|
|
117
|
+
outputType?: 'jpeg' | 'jpg';
|
|
118
|
+
quality?: number;
|
|
119
|
+
minQuality?: number;
|
|
120
|
+
minScale?: number;
|
|
121
|
+
compression?: boolean | AndroidShareScreenCompressionOptions;
|
|
122
|
+
compress?: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface AndroidShareLinkCaptureOptions {
|
|
126
|
+
actorInfo?: string | AndroidActorInfoItem;
|
|
127
|
+
actor?: string | AndroidActorInfoItem;
|
|
128
|
+
actorKey?: string | AndroidActorInfoItem;
|
|
129
|
+
share?: Partial<AndroidActorShareInfo>;
|
|
130
|
+
timeoutMs?: number;
|
|
131
|
+
pollIntervalMs?: number;
|
|
132
|
+
payloadSnapshotMaxLen?: number;
|
|
133
|
+
signal?: AbortSignal;
|
|
134
|
+
performActions?: () => any | Promise<any>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface AndroidShareLinkCaptureResult {
|
|
138
|
+
link: string | null;
|
|
139
|
+
payloadText: string;
|
|
140
|
+
payloadSnapshot: string;
|
|
141
|
+
source: 'clipboard' | 'custom' | 'none' | string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface AndroidShareModule {
|
|
145
|
+
captureScreen(ctx: AndroidContext, options?: AndroidShareScreenCaptureOptions): Promise<string>;
|
|
146
|
+
captureLink(ctx: AndroidContext, options?: AndroidShareLinkCaptureOptions): Promise<AndroidShareLinkCaptureResult>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface AndroidApifyKitInstance {
|
|
150
|
+
runStep<T>(name: string, meta: any, fn: () => Promise<T> | T): Promise<T>;
|
|
151
|
+
runStepLoose<T>(name: string, meta: any, fn: () => Promise<T> | T): Promise<T>;
|
|
152
|
+
pushSuccess(data: any): Promise<void>;
|
|
153
|
+
pushFailed(error: unknown, context?: Record<string, any>): Promise<void>;
|
|
154
|
+
pushArtifact(data: any): Promise<void>;
|
|
155
|
+
hasPushed(): boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface AndroidApifyKitModule {
|
|
159
|
+
useApifyKit(options?: Record<string, any>): Promise<AndroidApifyKitInstance>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface AndroidLaunchRunHandlerArgs {
|
|
163
|
+
input: any;
|
|
164
|
+
ctx: AndroidContext;
|
|
165
|
+
kit: {
|
|
166
|
+
ApifyKit: AndroidApifyKitInstance;
|
|
167
|
+
Device: AndroidDeviceModule;
|
|
168
|
+
Frida: AndroidFridaModule;
|
|
169
|
+
Share: AndroidShareModule;
|
|
170
|
+
Logger: AndroidLogger;
|
|
171
|
+
};
|
|
172
|
+
ApifyKit: AndroidApifyKitInstance;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface AndroidLaunchModule {
|
|
176
|
+
run(handler: (args: AndroidLaunchRunHandlerArgs) => Promise<void> | void, options?: Record<string, any>): Promise<void>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface AndroidToolkit {
|
|
180
|
+
Launch: AndroidLaunchModule;
|
|
181
|
+
ApifyKit: AndroidApifyKitModule;
|
|
182
|
+
Device: AndroidDeviceModule;
|
|
183
|
+
Frida: AndroidFridaModule;
|
|
184
|
+
Share: AndroidShareModule;
|
|
185
|
+
Constants: AndroidConstantsModule;
|
|
186
|
+
Errors: AndroidErrorsModule;
|
|
187
|
+
Logger: AndroidLogger;
|
|
188
|
+
Context: Record<string, any>;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function useAndroidToolKit(): AndroidToolkit;
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skrillex1224/android-toolkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.js",
|
|
6
|
+
"types": "index.d.ts",
|
|
6
7
|
"exports": {
|
|
7
8
|
".": "./index.js"
|
|
8
9
|
},
|
|
9
10
|
"files": [
|
|
10
11
|
"index.js",
|
|
12
|
+
"index.d.ts",
|
|
11
13
|
"entrys",
|
|
12
14
|
"src",
|
|
13
15
|
"scripts",
|
|
@@ -15,10 +17,13 @@
|
|
|
15
17
|
],
|
|
16
18
|
"scripts": {
|
|
17
19
|
"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",
|
|
20
|
+
"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/share.js && node --check src/internals/compression.js && node --check src/internals/frida/webview_event_agent.js",
|
|
19
21
|
"test": "node --test test/*.test.js"
|
|
20
22
|
},
|
|
21
23
|
"engines": {
|
|
22
24
|
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"jimp": "^1.6.1"
|
|
23
28
|
}
|
|
24
29
|
}
|
package/src/apify-kit.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import {Code, Status} from './constants.js';
|
|
4
4
|
import {createAndroidContext} from './context.js';
|
|
5
5
|
import {CrawlerError, serializeError} from './errors.js';
|
|
6
|
-
import {Logger
|
|
6
|
+
import {Logger} from './logger.js';
|
|
7
7
|
|
|
8
8
|
let instance = null;
|
|
9
9
|
|
|
@@ -30,46 +30,25 @@ async function createApifyKit(options = {}) {
|
|
|
30
30
|
* - step 是中文步骤名;
|
|
31
31
|
* - target 位置保留给 device/session,便于未来接入云真机 live view;
|
|
32
32
|
* - actionFn 是真正业务动作;
|
|
33
|
-
* - retry 支持 direct / before hook;
|
|
34
33
|
* - failActor=true 时会先 pushFailed,再把错误继续抛给 Launch。
|
|
35
34
|
*/
|
|
36
35
|
async runStep(step, target, actionFn, options = {}) {
|
|
37
36
|
if (target) lastDevice = target;
|
|
38
|
-
const {failActor = true
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
Logger.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
}
|
|
37
|
+
const {failActor = true} = options;
|
|
38
|
+
const startedAt = Date.now();
|
|
39
|
+
|
|
40
|
+
Logger.start(`[Step] ${step}`, stepDetail({target, failActor}));
|
|
41
|
+
try {
|
|
42
|
+
const result = await actionFn();
|
|
43
|
+
Logger.success(`[Step] ${step}`, {duration: Logger.duration(startedAt)});
|
|
44
|
+
return result;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
Logger.fail(`[Step] ${step} duration=${Logger.duration(startedAt)}`, error);
|
|
47
|
+
if (failActor) {
|
|
48
|
+
await this.pushFailed(error, {step}, {target});
|
|
63
49
|
}
|
|
50
|
+
throw error;
|
|
64
51
|
}
|
|
65
|
-
|
|
66
|
-
if (failActor) {
|
|
67
|
-
await this.pushFailed(lastError, {
|
|
68
|
-
step,
|
|
69
|
-
retryAttempts: retryTimes
|
|
70
|
-
}, {target});
|
|
71
|
-
}
|
|
72
|
-
throw lastError;
|
|
73
52
|
},
|
|
74
53
|
|
|
75
54
|
/**
|
|
@@ -92,15 +71,34 @@ async function createApifyKit(options = {}) {
|
|
|
92
71
|
* code/status/timestamp/data 外壳。这样 commander/transformer 看到的是同类结构。
|
|
93
72
|
*/
|
|
94
73
|
async pushSuccess(data = {}, options = {}) {
|
|
74
|
+
const dataSummary = summarizeDatasetData(data);
|
|
95
75
|
pushed = true;
|
|
96
|
-
|
|
76
|
+
appendOutput(io.outputPath, {
|
|
97
77
|
code: Code.Success,
|
|
98
78
|
status: Status.Success,
|
|
99
79
|
timestamp: new Date().toISOString(),
|
|
100
80
|
data: sanitizeData(data),
|
|
101
81
|
...(options.meta ? {meta: sanitizeData(options.meta)} : {})
|
|
102
|
-
}
|
|
103
|
-
Logger.success('pushSuccess',
|
|
82
|
+
});
|
|
83
|
+
Logger.success('pushSuccess', {
|
|
84
|
+
outputPath: io.outputPath,
|
|
85
|
+
...dataSummary
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 写补充 artifact dataset。
|
|
91
|
+
*
|
|
92
|
+
* 这类数据不是最终业务结果,只用于给 commander 的 ext/base64 链路消费,
|
|
93
|
+
* 例如 screenshotBase64、conversationShareLink。它不改变 hasPushed()
|
|
94
|
+
* 状态,避免 artifact 写入后主流程失败时吞掉失败 dataset。
|
|
95
|
+
*/
|
|
96
|
+
async pushArtifact(data = {}) {
|
|
97
|
+
appendOutput(io.outputPath, sanitizeData(data));
|
|
98
|
+
Logger.success('pushArtifact', {
|
|
99
|
+
outputPath: io.outputPath,
|
|
100
|
+
keys: Object.keys(data || {}).join(',')
|
|
101
|
+
});
|
|
104
102
|
},
|
|
105
103
|
|
|
106
104
|
/**
|
|
@@ -115,15 +113,19 @@ async function createApifyKit(options = {}) {
|
|
|
115
113
|
: CrawlerError.from(error, {code: Code.UnknownError, context: meta});
|
|
116
114
|
|
|
117
115
|
pushed = true;
|
|
118
|
-
|
|
116
|
+
appendOutput(io.outputPath, {
|
|
119
117
|
code: normalized.code,
|
|
120
118
|
status: Status.Failed,
|
|
121
119
|
error: serializeError(normalized),
|
|
122
120
|
meta: sanitizeData(meta),
|
|
123
121
|
context: sanitizeData(normalized.context),
|
|
124
122
|
timestamp: new Date().toISOString()
|
|
125
|
-
}
|
|
126
|
-
Logger.success('pushFailed',
|
|
123
|
+
});
|
|
124
|
+
Logger.success('pushFailed', {
|
|
125
|
+
outputPath: io.outputPath,
|
|
126
|
+
code: normalized.code,
|
|
127
|
+
message: normalized.message
|
|
128
|
+
});
|
|
127
129
|
},
|
|
128
130
|
|
|
129
131
|
hasPushed() {
|
|
@@ -147,12 +149,50 @@ export const ApifyKit = {
|
|
|
147
149
|
useApifyKit
|
|
148
150
|
};
|
|
149
151
|
|
|
152
|
+
function appendOutput(outputPath, item) {
|
|
153
|
+
const items = readOutputArray(outputPath);
|
|
154
|
+
items.push(item);
|
|
155
|
+
fs.writeFileSync(outputPath, JSON.stringify(items, null, 2) + '\n', 'utf8');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function readOutputArray(outputPath) {
|
|
159
|
+
if (!fs.existsSync(outputPath)) return [];
|
|
160
|
+
const raw = fs.readFileSync(outputPath, 'utf8').trim();
|
|
161
|
+
if (!raw) return [];
|
|
162
|
+
const parsed = JSON.parse(raw);
|
|
163
|
+
if (!Array.isArray(parsed)) {
|
|
164
|
+
throw new Error(`Android output must be a JSON array: ${outputPath}`);
|
|
165
|
+
}
|
|
166
|
+
return parsed;
|
|
167
|
+
}
|
|
168
|
+
|
|
150
169
|
function requiredOption(value, name) {
|
|
151
170
|
const clean = String(value || '').trim();
|
|
152
171
|
if (!clean) throw new Error(`ApifyKit.useApifyKit requires ${name}`);
|
|
153
172
|
return clean;
|
|
154
173
|
}
|
|
155
174
|
|
|
175
|
+
function stepDetail({target, failActor}) {
|
|
176
|
+
const detail = {failActor};
|
|
177
|
+
if (!target) return detail;
|
|
178
|
+
if (typeof target === 'string') detail.target = target;
|
|
179
|
+
else if (typeof target === 'object') {
|
|
180
|
+
detail.serial = target.serial;
|
|
181
|
+
detail.packageName = target.packageName;
|
|
182
|
+
detail.runId = target.runId;
|
|
183
|
+
}
|
|
184
|
+
return detail;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function summarizeDatasetData(data) {
|
|
188
|
+
const summary = {};
|
|
189
|
+
if (typeof data?.answer === 'string') summary.answerChars = data.answer.length;
|
|
190
|
+
if (Array.isArray(data?.sources)) summary.sourceCount = data.sources.length;
|
|
191
|
+
if (data?.imMessage?.message_id) summary.messageId = data.imMessage.message_id;
|
|
192
|
+
if (data?.imMessage?.local_message_id) summary.localMessageId = data.imMessage.local_message_id;
|
|
193
|
+
return summary;
|
|
194
|
+
}
|
|
195
|
+
|
|
156
196
|
function sanitizeData(value, depth = 0, seen = new WeakSet()) {
|
|
157
197
|
if (depth > 8) return '[MaxDepth]';
|
|
158
198
|
if (value == null) return value;
|
package/src/constants.js
CHANGED
|
@@ -15,10 +15,61 @@ export const Code = {
|
|
|
15
15
|
AutomationFailed: 30010005,
|
|
16
16
|
RunnerFailed: 30010006,
|
|
17
17
|
AppiumUnavailable: 30010007,
|
|
18
|
-
FridaUnavailable: 30010008
|
|
18
|
+
FridaUnavailable: 30010008,
|
|
19
|
+
AppNotInstalled: 30010009
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
export const Status = {
|
|
22
23
|
Success: 'SUCCESS',
|
|
23
24
|
Failed: 'FAILED'
|
|
24
25
|
};
|
|
26
|
+
|
|
27
|
+
const normalizePrefix = (value) => String(value || '').trim();
|
|
28
|
+
|
|
29
|
+
const normalizeShare = (value) => {
|
|
30
|
+
const raw = value && typeof value === 'object' ? value : {};
|
|
31
|
+
const modeRaw = String(raw.mode || 'clipboard').trim().toLowerCase();
|
|
32
|
+
const mode = ['clipboard', 'custom'].includes(modeRaw) ? modeRaw : 'clipboard';
|
|
33
|
+
return {
|
|
34
|
+
mode,
|
|
35
|
+
prefix: normalizePrefix(raw.prefix),
|
|
36
|
+
xurl: Array.isArray(raw.xurl) ? raw.xurl : []
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const createActorInfo = (info) => {
|
|
41
|
+
const key = String(info?.key || '').trim();
|
|
42
|
+
const share = normalizeShare(info?.share);
|
|
43
|
+
return {
|
|
44
|
+
...info,
|
|
45
|
+
key,
|
|
46
|
+
share,
|
|
47
|
+
get icon() {
|
|
48
|
+
if (info.icon) return info.icon;
|
|
49
|
+
return buildIcon(this.key);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const buildIcon = (key) => `https://static.heartbitai.com/general/actors/${key}.png`;
|
|
55
|
+
|
|
56
|
+
export const ActorInfo = {
|
|
57
|
+
'doubao.android': createActorInfo({
|
|
58
|
+
key: 'doubao.android',
|
|
59
|
+
name: '豆包 Android',
|
|
60
|
+
share: {
|
|
61
|
+
mode: 'clipboard',
|
|
62
|
+
prefix: 'https://www.doubao.com/thread/',
|
|
63
|
+
xurl: []
|
|
64
|
+
}
|
|
65
|
+
}),
|
|
66
|
+
'wechat.android': createActorInfo({
|
|
67
|
+
key: 'wechat.android',
|
|
68
|
+
name: '微信 Android',
|
|
69
|
+
share: {
|
|
70
|
+
mode: 'clipboard',
|
|
71
|
+
prefix: '',
|
|
72
|
+
xurl: []
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
};
|
package/src/context.js
CHANGED
|
@@ -8,7 +8,6 @@ const toolkitRoot = path.resolve(__dirname, '..');
|
|
|
8
8
|
export const Context = {
|
|
9
9
|
createAndroidContext,
|
|
10
10
|
firstNonEmpty,
|
|
11
|
-
trimRight,
|
|
12
11
|
toolkitPath
|
|
13
12
|
};
|
|
14
13
|
|
|
@@ -16,7 +15,7 @@ export const Context = {
|
|
|
16
15
|
* 创建 Android actor 的运行上下文。
|
|
17
16
|
*
|
|
18
17
|
* 这里刻意只接受 input/defaults 参数,不读取环境变量,也不写任何业务默认值:
|
|
19
|
-
* -
|
|
18
|
+
* - 具体 app 包名由后端注入 input.runtime.device.packageName;
|
|
20
19
|
* - 不知道当前 visitor 要 attach 哪些 WebView 类;
|
|
21
20
|
*
|
|
22
21
|
* ADB/Frida 是机器环境,不是业务 input。toolkit 只从 PATH 查找命令;
|
|
@@ -32,7 +31,7 @@ export function createAndroidContext(input = {}, defaults = {}) {
|
|
|
32
31
|
mode: firstNonEmpty(defaults.mode, 'ai_search'),
|
|
33
32
|
serial: firstNonEmpty(device.serial, defaults.serial),
|
|
34
33
|
adbPath: resolveAdbPath(defaults),
|
|
35
|
-
packageName: firstNonEmpty(
|
|
34
|
+
packageName: firstNonEmpty(device.packageName),
|
|
36
35
|
fridaPath: resolveFridaPath(defaults),
|
|
37
36
|
// Frida WebView event agent 是当前保留的唯一 Frida agent:
|
|
38
37
|
// 它 hook WebView 执行 JS 字符串的入口,业务方再从注入事件中解析数据。
|
|
@@ -40,8 +39,6 @@ export function createAndroidContext(input = {}, defaults = {}) {
|
|
|
40
39
|
defaults.fridaWebViewEventAgentScript,
|
|
41
40
|
toolkitPath('src/internals/frida/webview_event_agent.js')
|
|
42
41
|
),
|
|
43
|
-
appiumServerUrl: trimRight(firstNonEmpty(defaults.appiumServerUrl), '/'),
|
|
44
|
-
appiumSessionId: firstNonEmpty(defaults.appiumSessionId),
|
|
45
42
|
appVersion: firstNonEmpty(defaults.appVersion),
|
|
46
43
|
slotKey: firstNonEmpty(defaults.slotKey),
|
|
47
44
|
instanceId: firstNonEmpty(defaults.instanceId),
|
|
@@ -61,12 +58,6 @@ export function firstNonEmpty(...values) {
|
|
|
61
58
|
return '';
|
|
62
59
|
}
|
|
63
60
|
|
|
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
61
|
function normalizeRuntimeDevice(value) {
|
|
71
62
|
if (typeof value === 'string') return {serial: value};
|
|
72
63
|
if (value && typeof value === 'object') return value;
|