@web-auto/webauto 0.1.18 → 0.1.19

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.
Files changed (65) hide show
  1. package/README.md +122 -53
  2. package/apps/desktop-console/dist/main/index.mjs +227 -12
  3. package/apps/desktop-console/dist/renderer/index.js +237 -8
  4. package/apps/desktop-console/entry/ui-cli.mjs +282 -16
  5. package/apps/desktop-console/entry/ui-console.mjs +46 -15
  6. package/apps/webauto/entry/account.mjs +126 -27
  7. package/apps/webauto/entry/lib/account-detect.mjs +399 -9
  8. package/apps/webauto/entry/lib/account-store.mjs +201 -109
  9. package/apps/webauto/entry/lib/iflow-reply.mjs +194 -0
  10. package/apps/webauto/entry/lib/profile-policy.mjs +48 -0
  11. package/apps/webauto/entry/lib/profilepool.mjs +12 -0
  12. package/apps/webauto/entry/lib/schedule-store.mjs +29 -2
  13. package/apps/webauto/entry/lib/session-init.mjs +227 -0
  14. package/apps/webauto/entry/lib/upgrade-check.mjs +269 -0
  15. package/apps/webauto/entry/lib/xhs-unified-blocks.mjs +160 -0
  16. package/apps/webauto/entry/lib/xhs-unified-output-blocks.mjs +83 -0
  17. package/apps/webauto/entry/lib/xhs-unified-plan-blocks.mjs +55 -0
  18. package/apps/webauto/entry/lib/xhs-unified-profile-blocks.mjs +542 -0
  19. package/apps/webauto/entry/lib/xhs-unified-runtime-blocks.mjs +436 -0
  20. package/apps/webauto/entry/profilepool.mjs +56 -9
  21. package/apps/webauto/entry/smart-reply-cli.mjs +267 -0
  22. package/apps/webauto/entry/weibo-unified.mjs +84 -11
  23. package/apps/webauto/entry/xhs-orchestrate.mjs +43 -1
  24. package/apps/webauto/entry/xhs-unified.mjs +92 -997
  25. package/bin/webauto.mjs +22 -4
  26. package/dist/modules/camo-backend/src/index.js +33 -0
  27. package/dist/modules/camo-backend/src/internal/BrowserSession.js +232 -49
  28. package/dist/modules/camo-backend/src/internal/engine-manager.js +14 -13
  29. package/dist/modules/camo-backend/src/internal/ws-server.js +16 -19
  30. package/dist/modules/camo-runtime/src/utils/browser-service.mjs +38 -6
  31. package/dist/modules/workflow/blocks/EnsureSession.js +0 -8
  32. package/dist/modules/workflow/blocks/WeiboCollectFromLinksBlock.js +78 -6
  33. package/dist/modules/workflow/blocks/WeiboCollectSearchLinksBlock.js +266 -192
  34. package/dist/modules/workflow/definitions/weibo-search-workflow-v1.js +2 -0
  35. package/dist/modules/workflow/src/runner.js +2 -0
  36. package/dist/modules/xiaohongshu/app/src/blocks/ReplyInteractBlock.js +150 -37
  37. package/dist/modules/xiaohongshu/app/src/blocks/SmartReplyBlock.js +491 -0
  38. package/modules/camo-backend/src/index.ts +31 -0
  39. package/modules/camo-backend/src/internal/BrowserSession.ts +224 -53
  40. package/modules/camo-backend/src/internal/engine-manager.ts +14 -15
  41. package/modules/camo-backend/src/internal/ws-server.ts +17 -17
  42. package/modules/camo-runtime/src/autoscript/action-providers/xhs/common.mjs +12 -2
  43. package/modules/camo-runtime/src/autoscript/action-providers/xhs/persistence.mjs +57 -0
  44. package/modules/camo-runtime/src/autoscript/action-providers/xhs.mjs +2475 -243
  45. package/modules/camo-runtime/src/autoscript/runtime.mjs +35 -30
  46. package/modules/camo-runtime/src/autoscript/xhs-unified-template.mjs +80 -443
  47. package/modules/camo-runtime/src/container/runtime-core/checkpoint.mjs +39 -6
  48. package/modules/camo-runtime/src/container/runtime-core/operations/index.mjs +206 -39
  49. package/modules/camo-runtime/src/container/runtime-core/operations/tab-pool.mjs +0 -79
  50. package/modules/camo-runtime/src/container/runtime-core/operations/viewport.mjs +46 -0
  51. package/modules/camo-runtime/src/utils/browser-service.mjs +41 -6
  52. package/modules/camo-runtime/src/utils/js-policy.mjs +28 -0
  53. package/modules/workflow/blocks/EnsureSession.ts +0 -4
  54. package/modules/workflow/blocks/WeiboCollectFromLinksBlock.ts +81 -6
  55. package/modules/workflow/blocks/WeiboCollectSearchLinksBlock.ts +316 -0
  56. package/modules/workflow/definitions/weibo-search-workflow-v1.ts +2 -0
  57. package/modules/workflow/src/runner.ts +2 -0
  58. package/modules/xiaohongshu/app/src/blocks/ReplyInteractBlock.ts +198 -53
  59. package/modules/xiaohongshu/app/src/blocks/SmartReplyBlock.ts +706 -0
  60. package/package.json +2 -2
  61. package/modules/camo-runtime/src/autoscript/action-providers/xhs/comments.mjs +0 -498
  62. package/modules/camo-runtime/src/autoscript/action-providers/xhs/detail.mjs +0 -181
  63. package/modules/camo-runtime/src/autoscript/action-providers/xhs/interaction.mjs +0 -691
  64. package/modules/camo-runtime/src/autoscript/action-providers/xhs/search.mjs +0 -388
  65. package/modules/camo-runtime/src/container/runtime-core/operations/selector-scripts.mjs +0 -135
