yaGeey commited on
Commit
78a24ef
·
1 Parent(s): 20f1d9d
Files changed (5) hide show
  1. src/actions.ts +64 -50
  2. src/browser.ts +13 -0
  3. src/hashHandlers.ts +62 -52
  4. src/index.ts +30 -21
  5. src/storage.ts +14 -6
src/actions.ts CHANGED
@@ -1,4 +1,5 @@
1
  import type { Page } from 'playwright'
 
2
 
3
  const dismissConsentIfPresent = async (page: Page) => {
4
  const overlay = page.locator('#onetrust-consent-sdk')
@@ -16,81 +17,94 @@ const dismissConsentIfPresent = async (page: Page) => {
16
  await overlay.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {})
17
  }
18
 
19
- export const addToPlaylistAction = async (page: Page) => {
20
- const slowHostTimeoutMs = 90000 // Зменшив, 90с це занадто, краще впасти раніше
21
 
22
- await page.waitForLoadState('networkidle', { timeout: slowHostTimeoutMs }) // Важливо: networkidle краще ніж domcontentloaded для SPA
23
  await dismissConsentIfPresent(page)
24
- console.log('page loaded')
25
 
26
  const track = page.locator('[data-testid="tracklist-row"]').first()
27
- await track.waitFor({ state: 'visible', timeout: slowHostTimeoutMs })
28
- console.log('track found')
29
 
30
- // ТРЮК 1: Примусовий скрол до елемента перед кліком
31
- await track.scrollIntoViewIfNeeded()
32
- await track.click({ button: 'right', timeout: slowHostTimeoutMs })
33
- console.log('right-clicked track')
34
 
35
  const menu = page.locator('[data-testid="context-menu"]')
36
- await menu.waitFor({ state: 'visible', timeout: slowHostTimeoutMs })
37
- console.log('context menu visible')
38
 
39
  const addToPlaylistButton = menu.getByText('Add to Playlist', { exact: false })
40
  await addToPlaylistButton.waitFor({ state: 'visible' })
41
- await addToPlaylistButton.hover() // Hover обов'язковий
42
- console.log('hovered "Add to Playlist"')
43
 
44
  const input = page.locator('[placeholder="Find a playlist"]')
45
- await input.waitFor({ state: 'visible', timeout: slowHostTimeoutMs })
46
- console.log('search input visible')
47
 
48
- // ТРЮК 2: Повільний ввід тексту. Це дає React час обробити стейт.
49
- // Замість fill використовуємо pressSequentially з затримкою
50
  await input.pressSequentially('TEST', { delay: 100 })
51
- console.log('typed playlist name with delay')
52
 
53
- // Даємо час на рендеринг відфільтрованого списку
54
  await page.waitForTimeout(1500)
55
 
56
- // ТРЮК 3: Замість кліку мишкою - ENTER
57
- // Після пошуку фокус зазвичай залишається в input або перший елемент стає активним.
58
- // Спробуємо натиснути стрілку вниз (щоб точно вибрати плейліст) і Enter.
59
  const targetPlaylist = page.getByRole('menuitem', { name: 'TEST', exact: true }).first()
60
  if (!(await targetPlaylist.isVisible())) {
61
- console.log('playlist not found in search results, clicking search result to trigger playlist loading')
62
- await dismissConsentIfPresent(page)
63
- console.log('dissmissed')
64
- await page.getByText('TEST', { exact: true }).first().click({ force: true, timeout: slowHostTimeoutMs })
65
- console.log('clicked search result, waiting for playlist to appear in search results')
66
  } else {
67
- console.log('playlist found in search results, clicking it')
68
- await dismissConsentIfPresent(page)
69
- console.log('dissmissed')
70
- await targetPlaylist.click({ force: true, timeout: slowHostTimeoutMs })
71
- console.log('clicked playlist in search results')
72
  }
73
 
74
- // Wait for context menu to close implicitly
75
- await menu.waitFor({ state: 'hidden', timeout: 15000 }).catch(() => { })
76
- console.log('context menu closed')
77
 
78
- // --- БЛОК ОБРОБКИ "Add Anyway" ---
79
- // Тут важливо чекати не просто появи, а появи АБО зникнення діалогу
80
- // Але оскільки нам треба клікнути, чекаємо кнопку.
81
 
82
- try {
83
- const addAnywayBtn = page.getByRole('button', { name: /add anyway/i })
84
- // Чекаємо трохи довше, бо модалка може мати анімацію появи
85
- await addAnywayBtn.waitFor({ state: 'visible', timeout: 15000 })
86
 
87
- console.log('"Add anyway" visible, clicking...')
88
- // Тут теж краще без force, якщо можливо, але для модалок force допустимий
89
- await addAnywayBtn.click()
90
- } catch (e) {
91
- console.log('"Add anyway" button did not appear (track likely added or not duplicate)')
92
- }
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
- // Фінальне очікування, щоб запит встиг піти
95
  await page.waitForTimeout(3000)
96
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import type { Page } from 'playwright'
2
+ import { captureQueryPromise } from './hashHandlers.js'
3
 
4
  const dismissConsentIfPresent = async (page: Page) => {
5
  const overlay = page.locator('#onetrust-consent-sdk')
 
17
  await overlay.waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {})
18
  }
