@leg3ndy/otto-bridge 0.8.1 → 0.8.2
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/dist/executors/native_macos.js +851 -16
- package/dist/http.js +6 -0
- package/dist/local_automations.js +559 -0
- package/dist/macos_whatsapp_helper.js +111 -0
- package/dist/runtime.js +8 -0
- package/dist/types.js +1 -1
- package/dist/whatsapp_background.js +107 -0
- package/package.json +1 -1
|
@@ -221,6 +221,136 @@ function descriptionWantsPause(description) {
|
|
|
221
221
|
function descriptionWantsResume(description) {
|
|
222
222
|
return /\b(retoma|retomar|resume|continu[ae]r|despausa|play)\b/.test(normalizeText(description || ""));
|
|
223
223
|
}
|
|
224
|
+
function descriptionWantsSpotifyTrackPageOpen(description) {
|
|
225
|
+
const normalized = normalizeText(description || "");
|
|
226
|
+
return /\bspotify\b/.test(normalized)
|
|
227
|
+
&& /\b(pagina|titulo|nome|link)\b/.test(normalized)
|
|
228
|
+
&& /\b(faixa|musica|track)\b/.test(normalized);
|
|
229
|
+
}
|
|
230
|
+
function descriptionWantsSpotifyTrackPagePlay(description) {
|
|
231
|
+
const normalized = normalizeText(description || "");
|
|
232
|
+
return /\bspotify\b/.test(normalized)
|
|
233
|
+
&& /\b(play|reproduzir|tocar|verde|principal)\b/.test(normalized)
|
|
234
|
+
&& /\b(pagina|faixa|musica|track)\b/.test(normalized);
|
|
235
|
+
}
|
|
236
|
+
function isSpotifySafariDomOnlyStep(description) {
|
|
237
|
+
return descriptionWantsSpotifyTrackPageOpen(description) || descriptionWantsSpotifyTrackPagePlay(description);
|
|
238
|
+
}
|
|
239
|
+
function playerStateLooksActive(playerState) {
|
|
240
|
+
const normalized = normalizeText(playerState || "");
|
|
241
|
+
return normalized.includes("pause") || normalized.includes("pausar");
|
|
242
|
+
}
|
|
243
|
+
function browserStateMatchesSpotifyTrackDescription(state, description) {
|
|
244
|
+
if (!state) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
const normalizedUrl = normalizeComparableUrl(state.url || "");
|
|
248
|
+
if (!normalizedUrl.includes("open.spotify.com/track/")) {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
const haystack = [
|
|
252
|
+
state.playerTitle || "",
|
|
253
|
+
state.title || "",
|
|
254
|
+
String(state.text || "").slice(0, 4000),
|
|
255
|
+
].join(" ");
|
|
256
|
+
const normalizedHaystack = normalizeText(haystack);
|
|
257
|
+
if (!normalizedHaystack) {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
const quotedPhrases = extractQuotedPhrases(description);
|
|
261
|
+
if (quotedPhrases.some((phrase) => normalizedHaystack.includes(phrase))) {
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
const mediaQueryTokens = extractMediaQueryTokens(description);
|
|
265
|
+
if (!mediaQueryTokens.length) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
const matchCount = countMatchingTokens(haystack, mediaQueryTokens);
|
|
269
|
+
return matchCount >= Math.max(1, Math.ceil(mediaQueryTokens.length * 0.5));
|
|
270
|
+
}
|
|
271
|
+
function spotifyStateContentMatchesDescription(state, description) {
|
|
272
|
+
if (!state) {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
const normalizedUrl = normalizeComparableUrl(state.url || "");
|
|
276
|
+
if (!normalizedUrl.includes("open.spotify.com")) {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
const haystack = [
|
|
280
|
+
state.playerTitle || "",
|
|
281
|
+
state.title || "",
|
|
282
|
+
String(state.text || "").slice(0, 4000),
|
|
283
|
+
].join(" ");
|
|
284
|
+
const normalizedHaystack = normalizeText(haystack);
|
|
285
|
+
if (!normalizedHaystack) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
const quotedPhrases = extractQuotedPhrases(description);
|
|
289
|
+
if (quotedPhrases.some((phrase) => normalizedHaystack.includes(phrase))) {
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
const mediaQueryTokens = extractMediaQueryTokens(description);
|
|
293
|
+
if (!mediaQueryTokens.length) {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
const matchCount = countMatchingTokens(haystack, mediaQueryTokens);
|
|
297
|
+
return matchCount >= Math.max(1, Math.ceil(mediaQueryTokens.length * 0.5));
|
|
298
|
+
}
|
|
299
|
+
function spotifyPlayerAlreadyPlayingMatchesDescription(state, description) {
|
|
300
|
+
if (!state) {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
const normalizedUrl = normalizeComparableUrl(state.url || "");
|
|
304
|
+
if (!normalizedUrl.includes("open.spotify.com")) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
if (!playerStateLooksActive(state.playerState || "")) {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
const haystack = [
|
|
311
|
+
state.playerTitle || "",
|
|
312
|
+
state.title || "",
|
|
313
|
+
String(state.text || "").slice(0, 4000),
|
|
314
|
+
].join(" ");
|
|
315
|
+
const normalizedHaystack = normalizeText(haystack);
|
|
316
|
+
if (!normalizedHaystack) {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
const quotedPhrases = extractQuotedPhrases(description);
|
|
320
|
+
if (quotedPhrases.some((phrase) => normalizedHaystack.includes(phrase))) {
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
const mediaQueryTokens = extractMediaQueryTokens(description);
|
|
324
|
+
if (!mediaQueryTokens.length) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
const matchCount = countMatchingTokens(haystack, mediaQueryTokens);
|
|
328
|
+
return matchCount >= Math.max(1, Math.ceil(mediaQueryTokens.length * 0.5));
|
|
329
|
+
}
|
|
330
|
+
function spotifyTrackAlreadyPlayingMatchesDescription(state, description) {
|
|
331
|
+
return browserStateMatchesSpotifyTrackDescription(state, description)
|
|
332
|
+
&& spotifyPlayerAlreadyPlayingMatchesDescription(state, description);
|
|
333
|
+
}
|
|
334
|
+
function spotifyTrackPageLooksPlaying(state, description) {
|
|
335
|
+
return browserStateMatchesSpotifyTrackDescription(state, description)
|
|
336
|
+
&& playerStateLooksActive(state?.playerState || "");
|
|
337
|
+
}
|
|
338
|
+
function spotifyDescriptionsLikelyMatch(left, right) {
|
|
339
|
+
const leftPhrases = new Set(extractQuotedPhrases(left));
|
|
340
|
+
const rightPhrases = new Set(extractQuotedPhrases(right));
|
|
341
|
+
for (const phrase of leftPhrases) {
|
|
342
|
+
if (rightPhrases.has(phrase)) {
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const leftTokens = extractMediaQueryTokens(left);
|
|
347
|
+
const rightTokens = new Set(extractMediaQueryTokens(right));
|
|
348
|
+
if (!leftTokens.length || !rightTokens.size) {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
const overlap = leftTokens.filter((token) => rightTokens.has(token)).length;
|
|
352
|
+
return overlap >= Math.max(1, Math.ceil(Math.min(leftTokens.length, rightTokens.size) * 0.5));
|
|
353
|
+
}
|
|
224
354
|
function extractNativeMediaTransportCommand(description) {
|
|
225
355
|
const normalizedDescription = normalizeText(description || "");
|
|
226
356
|
if (!normalizedDescription
|
|
@@ -473,6 +603,10 @@ function urlHostname(url) {
|
|
|
473
603
|
return null;
|
|
474
604
|
}
|
|
475
605
|
}
|
|
606
|
+
function isSpotifySearchUrl(url) {
|
|
607
|
+
const normalized = normalizeComparableUrl(normalizeUrl(url));
|
|
608
|
+
return normalized.includes("open.spotify.com/search/");
|
|
609
|
+
}
|
|
476
610
|
function uniqueStrings(values) {
|
|
477
611
|
const seen = new Set();
|
|
478
612
|
const result = [];
|
|
@@ -971,6 +1105,9 @@ export class NativeMacOSJobExecutor {
|
|
|
971
1105
|
lastActiveApp = null;
|
|
972
1106
|
lastVisualTargetDescription = null;
|
|
973
1107
|
lastVisualTargetApp = null;
|
|
1108
|
+
lastSatisfiedSpotifyDescription = null;
|
|
1109
|
+
lastSatisfiedSpotifyConfirmedPlaying = false;
|
|
1110
|
+
lastSatisfiedSpotifyAt = 0;
|
|
974
1111
|
whatsappBackgroundBrowser = null;
|
|
975
1112
|
whatsappRuntimeMonitor = null;
|
|
976
1113
|
constructor(bridgeConfig) {
|
|
@@ -987,6 +1124,9 @@ export class NativeMacOSJobExecutor {
|
|
|
987
1124
|
if (actions.length === 0) {
|
|
988
1125
|
throw new Error("Otto Bridge native-macos could not derive a supported local action from this request");
|
|
989
1126
|
}
|
|
1127
|
+
this.lastSatisfiedSpotifyDescription = null;
|
|
1128
|
+
this.lastSatisfiedSpotifyConfirmedPlaying = false;
|
|
1129
|
+
this.lastSatisfiedSpotifyAt = 0;
|
|
990
1130
|
await reporter.accepted();
|
|
991
1131
|
const confirmation = extractConfirmationOptions(job, actions);
|
|
992
1132
|
if (confirmation.required) {
|
|
@@ -1025,7 +1165,7 @@ export class NativeMacOSJobExecutor {
|
|
|
1025
1165
|
}
|
|
1026
1166
|
if (action.type === "press_shortcut") {
|
|
1027
1167
|
await reporter.progress(progressPercent, `Enviando atalho ${action.shortcut}`);
|
|
1028
|
-
await this.pressShortcut(action.shortcut);
|
|
1168
|
+
const shortcutResult = await this.pressShortcut(action.shortcut);
|
|
1029
1169
|
if (action.shortcut.startsWith("media_")) {
|
|
1030
1170
|
const mediaSummaryMap = {
|
|
1031
1171
|
media_next: "Acionei o comando de próxima mídia no macOS.",
|
|
@@ -1035,7 +1175,14 @@ export class NativeMacOSJobExecutor {
|
|
|
1035
1175
|
media_play: "Acionei o comando de reproduzir mídia no macOS.",
|
|
1036
1176
|
media_play_pause: "Acionei o comando de play/pause de mídia no macOS.",
|
|
1037
1177
|
};
|
|
1038
|
-
|
|
1178
|
+
const mediaSkippedSummaryMap = {
|
|
1179
|
+
media_pause: "Nenhuma mídia estava tocando no macOS, então o pause global foi pulado.",
|
|
1180
|
+
media_resume: "Já havia mídia tocando no macOS, então o comando de retomar foi pulado.",
|
|
1181
|
+
media_play: "Já havia mídia tocando no macOS, então o comando de reproduzir foi pulado.",
|
|
1182
|
+
};
|
|
1183
|
+
completionNotes.push(shortcutResult.performed
|
|
1184
|
+
? (mediaSummaryMap[action.shortcut] || `Acionei ${action.shortcut} no macOS.`)
|
|
1185
|
+
: (mediaSkippedSummaryMap[action.shortcut] || shortcutResult.reason || `O atalho ${action.shortcut} foi pulado no macOS.`));
|
|
1039
1186
|
}
|
|
1040
1187
|
continue;
|
|
1041
1188
|
}
|
|
@@ -1239,7 +1386,9 @@ export class NativeMacOSJobExecutor {
|
|
|
1239
1386
|
await reporter.progress(progressPercent, `Trazendo ${action.app} para frente antes do clique`);
|
|
1240
1387
|
await this.focusApp(action.app);
|
|
1241
1388
|
}
|
|
1242
|
-
const targetDescriptions =
|
|
1389
|
+
const targetDescriptions = isSpotifySafariDomOnlyStep(action.description)
|
|
1390
|
+
? [action.description]
|
|
1391
|
+
: uniqueStrings([action.description, ...(action.retry_descriptions || [])]);
|
|
1243
1392
|
let clickSucceeded = false;
|
|
1244
1393
|
let lastFailureReason = "";
|
|
1245
1394
|
for (let attempt = 0; attempt < targetDescriptions.length; attempt += 1) {
|
|
@@ -1247,6 +1396,33 @@ export class NativeMacOSJobExecutor {
|
|
|
1247
1396
|
const initialBrowserState = browserApp
|
|
1248
1397
|
? await this.captureBrowserPageState(browserApp).catch(() => null)
|
|
1249
1398
|
: null;
|
|
1399
|
+
const isSpotifySafariStep = browserApp === "Safari" && isSpotifySafariDomOnlyStep(targetDescription);
|
|
1400
|
+
const verificationPrompt = isSpotifySafariStep ? undefined : action.verification_prompt;
|
|
1401
|
+
if (isSpotifySafariStep) {
|
|
1402
|
+
await reporter.progress(progressPercent, `Tentando concluir ${targetDescription} pelo DOM do Spotify no Safari`);
|
|
1403
|
+
const spotifyDomResult = await this.executeSpotifySafariDomStep(targetDescription, initialBrowserState);
|
|
1404
|
+
if (spotifyDomResult.ok) {
|
|
1405
|
+
this.rememberSatisfiedSpotifyStep(targetDescription, !!spotifyDomResult.confirmedPlaying);
|
|
1406
|
+
this.lastVisualTargetDescription = targetDescription;
|
|
1407
|
+
this.lastVisualTargetApp = browserApp || action.app || this.lastActiveApp;
|
|
1408
|
+
resultPayload.last_click = {
|
|
1409
|
+
strategy: spotifyDomResult.skipped
|
|
1410
|
+
? "spotify_browser_state_skip"
|
|
1411
|
+
: (spotifyDomResult.click?.strategy || "spotify_safari_dom"),
|
|
1412
|
+
matched_text: spotifyDomResult.click?.matchedText || targetDescription,
|
|
1413
|
+
matched_href: spotifyDomResult.click?.matchedHref || spotifyDomResult.afterState?.url || null,
|
|
1414
|
+
score: spotifyDomResult.click?.score || null,
|
|
1415
|
+
total_candidates: spotifyDomResult.click?.totalCandidates || null,
|
|
1416
|
+
};
|
|
1417
|
+
completionNotes.push(spotifyDomResult.skipped
|
|
1418
|
+
? `O Spotify Web ja estava no estado certo para ${targetDescription}, então pulei tentativas extras.`
|
|
1419
|
+
: `Concluí ${targetDescription} diretamente pelo DOM do Spotify Web no Safari.`);
|
|
1420
|
+
clickSucceeded = true;
|
|
1421
|
+
break;
|
|
1422
|
+
}
|
|
1423
|
+
lastFailureReason = spotifyDomResult.reason || `Nao consegui concluir ${targetDescription} pelo DOM do Spotify Web no Safari.`;
|
|
1424
|
+
continue;
|
|
1425
|
+
}
|
|
1250
1426
|
const nativeMediaTransport = extractNativeMediaTransportCommand(targetDescription);
|
|
1251
1427
|
if (nativeMediaTransport) {
|
|
1252
1428
|
await reporter.progress(progressPercent, `Tentando controle de mídia nativo do macOS para ${targetDescription}`);
|
|
@@ -1259,8 +1435,8 @@ export class NativeMacOSJobExecutor {
|
|
|
1259
1435
|
validated = browserValidation.ok;
|
|
1260
1436
|
validationReason = browserValidation.reason;
|
|
1261
1437
|
}
|
|
1262
|
-
if (!validated &&
|
|
1263
|
-
const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription,
|
|
1438
|
+
if (!validated && verificationPrompt) {
|
|
1439
|
+
const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, "native_media_transport_result");
|
|
1264
1440
|
if (verification.unavailable) {
|
|
1265
1441
|
lastFailureReason = verification.reason;
|
|
1266
1442
|
break;
|
|
@@ -1298,8 +1474,8 @@ export class NativeMacOSJobExecutor {
|
|
|
1298
1474
|
validated = browserValidation.ok;
|
|
1299
1475
|
validationReason = browserValidation.reason;
|
|
1300
1476
|
}
|
|
1301
|
-
if (!validated &&
|
|
1302
|
-
const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription,
|
|
1477
|
+
if (!validated && verificationPrompt) {
|
|
1478
|
+
const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, "dom_click_result");
|
|
1303
1479
|
if (verification.unavailable) {
|
|
1304
1480
|
lastFailureReason = verification.reason;
|
|
1305
1481
|
break;
|
|
@@ -1344,8 +1520,8 @@ export class NativeMacOSJobExecutor {
|
|
|
1344
1520
|
validated = browserValidation.ok;
|
|
1345
1521
|
validationReason = browserValidation.reason;
|
|
1346
1522
|
}
|
|
1347
|
-
if (!validated &&
|
|
1348
|
-
const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription,
|
|
1523
|
+
if (!validated && verificationPrompt) {
|
|
1524
|
+
const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, "local_ocr_click_result");
|
|
1349
1525
|
if (verification.unavailable) {
|
|
1350
1526
|
lastFailureReason = verification.reason;
|
|
1351
1527
|
break;
|
|
@@ -1425,8 +1601,8 @@ export class NativeMacOSJobExecutor {
|
|
|
1425
1601
|
y: scaledY,
|
|
1426
1602
|
strategy: "visual_locator",
|
|
1427
1603
|
};
|
|
1428
|
-
if (
|
|
1429
|
-
const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription,
|
|
1604
|
+
if (verificationPrompt) {
|
|
1605
|
+
const verification = await this.validateVisualClickWithVision(job.job_id, targetDescription, verificationPrompt, progressPercent, reporter, artifacts, "visual_click_result");
|
|
1430
1606
|
if (verification.unavailable) {
|
|
1431
1607
|
lastFailureReason = verification.reason;
|
|
1432
1608
|
break;
|
|
@@ -1531,6 +1707,9 @@ export class NativeMacOSJobExecutor {
|
|
|
1531
1707
|
await this.runCommand("open", ["-a", app, url]);
|
|
1532
1708
|
}
|
|
1533
1709
|
await this.focusApp(app);
|
|
1710
|
+
if (isSpotifySearchUrl(url)) {
|
|
1711
|
+
await this.ensureSafariSpotifySearchUrl(url);
|
|
1712
|
+
}
|
|
1534
1713
|
this.lastActiveApp = app;
|
|
1535
1714
|
return;
|
|
1536
1715
|
}
|
|
@@ -1542,6 +1721,48 @@ export class NativeMacOSJobExecutor {
|
|
|
1542
1721
|
}
|
|
1543
1722
|
await this.runCommand("open", [url]);
|
|
1544
1723
|
}
|
|
1724
|
+
async ensureSafariSpotifySearchUrl(url) {
|
|
1725
|
+
const targetUrl = normalizeUrl(url);
|
|
1726
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1727
|
+
await delay(700);
|
|
1728
|
+
const currentUrl = await this.readSafariCurrentUrl().catch(() => "");
|
|
1729
|
+
const normalizedCurrentUrl = normalizeComparableUrl(currentUrl);
|
|
1730
|
+
if (!normalizedCurrentUrl || !normalizedCurrentUrl.includes("open.spotify.com")) {
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
if (normalizedCurrentUrl.includes("/search/")) {
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
if (!/open\.spotify\.com\/?$/.test(normalizedCurrentUrl)) {
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
await this.setSafariFrontTabUrl(targetUrl).catch(() => undefined);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
async readSafariCurrentUrl() {
|
|
1743
|
+
const script = `
|
|
1744
|
+
tell application "Safari"
|
|
1745
|
+
if (count of windows) = 0 then return ""
|
|
1746
|
+
try
|
|
1747
|
+
return URL of current tab of front window
|
|
1748
|
+
on error
|
|
1749
|
+
return ""
|
|
1750
|
+
end try
|
|
1751
|
+
end tell
|
|
1752
|
+
`;
|
|
1753
|
+
const { stdout } = await this.runCommandCapture("osascript", ["-e", script]);
|
|
1754
|
+
return String(stdout || "").trim();
|
|
1755
|
+
}
|
|
1756
|
+
async setSafariFrontTabUrl(url) {
|
|
1757
|
+
const script = `
|
|
1758
|
+
set targetUrl to "${escapeAppleScript(url)}"
|
|
1759
|
+
tell application "Safari"
|
|
1760
|
+
if (count of windows) = 0 then return
|
|
1761
|
+
set URL of current tab of front window to targetUrl
|
|
1762
|
+
end tell
|
|
1763
|
+
`;
|
|
1764
|
+
await this.runCommand("osascript", ["-e", script]);
|
|
1765
|
+
}
|
|
1545
1766
|
async tryReuseSafariTab(url) {
|
|
1546
1767
|
const targetUrl = normalizeUrl(url);
|
|
1547
1768
|
const targetHost = urlHostname(targetUrl);
|
|
@@ -1651,6 +1872,161 @@ end tell
|
|
|
1651
1872
|
playerState: page.playerState || "",
|
|
1652
1873
|
};
|
|
1653
1874
|
}
|
|
1875
|
+
rememberSatisfiedSpotifyStep(description, confirmedPlaying) {
|
|
1876
|
+
if (!isSpotifySafariDomOnlyStep(description)) {
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
this.lastSatisfiedSpotifyDescription = description;
|
|
1880
|
+
this.lastSatisfiedSpotifyConfirmedPlaying = confirmedPlaying;
|
|
1881
|
+
this.lastSatisfiedSpotifyAt = Date.now();
|
|
1882
|
+
}
|
|
1883
|
+
hasRecentSatisfiedSpotifyPlayback(description, maxAgeMs = 10_000) {
|
|
1884
|
+
if (!this.lastSatisfiedSpotifyConfirmedPlaying || !this.lastSatisfiedSpotifyDescription || !this.lastSatisfiedSpotifyAt) {
|
|
1885
|
+
return false;
|
|
1886
|
+
}
|
|
1887
|
+
if ((Date.now() - this.lastSatisfiedSpotifyAt) > maxAgeMs) {
|
|
1888
|
+
return false;
|
|
1889
|
+
}
|
|
1890
|
+
return spotifyDescriptionsLikelyMatch(this.lastSatisfiedSpotifyDescription, description);
|
|
1891
|
+
}
|
|
1892
|
+
hasRecentSatisfiedSpotifyStep(description, maxAgeMs = 10_000) {
|
|
1893
|
+
if (!this.lastSatisfiedSpotifyDescription || !this.lastSatisfiedSpotifyAt) {
|
|
1894
|
+
return false;
|
|
1895
|
+
}
|
|
1896
|
+
if ((Date.now() - this.lastSatisfiedSpotifyAt) > maxAgeMs) {
|
|
1897
|
+
return false;
|
|
1898
|
+
}
|
|
1899
|
+
return spotifyDescriptionsLikelyMatch(this.lastSatisfiedSpotifyDescription, description);
|
|
1900
|
+
}
|
|
1901
|
+
async executeSpotifySafariDomStep(targetDescription, initialBrowserState) {
|
|
1902
|
+
if (descriptionWantsSpotifyTrackPagePlay(targetDescription)
|
|
1903
|
+
&& this.hasRecentSatisfiedSpotifyStep(targetDescription)
|
|
1904
|
+
&& browserStateMatchesSpotifyTrackDescription(initialBrowserState, targetDescription)) {
|
|
1905
|
+
return {
|
|
1906
|
+
ok: true,
|
|
1907
|
+
skipped: true,
|
|
1908
|
+
afterState: initialBrowserState,
|
|
1909
|
+
};
|
|
1910
|
+
}
|
|
1911
|
+
if (descriptionWantsSpotifyTrackPagePlay(targetDescription) && this.hasRecentSatisfiedSpotifyPlayback(targetDescription)) {
|
|
1912
|
+
return {
|
|
1913
|
+
ok: true,
|
|
1914
|
+
skipped: true,
|
|
1915
|
+
confirmedPlaying: true,
|
|
1916
|
+
afterState: initialBrowserState,
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
if (spotifyPlayerAlreadyPlayingMatchesDescription(initialBrowserState, targetDescription)) {
|
|
1920
|
+
return {
|
|
1921
|
+
ok: true,
|
|
1922
|
+
skipped: true,
|
|
1923
|
+
confirmedPlaying: true,
|
|
1924
|
+
afterState: initialBrowserState,
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
if (descriptionWantsSpotifyTrackPagePlay(targetDescription)) {
|
|
1928
|
+
const shouldWaitLongerForAutoplay = this.hasRecentSatisfiedSpotifyStep(targetDescription);
|
|
1929
|
+
let settledState = initialBrowserState;
|
|
1930
|
+
for (let attempt = 0; attempt < (shouldWaitLongerForAutoplay ? 6 : 3); attempt += 1) {
|
|
1931
|
+
if (attempt > 0) {
|
|
1932
|
+
await delay(shouldWaitLongerForAutoplay ? 550 : 450);
|
|
1933
|
+
}
|
|
1934
|
+
settledState = await this.captureBrowserPageState("Safari").catch(() => settledState);
|
|
1935
|
+
const playbackState = await this.readGlobalMediaPlaybackState();
|
|
1936
|
+
if ((playbackState === "playing" && spotifyStateContentMatchesDescription(settledState, targetDescription))
|
|
1937
|
+
|| spotifyTrackPageLooksPlaying(settledState, targetDescription)
|
|
1938
|
+
|| spotifyTrackAlreadyPlayingMatchesDescription(settledState, targetDescription)) {
|
|
1939
|
+
return {
|
|
1940
|
+
ok: true,
|
|
1941
|
+
skipped: true,
|
|
1942
|
+
confirmedPlaying: true,
|
|
1943
|
+
afterState: settledState,
|
|
1944
|
+
};
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
const alreadyOnRequestedTrack = browserStateMatchesSpotifyTrackDescription(initialBrowserState, targetDescription);
|
|
1948
|
+
if (alreadyOnRequestedTrack) {
|
|
1949
|
+
const playbackState = await this.readGlobalMediaPlaybackState();
|
|
1950
|
+
if (playbackState === "playing"
|
|
1951
|
+
|| spotifyTrackPageLooksPlaying(initialBrowserState, targetDescription)
|
|
1952
|
+
|| spotifyTrackAlreadyPlayingMatchesDescription(initialBrowserState, targetDescription)) {
|
|
1953
|
+
return {
|
|
1954
|
+
ok: true,
|
|
1955
|
+
skipped: true,
|
|
1956
|
+
confirmedPlaying: true,
|
|
1957
|
+
afterState: initialBrowserState,
|
|
1958
|
+
};
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
const domClick = await this.trySafariDomClick(targetDescription);
|
|
1963
|
+
let currentState = await this.captureBrowserPageState("Safari").catch(() => initialBrowserState);
|
|
1964
|
+
if (!domClick?.clicked) {
|
|
1965
|
+
return {
|
|
1966
|
+
ok: false,
|
|
1967
|
+
reason: domClick?.reason || `Nao consegui concluir ${targetDescription} pelo DOM do Spotify Web no Safari.`,
|
|
1968
|
+
afterState: currentState,
|
|
1969
|
+
};
|
|
1970
|
+
}
|
|
1971
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
1972
|
+
if (attempt > 0) {
|
|
1973
|
+
await delay(500);
|
|
1974
|
+
}
|
|
1975
|
+
currentState = await this.captureBrowserPageState("Safari").catch(() => currentState);
|
|
1976
|
+
if (descriptionWantsSpotifyTrackPageOpen(targetDescription)) {
|
|
1977
|
+
if (browserStateMatchesSpotifyTrackDescription(currentState, targetDescription)) {
|
|
1978
|
+
return {
|
|
1979
|
+
ok: true,
|
|
1980
|
+
click: domClick,
|
|
1981
|
+
afterState: currentState,
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
const playbackState = await this.readGlobalMediaPlaybackState();
|
|
1985
|
+
if (playbackState === "playing" && spotifyStateContentMatchesDescription(currentState, targetDescription)) {
|
|
1986
|
+
return {
|
|
1987
|
+
ok: true,
|
|
1988
|
+
click: domClick,
|
|
1989
|
+
confirmedPlaying: true,
|
|
1990
|
+
afterState: currentState,
|
|
1991
|
+
};
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
if (descriptionWantsSpotifyTrackPagePlay(targetDescription)) {
|
|
1995
|
+
if (spotifyPlayerAlreadyPlayingMatchesDescription(currentState, targetDescription)) {
|
|
1996
|
+
return {
|
|
1997
|
+
ok: true,
|
|
1998
|
+
click: domClick,
|
|
1999
|
+
confirmedPlaying: true,
|
|
2000
|
+
afterState: currentState,
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
2003
|
+
const playbackState = await this.readGlobalMediaPlaybackState();
|
|
2004
|
+
if (playbackState === "playing" && spotifyStateContentMatchesDescription(currentState, targetDescription)) {
|
|
2005
|
+
return {
|
|
2006
|
+
ok: true,
|
|
2007
|
+
click: domClick,
|
|
2008
|
+
confirmedPlaying: true,
|
|
2009
|
+
afterState: currentState,
|
|
2010
|
+
};
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
if (descriptionWantsSpotifyTrackPageOpen(targetDescription)) {
|
|
2015
|
+
const matchedHref = normalizeComparableUrl(domClick.matchedHref || "");
|
|
2016
|
+
if (matchedHref.includes("/track/")) {
|
|
2017
|
+
return {
|
|
2018
|
+
ok: true,
|
|
2019
|
+
click: domClick,
|
|
2020
|
+
afterState: currentState,
|
|
2021
|
+
};
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
return {
|
|
2025
|
+
ok: false,
|
|
2026
|
+
reason: `O clique em ${targetDescription} nao mudou a pagina do navegador de forma verificavel.`,
|
|
2027
|
+
afterState: currentState,
|
|
2028
|
+
};
|
|
2029
|
+
}
|
|
1654
2030
|
resolveExpectedBrowserHref(rawHref, baseUrl) {
|
|
1655
2031
|
const href = String(rawHref || "").trim();
|
|
1656
2032
|
if (!href) {
|
|
@@ -1691,12 +2067,14 @@ end tell
|
|
|
1691
2067
|
const afterPlayerTitle = normalizeText(after.playerTitle || "");
|
|
1692
2068
|
const beforePlayerState = normalizeText(before?.playerState || "");
|
|
1693
2069
|
const afterPlayerState = normalizeText(after.playerState || "");
|
|
1694
|
-
const playerLooksActive =
|
|
2070
|
+
const playerLooksActive = playerStateLooksActive(after.playerState || "");
|
|
1695
2071
|
const playerLooksPaused = !playerLooksActive && /play|tocar|reproduzir|continuar|retomar|resume/.test(afterPlayerState);
|
|
1696
2072
|
const wantsNext = descriptionWantsNext(targetDescription);
|
|
1697
2073
|
const wantsPrevious = descriptionWantsPrevious(targetDescription);
|
|
1698
2074
|
const wantsPause = descriptionWantsPause(targetDescription);
|
|
1699
2075
|
const wantsResume = descriptionWantsResume(targetDescription);
|
|
2076
|
+
const wantsSpotifyTrackPageOpen = descriptionWantsSpotifyTrackPageOpen(targetDescription);
|
|
2077
|
+
const wantsSpotifyTrackPagePlay = descriptionWantsSpotifyTrackPagePlay(targetDescription);
|
|
1700
2078
|
const mediaQueryTokens = extractMediaQueryTokens(targetDescription);
|
|
1701
2079
|
const mediaMatchCount = countMatchingTokens(after.playerTitle || "", mediaQueryTokens);
|
|
1702
2080
|
if (afterUrl.includes("music.youtube.com")) {
|
|
@@ -1722,6 +2100,51 @@ end tell
|
|
|
1722
2100
|
return true;
|
|
1723
2101
|
}
|
|
1724
2102
|
}
|
|
2103
|
+
if (afterUrl.includes("open.spotify.com")) {
|
|
2104
|
+
if (wantsSpotifyTrackPageOpen && spotifyPlayerAlreadyPlayingMatchesDescription(after, targetDescription)) {
|
|
2105
|
+
return true;
|
|
2106
|
+
}
|
|
2107
|
+
if (wantsSpotifyTrackPagePlay && spotifyTrackAlreadyPlayingMatchesDescription(after, targetDescription)) {
|
|
2108
|
+
return true;
|
|
2109
|
+
}
|
|
2110
|
+
if (wantsSpotifyTrackPageOpen) {
|
|
2111
|
+
const beforeWasTrackPage = beforeUrl.includes("/track/");
|
|
2112
|
+
const afterIsTrackPage = afterUrl.includes("/track/");
|
|
2113
|
+
if (afterIsTrackPage && (!beforeWasTrackPage || beforeUrl !== afterUrl)) {
|
|
2114
|
+
return true;
|
|
2115
|
+
}
|
|
2116
|
+
return spotifyPlayerAlreadyPlayingMatchesDescription(after, targetDescription);
|
|
2117
|
+
}
|
|
2118
|
+
if (wantsPause && beforePlayerState && beforePlayerState !== afterPlayerState && playerLooksPaused) {
|
|
2119
|
+
return true;
|
|
2120
|
+
}
|
|
2121
|
+
if (wantsResume && playerLooksActive && beforePlayerState !== afterPlayerState) {
|
|
2122
|
+
return true;
|
|
2123
|
+
}
|
|
2124
|
+
if ((wantsNext || wantsPrevious) && beforePlayerTitle && afterPlayerTitle && beforePlayerTitle !== afterPlayerTitle) {
|
|
2125
|
+
return true;
|
|
2126
|
+
}
|
|
2127
|
+
if (mediaQueryTokens.length >= 2) {
|
|
2128
|
+
const requiredMatches = Math.max(2, Math.ceil(mediaQueryTokens.length * 0.5));
|
|
2129
|
+
const titleLooksCorrect = mediaMatchCount >= requiredMatches;
|
|
2130
|
+
const playerChanged = ((beforePlayerTitle && afterPlayerTitle && beforePlayerTitle !== afterPlayerTitle)
|
|
2131
|
+
|| (!beforePlayerTitle && !!afterPlayerTitle)
|
|
2132
|
+
|| (beforePlayerState && afterPlayerState && beforePlayerState !== afterPlayerState));
|
|
2133
|
+
if (titleLooksCorrect && (playerChanged || (beforePlayerTitle === afterPlayerTitle && playerLooksActive))) {
|
|
2134
|
+
return true;
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
if (beforePlayerTitle && afterPlayerTitle && beforePlayerTitle !== afterPlayerTitle && playerLooksActive) {
|
|
2138
|
+
return true;
|
|
2139
|
+
}
|
|
2140
|
+
if (!beforePlayerTitle && afterPlayerTitle && playerLooksActive) {
|
|
2141
|
+
return true;
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
const looksLikeMediaAction = wantsNext || wantsPrevious || wantsPause || wantsResume || mediaQueryTokens.length > 0;
|
|
2145
|
+
if (looksLikeMediaAction && (afterUrl.includes("music.youtube.com") || afterUrl.includes("open.spotify.com"))) {
|
|
2146
|
+
return false;
|
|
2147
|
+
}
|
|
1725
2148
|
const beforeTitle = normalizeText(before?.title || "");
|
|
1726
2149
|
const afterTitle = normalizeText(after.title || "");
|
|
1727
2150
|
if (beforeTitle && afterTitle && beforeTitle !== afterTitle) {
|
|
@@ -1742,9 +2165,11 @@ end tell
|
|
|
1742
2165
|
afterState: null,
|
|
1743
2166
|
};
|
|
1744
2167
|
}
|
|
2168
|
+
let lastAfterState = null;
|
|
1745
2169
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
1746
2170
|
await delay(attempt === 0 ? 900 : 700);
|
|
1747
2171
|
const afterState = await this.captureBrowserPageState(app).catch(() => null);
|
|
2172
|
+
lastAfterState = afterState;
|
|
1748
2173
|
if (this.didBrowserPageStateChange(before, afterState, targetDescription, matchedHref)) {
|
|
1749
2174
|
return {
|
|
1750
2175
|
ok: true,
|
|
@@ -1756,7 +2181,7 @@ end tell
|
|
|
1756
2181
|
return {
|
|
1757
2182
|
ok: false,
|
|
1758
2183
|
reason: `O clique em ${targetDescription} nao mudou a pagina do navegador de forma verificavel.`,
|
|
1759
|
-
afterState:
|
|
2184
|
+
afterState: lastAfterState,
|
|
1760
2185
|
};
|
|
1761
2186
|
}
|
|
1762
2187
|
async pressShortcut(shortcut) {
|
|
@@ -1778,8 +2203,28 @@ end tell
|
|
|
1778
2203
|
};
|
|
1779
2204
|
const mediaCommand = mediaCommandMap[normalizedShortcut];
|
|
1780
2205
|
if (mediaCommand) {
|
|
2206
|
+
if (normalizedShortcut === "media_pause") {
|
|
2207
|
+
const playbackState = await this.readGlobalMediaPlaybackState();
|
|
2208
|
+
if (playbackState !== "playing") {
|
|
2209
|
+
return {
|
|
2210
|
+
performed: false,
|
|
2211
|
+
reason: playbackState === "unknown"
|
|
2212
|
+
? "O macOS nao confirmou nenhuma mídia tocando com segurança."
|
|
2213
|
+
: "Nenhuma mídia estava tocando no macOS.",
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
if (normalizedShortcut === "media_resume" || normalizedShortcut === "media_play") {
|
|
2218
|
+
const playbackState = await this.readGlobalMediaPlaybackState();
|
|
2219
|
+
if (playbackState === "playing") {
|
|
2220
|
+
return {
|
|
2221
|
+
performed: false,
|
|
2222
|
+
reason: "Já havia mídia tocando no macOS.",
|
|
2223
|
+
};
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
1781
2226
|
await this.triggerMacOSMediaTransport(mediaCommand);
|
|
1782
|
-
return;
|
|
2227
|
+
return { performed: true };
|
|
1783
2228
|
}
|
|
1784
2229
|
const namedKeyCodes = {
|
|
1785
2230
|
return: 36,
|
|
@@ -1799,12 +2244,67 @@ end tell
|
|
|
1799
2244
|
"-e",
|
|
1800
2245
|
`tell application "System Events" to key code ${namedKeyCodes[key]}${usingClause}`,
|
|
1801
2246
|
]);
|
|
1802
|
-
return;
|
|
2247
|
+
return { performed: true };
|
|
1803
2248
|
}
|
|
1804
2249
|
await this.runCommand("osascript", [
|
|
1805
2250
|
"-e",
|
|
1806
2251
|
`tell application "System Events" to keystroke "${escapeAppleScript(key)}"${usingClause}`,
|
|
1807
2252
|
]);
|
|
2253
|
+
return { performed: true };
|
|
2254
|
+
}
|
|
2255
|
+
async readGlobalMediaPlaybackState() {
|
|
2256
|
+
const swiftScript = `
|
|
2257
|
+
import Foundation
|
|
2258
|
+
import Dispatch
|
|
2259
|
+
import Darwin
|
|
2260
|
+
|
|
2261
|
+
typealias MRMediaRemoteGetNowPlayingApplicationIsPlayingFunction = @convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void
|
|
2262
|
+
|
|
2263
|
+
guard let handle = dlopen("/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote", RTLD_NOW) else {
|
|
2264
|
+
print("unknown")
|
|
2265
|
+
exit(0)
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
guard let symbol = dlsym(handle, "MRMediaRemoteGetNowPlayingApplicationIsPlaying") else {
|
|
2269
|
+
print("unknown")
|
|
2270
|
+
exit(0)
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
let fn = unsafeBitCast(symbol, to: MRMediaRemoteGetNowPlayingApplicationIsPlayingFunction.self)
|
|
2274
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
2275
|
+
var isPlaying: Bool? = nil
|
|
2276
|
+
|
|
2277
|
+
fn(DispatchQueue.global()) { value in
|
|
2278
|
+
isPlaying = value
|
|
2279
|
+
semaphore.signal()
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
if semaphore.wait(timeout: .now() + .milliseconds(800)) == .timedOut {
|
|
2283
|
+
print("unknown")
|
|
2284
|
+
exit(0)
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
print(isPlaying == true ? "playing" : "not_playing")
|
|
2288
|
+
`;
|
|
2289
|
+
try {
|
|
2290
|
+
const { stdout } = await this.runCommandCapture("swift", [
|
|
2291
|
+
"-module-cache-path",
|
|
2292
|
+
path.join(os.tmpdir(), "otto-bridge-swift-modcache"),
|
|
2293
|
+
"-e",
|
|
2294
|
+
swiftScript,
|
|
2295
|
+
]);
|
|
2296
|
+
const state = normalizeText(stdout || "").trim();
|
|
2297
|
+
if (state.includes("not_playing")) {
|
|
2298
|
+
return "not_playing";
|
|
2299
|
+
}
|
|
2300
|
+
if (state.includes("playing")) {
|
|
2301
|
+
return "playing";
|
|
2302
|
+
}
|
|
2303
|
+
return "unknown";
|
|
2304
|
+
}
|
|
2305
|
+
catch {
|
|
2306
|
+
return "unknown";
|
|
2307
|
+
}
|
|
1808
2308
|
}
|
|
1809
2309
|
async triggerMacOSMediaTransport(command) {
|
|
1810
2310
|
const keyTypeMap = {
|
|
@@ -2889,6 +3389,14 @@ const wantsNext = /\\b(proxim[ao]?|next|skip|pular|avanca|avancar)\\b/.test(norm
|
|
|
2889
3389
|
const wantsPrevious = /\\b(anterior|previous|volta[ar]?|back|retorna[ar]?)\\b/.test(normalizedDescription);
|
|
2890
3390
|
const wantsPause = /\\b(pausa|pause|pausar)\\b/.test(normalizedDescription);
|
|
2891
3391
|
const wantsResume = /\\b(retoma|retomar|resume|continu[ae]r|despausa|play)\\b/.test(normalizedDescription);
|
|
3392
|
+
const wantsSpotifyTrackPageOpen = location.hostname.includes("open.spotify.com")
|
|
3393
|
+
&& /\\bspotify\\b/.test(normalizedDescription)
|
|
3394
|
+
&& /\\b(pagina|titulo|nome|link)\\b/.test(normalizedDescription)
|
|
3395
|
+
&& /\\b(faixa|musica|track)\\b/.test(normalizedDescription);
|
|
3396
|
+
const wantsSpotifyTrackPagePlay = location.hostname.includes("open.spotify.com")
|
|
3397
|
+
&& /\\bspotify\\b/.test(normalizedDescription)
|
|
3398
|
+
&& /\\b(play|reproduzir|tocar|verde|principal)\\b/.test(normalizedDescription)
|
|
3399
|
+
&& /\\b(pagina|faixa|musica|track)\\b/.test(normalizedDescription);
|
|
2892
3400
|
const stopWords = new Set([
|
|
2893
3401
|
"o", "a", "os", "as", "um", "uma", "uns", "umas", "de", "da", "do", "das", "dos",
|
|
2894
3402
|
"em", "no", "na", "nos", "nas", "para", "por", "com", "que", "visivel", "visiveis",
|
|
@@ -2998,6 +3506,33 @@ function clickElement(element, strategy, matchedText, matchedHref, score, totalC
|
|
|
2998
3506
|
};
|
|
2999
3507
|
}
|
|
3000
3508
|
|
|
3509
|
+
function pickYouTubeMusicPrimaryCard(element) {
|
|
3510
|
+
let current = element instanceof Element ? element : null;
|
|
3511
|
+
let best = null;
|
|
3512
|
+
let bestScore = -Infinity;
|
|
3513
|
+
while (current && current instanceof HTMLElement && current !== document.body) {
|
|
3514
|
+
const rect = current.getBoundingClientRect();
|
|
3515
|
+
const text = normalize(deriveText(current));
|
|
3516
|
+
let score = 0;
|
|
3517
|
+
if (rect.width >= window.innerWidth * 0.32) score += 40;
|
|
3518
|
+
if (rect.width >= window.innerWidth * 0.42) score += 80;
|
|
3519
|
+
if (rect.left <= window.innerWidth * 0.18) score += 90;
|
|
3520
|
+
if ((rect.left + rect.width) <= window.innerWidth * 0.68) score += 120;
|
|
3521
|
+
if (rect.top <= window.innerHeight * 0.7) score += 40;
|
|
3522
|
+
if (text.includes("mais do youtube")) score -= 260;
|
|
3523
|
+
if (text.includes("playlist")) score -= 40;
|
|
3524
|
+
if (text.includes("podcast")) score -= 40;
|
|
3525
|
+
if (text.includes("musica")) score += 28;
|
|
3526
|
+
if (text.includes("video")) score -= 18;
|
|
3527
|
+
if (score > bestScore) {
|
|
3528
|
+
bestScore = score;
|
|
3529
|
+
best = current;
|
|
3530
|
+
}
|
|
3531
|
+
current = current.parentElement;
|
|
3532
|
+
}
|
|
3533
|
+
return best;
|
|
3534
|
+
}
|
|
3535
|
+
|
|
3001
3536
|
function attemptYouTubeMusicTransportClick() {
|
|
3002
3537
|
if (!isYouTubeMusic || !(wantsNext || wantsPrevious || wantsPause || wantsResume)) {
|
|
3003
3538
|
return null;
|
|
@@ -3045,6 +3580,97 @@ function attemptYouTubeMusicSearchResultClick() {
|
|
|
3045
3580
|
return null;
|
|
3046
3581
|
}
|
|
3047
3582
|
|
|
3583
|
+
const primaryCardButtons = Array.from(document.querySelectorAll("button, [role='button'], a[href*='watch?v=']"))
|
|
3584
|
+
.filter((node) => node instanceof HTMLElement || node instanceof HTMLAnchorElement)
|
|
3585
|
+
.filter((node) => isVisible(node))
|
|
3586
|
+
.filter((node) => !(node.closest("ytmusic-player-bar")))
|
|
3587
|
+
.map((node) => {
|
|
3588
|
+
const element = node;
|
|
3589
|
+
const rect = element.getBoundingClientRect();
|
|
3590
|
+
const primaryCard = pickYouTubeMusicPrimaryCard(element);
|
|
3591
|
+
const primaryCardRect = primaryCard ? primaryCard.getBoundingClientRect() : rect;
|
|
3592
|
+
const label = normalize([
|
|
3593
|
+
deriveText(element),
|
|
3594
|
+
element.getAttribute("aria-label"),
|
|
3595
|
+
element.getAttribute("title"),
|
|
3596
|
+
].filter(Boolean).join(" "));
|
|
3597
|
+
const cardText = normalize(primaryCard ? deriveText(primaryCard) : deriveText(element.parentElement || element));
|
|
3598
|
+
const isPlayButton = /\b(play|reproduzir|tocar)\b/.test(label);
|
|
3599
|
+
const buttonCenterX = rect.left + (rect.width / 2);
|
|
3600
|
+
const cardCenterX = primaryCardRect.left + (primaryCardRect.width / 2);
|
|
3601
|
+
let score = 0;
|
|
3602
|
+
|
|
3603
|
+
if (!isPlayButton) {
|
|
3604
|
+
return null;
|
|
3605
|
+
}
|
|
3606
|
+
if (buttonCenterX > (window.innerWidth * 0.44)) {
|
|
3607
|
+
return null;
|
|
3608
|
+
}
|
|
3609
|
+
if (rect.width < 110 || rect.height < 36) {
|
|
3610
|
+
return null;
|
|
3611
|
+
}
|
|
3612
|
+
if (primaryCardRect.left > (window.innerWidth * 0.16)) {
|
|
3613
|
+
return null;
|
|
3614
|
+
}
|
|
3615
|
+
if (cardCenterX > (window.innerWidth * 0.36)) {
|
|
3616
|
+
return null;
|
|
3617
|
+
}
|
|
3618
|
+
if (primaryCardRect.width < (window.innerWidth * 0.28)) {
|
|
3619
|
+
return null;
|
|
3620
|
+
}
|
|
3621
|
+
|
|
3622
|
+
for (const phrase of quotedPhrases) {
|
|
3623
|
+
if (!phrase) continue;
|
|
3624
|
+
if (cardText.includes(phrase) || label.includes(phrase)) score += 220;
|
|
3625
|
+
}
|
|
3626
|
+
|
|
3627
|
+
for (const token of tokens) {
|
|
3628
|
+
if (cardText.includes(token)) score += 30;
|
|
3629
|
+
else if (label.includes(token)) score += 16;
|
|
3630
|
+
}
|
|
3631
|
+
|
|
3632
|
+
if (tokens.length > 1) {
|
|
3633
|
+
const matchCount = tokens.filter((token) => cardText.includes(token)).length;
|
|
3634
|
+
if (matchCount >= Math.max(2, Math.ceil(tokens.length * 0.5))) score += 120;
|
|
3635
|
+
if (matchCount === tokens.length) score += 80;
|
|
3636
|
+
}
|
|
3637
|
+
|
|
3638
|
+
if ((primaryCardRect.left + (primaryCardRect.width / 2)) < (window.innerWidth * 0.48)) score += 320;
|
|
3639
|
+
if (primaryCardRect.left <= window.innerWidth * 0.18) score += 140;
|
|
3640
|
+
if (primaryCardRect.width >= window.innerWidth * 0.38) score += 120;
|
|
3641
|
+
if ((primaryCardRect.left + primaryCardRect.width) <= window.innerWidth * 0.7) score += 160;
|
|
3642
|
+
if (buttonCenterX < (window.innerWidth * 0.34)) score += 220;
|
|
3643
|
+
if (buttonCenterX < (window.innerWidth * 0.4)) score += 120;
|
|
3644
|
+
if (rect.top < (window.innerHeight * 0.7)) score += 90;
|
|
3645
|
+
if (rect.width >= 120) score += 36;
|
|
3646
|
+
if (rect.height >= 44) score += 18;
|
|
3647
|
+
if (cardText.includes("mais do youtube")) score -= 320;
|
|
3648
|
+
if (primaryCardRect.left > window.innerWidth * 0.45) score -= 260;
|
|
3649
|
+
if (buttonCenterX > window.innerWidth * 0.42) score -= 360;
|
|
3650
|
+
score += Math.max(0, 40 - Math.round(rect.top / 20));
|
|
3651
|
+
|
|
3652
|
+
return score > 0 ? {
|
|
3653
|
+
node: element,
|
|
3654
|
+
score,
|
|
3655
|
+
label,
|
|
3656
|
+
matchedText: primaryCard ? deriveText(primaryCard) : deriveText(element),
|
|
3657
|
+
} : null;
|
|
3658
|
+
})
|
|
3659
|
+
.filter(Boolean)
|
|
3660
|
+
.sort((left, right) => right.score - left.score);
|
|
3661
|
+
|
|
3662
|
+
if (primaryCardButtons.length) {
|
|
3663
|
+
const winner = primaryCardButtons[0];
|
|
3664
|
+
return clickElement(
|
|
3665
|
+
winner.node,
|
|
3666
|
+
"safari_dom_ytmusic_primary_card",
|
|
3667
|
+
winner.matchedText || winner.label,
|
|
3668
|
+
"",
|
|
3669
|
+
winner.score,
|
|
3670
|
+
primaryCardButtons.length,
|
|
3671
|
+
);
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3048
3674
|
const rows = Array.from(document.querySelectorAll("ytmusic-responsive-list-item-renderer"))
|
|
3049
3675
|
.filter((node) => node instanceof HTMLElement)
|
|
3050
3676
|
.filter((node) => isVisible(node));
|
|
@@ -3137,6 +3763,215 @@ if (ytmResult) {
|
|
|
3137
3763
|
return ytmResult;
|
|
3138
3764
|
}
|
|
3139
3765
|
|
|
3766
|
+
function attemptSpotifyTrackPagePlayClick() {
|
|
3767
|
+
if (!location.hostname.includes("open.spotify.com")) {
|
|
3768
|
+
return null;
|
|
3769
|
+
}
|
|
3770
|
+
if (!location.pathname.includes("/track/")) {
|
|
3771
|
+
return null;
|
|
3772
|
+
}
|
|
3773
|
+
|
|
3774
|
+
const rankedButtons = Array.from(document.querySelectorAll(
|
|
3775
|
+
"main [data-testid='play-button'], main button[aria-label], main button[title], [data-testid='entityHeader'] button, main button"
|
|
3776
|
+
))
|
|
3777
|
+
.filter((node) => node instanceof HTMLElement)
|
|
3778
|
+
.filter((node) => isVisible(node))
|
|
3779
|
+
.filter((node) => !(node.closest("footer")))
|
|
3780
|
+
.map((node, index) => {
|
|
3781
|
+
const rect = node.getBoundingClientRect();
|
|
3782
|
+
const label = normalize([
|
|
3783
|
+
deriveText(node),
|
|
3784
|
+
node.getAttribute("aria-label"),
|
|
3785
|
+
node.getAttribute("title"),
|
|
3786
|
+
node.getAttribute("data-testid"),
|
|
3787
|
+
].filter(Boolean).join(" "));
|
|
3788
|
+
const background = normalize(window.getComputedStyle(node).backgroundColor || "");
|
|
3789
|
+
let score = 0;
|
|
3790
|
+
|
|
3791
|
+
if (!/play|tocar|reproduzir/.test(label)) {
|
|
3792
|
+
return null;
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
if (node.getAttribute("data-testid") === "play-button") score += 220;
|
|
3796
|
+
if (label.includes("play")) score += 100;
|
|
3797
|
+
if (background.includes("29, 185, 84") || background.includes("30, 215, 96")) score += 180;
|
|
3798
|
+
if (rect.top <= window.innerHeight * 0.72) score += 90;
|
|
3799
|
+
if (rect.top <= window.innerHeight * 0.56) score += 70;
|
|
3800
|
+
if (rect.left <= window.innerWidth * 0.4) score += 80;
|
|
3801
|
+
if (rect.width >= 44) score += 30;
|
|
3802
|
+
if (rect.height >= 44) score += 30;
|
|
3803
|
+
if (node.closest("[data-testid='entityHeader'], main")) score += 60;
|
|
3804
|
+
score += Math.max(0, 18 - index);
|
|
3805
|
+
|
|
3806
|
+
return score > 0 ? { node, label, score } : null;
|
|
3807
|
+
})
|
|
3808
|
+
.filter(Boolean)
|
|
3809
|
+
.sort((left, right) => right.score - left.score);
|
|
3810
|
+
|
|
3811
|
+
if (!rankedButtons.length) {
|
|
3812
|
+
return null;
|
|
3813
|
+
}
|
|
3814
|
+
|
|
3815
|
+
const winner = rankedButtons[0];
|
|
3816
|
+
return clickElement(
|
|
3817
|
+
winner.node,
|
|
3818
|
+
"safari_dom_spotify_track_page_play",
|
|
3819
|
+
winner.label,
|
|
3820
|
+
location.href || "",
|
|
3821
|
+
winner.score,
|
|
3822
|
+
rankedButtons.length,
|
|
3823
|
+
);
|
|
3824
|
+
}
|
|
3825
|
+
|
|
3826
|
+
const spotifyTrackPagePlay = attemptSpotifyTrackPagePlayClick();
|
|
3827
|
+
if (spotifyTrackPagePlay) {
|
|
3828
|
+
return spotifyTrackPagePlay;
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3831
|
+
function attemptSpotifySearchResultClick() {
|
|
3832
|
+
if (!location.hostname.includes("open.spotify.com")) {
|
|
3833
|
+
return null;
|
|
3834
|
+
}
|
|
3835
|
+
if (!quotedPhrases.length && !tokens.length) {
|
|
3836
|
+
return null;
|
|
3837
|
+
}
|
|
3838
|
+
|
|
3839
|
+
const rows = Array.from(document.querySelectorAll(
|
|
3840
|
+
"[data-testid='tracklist-row'], [role='row']"
|
|
3841
|
+
))
|
|
3842
|
+
.filter((node) => node instanceof HTMLElement)
|
|
3843
|
+
.filter((node) => isVisible(node));
|
|
3844
|
+
|
|
3845
|
+
const rankedRows = rows
|
|
3846
|
+
.map((row, index) => {
|
|
3847
|
+
const titleNode = row.querySelector(
|
|
3848
|
+
"[data-testid='internal-track-link'], a[href*='/track/'], [aria-colindex='2'] a[href*='/track/']"
|
|
3849
|
+
);
|
|
3850
|
+
const navigationTarget = titleNode instanceof HTMLAnchorElement || titleNode instanceof HTMLElement
|
|
3851
|
+
? titleNode
|
|
3852
|
+
: null;
|
|
3853
|
+
const titleText = String((titleNode && titleNode.textContent) || "").trim();
|
|
3854
|
+
const rowText = deriveText(row);
|
|
3855
|
+
const normalizedTitle = normalize(titleText);
|
|
3856
|
+
const normalizedRow = normalize(rowText);
|
|
3857
|
+
let score = 0;
|
|
3858
|
+
|
|
3859
|
+
for (const phrase of quotedPhrases) {
|
|
3860
|
+
if (!phrase) continue;
|
|
3861
|
+
if (normalizedTitle.includes(phrase)) score += 180;
|
|
3862
|
+
else if (normalizedRow.includes(phrase)) score += 110;
|
|
3863
|
+
}
|
|
3864
|
+
|
|
3865
|
+
for (const token of tokens) {
|
|
3866
|
+
if (normalizedTitle.includes(token)) score += 26;
|
|
3867
|
+
else if (normalizedRow.includes(token)) score += 10;
|
|
3868
|
+
}
|
|
3869
|
+
|
|
3870
|
+
const titleMatches = tokens.filter((token) => normalizedTitle.includes(token)).length;
|
|
3871
|
+
if (tokens.length > 1 && titleMatches >= Math.max(2, Math.ceil(tokens.length * 0.5))) score += 90;
|
|
3872
|
+
if (titleMatches === tokens.length && tokens.length > 0) score += 70;
|
|
3873
|
+
if (index < 4) score += Math.max(0, 24 - (index * 5));
|
|
3874
|
+
|
|
3875
|
+
const playCandidate = Array.from(row.querySelectorAll("button, [role='button'], a[href*='/track/']"))
|
|
3876
|
+
.filter((candidate) => candidate instanceof HTMLElement || candidate instanceof HTMLAnchorElement)
|
|
3877
|
+
.filter((candidate) => isVisible(candidate))
|
|
3878
|
+
.map((candidate) => {
|
|
3879
|
+
const label = normalize([
|
|
3880
|
+
deriveText(candidate),
|
|
3881
|
+
candidate.getAttribute("aria-label"),
|
|
3882
|
+
candidate.getAttribute("title"),
|
|
3883
|
+
].filter(Boolean).join(" "));
|
|
3884
|
+
let candidateScore = 0;
|
|
3885
|
+
if (/play|tocar|reproduzir/.test(label)) candidateScore += 40;
|
|
3886
|
+
if (candidate instanceof HTMLAnchorElement && normalize(candidate.href).includes("/track/")) candidateScore += 18;
|
|
3887
|
+
return { candidate, candidateScore };
|
|
3888
|
+
})
|
|
3889
|
+
.sort((left, right) => right.candidateScore - left.candidateScore);
|
|
3890
|
+
|
|
3891
|
+
if (wantsSpotifyTrackPageOpen && !navigationTarget) {
|
|
3892
|
+
return null;
|
|
3893
|
+
}
|
|
3894
|
+
|
|
3895
|
+
return score > 0 ? {
|
|
3896
|
+
row,
|
|
3897
|
+
titleText,
|
|
3898
|
+
href: titleNode instanceof HTMLAnchorElement ? titleNode.href : "",
|
|
3899
|
+
score: score + (playCandidate[0]?.candidateScore || 0),
|
|
3900
|
+
target: wantsSpotifyTrackPageOpen
|
|
3901
|
+
? navigationTarget
|
|
3902
|
+
: (playCandidate[0]?.candidate || navigationTarget || row),
|
|
3903
|
+
} : null;
|
|
3904
|
+
})
|
|
3905
|
+
.filter(Boolean)
|
|
3906
|
+
.sort((left, right) => right.score - left.score);
|
|
3907
|
+
|
|
3908
|
+
if (!rankedRows.length) {
|
|
3909
|
+
return null;
|
|
3910
|
+
}
|
|
3911
|
+
|
|
3912
|
+
const winner = rankedRows[0];
|
|
3913
|
+
const spotifyRow = winner.row instanceof HTMLElement ? winner.row : null;
|
|
3914
|
+
const titleCandidate = spotifyRow?.querySelector(
|
|
3915
|
+
"[data-testid='internal-track-link'], a[href*='/track/'], [aria-colindex='2'] a[href*='/track/']"
|
|
3916
|
+
);
|
|
3917
|
+
const explicitPlayButton = spotifyRow
|
|
3918
|
+
? Array.from(spotifyRow.querySelectorAll("button, [role='button']"))
|
|
3919
|
+
.filter((node) => node instanceof HTMLElement)
|
|
3920
|
+
.filter((node) => isVisible(node))
|
|
3921
|
+
.find((node) => {
|
|
3922
|
+
const label = normalize([
|
|
3923
|
+
deriveText(node),
|
|
3924
|
+
node.getAttribute("aria-label"),
|
|
3925
|
+
node.getAttribute("title"),
|
|
3926
|
+
].filter(Boolean).join(" "));
|
|
3927
|
+
return /\b(play|tocar|reproduzir)\b/.test(label);
|
|
3928
|
+
})
|
|
3929
|
+
: null;
|
|
3930
|
+
|
|
3931
|
+
const clickTarget = wantsSpotifyTrackPageOpen
|
|
3932
|
+
? (titleCandidate || winner.target)
|
|
3933
|
+
: (explicitPlayButton || titleCandidate || winner.target);
|
|
3934
|
+
const clicked = clickElement(
|
|
3935
|
+
clickTarget,
|
|
3936
|
+
wantsSpotifyTrackPageOpen
|
|
3937
|
+
? "safari_dom_spotify_track_link"
|
|
3938
|
+
: (explicitPlayButton ? "safari_dom_spotify_play_button" : "safari_dom_spotify_result"),
|
|
3939
|
+
winner.titleText || deriveText(winner.row),
|
|
3940
|
+
winner.href || "",
|
|
3941
|
+
winner.score,
|
|
3942
|
+
rankedRows.length,
|
|
3943
|
+
);
|
|
3944
|
+
if (!clicked) {
|
|
3945
|
+
return null;
|
|
3946
|
+
}
|
|
3947
|
+
if (spotifyRow instanceof HTMLElement && !explicitPlayButton && !wantsSpotifyTrackPageOpen) {
|
|
3948
|
+
const rect = spotifyRow.getBoundingClientRect();
|
|
3949
|
+
spotifyRow.dispatchEvent(new MouseEvent("dblclick", {
|
|
3950
|
+
bubbles: true,
|
|
3951
|
+
cancelable: true,
|
|
3952
|
+
view: window,
|
|
3953
|
+
clientX: rect.left + (rect.width / 2),
|
|
3954
|
+
clientY: rect.top + (rect.height / 2),
|
|
3955
|
+
}));
|
|
3956
|
+
}
|
|
3957
|
+
return clicked;
|
|
3958
|
+
}
|
|
3959
|
+
|
|
3960
|
+
const spotifyResult = attemptSpotifySearchResultClick();
|
|
3961
|
+
if (spotifyResult) {
|
|
3962
|
+
return spotifyResult;
|
|
3963
|
+
}
|
|
3964
|
+
|
|
3965
|
+
if (location.hostname.includes("open.spotify.com") && (wantsSpotifyTrackPageOpen || wantsSpotifyTrackPagePlay)) {
|
|
3966
|
+
return {
|
|
3967
|
+
clicked: false,
|
|
3968
|
+
reason: wantsSpotifyTrackPageOpen
|
|
3969
|
+
? "Nenhuma faixa visivel do Spotify Web combinou com a busca atual; nao vou clicar em abas, playlists ou albuns por fallback."
|
|
3970
|
+
: "O Spotify Web nao mostrou um botao principal de reproduzir confiavel; nao vou clicar em elementos genericos por fallback.",
|
|
3971
|
+
strategy: "safari_dom_spotify_no_fallback",
|
|
3972
|
+
};
|
|
3973
|
+
}
|
|
3974
|
+
|
|
3140
3975
|
function scoreCandidate(element, rank) {
|
|
3141
3976
|
const text = deriveText(element);
|
|
3142
3977
|
const href = element instanceof HTMLAnchorElement
|
|
@@ -3257,7 +4092,7 @@ tell application "Safari"
|
|
|
3257
4092
|
activate
|
|
3258
4093
|
if (count of windows) = 0 then error "Safari nao possui janelas abertas."
|
|
3259
4094
|
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');
|
|
4095
|
+
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
4096
|
set pageJson to do JavaScript jsCode in current tab of front window
|
|
3262
4097
|
end tell
|
|
3263
4098
|
return pageJson
|