@leg3ndy/otto-bridge 0.8.1 → 0.8.3

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.
@@ -7,6 +7,7 @@ import { JobCancelledError } from "./shared.js";
7
7
  import { loadManagedBridgeExtensionState, saveManagedBridgeExtensionState, } from "../extensions.js";
8
8
  import { postDeviceJson, uploadDeviceJobArtifact } from "../http.js";
9
9
  import { WHATSAPP_WEB_URL, WhatsAppBackgroundBrowser, } from "../whatsapp_background.js";
10
+ import { verifyExpectedWhatsAppMessage } from "../whatsapp_verification.js";
10
11
  const KNOWN_APPS = [
11
12
  { canonical: "Safari", patterns: [/\bsafari\b/i] },
12
13
  { canonical: "Google Chrome", patterns: [/\bgoogle chrome\b/i, /\bchrome\b/i] },
@@ -221,6 +222,136 @@ function descriptionWantsPause(description) {
221
222
  function descriptionWantsResume(description) {
222
223
  return /\b(retoma|retomar|resume|continu[ae]r|despausa|play)\b/.test(normalizeText(description || ""));
223
224
  }
225
+ function descriptionWantsSpotifyTrackPageOpen(description) {
226
+ const normalized = normalizeText(description || "");
227
+ return /\bspotify\b/.test(normalized)
228
+ && /\b(pagina|titulo|nome|link)\b/.test(normalized)
229
+ && /\b(faixa|musica|track)\b/.test(normalized);
230
+ }
231
+ function descriptionWantsSpotifyTrackPagePlay(description) {
232
+ const normalized = normalizeText(description || "");
233
+ return /\bspotify\b/.test(normalized)
234
+ && /\b(play|reproduzir|tocar|verde|principal)\b/.test(normalized)
235
+ && /\b(pagina|faixa|musica|track)\b/.test(normalized);
236
+ }
237
+ function isSpotifySafariDomOnlyStep(description) {
238
+ return descriptionWantsSpotifyTrackPageOpen(description) || descriptionWantsSpotifyTrackPagePlay(description);
239
+ }
240
+ function playerStateLooksActive(playerState) {
241
+ const normalized = normalizeText(playerState || "");
242
+ return normalized.includes("pause") || normalized.includes("pausar");
243
+ }
244
+ function browserStateMatchesSpotifyTrackDescription(state, description) {
245
+ if (!state) {
246
+ return false;
247
+ }
248
+ const normalizedUrl = normalizeComparableUrl(state.url || "");
249
+ if (!normalizedUrl.includes("open.spotify.com/track/")) {
250
+ return false;
251
+ }
252
+ const haystack = [
253
+ state.playerTitle || "",
254
+ state.title || "",
255
+ String(state.text || "").slice(0, 4000),
256
+ ].join(" ");
257
+ const normalizedHaystack = normalizeText(haystack);
258
+ if (!normalizedHaystack) {
259
+ return false;
260
+ }
261
+ const quotedPhrases = extractQuotedPhrases(description);
262
+ if (quotedPhrases.some((phrase) => normalizedHaystack.includes(phrase))) {
263
+ return true;
264
+ }
265
+ const mediaQueryTokens = extractMediaQueryTokens(description);
266
+ if (!mediaQueryTokens.length) {
267
+ return false;
268
+ }
269
+ const matchCount = countMatchingTokens(haystack, mediaQueryTokens);
270
+ return matchCount >= Math.max(1, Math.ceil(mediaQueryTokens.length * 0.5));
271
+ }
272
+ function spotifyStateContentMatchesDescription(state, description) {
273
+ if (!state) {
274
+ return false;
275
+ }
276
+ const normalizedUrl = normalizeComparableUrl(state.url || "");
277
+ if (!normalizedUrl.includes("open.spotify.com")) {
278
+ return false;
279
+ }
280
+ const haystack = [
281
+ state.playerTitle || "",
282
+ state.title || "",
283
+ String(state.text || "").slice(0, 4000),
284
+ ].join(" ");
285
+ const normalizedHaystack = normalizeText(haystack);
286
+ if (!normalizedHaystack) {
287
+ return false;
288
+ }
289
+ const quotedPhrases = extractQuotedPhrases(description);
290
+ if (quotedPhrases.some((phrase) => normalizedHaystack.includes(phrase))) {
291
+ return true;
292
+ }
293
+ const mediaQueryTokens = extractMediaQueryTokens(description);
294
+ if (!mediaQueryTokens.length) {
295
+ return false;
296
+ }
297
+ const matchCount = countMatchingTokens(haystack, mediaQueryTokens);
298
+ return matchCount >= Math.max(1, Math.ceil(mediaQueryTokens.length * 0.5));
299
+ }
300
+ function spotifyPlayerAlreadyPlayingMatchesDescription(state, description) {
301
+ if (!state) {
302
+ return false;
303
+ }
304
+ const normalizedUrl = normalizeComparableUrl(state.url || "");
305
+ if (!normalizedUrl.includes("open.spotify.com")) {
306
+ return false;
307
+ }
308
+ if (!playerStateLooksActive(state.playerState || "")) {
309
+ return false;
310
+ }
311
+ const haystack = [
312
+ state.playerTitle || "",
313
+ state.title || "",
314
+ String(state.text || "").slice(0, 4000),
315
+ ].join(" ");
316
+ const normalizedHaystack = normalizeText(haystack);
317
+ if (!normalizedHaystack) {
318
+ return false;
319
+ }
320
+ const quotedPhrases = extractQuotedPhrases(description);
321
+ if (quotedPhrases.some((phrase) => normalizedHaystack.includes(phrase))) {
322
+ return true;
323
+ }
324
+ const mediaQueryTokens = extractMediaQueryTokens(description);
325
+ if (!mediaQueryTokens.length) {
326
+ return false;
327
+ }
328
+ const matchCount = countMatchingTokens(haystack, mediaQueryTokens);
329
+ return matchCount >= Math.max(1, Math.ceil(mediaQueryTokens.length * 0.5));
330
+ }
331
+ function spotifyTrackAlreadyPlayingMatchesDescription(state, description) {
332
+ return browserStateMatchesSpotifyTrackDescription(state, description)
333
+ && spotifyPlayerAlreadyPlayingMatchesDescription(state, description);
334
+ }
335
+ function spotifyTrackPageLooksPlaying(state, description) {
336
+ return browserStateMatchesSpotifyTrackDescription(state, description)
337
+ && playerStateLooksActive(state?.playerState || "");
338
+ }
339
+ function spotifyDescriptionsLikelyMatch(left, right) {
340
+ const leftPhrases = new Set(extractQuotedPhrases(left));
341
+ const rightPhrases = new Set(extractQuotedPhrases(right));
342
+ for (const phrase of leftPhrases) {
343
+ if (rightPhrases.has(phrase)) {
344
+ return true;
345
+ }
346
+ }
347
+ const leftTokens = extractMediaQueryTokens(left);
348
+ const rightTokens = new Set(extractMediaQueryTokens(right));
349
+ if (!leftTokens.length || !rightTokens.size) {
350
+ return false;
351
+ }
352
+ const overlap = leftTokens.filter((token) => rightTokens.has(token)).length;
353
+ return overlap >= Math.max(1, Math.ceil(Math.min(leftTokens.length, rightTokens.size) * 0.5));
354
+ }
224
355
  function extractNativeMediaTransportCommand(description) {
225
356
  const normalizedDescription = normalizeText(description || "");
226
357
  if (!normalizedDescription
@@ -473,6 +604,10 @@ function urlHostname(url) {
473
604
  return null;
474
605
  }
475
606
  }
607
+ function isSpotifySearchUrl(url) {
608
+ const normalized = normalizeComparableUrl(normalizeUrl(url));
609
+ return normalized.includes("open.spotify.com/search/");
610
+ }
476
611
  function uniqueStrings(values) {
477
612
  const seen = new Set();
478
613
  const result = [];
@@ -971,6 +1106,9 @@ export class NativeMacOSJobExecutor {
971
1106
  lastActiveApp = null;
972
1107
  lastVisualTargetDescription = null;
973
1108
  lastVisualTargetApp = null;
1109
+ lastSatisfiedSpotifyDescription = null;
1110
+ lastSatisfiedSpotifyConfirmedPlaying = false;
1111
+ lastSatisfiedSpotifyAt = 0;
974
1112
  whatsappBackgroundBrowser = null;
975
1113
  whatsappRuntimeMonitor = null;
976
1114
  constructor(bridgeConfig) {
@@ -987,6 +1125,9 @@ export class NativeMacOSJobExecutor {
987
1125
  if (actions.length === 0) {
988
1126
  throw new Error("Otto Bridge native-macos could not derive a supported local action from this request");
989
1127
  }
1128
+ this.lastSatisfiedSpotifyDescription = null;
1129
+ this.lastSatisfiedSpotifyConfirmedPlaying = false;
1130
+ this.lastSatisfiedSpotifyAt = 0;
990
1131
  await reporter.accepted();
991
1132
  const confirmation = extractConfirmationOptions(job, actions);
992
1133
  if (confirmation.required) {
@@ -1025,7 +1166,7 @@ export class NativeMacOSJobExecutor {
1025
1166
  }
1026
1167
  if (action.type === "press_shortcut") {
1027
1168
  await reporter.progress(progressPercent, `Enviando atalho ${action.shortcut}`);
1028
- await this.pressShortcut(action.shortcut);
1169
+ const shortcutResult = await this.pressShortcut(action.shortcut);
1029
1170
  if (action.shortcut.startsWith("media_")) {
1030
1171
  const mediaSummaryMap = {
1031
1172
  media_next: "Acionei o comando de próxima mídia no macOS.",
@@ -1035,7 +1176,14 @@ export class NativeMacOSJobExecutor {
1035
1176
  media_play: "Acionei o comando de reproduzir mídia no macOS.",
1036
1177
  media_play_pause: "Acionei o comando de play/pause de mídia no macOS.",
1037
1178
  };
1038
- completionNotes.push(mediaSummaryMap[action.shortcut] || `Acionei ${action.shortcut} no macOS.`);
1179
+ const mediaSkippedSummaryMap = {
1180
+ media_pause: "Nenhuma mídia estava tocando no macOS, então o pause global foi pulado.",
1181
+ media_resume: "Já havia mídia tocando no macOS, então o comando de retomar foi pulado.",
1182
+ media_play: "Já havia mídia tocando no macOS, então o comando de reproduzir foi pulado.",
1183
+ };
1184
+ completionNotes.push(shortcutResult.performed
1185
+ ? (mediaSummaryMap[action.shortcut] || `Acionei ${action.shortcut} no macOS.`)
1186
+ : (mediaSkippedSummaryMap[action.shortcut] || shortcutResult.reason || `O atalho ${action.shortcut} foi pulado no macOS.`));
1039
1187
  }
1040
1188
  continue;
1041
1189
  }
@@ -1197,10 +1345,6 @@ export class NativeMacOSJobExecutor {
1197
1345
  await reporter.progress(progressPercent, `Enviando a mensagem para ${action.contact} no WhatsApp`);
1198
1346
  await this.sendWhatsAppMessage(action.text);
1199
1347
  await delay(900);
1200
- const verification = await this.verifyWhatsAppLastMessageAgainstBaseline(action.text, beforeSend.messages);
1201
- if (!verification.ok) {
1202
- throw new Error(verification.reason || `Nao consegui confirmar o envio da mensagem para ${action.contact} no WhatsApp.`);
1203
- }
1204
1348
  const afterSend = await this.readWhatsAppVisibleConversation(action.contact, Math.max(12, beforeSend.messages.length + 4)).catch(() => null);
1205
1349
  resultPayload.whatsapp = {
1206
1350
  action: "send_message",
@@ -1209,6 +1353,12 @@ export class NativeMacOSJobExecutor {
1209
1353
  messages: afterSend?.messages || [],
1210
1354
  summary: afterSend?.summary || "",
1211
1355
  };
1356
+ const verification = await this.verifyWhatsAppLastMessageAgainstBaseline(action.text, beforeSend.messages);
1357
+ if (!verification.ok) {
1358
+ resultPayload.summary = verification.reason || `Nao consegui confirmar o envio da mensagem para ${action.contact} no WhatsApp.`;
1359
+ await reporter.failed(verification.reason || `Nao consegui confirmar o envio da mensagem para ${action.contact} no WhatsApp.`, resultPayload);
1360
+ return;
1361
+ }
1212
1362
  completionNotes.push(`Enviei no WhatsApp para ${action.contact}: ${clipText(action.text, 180)}`);
1213
1363
  continue;
1214
1364
  }
@@ -1239,7 +1389,9 @@ export class NativeMacOSJobExecutor {
1239
1389
  await reporter.progress(progressPercent, `Trazendo ${action.app} para frente antes do clique`);
1240
1390
  await this.focusApp(action.app);
1241
1391
  }
1242
- const targetDescriptions = uniqueStrings([action.description, ...(action.retry_descriptions || [])]);
1392
+ const targetDescriptions = isSpotifySafariDomOnlyStep(action.description)
1393
+ ? [action.description]
1394
+ : uniqueStrings([action.description, ...(action.retry_descriptions || [])]);
1243
1395
  let clickSucceeded = false;
1244
1396
  let lastFailureReason = "";
1245
1397
  for (let attempt = 0; attempt < targetDescriptions.length; attempt += 1) {
@@ -1247,6 +1399,33 @@ export class NativeMacOSJobExecutor {
1247
1399
  const initialBrowserState = browserApp
1248
1400
  ? await this.captureBrowserPageState(browserApp).catch(() => null)
1249
1401
  : null;
1402
+ const isSpotifySafariStep = browserApp === "Safari" && isSpotifySafariDomOnlyStep(targetDescription);
1403
+ const verificationPrompt = isSpotifySafariStep ? undefined : action.verification_prompt;
1404
+ if (isSpotifySafariStep) {
1405
+ await reporter.progress(progressPercent, `Tentando concluir ${targetDescription} pelo DOM do Spotify no Safari`);
1406
+ const spotifyDomResult = await this.executeSpotifySafariDomStep(targetDescription, initialBrowserState);
1407
+ if (spotifyDomResult.ok) {
1408
+ this.rememberSatisfiedSpotifyStep(targetDescription, !!spotifyDomResult.confirmedPlaying);
1409
+ this.lastVisualTargetDescription = targetDescription;
1410
+ this.lastVisualTargetApp = browserApp || action.app || this.lastActiveApp;
1411
+ resultPayload.last_click = {
1412
+ strategy: spotifyDomResult.skipped
1413
+ ? "spotify_browser_state_skip"
1414
+ : (spotifyDomResult.click?.strategy || "spotify_safari_dom"),
1415
+ matched_text: spotifyDomResult.click?.matchedText || targetDescription,
1416
+ matched_href: spotifyDomResult.click?.matchedHref || spotifyDomResult.afterState?.url || null,
1417
+ score: spotifyDomResult.click?.score || null,
1418
+ total_candidates: spotifyDomResult.click?.totalCandidates || null,
1419
+ };
1420
+ completionNotes.push(spotifyDomResult.skipped
1421
+ ? `O Spotify Web ja estava no estado certo para ${targetDescription}, então pulei tentativas extras.`
1422
+ : `Concluí ${targetDescription} diretamente pelo DOM do Spotify Web no Safari.`);
1423
+ clickSucceeded = true;
1424
+ break;
1425
+ }
1426
+ lastFailureReason = spotifyDomResult.reason || `Nao consegui concluir ${targetDescription} pelo DOM do Spotify Web no Safari.`;
1427
+ continue;
1428
+ }
1250
1429
  const nativeMediaTransport = extractNativeMediaTransportCommand(targetDescription);
1251
1430
  if (nativeMediaTransport) {
1252
1431
  await reporter.progress(progressPercent, `Tentando controle de mídia nativo do macOS para ${targetDescription}`);
@@ -1259,8 +1438,8 @@ export class NativeMacOSJobExecutor {
1259
1438
  validated = browserValidation.ok;
1260
1439
  validationReason = browserValidation.reason;
1261
1440
  }
1262
- if (!validated && action.verification_prompt) {
1263
- const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, action.verification_prompt, progressPercent, reporter, artifacts, "native_media_transport_result");
1441
+ if (!validated && verificationPrompt) {
1442
+ const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, "native_media_transport_result");
1264
1443
  if (verification.unavailable) {
1265
1444
  lastFailureReason = verification.reason;
1266
1445
  break;
@@ -1298,8 +1477,8 @@ export class NativeMacOSJobExecutor {
1298
1477
  validated = browserValidation.ok;
1299
1478
  validationReason = browserValidation.reason;
1300
1479
  }
1301
- if (!validated && action.verification_prompt) {
1302
- const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, action.verification_prompt, progressPercent, reporter, artifacts, "dom_click_result");
1480
+ if (!validated && verificationPrompt) {
1481
+ const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, "dom_click_result");
1303
1482
  if (verification.unavailable) {
1304
1483
  lastFailureReason = verification.reason;
1305
1484
  break;
@@ -1344,8 +1523,8 @@ export class NativeMacOSJobExecutor {
1344
1523
  validated = browserValidation.ok;
1345
1524
  validationReason = browserValidation.reason;
1346
1525
  }
1347
- if (!validated && action.verification_prompt) {
1348
- const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, action.verification_prompt, progressPercent, reporter, artifacts, "local_ocr_click_result");
1526
+ if (!validated && verificationPrompt) {
1527
+ const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, "local_ocr_click_result");
1349
1528
  if (verification.unavailable) {
1350
1529
  lastFailureReason = verification.reason;
1351
1530
  break;
@@ -1425,8 +1604,8 @@ export class NativeMacOSJobExecutor {
1425
1604
  y: scaledY,
1426
1605
  strategy: "visual_locator",
1427
1606
  };
1428
- if (action.verification_prompt) {
1429
- const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, action.verification_prompt, progressPercent, reporter, artifacts, "visual_click_result");
1607
+ if (verificationPrompt) {
1608
+ const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, "visual_click_result");
1430
1609
  if (verification.unavailable) {
1431
1610
  lastFailureReason = verification.reason;
1432
1611
  break;
@@ -1531,6 +1710,9 @@ export class NativeMacOSJobExecutor {
1531
1710
  await this.runCommand("open", ["-a", app, url]);
1532
1711
  }
1533
1712
  await this.focusApp(app);
1713
+ if (isSpotifySearchUrl(url)) {
1714
+ await this.ensureSafariSpotifySearchUrl(url);
1715
+ }
1534
1716
  this.lastActiveApp = app;
1535
1717
  return;
1536
1718
  }
@@ -1542,6 +1724,48 @@ export class NativeMacOSJobExecutor {
1542
1724
  }
1543
1725
  await this.runCommand("open", [url]);
1544
1726
  }
1727
+ async ensureSafariSpotifySearchUrl(url) {
1728
+ const targetUrl = normalizeUrl(url);
1729
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1730
+ await delay(700);
1731
+ const currentUrl = await this.readSafariCurrentUrl().catch(() => "");
1732
+ const normalizedCurrentUrl = normalizeComparableUrl(currentUrl);
1733
+ if (!normalizedCurrentUrl || !normalizedCurrentUrl.includes("open.spotify.com")) {
1734
+ return;
1735
+ }
1736
+ if (normalizedCurrentUrl.includes("/search/")) {
1737
+ return;
1738
+ }
1739
+ if (!/open\.spotify\.com\/?$/.test(normalizedCurrentUrl)) {
1740
+ return;
1741
+ }
1742
+ await this.setSafariFrontTabUrl(targetUrl).catch(() => undefined);
1743
+ }
1744
+ }
1745
+ async readSafariCurrentUrl() {
1746
+ const script = `
1747
+ tell application "Safari"
1748
+ if (count of windows) = 0 then return ""
1749
+ try
1750
+ return URL of current tab of front window
1751
+ on error
1752
+ return ""
1753
+ end try
1754
+ end tell
1755
+ `;
1756
+ const { stdout } = await this.runCommandCapture("osascript", ["-e", script]);
1757
+ return String(stdout || "").trim();
1758
+ }
1759
+ async setSafariFrontTabUrl(url) {
1760
+ const script = `
1761
+ set targetUrl to "${escapeAppleScript(url)}"
1762
+ tell application "Safari"
1763
+ if (count of windows) = 0 then return
1764
+ set URL of current tab of front window to targetUrl
1765
+ end tell
1766
+ `;
1767
+ await this.runCommand("osascript", ["-e", script]);
1768
+ }
1545
1769
  async tryReuseSafariTab(url) {
1546
1770
  const targetUrl = normalizeUrl(url);
1547
1771
  const targetHost = urlHostname(targetUrl);
@@ -1651,6 +1875,161 @@ end tell
1651
1875
  playerState: page.playerState || "",
1652
1876
  };
1653
1877
  }
1878
+ rememberSatisfiedSpotifyStep(description, confirmedPlaying) {
1879
+ if (!isSpotifySafariDomOnlyStep(description)) {
1880
+ return;
1881
+ }
1882
+ this.lastSatisfiedSpotifyDescription = description;
1883
+ this.lastSatisfiedSpotifyConfirmedPlaying = confirmedPlaying;
1884
+ this.lastSatisfiedSpotifyAt = Date.now();
1885
+ }
1886
+ hasRecentSatisfiedSpotifyPlayback(description, maxAgeMs = 10_000) {
1887
+ if (!this.lastSatisfiedSpotifyConfirmedPlaying || !this.lastSatisfiedSpotifyDescription || !this.lastSatisfiedSpotifyAt) {
1888
+ return false;
1889
+ }
1890
+ if ((Date.now() - this.lastSatisfiedSpotifyAt) > maxAgeMs) {
1891
+ return false;
1892
+ }
1893
+ return spotifyDescriptionsLikelyMatch(this.lastSatisfiedSpotifyDescription, description);
1894
+ }
1895
+ hasRecentSatisfiedSpotifyStep(description, maxAgeMs = 10_000) {
1896
+ if (!this.lastSatisfiedSpotifyDescription || !this.lastSatisfiedSpotifyAt) {
1897
+ return false;
1898
+ }
1899
+ if ((Date.now() - this.lastSatisfiedSpotifyAt) > maxAgeMs) {
1900
+ return false;
1901
+ }
1902
+ return spotifyDescriptionsLikelyMatch(this.lastSatisfiedSpotifyDescription, description);
1903
+ }
1904
+ async executeSpotifySafariDomStep(targetDescription, initialBrowserState) {
1905
+ if (descriptionWantsSpotifyTrackPagePlay(targetDescription)
1906
+ && this.hasRecentSatisfiedSpotifyStep(targetDescription)
1907
+ && browserStateMatchesSpotifyTrackDescription(initialBrowserState, targetDescription)) {
1908
+ return {
1909
+ ok: true,
1910
+ skipped: true,
1911
+ afterState: initialBrowserState,
1912
+ };
1913
+ }
1914
+ if (descriptionWantsSpotifyTrackPagePlay(targetDescription) && this.hasRecentSatisfiedSpotifyPlayback(targetDescription)) {
1915
+ return {
1916
+ ok: true,
1917
+ skipped: true,
1918
+ confirmedPlaying: true,
1919
+ afterState: initialBrowserState,
1920
+ };
1921
+ }
1922
+ if (spotifyPlayerAlreadyPlayingMatchesDescription(initialBrowserState, targetDescription)) {
1923
+ return {
1924
+ ok: true,
1925
+ skipped: true,
1926
+ confirmedPlaying: true,
1927
+ afterState: initialBrowserState,
1928
+ };
1929
+ }
1930
+ if (descriptionWantsSpotifyTrackPagePlay(targetDescription)) {
1931
+ const shouldWaitLongerForAutoplay = this.hasRecentSatisfiedSpotifyStep(targetDescription);
1932
+ let settledState = initialBrowserState;
1933
+ for (let attempt = 0; attempt < (shouldWaitLongerForAutoplay ? 6 : 3); attempt += 1) {
1934
+ if (attempt > 0) {
1935
+ await delay(shouldWaitLongerForAutoplay ? 550 : 450);
1936
+ }
1937
+ settledState = await this.captureBrowserPageState("Safari").catch(() => settledState);
1938
+ const playbackState = await this.readGlobalMediaPlaybackState();
1939
+ if ((playbackState === "playing" && spotifyStateContentMatchesDescription(settledState, targetDescription))
1940
+ || spotifyTrackPageLooksPlaying(settledState, targetDescription)
1941
+ || spotifyTrackAlreadyPlayingMatchesDescription(settledState, targetDescription)) {
1942
+ return {
1943
+ ok: true,
1944
+ skipped: true,
1945
+ confirmedPlaying: true,
1946
+ afterState: settledState,
1947
+ };
1948
+ }
1949
+ }
1950
+ const alreadyOnRequestedTrack = browserStateMatchesSpotifyTrackDescription(initialBrowserState, targetDescription);
1951
+ if (alreadyOnRequestedTrack) {
1952
+ const playbackState = await this.readGlobalMediaPlaybackState();
1953
+ if (playbackState === "playing"
1954
+ || spotifyTrackPageLooksPlaying(initialBrowserState, targetDescription)
1955
+ || spotifyTrackAlreadyPlayingMatchesDescription(initialBrowserState, targetDescription)) {
1956
+ return {
1957
+ ok: true,
1958
+ skipped: true,
1959
+ confirmedPlaying: true,
1960
+ afterState: initialBrowserState,
1961
+ };
1962
+ }
1963
+ }
1964
+ }
1965
+ const domClick = await this.trySafariDomClick(targetDescription);
1966
+ let currentState = await this.captureBrowserPageState("Safari").catch(() => initialBrowserState);
1967
+ if (!domClick?.clicked) {
1968
+ return {
1969
+ ok: false,
1970
+ reason: domClick?.reason || `Nao consegui concluir ${targetDescription} pelo DOM do Spotify Web no Safari.`,
1971
+ afterState: currentState,
1972
+ };
1973
+ }
1974
+ for (let attempt = 0; attempt < 4; attempt += 1) {
1975
+ if (attempt > 0) {
1976
+ await delay(500);
1977
+ }
1978
+ currentState = await this.captureBrowserPageState("Safari").catch(() => currentState);
1979
+ if (descriptionWantsSpotifyTrackPageOpen(targetDescription)) {
1980
+ if (browserStateMatchesSpotifyTrackDescription(currentState, targetDescription)) {
1981
+ return {
1982
+ ok: true,
1983
+ click: domClick,
1984
+ afterState: currentState,
1985
+ };
1986
+ }
1987
+ const playbackState = await this.readGlobalMediaPlaybackState();
1988
+ if (playbackState === "playing" && spotifyStateContentMatchesDescription(currentState, targetDescription)) {
1989
+ return {
1990
+ ok: true,
1991
+ click: domClick,
1992
+ confirmedPlaying: true,
1993
+ afterState: currentState,
1994
+ };
1995
+ }
1996
+ }
1997
+ if (descriptionWantsSpotifyTrackPagePlay(targetDescription)) {
1998
+ if (spotifyPlayerAlreadyPlayingMatchesDescription(currentState, targetDescription)) {
1999
+ return {
2000
+ ok: true,
2001
+ click: domClick,
2002
+ confirmedPlaying: true,
2003
+ afterState: currentState,
2004
+ };
2005
+ }
2006
+ const playbackState = await this.readGlobalMediaPlaybackState();
2007
+ if (playbackState === "playing" && spotifyStateContentMatchesDescription(currentState, targetDescription)) {
2008
+ return {
2009
+ ok: true,
2010
+ click: domClick,
2011
+ confirmedPlaying: true,
2012
+ afterState: currentState,
2013
+ };
2014
+ }
2015
+ }
2016
+ }
2017
+ if (descriptionWantsSpotifyTrackPageOpen(targetDescription)) {
2018
+ const matchedHref = normalizeComparableUrl(domClick.matchedHref || "");
2019
+ if (matchedHref.includes("/track/")) {
2020
+ return {
2021
+ ok: true,
2022
+ click: domClick,
2023
+ afterState: currentState,
2024
+ };
2025
+ }
2026
+ }
2027
+ return {
2028
+ ok: false,
2029
+ reason: `O clique em ${targetDescription} nao mudou a pagina do navegador de forma verificavel.`,
2030
+ afterState: currentState,
2031
+ };
2032
+ }
1654
2033
  resolveExpectedBrowserHref(rawHref, baseUrl) {
1655
2034
  const href = String(rawHref || "").trim();
1656
2035
  if (!href) {
@@ -1691,12 +2070,14 @@ end tell
1691
2070
  const afterPlayerTitle = normalizeText(after.playerTitle || "");
1692
2071
  const beforePlayerState = normalizeText(before?.playerState || "");
1693
2072
  const afterPlayerState = normalizeText(after.playerState || "");
1694
- const playerLooksActive = afterPlayerState.includes("pause") || afterPlayerState.includes("pausar");
2073
+ const playerLooksActive = playerStateLooksActive(after.playerState || "");
1695
2074
  const playerLooksPaused = !playerLooksActive && /play|tocar|reproduzir|continuar|retomar|resume/.test(afterPlayerState);
1696
2075
  const wantsNext = descriptionWantsNext(targetDescription);
1697
2076
  const wantsPrevious = descriptionWantsPrevious(targetDescription);
1698
2077
  const wantsPause = descriptionWantsPause(targetDescription);
1699
2078
  const wantsResume = descriptionWantsResume(targetDescription);
2079
+ const wantsSpotifyTrackPageOpen = descriptionWantsSpotifyTrackPageOpen(targetDescription);
2080
+ const wantsSpotifyTrackPagePlay = descriptionWantsSpotifyTrackPagePlay(targetDescription);
1700
2081
  const mediaQueryTokens = extractMediaQueryTokens(targetDescription);
1701
2082
  const mediaMatchCount = countMatchingTokens(after.playerTitle || "", mediaQueryTokens);
1702
2083
  if (afterUrl.includes("music.youtube.com")) {
@@ -1722,6 +2103,51 @@ end tell
1722
2103
  return true;
1723
2104
  }
1724
2105
  }
2106
+ if (afterUrl.includes("open.spotify.com")) {
2107
+ if (wantsSpotifyTrackPageOpen && spotifyPlayerAlreadyPlayingMatchesDescription(after, targetDescription)) {
2108
+ return true;
2109
+ }
2110
+ if (wantsSpotifyTrackPagePlay && spotifyTrackAlreadyPlayingMatchesDescription(after, targetDescription)) {
2111
+ return true;
2112
+ }
2113
+ if (wantsSpotifyTrackPageOpen) {
2114
+ const beforeWasTrackPage = beforeUrl.includes("/track/");
2115
+ const afterIsTrackPage = afterUrl.includes("/track/");
2116
+ if (afterIsTrackPage && (!beforeWasTrackPage || beforeUrl !== afterUrl)) {
2117
+ return true;
2118
+ }
2119
+ return spotifyPlayerAlreadyPlayingMatchesDescription(after, targetDescription);
2120
+ }
2121
+ if (wantsPause && beforePlayerState && beforePlayerState !== afterPlayerState && playerLooksPaused) {
2122
+ return true;
2123
+ }
2124
+ if (wantsResume && playerLooksActive && beforePlayerState !== afterPlayerState) {
2125
+ return true;
2126
+ }
2127
+ if ((wantsNext || wantsPrevious) && beforePlayerTitle && afterPlayerTitle && beforePlayerTitle !== afterPlayerTitle) {
2128
+ return true;
2129
+ }
2130
+ if (mediaQueryTokens.length >= 2) {
2131
+ const requiredMatches = Math.max(2, Math.ceil(mediaQueryTokens.length * 0.5));
2132
+ const titleLooksCorrect = mediaMatchCount >= requiredMatches;
2133
+ const playerChanged = ((beforePlayerTitle && afterPlayerTitle && beforePlayerTitle !== afterPlayerTitle)
2134
+ || (!beforePlayerTitle && !!afterPlayerTitle)
2135
+ || (beforePlayerState && afterPlayerState && beforePlayerState !== afterPlayerState));
2136
+ if (titleLooksCorrect && (playerChanged || (beforePlayerTitle === afterPlayerTitle && playerLooksActive))) {
2137
+ return true;
2138
+ }
2139
+ }
2140
+ if (beforePlayerTitle && afterPlayerTitle && beforePlayerTitle !== afterPlayerTitle && playerLooksActive) {
2141
+ return true;
2142
+ }
2143
+ if (!beforePlayerTitle && afterPlayerTitle && playerLooksActive) {
2144
+ return true;
2145
+ }
2146
+ }
2147
+ const looksLikeMediaAction = wantsNext || wantsPrevious || wantsPause || wantsResume || mediaQueryTokens.length > 0;
2148
+ if (looksLikeMediaAction && (afterUrl.includes("music.youtube.com") || afterUrl.includes("open.spotify.com"))) {
2149
+ return false;
2150
+ }
1725
2151
  const beforeTitle = normalizeText(before?.title || "");
1726
2152
  const afterTitle = normalizeText(after.title || "");
1727
2153
  if (beforeTitle && afterTitle && beforeTitle !== afterTitle) {
@@ -1742,9 +2168,11 @@ end tell
1742
2168
  afterState: null,
1743
2169
  };
1744
2170
  }
2171
+ let lastAfterState = null;
1745
2172
  for (let attempt = 0; attempt < 4; attempt += 1) {
1746
2173
  await delay(attempt === 0 ? 900 : 700);
1747
2174
  const afterState = await this.captureBrowserPageState(app).catch(() => null);
2175
+ lastAfterState = afterState;
1748
2176
  if (this.didBrowserPageStateChange(before, afterState, targetDescription, matchedHref)) {
1749
2177
  return {
1750
2178
  ok: true,
@@ -1756,7 +2184,7 @@ end tell
1756
2184
  return {
1757
2185
  ok: false,
1758
2186
  reason: `O clique em ${targetDescription} nao mudou a pagina do navegador de forma verificavel.`,
1759
- afterState: null,
2187
+ afterState: lastAfterState,
1760
2188
  };
1761
2189
  }
1762
2190
  async pressShortcut(shortcut) {
@@ -1778,8 +2206,28 @@ end tell
1778
2206
  };
1779
2207
  const mediaCommand = mediaCommandMap[normalizedShortcut];
1780
2208
  if (mediaCommand) {
2209
+ if (normalizedShortcut === "media_pause") {
2210
+ const playbackState = await this.readGlobalMediaPlaybackState();
2211
+ if (playbackState !== "playing") {
2212
+ return {
2213
+ performed: false,
2214
+ reason: playbackState === "unknown"
2215
+ ? "O macOS nao confirmou nenhuma mídia tocando com segurança."
2216
+ : "Nenhuma mídia estava tocando no macOS.",
2217
+ };
2218
+ }
2219
+ }
2220
+ if (normalizedShortcut === "media_resume" || normalizedShortcut === "media_play") {
2221
+ const playbackState = await this.readGlobalMediaPlaybackState();
2222
+ if (playbackState === "playing") {
2223
+ return {
2224
+ performed: false,
2225
+ reason: "Já havia mídia tocando no macOS.",
2226
+ };
2227
+ }
2228
+ }
1781
2229
  await this.triggerMacOSMediaTransport(mediaCommand);
1782
- return;
2230
+ return { performed: true };
1783
2231
  }
1784
2232
  const namedKeyCodes = {
1785
2233
  return: 36,
@@ -1799,12 +2247,67 @@ end tell
1799
2247
  "-e",
1800
2248
  `tell application "System Events" to key code ${namedKeyCodes[key]}${usingClause}`,
1801
2249
  ]);
1802
- return;
2250
+ return { performed: true };
1803
2251
  }
1804
2252
  await this.runCommand("osascript", [
1805
2253
  "-e",
1806
2254
  `tell application "System Events" to keystroke "${escapeAppleScript(key)}"${usingClause}`,
1807
2255
  ]);
2256
+ return { performed: true };
2257
+ }
2258
+ async readGlobalMediaPlaybackState() {
2259
+ const swiftScript = `
2260
+ import Foundation
2261
+ import Dispatch
2262
+ import Darwin
2263
+
2264
+ typealias MRMediaRemoteGetNowPlayingApplicationIsPlayingFunction = @convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void
2265
+
2266
+ guard let handle = dlopen("/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote", RTLD_NOW) else {
2267
+ print("unknown")
2268
+ exit(0)
2269
+ }
2270
+
2271
+ guard let symbol = dlsym(handle, "MRMediaRemoteGetNowPlayingApplicationIsPlaying") else {
2272
+ print("unknown")
2273
+ exit(0)
2274
+ }
2275
+
2276
+ let fn = unsafeBitCast(symbol, to: MRMediaRemoteGetNowPlayingApplicationIsPlayingFunction.self)
2277
+ let semaphore = DispatchSemaphore(value: 0)
2278
+ var isPlaying: Bool? = nil
2279
+
2280
+ fn(DispatchQueue.global()) { value in
2281
+ isPlaying = value
2282
+ semaphore.signal()
2283
+ }
2284
+
2285
+ if semaphore.wait(timeout: .now() + .milliseconds(800)) == .timedOut {
2286
+ print("unknown")
2287
+ exit(0)
2288
+ }
2289
+
2290
+ print(isPlaying == true ? "playing" : "not_playing")
2291
+ `;
2292
+ try {
2293
+ const { stdout } = await this.runCommandCapture("swift", [
2294
+ "-module-cache-path",
2295
+ path.join(os.tmpdir(), "otto-bridge-swift-modcache"),
2296
+ "-e",
2297
+ swiftScript,
2298
+ ]);
2299
+ const state = normalizeText(stdout || "").trim();
2300
+ if (state.includes("not_playing")) {
2301
+ return "not_playing";
2302
+ }
2303
+ if (state.includes("playing")) {
2304
+ return "playing";
2305
+ }
2306
+ return "unknown";
2307
+ }
2308
+ catch {
2309
+ return "unknown";
2310
+ }
1808
2311
  }
1809
2312
  async triggerMacOSMediaTransport(command) {
1810
2313
  const keyTypeMap = {
@@ -2664,42 +3167,7 @@ return { messages: messages.slice(-maxMessages) };
2664
3167
  }
2665
3168
  const baseline = Array.isArray(previousMessages) ? previousMessages : [];
2666
3169
  const chat = await this.readWhatsAppVisibleConversation("Contato", Math.max(8, baseline.length + 2));
2667
- if (!chat.messages.length) {
2668
- return {
2669
- ok: false,
2670
- reason: "Nao consegui ler as mensagens visiveis apos o envio no WhatsApp.",
2671
- };
2672
- }
2673
- const normalizedExpected = normalizeText(expectedText).slice(0, 120);
2674
- const normalizeMessage = (item) => `${normalizeText(item.author)}|${normalizeText(item.text)}`;
2675
- const beforeSignature = baseline.map(normalizeMessage).join("\n");
2676
- const afterSignature = chat.messages.map(normalizeMessage).join("\n");
2677
- const changed = beforeSignature !== afterSignature;
2678
- const beforeMatches = baseline.filter((item) => normalizeText(item.text).includes(normalizedExpected)).length;
2679
- const afterMatches = chat.messages.filter((item) => normalizeText(item.text).includes(normalizedExpected)).length;
2680
- const latest = chat.messages[chat.messages.length - 1] || null;
2681
- const latestAuthor = normalizeText(latest?.author || "");
2682
- const latestText = normalizeText(latest?.text || "");
2683
- const latestMatches = latestText.includes(normalizedExpected) && (latestAuthor === "voce" || latestAuthor === "você");
2684
- if ((changed && latestMatches) || (changed && afterMatches > beforeMatches)) {
2685
- return { ok: true, reason: "" };
2686
- }
2687
- if (!changed) {
2688
- return {
2689
- ok: false,
2690
- reason: "O WhatsApp nao mostrou mudanca visivel na conversa depois da tentativa de envio.",
2691
- };
2692
- }
2693
- if (afterMatches <= beforeMatches) {
2694
- return {
2695
- ok: false,
2696
- reason: "A conversa mudou, mas nao apareceu uma nova mensagem com o texto esperado no WhatsApp.",
2697
- };
2698
- }
2699
- return {
2700
- ok: false,
2701
- reason: "Nao consegui confirmar visualmente a nova mensagem enviada no WhatsApp.",
2702
- };
3170
+ return verifyExpectedWhatsAppMessage(expectedText, baseline, chat.messages);
2703
3171
  }
2704
3172
  async takeScreenshot(targetPath) {
2705
3173
  const artifactsDir = path.join(os.homedir(), ".otto-bridge", "artifacts");
@@ -2889,6 +3357,14 @@ const wantsNext = /\\b(proxim[ao]?|next|skip|pular|avanca|avancar)\\b/.test(norm
2889
3357
  const wantsPrevious = /\\b(anterior|previous|volta[ar]?|back|retorna[ar]?)\\b/.test(normalizedDescription);
2890
3358
  const wantsPause = /\\b(pausa|pause|pausar)\\b/.test(normalizedDescription);
2891
3359
  const wantsResume = /\\b(retoma|retomar|resume|continu[ae]r|despausa|play)\\b/.test(normalizedDescription);
3360
+ const wantsSpotifyTrackPageOpen = location.hostname.includes("open.spotify.com")
3361
+ && /\\bspotify\\b/.test(normalizedDescription)
3362
+ && /\\b(pagina|titulo|nome|link)\\b/.test(normalizedDescription)
3363
+ && /\\b(faixa|musica|track)\\b/.test(normalizedDescription);
3364
+ const wantsSpotifyTrackPagePlay = location.hostname.includes("open.spotify.com")
3365
+ && /\\bspotify\\b/.test(normalizedDescription)
3366
+ && /\\b(play|reproduzir|tocar|verde|principal)\\b/.test(normalizedDescription)
3367
+ && /\\b(pagina|faixa|musica|track)\\b/.test(normalizedDescription);
2892
3368
  const stopWords = new Set([
2893
3369
  "o", "a", "os", "as", "um", "uma", "uns", "umas", "de", "da", "do", "das", "dos",
2894
3370
  "em", "no", "na", "nos", "nas", "para", "por", "com", "que", "visivel", "visiveis",
@@ -2998,6 +3474,33 @@ function clickElement(element, strategy, matchedText, matchedHref, score, totalC
2998
3474
  };
2999
3475
  }
3000
3476
 
3477
+ function pickYouTubeMusicPrimaryCard(element) {
3478
+ let current = element instanceof Element ? element : null;
3479
+ let best = null;
3480
+ let bestScore = -Infinity;
3481
+ while (current && current instanceof HTMLElement && current !== document.body) {
3482
+ const rect = current.getBoundingClientRect();
3483
+ const text = normalize(deriveText(current));
3484
+ let score = 0;
3485
+ if (rect.width >= window.innerWidth * 0.32) score += 40;
3486
+ if (rect.width >= window.innerWidth * 0.42) score += 80;
3487
+ if (rect.left <= window.innerWidth * 0.18) score += 90;
3488
+ if ((rect.left + rect.width) <= window.innerWidth * 0.68) score += 120;
3489
+ if (rect.top <= window.innerHeight * 0.7) score += 40;
3490
+ if (text.includes("mais do youtube")) score -= 260;
3491
+ if (text.includes("playlist")) score -= 40;
3492
+ if (text.includes("podcast")) score -= 40;
3493
+ if (text.includes("musica")) score += 28;
3494
+ if (text.includes("video")) score -= 18;
3495
+ if (score > bestScore) {
3496
+ bestScore = score;
3497
+ best = current;
3498
+ }
3499
+ current = current.parentElement;
3500
+ }
3501
+ return best;
3502
+ }
3503
+
3001
3504
  function attemptYouTubeMusicTransportClick() {
3002
3505
  if (!isYouTubeMusic || !(wantsNext || wantsPrevious || wantsPause || wantsResume)) {
3003
3506
  return null;
@@ -3045,6 +3548,97 @@ function attemptYouTubeMusicSearchResultClick() {
3045
3548
  return null;
3046
3549
  }
3047
3550
 
3551
+ const primaryCardButtons = Array.from(document.querySelectorAll("button, [role='button'], a[href*='watch?v=']"))
3552
+ .filter((node) => node instanceof HTMLElement || node instanceof HTMLAnchorElement)
3553
+ .filter((node) => isVisible(node))
3554
+ .filter((node) => !(node.closest("ytmusic-player-bar")))
3555
+ .map((node) => {
3556
+ const element = node;
3557
+ const rect = element.getBoundingClientRect();
3558
+ const primaryCard = pickYouTubeMusicPrimaryCard(element);
3559
+ const primaryCardRect = primaryCard ? primaryCard.getBoundingClientRect() : rect;
3560
+ const label = normalize([
3561
+ deriveText(element),
3562
+ element.getAttribute("aria-label"),
3563
+ element.getAttribute("title"),
3564
+ ].filter(Boolean).join(" "));
3565
+ const cardText = normalize(primaryCard ? deriveText(primaryCard) : deriveText(element.parentElement || element));
3566
+ const isPlayButton = /\b(play|reproduzir|tocar)\b/.test(label);
3567
+ const buttonCenterX = rect.left + (rect.width / 2);
3568
+ const cardCenterX = primaryCardRect.left + (primaryCardRect.width / 2);
3569
+ let score = 0;
3570
+
3571
+ if (!isPlayButton) {
3572
+ return null;
3573
+ }
3574
+ if (buttonCenterX > (window.innerWidth * 0.44)) {
3575
+ return null;
3576
+ }
3577
+ if (rect.width < 110 || rect.height < 36) {
3578
+ return null;
3579
+ }
3580
+ if (primaryCardRect.left > (window.innerWidth * 0.16)) {
3581
+ return null;
3582
+ }
3583
+ if (cardCenterX > (window.innerWidth * 0.36)) {
3584
+ return null;
3585
+ }
3586
+ if (primaryCardRect.width < (window.innerWidth * 0.28)) {
3587
+ return null;
3588
+ }
3589
+
3590
+ for (const phrase of quotedPhrases) {
3591
+ if (!phrase) continue;
3592
+ if (cardText.includes(phrase) || label.includes(phrase)) score += 220;
3593
+ }
3594
+
3595
+ for (const token of tokens) {
3596
+ if (cardText.includes(token)) score += 30;
3597
+ else if (label.includes(token)) score += 16;
3598
+ }
3599
+
3600
+ if (tokens.length > 1) {
3601
+ const matchCount = tokens.filter((token) => cardText.includes(token)).length;
3602
+ if (matchCount >= Math.max(2, Math.ceil(tokens.length * 0.5))) score += 120;
3603
+ if (matchCount === tokens.length) score += 80;
3604
+ }
3605
+
3606
+ if ((primaryCardRect.left + (primaryCardRect.width / 2)) < (window.innerWidth * 0.48)) score += 320;
3607
+ if (primaryCardRect.left <= window.innerWidth * 0.18) score += 140;
3608
+ if (primaryCardRect.width >= window.innerWidth * 0.38) score += 120;
3609
+ if ((primaryCardRect.left + primaryCardRect.width) <= window.innerWidth * 0.7) score += 160;
3610
+ if (buttonCenterX < (window.innerWidth * 0.34)) score += 220;
3611
+ if (buttonCenterX < (window.innerWidth * 0.4)) score += 120;
3612
+ if (rect.top < (window.innerHeight * 0.7)) score += 90;
3613
+ if (rect.width >= 120) score += 36;
3614
+ if (rect.height >= 44) score += 18;
3615
+ if (cardText.includes("mais do youtube")) score -= 320;
3616
+ if (primaryCardRect.left > window.innerWidth * 0.45) score -= 260;
3617
+ if (buttonCenterX > window.innerWidth * 0.42) score -= 360;
3618
+ score += Math.max(0, 40 - Math.round(rect.top / 20));
3619
+
3620
+ return score > 0 ? {
3621
+ node: element,
3622
+ score,
3623
+ label,
3624
+ matchedText: primaryCard ? deriveText(primaryCard) : deriveText(element),
3625
+ } : null;
3626
+ })
3627
+ .filter(Boolean)
3628
+ .sort((left, right) => right.score - left.score);
3629
+
3630
+ if (primaryCardButtons.length) {
3631
+ const winner = primaryCardButtons[0];
3632
+ return clickElement(
3633
+ winner.node,
3634
+ "safari_dom_ytmusic_primary_card",
3635
+ winner.matchedText || winner.label,
3636
+ "",
3637
+ winner.score,
3638
+ primaryCardButtons.length,
3639
+ );
3640
+ }
3641
+
3048
3642
  const rows = Array.from(document.querySelectorAll("ytmusic-responsive-list-item-renderer"))
3049
3643
  .filter((node) => node instanceof HTMLElement)
3050
3644
  .filter((node) => isVisible(node));
@@ -3137,6 +3731,215 @@ if (ytmResult) {
3137
3731
  return ytmResult;
3138
3732
  }
3139
3733
 
3734
+ function attemptSpotifyTrackPagePlayClick() {
3735
+ if (!location.hostname.includes("open.spotify.com")) {
3736
+ return null;
3737
+ }
3738
+ if (!location.pathname.includes("/track/")) {
3739
+ return null;
3740
+ }
3741
+
3742
+ const rankedButtons = Array.from(document.querySelectorAll(
3743
+ "main [data-testid='play-button'], main button[aria-label], main button[title], [data-testid='entityHeader'] button, main button"
3744
+ ))
3745
+ .filter((node) => node instanceof HTMLElement)
3746
+ .filter((node) => isVisible(node))
3747
+ .filter((node) => !(node.closest("footer")))
3748
+ .map((node, index) => {
3749
+ const rect = node.getBoundingClientRect();
3750
+ const label = normalize([
3751
+ deriveText(node),
3752
+ node.getAttribute("aria-label"),
3753
+ node.getAttribute("title"),
3754
+ node.getAttribute("data-testid"),
3755
+ ].filter(Boolean).join(" "));
3756
+ const background = normalize(window.getComputedStyle(node).backgroundColor || "");
3757
+ let score = 0;
3758
+
3759
+ if (!/play|tocar|reproduzir/.test(label)) {
3760
+ return null;
3761
+ }
3762
+
3763
+ if (node.getAttribute("data-testid") === "play-button") score += 220;
3764
+ if (label.includes("play")) score += 100;
3765
+ if (background.includes("29, 185, 84") || background.includes("30, 215, 96")) score += 180;
3766
+ if (rect.top <= window.innerHeight * 0.72) score += 90;
3767
+ if (rect.top <= window.innerHeight * 0.56) score += 70;
3768
+ if (rect.left <= window.innerWidth * 0.4) score += 80;
3769
+ if (rect.width >= 44) score += 30;
3770
+ if (rect.height >= 44) score += 30;
3771
+ if (node.closest("[data-testid='entityHeader'], main")) score += 60;
3772
+ score += Math.max(0, 18 - index);
3773
+
3774
+ return score > 0 ? { node, label, score } : null;
3775
+ })
3776
+ .filter(Boolean)
3777
+ .sort((left, right) => right.score - left.score);
3778
+
3779
+ if (!rankedButtons.length) {
3780
+ return null;
3781
+ }
3782
+
3783
+ const winner = rankedButtons[0];
3784
+ return clickElement(
3785
+ winner.node,
3786
+ "safari_dom_spotify_track_page_play",
3787
+ winner.label,
3788
+ location.href || "",
3789
+ winner.score,
3790
+ rankedButtons.length,
3791
+ );
3792
+ }
3793
+
3794
+ const spotifyTrackPagePlay = attemptSpotifyTrackPagePlayClick();
3795
+ if (spotifyTrackPagePlay) {
3796
+ return spotifyTrackPagePlay;
3797
+ }
3798
+
3799
+ function attemptSpotifySearchResultClick() {
3800
+ if (!location.hostname.includes("open.spotify.com")) {
3801
+ return null;
3802
+ }
3803
+ if (!quotedPhrases.length && !tokens.length) {
3804
+ return null;
3805
+ }
3806
+
3807
+ const rows = Array.from(document.querySelectorAll(
3808
+ "[data-testid='tracklist-row'], [role='row']"
3809
+ ))
3810
+ .filter((node) => node instanceof HTMLElement)
3811
+ .filter((node) => isVisible(node));
3812
+
3813
+ const rankedRows = rows
3814
+ .map((row, index) => {
3815
+ const titleNode = row.querySelector(
3816
+ "[data-testid='internal-track-link'], a[href*='/track/'], [aria-colindex='2'] a[href*='/track/']"
3817
+ );
3818
+ const navigationTarget = titleNode instanceof HTMLAnchorElement || titleNode instanceof HTMLElement
3819
+ ? titleNode
3820
+ : null;
3821
+ const titleText = String((titleNode && titleNode.textContent) || "").trim();
3822
+ const rowText = deriveText(row);
3823
+ const normalizedTitle = normalize(titleText);
3824
+ const normalizedRow = normalize(rowText);
3825
+ let score = 0;
3826
+
3827
+ for (const phrase of quotedPhrases) {
3828
+ if (!phrase) continue;
3829
+ if (normalizedTitle.includes(phrase)) score += 180;
3830
+ else if (normalizedRow.includes(phrase)) score += 110;
3831
+ }
3832
+
3833
+ for (const token of tokens) {
3834
+ if (normalizedTitle.includes(token)) score += 26;
3835
+ else if (normalizedRow.includes(token)) score += 10;
3836
+ }
3837
+
3838
+ const titleMatches = tokens.filter((token) => normalizedTitle.includes(token)).length;
3839
+ if (tokens.length > 1 && titleMatches >= Math.max(2, Math.ceil(tokens.length * 0.5))) score += 90;
3840
+ if (titleMatches === tokens.length && tokens.length > 0) score += 70;
3841
+ if (index < 4) score += Math.max(0, 24 - (index * 5));
3842
+
3843
+ const playCandidate = Array.from(row.querySelectorAll("button, [role='button'], a[href*='/track/']"))
3844
+ .filter((candidate) => candidate instanceof HTMLElement || candidate instanceof HTMLAnchorElement)
3845
+ .filter((candidate) => isVisible(candidate))
3846
+ .map((candidate) => {
3847
+ const label = normalize([
3848
+ deriveText(candidate),
3849
+ candidate.getAttribute("aria-label"),
3850
+ candidate.getAttribute("title"),
3851
+ ].filter(Boolean).join(" "));
3852
+ let candidateScore = 0;
3853
+ if (/play|tocar|reproduzir/.test(label)) candidateScore += 40;
3854
+ if (candidate instanceof HTMLAnchorElement && normalize(candidate.href).includes("/track/")) candidateScore += 18;
3855
+ return { candidate, candidateScore };
3856
+ })
3857
+ .sort((left, right) => right.candidateScore - left.candidateScore);
3858
+
3859
+ if (wantsSpotifyTrackPageOpen && !navigationTarget) {
3860
+ return null;
3861
+ }
3862
+
3863
+ return score > 0 ? {
3864
+ row,
3865
+ titleText,
3866
+ href: titleNode instanceof HTMLAnchorElement ? titleNode.href : "",
3867
+ score: score + (playCandidate[0]?.candidateScore || 0),
3868
+ target: wantsSpotifyTrackPageOpen
3869
+ ? navigationTarget
3870
+ : (playCandidate[0]?.candidate || navigationTarget || row),
3871
+ } : null;
3872
+ })
3873
+ .filter(Boolean)
3874
+ .sort((left, right) => right.score - left.score);
3875
+
3876
+ if (!rankedRows.length) {
3877
+ return null;
3878
+ }
3879
+
3880
+ const winner = rankedRows[0];
3881
+ const spotifyRow = winner.row instanceof HTMLElement ? winner.row : null;
3882
+ const titleCandidate = spotifyRow?.querySelector(
3883
+ "[data-testid='internal-track-link'], a[href*='/track/'], [aria-colindex='2'] a[href*='/track/']"
3884
+ );
3885
+ const explicitPlayButton = spotifyRow
3886
+ ? Array.from(spotifyRow.querySelectorAll("button, [role='button']"))
3887
+ .filter((node) => node instanceof HTMLElement)
3888
+ .filter((node) => isVisible(node))
3889
+ .find((node) => {
3890
+ const label = normalize([
3891
+ deriveText(node),
3892
+ node.getAttribute("aria-label"),
3893
+ node.getAttribute("title"),
3894
+ ].filter(Boolean).join(" "));
3895
+ return /\b(play|tocar|reproduzir)\b/.test(label);
3896
+ })
3897
+ : null;
3898
+
3899
+ const clickTarget = wantsSpotifyTrackPageOpen
3900
+ ? (titleCandidate || winner.target)
3901
+ : (explicitPlayButton || titleCandidate || winner.target);
3902
+ const clicked = clickElement(
3903
+ clickTarget,
3904
+ wantsSpotifyTrackPageOpen
3905
+ ? "safari_dom_spotify_track_link"
3906
+ : (explicitPlayButton ? "safari_dom_spotify_play_button" : "safari_dom_spotify_result"),
3907
+ winner.titleText || deriveText(winner.row),
3908
+ winner.href || "",
3909
+ winner.score,
3910
+ rankedRows.length,
3911
+ );
3912
+ if (!clicked) {
3913
+ return null;
3914
+ }
3915
+ if (spotifyRow instanceof HTMLElement && !explicitPlayButton && !wantsSpotifyTrackPageOpen) {
3916
+ const rect = spotifyRow.getBoundingClientRect();
3917
+ spotifyRow.dispatchEvent(new MouseEvent("dblclick", {
3918
+ bubbles: true,
3919
+ cancelable: true,
3920
+ view: window,
3921
+ clientX: rect.left + (rect.width / 2),
3922
+ clientY: rect.top + (rect.height / 2),
3923
+ }));
3924
+ }
3925
+ return clicked;
3926
+ }
3927
+
3928
+ const spotifyResult = attemptSpotifySearchResultClick();
3929
+ if (spotifyResult) {
3930
+ return spotifyResult;
3931
+ }
3932
+
3933
+ if (location.hostname.includes("open.spotify.com") && (wantsSpotifyTrackPageOpen || wantsSpotifyTrackPagePlay)) {
3934
+ return {
3935
+ clicked: false,
3936
+ reason: wantsSpotifyTrackPageOpen
3937
+ ? "Nenhuma faixa visivel do Spotify Web combinou com a busca atual; nao vou clicar em abas, playlists ou albuns por fallback."
3938
+ : "O Spotify Web nao mostrou um botao principal de reproduzir confiavel; nao vou clicar em elementos genericos por fallback.",
3939
+ strategy: "safari_dom_spotify_no_fallback",
3940
+ };
3941
+ }
3942
+
3140
3943
  function scoreCandidate(element, rank) {
3141
3944
  const text = deriveText(element);
3142
3945
  const href = element instanceof HTMLAnchorElement
@@ -3257,7 +4060,7 @@ tell application "Safari"
3257
4060
  activate
3258
4061
  if (count of windows) = 0 then error "Safari nao possui janelas abertas."
3259
4062
  delay 1
3260
- set jsCode to "(function(){const title=document.title||'';const url=location.href||'';const text=((document.body&&document.body.innerText)||'').trim().slice(0,12000);const playerButton=document.querySelector('ytmusic-player-bar #play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button#play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button.play-pause-button');const playerTitle=(Array.from(document.querySelectorAll('ytmusic-player-bar .title, ytmusic-player-bar .content-info-wrapper .title, ytmusic-player-bar [slot=title]')).map((node)=>((node&&node.textContent)||'').trim()).find(Boolean))||'';const playerState=(playerButton&&((playerButton.getAttribute('title')||playerButton.getAttribute('aria-label')||playerButton.textContent)||'').trim())||'';return JSON.stringify({title,url,text,playerTitle,playerState});})();"
4063
+ set jsCode to "(function(){const title=document.title||'';const url=location.href||'';const text=((document.body&&document.body.innerText)||'').trim().slice(0,12000);const isYouTubeMusic=location.hostname.includes('music.youtube.com');const isSpotify=location.hostname.includes('open.spotify.com');let playerButton=null;let playerTitle='';let playerState='';if(isYouTubeMusic){playerButton=document.querySelector('ytmusic-player-bar #play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button#play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button.play-pause-button');playerTitle=(Array.from(document.querySelectorAll('ytmusic-player-bar .title, ytmusic-player-bar .content-info-wrapper .title, ytmusic-player-bar [slot=title]')).map((node)=>((node&&node.textContent)||'').trim()).find(Boolean))||'';playerState=(playerButton&&((playerButton.getAttribute('title')||playerButton.getAttribute('aria-label')||playerButton.textContent)||'').trim())||'';}else if(isSpotify){const visible=(node)=>{if(!(node instanceof Element))return false;const rect=node.getBoundingClientRect();if(rect.width<4||rect.height<4)return false;const style=window.getComputedStyle(node);if(style.visibility==='hidden'||style.display==='none'||Number(style.opacity||'1')===0)return false;return rect.bottom>=0&&rect.right>=0&&rect.top<=window.innerHeight&&rect.left<=window.innerWidth;};const spotifyTitleCandidates=Array.from(document.querySelectorAll(\"[data-testid='nowplaying-track-link'], footer a[href*='/track/'], [data-testid='now-playing-widget'] a[href*='/track/'], a[href*='/track/']\")).filter((node)=>visible(node)).map((node)=>({node,text:((node&&node.textContent)||'').trim(),rect:node.getBoundingClientRect()})).filter((entry)=>entry.text).sort((left,right)=>{const leftBottomBias=(left.rect.top>=window.innerHeight*0.72?200:0)+(left.rect.left<=window.innerWidth*0.45?120:0)+left.rect.top;const rightBottomBias=(right.rect.top>=window.innerHeight*0.72?200:0)+(right.rect.left<=window.innerWidth*0.45?120:0)+right.rect.top;return rightBottomBias-leftBottomBias;});playerTitle=(spotifyTitleCandidates[0]&&spotifyTitleCandidates[0].text)||'';playerButton=Array.from(document.querySelectorAll(\"footer button, [data-testid='control-button-playpause'], button[aria-label], button[title]\")).filter((node)=>visible(node)).map((node)=>({node,label:((node.getAttribute('aria-label')||node.getAttribute('title')||node.textContent)||'').trim(),rect:node.getBoundingClientRect()})).filter((entry)=>/play|pause|tocar|pausar|reproduzir/i.test(entry.label)).sort((left,right)=>{const leftScore=(left.rect.top>=window.innerHeight*0.72?200:0)+Math.max(0,200-Math.abs((left.rect.left+left.rect.width/2)-(window.innerWidth/2)));const rightScore=(right.rect.top>=window.innerHeight*0.72?200:0)+Math.max(0,200-Math.abs((right.rect.left+right.rect.width/2)-(window.innerWidth/2)));return rightScore-leftScore;})[0]?.node||null;playerState=(playerButton&&((playerButton.getAttribute('aria-label')||playerButton.getAttribute('title')||playerButton.textContent)||'').trim())||'';}return JSON.stringify({title,url,text,playerTitle,playerState});})();"
3261
4064
  set pageJson to do JavaScript jsCode in current tab of front window
3262
4065
  end tell
3263
4066
  return pageJson