19
 
20
+ export const addNotDuplicateItemToPlaylistAction = async (page: Page) => {
21
+ const timeout = 60000 // Зменшив, 90с це занадто, краще впасти раніше
22
 
23
+ await page.waitForLoadState('domcontentloaded', { timeout }) // Важливо: networkidle краще ніж domcontentloaded для SPA
24
  await dismissConsentIfPresent(page)
25
+ console.log('[a] page loaded')
26
 
27
  const track = page.locator('[data-testid="tracklist-row"]').first()
28
+ await track.waitFor({ state: 'visible', timeout })
29
+ console.log('[a] track found')
30
 
31
+ await track.click({ button: 'right', timeout })
32
+ console.log('[a] right-clicked track')
 
 
33
 
34
  const menu = page.locator('[data-testid="context-menu"]')
35
+ await menu.waitFor({ state: 'visible', timeout })
36
+ console.log('[a] context menu visible')
37
 
38
  const addToPlaylistButton = menu.getByText('Add to Playlist', { exact: false })
39
  await addToPlaylistButton.waitFor({ state: 'visible' })
40
+ await addToPlaylistButton.hover()
41
+ console.log('[a] hovered "Add to Playlist"')
42
 
43
  const input = page.locator('[placeholder="Find a playlist"]')
44
+ await input.waitFor({ state: 'visible', timeout })
45
+ console.log('[a] search input visible')
46
 
 
 
47
  await input.pressSequentially('TEST', { delay: 100 })
48
+ console.log('[a] typed playlist name with delay')
49
 
 
50
  await page.waitForTimeout(1500)
51
 
 
 
 
52
  const targetPlaylist = page.getByRole('menuitem', { name: 'TEST', exact: true }).first()
53
  if (!(await targetPlaylist.isVisible())) {
54
+ console.log('[a] playlist not found in search results, clicking search result to trigger playlist loading')
55
+ await page.getByText('TEST', { exact: true }).first().click({ force: true, timeout })
56
+ console.log('[a] clicked search result, waiting for playlist to appear in search results')
 
 
57
  } else {
58
+ console.log('[a] playlist found in search results, clicking it')
59
+ await targetPlaylist.click({ force: true, timeout })
60
+ console.log('[a] clicked playlist in search results')
 
 
61
  }
62
 
63
+ await page.waitForTimeout(3000)
64
+ }
 
65
 