@@ -42,6 +42,36 @@ interface RecordingState {
42
42
  lastError: string | null;
43
43
  }
44
44
 
45
+ type NavigationWaitUntil = 'load' | 'domcontentloaded' | 'networkidle' | 'commit';
46
+
47
+ function resolveInputActionTimeoutMs(): number {
48
+ const raw = Number(process.env.CAMO_INPUT_ACTION_TIMEOUT_MS ?? process.env.CAMO_API_TIMEOUT_MS ?? 30000);
49
+ return Math.max(1000, Number.isFinite(raw) ? raw : 30000);
50
+ }
51
+
52
+ function resolveNavigationWaitUntil(): NavigationWaitUntil {
53
+ const raw = String(process.env.CAMO_NAV_WAIT_UNTIL ?? 'commit').trim().toLowerCase();
54
+ if (raw === 'load') return 'load';
55
+ if (raw === 'domcontentloaded' || raw === 'dom') return 'domcontentloaded';
56
+ if (raw === 'networkidle') return 'networkidle';
57
+ return 'commit';
58
+ }
59
+
60
+ function resolveInputActionMaxAttempts(): number {
61
+ const raw = Number(process.env.CAMO_INPUT_ACTION_MAX_ATTEMPTS ?? 2);
62
+ return Math.max(1, Math.min(3, Number.isFinite(raw) ? Math.floor(raw) : 2));
63
+ }
64
+
65
+ function resolveInputRecoveryDelayMs(): number {
66
+ const raw = Number(process.env.CAMO_INPUT_RECOVERY_DELAY_MS ?? 120);
67
+ return Math.max(0, Number.isFinite(raw) ? Math.floor(raw) : 120);
68
+ }
69
+
70
+ function resolveInputRecoveryBringToFrontTimeoutMs(): number {
71
+ const raw = Number(process.env.CAMO_INPUT_RECOVERY_BRING_TO_FRONT_TIMEOUT_MS ?? 800);
72
+ return Math.max(100, Number.isFinite(raw) ? Math.floor(raw) : 800);
73
+ }
74
+
45
75
  export class BrowserSession {
46
76
  private browser?: Browser;
47
77
  private context?: BrowserContext;
@@ -56,6 +86,8 @@ export class BrowserSession {
56
86
  private bridgedPages = new WeakSet<Page>();
57
87
  private recorderBridgePages = new WeakSet<Page>();
58
88
  private lastViewport: { width: number; height: number } | null = null;
89
+ private followWindowViewport = false;
90
+ private inputActionTail: Promise<void> = Promise.resolve();
59
91
  private fingerprint: any = null;
60
92
  private recordingStream: fs.WriteStream | null = null;
61
93
  private recording: RecordingState = {
@@ -129,15 +161,26 @@ export class BrowserSession {
129
161
  userAgent: fingerprint.userAgent?.substring(0, 50) + '...',
130
162
  });
131
163
 
132
- const viewport = this.options.viewport || { width: 3840, height: 2046 };
164
+ const fallbackViewport = { width: 1440, height: 1100 };
165
+ const explicitViewport = this.options.viewport
166
+ && Number(this.options.viewport.width) > 0
167
+ && Number(this.options.viewport.height) > 0
168
+ ? {
169
+ width: Math.floor(Number(this.options.viewport.width)),
170
+ height: Math.floor(Number(this.options.viewport.height)),
171
+ }
172
+ : null;
173
+ const viewport = explicitViewport || fingerprint?.viewport || fallbackViewport;
174
+ const headless = !!this.options.headless;
175
+ this.followWindowViewport = !headless;
133
176
  const deviceScaleFactor = this.resolveDeviceScaleFactor();
134
177
 
135
178
  // 使用 EngineManager 启动上下文(Chromium 已移除,仅支持 Camoufox)
136
179
  this.context = await launchEngineContext({
137
180
  engine,
138
- headless: !!this.options.headless,
181
+ headless,
139
182
  profileDir: this.profileDir,
140
- viewport: fingerprint?.viewport || viewport,
183
+ viewport,
141
184
  userAgent: fingerprint?.userAgent,
142
185
  locale: 'zh-CN',
143
186
  timezoneId: fingerprint?.timezoneId || 'Asia/Shanghai',
@@ -148,7 +191,9 @@ export class BrowserSession {
148
191
 
149
192
  // NOTE: deviceScaleFactor override was Chromium-only (CDP). Chromium removed.
150
193
 
151
- this.lastViewport = { width: viewport.width, height: viewport.height };
194
+ this.lastViewport = this.followWindowViewport
195
+ ? null
196
+ : { width: viewport.width, height: viewport.height };
152
197
  this.browser = this.context.browser();
153
198
  this.browser.on('disconnected', () => this.notifyExit());
154
199
  this.context.on('close', () => this.notifyExit());
@@ -158,6 +203,9 @@ export class BrowserSession {
158
203
 
159
204
  this.setupPageHooks(this.page);
160
205
  this.context.on('page', (p) => this.setupPageHooks(p));
206
+ if (this.followWindowViewport) {
207
+ await this.refreshViewportFromWindow(this.page).catch(() => {});
208
+ }
161
209
 
162
210
  if (initialUrl) {
163
211
  await this.goto(initialUrl);
@@ -863,6 +911,10 @@ export class BrowserSession {
863
911
  }
864
912
 
865
913
  private async ensurePageViewport(page: Page): Promise<void> {
914
+ if (this.followWindowViewport) {
915
+ await this.refreshViewportFromWindow(page).catch(() => {});
916
+ return;
917
+ }
866
918
  if (!this.lastViewport) return;
867
919
  const current = page.viewportSize();
868
920
  if (current && current.width === this.lastViewport.width && current.height === this.lastViewport.height) {
@@ -876,6 +928,31 @@ export class BrowserSession {
876
928
  await this.syncDeviceScaleFactor(page, { ...this.lastViewport });
877
929
  }
878
930
 
931
+ private async readWindowInnerSize(page: Page): Promise<{ width: number; height: number } | null> {
932
+ try {
933
+ const metrics = await page.evaluate(() => ({
934
+ width: Math.floor(Number(window.innerWidth || 0)),
935
+ height: Math.floor(Number(window.innerHeight || 0)),
936
+ }));
937
+ const width = Number(metrics?.width);
938
+ const height = Number(metrics?.height);
939
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
940
+ if (width < 300 || height < 200) return null;
941
+ return { width, height };
942
+ } catch {
943
+ return null;
944
+ }
945
+ }
946
+
947
+ private async refreshViewportFromWindow(page: Page): Promise<void> {
948
+ const inner = await this.readWindowInnerSize(page);
949
+ if (!inner) return;
950
+ this.lastViewport = {
951
+ width: Math.max(800, Math.floor(inner.width)),
952
+ height: Math.max(700, Math.floor(inner.height)),
953
+ };
954
+ }
955
+
879
956
  private ensureContext(): BrowserContext {
880
957
  if (!this.context) {
881
958
  throw new Error('browser context not ready');
@@ -887,6 +964,11 @@ export class BrowserSession {
887
964
  const ctx = this.ensureContext();
888
965
  const existing = this.getActivePage();
889
966
  if (existing) {
967
+ try {
968
+ await this.ensurePageViewport(existing);
969
+ } catch {
970
+ /* ignore */
971
+ }
890
972
  return existing;
891
973
  }
892
974
  this.page = await ctx.newPage();
@@ -913,9 +995,10 @@ export class BrowserSession {
913
995
 
914
996
  async goBack(): Promise<{ ok: boolean; url: string }> {
915
997
  const page = await this.ensurePrimaryPage();
998
+ const waitUntil = resolveNavigationWaitUntil();
916
999
  try {
917
1000
  const res = await page
918
- .goBack({ waitUntil: 'domcontentloaded' })
1001
+ .goBack({ waitUntil })
919
1002
  .catch((): null => null);
920
1003
  await ensurePageRuntime(page, true).catch(() => {});
921
1004
  this.lastKnownUrl = page.url();
@@ -1001,7 +1084,7 @@ export class BrowserSession {
1001
1084
  /* ignore */
1002
1085
  }
1003
1086
  if (url) {
1004
- await page.goto(url, { waitUntil: 'domcontentloaded' });
1087
+ await page.goto(url, { waitUntil: resolveNavigationWaitUntil() });
1005
1088
  await ensurePageRuntime(page);
1006
1089
  this.lastKnownUrl = url;
1007
1090
  }
@@ -1149,7 +1232,7 @@ export class BrowserSession {
1149
1232
 
1150
1233
  async goto(url: string): Promise<void> {
1151
1234
  const page = await this.ensurePrimaryPage();
1152
- await page.goto(url, { waitUntil: 'domcontentloaded' });
1235
+ await page.goto(url, { waitUntil: resolveNavigationWaitUntil() });
1153
1236
  await ensurePageRuntime(page);
1154
1237
  this.lastKnownUrl = url;
1155
1238
  }
@@ -1159,19 +1242,8 @@ export class BrowserSession {
1159
1242
  return page.screenshot({ fullPage });
1160
1243
  }
1161
1244
 
1162
- async click(selector: string): Promise<void> {
1163
- const page = await this.ensurePrimaryPage();
1164
- await page.click(selector, { timeout: 20000 });
1165
- }
1166
-
1167
- async fill(selector: string, text: string): Promise<void> {
1168
- const page = await this.ensurePrimaryPage();
1169
- await page.fill(selector, text, { timeout: 20000 });
1170
- }
1171
-
1172
1245
  private async ensureInputReady(page: Page): Promise<void> {
1173
1246
  if (this.options.headless) return;
1174
- if (os.platform() !== 'win32') return;
1175
1247
  let needsFocus = false;
1176
1248
  try {
1177
1249
  const state = await page.evaluate(() => ({
@@ -1193,30 +1265,112 @@ export class BrowserSession {
1193
1265
  }
1194
1266
  }
1195
1267
 
1268
+ private async withInputActionTimeout<T>(label: string, run: () => Promise<T>): Promise<T> {
1269
+ const timeoutMs = resolveInputActionTimeoutMs();
1270
+ let timer: NodeJS.Timeout | null = null;
1271
+ try {
1272
+ return await Promise.race<T>([
1273
+ run(),
1274
+ new Promise<T>((_resolve, reject) => {
1275
+ timer = setTimeout(() => {
1276
+ reject(new Error(`${label} timed out after ${timeoutMs}ms`));
1277
+ }, timeoutMs);
1278
+ }),
1279
+ ]);
1280
+ } finally {
1281
+ if (timer) clearTimeout(timer);
1282
+ }
1283
+ }
1284
+
1285
+ private async recoverInputPipeline(page: Page): Promise<Page> {
1286
+ const bringToFrontTimeoutMs = resolveInputRecoveryBringToFrontTimeoutMs();
1287
+ let bringToFrontTimer: NodeJS.Timeout | null = null;
1288
+ try {
1289
+ await Promise.race<void>([
1290
+ page.bringToFront(),
1291
+ new Promise<void>((_resolve, reject) => {
1292
+ bringToFrontTimer = setTimeout(() => {
1293
+ reject(new Error(`input recovery bringToFront timed out after ${bringToFrontTimeoutMs}ms`));
1294
+ }, bringToFrontTimeoutMs);
1295
+ }),
1296
+ ]);
1297
+ } catch {
1298
+ // Best-effort recovery only.
1299
+ } finally {
1300
+ if (bringToFrontTimer) clearTimeout(bringToFrontTimer);
1301
+ }
1302
+ const delayMs = resolveInputRecoveryDelayMs();
1303
+ if (delayMs > 0) {
1304
+ try {
1305
+ await page.waitForTimeout(delayMs);
1306
+ } catch {
1307
+ // Best-effort recovery only.
1308
+ }
1309
+ }
1310
+ return page;
1311
+ }
1312
+
1313
+ private async runInputAction<T>(page: Page, label: string, run: (activePage: Page) => Promise<T>): Promise<T> {
1314
+ const maxAttempts = resolveInputActionMaxAttempts();
1315
+ let lastError: unknown = null;
1316
+ let activePage = page;
1317
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
1318
+ try {
1319
+ return await this.withInputActionTimeout(`${label} (attempt ${attempt}/${maxAttempts})`, () => run(activePage));
1320
+ } catch (error) {
1321
+ lastError = error;
1322
+ if (attempt >= maxAttempts) break;
1323
+ activePage = await this.recoverInputPipeline(activePage);
1324
+ }
1325
+ }
1326
+ if (lastError instanceof Error) throw lastError;
1327
+ throw new Error(`${label} failed`);
1328
+ }
1329
+
1330
+ private async withInputActionLock<T>(run: () => Promise<T>): Promise<T> {
1331
+ const previous = this.inputActionTail;
1332
+ let release: (() => void) | null = null;
1333
+ this.inputActionTail = new Promise<void>((resolve) => {
1334
+ release = resolve;
1335
+ });
1336
+ await previous.catch(() => {});
1337
+ try {
1338
+ return await run();
1339
+ } finally {
1340
+ if (release) release();
1341
+ }
1342
+ }
1343
+
1196
1344
  /**
1197
1345
  * 基于屏幕坐标的系统级鼠标点击(Playwright 原生)
1198
1346
  * @param opts 屏幕坐标及点击选项
1199
1347
  */
1200
1348
  async mouseClick(opts: { x: number; y: number; button?: 'left' | 'right' | 'middle'; clicks?: number; delay?: number }): Promise<void> {
1201
1349
  const page = await this.ensurePrimaryPage();
1202
- await this.ensureInputReady(page);
1203
- const { x, y, button = 'left', clicks = 1, delay = 50 } = opts;
1204
-
1205
- // 移动鼠标到目标位置(模拟轨迹)
1206
- await page.mouse.move(x, y, { steps: 3 });
1207
-
1208
- // 执行点击(支持多次、间隔随机抖动)
1209
- for (let i = 0; i < clicks; i++) {
1210
- if (i > 0) {
1211
- // 多次点击间隔 100-200ms
1212
- await new Promise(r => setTimeout(r, 100 + Math.random() * 100));
1350
+ await this.withInputActionLock(async () => {
1351
+ await this.runInputAction(page, 'input:ready', (activePage) => this.ensureInputReady(activePage));
1352
+ const { x, y, button = 'left', clicks = 1, delay = 50 } = opts;
1353
+
1354
+ // Avoid page.mouse.click() composite hangs by using explicit down/up sequence.
1355
+ for (let i = 0; i < clicks; i++) {
1356
+ if (i > 0) {
1357
+ // 多次点击间隔 100-200ms
1358
+ await new Promise(r => setTimeout(r, 100 + Math.random() * 100));
1359
+ }
1360
+ await this.runInputAction(page, 'mouse:move(pre-click)', (activePage) => activePage.mouse.move(x, y, { steps: 1 }));
1361
+ await this.runInputAction(page, 'mouse:down', (activePage) => activePage.mouse.down({
1362
+ button,
1363
+ clickCount: 1,
1364
+ }));
1365
+ if (delay > 0) {
1366
+ await page.waitForTimeout(delay);
1367
+ }
1368
+ await this.runInputAction(page, 'mouse:up', (activePage) => activePage.mouse.up({
1369
+ button,
1370
+ clickCount: 1,
1371
+ }));
1213
1372
  }
1214
- await page.mouse.click(x, y, {
1215
- button,
1216
- clickCount: 1,
1217
- delay // 按键间隔
1218
- });
1219
- }
1373
+ });
1220
1374
  }
1221
1375
 
1222
1376
  /**
@@ -1225,10 +1379,11 @@ export class BrowserSession {
1225
1379
  */
1226
1380
  async mouseMove(opts: { x: number; y: number; steps?: number }): Promise<void> {
1227
1381
  const page = await this.ensurePrimaryPage();
1228
- await this.ensureInputReady(page);
1229
- const { x, y, steps = 3 } = opts;
1230
-
1231
- await page.mouse.move(x, y, { steps });
1382
+ await this.withInputActionLock(async () => {
1383
+ await this.runInputAction(page, 'input:ready', (activePage) => this.ensureInputReady(activePage));
1384
+ const { x, y, steps = 3 } = opts;
1385
+ await this.runInputAction(page, 'mouse:move', (activePage) => activePage.mouse.move(x, y, { steps }));
1386
+ });
1232
1387
  }
1233
1388
 
1234
1389
  /**
@@ -1236,23 +1391,27 @@ export class BrowserSession {
1236
1391
  */
1237
1392
  async keyboardType(opts: { text: string; delay?: number; submit?: boolean }): Promise<void> {
1238
1393
  const page = await this.ensurePrimaryPage();
1239
- await this.ensureInputReady(page);
1240
- const { text, delay = 80, submit } = opts;
1394
+ await this.withInputActionLock(async () => {
1395
+ await this.runInputAction(page, 'input:ready', (activePage) => this.ensureInputReady(activePage));
1396
+ const { text, delay = 80, submit } = opts;
1241
1397
 
1242
- if (text && text.length > 0) {
1243
- await page.keyboard.type(text, { delay });
1244
- }
1398
+ if (text && text.length > 0) {
1399
+ await this.runInputAction(page, 'keyboard:type', (activePage) => activePage.keyboard.type(text, { delay }));
1400
+ }
1245
1401
 
1246
- if (submit) {
1247
- await page.keyboard.press('Enter');
1248
- }
1402
+ if (submit) {
1403
+ await this.runInputAction(page, 'keyboard:press', (activePage) => activePage.keyboard.press('Enter'));
1404
+ }
1405
+ });
1249
1406
  }
1250
1407
 
1251
1408
  async keyboardPress(opts: { key: string; delay?: number }): Promise<void> {
1252
1409
  const page = await this.ensurePrimaryPage();
1253
- await this.ensureInputReady(page);
1254
- const { key, delay } = opts;
1255
- await page.keyboard.press(key, typeof delay === 'number' ? { delay } : undefined);
1410
+ await this.withInputActionLock(async () => {
1411
+ await this.runInputAction(page, 'input:ready', (activePage) => this.ensureInputReady(activePage));
1412
+ const { key, delay } = opts;
1413
+ await this.runInputAction(page, 'keyboard:press', (activePage) => activePage.keyboard.press(key, typeof delay === 'number' ? { delay } : undefined));
1414
+ });
1256
1415
  }
1257
1416
 
1258
1417
  /**
@@ -1261,9 +1420,11 @@ export class BrowserSession {
1261
1420
  */
1262
1421
  async mouseWheel(opts: { deltaY: number; deltaX?: number }): Promise<void> {
1263
1422
  const page = await this.ensurePrimaryPage();
1264
- await this.ensureInputReady(page);
1265
- const { deltaX = 0, deltaY } = opts;
1266
- await page.mouse.wheel(Number(deltaX) || 0, Number(deltaY) || 0);
1423
+ await this.withInputActionLock(async () => {
1424
+ await this.runInputAction(page, 'input:ready', (activePage) => this.ensureInputReady(activePage));
1425
+ const { deltaX = 0, deltaY } = opts;
1426
+ await this.runInputAction(page, 'mouse:wheel', (activePage) => activePage.mouse.wheel(Number(deltaX) || 0, Number(deltaY) || 0));
1427
+ });
1267
1428
  }
1268
1429
 
1269
1430
  private async syncWindowBounds(
@@ -1384,6 +1545,16 @@ export class BrowserSession {
1384
1545
  if (!width || !height) {
1385
1546
  throw new Error('invalid_viewport_size');
1386
1547
  }
1548
+ if (this.followWindowViewport && !this.options.headless) {
1549
+ await page.evaluate(({ w, h }) => {
1550
+ try { window.resizeTo(w, h); } catch {}
1551
+ }, { w: width, h: height });
1552
+ await page.waitForTimeout(150);
1553
+ await this.refreshViewportFromWindow(page).catch(() => {});
1554
+ const next = this.lastViewport || { width, height };
1555
+ await this.maybeCenterWindow(page, next).catch(() => {});
1556
+ return next;
1557
+ }
1387
1558
  await page.setViewportSize({ width, height });
1388
1559
  await this.syncWindowBounds(page, { width, height });
1389
1560
  await this.syncDeviceScaleFactor(page, { width, height });
@@ -176,17 +176,13 @@ export async function launchEngineContext(opts: EngineLaunchOptions): Promise<Br
176
176
  const workW = Number(dm?.workWidth || physicalW || 4096);
177
177
  const workH = Number(dm?.workHeight || physicalH || 2190);
178
178
 
179
- // Use provided viewport as MAXIMUM (e.g., from fingerprint or explicit opt), default to full work area
180
- const maxViewportW = Number(opts.viewport?.width || workW);
181
- const maxViewportH = Number(opts.viewport?.height || workH);
182
-
183
- // Target: fill work area, but don't exceed explicit viewport caps if provided
184
- const targetW = maxViewportW > 0 ? Math.min(maxViewportW, workW) : workW;
185
- const targetH = maxViewportH > 0 ? Math.min(maxViewportH, workH) : workH;
186
-
187
- // Use targetW/targetH as viewport (even in headless) to match headful size
188
- const viewportW = Math.max(1440, Math.floor(Number(targetW) || 4096));
189
- const viewportH = Math.max(900, Math.floor(Number(targetH) || 2190));
179
+ // Base viewport target:
180
+ // - prefer explicit/fingerprint viewport if provided
181
+ // - otherwise use a deterministic default for stable first-run UX
182
+ const requestedW = Number(opts.viewport?.width || process.env.WEBAUTO_VIEWPORT_WIDTH || 1440);
183
+ const requestedH = Number(opts.viewport?.height || process.env.WEBAUTO_VIEWPORT_HEIGHT || 1100);
184
+ const viewportW = Math.max(900, Math.floor(Number(requestedW) || 1440));
185
+ const viewportH = Math.max(700, Math.floor(Number(requestedH) || 1100));
190
186
 
191
187
  const envHeadlessW = Number(process.env.WEBAUTO_HEADLESS_WIDTH || 0);
192
188
  const envHeadlessH = Number(process.env.WEBAUTO_HEADLESS_HEIGHT || 0);
@@ -198,9 +194,12 @@ export async function launchEngineContext(opts: EngineLaunchOptions): Promise<Br
198
194
 
199
195
  const headless = Boolean(opts.headless);
200
196
 
201
- // Final window size: maximize to fill work area (leave small margin for chrome/window decorations)
202
- const winW = headless ? headlessW : Math.max(1440, Math.floor(workW * 0.95));
203
- const winH = headless ? headlessH : Math.max(900, Math.floor(workH - 80));
197
+ // Final headful window size:
198
+ // align window with viewport by default and keep it inside available work area.
199
+ const maxHeadfulW = workW > 0 ? Math.max(900, workW - 40) : 1920;
200
+ const maxHeadfulH = workH > 0 ? Math.max(700, workH - 80) : 1200;
201
+ const winW = headless ? headlessW : Math.min(viewportW, maxHeadfulW);
202
+ const winH = headless ? headlessH : Math.min(viewportH, maxHeadfulH);
204
203
 
205
204
  // Ensure integer types for Camoufox
206
205
  const screenW = Math.floor(physicalW) | 0;
@@ -247,7 +246,7 @@ export async function launchEngineContext(opts: EngineLaunchOptions): Promise<Br
247
246
  headless,
248
247
  os: targetOS,
249
248
  window: [intWinW, intWinH],
250
- viewport: { width: headless ? headlessW : viewportW, height: headless ? headlessH : viewportH },
249
+ viewport: headless ? { width: headlessW, height: headlessH } : null,
251
250
  firefox_user_prefs,
252
251
  config,
253
252
  data_dir: opts.profileDir,
@@ -40,6 +40,19 @@ function appendLog(target: string, event: string, payload: Record<string, any> =
40
40
  const appendDomPickerLog = (event: string, payload: Record<string, any> = {}) => appendLog(domPickerLogPath, event, payload);
41
41
  const appendHighlightLog = (event: string, payload: Record<string, any> = {}) => appendLog(highlightLogPath, event, payload);
42
42
 
43
+ function legacySelectorActionDisabled(source: string, action: 'click' | 'type') {
44
+ return {
45
+ success: false,
46
+ code: 'LEGACY_ACTION_DISABLED',
47
+ error: `[${source}] legacy selector action "${action}" is disabled; use mouse:* and keyboard:* protocol actions instead`,
48
+ data: {
49
+ source,
50
+ action,
51
+ replacements: ['mouse:move', 'mouse:click', 'mouse:wheel', 'keyboard:press', 'keyboard:type'],
52
+ },
53
+ };
54
+ }
55
+
43
56
  export class BrowserWsServer {
44
57
  private wss?: WebSocketServer;
45
58
  private matcher = new ContainerMatcher();
@@ -687,10 +700,10 @@ export class BrowserWsServer {
687
700
  const op = parameters.operation_type;
688
701
  if (!op) throw new Error('operation_type required');
689
702
  if (op === 'click') {
690
- return this.handleNodeExecute(sessionId, { command_type: 'node_execute', node_type: 'click', parameters: parameters.target || parameters });
703
+ return legacySelectorActionDisabled('user_action.operation', 'click');
691
704
  }
692
705
  if (op === 'type') {
693
- return this.handleNodeExecute(sessionId, { command_type: 'node_execute', node_type: 'type', parameters: parameters.target || parameters });
706
+ return legacySelectorActionDisabled('user_action.operation', 'type');
694
707
  }
695
708
  if (op === 'scroll') {
696
709
  const session = this.options.sessionManager.getSession(sessionId);
@@ -1000,23 +1013,10 @@ export class BrowserWsServer {
1000
1013
  };
1001
1014
  }
1002
1015
  case 'click': {
1003
- const selector = parameters.selector;
1004
- if (!selector) throw new Error('Click node requires selector');
1005
- await session.click(selector);
1006
- return {
1007
- success: true,
1008
- data: { action: 'clicked', selector },
1009
- };
1016
+ return legacySelectorActionDisabled('node_execute', 'click');
1010
1017
  }
1011
1018
  case 'type': {
1012
- const selector = parameters.selector;
1013
- if (!selector) throw new Error('Type node requires selector');
1014
- const text = parameters.text ?? parameters.value ?? '';
1015
- await session.fill(selector, String(text));
1016
- return {
1017
- success: true,
1018
- data: { action: 'typed', selector, text },
1019
- };
1019
+ return legacySelectorActionDisabled('node_execute', 'type');
1020
1020
  }
1021
1021
  case 'screenshot': {
1022
1022
  const filename = parameters.filename || `screenshot_${Date.now()}.png`;
@@ -1,4 +1,5 @@
1
1
  import { callAPI } from '../../../utils/browser-service.mjs';
2
+ import { assertNoForbiddenJsAction } from '../../../utils/js-policy.mjs';
2
3
 
3
4
  export function withOperationHighlight(script, color = '#ff7a00') {
4
5
  return `(() => {
@@ -37,8 +38,17 @@ export function withOperationHighlight(script, color = '#ff7a00') {
37
38
  })()`;
38
39
  }
39
40
 
40
- export async function runEvaluateScript({ profileId, script, highlight = true }) {
41
- const wrappedScript = highlight ? withOperationHighlight(script) : script;
41
+ export async function runEvaluateScript({
42
+ profileId,
43
+ script,
44
+ highlight = true,
45
+ allowUnsafeJs = false,
46
+ }) {
47
+ const sourceScript = String(script || '');
48
+ if (!allowUnsafeJs) {
49
+ assertNoForbiddenJsAction(sourceScript, 'xhs provider evaluate');
50
+ }
51
+ const wrappedScript = highlight && allowUnsafeJs ? withOperationHighlight(sourceScript) : sourceScript;
42
52
  return callAPI('evaluate', { profileId, script: wrappedScript });
43
53
  }
44
54
 
@@ -39,6 +39,7 @@ export function resolveXhsOutputContext({
39
39
  noteId: note,
40
40
  keywordDir,
41
41
  noteDir,
42
+ linksPath: path.join(keywordDir, 'links.collected.jsonl'),
42
43
  commentsPath: path.join(noteDir, 'comments.jsonl'),
43
44
  likeStatePath: path.join(keywordDir, '.like-state.jsonl'),
44
45
  likeEvidenceDir: path.join(keywordDir, 'like-evidence', note),
@@ -93,6 +94,38 @@ function commentDedupKey(row) {
93
94
  return `${String(row?.userId || '')}:${String(row?.content || '')}`;
94
95
  }
95
96
 
97
+ function readXsecToken(url) {
98
+ const href = String(url || '').trim();
99
+ if (!href) return '';
100
+ try {
101
+ const parsed = new URL(href);
102
+ return String(parsed.searchParams.get('xsec_token') || '').trim();
103
+ } catch {
104
+ return '';
105
+ }
106
+ }
107
+
108
+ function normalizeLinkRow(row) {
109
+ const noteId = String(row?.noteId || '').trim();
110
+ const noteUrl = String(row?.noteUrl || row?.url || '').trim();
111
+ const listUrl = String(row?.listUrl || '').trim();
112
+ const xsecToken = String(row?.xsecToken || readXsecToken(noteUrl)).trim();
113
+ return {
114
+ noteId,
115
+ noteUrl,
116
+ listUrl,
117
+ xsecToken,
118
+ ts: new Date().toISOString(),
119
+ };
120
+ }
121
+
122
+ function linkDedupKey(row) {
123
+ const noteId = String(row?.noteId || '').trim();
124
+ if (noteId) return `note:${noteId}`;
125
+ const noteUrl = String(row?.noteUrl || '').trim();
126
+ return noteUrl ? `url:${noteUrl}` : '';
127
+ }
128
+
96
129
  export async function mergeCommentsJsonl({ filePath, noteId, comments = [] }) {
97
130
  const existing = await readJsonlRows(filePath);
98
131
  const seen = new Set(
@@ -122,6 +155,30 @@ export async function mergeCommentsJsonl({ filePath, noteId, comments = [] }) {
122
155
  };
123
156
  }
124
157
 
158
+ export async function mergeLinksJsonl({ filePath, links = [] }) {
159
+ const existing = await readJsonlRows(filePath);
160
+ const seen = new Set(existing.map((row) => linkDedupKey(row)).filter(Boolean));
161
+
162
+ const added = [];
163
+ for (const row of links) {
164
+ const normalized = normalizeLinkRow(row);
165
+ if (!normalized.noteUrl || !normalized.xsecToken) continue;
166
+ const key = linkDedupKey(normalized);
167
+ if (!key || seen.has(key)) continue;
168
+ seen.add(key);
169
+ added.push(normalized);
170
+ }
171
+
172
+ await appendJsonlRows(filePath, added);
173
+ return {
174
+ filePath,
175
+ added: added.length,
176
+ existing: existing.length,
177
+ total: existing.length + added.length,
178
+ rowsAdded: added,
179
+ };
180
+ }
181
+
125
182
  export function makeLikeSignature({ noteId, userId = '', userName = '', text = '' }) {
126
183
  const normalizedText = String(text || '').replace(/\s+/g, ' ').trim().slice(0, 200);
127
184
  return [