|
| 1 | +import myzod from 'myzod' |
| 2 | +import { translateMany, translateText } from '../modules/googleTranslate.js' |
| 3 | +import { |
| 4 | + decodeTrack, |
| 5 | + http1makeRequest, |
| 6 | + logger, |
| 7 | + sendErrorResponse |
| 8 | +} from '../utils.js' |
| 9 | + |
| 10 | +const meaningSchema = myzod.object({ |
| 11 | + encodedTrack: myzod.string(), |
| 12 | + lang: myzod.string().optional() |
| 13 | +}) |
| 14 | + |
| 15 | +const decodeHtml = (text) => { |
| 16 | + if (!text) return text |
| 17 | + return text |
| 18 | + .replace(/&/g, '&') |
| 19 | + .replace(/"/g, '"') |
| 20 | + .replace(/'/g, "'") |
| 21 | + .replace(/'/g, "'") |
| 22 | + .replace(/</g, '<') |
| 23 | + .replace(/>/g, '>') |
| 24 | +} |
| 25 | + |
| 26 | +const extractMeta = (html, property) => { |
| 27 | + const re1 = new RegExp( |
| 28 | + `<meta[^>]+property=[\"']${property}[\"'][^>]+content=[\"']([^\"']+)[\"'][^>]*>`, |
| 29 | + 'i' |
| 30 | + ) |
| 31 | + const re2 = new RegExp( |
| 32 | + `<meta[^>]+content=[\"']([^\"']+)[^>]+property=[\"']${property}[\"'][^>]*>`, |
| 33 | + 'i' |
| 34 | + ) |
| 35 | + const match = html.match(re1) || html.match(re2) |
| 36 | + return match ? decodeHtml(match[1]) : null |
| 37 | +} |
| 38 | + |
| 39 | +const extractOmqLyric = (html) => { |
| 40 | + const match = html.match(/_omq\.push\(\['ui\/lyric',\s*({[\s\S]*?})\s*,/i) |
| 41 | + if (!match) return null |
| 42 | + try { |
| 43 | + return JSON.parse(match[1]) |
| 44 | + } catch { |
| 45 | + return null |
| 46 | + } |
| 47 | +} |
| 48 | + |
| 49 | +const extractOmqMeaning = (html) => { |
| 50 | + const match = html.match( |
| 51 | + /_omq\.push\(\['ui\/lyric',\s*({[\s\S]*?})\s*,\s*({[\s\S]*?})\s*,/i |
| 52 | + ) |
| 53 | + if (!match) return null |
| 54 | + try { |
| 55 | + return JSON.parse(match[2]) |
| 56 | + } catch { |
| 57 | + return null |
| 58 | + } |
| 59 | +} |
| 60 | + |
| 61 | +const extractMeaning = (html) => { |
| 62 | + const match = html.match(/<div class="lyric-meaning[^>]*">([\s\S]*?)<\/div>/i) |
| 63 | + if (!match) return { title: null, body: [] } |
| 64 | + let block = match[1] |
| 65 | + const titleMatch = block.match(/<h3[^>]*>([\s\S]*?)<\/h3>/i) |
| 66 | + const title = titleMatch ? decodeHtml(titleMatch[1].replace(/<[^>]+>/g, '')) : null |
| 67 | + block = block.replace(/<h3[^>]*>[\s\S]*?<\/h3>/i, '') |
| 68 | + const paragraphs = [] |
| 69 | + const pRegex = /<p[^>]*>([\s\S]*?)<\/p>/gi |
| 70 | + let pMatch |
| 71 | + while ((pMatch = pRegex.exec(block))) { |
| 72 | + let text = pMatch[1] |
| 73 | + text = text.replace(/<br\s*\/?>/gi, '\n') |
| 74 | + text = text.replace(/<[^>]+>/g, '') |
| 75 | + text = decodeHtml(text) |
| 76 | + const lines = text |
| 77 | + .split('\n') |
| 78 | + .map((line) => line.trim()) |
| 79 | + .filter(Boolean) |
| 80 | + if (lines.length) paragraphs.push(lines.join(' ')) |
| 81 | + } |
| 82 | + if (!paragraphs.length) { |
| 83 | + let text = block.replace(/<br\s*\/?>/gi, '\n') |
| 84 | + text = text.replace(/<[^>]+>/g, '') |
| 85 | + text = decodeHtml(text) |
| 86 | + const lines = text |
| 87 | + .split('\n') |
| 88 | + .map((line) => line.trim()) |
| 89 | + .filter(Boolean) |
| 90 | + if (lines.length) paragraphs.push(lines.join(' ')) |
| 91 | + } |
| 92 | + return { title, body: paragraphs } |
| 93 | +} |
| 94 | + |
| 95 | +async function handler(nodelink, req, res, sendResponse, parsedUrl) { |
| 96 | + const result = meaningSchema.try({ |
| 97 | + encodedTrack: parsedUrl.searchParams.get('encodedTrack'), |
| 98 | + lang: parsedUrl.searchParams.get('lang') || 'en' |
| 99 | + }) |
| 100 | + |
| 101 | + if (result instanceof myzod.ValidationError) { |
| 102 | + const errorMessage = result.message || 'encodedTrack parameter is required.' |
| 103 | + logger('warn', 'Meaning', errorMessage) |
| 104 | + return sendErrorResponse( |
| 105 | + req, |
| 106 | + res, |
| 107 | + 400, |
| 108 | + 'missing encodedTrack parameter', |
| 109 | + errorMessage, |
| 110 | + parsedUrl.pathname, |
| 111 | + true |
| 112 | + ) |
| 113 | + } |
| 114 | + |
| 115 | + let decodedTrack |
| 116 | + const targetLang = result.lang |
| 117 | + try { |
| 118 | + decodedTrack = decodeTrack(result.encodedTrack.replace(/ /g, '+')) |
| 119 | + } catch (err) { |
| 120 | + logger('warn', 'Meaning', `Invalid encoded track: ${err.message}`) |
| 121 | + return sendErrorResponse( |
| 122 | + req, |
| 123 | + res, |
| 124 | + 400, |
| 125 | + 'invalid encodedTrack', |
| 126 | + err.message, |
| 127 | + parsedUrl.pathname, |
| 128 | + true |
| 129 | + ) |
| 130 | + } |
| 131 | + |
| 132 | + try { |
| 133 | + let trackInfo = decodedTrack.info |
| 134 | + if (nodelink.sources?.resolve && decodedTrack.info?.uri) { |
| 135 | + const resolved = await nodelink.sources.resolve(decodedTrack.info.uri) |
| 136 | + if (resolved.loadType !== 'track') { |
| 137 | + return sendResponse( |
| 138 | + req, |
| 139 | + res, |
| 140 | + { loadType: 'empty', data: {} }, |
| 141 | + 200 |
| 142 | + ) |
| 143 | + } |
| 144 | + trackInfo = resolved.data?.info || resolved.data |
| 145 | + } |
| 146 | + |
| 147 | + if (!trackInfo || trackInfo.sourceName !== 'letrasmus') { |
| 148 | + return sendResponse( |
| 149 | + req, |
| 150 | + res, |
| 151 | + { loadType: 'empty', data: {} }, |
| 152 | + 200 |
| 153 | + ) |
| 154 | + } |
| 155 | + |
| 156 | + const baseUrl = trackInfo.uri?.endsWith('/') |
| 157 | + ? trackInfo.uri |
| 158 | + : `${trackInfo.uri}/` |
| 159 | + const meaningUrl = `${baseUrl}significado.html` |
| 160 | + const { body, statusCode, error } = await http1makeRequest(meaningUrl, { |
| 161 | + method: 'GET' |
| 162 | + }).catch((e) => ({ error: e })) |
| 163 | + |
| 164 | + if (error || statusCode !== 200 || !body) { |
| 165 | + return sendResponse( |
| 166 | + req, |
| 167 | + res, |
| 168 | + { loadType: 'empty', data: {} }, |
| 169 | + 200 |
| 170 | + ) |
| 171 | + } |
| 172 | + |
| 173 | + const meaning = extractMeaning(body) |
| 174 | + const omq = extractOmqLyric(body) |
| 175 | + const meaningMeta = extractOmqMeaning(body) |
| 176 | + const ogImage = extractMeta(body, 'og:image') |
| 177 | + const ogTitle = extractMeta(body, 'og:title') |
| 178 | + const ogDescription = extractMeta(body, 'og:description') |
| 179 | + |
| 180 | + let translated = null |
| 181 | + if (targetLang) { |
| 182 | + const sourceLang = 'pt' |
| 183 | + try { |
| 184 | + const translatedParagraphs = await translateMany( |
| 185 | + meaning.body, |
| 186 | + sourceLang, |
| 187 | + targetLang |
| 188 | + ) |
| 189 | + const translatedTitle = meaning.title |
| 190 | + ? await translateText(meaning.title, sourceLang, targetLang) |
| 191 | + : null |
| 192 | + const translatedDescription = ogDescription |
| 193 | + ? await translateText(ogDescription, sourceLang, targetLang) |
| 194 | + : null |
| 195 | + translated = { |
| 196 | + language: { |
| 197 | + source: sourceLang, |
| 198 | + target: targetLang |
| 199 | + }, |
| 200 | + title: translatedTitle?.translation || null, |
| 201 | + description: translatedDescription?.translation || null, |
| 202 | + paragraphs: translatedParagraphs |
| 203 | + } |
| 204 | + } catch (e) { |
| 205 | + logger('warn', 'Meaning', `Translate failed: ${e.message}`) |
| 206 | + } |
| 207 | + } |
| 208 | + |
| 209 | + return sendResponse(req, res, { |
| 210 | + loadType: meaning.body.length ? 'meaning' : 'empty', |
| 211 | + data: { |
| 212 | + title: meaning.title || ogTitle || null, |
| 213 | + description: ogDescription || null, |
| 214 | + paragraphs: meaning.body, |
| 215 | + translation: translated, |
| 216 | + url: meaningUrl, |
| 217 | + meaningMeta: { |
| 218 | + id: meaningMeta?.ID || null, |
| 219 | + localeId: meaningMeta?.LocaleID || null, |
| 220 | + origin: meaningMeta?.Origin || null, |
| 221 | + submittedBy: null, |
| 222 | + reviewedBy: null |
| 223 | + }, |
| 224 | + song: { |
| 225 | + title: omq?.Name || trackInfo.title || null, |
| 226 | + artist: omq?.Artist || trackInfo.author || null, |
| 227 | + youtubeId: omq?.YoutubeID || null, |
| 228 | + letrasId: omq?.ID || null, |
| 229 | + artworkUrl: ogImage || trackInfo.artworkUrl || null |
| 230 | + } |
| 231 | + } |
| 232 | + }, 200) |
| 233 | + } catch (err) { |
| 234 | + logger('error', 'Meaning', `Failed to load meaning: ${err.message}`) |
| 235 | + return sendErrorResponse( |
| 236 | + req, |
| 237 | + res, |
| 238 | + 500, |
| 239 | + 'failed to load meaning', |
| 240 | + err.message || 'Failed to load meaning', |
| 241 | + parsedUrl.pathname, |
| 242 | + true |
| 243 | + ) |
| 244 | + } |
| 245 | +} |
| 246 | + |
| 247 | +export default { |
| 248 | + handler |
| 249 | +} |
0 commit comments