66
+ export const removeFromPlaylistAction = async (page: Page) => {
67
+ const timeout = 60000
 
68
 
69
+ await page.waitForLoadState('networkidle', { timeout })
70
+ console.log('[a] page loaded')
 
 
71
 
72
+ const track = page.locator('[data-testid="tracklist-row"]').first()
73
+ await track.waitFor({ state: 'visible', timeout })
74
+ console.log('[a] track found')
75
+ const clickZone = track.locator('[aria-colindex="2"]').first()
76
+ await clickZone.waitFor({ state: 'visible', timeout })
77
+ console.log('[a] clickZone found')
78
+
79
+ await clickZone.click({ button: 'right', timeout })
80
+ console.log('[a] right-clicked track')
81
+
82
+ const menu = page.locator('[data-testid="context-menu"]')
83
+ await menu.waitFor({ state: 'visible', timeout })
84
+ console.log('[a] context menu visible')
85
+
86
+ const btn = menu.getByText('Remove from this playlist')
87
+ await btn.waitFor({ state: 'visible', timeout })
88
+ await btn.click({ timeout })
89
+ console.log('[a] remove from playlist btn clicked')
90
 
 
91
  await page.waitForTimeout(3000)
92
  }
93
+
94
+ // fetchPlaylist + modify hashes + (possibly search)
95
+ export const getModifyPlaylistHashAction = async (page: Page): Promise<void> => {
96
+ await page.goto('https://open.spotify.com/playlist/6uXwlbGoEnIQT9Cu5RsuxP', {
97
+ waitUntil: 'domcontentloaded',
98
+ })
99
+ const queryData = await captureQueryPromise(page, ['fetchPlaylist'])
100
+ const result = queryData?.json
101
+ if (!result) throw new Error('No data in fetchPlaylist response')
102
+
103
+ if (result.data.playlistV2.content.totalCount === 0) {
104
+ console.log('Playlist is empty, using addToPlaylist flow to get hash')
105
+ await page.goto('https://open.spotify.com/search/deco27', { waitUntil: 'domcontentloaded' })
106
+ return addNotDuplicateItemToPlaylistAction(page)
107
+ } else {
108
+ return removeFromPlaylistAction(page)
109
+ }
110
+ }
src/browser.ts CHANGED
@@ -62,3 +62,16 @@ export async function killBrowser(browser: Browser | null) {
62
  if (browser) await browser.close().catch(() => {})
63
  if (global.gc) global.gc() // force garbage collection to free RAM
64
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  if (browser) await browser.close().catch(() => {})
63
  if (global.gc) global.gc() // force garbage collection to free RAM
64
  }
65
+
66
+ export async function browserWrapper<T>(fn: (page: Page) => Promise<T>) {
67
+ const { browser, page } = await createInstance()
68
+ try {
69
+ const data = await fn(page)
70
+ return data
71
+ } catch (err) {
72
+ await handleError(err)
73
+ return null
74
+ } finally {
75
+ killBrowser(browser)
76
+ }
77
+ }
src/hashHandlers.ts CHANGED
@@ -1,77 +1,87 @@
1
- import { addToPlaylistAction } from './actions.js'
2
- import { createInstance, killBrowser, handleError } from './browser.js'
3
  import { type Operation, store } from './storage.js'
4
  import { delay } from './utils.js'
 
 
5
 
6
  export const operations: Operation[] = [
7
- { name: 'fetchPlaylist', url: 'https://open.spotify.com/playlist/79QHayucQm6M4wUlUbhQNQ' },
8
  {
9
- name: 'searchDesktop',
10
- url: `https://open.spotify.com/search/deco27/tracks`,
 
11
  },
 
12
  {
13
- name: 'addToPlaylist',
14
- url: 'https://open.spotify.com/search/deco27/tracks',
15
- action: addToPlaylistAction,
16
  },
17
  ]
18
 
