Spaces:
Paused
Paused
yaGeey commited on
Commit ·
78a24ef
1
Parent(s): 20f1d9d
swag
Browse files- src/actions.ts +64 -50
- src/browser.ts +13 -0
- src/hashHandlers.ts +62 -52
- src/index.ts +30 -21
- 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
|
| 20 |
-
const
|
| 21 |
|
| 22 |
-
await page.waitForLoadState('
|
| 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
|
| 28 |
-
console.log('track found')
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 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
|
| 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()
|
| 42 |
-
console.log('hovered "Add to Playlist"')
|
| 43 |
|
| 44 |
const input = page.locator('[placeholder="Find a playlist"]')
|
| 45 |
-
await input.waitFor({ state: 'visible', timeout
|
| 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
|
| 63 |
-
console.log('
|
| 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
|
| 69 |
-
console.log('
|
| 70 |
-
await targetPlaylist.click({ force: true, timeout: slowHostTimeoutMs })
|
| 71 |
-
console.log('clicked playlist in search results')
|
| 72 |
}
|
| 73 |
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
console.log('context menu closed')
|
| 77 |
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
// Але оскільки нам треба клікнути, чекаємо кнопку.
|
| 81 |
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
// Чекаємо трохи довше, бо модалка може мати анімацію появи
|
| 85 |
-
await addAnywayBtn.waitFor({ state: 'visible', timeout: 15000 })
|
| 86 |
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 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 {
|
| 2 |
-
import {
|
| 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 |
-
|
| 10 |
-
|
|
|
|
| 11 |
},
|
|
|
|
| 12 |
{
|
| 13 |
-
|
| 14 |
-
url:
|
| 15 |
-
action: addToPlaylistAction,
|
| 16 |
},
|
| 17 |
]
|
| 18 |
|
| 19 |
-
export async function
|
| 20 |
-
const
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 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 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
return null
|
| 49 |
-
})
|
| 50 |
|
| 51 |
-
|
|
|
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
throw new Error(`Action failed: ${e.message}`)
|
| 56 |
-
})
|
| 57 |
-
: Promise.resolve()
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
return null
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
}
|
| 68 |
|
| 69 |
export async function updateAllHashes() {
|
| 70 |
-
const res =
|
|
|
|
|
|
|
| 71 |
for (const op of operations) {
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
| 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
|
| 125 |
|
| 126 |
-
|
|
|
|
|
|
|
| 127 |
// update all
|
| 128 |
-
|
| 129 |
} else {
|
| 130 |
// update selected
|
| 131 |
-
const names = raw.split(',')
|
| 132 |
for (const name of names) {
|
| 133 |
-
const op = operations.find((o) => o.name
|
| 134 |
-
if (!op)
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
await delay(800)
|
| 138 |
}
|
| 139 |
-
|
|
|
|
| 140 |
return res.status(400).json({ error: 'No valid operation names provided' })
|
| 141 |
}
|
| 142 |
-
if (
|
| 143 |
return res.status(404).json({
|
| 144 |
error: 'Some operation names were invalid',
|
| 145 |
-
details: { raw, hashes:
|
| 146 |
})
|
| 147 |
}
|
| 148 |
}
|
| 149 |
|
| 150 |
-
if (
|
| 151 |
-
console.error('Failed to update some 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 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 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|