19
- export async function updateHash(op: Operation) {
20
- const { browser, page } = await createInstance()
21
-
22
- try {
23
- const hashPromise = page
24
- .waitForResponse(
25
- async (res) => {
26
- const url = res.url()
27
- if (url.includes('query') || (url.includes('graphql') && res.status() === 200)) {
28
- try {
29
- const body = res.request().postDataJSON()
30
- if (!body) return false
31
- const hash = body.extensions?.persistedQuery?.sha256Hash
32
- if (hash) {
33
- console.log(body.operationName)
34
- store.hashes[body.operationName] = hash
35
- }
36
- return body.operationName === op.name
37
- } catch {
38
- return false
39
  }
40
  }
 
 
41
  return false
42
- },
43
- { timeout: 150000 },
44
- )
45
- .then((res) => (res.request().postDataJSON()?.extensions?.persistedQuery?.sha256Hash || null) as string | null)
46
- .catch((err) => {
47
- console.warn(`⚠️ [${op.name}] Hash listener ended: ${err.message}`)
48
- return null
49
- })
50
 
51
- await page.goto(op.url, { waitUntil: 'domcontentloaded', timeout: 120000 })
 
52
 
53
- const actionPromise = op.action
54
- ? op.action(page).catch((e) => {
55
- throw new Error(`Action failed: ${e.message}`)
56
- })
57
- : Promise.resolve()
58
 
59
- const [hash] = await Promise.all([hashPromise, actionPromise])
60
- return hash
61
- } catch (err) {
62
- handleError(err)
63
  return null
64
- } finally {
65
- await killBrowser(browser)
66
- }
 
 
 
 
 
 
 
 
 
67
  }
68
 
69
  export async function updateAllHashes() {
70
- const res = []
 
 
71
  for (const op of operations) {
72
- const hash = await updateHash(op)
73
- res.push({ name: op.name, hash })
 
 
74
  await delay(800)
75
  }
 
 
76
  return res
77
  }
 
1
+ import type { Page } from 'playwright'
2
+ import { browserWrapper } from './browser.js'
3
  import { type Operation, store } from './storage.js'
4
  import { delay } from './utils.js'
5
+ import { getModifyPlaylistHashAction } from './actions.js'
6
+ import { queue } from './index.js'
7
 
8
  export const operations: Operation[] = [
 
9
  {
10
+ type: 'action',
11
+ names: ['addToPlaylist', 'removeFromPlaylist'],
12
+ action: getModifyPlaylistHashAction,
13
  },
14
+ { names: ['fetchPlaylist'], url: 'https://open.spotify.com/playlist/79QHayucQm6M4wUlUbhQNQ' },
15
  {
16
+ names: ['searchDesktop'],
17
+ url: `https://open.spotify.com/search/deco27/tracks`,
 
18
  },
19
  ]
20
 
21
+ export async function captureQueryPromise(page: Page, operationNames: string[]) {
22
+ const res = await page.waitForResponse(
23
+ async (res) => {
24
+ const url = res.url()
25
+ if (url.includes('query') || (url.includes('graphql') && res.status() === 200)) {
26
+ try {
27
+ const body = res.request().postDataJSON()
28
+ if (!body) return false
29
+ const hash = body.extensions?.persistedQuery?.sha256Hash
30
+ if (hash) {
31
+ console.log(body.operationName)
32
+ store.hashes[body.operationName] = hash
33
+ // Record hash for ALL operation names since they share the same hash
34
+ for (const name of operationNames) {
35
+ store.tempHashes[name] = hash
 
 
 
 
 
36
  }
37
  }
38
+ return operationNames.includes(body.operationName)
39
+ } catch {
40
  return false
41
+ }
42
+ }
43
+ return false
44
+ },
45
+ { timeout: 150000 },
46
+ )
 
 
47
 
48
+ const hash = (res.request().postDataJSON()?.extensions?.persistedQuery?.sha256Hash || null) as string | null
49
+ const json = await res.json().catch(() => null)
50
 
51
+ return { hash, json: operationNames.length === 1 ? json : null }
52
+ }
 
 
 
53
 
54
+ export async function updateHash(page: Page, op: Operation) {
55
+ // For URL-based operations
56
+ const queryPromise = captureQueryPromise(page, op.names).catch((err) => {
57
+ console.warn(`⚠️ [${op.names}] Hash listener ended: ${err.message}`)
58
  return null
59
+ })
60
+
61
+ if (op.type !== 'action') await page.goto(op.url, { waitUntil: 'domcontentloaded', timeout: 120000 })
62
+
63
+ const actionPromise = op.action
64
+ ? op.action(page).catch((e) => {
65
+ throw new Error(`Action failed: ${e.message}`)
66
+ })
67
+ : Promise.resolve()
68
+
69
+ await Promise.all([queryPromise, actionPromise])
70
+ return store.tempHashes[op.names[0]] || null
71
  }
72
 
73
  export async function updateAllHashes() {
74
+ const res: Record<string, string | null> = {}
75
+ store.tempHashes = {}
76
+
77
  for (const op of operations) {
78
+ // if we already have hashes from previos operations, we can skip
79
+ if (op.names.every((name) => store.tempHashes[name])) continue
80
+ const hash = await queue.add(() => browserWrapper((page) => updateHash(page, op)))
81
+ op.names.forEach((name) => (res[name] = hash))
82
  await delay(800)
83
  }
84
+
85
+ store.tempHashes = {}
86
  return res
87
  }
src/index.ts CHANGED
@@ -2,15 +2,15 @@ import express from 'express'
2
  import 'dotenv/config'
3
  import PQueue from 'p-queue'
4
  import cron from 'node-cron'
5
- import { store, type AccessTokenResponse, type TokenResponse } from './storage.js'
6
- import { createInstance, killBrowser, handleError } from './browser.js'
7
  import { delay } from './utils.js'
8
  import { updateAllHashes, operations, updateHash } from './hashHandlers.js'
9
  import type { Browser } from 'playwright'
10
 
11
  const app = express()
12
  const PORT = process.env.PORT || 3000
13
- const queue = new PQueue({ concurrency: 1 })
14
 
15
  // Health check - must be before auth middleware
16
  app.get('/', (req, res) => {
@@ -121,40 +121,49 @@ app.get('/hashes', (req, res) => {
121
 
122
  app.put('/hashes', async (req, res) => {
123
  const raw = req.query.names as string
124
- let hashes: { name: string; hash: string | null }[] = []
125
 
126
- if (!raw) {
 
 
127
  // update all
128
- hashes = await queue.add(updateAllHashes)
129
  } else {
130
  // update selected
131
- const names = raw.split(',')
132
  for (const name of names) {
133
- const op = operations.find((o) => o.name === name)
134
- if (!op) continue
135
- const hash = await queue.add(() => updateHash(op))
136
- hashes.push({ name: op.name, hash })
 
 
 
 
 
 
137
  await delay(800)
138
  }
139
- if (hashes.length === 0) {
 
140
  return res.status(400).json({ error: 'No valid operation names provided' })
141
  }
142
- if (hashes.length !== names.length) {
143
  return res.status(404).json({
144
  error: 'Some operation names were invalid',
145
- details: { raw, hashes: Object.fromEntries(hashes.map((i) => [i.name, i.hash])) },
146
  })
147
  }
148
  }
149
 
150
- if (hashes.map((h) => h.hash).some((h) => h === null)) {
151
- console.error('Failed to update some hashes', hashes)
152
- return res
153
- .status(502)
154
- .json({ error: 'Failed to update some hashes', details: Object.fromEntries(hashes.map((i) => [i.name, i.hash])) })
155
  }
156
 
157
- res.json({ requested: Object.fromEntries(hashes.map((i) => [i.name, i.hash])), all: store.hashes })
 
 
 
158
  })
159
 
160
  cron.schedule('0 3 * * *', () => {
@@ -162,7 +171,7 @@ cron.schedule('0 3 * * *', () => {
162
  setTimeout(async () => {
163
  await queue.add(async () => {
164
  const hashes = await updateAllHashes()
165
- if (hashes.map((h) => h.hash).some((h) => h === null)) {
166
  console.error('cron: Failed to update some hashes', hashes)
167
  }
168
  })
 
2
  import 'dotenv/config'
3
  import PQueue from 'p-queue'
4
  import cron from 'node-cron'
5
+ import { store, type AccessTokenResponse, type Hashes, type TokenResponse } from './storage.js'
6
+ import { createInstance, killBrowser, handleError, browserWrapper } from './browser.js'
7
  import { delay } from './utils.js'
8
  import { updateAllHashes, operations, updateHash } from './hashHandlers.js'
9
  import type { Browser } from 'playwright'
10
 
11
  const app = express()
12
  const PORT = process.env.PORT || 3000
13
+ export const queue: PQueue = new PQueue({ concurrency: 1 })
14
 
15
  // Health check - must be before auth middleware
16
  app.get('/', (req, res) => {
 
121
 
122
  app.put('/hashes', async (req, res) => {
123
  const raw = req.query.names as string
124
+ let tempHash = store.tempHashes
125
 
126
+ const names = raw ? raw.split(',') : null
127
+
128
+ if (!names) {
129
  // update all
130
+ tempHash = await updateAllHashes()
131
  } else {
132
  // update selected
 
133
  for (const name of names) {
134
+ const op = operations.find((o) => o.names.includes(name))
135
+ if (!op || op.names.every((name) => tempHash[name])) {
136
+ console.log(`Skipping ${name}`)
137
+ continue
138
+ }
139
+
140
+ const hash = await queue.add(() => browserWrapper((page) => updateHash(page, op)))
141
+ // Record hash for ALL names in the operation
142
+ console.log(`Hash for ${op.names.join(', ')}: ${hash}`)
143
+ for (const opName of op.names) tempHash[opName] = hash
144
  await delay(800)
145
  }
146
+ const hashesAmount = Object.keys(tempHash).length
147
+ if (hashesAmount === 0) {
148
  return res.status(400).json({ error: 'No valid operation names provided' })
149
  }
150
+ if (names.some((name) => !operations.some((op) => op.names.includes(name)))) {
151
  return res.status(404).json({
152
  error: 'Some operation names were invalid',
153
+ details: { raw, hashes: tempHash },
154
  })
155
  }
156
  }
157
 
158
+ if (Object.keys(tempHash).some((key) => tempHash[key] === null)) {
159
+ console.error('Failed to update some hashes', tempHash)
160
+ return res.status(502).json({ error: 'Failed to update some hashes', details: tempHash })
 
 
161
  }
162
 
163
+ store.tempHashes = {}
164
+ Object.assign(store.hashes, tempHash)
165
+ const requested = Object.fromEntries(Object.entries(store.hashes).filter(([key, value]) => names?.includes(key) && value))
166
+ res.json({ requested, all: store.hashes })
167
  })
168
 
169
  cron.schedule('0 3 * * *', () => {
 
171
  setTimeout(async () => {
172
  await queue.add(async () => {
173
  const hashes = await updateAllHashes()
174
+ if (Object.keys(hashes).some((key) => hashes[key] === null)) {
175
  console.error('cron: Failed to update some hashes', hashes)
176
  }
177
  })
src/storage.ts CHANGED
@@ -8,14 +8,22 @@ export type TokenResponse = {
8
  client: ClientTokenResponse
9
  }
10
 
11
- export type Operation = {
12
- name: string
13
- url: string
14
- action?: (page: Page) => Promise<void>
15
- }
16
- type Hashes = Record<string, string | null>
 
 
 
 
 
 
 
17
  export const store = {
18
  access: null as AccessTokenResponse | null,
19
  client: null as ClientTokenResponse | null,
20
  hashes: {} as Hashes,
 
21
  }
 
8
  client: ClientTokenResponse
9
  }
10
 
11
+ export type Operation = (
12
+ | {
13
+ type?: never
14
+ url: string
15
+ action?: (page: Page) => Promise<void>
16
+ }
17
+ | {
18
+ type: 'action'
19
+ action: (page: Page) => Promise<void>
20
+ }
21
+ ) & { names: [string, ...string[]] }
22
+
23
+ export type Hashes = Record<string, string | null>
24
  export const store = {
25
  access: null as AccessTokenResponse | null,
26
  client: null as ClientTokenResponse | null,
27
  hashes: {} as Hashes,
28
+ tempHashes: {} as Hashes,
29
  }